Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
38 changes: 38 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,38 @@
# 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, QPersistentModelIndex, QSize
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 | QPersistentModelIndex,
) -> 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( # noqa: N802
self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex
) -> QSize:
"""Return the size hint for the item."""
# For now, use default size - we'll enhance this later
return super().sizeHint(option, index)
Loading
Loading