Compare commits

..

5 Commits

Author SHA1 Message Date
Travis Abendshien 8610cb41af feat!: add file date metadata to db 2026-08-16 12:12:28 -07:00
Travis Abendshien 17cf87a4fa fix(docs): fix changelog header level 2026-08-16 11:57:48 -07:00
Sola-ris 3fe7922642 feat: add per-tag category display options (#1470)
* feat: add per-tag display options.

* feat: bump DB_VERSION to 301, add migration.

* tests: add tests.

* docs: document cateogry display options.

* docs: document library versions 300 and 301.

* fix: resolve ruff and pyright issues.

* fix: redo migration in the new style.

* fix: Remove unnecessary @staticmethod decorators.

* fix: Bump library version to 400.

* fix: Fix color order in colored_radio_button_style.

* fix: Commit the library produced by pytest.

* test: Add test for removing a category during tag creation.

* fix: Fix removing categories during tag creation.

* test: Add test for adding another tag after removing an existing category.

* fix: Fix adding another tag after removing an existing category.

* feat: Add separators between widgets.

* docs: Fix version in library-changes.md.

* test: Test removing a category inherited both directly and indirectly.

* fix: Fix removing a category inherited both directly and indirectly.

---------

Co-authored-by: Travis Abendshien <46939827+cyanvoxel@users.noreply.github.com>
2026-08-16 11:54:04 -07:00
Travis Abendshien 555dae50d4 chore: bump version to v9.6.4 2026-08-15 21:46:46 -07:00
Travis Abendshien e6d67c26dd docs: reorder changelog to match release notes 2026-08-15 21:46:20 -07:00
24 changed files with 937 additions and 106 deletions
+3 -3
View File
@@ -9,7 +9,7 @@ toc_depth: 2
# :material-script-text: Changelog
### 9.6.3 <small>August 15th, 2026</small>
## 9.6.3 <small>August 15th, 2026</small>
This update includes some critical library bugfixes along with a handful QoL tweaks and additions to the tag/field search bars. The [documentation](https://docs.tagstud.io/usage/#tagging) on this feature has been updated to include the new improvements.
@@ -17,8 +17,6 @@ This update includes some critical library bugfixes along with a handful QoL twe
- feat(ui): show library format version in "About" window by @CyanVoxel in 102cfdf4a2e12635f4b6e47259aa23ea95787421
### Changed
#### Tag and Field Bars
- feat(ui): keep tag/field search bars open by default by @CyanVoxel in #1472
@@ -26,6 +24,8 @@ This update includes some critical library bugfixes along with a handful QoL twe
- feat(ui): use tab and shift+tab to navigate tag/field search bars by @CyanVoxel in #1474
- feat(ui): add hint icons to tag/field search bars by @CyanVoxel in #1475
### Changed
#### Internal Changes
- refactor: almost final migrations cleanup by @Computerdores in #1456
+17
View File
@@ -201,3 +201,20 @@ Migration from the legacy JSON format is provided via a walkthrough when opening
| 95e2fe7b4449951c385e35a2e13f0c1925f1f98e | [v9.6.1](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.1) | SQLite |
- Applies repairs to the `tag_parents` table, removing rows that reference child tags that have been deleted.
#### Version 300
| Added in Commit | Introduced in Release | Format |
| ---------------------------------------- |-------------------------------------------------------------------------| ------ |
| 51a9c16f50ca785d810911d2d0c83fa33eb1c0ae | [v9.6.2](https://github.com/TagStudioDev/TagStudio/releases/tag/v9.6.2) | SQLite |
- Drops `folder` columns from the `entries` table.
- Drops the unused `folders` table.
#### Version 400
| Added in Commit | Introduced in Release | Format |
|-----------------|-----------------------| ------ |
| TBD | TBD | SQLite |
- Adds the `category_exclusion` table.
+2
View File
@@ -106,6 +106,8 @@ This means that duplicates of tags can appear on entries if the tag inherits fro
![Tag Category Example](assets/tag_categories_example.png)
If you don't want a tag to appear in one, more, or even all the applicable categories, simply uncheck the category in the "Edit Tag" panel.
### Built-In Tags and Categories
The built-in tags "Favorite" and "Archived" inherit from the built-in "Meta Tags" category which is marked as a category by default. This behavior of default tags can be fully customized by disabling the category option and/or by adding/removing the tags' Parent Tags.
+1 -1
View File
@@ -9,7 +9,7 @@ build-backend = "hatchling.build"
[project]
name = "TagStudio"
description = "A User-Focused Photo & File Management System."
version = "9.6.3"
version = "9.6.4"
license = "GPL-3.0-only"
readme = "README.md"
requires-python = ">=3.12,<3.14"
@@ -14,14 +14,14 @@ JSON_FILENAME: str = "ts_library.json"
DB_VERSION_CURRENT_KEY: str = "CURRENT"
DB_VERSION_INITIAL_KEY: str = "INITIAL"
DB_VERSION: int = 300
DB_VERSION: int = 400
TAG_CHILDREN_QUERY = text("""
WITH RECURSIVE ChildTags AS (
SELECT :tag_id AS tag_id
UNION
SELECT tp.child_id AS tag_id
FROM tag_parents tp
FROM tag_parents tp
INNER JOIN ChildTags c ON tp.parent_id = c.tag_id
)
SELECT * FROM ChildTags;
@@ -20,3 +20,10 @@ class TagEntry(Base):
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
entry_id: Mapped[int] = mapped_column(ForeignKey("entries.id"), primary_key=True)
class CategoryExclusion(Base):
__tablename__ = "category_exclusions"
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
category_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
+114 -8
View File
@@ -82,7 +82,8 @@ from tagstudio.core.library.alchemy.fields import (
TextField,
TextFieldTemplate,
)
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
from tagstudio.core.library.alchemy.joins import CategoryExclusion, TagEntry, TagParent
from tagstudio.core.library.alchemy.metadata import FileMetadata
from tagstudio.core.library.alchemy.migrations import DBMigrations, MigrationError
from tagstudio.core.library.alchemy.models import (
Entry,
@@ -95,6 +96,7 @@ from tagstudio.core.library.alchemy.models import (
from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder
from tagstudio.core.library.ignore import migrate_ext_list
from tagstudio.core.library.json.library import Library as JsonLibrary
from tagstudio.core.utils.stat import get_date_created, get_date_modified
from tagstudio.core.utils.types import unwrap
if TYPE_CHECKING:
@@ -540,7 +542,11 @@ class Library:
return entry
def get_entry_full(
self, entry_id: int, with_fields: bool = True, with_tags: bool = True
self,
entry_id: int,
with_fields: bool = True,
with_tags: bool = True,
with_metadata: bool = True,
) -> Entry | None:
"""Load entry and join with all joins and all tags."""
# NOTE: TODO: Currently this method makes multiple separate queries to the db and combines
@@ -570,6 +576,11 @@ class Library:
)
)
if with_metadata:
entry_stmt = entry_stmt.outerjoin(Entry.file_metadata).options(
selectinload(Entry.file_metadata),
)
start_time = time.time()
entry = session.scalar(entry_stmt)
if with_tags:
@@ -764,10 +775,79 @@ class Library:
session.query(Entry).where(Entry.id.in_(sub_list)).delete()
session.commit()
def has_entry_with_path(self, path: Path) -> bool:
"""Check if an entry with this path is in the library."""
def get_entry_id_from_path(self, path: Path) -> int:
"""Attempt to return an Entry ID given a filepath, else return -1."""
with Session(self.engine) as session:
return session.query(exists().where(Entry.path == path)).scalar()
return session.scalar(select(Entry.id).where(Entry.path == path).limit(1)) or -1
# def update_entry_file_metadata(
# self, entry_id: int, date_created: datetime | None, date_modified: datetime | None
# ):
# with Session(self.engine) as session:
# stmt = update(FileMetadata).where(
# and_(
# FileMetadata.entry_id == entry_id,
# )
# )
# if date_created:
# stmt = stmt.values(date_created=date_created)
# if date_modified:
# stmt = stmt.values(date_modified=date_modified)
# session.execute(stmt)
# session.commit()
def refresh_file_entry_stats(self, entry_id: int, path: Path | None):
"""Updates a file entry's associated stat() data."""
needs_update = False
entry = self.get_entry_full(
entry_id, with_fields=False, with_tags=False, with_metadata=True
)
if not entry:
return
if not path:
full_path = unwrap(self.library_dir) / entry.path
else:
full_path = unwrap(self.library_dir) / path
logger.info(full_path)
file_date_created = get_date_created(full_path)
file_date_modified = get_date_modified(full_path)
# Log info
if entry.date_created != file_date_created:
logger.info(f"Difference in date_created!: {entry.date_created}/{file_date_created}")
needs_update = True
else:
logger.info("No difference in date_created.")
if entry.date_modified != file_date_modified:
logger.info(f"Difference in date_modified!: {entry.date_modified}/{file_date_modified}")
needs_update = True
else:
logger.info("No difference in date_modified")
if needs_update:
return
else:
logger.info(f"Updating entry file_metadata for {full_path}")
with Session(self.engine) as session:
stmt = update(FileMetadata).where(
and_(
FileMetadata.entry_id == entry_id,
)
)
if file_date_created:
stmt = stmt.values(date_created=file_date_created)
if file_date_modified:
stmt = stmt.values(date_modified=file_date_modified)
session.execute(stmt)
session.commit()
def get_paths(self, limit: int = -1) -> list[str]:
path_strings: list[str] = []
@@ -1085,7 +1165,7 @@ class Library:
Returns True if the action succeeded and False if the path already exists.
"""
if self.has_entry_with_path(path):
if self.get_entry_id_from_path(path) >= 0:
return False
if isinstance(entry_id, Entry):
entry_id = entry_id.id
@@ -1326,6 +1406,7 @@ class Library:
tag: Tag,
parent_ids: list[int] | set[int] | None = None,
aliases: Iterable[TagAlias] | None = None,
exclusion_ids: list[int] | set[int] | None = None,
) -> Tag | None:
with Session(self.engine, expire_on_commit=False) as session:
try:
@@ -1342,6 +1423,9 @@ class Library:
self.update_aliases(tag, aliases, session)
session.flush()
if exclusion_ids is not None:
self._update_category_exclusion(tag, exclusion_ids, session)
session.commit()
session.expunge(tag)
return tag
@@ -1471,6 +1555,7 @@ class Library:
selectinload(Tag.parent_tags),
selectinload(Tag.aliases),
joinedload(Tag.color),
selectinload(Tag.category_exclusions),
)
tag = session.scalar(tags_query.where(Tag.id == tag_id))
@@ -1541,7 +1626,10 @@ class Library:
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:
@@ -1620,9 +1708,10 @@ class Library:
tag: Tag,
parent_ids: list[int] | set[int] | None = None,
aliases: Iterable[TagAlias] | None = None,
exclusion_ids: list[int] | set[int] | None = None,
) -> None:
"""Edit a Tag in the Library."""
self.add_tag(tag, parent_ids, aliases)
self.add_tag(tag, parent_ids, aliases, exclusion_ids)
def update_color(self, old_color_group: TagColorGroup, new_color_group: TagColorGroup) -> None:
"""Update a TagColorGroup in the Library. If it doesn't already exist, create it."""
@@ -1745,6 +1834,23 @@ class Library:
)
session.add(parent_tag)
def _update_category_exclusion(
self, tag: Tag, exclusion_ids: list[int] | set[int], session: Session
):
prev_exclusions = session.scalars(
select(CategoryExclusion).where(CategoryExclusion.tag_id == tag.id)
).all()
for exclusion in prev_exclusions:
if exclusion.category_id not in exclusion_ids:
session.delete(exclusion)
else:
exclusion_ids.remove(exclusion.category_id)
for exclusion_id in exclusion_ids:
exclusion = CategoryExclusion(tag_id=tag.id, category_id=exclusion_id)
session.add(exclusion)
def get_version(self, key: str) -> int:
"""Get a version value from the DB.
@@ -0,0 +1,116 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
from __future__ import annotations
from datetime import datetime as dt
from pathlib import Path
from typing import TYPE_CHECKING, Any, override
from sqlalchemy import ForeignKey, ForeignKeyConstraint, Integer, null
from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
from tagstudio.core.library.alchemy.db import Base, PathType
from tagstudio.core.library.alchemy.joins import TagParent
if TYPE_CHECKING:
from tagstudio.core.library.alchemy.models import Entry
class FileMetadata(Base):
"""Table that includes file data and metadata obtained from os.stat() for entries."""
__tablename__ = "file_metadata"
entry_id: Mapped[int] = mapped_column(
ForeignKey("entries.id"), primary_key=True, nullable=False
)
# NOTE: These dates are stored as floats because that's their natural form from os.stat()
# and comparisons are quicker without having to convert to/from datetime objects.
date_created: Mapped[float | None]
date_modified: Mapped[float | None]
def __init__(
self,
entry_id: int,
date_created: float | None = None,
date_modified: float | None = None,
) -> None:
super().__init__()
self.entry_id = entry_id
# # Path data
# self.path = path
# self.filename = path.name
# self.suffix = path.suffix.lstrip(".").lower()
# File metadata
self.date_created = date_created # st_birthtime on Windows and Mac, st_ctime on Linux
self.date_modified = date_modified # st_mtime
class ExifMetadata(Base):
"""Contains Exif metadata for a entries."""
__tablename__ = "exif_metadata"
entry_id: Mapped[int] = mapped_column(
ForeignKey("entries.id"), primary_key=True, nullable=False
)
date_taken: Mapped[dt | None]
def __init__(
self,
entry_id: int,
date_taken: dt | None = None,
) -> None:
super().__init__()
self.entry_id = entry_id
self.date_taken = date_taken # Exif.Image.DateTime
class DimensionMetadata(Base):
"""Contains dimension metadata for entries (e.g. image and video files)."""
__tablename__ = "dimension_metadata"
entry_id: Mapped[int] = mapped_column(
ForeignKey("entries.id"), primary_key=True, nullable=False
)
width: Mapped[int] = mapped_column(nullable=False)
height: Mapped[int] = mapped_column(nullable=False)
def __init__(
self,
entry_id: int,
width: int,
height: int,
) -> None:
super().__init__()
self.entry_id = entry_id
self.width = width
self.height = height
class DurationMetadata(Base):
"""Contains duration metadata for entries (e.g. audio and video files)."""
__tablename__ = "duration_metadata"
entry_id: Mapped[int] = mapped_column(
ForeignKey("entries.id"), primary_key=True, nullable=False
)
duration: Mapped[float] = mapped_column(nullable=False)
def __init__(
self,
entry_id: int,
duration: float,
) -> None:
super().__init__()
self.entry_id = entry_id
self.duration = duration
@@ -100,6 +100,7 @@ class DBMigrations:
MigrationTo201, # changes: field tables
MigrationTo202, # changes: tag_parents
MigrationTo300, # changes: deletes folders
MigrationTo400, # changes: add category_exclusions
]
with Session(self.engine) as session:
if self.loaded_db_version > DB_VERSION:
@@ -578,3 +579,23 @@ class MigrationTo300(DBMigration):
## drop table "folders"
session.execute(text("DROP TABLE folders"))
session.flush()
class MigrationTo400(DBMigration):
version = 400
@override
@classmethod
def run(cls, session: Session, library_dir: Path, fmt_log):
logger.info(fmt_log("Creating category_exclusions table..."))
session.execute(
text("""
CREATE TABLE category_exclusions (
tag_id INTEGER NOT NULL REFERENCES tags(id),
category_id INTEGER NOT NULL REFERENCES tags(id),
PRIMARY KEY (tag_id, category_id)
)
""")
)
session.flush()
+44 -19
View File
@@ -11,12 +11,10 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from tagstudio.core.library.alchemy.db import Base, PathType
from tagstudio.core.library.alchemy.fields import (
BaseField,
DatetimeField,
TextField,
)
from tagstudio.core.library.alchemy.joins import TagParent
from tagstudio.core.library.alchemy.fields import BaseField, DatetimeField, TextField
from tagstudio.core.library.alchemy.joins import CategoryExclusion, TagParent
from tagstudio.core.library.alchemy.metadata import FileMetadata
from tagstudio.core.utils.stat import get_date_created, get_date_modified
class Namespace(Base):
@@ -104,6 +102,12 @@ class Tag(Base):
back_populates="parent_tags",
)
disambiguation_id: Mapped[int | None]
category_exclusions: Mapped[set["Tag"]] = relationship(
secondary=CategoryExclusion.__tablename__,
primaryjoin="Tag.id == CategoryExclusion.tag_id",
secondaryjoin="Tag.id == CategoryExclusion.category_id",
back_populates="category_exclusions",
)
__table_args__ = (
ForeignKeyConstraint(
@@ -124,6 +128,10 @@ class Tag(Base):
def alias_ids(self) -> list[int]:
return [tag.id for tag in self.aliases]
@property
def exclusion_ids(self) -> list[int]:
return [tag.id for tag in self.category_exclusions]
def __init__(
self,
name: str,
@@ -137,6 +145,7 @@ class Tag(Base):
disambiguation_id: int | None = None,
is_category: bool = False,
is_hidden: bool = False,
category_exclusions: set["Tag"] | None = None,
):
self.name = name
self.aliases = aliases or set()
@@ -149,6 +158,7 @@ class Tag(Base):
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
@@ -187,12 +197,12 @@ class Entry(Base):
id: Mapped[int] = mapped_column(primary_key=True)
# TODO: Possibly move to FileMetadata table if Entry is split into Entry/FileEntry (see #588)
path: Mapped[Path] = mapped_column(PathType, unique=True)
filename: Mapped[str] = mapped_column()
suffix: Mapped[str] = mapped_column()
date_created: Mapped[dt | None]
date_modified: Mapped[dt | None]
date_added: Mapped[dt | None]
date_added: Mapped[dt | None] # The date this entry was added to the library
tags: Mapped[set[Tag]] = relationship(secondary="tag_entries")
@@ -205,6 +215,11 @@ class Entry(Base):
cascade="all, delete",
)
file_metadata: Mapped["FileMetadata"] = relationship(
uselist=False,
cascade="all, delete-orphan",
)
@property
def fields(self) -> list[BaseField]:
fields: list[BaseField] = []
@@ -220,28 +235,31 @@ class Entry(Base):
def is_archived(self) -> bool:
return any(tag.id == TAG_ARCHIVED for tag in self.tags)
@property
def date_created(self) -> float | None:
return self.file_metadata.date_created if self.file_metadata else None
@property
def date_modified(self) -> float | None:
return self.file_metadata.date_modified if self.file_metadata else None
def __init__(
self,
path: Path,
fields: list[BaseField],
id: int | None = None,
date_created: dt | None = None,
date_modified: dt | None = None,
date_added: dt | None = None,
# date_created: float | None = None,
# date_modified: float | None = None,
path_for_file_metadata: Path | None = None,
) -> None:
super().__init__()
self.path = path
self.id = id # pyright: ignore[reportAttributeAccessIssue]
self.path = path
self.filename = path.name
self.suffix = path.suffix.lstrip(".").lower()
# The date the file associated with this entry was created.
# st_birthtime on Windows and Mac, st_ctime on Linux.
self.date_created = date_created
# The date the file associated with this entry was last modified: st_mtime.
self.date_modified = date_modified
# The date this entry was added to the library.
self.date_added = date_added
self.date_added = date_added # The date this entry was added to the library
for field in fields:
if isinstance(field, TextField):
@@ -251,6 +269,13 @@ class Entry(Base):
else:
raise ValueError(f"Invalid field type: {field}")
if path_for_file_metadata:
self.file_metadata = FileMetadata(
entry_id=self.id,
date_created=get_date_created(path_for_file_metadata),
date_modified=get_date_modified(path_for_file_metadata),
)
def has_tag(self, tag: Tag) -> bool:
return tag in self.tags
+12 -2
View File
@@ -8,6 +8,7 @@ from dataclasses import dataclass, field
from datetime import datetime as dt
from pathlib import Path
from time import time
import platform
import structlog
from wcmatch import pathlib
@@ -37,11 +38,13 @@ class RefreshTracker:
while index < len(self.files_not_in_library):
yield index
end = min(len(self.files_not_in_library), index + batch_size)
lib_dir = unwrap(self.library.library_dir)
entries = [
Entry(
path=entry_path,
fields=[],
date_added=dt.now(),
path_for_file_metadata=(lib_dir / entry_path),
)
for entry_path in self.files_not_in_library[index:end]
]
@@ -142,8 +145,11 @@ class RefreshTracker:
dir_file_count += 1
self.library.included_files.add(f)
if not self.library.has_entry_with_path(f):
entry_id = self.library.get_entry_id_from_path(f)
if entry_id < 0:
self.files_not_in_library.append(f)
else:
self.library.refresh_file_entry_stats(entry_id, path=f)
end_time_total = time()
yield dir_file_count
@@ -187,8 +193,12 @@ class RefreshTracker:
relative_path = f.relative_to(library_dir)
if not self.library.has_entry_with_path(relative_path):
entry_id = self.library.get_entry_id_from_path(relative_path)
if entry_id < 0:
self.files_not_in_library.append(relative_path)
else:
self.library.refresh_file_entry_stats(entry_id, path=relative_path)
except ValueError:
logger.info("[Refresh]: ValueError when refreshing directory with wcmatch!")
+16
View File
@@ -0,0 +1,16 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: MIT
import platform
from pathlib import Path
def get_date_modified(path: Path) -> float:
return path.stat().st_mtime
def get_date_created(path: Path) -> float:
if platform.system() in {"Windows", "Darwin"}:
return path.stat().st_birthtime
else:
return path.stat().st_ctime
@@ -88,6 +88,7 @@ class TagBoxWidget(TagBoxWidgetView):
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
exclusion_ids=set(build_tag_panel.exclusion_ids),
)
self.on_update.emit()
@@ -166,7 +166,10 @@ class TagSearchPanel(SearchPanel[Tag]):
if isinstance(edit_item_panel, BuildTagPanel):
tag: Tag = edit_item_panel.build_tag()
self._lib.add_tag(
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
tag,
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
exclusion_ids=edit_item_panel.exclusion_ids,
)
if choose_item:
@@ -188,6 +191,7 @@ class TagSearchPanel(SearchPanel[Tag]):
tag=edit_item_panel.build_tag(),
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
exclusion_ids=edit_item_panel.exclusion_ids,
)
self.update_items(self.layout().search_field.text())
@@ -158,7 +158,10 @@ class TagSuggestBox(SuggestBox[Tag]):
if isinstance(edit_item_panel, BuildTagPanel):
tag: Tag = edit_item_panel.build_tag()
self._lib.add_tag(
tag, parent_ids=edit_item_panel.parent_ids, aliases=edit_item_panel.aliases
tag,
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
exclusion_ids=edit_item_panel.exclusion_ids,
)
self._on_item_chosen(tag)
@@ -174,6 +177,7 @@ class TagSuggestBox(SuggestBox[Tag]):
tag=edit_item_panel.build_tag(),
parent_ids=edit_item_panel.parent_ids,
aliases=edit_item_panel.aliases,
exclusion_ids=edit_item_panel.exclusion_ids,
)
self._update_items(self.layout().search_field.text())
+181 -34
View File
@@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QFrame,
QGraphicsOpacityEffect,
QHBoxLayout,
QLabel,
QLineEdit,
@@ -38,6 +39,7 @@ from tagstudio.qt.translations import Translations
from tagstudio.qt.views.search_panel_view import SearchPanelView
from tagstudio.qt.views.stylesheets.stylesheets import (
checkbox_style,
colored_checkbox_style,
colored_radio_button_style,
get_tag_border_color,
get_tag_highlight_color,
@@ -86,9 +88,10 @@ class BuildTagPanel(ModalContent):
self.tag_color_slug: str | None
self.disambiguation_id: int | None
self.parent_ids: set[int] = set()
self.exclusion_ids: set[int] = set()
self.aliases: list[TagAlias] = []
self.setMinimumSize(300, 460)
self.setMinimumSize(300, 640)
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(6, 0, 6, 0)
self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
@@ -96,7 +99,6 @@ class BuildTagPanel(ModalContent):
# Name -----------------------------------------------------------------
self.name_widget = QWidget()
self.name_layout = QVBoxLayout(self.name_widget)
self.name_layout.setStretch(1, 1)
self.name_layout.setContentsMargins(0, 0, 0, 0)
self.name_layout.setSpacing(0)
self.name_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
@@ -111,7 +113,6 @@ class BuildTagPanel(ModalContent):
# Shorthand ------------------------------------------------------------
self.shorthand_widget = QWidget()
self.shorthand_layout = QVBoxLayout(self.shorthand_widget)
self.shorthand_layout.setStretch(1, 1)
self.shorthand_layout.setContentsMargins(0, 0, 0, 0)
self.shorthand_layout.setSpacing(0)
self.shorthand_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
@@ -123,7 +124,6 @@ class BuildTagPanel(ModalContent):
# Aliases --------------------------------------------------------------
self.aliases_widget = QWidget()
self.aliases_layout = QVBoxLayout(self.aliases_widget)
self.aliases_layout.setStretch(1, 1)
self.aliases_layout.setContentsMargins(0, 0, 0, 0)
self.aliases_layout.setSpacing(0)
self.aliases_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
@@ -144,16 +144,14 @@ class BuildTagPanel(ModalContent):
# Parent Tags ----------------------------------------------------------
self.parent_tags_widget = QWidget()
self.parent_tags_widget.setMinimumHeight(128)
self.parent_tags_layout = QVBoxLayout(self.parent_tags_widget)
self.parent_tags_layout.setStretch(1, 1)
self.parent_tags_layout.setContentsMargins(0, 0, 0, 0)
self.parent_tags_layout.setSpacing(0)
self.parent_tags_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.disam_button_group = QButtonGroup(self)
self.disam_button_group.setExclusive(False)
self.parent_tags_title = QLabel(Translations["tag.parent_tags"])
self.parent_tags_title = QLabel(header(Translations["tag.parent_tags"], 3))
self.parent_tags_layout.addWidget(self.parent_tags_title)
self.scroll_contents = QWidget()
self.parent_tags_scroll_layout = QVBoxLayout(self.scroll_contents)
@@ -184,14 +182,40 @@ class BuildTagPanel(ModalContent):
self.parent_tags_add_button.clicked.connect(self.add_tag_modal.show)
# Categories -----------------------------------------------------------
self.category_widget = QWidget()
self.category_layout = QVBoxLayout(self.category_widget)
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(header(Translations["tag.categories"], 3)))
category_subtitle = QLabel(Translations["tag.categories.subtitle"])
opacity_effect = QGraphicsOpacityEffect(self)
opacity_effect.setOpacity(0.5)
category_subtitle.setGraphicsEffect(opacity_effect)
self.category_layout.addWidget(category_subtitle)
self.category_scroll_contents = QWidget()
self.category_scroll_layout = QVBoxLayout(self.category_scroll_contents)
self.category_scroll_layout.setContentsMargins(6, 6, 6, 0)
self.category_scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
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)
self.color_layout.setStretch(1, 1)
self.color_layout.setContentsMargins(0, 0, 0, 6)
self.color_layout.setSpacing(6)
self.color_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.color_title = QLabel(Translations["tag.color"])
self.color_title = QLabel(header(Translations["tag.color"], 3))
self.color_layout.addWidget(self.color_title)
self.color_button: TagColorPreview
try:
@@ -215,7 +239,6 @@ class BuildTagPanel(ModalContent):
# Category -------------------------------------------------------------
self.cat_widget = QWidget()
self.cat_layout = QHBoxLayout(self.cat_widget)
self.cat_layout.setStretch(1, 1)
self.cat_layout.setContentsMargins(0, 0, 0, 0)
self.cat_layout.setSpacing(6)
self.cat_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
@@ -229,7 +252,6 @@ class BuildTagPanel(ModalContent):
# Hidden ---------------------------------------------------------------
self.hidden_widget = QWidget()
self.hidden_layout = QHBoxLayout(self.hidden_widget)
self.hidden_layout.setStretch(1, 1)
self.hidden_layout.setContentsMargins(0, 0, 0, 0)
self.hidden_layout.setSpacing(6)
self.hidden_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
@@ -244,16 +266,33 @@ class BuildTagPanel(ModalContent):
self.root_layout.addWidget(self.name_widget)
self.root_layout.addWidget(self.shorthand_widget)
self.root_layout.addWidget(self.aliases_widget)
self.root_layout.addWidget(self.aliases_table)
self.root_layout.addWidget(self.aliases_table, stretch=1)
self.root_layout.addWidget(self.aliases_add_button)
self.root_layout.addWidget(self.parent_tags_widget)
self._add_spaced_separator()
self.root_layout.addWidget(self.parent_tags_widget, stretch=1)
self._add_spaced_separator()
self.root_layout.addWidget(self.category_widget)
self._add_spaced_separator()
self.root_layout.addWidget(self.color_widget)
self._add_spaced_separator()
self.root_layout.addWidget(QLabel(header(Translations["tag.properties"], 3)))
self.root_layout.addWidget(self.cat_widget)
self.root_layout.addWidget(self.hidden_widget)
self.set_tag(tag or Tag(name=Translations["tag.new"]))
def _add_spaced_separator(self) -> None:
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
sep.setFrameShadow(QFrame.Shadow.Plain)
opacity_effect = QGraphicsOpacityEffect(self)
opacity_effect.setOpacity(0.1)
sep.setGraphicsEffect(opacity_effect)
self.root_layout.addSpacing(6)
self.root_layout.addWidget(sep)
self.root_layout.addSpacing(6)
def backspace(self):
focused_widget = QApplication.focusWidget()
row = self.aliases_table.rowCount()
@@ -285,10 +324,12 @@ class BuildTagPanel(ModalContent):
def _add_parent_tag_callback(self, tag_id: int):
self.parent_ids.add(tag_id)
self.set_parent_tags()
self.set_categories(added_parent_id=tag_id)
def _remove_parent_tag_callback(self, tag_id: int):
self.parent_ids.remove(tag_id)
self.set_parent_tags()
self.set_categories(removed_parent=True)
def _create_alias_callback(self):
alias = TagAlias("", tag_id=self.tag.id)
@@ -315,6 +356,127 @@ class BuildTagPanel(ModalContent):
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: bool = False):
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:
tags_by_category: dict[Tag, set[Tag]] = {}
hierarchy = set(self._lib.get_tag_hierarchy(self.parent_ids).values())
for tag in hierarchy:
if tag.is_category:
tags_by_category[tag] = set()
for tag in hierarchy:
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 = set(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 _build_category_row_widget(self, category: Tag) -> tuple[QPushButton, QCheckBox, QWidget]:
container = QWidget()
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
def update_parent_tag_callback(build_tag_panel: BuildTagPanel):
self._lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
exclusion_ids=set(build_tag_panel.exclusion_ids),
)
self.set_categories()
def on_category_edit(category_tag: Tag) -> None:
build_tag_panel = BuildTagPanel(self._lib, tag=category_tag)
edit_modal = Modal(
build_tag_panel,
self._lib.tag_display_name(category_tag),
"Edit Tag",
is_savable=True,
)
edit_modal.saved.connect(partial(update_parent_tag_callback, build_tag_panel))
edit_modal.show()
def update_category_exclusion(category_tag: Tag, checked: bool) -> None:
if checked:
self.exclusion_ids.remove(category_tag.id)
else:
self.exclusion_ids.add(category_tag.id)
# Add Tag Widget
tag_widget = TagWidget(
category,
library=self._lib,
has_edit=True,
has_remove=False,
)
tag_widget.on_edit.connect(partial(on_category_edit, category))
row.addWidget(tag_widget)
# Add Category Exclusion Tag Button
include_checkbox = QCheckBox()
include_checkbox.setFixedSize(22, 22)
include_checkbox.setToolTip(Translations["tag.categories.tooltip"])
include_checkbox.setStyleSheet(colored_checkbox_style(*self._tag_colors(category)))
if category.id not in self.exclusion_ids:
include_checkbox.setChecked(True)
include_checkbox.toggled.connect(partial(update_category_exclusion, category))
row.addWidget(include_checkbox)
return tag_widget.bg_button, include_checkbox, container
def _tag_colors(self, tag: Tag) -> tuple[QColor, QColor, QColor, QColor]:
primary_color = get_tag_primary_color(tag)
border_color = (
get_tag_border_color(primary_color)
if not (tag.color and tag.color.secondary and tag.color.color_border)
else (QColor(tag.color.secondary))
)
highlight_color = get_tag_highlight_color(
primary_color
if not (tag.color and tag.color.secondary)
else QColor(tag.color.secondary)
)
text_color: QColor
if tag.color and tag.color.secondary:
text_color = QColor(tag.color.secondary)
else:
text_color = get_tag_text_color(primary_color, highlight_color)
return primary_color, border_color, highlight_color, text_color
def set_parent_tags(self):
while self.parent_tags_scroll_layout.itemAt(0):
self.parent_tags_scroll_layout.takeAt(0).widget().deleteLater()
@@ -346,29 +508,12 @@ class BuildTagPanel(ModalContent):
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(3)
# Init Colors
primary_color = get_tag_primary_color(tag)
border_color = (
get_tag_border_color(primary_color)
if not (tag.color and tag.color.secondary and tag.color.color_border)
else (QColor(tag.color.secondary))
)
highlight_color = get_tag_highlight_color(
primary_color
if not (tag.color and tag.color.secondary)
else QColor(tag.color.secondary)
)
text_color: QColor
if tag.color and tag.color.secondary:
text_color = QColor(tag.color.secondary)
else:
text_color = get_tag_text_color(primary_color, highlight_color)
def update_parent_tag_callback(build_tag_panel: BuildTagPanel):
self._lib.update_tag(
build_tag_panel.build_tag(),
parent_ids=set(build_tag_panel.parent_ids),
aliases=set(build_tag_panel.aliases),
exclusion_ids=set(build_tag_panel.exclusion_ids),
)
self.set_parent_tags()
@@ -395,9 +540,7 @@ class BuildTagPanel(ModalContent):
disam_button.setObjectName(f"disambiguationButton.{parent_id}")
disam_button.setFixedSize(22, 22)
disam_button.setToolTip(Translations["tag.disambiguation.tooltip"])
disam_button.setStyleSheet(
colored_radio_button_style(primary_color, text_color, border_color, highlight_color)
)
disam_button.setStyleSheet(colored_radio_button_style(*self._tag_colors(tag)))
self.disam_button_group.addButton(disam_button)
if is_disambiguation:
@@ -478,6 +621,10 @@ class BuildTagPanel(ModalContent):
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
+1 -1
View File
@@ -185,7 +185,7 @@ class FieldContainers(QWidget):
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)
+1
View File
@@ -894,6 +894,7 @@ class QtDriver(DriverMixin, QObject):
panel.build_tag(),
set(panel.parent_ids),
set(panel.aliases),
set(panel.exclusion_ids),
),
self.modal.hide(),
)
@@ -118,44 +118,57 @@ def line_edit_style_main() -> str:
def checkbox_style() -> str:
"""Style used for QCheckBoxes."""
"""Style used for common QCheckBoxes."""
primary_color = QColor(get_tag_color(ColorType.PRIMARY, TagColorEnum.DEFAULT))
border_color = get_tag_border_color(primary_color)
highlight_color = get_tag_highlight_color(primary_color)
text_color: QColor = get_tag_text_color(primary_color, highlight_color)
return colored_checkbox_style(
primary_color,
get_tag_border_color(primary_color),
highlight_color,
get_tag_text_color(primary_color, highlight_color),
)
def colored_checkbox_style(
primary_color: QColor,
border_color: QColor,
highlight_color: QColor,
text_color: QColor,
) -> str:
"""Style used for QCheckBoxes."""
return f"""
QCheckBox{{
background: rgba{primary_color.toTuple()};
color: rgba{text_color.toTuple()};
border-color: rgba{border_color.toTuple()};
border-radius: 6px;
border-style: solid;
border-width: 2px;
}}
QCheckBox::indicator{{
width: 10px;
height: 10px;
border-radius: 2px;
margin: 4px;
}}
QCheckBox::indicator:checked{{
background: rgba{text_color.toTuple()};
}}
QCheckBox::hover{{
border-color: rgba{highlight_color.toTuple()};
}}
QCheckBox::focus{{
border-color: rgba{highlight_color.toTuple()};
outline: none;
}}
"""
QCheckBox{{
background: rgba{primary_color.toTuple()};
color: rgba{text_color.toTuple()};
border-color: rgba{border_color.toTuple()};
border-radius: 6px;
border-style: solid;
border-width: 2px;
}}
QCheckBox::indicator{{
width: 10px;
height: 10px;
border-radius: 2px;
margin: 4px;
}}
QCheckBox::indicator:checked{{
background: rgba{text_color.toTuple()};
}}
QCheckBox::hover{{
border-color: rgba{highlight_color.toTuple()};
}}
QCheckBox::focus{{
border-color: rgba{highlight_color.toTuple()};
outline: none;
}}
"""
def colored_radio_button_style(
primary_color: QColor,
text_color: QColor,
border_color: QColor,
highlight_color: QColor,
text_color: QColor,
) -> str:
return f"""
QRadioButton{{
@@ -386,6 +386,9 @@
"tag.add.plural": "Add Tags",
"tag.aliases": "Aliases",
"tag.all_tags": "All Tags",
"tag.categories": "Category Visibility",
"tag.categories.subtitle": "Inherited from Parent Tags",
"tag.categories.tooltip": "Show tag in this category",
"tag.choose_color": "Choose Tag Color",
"tag.color": "Color",
"tag.confirm_delete": "Are you sure you want to delete the tag \"{tag_name}\"?",
Binary file not shown.
+312
View File
@@ -4,13 +4,16 @@
# pyright: reportPrivateUsage = false
from collections.abc import Callable
from typing import cast
from PySide6.QtWidgets import QCheckBox
from pytestqt.qtbot import QtBot
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Tag, TagAlias
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.mixed.build_tag import BuildTagPanel, CustomTableItem
from tagstudio.qt.mixed.tag_widget import TagWidget
from tagstudio.qt.translations import Translations
@@ -171,3 +174,312 @@ 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 test_build_tag_panel_remove_inherited_from_multiple_parents_during_tag_creation(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
child1 = unwrap(library.add_tag(generate_tag("child1", id=124, parent_tags={parent})))
child2 = unwrap(library.add_tag(generate_tag("child2", id=125, parent_tags={parent})))
panel: BuildTagPanel = BuildTagPanel(library)
qtbot.addWidget(panel)
panel._add_parent_tag_callback(124)
panel._add_parent_tag_callback(125)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
panel._remove_parent_tag_callback(child1.id)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
panel._remove_parent_tag_callback(child2.id)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is None
def test_build_tag_panel_add_different_category_after_removing_other_category(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
category = unwrap(library.add_tag(generate_tag("category", id=123, is_category=True)))
tag = unwrap(library.add_tag(generate_tag("tag", id=124, parent_tags={category})))
other = unwrap(library.add_tag(generate_tag("other", id=125)))
panel: BuildTagPanel = BuildTagPanel(library, tag)
qtbot.addWidget(panel)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
panel._remove_parent_tag_callback(category.id)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is None
panel._add_parent_tag_callback(other.id)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is None
def test_build_tag_panel_remove_category_inherited_directly_and_indirectly(
qtbot: QtBot, library: Library, generate_tag: Callable[..., Tag]
):
parent = unwrap(library.add_tag(generate_tag("parent", id=123, is_category=True)))
child = unwrap(library.add_tag(generate_tag("child", id=124, parent_tags={parent})))
grandchild = unwrap(
library.add_tag(generate_tag("grandchild", id=125, parent_tags={parent, child}))
)
panel: BuildTagPanel = BuildTagPanel(library, grandchild)
qtbot.addWidget(panel)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
panel._remove_parent_tag_callback(parent.id)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is not None
panel._remove_parent_tag_callback(child.id)
tag_widget = __find_category_tag_widget(panel)
assert tag_widget is None
def __find_category_tag_widget(panel: BuildTagPanel, index: int = 0) -> TagWidget | None:
item = panel.category_scroll_layout.itemAt(0).widget().layout().itemAt(index)
while item is not None:
if isinstance(item.widget(), TagWidget):
break
item = item.widget().layout().itemAt(0)
if item is not None:
return cast(TagWidget, 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
+27 -1
View File
@@ -1,8 +1,11 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from collections.abc import Callable
from pathlib import Path
from tagstudio.core.library.alchemy.library import Library
# pyright: reportPrivateUsage=false
from tagstudio.core.library.alchemy.models import Entry, Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.preview_panel_controller import PreviewPanel
@@ -182,3 +185,26 @@ def test_custom_tag_category(qt_driver: QtDriver, entry_full: Entry):
assert container.title != "<h4>Tags</h4>"
case _:
pass
def test_exclude_tag_category(
qt_driver: QtDriver, library: Library, generate_tag: Callable[..., Tag]
):
panel = PreviewPanel(qt_driver)
category_parent = unwrap(generate_tag("category_parent", id=123, is_category=True))
library.add_tag(category_parent)
tag = unwrap(generate_tag("tag", id=124))
library.add_tag(tag, parent_ids={category_parent.id}, exclusion_ids={category_parent.id})
entry = Entry(id=777, path=Path("test.txt"), fields=[])
library.add_entries([entry])
library.add_tags_to_entries(entry.id, tag.id)
qt_driver.toggle_item_selection(entry.id, append=False, bridge=False)
panel.set_selection(qt_driver.selected)
assert len(panel.containers._containers) == 1
assert panel.containers._containers[0].title == "<h4>Tags</h4>"
+4 -4
View File
@@ -80,9 +80,9 @@ def test_library_add_file(library: Library):
fields=[TextField(name="Title", value="I'm a Test Title")],
)
assert not library.has_entry_with_path(entry.path)
assert not library.get_entry_id_from_path(entry.path)
assert library.add_entries([entry])
assert library.has_entry_with_path(entry.path)
assert library.get_entry_id_from_path(entry.path)
def test_create_tag(library: Library, generate_tag: Callable[..., Tag]):
@@ -338,8 +338,8 @@ def test_merge_entries(library: Library):
entry_b_: Entry = unwrap(library.get_entry_full(entry_b_id))
assert library.merge_entries(entry_a_, entry_b_)
assert not library.has_entry_with_path(Path("a"))
assert library.has_entry_with_path(Path("b"))
assert not library.get_entry_id_from_path(Path("a"))
assert library.get_entry_id_from_path(Path("b"))
entry_b_merged = unwrap(library.get_entry_full(entry_b_id))