Skip to content
Draft
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
18 changes: 18 additions & 0 deletions src/tagstudio/core/library/alchemy/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
import random
from dataclasses import dataclass, replace
from pathlib import Path
from typing import TYPE_CHECKING

import structlog

from tagstudio.core.query_lang.ast import AST
from tagstudio.core.query_lang.parser import Parser

if TYPE_CHECKING:
from tagstudio.core.library.alchemy.grouping import GroupingCriteria

MAX_SQL_VARIABLES = 32766 # 32766 is the max sql bind parameter count as defined here: https://github.com/sqlite/sqlite/blob/master/src/sqliteLimit.h#L140

logger = structlog.get_logger(__name__)
Expand Down Expand Up @@ -86,6 +90,9 @@ class BrowsingState:

query: str | None = None

# Grouping criteria (None = no grouping)
grouping: "GroupingCriteria | None" = None

# Abstract Syntax Tree Of the current Search Query
@property
def ast(self) -> AST | None:
Expand Down Expand Up @@ -152,6 +159,17 @@ 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_grouping(self, criteria: "GroupingCriteria | None") -> "BrowsingState":
return replace(self, grouping=criteria)

def with_group_by_tag(self, tag_id: int | None) -> "BrowsingState":
"""Backward compatibility wrapper for tag grouping."""
from tagstudio.core.library.alchemy.grouping import GroupingCriteria, GroupingType

if tag_id is None:
return replace(self, grouping=None)
return replace(self, grouping=GroupingCriteria(type=GroupingType.TAG, value=tag_id))


class FieldTypeEnum(enum.Enum):
TEXT_LINE = "Text Line"
Expand Down
115 changes: 115 additions & 0 deletions src/tagstudio/core/library/alchemy/grouping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio


"""Grouping strategies for organizing library entries."""

from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any

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


class GroupingType(Enum):
"""Types of grouping strategies available."""

NONE = "none"
TAG = "tag"
FILETYPE = "filetype"


@dataclass(frozen=True)
class GroupingCriteria:
"""Defines what to group by.

Attributes:
type: The type of grouping to apply.
value: Optional value for the grouping (e.g., tag_id for TAG type).
"""

type: GroupingType
value: Any | None = None


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

Attributes:
key: The grouping key (Tag object, filetype string, etc.).
entry_ids: List of entry IDs in this group.
is_special: Whether this is a special group (e.g., "No Tag").
special_label: Label for special groups.
metadata: Flexible metadata dict for group-specific data.
"""

key: Any
entry_ids: list[int]
is_special: bool = False
special_label: str | None = None
metadata: dict[str, Any] | None = None


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

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

total_count: int
groups: list[EntryGroup]

@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


class GroupingStrategy(ABC):
"""Abstract base class for grouping implementations."""

@abstractmethod
def group_entries(
self, lib: "Library", entry_ids: list[int], criteria: GroupingCriteria
) -> GroupedSearchResult:
"""Group entries according to criteria.

Args:
lib: Library instance.
entry_ids: List of entry IDs to group.
criteria: Grouping criteria.

Returns:
GroupedSearchResult with entries organized into EntryGroup objects.
"""
pass

@abstractmethod
def get_display_name(self, group: EntryGroup) -> str:
"""Get display name for a group.

Args:
group: The entry group.

Returns:
Human-readable name for the group.
"""
pass
66 changes: 66 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 All @@ -90,6 +91,11 @@
FieldID,
TextField,
)
from tagstudio.core.library.alchemy.grouping import (
GroupedSearchResult,
GroupingCriteria,
GroupingType,
)
from tagstudio.core.library.alchemy.joins import TagEntry, TagParent
from tagstudio.core.library.alchemy.models import (
Entry,
Expand All @@ -102,6 +108,10 @@
ValueType,
Version,
)
from tagstudio.core.library.alchemy.strategies import (
FiletypeGroupingStrategy,
TagGroupingStrategy,
)
from tagstudio.core.library.alchemy.visitors import SQLBoolExpressionBuilder
from tagstudio.core.library.json.library import Library as JsonLibrary
from tagstudio.core.utils.types import unwrap
Expand Down Expand Up @@ -228,6 +238,12 @@ def __init__(self) -> None:
self.ignored_entries_count: int = -1
self.unlinked_entries_count: int = -1

# Initialize grouping strategies
self._grouping_strategies = {
GroupingType.TAG: TagGroupingStrategy(),
GroupingType.FILETYPE: FiletypeGroupingStrategy(),
}

def close(self):
if self.engine:
self.engine.dispose()
Expand Down Expand Up @@ -907,6 +923,56 @@ 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(
self, entry_ids: list[int], criteria: GroupingCriteria
) -> GroupedSearchResult:
"""Group entries according to criteria.

Args:
entry_ids: List of entry IDs to group.
criteria: Grouping criteria.

Returns:
GroupedSearchResult with entries organized into EntryGroup objects.
"""
if criteria.type == GroupingType.NONE:
return GroupedSearchResult(total_count=len(entry_ids), groups=[])

strategy = self._grouping_strategies.get(criteria.type)
if strategy is None:
logger.warning("Unknown grouping type", type=criteria.type)
return GroupedSearchResult(total_count=len(entry_ids), groups=[])

return strategy.group_entries(self, entry_ids, criteria)

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

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 EntryGroup objects.
"""
criteria = GroupingCriteria(type=GroupingType.TAG, value=group_by_tag_id)
return self.group_entries(entry_ids, criteria)

@property
def entries_count(self) -> int:
with Session(self.engine) as session:
Expand Down
2 changes: 1 addition & 1 deletion src/tagstudio/core/library/alchemy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pathlib import Path
from typing import override

from sqlalchemy import JSON, ForeignKey, ForeignKeyConstraint, Integer, event
from sqlalchemy import JSON, ForeignKey, ForeignKeyConstraint, Index, Integer, event
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing_extensions import deprecated

Expand Down
Loading