Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/tagstudio/core/library/alchemy/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ class BrowsingState:

query: str | None = None

# Tag-based grouping (None = no grouping, int = group by tag ID)
group_by_tag_id: int | None = None

# Abstract Syntax Tree Of the current Search Query
@property
def ast(self) -> AST | None:
Expand Down Expand Up @@ -152,6 +155,9 @@ def with_search_query(self, search_query: str) -> "BrowsingState":
def with_show_hidden_entries(self, show_hidden_entries: bool) -> "BrowsingState":
return replace(self, show_hidden_entries=show_hidden_entries)

def with_group_by_tag(self, tag_id: int | None) -> "BrowsingState":
return replace(self, group_by_tag_id=tag_id)


class FieldTypeEnum(enum.Enum):
TEXT_LINE = "Text Line"
Expand Down
151 changes: 151 additions & 0 deletions src/tagstudio/core/library/alchemy/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
DB_VERSION_LEGACY_KEY,
JSON_FILENAME,
SQL_FILENAME,
TAG_CHILDREN_ID_QUERY,
TAG_CHILDREN_QUERY,
)
from tagstudio.core.library.alchemy.db import make_tables
Expand Down Expand Up @@ -202,6 +203,54 @@ def __getitem__(self, index: int) -> int:
return self.ids[index]


@dataclass(frozen=True)
class TagGroup:
"""Represents a group of entries sharing a tag.

Attributes:
tag: The tag for this group (None for special groups).
entry_ids: List of entry IDs in this group.
is_special: Whether this is a special group (Multiple Tags, No Tag).
special_label: Label for special groups ("Multiple Tags" or "No Tag").
tags: Multiple tags for multi-tag combination groups (None for others).
"""

tag: "Tag | None"
entry_ids: list[int]
is_special: bool = False
special_label: str | None = None
tags: list["Tag"] | None = None


@dataclass(frozen=True)
class GroupedSearchResult:
"""Wrapper for grouped search results.

Attributes:
total_count: Total number of entries across all groups.
groups: List of TagGroup objects.
"""

total_count: int
groups: list[TagGroup]

@property
def all_entry_ids(self) -> list[int]:
"""Flatten all entry IDs from all groups for backward compatibility."""
result: list[int] = []
for group in self.groups:
result.extend(group.entry_ids)
return result

def __bool__(self) -> bool:
"""Boolean evaluation for the wrapper."""
return self.total_count > 0

def __len__(self) -> int:
"""Return the total number of entries across all groups."""
return self.total_count


@dataclass
class LibraryStatus:
"""Keep status of library opening operation."""
Expand Down Expand Up @@ -907,6 +956,108 @@ def get_tag_entries(
tag_entries[tag_entry.tag_id].add(tag_entry.entry_id)
return tag_entries

def get_grouping_tag_ids(self, group_by_tag_id: int) -> set[int]:
"""Get all tag IDs relevant to a grouping.

Args:
group_by_tag_id: The tag ID to get related tags for.

Returns:
Set of tag IDs including the tag and all its children.
"""
with Session(self.engine) as session:
result = session.execute(TAG_CHILDREN_ID_QUERY, {"tag_id": group_by_tag_id})
return set(row[0] for row in result)

def group_entries_by_tag(
self, entry_ids: list[int], group_by_tag_id: int
) -> GroupedSearchResult:
"""Group entries by tag hierarchy.

Args:
entry_ids: List of entry IDs to group.
group_by_tag_id: The tag ID to group by.

Returns:
GroupedSearchResult with entries organized into TagGroup objects.
"""
if not entry_ids:
return GroupedSearchResult(total_count=0, groups=[])

with Session(self.engine) as session:
result = session.execute(TAG_CHILDREN_ID_QUERY, {"tag_id": group_by_tag_id})
child_tag_ids = [row[0] for row in result]

tags_by_id: dict[int, Tag] = {}
with Session(self.engine) as session:
for tag in session.scalars(select(Tag).where(Tag.id.in_(child_tag_ids))):
tags_by_id[tag.id] = tag

tag_to_entries = self.get_tag_entries(child_tag_ids, entry_ids)

entry_to_tags: dict[int, list[int]] = {entry_id: [] for entry_id in entry_ids}
for tag_id, entries_with_tag in tag_to_entries.items():
for entry_id in entries_with_tag:
entry_to_tags[entry_id].append(tag_id)

tag_groups: dict[int, list[int]] = {}
multi_tag_groups: dict[tuple[int, ...], list[int]] = {}
no_tag_entries: list[int] = []

for entry_id in entry_ids:
tag_count = len(entry_to_tags[entry_id])
if tag_count == 0:
no_tag_entries.append(entry_id)
elif tag_count == 1:
tag_id = entry_to_tags[entry_id][0]
if tag_id not in tag_groups:
tag_groups[tag_id] = []
tag_groups[tag_id].append(entry_id)
else:
tag_tuple = tuple(sorted(entry_to_tags[entry_id]))
if tag_tuple not in multi_tag_groups:
multi_tag_groups[tag_tuple] = []
multi_tag_groups[tag_tuple].append(entry_id)

groups: list[TagGroup] = []

sorted_tag_ids = sorted(tag_groups.keys(), key=lambda tid: tags_by_id[tid].name.lower())
for tag_id in sorted_tag_ids:
groups.append(
TagGroup(
tag=tags_by_id[tag_id],
entry_ids=tag_groups[tag_id],
is_special=False,
)
)

if multi_tag_groups:
all_multi_tag_entries: list[int] = []
for entries in multi_tag_groups.values():
all_multi_tag_entries.extend(entries)

groups.append(
TagGroup(
tag=None,
entry_ids=all_multi_tag_entries,
is_special=True,
special_label="Multiple Tags",
tags=None,
)
)

if no_tag_entries:
groups.append(
TagGroup(
tag=None,
entry_ids=no_tag_entries,
is_special=True,
special_label="No Tag",
)
)

return GroupedSearchResult(total_count=len(entry_ids), groups=groups)

@property
def entries_count(self) -> int:
with Session(self.engine) as session:
Expand Down
1 change: 1 addition & 0 deletions src/tagstudio/core/library/ignore.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
".Spotlight-V100",
".TemporaryItems",
"desktop.ini",
"Thumbs.db",
"System Volume Information",
".localized",
]
Expand Down
3 changes: 3 additions & 0 deletions src/tagstudio/qt/controllers/preview_panel_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
class PreviewPanel(PreviewPanelView):
def __init__(self, library: Library, driver: "QtDriver"):
super().__init__(library, driver)
self.__driver = driver

self.__add_field_modal = AddFieldModal(self.lib)
self.__add_tag_modal = TagSearchModal(self.lib, is_tag_chooser=True)
Expand All @@ -26,6 +27,8 @@ def _add_field_button_callback(self):
self.__add_field_modal.show()

def _add_tag_button_callback(self):
# Set driver before showing to enable dropdown refresh when creating tags
self.__add_tag_modal.tsp.driver = self.__driver
self.__add_tag_modal.show()

def _set_selection_callback(self):
Expand Down
6 changes: 6 additions & 0 deletions src/tagstudio/qt/controllers/tag_box_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ def _on_remove(self, tag: Tag) -> None: # type: ignore[misc]
for entry_id in self.__entries:
self.__driver.lib.remove_tags_from_entries(entry_id, tag.id)

group_by_tag_id = self.__driver.browsing_history.current.group_by_tag_id
if group_by_tag_id is not None:
relevant_tag_ids = self.__driver.lib.get_grouping_tag_ids(group_by_tag_id)
if tag.id in relevant_tag_ids:
self.__driver.update_browsing_state()

self.on_update.emit()

@override
Expand Down
6 changes: 6 additions & 0 deletions src/tagstudio/qt/mixed/field_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,12 @@ def add_tags_to_selected(self, tags: int | list[int]):
)
self.driver.emit_badge_signals(tags, emit_on_absent=False)

group_by_tag_id = self.driver.browsing_history.current.group_by_tag_id
if group_by_tag_id is not None:
relevant_tag_ids = self.lib.get_grouping_tag_ids(group_by_tag_id)
if any(tag_id in relevant_tag_ids for tag_id in tags):
self.driver.update_browsing_state()

def write_container(self, index: int, field: BaseField, is_mixed: bool = False):
"""Update/Create data for a FieldContainer.

Expand Down
33 changes: 33 additions & 0 deletions src/tagstudio/qt/mixed/group_by_tag_delegate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio


from typing import TYPE_CHECKING

from PySide6.QtCore import QModelIndex, QRect, QSize, Qt

Check failure on line 8 in src/tagstudio/qt/mixed/group_by_tag_delegate.py

View workflow job for this annotation

GitHub Actions / Run Ruff check

Ruff (F401)

src/tagstudio/qt/mixed/group_by_tag_delegate.py:8:55: F401 `PySide6.QtCore.Qt` imported but unused

Check failure on line 8 in src/tagstudio/qt/mixed/group_by_tag_delegate.py

View workflow job for this annotation

GitHub Actions / Run Ruff check

Ruff (F401)

src/tagstudio/qt/mixed/group_by_tag_delegate.py:8:41: F401 `PySide6.QtCore.QRect` imported but unused
from PySide6.QtGui import QPainter
from PySide6.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem

if TYPE_CHECKING:
from tagstudio.core.library.alchemy.library import Library


class GroupByTagDelegate(QStyledItemDelegate):
"""Custom delegate for rendering tags in the Group By dropdown with decorations."""

def __init__(self, library: "Library", parent=None):
super().__init__(parent)
self.library = library

def paint(
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex

Check failure on line 24 in src/tagstudio/qt/mixed/group_by_tag_delegate.py

View workflow job for this annotation

GitHub Actions / Run MyPy

[mypy] reported by reviewdog 🐶 Argument 3 of "paint" is incompatible with supertype "QAbstractItemDelegate"; supertype defines the argument type as "QModelIndex | QPersistentModelIndex" [override] Raw Output: /home/runner/work/TagStudio/TagStudio/src/tagstudio/qt/mixed/group_by_tag_delegate.py:24:64: error: Argument 3 of "paint" is incompatible with supertype "QAbstractItemDelegate"; supertype defines the argument type as "QModelIndex | QPersistentModelIndex" [override]

Check failure on line 24 in src/tagstudio/qt/mixed/group_by_tag_delegate.py

View workflow job for this annotation

GitHub Actions / Run MyPy

[mypy] reported by reviewdog 🐶 Argument 3 of "paint" is incompatible with supertype "QStyledItemDelegate"; supertype defines the argument type as "QModelIndex | QPersistentModelIndex" [override] Raw Output: /home/runner/work/TagStudio/TagStudio/src/tagstudio/qt/mixed/group_by_tag_delegate.py:24:64: error: Argument 3 of "paint" is incompatible with supertype "QStyledItemDelegate"; supertype defines the argument type as "QModelIndex | QPersistentModelIndex" [override]
) -> None:
"""Paint the tag item with proper decorations."""
# For now, use default painting - we'll enhance this later
super().paint(painter, option, index)

def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:

Check failure on line 30 in src/tagstudio/qt/mixed/group_by_tag_delegate.py

View workflow job for this annotation

GitHub Actions / Run Ruff check

Ruff (N802)

src/tagstudio/qt/mixed/group_by_tag_delegate.py:30:9: N802 Function name `sizeHint` should be lowercase

Check failure on line 30 in src/tagstudio/qt/mixed/group_by_tag_delegate.py

View workflow job for this annotation

GitHub Actions / Run MyPy

[mypy] reported by reviewdog 🐶 Argument 2 of "sizeHint" is incompatible with supertype "QAbstractItemDelegate"; supertype defines the argument type as "QModelIndex | QPersistentModelIndex" [override] Raw Output: /home/runner/work/TagStudio/TagStudio/src/tagstudio/qt/mixed/group_by_tag_delegate.py:30:54: error: Argument 2 of "sizeHint" is incompatible with supertype "QAbstractItemDelegate"; supertype defines the argument type as "QModelIndex | QPersistentModelIndex" [override]

Check failure on line 30 in src/tagstudio/qt/mixed/group_by_tag_delegate.py

View workflow job for this annotation

GitHub Actions / Run MyPy

[mypy] reported by reviewdog 🐶 Argument 2 of "sizeHint" is incompatible with supertype "QStyledItemDelegate"; supertype defines the argument type as "QModelIndex | QPersistentModelIndex" [override] Raw Output: /home/runner/work/TagStudio/TagStudio/src/tagstudio/qt/mixed/group_by_tag_delegate.py:30:54: error: Argument 2 of "sizeHint" is incompatible with supertype "QStyledItemDelegate"; supertype defines the argument type as "QModelIndex | QPersistentModelIndex" [override]
"""Return the size hint for the item."""
# For now, use default size - we'll enhance this later
return super().sizeHint(option, index)
Loading
Loading