From 9a25eb6bf2e94a286afb080b3aee93ff0fb2befb Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:46:02 +0200 Subject: [PATCH 01/19] feat: add per-tag display options. --- src/tagstudio/core/library/alchemy/joins.py | 6 + src/tagstudio/core/library/alchemy/library.py | 32 ++- src/tagstudio/core/library/alchemy/models.py | 14 +- .../qt/controllers/tag_box_controller.py | 1 + .../tag_search_panel_controller.py | 6 +- .../qt/controllers/tag_suggest_box.py | 6 +- src/tagstudio/qt/mixed/build_tag.py | 191 ++++++++++++++++-- src/tagstudio/qt/mixed/field_containers.py | 2 +- src/tagstudio/qt/ts_qt.py | 1 + .../qt/views/stylesheets/stylesheets.py | 69 ++++--- src/tagstudio/resources/translations/en.json | 2 + 11 files changed, 274 insertions(+), 56 deletions(-) diff --git a/src/tagstudio/core/library/alchemy/joins.py b/src/tagstudio/core/library/alchemy/joins.py index 7cf1c4862..ea56e6142 100644 --- a/src/tagstudio/core/library/alchemy/joins.py +++ b/src/tagstudio/core/library/alchemy/joins.py @@ -20,3 +20,9 @@ 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) diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index 41ada274f..67131da1b 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -53,6 +53,7 @@ make_transient, noload, selectinload, + immediateload, ) from tagstudio.core.constants import ( @@ -91,7 +92,7 @@ TextField, TextFieldTemplate, ) -from tagstudio.core.library.alchemy.joins import TagEntry, TagParent +from tagstudio.core.library.alchemy.joins import TagEntry, TagParent, CategoryExclusion from tagstudio.core.library.alchemy.models import ( Entry, Namespace, @@ -1717,6 +1718,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: @@ -1733,6 +1735,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 @@ -1861,6 +1866,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)) @@ -1931,7 +1937,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: @@ -2010,9 +2019,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.""" @@ -2133,6 +2143,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. diff --git a/src/tagstudio/core/library/alchemy/models.py b/src/tagstudio/core/library/alchemy/models.py index 0b0a31c22..c7bd0731b 100644 --- a/src/tagstudio/core/library/alchemy/models.py +++ b/src/tagstudio/core/library/alchemy/models.py @@ -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): @@ -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( @@ -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, @@ -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() @@ -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 diff --git a/src/tagstudio/qt/controllers/tag_box_controller.py b/src/tagstudio/qt/controllers/tag_box_controller.py index 11dc2a911..42e52232f 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -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() diff --git a/src/tagstudio/qt/controllers/tag_search_panel_controller.py b/src/tagstudio/qt/controllers/tag_search_panel_controller.py index f3de4b165..ed2f248cf 100644 --- a/src/tagstudio/qt/controllers/tag_search_panel_controller.py +++ b/src/tagstudio/qt/controllers/tag_search_panel_controller.py @@ -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: @@ -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()) diff --git a/src/tagstudio/qt/controllers/tag_suggest_box.py b/src/tagstudio/qt/controllers/tag_suggest_box.py index 27f9030a8..d801de12f 100644 --- a/src/tagstudio/qt/controllers/tag_suggest_box.py +++ b/src/tagstudio/qt/controllers/tag_suggest_box.py @@ -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() @@ -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()) diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index c5492122a..7dd545cbb 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -4,6 +4,7 @@ from collections.abc import Callable from functools import partial +from types import BuiltinFunctionType from typing import cast, override import structlog @@ -45,6 +46,7 @@ get_tag_text_color, header, line_edit_style, + colored_checkbox_style, ) logger = structlog.get_logger(__name__) @@ -86,6 +88,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.tag_color_slug: str | None self.disambiguation_id: int | None self.parent_ids: set[int] = set() + self.exclusion_ids: set[int] = set() self.aliases: list[TagAlias] = [] self.setMinimumSize(300, 460) @@ -184,6 +187,31 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show) + # Categories ----------------------------------------------------------- + self.category_widget = QWidget() + self.category_widget.setMinimumHeight(128) + + self.category_layout = QVBoxLayout(self.category_widget) + self.category_layout.setStretch(1, 1) + self.category_layout.setContentsMargins(0, 0, 0, 0) + self.category_layout.setSpacing(0) + self.category_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.category_layout.addWidget(QLabel(Translations["tag.categories"])) + + self.category_scroll_contents = QWidget() + + self.category_scroll_layout = QVBoxLayout(self.category_scroll_contents) + self.category_scroll_layout.setContentsMargins(6, 6, 6, 0) + self.category_scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + self.category_scroll_area = QScrollArea() + self.category_scroll_area.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.category_scroll_area.setWidgetResizable(True) + self.category_scroll_area.setFrameShadow(QFrame.Shadow.Plain) + self.category_scroll_area.setFrameShape(QFrame.Shape.NoFrame) + self.category_scroll_area.setWidget(self.category_scroll_contents) + self.category_layout.addWidget(self.category_scroll_area) + # Color ---------------------------------------------------------------- self.color_widget = QWidget() self.color_layout = QVBoxLayout(self.color_widget) @@ -247,6 +275,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.root_layout.addWidget(self.aliases_table) self.root_layout.addWidget(self.aliases_add_button) self.root_layout.addWidget(self.parent_tags_widget) + self.root_layout.addWidget(self.category_widget) self.root_layout.addWidget(self.color_widget) self.root_layout.addWidget(QLabel(header(Translations["tag.properties"], 3))) self.root_layout.addWidget(self.cat_widget) @@ -285,10 +314,12 @@ def enter(self): def _add_parent_tag_callback(self, tag_id: int): self.parent_ids.add(tag_id) self.set_parent_tags() + self.set_categories(added_parent_id=tag_id) def _remove_parent_tag_callback(self, tag_id: int): self.parent_ids.remove(tag_id) self.set_parent_tags() + self.set_categories(removed_parent_id=tag_id) def _create_alias_callback(self): alias = TagAlias("", tag_id=self.tag.id) @@ -315,6 +346,139 @@ def choose_color_callback(self, tag_color_group: TagColorGroup | None): self.tag_color_slug = None self.color_button.set_tag_color_group(tag_color_group) + def set_categories( + self, added_parent_id: int | None = None, removed_parent_id: int | None = None + ): + while self.category_scroll_layout.itemAt(0): + self.category_scroll_layout.takeAt(0).widget().deleteLater() + + c = QWidget() + layout = QVBoxLayout(c) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(3) + + if removed_parent_id is not None: + tags_by_category: dict[Tag, set[Tag]] = {} + hierarchy = set(self._lib.get_tag_hierarchy([self.tag.id]).values()) + hierarchy.remove(self.tag) + for tag in hierarchy: + if self._is_removed_parent(tag): + continue + if tag.is_category: + tags_by_category[tag] = set() + for tag in hierarchy: + if self._is_removed_parent(tag): + continue + for parent in self._lib.get_tag_hierarchy([tag.id]).values(): + if parent in tags_by_category: + if tag == parent and parent.id not in self.parent_ids: + continue + tags_by_category[parent].add(tag) + + for category, tags in tags_by_category.items(): + if len(tags) == 0: + continue + + last_tab, next_tab, container = self._build_category_row_widget(category) + layout.addWidget(container) + self.setTabOrder(last_tab, next_tab) + else: + tag_ids = {self.tag.id} + tag_ids.update(self.parent_ids) + if added_parent_id is not None: + tag_ids.add(added_parent_id) + + for tag in self._lib.get_tag_hierarchy(tag_ids).values(): + if not tag.is_category or tag == self.tag: + continue + last_tab, next_tab, container = self._build_category_row_widget(tag) + layout.addWidget(container) + self.setTabOrder(last_tab, next_tab) + self.category_scroll_layout.addWidget(c) + + def _is_removed_parent(self, tag: Tag) -> bool: + return tag in self.tag.parent_tags and tag.id not in self.parent_ids + + def _build_category_row_widget(self, category: Tag) -> tuple[QPushButton, QCheckBox, QWidget]: + container = QWidget() + row = QHBoxLayout(container) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(3) + + def update_parent_tag_callback(build_tag_panel: BuildTagPanel): + self._lib.update_tag( + 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.set_categories() + + def on_category_edit(category_tag: Tag) -> None: + build_tag_panel = BuildTagPanel(self._lib, tag=category_tag) + edit_modal = Modal( + build_tag_panel, + self._lib.tag_display_name(category_tag), + "Edit Tag", + is_savable=True, + ) + edit_modal.saved.connect(partial(update_parent_tag_callback, build_tag_panel)) + edit_modal.show() + + def update_category_exclusion(category_tag: Tag, checked: bool) -> None: + if checked: + self.exclusion_ids.remove(category_tag.id) + else: + self.exclusion_ids.add(category_tag.id) + + # Add Tag Widget + tag_widget = TagWidget( + category, + library=self._lib, + has_edit=True, + has_remove=False, + ) + tag_widget.on_edit.connect(partial(on_category_edit, category)) + row.addWidget(tag_widget) + + # Add Category Exclusion Tag Button + include_checkbox = QCheckBox() + include_checkbox.setFixedSize(22, 22) + include_checkbox.setToolTip(Translations["tag.categories.tooltip"]) + include_checkbox.setStyleSheet(colored_checkbox_style(*self._tag_colors(category))) + + if category.id not in self.exclusion_ids: + include_checkbox.setChecked(True) + include_checkbox.toggled.connect(partial(update_category_exclusion, category)) + + row.addWidget(include_checkbox) + + return tag_widget.bg_button, include_checkbox, container + + @staticmethod + def _tag_colors(tag: Tag) -> tuple[QColor, QColor, QColor, QColor]: + primary_color = get_tag_primary_color(tag) + + border_color = ( + get_tag_border_color(primary_color) + if not (tag.color and tag.color.secondary and tag.color.color_border) + else (QColor(tag.color.secondary)) + ) + + highlight_color = get_tag_highlight_color( + primary_color + if not (tag.color and tag.color.secondary) + else QColor(tag.color.secondary) + ) + + text_color: QColor + if tag.color and tag.color.secondary: + text_color = QColor(tag.color.secondary) + else: + text_color = get_tag_text_color(primary_color, highlight_color) + + return primary_color, border_color, highlight_color, text_color + def set_parent_tags(self): while self.parent_tags_scroll_layout.itemAt(0): self.parent_tags_scroll_layout.takeAt(0).widget().deleteLater() @@ -346,29 +510,12 @@ def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: b row.setContentsMargins(0, 0, 0, 0) row.setSpacing(3) - # Init Colors - primary_color = get_tag_primary_color(tag) - border_color = ( - get_tag_border_color(primary_color) - if not (tag.color and tag.color.secondary and tag.color.color_border) - else (QColor(tag.color.secondary)) - ) - highlight_color = get_tag_highlight_color( - primary_color - if not (tag.color and tag.color.secondary) - else QColor(tag.color.secondary) - ) - text_color: QColor - if tag.color and tag.color.secondary: - text_color = QColor(tag.color.secondary) - else: - text_color = get_tag_text_color(primary_color, highlight_color) - def update_parent_tag_callback(build_tag_panel: BuildTagPanel): self._lib.update_tag( 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.set_parent_tags() @@ -395,9 +542,7 @@ def on_parent_tag_edit(tag: Tag) -> None: disam_button.setObjectName(f"disambiguationButton.{parent_id}") disam_button.setFixedSize(22, 22) disam_button.setToolTip(Translations["tag.disambiguation.tooltip"]) - disam_button.setStyleSheet( - colored_radio_button_style(primary_color, text_color, border_color, highlight_color) - ) + disam_button.setStyleSheet(colored_radio_button_style(*self._tag_colors(tag))) self.disam_button_group.addButton(disam_button) if is_disambiguation: @@ -478,6 +623,10 @@ def set_tag(self, tag: Tag): self.parent_ids.add(parent_id) self.set_parent_tags() + for exclusion_id in tag.exclusion_ids: + self.exclusion_ids.add(exclusion_id) + self.set_categories() + try: self.tag_color_namespace = tag.color_namespace self.tag_color_slug = tag.color_slug diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index 1a6ae897d..8e8fd01de 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -185,7 +185,7 @@ def get_tag_categories(self, tags: set[Tag]) -> dict[Tag | None, set[Tag]]: grandparent_tags: set[Tag] = set() for parent_tag in parent_tags: - if parent_tag in categories: + if parent_tag in categories and parent_tag.id not in tag.exclusion_ids: categories[parent_tag].add(tag) has_category_parent = True grandparent_tags.update(parent_tag.parent_tags) diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index ea3a65040..bd299e05a 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -887,6 +887,7 @@ def add_tag_action_callback(self): panel.build_tag(), set(panel.parent_ids), set(panel.aliases), + set(panel.exclusion_ids) ), self.modal.hide(), ) diff --git a/src/tagstudio/qt/views/stylesheets/stylesheets.py b/src/tagstudio/qt/views/stylesheets/stylesheets.py index 07b80f54a..b4b484927 100644 --- a/src/tagstudio/qt/views/stylesheets/stylesheets.py +++ b/src/tagstudio/qt/views/stylesheets/stylesheets.py @@ -118,37 +118,50 @@ def line_edit_style_main() -> str: def checkbox_style() -> str: - """Style used for QCheckBoxes.""" + """Style used for common QCheckBoxes.""" primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT)) - border_color = get_tag_border_color(primary_color) highlight_color = get_tag_highlight_color(primary_color) - text_color: QColor = get_tag_text_color(primary_color, highlight_color) + return colored_checkbox_style( + primary_color, + get_tag_border_color(primary_color), + highlight_color, + get_tag_text_color(primary_color, highlight_color), + ) + + +def colored_checkbox_style( + primary_color: QColor, + border_color: QColor, + highlight_color: QColor, + text_color: QColor, +) -> str: + """Style used for QCheckBoxes.""" return f""" - QCheckBox{{ - background: rgba{primary_color.toTuple()}; - color: rgba{text_color.toTuple()}; - border-color: rgba{border_color.toTuple()}; - border-radius: 6px; - border-style: solid; - border-width: 2px; - }} - QCheckBox::indicator{{ - width: 10px; - height: 10px; - border-radius: 2px; - margin: 4px; - }} - QCheckBox::indicator:checked{{ - background: rgba{text_color.toTuple()}; - }} - QCheckBox::hover{{ - border-color: rgba{highlight_color.toTuple()}; - }} - QCheckBox::focus{{ - border-color: rgba{highlight_color.toTuple()}; - outline: none; - }} - """ + QCheckBox{{ + background: rgba{primary_color.toTuple()}; + color: rgba{text_color.toTuple()}; + border-color: rgba{border_color.toTuple()}; + border-radius: 6px; + border-style: solid; + border-width: 2px; + }} + QCheckBox::indicator{{ + width: 10px; + height: 10px; + border-radius: 2px; + margin: 4px; + }} + QCheckBox::indicator:checked{{ + background: rgba{text_color.toTuple()}; + }} + QCheckBox::hover{{ + border-color: rgba{highlight_color.toTuple()}; + }} + QCheckBox::focus{{ + border-color: rgba{highlight_color.toTuple()}; + outline: none; + }} + """ def colored_radio_button_style( diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 2766729e7..0dad48c7e 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -384,6 +384,8 @@ "tag.add.plural": "Add Tags", "tag.aliases": "Aliases", "tag.all_tags": "All Tags", + "tag.categories": "Categories", + "tag.categories.tooltip": "Show tag in this category", "tag.choose_color": "Choose Tag Color", "tag.color": "Color", "tag.confirm_delete": "Are you sure you want to delete the tag \"{tag_name}\"?", From 5f23554bedcedd217967a622e7bfe45337c5754c Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:46:35 +0200 Subject: [PATCH 02/19] feat: bump DB_VERSION to 301, add migration. --- src/tagstudio/core/library/alchemy/constants.py | 4 ++-- src/tagstudio/core/library/alchemy/library.py | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/tagstudio/core/library/alchemy/constants.py b/src/tagstudio/core/library/alchemy/constants.py index 73493c9af..12d2e6caf 100644 --- a/src/tagstudio/core/library/alchemy/constants.py +++ b/src/tagstudio/core/library/alchemy/constants.py @@ -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; diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index 67131da1b..a834eab27 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -571,6 +571,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): @@ -915,6 +916,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: From d38f660ede1282bcefaee670e68c1187659dd8c4 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:46:53 +0200 Subject: [PATCH 03/19] tests: add tests. --- tests/qt/test_build_tag_panel.py | 240 ++++++++++++++++++++++++++++++ tests/qt/test_field_containers.py | 25 ++++ 2 files changed, 265 insertions(+) diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py index c53072ef1..1b89781f9 100644 --- a/tests/qt/test_build_tag_panel.py +++ b/tests/qt/test_build_tag_panel.py @@ -5,12 +5,14 @@ from collections.abc import Callable +from PySide6.QtWidgets import QCheckBox from pytestqt.qtbot import QtBot from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Tag, TagAlias from tagstudio.core.utils.types import unwrap from tagstudio.qt.mixed.build_tag import BuildTagPanel, CustomTableItem +from tagstudio.qt.mixed.tag_widget import TagWidget from tagstudio.qt.translations import Translations @@ -171,3 +173,241 @@ def test_build_tag_panel_build_tag(qtbot: QtBot, library: Library): tag: Tag = panel.build_tag() assert tag.name == Translations["tag.new"] + + +def test_build_tag_panel_show_category_from_parent( + qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag] +): + parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True))) + child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent}))) + + panel: BuildTagPanel = BuildTagPanel(library, child) + qtbot.addWidget(panel) + + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is not None + assert tag_widget.tag == parent + + +def test_build_tag_panel_show_category_from_grandparent( + qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag] +): + grandparent = unwrap(library.add_tag(generate_tag("grandparent", id=122, is_category=True))) + parent = unwrap(library.add_tag(generate_tag("parent", id=123, parent_tags={grandparent}))) + child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent}))) + + panel: BuildTagPanel = BuildTagPanel(library, child) + qtbot.addWidget(panel) + + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is not None + assert tag_widget.tag == grandparent + + +def test_build_tag_panel_add_category_through_parent( + qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag] +): + parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True))) + child = unwrap(library.add_tag(generate_tag("child", id=124))) + + panel: BuildTagPanel = BuildTagPanel(library, child) + qtbot.addWidget(panel) + + assert __find_category_tag_widget(panel) is None + + child.parent_tags.add(parent) + + panel._add_parent_tag_callback(parent.id) + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is not None + assert tag_widget.tag == parent + + +def test_build_tag_panel_add_category_through_grandparent( + qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag] +): + grandparent = unwrap(library.add_tag(generate_tag("grandparent", id=122, is_category=True))) + parent = unwrap(library.add_tag(generate_tag("parent", id=123, parent_tags={grandparent}))) + child = unwrap(library.add_tag(generate_tag("child", id=124))) + + panel: BuildTagPanel = BuildTagPanel(library, child) + qtbot.addWidget(panel) + + assert __find_category_tag_widget(panel) is None + + child.parent_tags.add(parent) + + panel._add_parent_tag_callback(parent.id) + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is not None + assert tag_widget.tag == grandparent + + +def test_build_tag_panel_remove_category_through_parent( + qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag] +): + parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True))) + child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent}))) + + panel: BuildTagPanel = BuildTagPanel(library, child) + qtbot.addWidget(panel) + + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is not None + assert tag_widget.tag == parent + + panel._remove_parent_tag_callback(parent.id) + + assert __find_category_tag_widget(panel) is None + + +def test_build_tag_panel_remove_category_through_grandparent( + qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag] +): + grandparent = unwrap(library.add_tag(generate_tag("grandparent", id=122, is_category=True))) + parent = unwrap(library.add_tag(generate_tag("parent", id=123, parent_tags={grandparent}))) + child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent}))) + + panel: BuildTagPanel = BuildTagPanel(library, child) + qtbot.addWidget(panel) + + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is not None + assert tag_widget.tag == grandparent + + panel._remove_parent_tag_callback(parent.id) + + assert __find_category_tag_widget(panel) is None + + +def test_build_tag_panel_exclude_from_category( + qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag] +): + parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True))) + child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent}))) + + panel: BuildTagPanel = BuildTagPanel(library, child) + qtbot.addWidget(panel) + + assert len(panel.exclusion_ids) == 0 + + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is not None + + checkbox = __find_include_checkbox(tag_widget) + assert checkbox.isChecked() + + checkbox.click() + + assert parent.id in panel.exclusion_ids + + +def test_build_tag_panel_include_in_category( + qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag] +): + parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True))) + child = unwrap( + library.add_tag( + generate_tag("child", id=124, parent_tags={parent}, category_exclusions={parent}) + ) + ) + + panel: BuildTagPanel = BuildTagPanel(library, child) + qtbot.addWidget(panel) + + assert parent.id in panel.exclusion_ids + + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is not None + + checkbox = __find_include_checkbox(tag_widget) + assert not checkbox.isChecked() + + checkbox.click() + + assert len(panel.exclusion_ids) == 0 + + +def test_build_tag_panel_remove_duplicate_category_retained( + qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag] +): + grandparent = unwrap(library.add_tag(generate_tag("grandparent", id=122, is_category=True))) + parent = unwrap(library.add_tag(generate_tag("parent", id=123, parent_tags={grandparent}))) + other_parent = unwrap( + library.add_tag(generate_tag("other_parent", id=124, parent_tags={grandparent})) + ) + child = unwrap( + library.add_tag(generate_tag("child", id=125, parent_tags={parent, other_parent})) + ) + + panel: BuildTagPanel = BuildTagPanel(library, child) + qtbot.addWidget(panel) + + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is not None + assert tag_widget.tag == grandparent + + panel._remove_parent_tag_callback(parent.id) + + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is not None + assert tag_widget.tag == grandparent + + +def test_build_tag_panel_new_tag_multiple_categories( + qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag] +): + parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True))) + other_parent = unwrap(library.add_tag(generate_tag("other_parent", id=124, is_category=True))) + + panel: BuildTagPanel = BuildTagPanel(library) + qtbot.addWidget(panel) + + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is None + + panel._add_parent_tag_callback(parent.id) + + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is not None + assert tag_widget.tag == parent + + panel._add_parent_tag_callback(other_parent.id) + + tag_widget = __find_category_tag_widget(panel, 1) + assert tag_widget is not None + assert tag_widget.tag == other_parent + + +def test_build_tag_panel_category_not_shown_for_self( + qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag] +): + library.add_tag(generate_tag("category", id=123, is_category=True)) + + panel: BuildTagPanel = BuildTagPanel(library) + qtbot.addWidget(panel) + + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is None + + +def __find_category_tag_widget(panel: BuildTagPanel, index: int = 0) -> TagWidget | None: + item = panel.category_scroll_layout.itemAt(0).widget().layout().itemAt(index) + while item is not None: + if isinstance(item.widget(), TagWidget): + break + item = item.widget().layout().itemAt(0) + + if item is not None: + return item.widget() + return None + + +def __find_include_checkbox(tag_widget: TagWidget) -> QCheckBox: + layout_item = tag_widget.parentWidget().layout().itemAt(1) + assert layout_item is not None + + widget = layout_item.widget() + assert isinstance(widget, QCheckBox) + + return widget diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index 3307239e4..fefddb89f 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -1,6 +1,9 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only +from collections.abc import Callable +from pathlib import Path +from tagstudio.core.library.alchemy.library import Library # pyright: reportPrivateUsage=false from tagstudio.core.library.alchemy.models import Entry, Tag @@ -182,3 +185,25 @@ def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry): assert container.title != "

Tags

" case _: pass + +def test_exclude_tag_category( + qt_driver: QtDriver, library: Library, generate_tag: Callable[..., Tag] +): + panel = PreviewPanel(qt_driver) + + category_parent = unwrap(generate_tag("category_parent", id=123, is_category=True)) + library.add_tag(category_parent) + + tag = unwrap(generate_tag("tag", id=124)) + library.add_tag(tag, parent_ids={category_parent.id}, exclusion_ids={category_parent.id}) + + entry = Entry(id=777, path=Path("test.txt"), fields=[]) + + library.add_entries([entry]) + library.add_tags_to_entries(entry.id, tag.id) + + qt_driver.toggle_item_selection(entry.id, append=False, bridge=False) + panel.set_selection(qt_driver.selected) + + assert len(panel.containers._containers) == 1 + assert panel.containers._containers[0].title == "

Tags

" From 109b6960ea564fa656b6fbfee80fe85b22997ac6 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:50:01 +0200 Subject: [PATCH 04/19] docs: document cateogry display options. --- docs/tags.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/tags.md b/docs/tags.md index 522d22ac0..234a5826f 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -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. From 28a812f262e30a3c3743558019f703c881197c42 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:56:30 +0200 Subject: [PATCH 05/19] docs: document library versions 300 and 301. --- docs/library-changes.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/library-changes.md b/docs/library-changes.md index 099cbf829..019d23c77 100644 --- a/docs/library-changes.md +++ b/docs/library-changes.md @@ -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. From 011113ff4ec43d6ebd09e8d6c2d7ec8399a2e1ba Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:18:38 +0200 Subject: [PATCH 06/19] fix: resolve ruff and pyright issues. --- src/tagstudio/core/library/alchemy/joins.py | 1 + src/tagstudio/core/library/alchemy/library.py | 3 +-- src/tagstudio/qt/mixed/build_tag.py | 3 +-- src/tagstudio/qt/ts_qt.py | 2 +- tests/qt/test_build_tag_panel.py | 3 ++- tests/qt/test_field_containers.py | 3 ++- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/tagstudio/core/library/alchemy/joins.py b/src/tagstudio/core/library/alchemy/joins.py index ea56e6142..aa37d915c 100644 --- a/src/tagstudio/core/library/alchemy/joins.py +++ b/src/tagstudio/core/library/alchemy/joins.py @@ -21,6 +21,7 @@ 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" diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index a834eab27..30ea5fc68 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -53,7 +53,6 @@ make_transient, noload, selectinload, - immediateload, ) from tagstudio.core.constants import ( @@ -92,7 +91,7 @@ TextField, TextFieldTemplate, ) -from tagstudio.core.library.alchemy.joins import TagEntry, TagParent, CategoryExclusion +from tagstudio.core.library.alchemy.joins import CategoryExclusion, TagEntry, TagParent from tagstudio.core.library.alchemy.models import ( Entry, Namespace, diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index 7dd545cbb..631e3fce9 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -4,7 +4,6 @@ from collections.abc import Callable from functools import partial -from types import BuiltinFunctionType from typing import cast, override import structlog @@ -39,6 +38,7 @@ from tagstudio.qt.views.search_panel_view import SearchPanelView from tagstudio.qt.views.stylesheets.stylesheets import ( checkbox_style, + colored_checkbox_style, colored_radio_button_style, get_tag_border_color, get_tag_highlight_color, @@ -46,7 +46,6 @@ get_tag_text_color, header, line_edit_style, - colored_checkbox_style, ) logger = structlog.get_logger(__name__) diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index bd299e05a..8a64f8a4f 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -887,7 +887,7 @@ def add_tag_action_callback(self): panel.build_tag(), set(panel.parent_ids), set(panel.aliases), - set(panel.exclusion_ids) + set(panel.exclusion_ids), ), self.modal.hide(), ) diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py index 1b89781f9..e170a8c09 100644 --- a/tests/qt/test_build_tag_panel.py +++ b/tests/qt/test_build_tag_panel.py @@ -4,6 +4,7 @@ # pyright: reportPrivateUsage = false from collections.abc import Callable +from typing import cast from PySide6.QtWidgets import QCheckBox from pytestqt.qtbot import QtBot @@ -399,7 +400,7 @@ def __find_category_tag_widget(panel: BuildTagPanel, index: int = 0) -> TagWidge item = item.widget().layout().itemAt(0) if item is not None: - return item.widget() + return cast(TagWidget, item.widget()) return None diff --git a/tests/qt/test_field_containers.py b/tests/qt/test_field_containers.py index fefddb89f..21f0e7d51 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -4,8 +4,8 @@ from pathlib import Path from tagstudio.core.library.alchemy.library import Library -# pyright: reportPrivateUsage=false +# pyright: reportPrivateUsage=false from tagstudio.core.library.alchemy.models import Entry, Tag from tagstudio.core.utils.types import unwrap from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel @@ -186,6 +186,7 @@ def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry): case _: pass + def test_exclude_tag_category( qt_driver: QtDriver, library: Library, generate_tag: Callable[..., Tag] ): From 7f92d947332671ec9e64dd6010b485366604d8e2 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:47:50 +0200 Subject: [PATCH 07/19] fix: redo migration in the new style. --- .../core/library/alchemy/migrations.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/tagstudio/core/library/alchemy/migrations.py b/src/tagstudio/core/library/alchemy/migrations.py index ca539b32d..0bb25aecb 100644 --- a/src/tagstudio/core/library/alchemy/migrations.py +++ b/src/tagstudio/core/library/alchemy/migrations.py @@ -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: @@ -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() From a2ac6d7a0df0ab98efe66062eb471a5ea55aed64 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:33:48 +0200 Subject: [PATCH 08/19] fix: Remove unnecessary @staticmethod decorators. --- src/tagstudio/core/library/alchemy/library.py | 7 ++++--- src/tagstudio/qt/mixed/build_tag.py | 3 +-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index 2e776ad83..122529e90 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -1344,7 +1344,7 @@ def add_tag( session.flush() if exclusion_ids is not None: - self.update_category_exclusion(tag, exclusion_ids, session) + self._update_category_exclusion(tag, exclusion_ids, session) session.commit() session.expunge(tag) @@ -1752,8 +1752,9 @@ 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): + 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() diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index 631e3fce9..aeaf0c997 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -454,8 +454,7 @@ def update_category_exclusion(category_tag: Tag, checked: bool) -> None: return tag_widget.bg_button, include_checkbox, container - @staticmethod - def _tag_colors(tag: Tag) -> tuple[QColor, QColor, QColor, QColor]: + def _tag_colors(self, tag: Tag) -> tuple[QColor, QColor, QColor, QColor]: primary_color = get_tag_primary_color(tag) border_color = ( From 960d9503275890ec717872a67aa545d08607631e Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:40:13 +0200 Subject: [PATCH 09/19] fix: Bump library version to 400. --- src/tagstudio/core/library/alchemy/constants.py | 2 +- src/tagstudio/core/library/alchemy/migrations.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tagstudio/core/library/alchemy/constants.py b/src/tagstudio/core/library/alchemy/constants.py index 3bb3e150b..aba48a278 100644 --- a/src/tagstudio/core/library/alchemy/constants.py +++ b/src/tagstudio/core/library/alchemy/constants.py @@ -14,7 +14,7 @@ DB_VERSION_CURRENT_KEY: str = "CURRENT" DB_VERSION_INITIAL_KEY: str = "INITIAL" -DB_VERSION: int = 301 +DB_VERSION: int = 400 TAG_CHILDREN_QUERY = text(""" WITH RECURSIVE ChildTags AS ( diff --git a/src/tagstudio/core/library/alchemy/migrations.py b/src/tagstudio/core/library/alchemy/migrations.py index 0bb25aecb..debef8411 100644 --- a/src/tagstudio/core/library/alchemy/migrations.py +++ b/src/tagstudio/core/library/alchemy/migrations.py @@ -96,7 +96,7 @@ def run(self): MigrationTo201, # changes: field tables MigrationTo202, # changes: tag_parents MigrationTo300, # changes: deletes folders - MigrationTo301, # changes: add category_exclusions + MigrationTo400, # changes: add category_exclusions ] with Session(self.engine) as session: for migration in migrations: @@ -572,8 +572,8 @@ def run(cls, session: Session, library_dir: Path, fmt_log): session.flush() -class MigrationTo301(DBMigration): - version = 301 +class MigrationTo400(DBMigration): + version = 400 @override @classmethod From 022a16c435bdd732198044fac997473b905ff3e1 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:02:27 +0200 Subject: [PATCH 10/19] fix: Fix color order in colored_radio_button_style. --- src/tagstudio/qt/views/stylesheets/stylesheets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tagstudio/qt/views/stylesheets/stylesheets.py b/src/tagstudio/qt/views/stylesheets/stylesheets.py index b4b484927..d1869b7be 100644 --- a/src/tagstudio/qt/views/stylesheets/stylesheets.py +++ b/src/tagstudio/qt/views/stylesheets/stylesheets.py @@ -166,9 +166,9 @@ def colored_checkbox_style( def colored_radio_button_style( primary_color: QColor, - text_color: QColor, border_color: QColor, highlight_color: QColor, + text_color: QColor, ) -> str: return f""" QRadioButton{{ From 9356cc454336851e784a7f2a8aa1b7f217beb49c Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:03:33 +0200 Subject: [PATCH 11/19] fix: Commit the library produced by pytest. --- .../.TagStudio/ts_library.sqlite | Bin 122880 -> 122880 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/fixtures/search_library/.TagStudio/ts_library.sqlite b/tests/fixtures/search_library/.TagStudio/ts_library.sqlite index 0cfb5bed4485b806fa87f83c1a82014ffc69cb8b..485e54a8f510792d204505ff7fba58c0b3c0b9e9 100644 GIT binary patch delta 425 zcmZoTz}|3xeS);$4+aJXIUwc(VrC##pQvNZ`h!8QO><+)9Q{QF0+SxFY-TC=!@pR- zKwwh>djTWUW{wN5_A^e{{<)t~P(Y5CZ6^ay5RW2vBG)!fZw_Ym+3Z?uJNY{JB6JLMky7DH980f0ZoD%r0J@{CLU=# jxsg@E7>kSJ4Y3Ha0)w9eh#7R1CL3_;Y;Rx3SZN3VLaBbG delta 130 zcmZoTz}|3xeS);$cLoLqIUwc(Vpbqlo2X;V`kg_q_t(ahIr^IpunTNfaCjg;af9Th z1oi?(mdzX&UhQYp+5WkoQBXjRn{6ipPY{nHcOus|PHzro_Sx)OY&-cn_##>Ev&3yz gTEMuGhlPQGv2C-WKocXAuF_-!Zk_Gz>liBy0hk3PHUIzs From b22a7a09dbd86e50e0be615413599c3f708cfada Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:48:07 +0200 Subject: [PATCH 12/19] test: Add test for removing a category during tag creation. --- tests/qt/test_build_tag_panel.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py index e170a8c09..82f86f80a 100644 --- a/tests/qt/test_build_tag_panel.py +++ b/tests/qt/test_build_tag_panel.py @@ -392,6 +392,31 @@ def test_build_tag_panel_category_not_shown_for_self( assert tag_widget is None +def test_build_tag_panel_remove_inherited_from_multiple_parents_during_tag_creation( + qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag] +): + parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True))) + child1 = unwrap(library.add_tag(generate_tag("child1", id=124, parent_tags={parent}))) + child2 = unwrap(library.add_tag(generate_tag("child2", id=125, parent_tags={parent}))) + + panel: BuildTagPanel = BuildTagPanel(library) + qtbot.addWidget(panel) + + panel._add_parent_tag_callback(124) + panel._add_parent_tag_callback(125) + + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is not None + + panel._remove_parent_tag_callback(child1.id) + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is not None + + panel._remove_parent_tag_callback(child2.id) + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is None + + def __find_category_tag_widget(panel: BuildTagPanel, index: int = 0) -> TagWidget | None: item = panel.category_scroll_layout.itemAt(0).widget().layout().itemAt(index) while item is not None: From f3d9cc38301dc4abe5d30570244bfd230dc323dd Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:49:24 +0200 Subject: [PATCH 13/19] fix: Fix removing categories during tag creation. --- src/tagstudio/qt/mixed/build_tag.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index aeaf0c997..ecb6b898d 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -358,8 +358,7 @@ def set_categories( if removed_parent_id is not None: tags_by_category: dict[Tag, set[Tag]] = {} - hierarchy = set(self._lib.get_tag_hierarchy([self.tag.id]).values()) - hierarchy.remove(self.tag) + hierarchy = set(self._lib.get_tag_hierarchy(self.parent_ids).values()) for tag in hierarchy: if self._is_removed_parent(tag): continue From 04a29f7be2f380e3bc6e6a3855add6d7a76dca69 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:59:26 +0200 Subject: [PATCH 14/19] test: Add test for adding another tag after removing an existing category. --- tests/qt/test_build_tag_panel.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py index 82f86f80a..6fde93e46 100644 --- a/tests/qt/test_build_tag_panel.py +++ b/tests/qt/test_build_tag_panel.py @@ -417,6 +417,28 @@ def test_build_tag_panel_remove_inherited_from_multiple_parents_during_tag_creat assert tag_widget is None +def test_build_tag_panel_add_different_category_after_removing_other_category( + qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag] +): + category = unwrap(library.add_tag(generate_tag("category1", id=123, is_category=True))) + tag = unwrap(library.add_tag(generate_tag("other", id=124, parent_tags={category}))) + other = unwrap(library.add_tag(generate_tag("other", id=125))) + + panel: BuildTagPanel = BuildTagPanel(library, tag) + qtbot.addWidget(panel) + + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is not None + + panel._remove_parent_tag_callback(category.id) + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is None + + panel._add_parent_tag_callback(other.id) + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is None + + def __find_category_tag_widget(panel: BuildTagPanel, index: int = 0) -> TagWidget | None: item = panel.category_scroll_layout.itemAt(0).widget().layout().itemAt(index) while item is not None: From 9544369f0fd3e85305fc5c772b8b1b31eac67747 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:59:52 +0200 Subject: [PATCH 15/19] fix: Fix adding another tag after removing an existing category. --- src/tagstudio/qt/mixed/build_tag.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index ecb6b898d..b2688ef2b 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -381,8 +381,7 @@ def set_categories( layout.addWidget(container) self.setTabOrder(last_tab, next_tab) else: - tag_ids = {self.tag.id} - tag_ids.update(self.parent_ids) + tag_ids = set(self.parent_ids) if added_parent_id is not None: tag_ids.add(added_parent_id) From 9626aa194542562c5587618c83bf053ca7224872 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+cyanvoxel@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:19:30 +0200 Subject: [PATCH 16/19] feat: Add separators between widgets. --- src/tagstudio/qt/mixed/build_tag.py | 47 ++++++++++++-------- src/tagstudio/resources/translations/en.json | 3 +- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index b2688ef2b..6266da71d 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -14,6 +14,7 @@ QButtonGroup, QCheckBox, QFrame, + QGraphicsOpacityEffect, QHBoxLayout, QLabel, QLineEdit, @@ -90,7 +91,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.exclusion_ids: set[int] = set() self.aliases: list[TagAlias] = [] - self.setMinimumSize(300, 460) + self.setMinimumSize(300, 640) self.root_layout = QVBoxLayout(self) self.root_layout.setContentsMargins(6, 0, 6, 0) self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop) @@ -98,7 +99,6 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: # Name ----------------------------------------------------------------- self.name_widget = QWidget() self.name_layout = QVBoxLayout(self.name_widget) - self.name_layout.setStretch(1, 1) self.name_layout.setContentsMargins(0, 0, 0, 0) self.name_layout.setSpacing(0) self.name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) @@ -113,7 +113,6 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: # Shorthand ------------------------------------------------------------ self.shorthand_widget = QWidget() self.shorthand_layout = QVBoxLayout(self.shorthand_widget) - self.shorthand_layout.setStretch(1, 1) self.shorthand_layout.setContentsMargins(0, 0, 0, 0) self.shorthand_layout.setSpacing(0) self.shorthand_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) @@ -125,7 +124,6 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: # Aliases -------------------------------------------------------------- self.aliases_widget = QWidget() self.aliases_layout = QVBoxLayout(self.aliases_widget) - self.aliases_layout.setStretch(1, 1) self.aliases_layout.setContentsMargins(0, 0, 0, 0) self.aliases_layout.setSpacing(0) self.aliases_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) @@ -146,16 +144,14 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: # Parent Tags ---------------------------------------------------------- self.parent_tags_widget = QWidget() - self.parent_tags_widget.setMinimumHeight(128) self.parent_tags_layout = QVBoxLayout(self.parent_tags_widget) - self.parent_tags_layout.setStretch(1, 1) self.parent_tags_layout.setContentsMargins(0, 0, 0, 0) self.parent_tags_layout.setSpacing(0) self.parent_tags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.disam_button_group = QButtonGroup(self) self.disam_button_group.setExclusive(False) - self.parent_tags_title = QLabel(Translations["tag.parent_tags"]) + self.parent_tags_title = QLabel(header(Translations["tag.parent_tags"], 3)) self.parent_tags_layout.addWidget(self.parent_tags_title) self.scroll_contents = QWidget() self.parent_tags_scroll_layout = QVBoxLayout(self.scroll_contents) @@ -188,17 +184,19 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: # Categories ----------------------------------------------------------- self.category_widget = QWidget() - self.category_widget.setMinimumHeight(128) - self.category_layout = QVBoxLayout(self.category_widget) - self.category_layout.setStretch(1, 1) self.category_layout.setContentsMargins(0, 0, 0, 0) self.category_layout.setSpacing(0) self.category_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - self.category_layout.addWidget(QLabel(Translations["tag.categories"])) + self.category_layout.addWidget(QLabel(header(Translations["tag.categories"], 3))) - self.category_scroll_contents = QWidget() + category_subtitle = QLabel(Translations["tag.categories.subtitle"]) + opacity_effect = QGraphicsOpacityEffect(self) + opacity_effect.setOpacity(0.5) + category_subtitle.setGraphicsEffect(opacity_effect) + self.category_layout.addWidget(category_subtitle) + self.category_scroll_contents = QWidget() self.category_scroll_layout = QVBoxLayout(self.category_scroll_contents) self.category_scroll_layout.setContentsMargins(6, 6, 6, 0) self.category_scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) @@ -214,11 +212,10 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: # Color ---------------------------------------------------------------- self.color_widget = QWidget() self.color_layout = QVBoxLayout(self.color_widget) - self.color_layout.setStretch(1, 1) self.color_layout.setContentsMargins(0, 0, 0, 6) self.color_layout.setSpacing(6) self.color_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - self.color_title = QLabel(Translations["tag.color"]) + self.color_title = QLabel(header(Translations["tag.color"], 3)) self.color_layout.addWidget(self.color_title) self.color_button: TagColorPreview try: @@ -242,7 +239,6 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: # Category ------------------------------------------------------------- self.cat_widget = QWidget() self.cat_layout = QHBoxLayout(self.cat_widget) - self.cat_layout.setStretch(1, 1) self.cat_layout.setContentsMargins(0, 0, 0, 0) self.cat_layout.setSpacing(6) self.cat_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) @@ -256,7 +252,6 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: # Hidden --------------------------------------------------------------- self.hidden_widget = QWidget() self.hidden_layout = QHBoxLayout(self.hidden_widget) - self.hidden_layout.setStretch(1, 1) self.hidden_layout.setContentsMargins(0, 0, 0, 0) self.hidden_layout.setSpacing(6) self.hidden_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) @@ -271,17 +266,33 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.root_layout.addWidget(self.name_widget) self.root_layout.addWidget(self.shorthand_widget) self.root_layout.addWidget(self.aliases_widget) - self.root_layout.addWidget(self.aliases_table) + self.root_layout.addWidget(self.aliases_table, stretch=1) self.root_layout.addWidget(self.aliases_add_button) - self.root_layout.addWidget(self.parent_tags_widget) + self._add_spaced_separator() + self.root_layout.addWidget(self.parent_tags_widget, stretch=1) + self._add_spaced_separator() self.root_layout.addWidget(self.category_widget) + self._add_spaced_separator() self.root_layout.addWidget(self.color_widget) + self._add_spaced_separator() self.root_layout.addWidget(QLabel(header(Translations["tag.properties"], 3))) self.root_layout.addWidget(self.cat_widget) self.root_layout.addWidget(self.hidden_widget) self.set_tag(tag or Tag(name=Translations["tag.new"])) + def _add_spaced_separator(self) -> None: + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setFrameShadow(QFrame.Shadow.Plain) + opacity_effect = QGraphicsOpacityEffect(self) + opacity_effect.setOpacity(0.1) + sep.setGraphicsEffect(opacity_effect) + + self.root_layout.addSpacing(6) + self.root_layout.addWidget(sep) + self.root_layout.addSpacing(6) + def backspace(self): focused_widget = QApplication.focusWidget() row = self.aliases_table.rowCount() diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 0dad48c7e..ad1f29d03 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -384,7 +384,8 @@ "tag.add.plural": "Add Tags", "tag.aliases": "Aliases", "tag.all_tags": "All Tags", - "tag.categories": "Categories", + "tag.categories": "Category Visibility", + "tag.categories.subtitle": "Inherited from Parent Tags", "tag.categories.tooltip": "Show tag in this category", "tag.choose_color": "Choose Tag Color", "tag.color": "Color", From 49d1e71e02d5a188439834b8da55fbb58a5f8878 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:20:50 +0200 Subject: [PATCH 17/19] docs: Fix version in library-changes.md. --- docs/library-changes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/library-changes.md b/docs/library-changes.md index 019d23c77..b05c2be4f 100644 --- a/docs/library-changes.md +++ b/docs/library-changes.md @@ -211,7 +211,7 @@ Migration from the legacy JSON format is provided via a walkthrough when opening - Drops `folder` columns from the `entries` table. - Drops the unused `folders` table. -#### Version 301 +#### Version 400 | Added in Commit | Introduced in Release | Format | |-----------------|-----------------------| ------ | From c870503e1c3c872def50c797368533feec663cd5 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:30:11 +0200 Subject: [PATCH 18/19] test: Test removing a category inherited both directly and indirectly. --- tests/qt/test_build_tag_panel.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py index 6fde93e46..b0134c236 100644 --- a/tests/qt/test_build_tag_panel.py +++ b/tests/qt/test_build_tag_panel.py @@ -420,8 +420,8 @@ def test_build_tag_panel_remove_inherited_from_multiple_parents_during_tag_creat def test_build_tag_panel_add_different_category_after_removing_other_category( qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag] ): - category = unwrap(library.add_tag(generate_tag("category1", id=123, is_category=True))) - tag = unwrap(library.add_tag(generate_tag("other", id=124, parent_tags={category}))) + category = unwrap(library.add_tag(generate_tag("category", id=123, is_category=True))) + tag = unwrap(library.add_tag(generate_tag("tag", id=124, parent_tags={category}))) other = unwrap(library.add_tag(generate_tag("other", id=125))) panel: BuildTagPanel = BuildTagPanel(library, tag) @@ -439,6 +439,30 @@ def test_build_tag_panel_add_different_category_after_removing_other_category( assert tag_widget is None +def test_build_tag_panel_remove_category_inherited_directly_and_indirectly( + qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag] +): + parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True))) + child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent}))) + grandchild = unwrap( + library.add_tag(generate_tag("grandchild", id=125, parent_tags={parent, child})) + ) + + panel: BuildTagPanel = BuildTagPanel(library, grandchild) + qtbot.addWidget(panel) + + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is not None + + panel._remove_parent_tag_callback(parent.id) + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is not None + + panel._remove_parent_tag_callback(child.id) + tag_widget = __find_category_tag_widget(panel) + assert tag_widget is None + + def __find_category_tag_widget(panel: BuildTagPanel, index: int = 0) -> TagWidget | None: item = panel.category_scroll_layout.itemAt(0).widget().layout().itemAt(index) while item is not None: From 7f31bda4ea80483c761caca3285def5b1be3695e Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:33:14 +0200 Subject: [PATCH 19/19] fix: Fix removing a category inherited both directly and indirectly. --- src/tagstudio/qt/mixed/build_tag.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index 6266da71d..7ffba3927 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -329,7 +329,7 @@ def _add_parent_tag_callback(self, tag_id: int): def _remove_parent_tag_callback(self, tag_id: int): self.parent_ids.remove(tag_id) self.set_parent_tags() - self.set_categories(removed_parent_id=tag_id) + self.set_categories(removed_parent=True) def _create_alias_callback(self): alias = TagAlias("", tag_id=self.tag.id) @@ -356,9 +356,7 @@ def choose_color_callback(self, tag_color_group: TagColorGroup | None): self.tag_color_slug = None self.color_button.set_tag_color_group(tag_color_group) - def set_categories( - self, added_parent_id: int | None = None, removed_parent_id: int | None = None - ): + def set_categories(self, added_parent_id: int | None = None, removed_parent: bool = False): while self.category_scroll_layout.itemAt(0): self.category_scroll_layout.takeAt(0).widget().deleteLater() @@ -367,17 +365,13 @@ def set_categories( layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(3) - if removed_parent_id is not None: + if removed_parent: tags_by_category: dict[Tag, set[Tag]] = {} hierarchy = set(self._lib.get_tag_hierarchy(self.parent_ids).values()) for tag in hierarchy: - if self._is_removed_parent(tag): - continue if tag.is_category: tags_by_category[tag] = set() for tag in hierarchy: - if self._is_removed_parent(tag): - continue for parent in self._lib.get_tag_hierarchy([tag.id]).values(): if parent in tags_by_category: if tag == parent and parent.id not in self.parent_ids: @@ -404,9 +398,6 @@ def set_categories( self.setTabOrder(last_tab, next_tab) self.category_scroll_layout.addWidget(c) - def _is_removed_parent(self, tag: Tag) -> bool: - return tag in self.tag.parent_tags and tag.id not in self.parent_ids - def _build_category_row_widget(self, category: Tag) -> tuple[QPushButton, QCheckBox, QWidget]: container = QWidget() row = QHBoxLayout(container)