diff --git a/docs/library-changes.md b/docs/library-changes.md index 6de13d274..aaefdbd9d 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 | +|--------------------------------------------------------------|--------|-------------------------------------------------| +| [#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 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" | 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/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..fc3300b52 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 @@ -171,6 +169,31 @@ 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(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) @@ -218,30 +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{{" - 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"}}" + 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) @@ -258,30 +258,7 @@ 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"}}" + 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) @@ -293,12 +270,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(QLabel(f"

{Translations['tag.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 = {} @@ -306,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() @@ -340,11 +373,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 +410,97 @@ 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) + + # 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_checkbox = QCheckBox() + include_checkbox.setFixedSize(22, 22) + include_checkbox.setToolTip(Translations["tag.categories.tooltip"]) + include_checkbox.setStyleSheet(self.__checkbox_stylesheet(*self.__tag_colors(category))) + + if category.id not in self.exclusion_ids: + include_checkbox.setChecked(True) + include_checkbox.toggled.connect( + lambda checked: self.__update_category_exclusion(category, checked) + ) + + row.addWidget(include_checkbox) + + return tag_widget.bg_button, include_checkbox, 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() @@ -391,7 +517,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 @@ -400,30 +526,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, @@ -435,41 +543,45 @@ 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}") 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) @@ -528,7 +640,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] @@ -575,6 +687,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..675195d46 100644 --- a/src/tagstudio/qt/mixed/field_containers.py +++ b/src/tagstudio/qt/mixed/field_containers.py @@ -189,7 +189,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_database.py b/src/tagstudio/qt/mixed/tag_database.py index 180cee9c7..40fbf276c 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 3a3e120b4..583676c52 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() @@ -382,7 +383,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()) diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index ab81b4e27..66c56a1c6 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(), ) diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index cdd46dc11..54444666d 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}\"?", @@ -333,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", 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 000000000..f1450ca5b Binary files /dev/null and b/tests/fixtures/empty_libraries/DB_VERSION_101/.TagStudio/ts_library.sqlite differ diff --git a/tests/fixtures/empty_libraries/DB_VERSION_102/.TagStudio/ts_library.sqlite b/tests/fixtures/empty_libraries/DB_VERSION_102/.TagStudio/ts_library.sqlite new file mode 100644 index 000000000..8a01bc63d Binary files /dev/null and b/tests/fixtures/empty_libraries/DB_VERSION_102/.TagStudio/ts_library.sqlite differ diff --git a/tests/fixtures/empty_libraries/DB_VERSION_103/.TagStudio/ts_library.sqlite b/tests/fixtures/empty_libraries/DB_VERSION_103/.TagStudio/ts_library.sqlite new file mode 100644 index 000000000..c68bf9047 Binary files /dev/null and b/tests/fixtures/empty_libraries/DB_VERSION_103/.TagStudio/ts_library.sqlite differ diff --git a/tests/fixtures/empty_libraries/DB_VERSION_104/.TagStudio/ts_library.sqlite b/tests/fixtures/empty_libraries/DB_VERSION_104/.TagStudio/ts_library.sqlite new file mode 100644 index 000000000..124cfe09a Binary files /dev/null and b/tests/fixtures/empty_libraries/DB_VERSION_104/.TagStudio/ts_library.sqlite differ diff --git a/tests/qt/test_build_tag_panel.py b/tests/qt/test_build_tag_panel.py index 9ae15726b..231cb7cf4 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,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 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

" 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