-
-
Notifications
You must be signed in to change notification settings - Fork 436
feat: Add hierarchical tag grouping with collapsible headers #1247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Groups thumbnails using collapsible groups based on tag selection. Thumbnails within the groups respect the user's other sorting choices. Features: - Hierarchical tag dropdown with grouping of parent/children tags in a table view so large amounts of tags can be handled (hopefully) - Collapsible/expandable groups with count badges in thumbnail view - The tags that act as headers respect the user's configuration and use the tags' configured colors - Auto-refreshes the thumbnail pane and group-by-tag dropdown on tag creation/modification no matter if it was through the tag manager or the right-hand pane - Works with all existing search/filter operations Core Library Changes: - Add `group_entries_by_tag()` method to generate a tag hierarchy - Add `get_grouping_tag_ids()` to fetch grouping tags - Extend `BrowsingState` with `group_by_tag_id` field - Add `GroupedSearchResult` dataclass with `TagGroup` hierarchy UI Components: - Added a new dropdown in main toolbar for tag selection to sort by. This works with parent tags so multiple tag groups are created in the thumbnail view. If an item has multiple sibling tags, it'll be put under a "Multiple Tags" grouping. - Implemented `GroupHeaderWidget` to create collapsible group headers - Extended `ThumbGridLayout` to render hierarchical grouped entries - Updated `update_browsing_state()` to handle grouped results Files Added: - src/tagstudio/qt/mixed/group_header.py - src/tagstudio/qt/mixed/group_by_tag_delegate.py Note: The QT components and the hierarchy generation were written with tool-assistance (Claude Code 4.5)
I got overzealous in updating the UI by starting to update the search element to handle a multi-columned layout. This isn't necessary for this feature, so I'm reverting it to main.
src/tagstudio/qt/ts_qt.py
Outdated
| block_signals: If True, block signals during population. | ||
| """ | ||
| if block_signals: | ||
| self.main_window.group_by_tag_combobox.blockSignals(True) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a QT thing so I would suggest adding the method to the ignore list for this rule.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added a quick check for Mock in the new code to prevent the math issues that occurred. I also went back and fixed up the linting issues caused by QT convention.
Added a check for Mock to the new code to handle tests to make sure they don't fail. Re-ran mypy and ruff. Fixes: - Added type check for view.columnWidth() to handle Mock objects in tests - Fixed test_title_update failures (There was a TypeError with Mock math) - Fixed test_add_tag_callback Qt event loop errors - Removed unused imports from group_by_tag_delegate.py (QRect, Qt) - Added type signatures for Qt method overrides (QModelIndex | QPersistentModelIndex) - Added linter suppressions for Qt API conventions (noqa: N802, FBT003) Changes: - src/tagstudio/qt/ts_qt.py: Added isinstance() check for Mock - src/tagstudio/qt/mixed/group_by_tag_delegate.py: Fixed imports and type signatures
|
Sorry for the churn. I missed the 'ruff format' command the pipeline was wanting. I'm not sure what the details are of the other CI/CD commands since they're GitHub hooks (or whatever the pre-build things are called), so hopefully I won't cause too much more pain! |
|
Instead of "Multiple Tags", I think it might be more useful to have the entry appear under each group it belongs to |
|
Also, if it's at all possible, it would be nice if the grouping functionality was more generic to allow for other types of grouping to be added in the future, such as for #68 |
Like a section that shows the common tags per sibling in the collection, or just duplicate a thumbnail into each group of the siblings where it's appropriate? I tested the first option where, like in the examples one thumbnail has "red" "green", etc. and generated a group per present sibling combination. It got pretty overwhelming and (I felt) cluttered. |
I was looking at that, but wasn't sure how to approach it, architecture-wise. Would we use something like invisible tags to do that? (e.g., a case-insensitive file type tag)? I'm happy to work on it, but didn't want to make big decisions without chatting first. |
Duplicate the thumbnails, similar to how tag categories function. Each group would sort of function as if you were searching for that tag. This is up to you though, this is just how I would personally prefer it to function. |
I can do that pretty easily. I was doing it like that in my first round of testing. I guess we could also make it a user option. Not sure how much more code complexity that would add, though. |
I can try taking a look at it once I get the chance, I haven't looked to closely into how you've handled grouping yet |
Rewrote the grouping system to make it extensible using grouping "strategies" like file extensions, file sizes, tags (already here), or others I haven't thought of yet, ## Changes - Added GroupingType enum, GroupingCriteria, and EntryGroup abstractions - Created TagGroupingStrategy and FiletypeGroupingStrategy - Updated BrowsingState to use GroupingCriteria instead of group_by_tag_id - Added strategy registry in Library class for future grouping strats ## Group-by-tag changes: - Tag grouping duplicates entries across multiple child tag groups insteaad of using a "Multiple Tags" catch-all. - Added "By Filetype" grouping option - Group headers now support generic group types (tags, filetypes, etc.) and will jam a separator between custom strategies and tags. - Empty child tag groups are now hidden instead of being placed in the "No Tag" group ## New stuff: - src/tagstudio/core/library/alchemy/grouping.py - Core grouping strat abstractions - src/tagstudio/core/library/alchemy/strategies.py - Grouping Implementations Note: I used ClaudeCode 4.5 to help out with the QT code.
|
Moved this back in to draft since I don't want to trigger the CI/CDD pipeline needlessly. Changes from last push
Feature Planning / ImplementationI've attempted to genericise the tag sorting code to support multiple grouping strategies. I've tested this successfully on my local system and I think I'm ready for feedback. I also made Claude come up with an example of adding a new strategy. It chose a group-by-date strategy (I haven't tested it because it doesn't account for locales or translations, but it's a solid example): ClaudeCode 4.5 Example (Untested) follows This example shows how to add a "Group by Date" feature that organizes entries by creation date. Step 1: Add Enum ValueEdit class GroupingType(Enum):
"""Types of grouping strategies available."""
NONE = "none"
TAG = "tag"
FILETYPE = "filetype"
DATE = "date" # Add thisStep 2: Implement StrategyCreate or edit from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import select
from sqlalchemy.orm import Session
from tagstudio.core.library.alchemy.fields import FieldID
from tagstudio.core.library.alchemy.grouping import (
EntryGroup,
GroupedSearchResult,
GroupingCriteria,
GroupingStrategy,
)
from tagstudio.core.library.alchemy.models import Entry, DatetimeField
if TYPE_CHECKING:
from tagstudio.core.library.alchemy.library import Library
class DateGroupingStrategy(GroupingStrategy):
"""Groups entries by creation date (year-month)."""
def group_entries(
self, lib: "Library", entry_ids: list[int], criteria: GroupingCriteria
) -> GroupedSearchResult:
"""Group entries by creation date.
Args:
lib: Library instance.
entry_ids: List of entry IDs to group.
criteria: Grouping criteria (value can specify granularity).
Returns:
GroupedSearchResult with entries organized by date.
"""
if not entry_ids:
return GroupedSearchResult(total_count=0, groups=[])
# Load entries with datetime fields
with Session(lib.engine) as session:
stmt = (
select(Entry)
.outerjoin(Entry.datetime_fields)
.where(Entry.id.in_(entry_ids))
)
entries = session.scalars(stmt).unique().all()
# Group by year-month
date_groups: dict[str, list[int]] = {}
no_date_entries: list[int] = []
for entry in entries:
# Find creation date field
creation_date = None
for field in entry.datetime_fields:
if field.type_key == FieldID.DATE_CREATED:
creation_date = field.value
break
if creation_date:
# Format as "YYYY-MM"
date_key = creation_date.strftime("%Y-%m")
date_groups.setdefault(date_key, []).append(entry.id)
else:
no_date_entries.append(entry.id)
# Create EntryGroup objects sorted by date
groups = [
EntryGroup(key=date_key, entry_ids=ids)
for date_key, ids in sorted(date_groups.items(), reverse=True)
]
# Add "No Date" group
if no_date_entries:
groups.append(
EntryGroup(
key=None,
entry_ids=no_date_entries,
is_special=True,
special_label="No Date",
)
)
return GroupedSearchResult(total_count=len(entry_ids), groups=groups)
def get_display_name(self, group: EntryGroup) -> str:
"""Get display name for a date group.
Args:
group: The entry group.
Returns:
Human-readable date or special label.
"""
if group.is_special and group.special_label:
return group.special_label
# Parse YYYY-MM and format nicely
try:
date_obj = datetime.strptime(str(group.key), "%Y-%m")
return date_obj.strftime("%B %Y") # e.g., "January 2025"
except (ValueError, AttributeError):
return str(group.key)Step 3: Register StrategyEdit from tagstudio.core.library.alchemy.strategies import (
TagGroupingStrategy,
FiletypeGroupingStrategy,
DateGroupingStrategy, # Add import
)
class Library:
def __init__(self, folder_path: Path | str):
# ... existing code ...
# Initialize grouping strategies
self._grouping_strategies = {
GroupingType.TAG: TagGroupingStrategy(),
GroupingType.FILETYPE: FiletypeGroupingStrategy(),
GroupingType.DATE: DateGroupingStrategy(), # Register here
}Step 4: Add UI IntegrationTo expose the new grouping in the UI, edit def populate_group_by_options(self):
"""Populate the group-by dropdown with available options."""
self.main_window.group_by_combobox.clear()
# Add standard options
self.main_window.group_by_combobox.addItem("None", GroupingType.NONE)
self.main_window.group_by_combobox.addItem("By Filetype", GroupingType.FILETYPE)
self.main_window.group_by_combobox.addItem("By Date", GroupingType.DATE) # Add this
# Add tag-based grouping options
# ... existing code ...Key ConceptsGroupingStrategy InterfaceAll strategies must implement:
EntryGroup StructureGroups contain:
GroupingCriteriaSpecifies what to group by:
Advanced Example: Custom MetadataStrategies can attach metadata to groups for UI rendering: def group_entries(self, lib, entry_ids, criteria):
# ... grouping logic ...
groups.append(
EntryGroup(
key=tag,
entry_ids=tagged_entries,
metadata={
"color": tag.color,
"icon": "📁",
"sort_order": tag.priority,
}
)
)
return GroupedSearchResult(total_count=len(entry_ids), groups=groups)TestingAdd tests in def test_date_grouping_strategy():
"""Test date-based grouping."""
strategy = DateGroupingStrategy()
# Create test data
lib = create_test_library()
entry_ids = [1, 2, 3]
criteria = GroupingCriteria(type=GroupingType.DATE)
# Test grouping
result = strategy.group_entries(lib, entry_ids, criteria)
assert len(result.groups) > 0
assert result.total_count == 3Performance Considerations
TODO
|
Adds a feature to group thumbnails in the main window by a tag or by a parent tag.
Grouped thumbnails in each section respect the user's other sorting choices (filename/date created, sort direction, etc.)
Features:
Core Library Changes:
group_entries_by_tag()method to generate a tag hierarchyget_grouping_tag_ids()to fetch grouping tagsBrowsingStatewithgroup_by_tag_idfieldGroupedSearchResultdataclass withTagGrouphierarchyUI Components:
GroupHeaderWidgetto create collapsible group headersThumbGridLayoutto render hierarchical grouped entriesupdate_browsing_state()to handle grouped resultsFiles Added:
Notes:
black -l100, mypy, and ruff. I did my best to stick to the existing coding style.delete_tag()only containedpass. I implemented a fulldelete_tag()method to fix this so the sort-by-tag dropdown would be able to automatically update correctly.Summary
Adds a feature to group thumbnails in the main window by a single tag or by a parent tag.
Grouped thumbnails in each section respect the user's other sorting choices (filename/date created, sort direction, etc.)
Tasks Completed
Screenshots
New Toolbar Dropdown
Selecting a Parent Tag to Group By
Sorting complete
Example Item with Multiple Sibling Tags