Skip to content
Documentation GitHub
Workspace

Tags — Lifecycle, Filtering, Groups, and Search

Tags — Lifecycle, Filtering, Groups, and Search

Covers the full tag lifecycle: creating, assigning, removing, renaming, recoloring, merging, and deleting tags. Also covers tag groups (organizational containers), the FTS5 search integration (tag names are indexed in the tags column with bm25 weight 5.0 versus title 10.0 and content 1.0), sidebar AND/OR filtering, and VFS frontmatter projection. Tags are a first-class organizational primitive — a bug in merge (which must deduplicate page associations), filter semantics (AND vs OR), or FTS5 re-indexing can silently corrupt the user’s taxonomy or produce stale search results.

This spec is P1 because tags drive discovery and filtering across the entire workspace. The FTS5 integration is particularly nuanced: tag name changes trigger a dedicated SQL trigger (tags_fts_name_update) that re-indexes every tagged page. The HTTP bridge exposes the full tag API including all mutation routes.

Preconditions

  • HTTP bridge running on port 9990
  • A workspace initialized via initialize_workspace before each scenario
  • Bridge shim injected via playwright.config.ts
  • The HTTP bridge exposes all tag routes: create_tag, update_tag, delete_tag, merge_tags, set_page_tags, add_tag_to_page, remove_tag_from_page, filter_pages_by_tags, search_tags, create_tag_group, list_tag_groups, update_tag_group, delete_tag_group, list_tags, and get_page_tags. All scenarios in this spec are exercisable via the bridge.

Scenarios

Seed: seed.spec.ts

1. Create a tag — minimal (name only)

A tag with no color and no group can be created from the tag management surface.

Steps:

  1. Open a workspace with at least one page.
  2. Open the tag management dialog (Settings → Tags, or sidebar tag panel).
  3. Click “Create tag” (or the + button in the tag panel).
  4. Enter the name "Adventure".
  5. Submit without specifying a color or group.
  6. Observe the tag list.

Expected: A new tag “Adventure” appears in the tag list with no color swatch and no group. The slug derived from the name is "adventure". The usage_count displayed is 0.

2. Create a tag — with hex color

Tags accept an optional 6-digit hex color used for display in TagChip components.

Steps:

  1. Open the tag creation form.
  2. Enter the name "Fantasy" and set the color to #8b5cf6 (purple).
  3. Submit the form.
  4. Observe the tag chip for “Fantasy” in the tag list.

Expected: The tag “Fantasy” appears with a purple color swatch (hex #8b5cf6). The chip color is visible in the tag list and wherever the tag is displayed on pages.

3. Create a tag — invalid name rejected (empty)

Domain validation requires a non-empty tag name.

Steps:

  1. Open the tag creation form.
  2. Submit the form with an empty name field (or a whitespace-only name such as " ").
  3. Observe the form validation feedback.

Expected: The form displays a validation error such as “Tag name must not be empty”. No tag is created. The tag list is unchanged.

4. Create a tag — name exceeding 100 characters rejected

Tag names must not exceed 100 Unicode scalar values.

Steps:

  1. Open the tag creation form.
  2. Enter a name that is exactly 101 characters long (e.g., 101 × "a").
  3. Submit the form.

Expected: A validation error is shown indicating the name must not exceed 100 characters. No tag is created.

5. Create a tag — invalid hex color rejected

Colors must be exactly #rrggbb format (7 characters, # prefix, 6 hex digits).

Steps:

  1. Open the tag creation form.
  2. Enter the name "BadColor" and supply a malformed hex color such as "purple" or "#gg5733" or "ff5733" (missing #).
  3. Submit the form.

Expected: A validation error is shown indicating the color must be a valid hex color (e.g., #ff5733). No tag is created.

6. Assign a tag to a page

Tags can be assigned to pages via the tag bar in the editor or via set_page_tags. Steps:

  1. Create a tag “Sci-Fi”.
  2. Create a page “Dune”.
  3. Open “Dune” in the editor.
  4. Click the tag bar (TagBar component) at the bottom of the editor.
  5. Type “Sci” in the autocomplete field.
  6. Select “Sci-Fi” from the suggestions.
  7. Close the tag input.
  8. Observe the tag bar below the editor content area.

Expected: The “Sci-Fi” chip appears in the tag bar of “Dune”. Calling get_page_tags for the Dune page returns a TagSummary with name = "Sci-Fi". The usage_count for the “Sci-Fi” tag is now 1.

7. Remove a tag from a page

A tag chip in the tag bar can be dismissed to remove the association. Steps:

  1. Create a page “Galaxy” and assign the tag “Space” to it.
  2. Open “Galaxy” in the editor.
  3. In the tag bar, click the × (remove) button on the “Space” chip.
  4. Observe the tag bar.

Expected: The “Space” chip disappears from the tag bar of “Galaxy”. Calling get_page_tags for “Galaxy” returns an empty list. The usage_count for “Space” decreases by 1.

8. Assign multiple tags to a single page

Pages are not limited to one tag; multiple tags can coexist on a page. Steps:

  1. Create tags “Action”, “Mystery”, “Historical”.
  2. Create a page “The Name of the Rose”.
  3. Assign all three tags to the page via the tag bar.
  4. Observe the tag bar.

Expected: All three chips (“Action”, “Mystery”, “Historical”) appear in the tag bar simultaneously. get_page_tags returns all three TagSummary entries for that page.

9. Set page tags atomically (replaces existing set)

set_page_tags replaces all tags on a page in a single operation. Steps:

  1. Create a page “Replaced Tags” and assign tags “Old-A” and “Old-B” to it.
  2. Call set_page_tags with tag names ["New-X", "New-Y"] for that page.
  3. Call get_page_tags for the page.

Expected: The result from get_page_tags contains exactly “New-X” and “New-Y”. “Old-A” and “Old-B” are no longer associated with the page. The total count is exactly 2.

10. Tag autocomplete — prefix search returns matching tags

The search_tags command returns tags matching a name prefix, ordered by usage_count descending. Steps:

  1. Create tags “Dragon”, “Drama”, “Drawing” with 3, 1, and 0 pages assigned respectively.
  2. Open the tag bar on any page and type "Dra".
  3. Observe the autocomplete dropdown.

Expected: The dropdown shows at least “Dragon”, “Drama”, and “Drawing”. “Dragon” appears first (highest usage count). The list is ordered by usage_count descending. The default limit is 20 results.

11. Tag autocomplete — auto-creates new tag on submit

When set_page_tags is called with a tag name that does not exist, the tag is auto-created. Steps:

  1. Verify that no tag named “Alchemy” exists in the workspace.
  2. Open a page in the editor, type "Alchemy" in the tag input, and press Enter to add it.
  3. Navigate to the tag list (Settings → Tags).

Expected: A tag “Alchemy” is now listed in the workspace tag list. It has usage_count of 1. The page shows “Alchemy” in its tag bar.

12. List all tags in workspace

list_tags returns all tags regardless of usage count.

Steps:

  1. Call list_tags via the bridge.
  2. Create 3 tags “Alpha”, “Beta”, “Gamma”.
  3. Call list_tags again.

Expected: All three tags appear in the response. Each entry contains id, name, slug, color (null), group_id (null), usage_count, created_at, and updated_at.

13. Rename a tag — FTS5 index stays consistent

Renaming a tag updates the tags_fts_name_update trigger, which re-indexes all pages tagged with it. Steps:

  1. Create a tag “SciFi” and assign it to two pages: “Asimov” and “Clarke”.
  2. Call update_tag to rename “SciFi” to “Science Fiction”.
  3. Call search_pages with query "Science Fiction".

Expected: Both “Asimov” and “Clarke” appear in the search results (matched via the tags FTS5 column). The old name “SciFi” no longer appears in the tag list. The tag slug is re-derived from the new name ("science-fiction").

14. Recolor a tag

A tag’s color can be changed independently of its name. Steps:

  1. Create a tag “Horror” with color #dc2626 (red).
  2. Call update_tag for “Horror” with color #0ea5e9 (blue).
  3. Observe the tag chip for “Horror” on any page that uses it.

Expected: The “Horror” chip now displays with #0ea5e9 (blue). The tag name and usage count are unchanged.

15. Merge two tags — pages updated, source deleted

Merging transfers all page associations from the source tag to the target, then deletes the source. Steps:

  1. Create tags “SF” and “Science Fiction”.
  2. Assign “SF” to pages “Asimov” and “Clarke”.
  3. Assign “Science Fiction” to page “Heinlein”.
  4. Call merge_tags with source_id = “SF” and target_id = “Science Fiction”.
  5. Observe the tag list and the tags on each page.

Expected: merge_tags returns 2 (pages updated — “Asimov” and “Clarke” now have “Science Fiction” instead of “SF”). All three pages (“Asimov”, “Clarke”, “Heinlein”) have the “Science Fiction” tag. The “SF” tag no longer exists in the tag list. The usage_count for “Science Fiction” is now 3.

16. Merge a tag into itself returns an error

Merging a tag with its own ID is an invalid operation. Steps:

  1. Create a tag “Orphan”.
  2. Call merge_tags with source_id == target_id (both pointing to “Orphan”).

Expected: A validation error is returned (“Cannot merge a tag into itself” or equivalent). The “Orphan” tag is unchanged. No pages are modified.

17. Delete a tag — page associations removed

Deleting a tag removes it and all page associations atomically. Steps:

  1. Create a tag “Temp” and assign it to pages “Page A” and “Page B”.
  2. Call delete_tag with the ID of “Temp”.
  3. Call get_page_tags for “Page A” and “Page B”.

Expected: Both get_page_tags calls return empty lists (or lists that do not contain “Temp”). The tag list no longer shows “Temp”. No error occurs on either page when opening them in the editor.

18. Filter pages by tags — AND semantics

filter_pages_by_tags with match_all = true returns only pages that have ALL listed tags. Steps:

  1. Create tags “Sci-Fi” and “Classic”.
  2. Create pages: “Dune” (has both “Sci-Fi” and “Classic”), “Foundation” (has “Sci-Fi” only), “Pride and Prejudice” (has “Classic” only).
  3. Call filter_pages_by_tags with tag_ids = [sci_fi_id, classic_id] and match_all = true.

Expected: Only “Dune” appears in the result. “Foundation” and “Pride and Prejudice” are excluded. The result is a list containing exactly one page ID (that of “Dune”).

19. Filter pages by tags — OR semantics

filter_pages_by_tags with match_all = false returns pages with ANY of the listed tags. Steps:

  1. Use the same setup as scenario 18 (tags “Sci-Fi” and “Classic”, three pages).
  2. Call filter_pages_by_tags with tag_ids = [sci_fi_id, classic_id] and match_all = false.

Expected: All three pages (“Dune”, “Foundation”, “Pride and Prejudice”) appear in the result. The result contains exactly 3 page IDs.

20. Filter with empty tag list returns empty result

Calling filter_pages_by_tags with no tag IDs is documented to return an empty list. Steps:

  1. Create several tagged pages in the workspace.
  2. Call filter_pages_by_tags with tag_ids = [] and match_all = false.

Expected: An empty list is returned. No error occurs. The existing pages are unaffected.

21. Tags included in FTS5 search — tag name finds tagged pages

The pages_fts table has a tags column indexed with bm25 weight 5.0. Searching for a tag name surfaces pages tagged with it. Steps:

  1. Create a tag “Worldbuilding”.
  2. Create two pages: “Map of Elenor” (tagged “Worldbuilding”) and “Character Sheet” (not tagged “Worldbuilding”).
  3. Call search_pages with query "Worldbuilding".

Expected: “Map of Elenor” appears in the search results. “Character Sheet” does not appear unless its title or content also contains “Worldbuilding”. The match is driven by the tags column in pages_fts.

22. Tag group — create and assign tags to group

Tag groups organize related tags (e.g., “Genre”, “Status”). Tags within a group have their group_id set.

Steps:

  1. Call create_tag_group with name "Genre".
  2. Call create_tag with name "Fantasy" and group_id = the new group’s ID.
  3. Call list_tag_groups and list_tags.

Expected: list_tag_groups returns a group with name “Genre” and a slug of "genre". list_tags returns “Fantasy” with group_id pointing to the “Genre” group. The TagSummary.group_name field reads "Genre" wherever the tag is displayed on pages.

23. Tag group — delete group, tags become ungrouped

Deleting a group must not delete the tags in it; instead, their group_id is set to NULL (via ON DELETE SET NULL).

Steps:

  1. Create a group “Status” and create tags “Draft” and “Published” in it.
  2. Call delete_tag_group with the “Status” group ID.
  3. Call list_tags.

Expected: Both “Draft” and “Published” still exist in the tag list with group_id = null (ungrouped). No error occurs. The “Status” group is no longer returned by list_tag_groups.

24. Duplicate tag name is rejected

The tags table enforces uniqueness by slug (derived from name).

Steps:

  1. Create a tag “Horror”.
  2. Attempt to create another tag named "Horror" (same name, which derives the same slug "horror").

Expected: The second creation returns an “Already exists” error (or equivalent). Only one “Horror” tag exists in the workspace.

25. Tag usage count reflects live page associations

usage_count is computed by query and must reflect the current number of pages tagged with a given tag.

Steps:

  1. Create tag “Lore”.
  2. Assign “Lore” to 3 pages.
  3. Call list_tags and inspect the usage_count for “Lore”.
  4. Remove “Lore” from one page.
  5. Call list_tags again and inspect usage_count.

Expected: After step 3, usage_count is 3. After step 5, usage_count is 2. The count reflects live associations, not a cached value.

26. Tag with special characters in name is handled correctly

Tags accept Unicode names (letters, emoji, punctuation within the 100-char limit) but the derived slug strips non-alphanumeric characters.

Steps:

  1. Attempt to create a tag named "Sci-Fi & Fantasy".
  2. Observe the created tag in the list.

Expected: The tag is created successfully with name "Sci-Fi & Fantasy". The slug is derived via the codebase slugification logic (typically "sci-fi-fantasy" or similar). The tag can be assigned to pages and searched by its name.

Test Data

KeyValueNotes
valid_name_shortAdventureMinimal valid tag name
valid_name_with_colorFantasy / #8b5cf6Name + valid hex color
invalid_name_empty"" or ” “Triggers domain validation error
invalid_name_long”a” × 101Exceeds 100-char limit
invalid_color_no_hashff5733Missing # prefix — validation error
invalid_color_short#ff573Only 6 chars total — too short
merge_sourceSFTag to be consumed in merge scenario
merge_targetScience FictionTag to be retained after merge
group_nameGenreTag group for organizational tests
fts_search_termWorldbuildingTag name to search for; confirms FTS5 tags-column indexing
and_filter_tags[Sci-Fi, Classic]Both required (match_all=true): only “Dune” matches
or_filter_tags[Sci-Fi, Classic]Either required (match_all=false): all 3 pages match
max_filter_tag_ids100Maximum tag IDs accepted by filter_pages_by_tags per request
bm25_tag_weight5.0Weight for tags column in bm25(pages_fts, 10.0, 1.0, 5.0) — see search.rs

Notes

  • The HTTP bridge exposes the full tag API. All tag mutation routes exercise the SqliteTagRepository directly.
  • usage_count is never stored in the database — it is computed by LEFT JOIN at query time in SqliteTagRepository. Scenarios verifying usage count must use list_tags or get_page_tags rather than inspecting a stored field.
  • merge_tags must deduplicate: when a page already has both source and target tags, the merge must result in the target tag appearing exactly once (no duplicate page_tags row).
  • Tag slugs are derived from names via the codebase slugify() function. Two tags with names that produce the same slug (e.g., “Sci-Fi” and “Sci Fi”) will conflict at the database level.
  • The FTS5 tags column is kept consistent by three triggers: page_tags_fts_insert, page_tags_fts_delete, and tags_fts_name_update. A tag rename re-indexes every page that has the renamed tag — this can be a write-heavy operation on large workspaces.
  • set_page_tags accepts tag names (not IDs) and auto-creates missing tags. This differs from add_tag_to_page which takes a tag UUID directly.
  • Tag groups have sort_order (i32) for ordering within the tag management dialog. Groups cannot be nested — the domain enforces flat group structure.

Was this page helpful?