From d56e870f6a4de99ce06582d1ca508de4192a57ef Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:34:31 +0200 Subject: [PATCH 01/12] feat: add category exclusions. --- src/tagstudio/core/library/alchemy/joins.py | 7 + src/tagstudio/core/library/alchemy/library.py | 41 ++- src/tagstudio/core/library/alchemy/models.py | 14 +- .../qt/controllers/tag_box_controller.py | 1 + src/tagstudio/qt/mixed/build_tag.py | 336 ++++++++++++++---- src/tagstudio/qt/mixed/field_containers.py | 3 +- src/tagstudio/qt/mixed/tag_search.py | 6 +- 7 files changed, 321 insertions(+), 87 deletions(-) diff --git a/src/tagstudio/core/library/alchemy/joins.py b/src/tagstudio/core/library/alchemy/joins.py index d01e67642..65d989314 100644 --- a/src/tagstudio/core/library/alchemy/joins.py +++ b/src/tagstudio/core/library/alchemy/joins.py @@ -21,3 +21,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) diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index ddb1a7bbe..9b88c01ce 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -90,7 +90,7 @@ FieldID, TextField, ) -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, Folder, @@ -124,7 +124,7 @@ class ReservedNamespaceError(Exception): def slugify(input_string: str, allow_reserved: bool = False) -> str: - # Convert to lowercase and normalize unicode characters + # Convert to lowercase and normalize Unicode characters slug = unicodedata.normalize("NFKD", input_string.lower()) # Remove non-word characters (except hyphens and spaces) @@ -630,7 +630,7 @@ def __apply_db8_default_data(self, session: Session): except IntegrityError: session.rollback() - # Update Neon colors to use the the color_border property + # Update Neon colors to use the color_border property for color in default_color_groups.neon(): try: neon_stmt = ( @@ -1102,6 +1102,7 @@ def search_tags(self, name: str | None, limit: int = 100) -> list[set[Tag]]: query = query.options( selectinload(Tag.parent_tags), selectinload(Tag.aliases), + selectinload(Tag.category_exclusions), ) if limit > 0: query = query.limit(limit) @@ -1456,6 +1457,7 @@ def add_tag( parent_ids: list[int] | set[int] | None = None, alias_names: list[str] | set[str] | None = None, alias_ids: list[int] | set[int] | None = None, + exclusion_ids: list[int] | set[int] | None = None, ) -> Tag | None: with Session(self.engine, expire_on_commit=False) as session: try: @@ -1468,6 +1470,9 @@ def add_tag( if alias_ids is not None and alias_names is not None: self.update_aliases(tag, alias_ids, alias_names, session) + if exclusion_ids is not None: + self.update_category_exclusion(tag, exclusion_ids, session) + session.commit() session.expunge(tag) return tag @@ -1582,6 +1587,7 @@ def get_tag(self, tag_id: int) -> Tag | None: with Session(self.engine) as session: tags_query = select(Tag).options( selectinload(Tag.parent_tags), + selectinload(Tag.category_exclusions), selectinload(Tag.aliases), joinedload(Tag.color), ) @@ -1654,7 +1660,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: @@ -1734,9 +1743,10 @@ def update_tag( parent_ids: list[int] | set[int] | None = None, alias_names: list[str] | set[str] | None = None, alias_ids: list[int] | set[int] | None = None, + exclusion_ids: list[int] | set[int] | None = None, ) -> None: """Edit a Tag in the Library.""" - self.add_tag(tag, parent_ids, alias_names, alias_ids) + self.add_tag(tag, parent_ids, alias_names, alias_ids, 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.""" @@ -1787,8 +1797,8 @@ def update_color(self, old_color_group: TagColorGroup, new_color_group: TagColor else: self.add_color(new_color_group) + @staticmethod def update_aliases( - self, tag: Tag, alias_ids: list[int] | set[int], alias_names: list[str] | set[str], @@ -1807,7 +1817,8 @@ def update_aliases( alias = TagAlias(alias_name, tag.id) session.add(alias) - def update_parent_tags(self, tag: Tag, parent_ids: list[int] | set[int], session: Session): + @staticmethod + def update_parent_tags(tag: Tag, parent_ids: list[int] | set[int], session: Session): if tag.id in parent_ids: parent_ids.remove(tag.id) @@ -1835,6 +1846,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 f5c315310..678ffee59 100644 --- a/src/tagstudio/core/library/alchemy/models.py +++ b/src/tagstudio/core/library/alchemy/models.py @@ -19,7 +19,7 @@ DatetimeField, TextField, ) -from tagstudio.core.library.alchemy.joins import TagParent +from tagstudio.core.library.alchemy.joins import CategoryExclusion, TagParent class Namespace(Base): @@ -107,6 +107,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( @@ -127,6 +133,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, @@ -140,6 +150,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() @@ -152,6 +163,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 2a5865d8b..198ac0e12 100644 --- a/src/tagstudio/qt/controllers/tag_box_controller.py +++ b/src/tagstudio/qt/controllers/tag_box_controller.py @@ -87,6 +87,7 @@ def _on_edit(self, tag: Tag) -> None: # type: ignore[misc] parent_ids=set(build_tag_panel.parent_ids), alias_names=set(build_tag_panel.alias_names), alias_ids=set(build_tag_panel.alias_ids), + exclusion_ids=set(build_tag_panel.exclusion_ids), ) ) edit_modal.show() diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index 94e63f3e8..92dc8b391 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -171,6 +171,34 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.add_tag_modal.tsp.tag_chosen.connect(lambda x: self.add_parent_tag_callback(x)) 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("Categories")) + + self.category_button_group = QButtonGroup(self) + self.category_button_group.setExclusive(False) + + 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) @@ -218,30 +246,31 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: text_color: QColor = get_text_color(primary_color, highlight_color) self.cat_checkbox.setStyleSheet( - f"QCheckBox{{" - f"background: rgba{primary_color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"border-color: rgba{border_color.toTuple()};" - f"border-radius: 6px;" - f"border-style:solid;" - f"border-width: 2px;" - f"}}" - f"QCheckBox::indicator{{" - f"width: 10px;" - f"height: 10px;" - f"border-radius: 2px;" - f"margin: 4px;" - f"}}" - f"QCheckBox::indicator:checked{{" - f"background: rgba{text_color.toTuple()};" - f"}}" - f"QCheckBox::hover{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"}}" - f"QCheckBox::focus{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"outline:none;" - f"}}" + 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; + }}""" ) self.cat_layout.addWidget(self.cat_checkbox) self.cat_layout.addWidget(self.cat_title) @@ -258,30 +287,31 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.hidden_checkbox.setFixedSize(22, 22) self.hidden_checkbox.setStyleSheet( - f"QCheckBox{{" - f"background: rgba{primary_color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"border-color: rgba{border_color.toTuple()};" - f"border-radius: 6px;" - f"border-style:solid;" - f"border-width: 2px;" - f"}}" - f"QCheckBox::indicator{{" - f"width: 10px;" - f"height: 10px;" - f"border-radius: 2px;" - f"margin: 4px;" - f"}}" - f"QCheckBox::indicator:checked{{" - f"background: rgba{text_color.toTuple()};" - f"}}" - f"QCheckBox::hover{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"}}" - f"QCheckBox::focus{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"outline:none;" - f"}}" + 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; + }}""" ) self.hidden_layout.addWidget(self.hidden_checkbox) self.hidden_layout.addWidget(self.hidden_title) @@ -293,12 +323,14 @@ 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("

Properties

")) self.root_layout.addWidget(self.cat_widget) self.root_layout.addWidget(self.hidden_widget) self.parent_ids: set[int] = set() + self.exclusion_ids: set[int] = set() self.alias_ids: list[int] = [] self.alias_names: list[str] = [] self.new_alias_names: dict = {} @@ -340,11 +372,13 @@ def add_parent_tag_callback(self, tag_id: int): logger.info("add_parent_tag_callback", tag_id=tag_id) 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): logger.info("remove_parent_tag_callback", tag_id=tag_id) self.parent_ids.remove(tag_id) self.set_parent_tags() + self.set_categories(removed_parent_id=tag_id) def add_alias_callback(self): logger.info("add_alias_callback") @@ -375,6 +409,149 @@ 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] + if added_parent_id is not None: + tag_ids.append(added_parent_id) + + for tag in self.lib.get_tag_hierarchy(tag_ids).values(): + if not tag.is_category: + 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, QRadioButton, QWidget]: + container = QWidget() + row = QHBoxLayout(container) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(3) + + # Init Colors + primary_color = get_primary_color(category) + border_color = ( + get_border_color(primary_color) + if not (category.color and category.color.secondary and category.color.color_border) + else (QColor(category.color.secondary)) + ) + highlight_color = get_highlight_color( + primary_color + if not (category.color and category.color.secondary) + else QColor(category.color.secondary) + ) + text_color: QColor + if category.color and category.color.secondary: + text_color = QColor(category.color.secondary) + else: + text_color = get_text_color(primary_color, highlight_color) + + # Add Tag Widget + tag_widget = TagWidget( + category, + library=self.lib, + has_edit=True, + has_remove=False, + ) + tag_widget.on_edit.connect(lambda c=category: TagSearchPanel(library=self.lib).edit_tag(c)) + row.addWidget(tag_widget) + + # Add Category Exclusion Tag Button + include_button = QRadioButton() + include_button.setObjectName(f"categoryExclusionButton.{category.id}") + include_button.setFixedSize(22, 22) + include_button.setToolTip("Show in category") + include_button.setStyleSheet( + f""" + QRadioButton{{ + 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; + }} + QRadioButton::indicator{{ + width: 10px; + height: 10px; + border-radius: 2px; + margin: 4px; + }} + QRadioButton::indicator:checked{{ + background: rgba{text_color.toTuple()}; + }} + QRadioButton::hover{{ + border-color: rgba{highlight_color.toTuple()}; + }} + QRadioButton::pressed{{ + background: rgba{border_color.toTuple()}; + color: rgba{primary_color.toTuple()}; + border-color: rgba{primary_color.toTuple()}; + }} + QRadioButton::focus{{ + border-color: rgba{highlight_color.toTuple()}; + outline: none; + }}""" + ) + + include_button.clicked.connect( + lambda: self.update_category_exclusion(category, include_button.isChecked()) + ) + self.category_button_group.addButton(include_button) + if category.id not in self.exclusion_ids: + include_button.setChecked(True) + + row.addWidget(include_button) + + return tag_widget.bg_button, include_button, container + + def update_category_exclusion(self, category: Tag, checked: bool) -> None: + if checked: + self.exclusion_ids.remove(category.id) + else: + self.exclusion_ids.add(category.id) + def set_parent_tags(self): while self.parent_tags_scroll_layout.itemAt(0): self.parent_tags_scroll_layout.takeAt(0).widget().deleteLater() @@ -441,35 +618,36 @@ def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: b disam_button.setFixedSize(22, 22) disam_button.setToolTip(Translations["tag.disambiguation.tooltip"]) disam_button.setStyleSheet( - f"QRadioButton{{" - f"background: rgba{primary_color.toTuple()};" - f"color: rgba{text_color.toTuple()};" - f"border-color: rgba{border_color.toTuple()};" - f"border-radius: 6px;" - f"border-style:solid;" - f"border-width: 2px;" - f"}}" - f"QRadioButton::indicator{{" - f"width: 10px;" - f"height: 10px;" - f"border-radius: 2px;" - f"margin: 4px;" - f"}}" - f"QRadioButton::indicator:checked{{" - f"background: rgba{text_color.toTuple()};" - f"}}" - f"QRadioButton::hover{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"}}" - f"QRadioButton::pressed{{" - f"background: rgba{border_color.toTuple()};" - f"color: rgba{primary_color.toTuple()};" - f"border-color: rgba{primary_color.toTuple()};" - f"}}" - f"QRadioButton::focus{{" - f"border-color: rgba{highlight_color.toTuple()};" - f"outline:none;" - f"}}" + f""" + QRadioButton{{ + 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; + }} + QRadioButton::indicator{{ + width: 10px; + height: 10px; + border-radius: 2px; + margin: 4px; + }} + QRadioButton::indicator:checked{{ + background: rgba{text_color.toTuple()}; + }} + QRadioButton::hover{{ + border-color: rgba{highlight_color.toTuple()}; + }} + QRadioButton::pressed{{ + background: rgba{border_color.toTuple()}; + color: rgba{primary_color.toTuple()}; + border-color: rgba{primary_color.toTuple()}; + }} + QRadioButton::focus{{ + border-color: rgba{highlight_color.toTuple()}; + outline: none; + }}""" ) self.disam_button_group.addButton(disam_button) @@ -575,6 +753,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 ae8df9107..e804c6c2b 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -124,6 +124,7 @@ def update_granular( self.write_tag_container( container_index, tags=tags, category_tag=cat, is_mixed=False ) + container_index += 1 container_len += 1 if update_badges: @@ -189,7 +190,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/mixed/tag_search.py b/src/tagstudio/qt/mixed/tag_search.py index 3a3e120b4..12d2a74b2 100644 --- a/src/tagstudio/qt/mixed/tag_search.py +++ b/src/tagstudio/qt/mixed/tag_search.py @@ -382,7 +382,11 @@ def edit_tag(self, tag: Tag): def callback(btp: BuildTagPanel): self.lib.update_tag( - btp.build_tag(), set(btp.parent_ids), set(btp.alias_names), set(btp.alias_ids) + btp.build_tag(), + set(btp.parent_ids), + set(btp.alias_names), + set(btp.alias_ids), + set(btp.exclusion_ids), ) self.update_tags(self.search_field.text()) From a9447d01bc155a9a1072a5fddb1c6d86948c503f Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:54:29 +0200 Subject: [PATCH 02/12] bump DB_VERSION to 104, update test_db_migrations. --- .../core/library/alchemy/constants.py | 4 ++-- .../.TagStudio/ts_library.sqlite | Bin 0 -> 114688 bytes .../.TagStudio/ts_library.sqlite | Bin 0 -> 114688 bytes .../.TagStudio/ts_library.sqlite | Bin 0 -> 114688 bytes .../.TagStudio/ts_library.sqlite | Bin 0 -> 122880 bytes tests/test_db_migrations.py | 6 +++++- 6 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/empty_libraries/DB_VERSION_101/.TagStudio/ts_library.sqlite create mode 100644 tests/fixtures/empty_libraries/DB_VERSION_102/.TagStudio/ts_library.sqlite create mode 100644 tests/fixtures/empty_libraries/DB_VERSION_103/.TagStudio/ts_library.sqlite create mode 100644 tests/fixtures/empty_libraries/DB_VERSION_104/.TagStudio/ts_library.sqlite diff --git a/src/tagstudio/core/library/alchemy/constants.py b/src/tagstudio/core/library/alchemy/constants.py index 1e2080249..5dff56fc3 100644 --- a/src/tagstudio/core/library/alchemy/constants.py +++ b/src/tagstudio/core/library/alchemy/constants.py @@ -11,14 +11,14 @@ DB_VERSION_LEGACY_KEY: str = "DB_VERSION" DB_VERSION_CURRENT_KEY: str = "CURRENT" DB_VERSION_INITIAL_KEY: str = "INITIAL" -DB_VERSION: int = 103 +DB_VERSION: int = 104 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/tests/fixtures/empty_libraries/DB_VERSION_101/.TagStudio/ts_library.sqlite b/tests/fixtures/empty_libraries/DB_VERSION_101/.TagStudio/ts_library.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..f1450ca5b4b9b45553c911f0f1e8141461b7cbb9 GIT binary patch literal 114688 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 SQ{Cld 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 C0yi@N literal 0 HcmV?d00001 diff --git a/tests/test_db_migrations.py b/tests/test_db_migrations.py index 2a1c7c960..709c71390 100644 --- a/tests/test_db_migrations.py +++ b/tests/test_db_migrations.py @@ -27,6 +27,10 @@ str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_8")), str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_9")), str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_100")), + str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_101")), + str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_102")), + str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_103")), + str(Path(CWD.parent / FIXTURES / EMPTY_LIBRARIES / "DB_VERSION_104")), ], ) def test_library_migrations(path: str): @@ -51,4 +55,4 @@ def test_library_migrations(path: str): except Exception as e: library.close() shutil.rmtree(temp_path) - raise (e) + raise e From f2f062639a9329f0b6dc6f25a4a89c01d4107d66 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:12:52 +0200 Subject: [PATCH 03/12] add translation keys. --- src/tagstudio/qt/mixed/build_tag.py | 4 ++-- src/tagstudio/resources/translations/en.json | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index 92dc8b391..c01cb5a05 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -180,7 +180,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: 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("Categories")) + self.category_layout.addWidget(QLabel(Translations["tag.categories"])) self.category_button_group = QButtonGroup(self) self.category_button_group.setExclusive(False) @@ -501,7 +501,7 @@ def __build_category_row_widget( include_button = QRadioButton() include_button.setObjectName(f"categoryExclusionButton.{category.id}") include_button.setFixedSize(22, 22) - include_button.setToolTip("Show in category") + include_button.setToolTip(Translations["tag.categories.tooltip"]) include_button.setStyleSheet( f""" QRadioButton{{ diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index cdd46dc11..a6974cdfd 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -319,6 +319,8 @@ "tag.add": "Add Tag", "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 e1184838cfb7cb1e4e3c1c3e6ac038d9cfb75526 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:06:47 +0200 Subject: [PATCH 04/12] make include_button a checkbox, cleanup. --- src/tagstudio/qt/mixed/build_tag.py | 215 ++++++++++------------------ 1 file changed, 74 insertions(+), 141 deletions(-) diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index c01cb5a05..bd709ca3c 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -1,8 +1,6 @@ # Copyright (C) 2025 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio - - import sys from typing import cast, override @@ -182,9 +180,6 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.category_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) self.category_layout.addWidget(QLabel(Translations["tag.categories"])) - self.category_button_group = QButtonGroup(self) - self.category_button_group.setExclusive(False) - self.category_scroll_contents = QWidget() self.category_scroll_layout = QVBoxLayout(self.category_scroll_contents) @@ -246,31 +241,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: text_color: QColor = get_text_color(primary_color, highlight_color) self.cat_checkbox.setStyleSheet( - 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; - }}""" + self.__checkbox_stylesheet(primary_color, border_color, highlight_color, text_color) ) self.cat_layout.addWidget(self.cat_checkbox) self.cat_layout.addWidget(self.cat_title) @@ -287,31 +258,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.hidden_checkbox.setFixedSize(22, 22) self.hidden_checkbox.setStyleSheet( - 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; - }}""" + self.__checkbox_stylesheet(primary_color, border_color, highlight_color, text_color) ) self.hidden_layout.addWidget(self.hidden_checkbox) self.hidden_layout.addWidget(self.hidden_title) @@ -338,6 +285,60 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: self.set_tag(tag or Tag(name=Translations["tag.new"])) + @staticmethod + def __checkbox_stylesheet( + primary_color: QColor, border_color: QColor, highlight_color: QColor, text_color: QColor + ) -> str: + 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; + }}""" + + @staticmethod + def __tag_colors(tag: Tag) -> tuple[QColor, QColor, QColor, QColor]: + primary_color = get_primary_color(tag) + + border_color = ( + get_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_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_text_color(primary_color, highlight_color) + + return primary_color, border_color, highlight_color, text_color + def backspace(self): focused_widget = QApplication.focusWidget() row = self.aliases_table.rowCount() @@ -461,32 +462,12 @@ def set_categories( 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, QRadioButton, QWidget]: + 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) - # Init Colors - primary_color = get_primary_color(category) - border_color = ( - get_border_color(primary_color) - if not (category.color and category.color.secondary and category.color.color_border) - else (QColor(category.color.secondary)) - ) - highlight_color = get_highlight_color( - primary_color - if not (category.color and category.color.secondary) - else QColor(category.color.secondary) - ) - text_color: QColor - if category.color and category.color.secondary: - text_color = QColor(category.color.secondary) - else: - text_color = get_text_color(primary_color, highlight_color) - # Add Tag Widget tag_widget = TagWidget( category, @@ -498,55 +479,22 @@ def __build_category_row_widget( row.addWidget(tag_widget) # Add Category Exclusion Tag Button - include_button = QRadioButton() - include_button.setObjectName(f"categoryExclusionButton.{category.id}") - include_button.setFixedSize(22, 22) - include_button.setToolTip(Translations["tag.categories.tooltip"]) - include_button.setStyleSheet( - f""" - QRadioButton{{ - 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; - }} - QRadioButton::indicator{{ - width: 10px; - height: 10px; - border-radius: 2px; - margin: 4px; - }} - QRadioButton::indicator:checked{{ - background: rgba{text_color.toTuple()}; - }} - QRadioButton::hover{{ - border-color: rgba{highlight_color.toTuple()}; - }} - QRadioButton::pressed{{ - background: rgba{border_color.toTuple()}; - color: rgba{primary_color.toTuple()}; - border-color: rgba{primary_color.toTuple()}; - }} - QRadioButton::focus{{ - border-color: rgba{highlight_color.toTuple()}; - outline: none; - }}""" - ) + include_checkbox = QCheckBox() + include_checkbox.setFixedSize(22, 22) + include_checkbox.setToolTip(Translations["tag.categories.tooltip"]) + include_checkbox.setStyleSheet(self.__checkbox_stylesheet(*self.__tag_colors(category))) - include_button.clicked.connect( - lambda: self.update_category_exclusion(category, include_button.isChecked()) - ) - self.category_button_group.addButton(include_button) if category.id not in self.exclusion_ids: - include_button.setChecked(True) + include_checkbox.setChecked(True) + include_checkbox.toggled.connect( + lambda checked: self.__update_category_exclusion(category, checked) + ) - row.addWidget(include_button) + row.addWidget(include_checkbox) - return tag_widget.bg_button, include_button, container + return tag_widget.bg_button, include_checkbox, container - def update_category_exclusion(self, category: Tag, checked: bool) -> None: + def __update_category_exclusion(self, category: Tag, checked: bool) -> None: if checked: self.exclusion_ids.remove(category.id) else: @@ -568,7 +516,7 @@ def set_parent_tags(self): if not tag: continue is_disam = parent_id == self.disambiguation_id - last_tab, next_tab, container = self.__build_row_item_widget(tag, parent_id, is_disam) + last_tab, next_tab, container = self.__build_parent_row_widget(tag, parent_id, is_disam) layout.addWidget(container) # TODO: Disam buttons after the first currently can't be added due to this error: # QWidget::setTabOrder: 'first' and 'second' must be in the same window @@ -577,30 +525,12 @@ def set_parent_tags(self): self.setTabOrder(next_tab, self.name_field) self.parent_tags_scroll_layout.addWidget(c) - def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: bool): + def __build_parent_row_widget(self, tag: Tag, parent_id: int, is_disambiguation: bool): container = QWidget() row = QHBoxLayout(container) row.setContentsMargins(0, 0, 0, 0) row.setSpacing(3) - # Init Colors - primary_color = get_primary_color(tag) - border_color = ( - get_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_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_text_color(primary_color, highlight_color) - # Add Tag Widget tag_widget = TagWidget( tag, @@ -612,6 +542,9 @@ def __build_row_item_widget(self, tag: Tag, parent_id: int, is_disambiguation: b tag_widget.on_edit.connect(lambda t=tag: TagSearchPanel(library=self.lib).edit_tag(t)) row.addWidget(tag_widget) + # Init Colors + primary_color, border_color, highlight_color, text_color = self.__tag_colors(tag) + # Add Disambiguation Tag Button disam_button = QRadioButton() disam_button.setObjectName(f"disambiguationButton.{parent_id}") @@ -706,7 +639,7 @@ def _set_aliases(self): alias_name = alias.name if alias else self.new_alias_names[alias_id] - # handel when an alias name changes + # handle when an alias name changes if alias_id in self.new_alias_names: alias_name = self.new_alias_names[alias_id] From 66742d9c528307ac39ae6512c761d91be1399c45 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:13:40 +0200 Subject: [PATCH 05/12] add missing translation for the properties label. --- src/tagstudio/qt/mixed/build_tag.py | 2 +- src/tagstudio/resources/translations/en.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index bd709ca3c..4cd79231b 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -272,7 +272,7 @@ def __init__(self, library: Library, tag: Tag | None = None) -> None: 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("

Properties

")) + self.root_layout.addWidget(QLabel(f"

{Translations['tag.properties']}

")) self.root_layout.addWidget(self.cat_widget) self.root_layout.addWidget(self.hidden_widget) diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index a6974cdfd..54444666d 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -335,6 +335,7 @@ "tag.parent_tags.add": "Add Parent Tag(s)", "tag.parent_tags.description": "This tag can be treated as a substitute for any of these Parent Tags in searches.", "tag.parent_tags": "Parent Tags", + "tag.properties": "Properties", "tag.remove": "Remove Tag", "tag.search_for_tag": "Search for Tag", "tag.shorthand": "Shorthand", From fa8b2aaa03e04d6d0d7530fdf130d73971641acc Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:06:15 +0200 Subject: [PATCH 06/12] add tests. --- src/tagstudio/qt/mixed/field_containers.py | 1 - tests/qt/test_build_tag_panel.py | 203 +++++++++++++++++++++ tests/qt/test_field_containers.py | 26 ++- 3 files changed, 228 insertions(+), 2 deletions(-) diff --git a/src/tagstudio/qt/mixed/field_containers.py b/src/tagstudio/qt/mixed/field_containers.py index e804c6c2b..675195d46 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -124,7 +124,6 @@ def update_granular( self.write_tag_container( container_index, tags=tags, category_tag=cat, is_mixed=False ) - container_index += 1 container_len += 1 if update_badges: diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py index 9ae15726b..8fd6c0d88 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 @@ -178,3 +180,204 @@ 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 __find_category_tag_widget(panel: BuildTagPanel) -> TagWidget | None: + item = panel.category_scroll_layout.itemAt(0) + 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 2b9921146..d762284a9 100644 --- a/tests/qt/test_field_containers.py +++ b/tests/qt/test_field_containers.py @@ -1,7 +1,8 @@ # Copyright (C) 2025 # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio - +from collections.abc import Callable +from pathlib import Path from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry, Tag @@ -185,3 +186,26 @@ def test_custom_tag_category(qt_driver: QtDriver, library: Library, entry_full: assert container.title != "

Tags

" case _: pass + + +def test_exclude_tag_category( + qt_driver: QtDriver, library: Library, generate_tag: Callable[..., Tag] +): + panel = PreviewPanel(library, 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, folder=unwrap(library.folder), 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.field_containers_widget.containers) == 1 + assert panel.field_containers_widget.containers[0].title == "

Tags

" From 8c68cb4f66a0c381493c2b0b024041b59a801f21 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:36:19 +0200 Subject: [PATCH 07/12] update docs. --- docs/library-changes.md | 68 +++++++++++++++++++++++------------------ docs/tags.md | 22 +++++++------ 2 files changed, 50 insertions(+), 40 deletions(-) diff --git a/docs/library-changes.md b/docs/library-changes.md index 6de13d274..29b9d727c 100644 --- a/docs/library-changes.md +++ b/docs/library-changes.md @@ -15,7 +15,7 @@ Legacy (JSON) library save format versions were tied to the release version of t ### Versions 1.0.0 - 9.4.2 | Used From | Format | Location | -| --------- | ------ | --------------------------------------------- | +|-----------|--------|-----------------------------------------------| | v1.0.0 | JSON | ``/.TagStudio/ts_library.json | The legacy database format for public TagStudio releases [v9.1](https://github.com/TagStudioDev/TagStudio/tree/Alpha-v9.1) through [v9.4.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.4.2). Variations of this format had been used privately since v1.0.0. @@ -49,7 +49,7 @@ These versions were used while developing the new SQLite file format, outside an ### Version 6 | Used From | Format | Location | -| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | +|---------------------------------------------------------------------------------|--------|-------------------------------------------------| | [v9.5.0-pr1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr1) | SQLite | ``/.TagStudio/ts_library.sqlite | The first public version of the SQLite save file format. @@ -61,74 +61,82 @@ Migration from the legacy JSON format is provided via a walkthrough when opening ### Version 7 | Used From | Format | Location | -| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | +|---------------------------------------------------------------------------------|--------|-------------------------------------------------| | [v9.5.0-pr2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr2) | SQLite | ``/.TagStudio/ts_library.sqlite | -- Repairs "Description" fields to use a TEXT_LINE key instead of a TEXT_BOX key. -- Repairs tags that may have a disambiguation_id pointing towards a deleted tag. +- Repairs "Description" fields to use a TEXT_LINE key instead of a TEXT_BOX key. +- Repairs tags that may have a disambiguation_id pointing towards a deleted tag. --- ### Version 8 | Used From | Format | Location | -| ------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | +|---------------------------------------------------------------------------------|--------|-------------------------------------------------| | [v9.5.0-pr4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.0-pr4) | SQLite | ``/.TagStudio/ts_library.sqlite | -- Adds the `color_border` column to the `tag_colors` table. Used for instructing the [secondary color](colors.md#secondary-color) to apply to a tag's border as a new optional behavior. -- Adds three new default colors: "Burgundy (TagStudio Shades)", "Dark Teal (TagStudio Shades)", and "Dark Lavender (TagStudio Shades)". -- Updates Neon colors to use the new `color_border` property. +- Adds the `color_border` column to the `tag_colors` table. Used for instructing the [secondary color](colors.md#secondary-color) to apply to a tag's border as a new optional behavior. +- Adds three new default colors: "Burgundy (TagStudio Shades)", "Dark Teal (TagStudio Shades)", and "Dark Lavender (TagStudio Shades)". +- Updates Neon colors to use the new `color_border` property. --- ### Version 9 | Used From | Format | Location | -| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- | +|-------------------------------------------------------------------------|--------|-------------------------------------------------| | [v9.5.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.2) | SQLite | ``/.TagStudio/ts_library.sqlite | -- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results. +- Adds the `filename` column to the `entries` table. Used for sorting entries by filename in search results. --- ### Version 100 | Used From | Format | Location | -| ---------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------- | +|------------------------------------------------------------------------------------------------------|--------|-------------------------------------------------| | [74383e3](https://github.com/TagStudioDev/TagStudio/commit/74383e3c3c12f72be1481ab0b86c7360b95c2d85) | SQLite | ``/.TagStudio/ts_library.sqlite | -- Introduces built-in minor versioning - - The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in. - - Values more precise than this ("ones" through "tens" columns) constitute the **minor** version. These indicate minor changes that don't prevent a newer library from being opened in an older version of TagStudio, as long as the major version is not also increased. -- Swaps `parent_id` and `child_id` values in the `tag_parents` table +- Introduces built-in minor versioning + - The version number divided by 100 (and floored) constitutes the **major** version. Major version indicate breaking changes that prevent libraries from being opened in TagStudio versions older than the ones they were created in. + - Values more precise than this ("ones" through "tens" columns) constitute the **minor** version. These indicate minor changes that don't prevent a newer library from being opened in an older version of TagStudio, as long as the major version is not also increased. +- Swaps `parent_id` and `child_id` values in the `tag_parents` table #### Version 101 | Used From | Format | Location | -| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- | +|-------------------------------------------------------------------------|--------|-------------------------------------------------| | [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | ``/.TagStudio/ts_library.sqlite | -- Deprecates the `preferences` table, set to be removed in a future TagStudio version. -- Introduces the `versions` table - - Has a string `key` column and an int `value` column - - The `key` column stores one of two values: `'INITIAL'` and `'CURRENT'` - - `'INITIAL'` stores the database version number in which in was created - - Pre-existing databases set this number to `100` - - `'CURRENT'` stores the current database version number +- Deprecates the `preferences` table, set to be removed in a future TagStudio version. +- Introduces the `versions` table + - Has a string `key` column and an int `value` column + - The `key` column stores one of two values: `'INITIAL'` and `'CURRENT'` + - `'INITIAL'` stores the database version number in which in was created + - Pre-existing databases set this number to `100` + - `'CURRENT'` stores the current database version number #### Version 102 | Used From | Format | Location | -| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- | +|-------------------------------------------------------------------------|--------|-------------------------------------------------| | [v9.5.4](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.5.4) | SQLite | ``/.TagStudio/ts_library.sqlite | -- Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted. +- Applies repairs to the `tag_parents` table created in [version 100](#version-100), removing rows that reference tags that have been deleted. #### Version 103 -| Used From | Format | Location | -| ----------------------------------------------------------------------- | ------ | ----------------------------------------------- | +| Used From | Format | Location | +|--------------------------------------------------------------|--------|-------------------------------------------------| | [#1139](https://github.com/TagStudioDev/TagStudio/pull/1139) | SQLite | ``/.TagStudio/ts_library.sqlite | -- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches. -- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default. \ No newline at end of file +- Adds the `is_hidden` column to the `tags` table (default `0`). Used for excluding entries tagged with hidden tags from library searches. +- Sets the `is_hidden` field on the built-in Archived tag to `1`, to match the Archived tag now being hidden by default. + +#### Version 104 + +| Used From | Format | Location | +|-----------|--------|-------------------------------------------------| +| TBD | SQLite | ``/.TagStudio/ts_library.sqlite | + +- Introduces the `category_exclusions` table. Used for excluding a tag from being displayed in a specific category \ No newline at end of file diff --git a/docs/tags.md b/docs/tags.md index b8cb65e1b..c4fb02716 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -10,9 +10,9 @@ Tags are discrete objects that represent some attribute. This could be a person, TagStudio tags do not share the same naming limitations of many other tagging solutions. The key standouts of tag names in TagStudio are: -- Tag names do **NOT** have to be unique -- Tag names are **NOT** limited to specific characters -- Tags can have **aliases**, a.k.a. alternate names to go by +- Tag names do **NOT** have to be unique +- Tag names are **NOT** limited to specific characters +- Tags can have **aliases**, a.k.a. alternate names to go by ### Name @@ -66,7 +66,7 @@ Lastly, when searching your files with broader categories such as `Character` or !!! warning "" - **_Coming in version 9.6.x_** +**_Coming in version 9.6.x_** Component tags will be built from a composition-based, or "HAS" type relationship between tags. This takes care of instances where an attribute may "have" another attribute, but doesn't inherit from it. Shrek may be an `Ogre`, he may be a `Character`, but he is NOT a `Leather Vest` - even if he's commonly seen _with_ it. Component tags, along with the upcoming "Tag Override" feature, are built to handle these cases in a way that still simplifies the tagging process without adding too much undue complexity for the user. @@ -88,7 +88,7 @@ Custom palettes and colors can be created via the [Tag Color Manager](colors.md) !!! warning "" - **_Coming in version 9.6.x_** +**_Coming in version 9.6.x_** ## Tag Properties @@ -96,12 +96,14 @@ Properties are special attributes of tags that change their behavior in some way #### Is Category -The "Is Category" property of tags determines if a tag should be treated as a category itself when being organized inside the preview panel. If this tag or any tags inheriting from this tag (i.e. tags that have this tag as a "[Parent Tag](#parent-tags)"), then these tags will appear under a separated group that's named after this tag. Tags inheriting from multiple "category tags" will still show up under any applicable category. +The "Is Category" property of tags determines if a tag should be treated as a category itself when being organized inside the preview panel. If this tag or any tags inheriting from this tag (i.e. tags that have this tag as a "[Parent Tag](#parent-tags)"), then these tags will appear under a separated group that's named after this tag. By default, tags inheriting from multiple "category tags" will still show up under any applicable category. This means that duplicates of tags can appear on entries if the tag inherits from multiple parent categories, however this is by design and reflects the nature of multiple inheritance. Any tags not inheriting from a category tag will simply show under a default "Tag" section. ![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. @@ -114,7 +116,7 @@ Due to the nature of how tags and Tag Felids operated prior to v9.5, the organiz !!! warning "" - **_Coming in version 9.6.x_** +**_Coming in version 9.6.x_** When the "Is Hidden" property is checked, any file entries tagged with this tag will not show up in searches by default. This property comes by default with the built-in "Archived" tag. @@ -123,7 +125,7 @@ When the "Is Hidden" property is checked, any file entries tagged with this tag The following are examples of how a set of given tags will respond to various search queries. | Tag | Name | Shorthand | Aliases | Parent Tags | -| ------------------- | ------------------- | --------- | ---------------------- | -------------------------------------------- | +|---------------------|---------------------|-----------|------------------------|----------------------------------------------| | _League of Legends_ | "League of Legends" | "LoL" | ["League"] | ["Game", "Fantasy"] | | _Arcane_ | "Arcane" | "" | [] | ["League of Legends", "Cartoon"] | | _Jinx (LoL)_ | "Jinx Piltover" | "Jinx" | ["Jinxy", "Jinxy Poo"] | ["League of Legends", "Arcane", "Character"] | @@ -133,7 +135,7 @@ The following are examples of how a set of given tags will respond to various se **The query "Arcane" will display results tagged with:** | Tag | Cause of Inclusion | Tag Tree Lineage | -| --------------- | -------------------------------- | -------------------------- | +|-----------------|----------------------------------|----------------------------| | Arcane | Direct match of tag name | "Arcane" | | Jinx (LoL) | Search term is set as parent tag | "Jinx (LoL) > Arcane" | | Zander (Arcane) | Search term is set as parent tag | "Zander (Arcane) > Arcane" | @@ -141,7 +143,7 @@ The following are examples of how a set of given tags will respond to various se **The query "League of Legends" will display results tagged with:** | Tag | Cause of Inclusion | Tag Tree Lineage | -| ----------------- | ------------------------------------------------------ | ---------------------------------------------- | +|-------------------|--------------------------------------------------------|------------------------------------------------| | League of Legends | Direct match of tag name | "League of Legends" | | Arcane | Search term is set as parent tag | "Arcane > League of Legends" | | Jinx (LoL) | Search term is set as parent tag | "Jinx (LoL) > League of Legends" | From 431b201f8ec1ef9ba0b3a484899cb8ac6f8d7f41 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:18:12 +0200 Subject: [PATCH 08/12] fix categories when creating new tags. --- src/tagstudio/qt/mixed/build_tag.py | 5 +++-- tests/qt/test_build_tag_panel.py | 29 +++++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index 4cd79231b..fc69da62d 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -447,9 +447,10 @@ def set_categories( layout.addWidget(container) self.setTabOrder(last_tab, next_tab) else: - tag_ids = [self.tag.id] + tag_ids = {self.tag.id} + tag_ids.update(self.parent_ids) if added_parent_id is not None: - tag_ids.append(added_parent_id) + tag_ids.add(added_parent_id) for tag in self.lib.get_tag_hierarchy(tag_ids).values(): if not tag.is_category: diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py index 8fd6c0d88..4fb5b27b6 100644 --- a/tests/qt/test_build_tag_panel.py +++ b/tests/qt/test_build_tag_panel.py @@ -361,8 +361,33 @@ def test_build_tag_panel_remove_duplicate_category_retained( assert tag_widget.tag == grandparent -def __find_category_tag_widget(panel: BuildTagPanel) -> TagWidget | None: - item = panel.category_scroll_layout.itemAt(0) +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 __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 From c5aed0de50b9b590937d09ce6d88c569f90734e7 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:41:06 +0200 Subject: [PATCH 09/12] link PR in library-changes.md. --- docs/library-changes.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/library-changes.md b/docs/library-changes.md index 29b9d727c..aaefdbd9d 100644 --- a/docs/library-changes.md +++ b/docs/library-changes.md @@ -135,8 +135,8 @@ Migration from the legacy JSON format is provided via a walkthrough when opening #### Version 104 -| Used From | Format | Location | -|-----------|--------|-------------------------------------------------| -| TBD | SQLite | ``/.TagStudio/ts_library.sqlite | +| Used From | Format | Location | +|--------------------------------------------------------------|--------|-------------------------------------------------| +| [#1336](https://github.com/TagStudioDev/TagStudio/pull/1336) | SQLite | ``/.TagStudio/ts_library.sqlite | - Introduces the `category_exclusions` table. Used for excluding a tag from being displayed in a specific category \ No newline at end of file From 26710a1953b7d89f8c570924da9b30d34f38aa53 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 4 Apr 2026 11:02:17 +0200 Subject: [PATCH 10/12] fix: don't show categories for tags that define the category. --- src/tagstudio/qt/mixed/build_tag.py | 2 +- tests/qt/test_build_tag_panel.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/tagstudio/qt/mixed/build_tag.py b/src/tagstudio/qt/mixed/build_tag.py index fc69da62d..fc3300b52 100644 --- a/src/tagstudio/qt/mixed/build_tag.py +++ b/src/tagstudio/qt/mixed/build_tag.py @@ -453,7 +453,7 @@ def set_categories( tag_ids.add(added_parent_id) for tag in self.lib.get_tag_hierarchy(tag_ids).values(): - if not tag.is_category: + if not tag.is_category or tag == self.tag: continue last_tab, next_tab, container = self.__build_category_row_widget(tag) layout.addWidget(container) diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py index 4fb5b27b6..6e4d54426 100644 --- a/tests/qt/test_build_tag_panel.py +++ b/tests/qt/test_build_tag_panel.py @@ -386,6 +386,17 @@ def test_build_tag_panel_new_tag_multiple_categories( 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: From f9aa0f5dd394c5d52c564815de27c10dd73222e2 Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 4 Apr 2026 11:37:11 +0200 Subject: [PATCH 11/12] fix: persist exclusion_ids on all relevant add_tag calls. --- src/tagstudio/qt/mixed/tag_database.py | 1 + src/tagstudio/qt/mixed/tag_search.py | 1 + src/tagstudio/qt/ts_qt.py | 1 + 3 files changed, 3 insertions(+) diff --git a/src/tagstudio/qt/mixed/tag_database.py b/src/tagstudio/qt/mixed/tag_database.py index 180cee9c7..db510f7e5 100644 --- a/src/tagstudio/qt/mixed/tag_database.py +++ b/src/tagstudio/qt/mixed/tag_database.py @@ -48,6 +48,7 @@ def build_tag(self, name: str): parent_ids=panel.parent_ids, alias_names=panel.alias_names, alias_ids=panel.alias_ids, + exclusion_ids=panel.exclusion_ids ), self.modal.hide(), self.update_tags(self.search_field.text()), diff --git a/src/tagstudio/qt/mixed/tag_search.py b/src/tagstudio/qt/mixed/tag_search.py index 12d2a74b2..6a5847e3f 100644 --- a/src/tagstudio/qt/mixed/tag_search.py +++ b/src/tagstudio/qt/mixed/tag_search.py @@ -189,6 +189,7 @@ def on_tag_modal_saved(): set(self.build_tag_modal.parent_ids), set(self.build_tag_modal.alias_names), set(self.build_tag_modal.alias_ids), + set(self.build_tag_modal.exclusion_ids) ) self.add_tag_modal.hide() diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index ab81b4e27..ed163e9b1 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -863,6 +863,7 @@ def add_tag_action_callback(self): set(panel.parent_ids), set(panel.alias_names), set(panel.alias_ids), + set(panel.exclusion_ids) ), self.modal.hide(), ) From d6a65d69e4d2fe2a301bbf34b562d731980aaa8d Mon Sep 17 00:00:00 2001 From: Sola-ris <190788035+Sola-ris@users.noreply.github.com> Date: Sat, 4 Apr 2026 11:40:10 +0200 Subject: [PATCH 12/12] fix formatting. --- src/tagstudio/qt/mixed/tag_database.py | 2 +- src/tagstudio/qt/mixed/tag_search.py | 2 +- src/tagstudio/qt/ts_qt.py | 2 +- tests/qt/test_build_tag_panel.py | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tagstudio/qt/mixed/tag_database.py b/src/tagstudio/qt/mixed/tag_database.py index db510f7e5..40fbf276c 100644 --- a/src/tagstudio/qt/mixed/tag_database.py +++ b/src/tagstudio/qt/mixed/tag_database.py @@ -48,7 +48,7 @@ def build_tag(self, name: str): parent_ids=panel.parent_ids, alias_names=panel.alias_names, alias_ids=panel.alias_ids, - exclusion_ids=panel.exclusion_ids + exclusion_ids=panel.exclusion_ids, ), self.modal.hide(), self.update_tags(self.search_field.text()), diff --git a/src/tagstudio/qt/mixed/tag_search.py b/src/tagstudio/qt/mixed/tag_search.py index 6a5847e3f..583676c52 100644 --- a/src/tagstudio/qt/mixed/tag_search.py +++ b/src/tagstudio/qt/mixed/tag_search.py @@ -189,7 +189,7 @@ def on_tag_modal_saved(): set(self.build_tag_modal.parent_ids), set(self.build_tag_modal.alias_names), set(self.build_tag_modal.alias_ids), - set(self.build_tag_modal.exclusion_ids) + set(self.build_tag_modal.exclusion_ids), ) self.add_tag_modal.hide() diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index ed163e9b1..66c56a1c6 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -863,7 +863,7 @@ def add_tag_action_callback(self): set(panel.parent_ids), set(panel.alias_names), set(panel.alias_ids), - 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 6e4d54426..231cb7cf4 100644 --- a/tests/qt/test_build_tag_panel.py +++ b/tests/qt/test_build_tag_panel.py @@ -397,6 +397,7 @@ def test_build_tag_panel_category_not_shown_for_self( 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: