Skip to content
Documentation GitHub
Content

Tag System

Status: Implemented Depends On: Page System


The Tag System provides categorization and filtering for workspace pages. Tags are named labels with optional colors and grouping, applied to pages via a many-to-many join table (page_tags). Tags are integrated into FTS5 full-text search as a third column alongside title and content, with BM25 weight boosting (10:1:5 ratio).

The system supports tag groups for organizing related tags, tag merging for consolidation, and sidebar AND/OR filtering for multi-tag page discovery. Tags also serve as the backend for the tags property in the property system — frontend writes to the “tags” property slug are routed to tag use cases via Tauri command routing.



Framework (Tauri)
└── Tauri commands apps/desktop/src-tauri/src/commands/
create_tag, update_tag, delete_tag, list_tags, search_tags,
get_page_tags, set_page_tags, add_tag_to_page, remove_tag_from_page,
merge_tags, filter_pages_by_tags,
create_tag_group, list_tag_groups, update_tag_group, delete_tag_group
Application
├── Tag use cases crates/application/src/tag/
│ CreateTagUseCase, UpdateTagUseCase, DeleteTagUseCase,
│ ListTagsUseCase, SearchTagsUseCase, MergeTagsUseCase,
│ GetPageTagsUseCase, SetPageTagsUseCase, AddTagToPageUseCase,
│ RemoveTagFromPageUseCase, FilterPagesByTagsUseCase,
│ ManageGroupsUseCase
└── TagRepository (trait) crates/application/src/tag/services.rs
17 methods, no default impls on mutations
Domain
├── Tag crates/domain/src/tag.rs
│ Name validation, slug derivation, color validation
├── TagGroup Tag grouping entity
└── TagSummary Lightweight tag for page display
Infrastructure
└── SqliteTagRepository crates/infrastructure/sqlite/src/workspace/tag_repository.rs
tags, tag_groups, page_tags tables; FTS5 triggers

Dependencies flow inward: Framework -> Infrastructure -> Application -> Domain.


1. FTS5 Three-Column Index with BM25 Weighting

Section titled “1. FTS5 Three-Column Index with BM25 Weighting”

The FTS5 virtual table indexes three columns with different BM25 weights:

CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(
title, -- weight 10.0 (title matches rank highest)
content, -- weight 1.0 (body text baseline)
tags, -- weight 5.0 (tag matches rank between title and body)
content='',
contentless_delete=1
);

Tag names are concatenated as a space-separated string in the tags column. This means a search for “adventure” matches pages tagged “Adventure” with 5x the weight of a body-text match, but half the weight of a title match.

Four SQLite triggers keep the FTS index in sync with tag changes:

TriggerEventEffect
pages_fts_insertINSERT ON pagesIndex new page with its tags
pages_fts_updateUPDATE ON pagesRe-index page (title/content/tags)
page_tags_fts_insertINSERT ON page_tagsRe-index page when tag added
page_tags_fts_deleteDELETE ON page_tagsRe-index page when tag removed
tags_fts_name_updateUPDATE OF name ON tagsRe-index all pages with renamed tag

The tag change triggers (page_tags_fts_insert, page_tags_fts_delete) delete the old FTS entry and re-insert with current tag names, ensuring the tag column stays consistent without application-level coordination.

3. Property Bridge (slug == “tags” Routing)

Section titled “3. Property Bridge (slug == “tags” Routing)”

The property system defines a tags property (UUID 00000000-0000-0000-0000-000000000013, type multi_select). When the frontend writes to the “tags” property, the Tauri command layer detects slug == "tags" and routes the write to tag use cases instead of the generic property store. This bridges two conceptual models — properties (key-value metadata) and tags (first-class entities with groups, merge, and filtering) — through a single UI surface.

MergeTagsUseCase transfers all page references from a source tag to a target tag, then deletes the source. The merge handles deduplication: if a page already has both the source and target tags, the source association is simply removed (no duplicate target). The operation returns the count of affected pages.

MergeTagsUseCase::execute(source_id, target_id)
1. Validate source != target
2. TagRepository::merge_tags(source_id, target_id)
a. Transfer page_tags rows from source to target (INSERT OR IGNORE for dedup)
b. Delete source tag
3. Return affected page count

FilterPagesByTagsUseCase accepts a list of tag IDs and a match_all flag:

  • AND mode (match_all = true): Returns pages that have ALL specified tags. Implemented via GROUP BY page_id HAVING COUNT(DISTINCT tag_id) = N.
  • OR mode (match_all = false): Returns pages that have ANY of the specified tags. Simple IN clause on the page_tags join.

The frontend TagFilter component presents this as a toggle in the sidebar, allowing users to narrow or broaden their page list.

Tags can be organized into groups via TagGroup entities. A group has a name, slug, optional color, and sort order. The relationship is optional (group_id is nullable on tags). Deleting a group sets group_id = NULL on member tags (via ON DELETE SET NULL in the schema). Groups are purely organizational — they don’t affect search ranking or filtering behavior.

7. No Default Implementations on Mutations

Section titled “7. No Default Implementations on Mutations”

The TagRepository trait has no default implementations on any mutation method. This is an explicit design decision to prevent the “silent no-op” bug where a missing SqliteTagRepository override compiles but silently drops data. Read methods may have defaults; mutations must be explicitly implemented.


CREATE TABLE tags (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
color TEXT,
group_id TEXT REFERENCES tag_groups(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_tags_group_id ON tags(group_id);
CREATE TABLE tag_groups (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
color TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE page_tags (
page_id TEXT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
assigned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')),
PRIMARY KEY (page_id, tag_id)
);
CREATE INDEX idx_page_tags_tag_id ON page_tags(tag_id);

ON DELETE CASCADE on both foreign keys ensures that deleting a page removes its tag associations, and deleting a tag removes all its page associations.


RuleEnforcement
Tag name non-empty, max 100 chars (Unicode scalar values)Tag::validate_name() in domain
Tag name no leading/trailing whitespaceTag::validate_name() in domain
Color must be valid 6-digit hex (e.g., #ff5733)Tag::validate_color() in domain
Slug derived from name via slugify()Tag::new() constructor
Cannot merge a tag into itselfMergeTagsUseCase validation
Tag group name same rules as tag nameShared validate_label_name()
Permissions: TagsRead for queries, TagsWrite for mutationsPermissionGuard::require() in each use case

ScenarioEntry Point
Create tagCreateTagUseCase::execute() in crates/application/src/tag/create_tag.rs
Merge tagsMergeTagsUseCase::execute() in crates/application/src/tag/merge_tags.rs
Set page tags atomicallySetPageTagsUseCase::execute() in crates/application/src/tag/set_page_tags.rs
Filter pages by tagsFilterPagesByTagsUseCase::execute() in crates/application/src/tag/filter_pages_by_tags.rs
Search tags by prefixSearchTagsUseCase::execute() in crates/application/src/tag/search_tags.rs
FTS5 tag triggerscrates/infrastructure/sqlite/src/migrations/mod.rs (lines 488-521)
Frontend tag barTagBar.tsx in apps/desktop/src-react/components/
Frontend tag filterTagFilter.tsx in apps/desktop/src-react/components/

  • Page System (page-system): Tags are applied to pages via the page_tags join table
  • Property System (property-system): Tags bridge via slug == "tags" routing

Enables page categorization and filtering. See also: Layout System, Wiki-Link System.

Was this page helpful?