From b58df503b1421b03d686f564849938a2887d34c0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 00:18:34 +0000 Subject: [PATCH 01/53] docs: add tag management features design doc Co-authored-by: andykais-claude --- docs/design/tag-management-features.md | 405 +++++++++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 docs/design/tag-management-features.md diff --git a/docs/design/tag-management-features.md b/docs/design/tag-management-features.md new file mode 100644 index 00000000..a23d5111 --- /dev/null +++ b/docs/design/tag-management-features.md @@ -0,0 +1,405 @@ +# Design: Tag Management Features + +## Overview + +This document outlines the plan for adding tag management capabilities to Forager. The work spans both `@forager/core` (backend) and `@forager/web` (frontend), introducing: + +- A new `tag_rule` database table for alias and parent/child relationships +- New action methods on `Forager::tag` for CRUD operations on tags and rules +- Two new web pages: a tag search page (`/tags`) and a tag detail/edit page (`/tags/[id]`) +- Removal of the existing naive `tag.alias_tag_id` column + +--- + +## Phase 1: Database — New `tag_rule` Table & Migration + +### New file: `packages/core/src/db/migrations/migration_v9.ts` + +A new migration that: + +1. Creates the `tag_rule` table +2. Removes the `alias_tag_id` column from `tag` + +#### `tag_rule` schema + +```sql +CREATE TABLE tag_rule ( + id INTEGER PRIMARY KEY NOT NULL, + tag_id INTEGER NOT NULL, + related_tag_id INTEGER NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('alias', 'parent')), + updated_at TIMESTAMP DATETIME DEFAULT(STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')), + created_at TIMESTAMP DATETIME DEFAULT(STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')), + + FOREIGN KEY (tag_id) REFERENCES tag(id), + FOREIGN KEY (related_tag_id) REFERENCES tag(id), + UNIQUE(tag_id, related_tag_id, kind) +); +``` + +The `kind` column distinguishes rule types: + +- **`alias`**: `tag_id` is an alias for `related_tag_id` (the canonical tag). When media is tagged with `tag_id`, it resolves to `related_tag_id`. +- **`parent`**: `tag_id` is a child of `related_tag_id`. When `related_tag_id` (the parent) is applied, `tag_id` (the child) is implicitly included. + +#### Removing `alias_tag_id` + +Since SQLite has limited `ALTER TABLE` support, the migration uses the table-rebuild pattern: + +1. Create `tag_new` without `alias_tag_id` +2. Copy data from `tag` to `tag_new` +3. Drop `tag` +4. Rename `tag_new` to `tag` +5. Recreate triggers and indexes on `tag` + +### Modified file: `packages/core/src/db/migrations/mod.ts` + +Add `import './migration_v9.ts'` to the migration list. + +--- + +## Phase 2: New Model — `TagRule` + +### New file: `packages/core/src/models/tag_rule.ts` + +```typescript +class TagRule extends Model { + static schema = schema('tag_rule', { + id: field.number(), + tag_id: field.number(), + related_tag_id: field.number(), + kind: field.string(), // 'alias' | 'parent' + updated_at: field.datetime(), + created_at: field.datetime(), + }) +} +``` + +Queries to include: + +| Query | Description | +|-------|-------------| +| `#create` | `INSERT ... RETURNING id` | +| `#delete_by_id` | `DELETE ... WHERE id = ?` | +| `#select_aliases_for_tag` | `SELECT ... WHERE related_tag_id = ? AND kind = 'alias'` — tags that are aliases *for* a canonical tag | +| `#select_alias_target` | `SELECT ... WHERE tag_id = ? AND kind = 'alias'` — the canonical tag an alias points to | +| `#select_children` | `SELECT ... WHERE related_tag_id = ? AND kind = 'parent'` — child tags of a parent | +| `#select_parents` | `SELECT ... WHERE tag_id = ? AND kind = 'parent'` — parent tags of a child | + +These queries should join through `tag` and `tag_group` to return full tag info (name, group, color) for related tags. + +### Modified file: `packages/core/src/models/mod.ts` + +Add `export {TagRule} from './tag_rule.ts'`. This automatically registers it with `ForagerTorm` via the existing `forager_models` iteration in `db/mod.ts`. + +--- + +## Phase 3: Remove `alias_tag_id` from Tag Model + +### Modified file: `packages/core/src/models/tag.ts` + +- Remove `alias_tag_id` from `Tag.schema` +- Remove `alias_tag_id` from the `#create` query's INSERT column list and VALUES + +### Modified file: `packages/core/src/actions/lib/base.ts` + +In `tag_create()`, remove `alias_tag_id: null` from the `Tag.get_or_create(...)` call: + +```typescript +// Before +const tag_record = this.models.Tag.get_or_create({ + alias_tag_id: null, name: tag.name, tag_group_id: tag_group.id, ... +}) + +// After +const tag_record = this.models.Tag.get_or_create({ + name: tag.name, tag_group_id: tag_group.id, ... +}) +``` + +### Modified file: `packages/core/src/actions/series_actions.ts` + +Same removal of `alias_tag_id: null` from `Tag.get_or_create(...)` calls (~lines 47 and 85). + +--- + +## Phase 4: New Input Schemas + +### New file: `packages/core/src/inputs/tag_rule_inputs.ts` + +Zod schemas for the new operations: + +```typescript +export const TagGet = z.object({ + id: z.number().int(), +}) + +export const TagUpdate = z.object({ + id: z.number().int(), + name: z.string().optional(), + group: z.string().optional(), + description: z.string().optional(), +}) + +export const TagRuleCreate = z.object({ + tag_id: z.number().int(), + related_tag_id: z.number().int(), + kind: z.enum(['alias', 'parent']), +}) + +export const TagRuleDelete = z.object({ + id: z.number().int(), +}) +``` + +### Modified file: `packages/core/src/inputs/lib/inputs_parsers.ts` + +Add `export * from '~/inputs/tag_rule_inputs.ts'` + +### Modified file: `packages/core/src/inputs/lib/inputs_types.ts` + +Add input types: + +```typescript +export type TagGet = z.input +export type TagUpdate = z.input +export type TagRuleCreate = z.input +export type TagRuleDelete = z.input +``` + +--- + +## Phase 5: New Action Methods on `TagActions` + +### Modified file: `packages/core/src/actions/tag_actions.ts` + +New methods on the `TagActions` class, exposed as `Forager::tag::*`: + +#### `Forager::tag::get` + +```typescript +get = (params: inputs.TagGet) => TagDetail +``` + +Fetches a single tag by ID along with its full relationship graph. Returns: + +```typescript +interface TagDetail { + tag: Tag & { group: string; color: string } + aliases: Array + alias_target: (Tag & { group: string; color: string }) | null + children: Array + parents: Array +} +``` + +- `tag` — the tag record itself with group name and color +- `aliases` — other tags that are aliases *for* this tag (this tag is canonical) +- `alias_target` — if this tag is itself an alias, the canonical tag it points to; otherwise `null` +- `children` — tags automatically included when this tag is applied (this tag is the parent) +- `parents` — tags that, when applied, automatically include this tag (this tag is the child) + +#### `Forager::tag::update` + +```typescript +update = (params: inputs.TagUpdate) => void +``` + +Updates a tag's name, group, and/or description. If the group name changes, the tag is moved to the new group (creating it via `TagGroup.get_or_create` if needed). Validates the new name/group don't collide with an existing tag. + +#### `Forager::tag::add_rule` + +```typescript +add_rule = (params: inputs.TagRuleCreate) => { id: number } +``` + +Creates an alias or parent/child relationship. Validations: + +- Both `tag_id` and `related_tag_id` must exist +- `tag_id !== related_tag_id` (no self-references) +- For alias rules: a tag cannot be both a canonical tag and an alias simultaneously +- For parent rules: no circular parent/child chains + +#### `Forager::tag::remove_rule` + +```typescript +remove_rule = (params: inputs.TagRuleDelete) => void +``` + +Deletes a tag rule by ID. Raises `NotFoundError` if the rule doesn't exist. + +#### Unchanged + +`Forager::tag::search` remains as-is. + +--- + +## Phase 6: Expose New Actions via RPC + +### Modified file: `packages/web/src/lib/api.ts` + +Extend `ForagerTagApi` to expose all new methods: + +```typescript +class ForagerTagApi extends rpc.ApiController { + search = this.context.forager.tag.search + get = this.context.forager.tag.get + update = this.context.forager.tag.update + add_rule = this.context.forager.tag.add_rule + remove_rule = this.context.forager.tag.remove_rule +} +``` + +--- + +## Phase 7: Web — Tag Search Page (`/tags`) + +A page for searching and browsing all tags, displayed as a table. + +### New file: `packages/web/src/routes/tags/+page.svelte` + +Top-level page component. Instantiates the controller and renders search params + results. + +### New file: `packages/web/src/routes/tags/controller.ts` + +`TagsController` extending `BaseController` with runes for: + +- `queryparams` — tag search query state (search string, sort_by, order, cursor) +- `focus` / `settings` — inherited from `BaseController` + +### New file: `packages/web/src/routes/tags/runes/queryparams.svelte.ts` + +Manages tag search URL params and draft state. On submit, calls `client.forager.tag.search(...)` and stores results. Handles: + +- `search_string` — maps to `query.tag_match` +- `sort_by` — one of `media_reference_count`, `unread_media_reference_count`, `created_at`, `updated_at` +- `order` — `asc` / `desc` +- `cursor` — for pagination + +### New file: `packages/web/src/routes/tags/components/TagSearchParams.svelte` + +Search controls inspired by the `/browse` `SearchParams` component, but simpler: + +- Text input for tag name/group filter (using `TagAutoCompleteInput` or a plain input) +- Sort dropdown +- Order toggle (asc/desc) + +### New file: `packages/web/src/routes/tags/components/TagSearchResults.svelte` + +Renders search results as a table with columns: + +| Column | Source | +|--------|--------| +| Tag (colored, with group prefix) | `name`, `group`, `color` — links to `/tags/[id]` | +| Group | `group` | +| Media Count | `media_reference_count` | +| Unread Count | `unread_media_reference_count` | +| Created At | `created_at` | +| Updated At | `updated_at` | + +Includes pagination controls (next/previous) driven by the cursor from the search response. + +--- + +## Phase 8: Web — Tag Detail/Edit Page (`/tags/[id]`) + +A page for viewing and editing a single tag's properties and relationships. + +### New file: `packages/web/src/routes/tags/[id]/+page.svelte` + +Top-level page component. Loads tag details on mount via `client.forager.tag.get({ id })`, then renders the following sections: + +#### Section 1: Tag Info (editable) + +- **Name**: text input, pre-filled with current name +- **Group**: text input (or dropdown of existing groups), pre-filled with current group +- **Description**: textarea, pre-filled with current description +- **Save button**: calls `client.forager.tag.update({ id, name, group, description })` and reloads + +#### Section 2: Aliases + +- **Alias target**: if this tag is an alias for another tag, display the canonical tag (linked to its `/tags/[id]` page) with a "Remove" button that calls `remove_rule` +- **Alias list**: tags that are aliases *for* this tag (this tag is canonical). Each row shows the alias tag (linked) with a "Remove" button +- **Add alias**: a `TagAutoCompleteInput` to search for and select a tag, then a button to call `client.forager.tag.add_rule({ tag_id: selected_tag.id, related_tag_id: this_tag.id, kind: 'alias' })` + +#### Section 3: Parent/Child Relationships + +- **Children** (tags automatically included when this tag is applied): + - List of child tags, each linked to `/tags/[id]` with a "Remove" button + - "Add child" input using `TagAutoCompleteInput`, calls `add_rule({ tag_id: child.id, related_tag_id: this_tag.id, kind: 'parent' })` + +- **Parents** (tags that automatically include this tag): + - List of parent tags, each linked to `/tags/[id]` with a "Remove" button + - "Add parent" input using `TagAutoCompleteInput`, calls `add_rule({ tag_id: this_tag.id, related_tag_id: parent.id, kind: 'parent' })` + +### New file: `packages/web/src/routes/tags/[id]/controller.ts` + +`TagDetailController` extending `BaseController` with: + +- Reactive state for the loaded `TagDetail` +- Editable draft state for name/group/description +- Methods: `load()`, `save()`, `add_rule()`, `remove_rule()` that call the RPC client and refresh state + +--- + +## Phase 9: Tests + +### Modified or new file: `packages/core/test/tag.test.ts` + +Tests for the new backend functionality: + +- **`Forager::tag::get`** — fetch a tag, verify aliases/parents/children are empty initially +- **`Forager::tag::update`** — update name, group, description; verify changes persist; verify group migration works +- **`Forager::tag::add_rule` (alias)** — create an alias rule, verify it appears in `get` for both tags +- **`Forager::tag::add_rule` (parent)** — create a parent rule, verify children/parents in `get` +- **`Forager::tag::remove_rule`** — remove a rule, verify it disappears from `get` +- **Validation** — self-reference rejected, circular alias rejected, duplicate rule rejected +- **Migration v9** — verify `alias_tag_id` is gone, `tag_rule` table exists + +--- + +## File Change Summary + +### New Files + +| File | Description | +|------|-------------| +| `packages/core/src/db/migrations/migration_v9.ts` | Migration: create `tag_rule` table, remove `alias_tag_id` from `tag` | +| `packages/core/src/models/tag_rule.ts` | `TagRule` model with CRUD queries | +| `packages/core/src/inputs/tag_rule_inputs.ts` | Zod schemas for `TagGet`, `TagUpdate`, `TagRuleCreate`, `TagRuleDelete` | +| `packages/core/test/tag.test.ts` | Tests for new tag action methods | +| `packages/web/src/routes/tags/+page.svelte` | Tag search page | +| `packages/web/src/routes/tags/controller.ts` | Tag search page controller | +| `packages/web/src/routes/tags/runes/queryparams.svelte.ts` | Tag search query params rune | +| `packages/web/src/routes/tags/components/TagSearchParams.svelte` | Tag search controls component | +| `packages/web/src/routes/tags/components/TagSearchResults.svelte` | Tag search results table component | +| `packages/web/src/routes/tags/[id]/+page.svelte` | Tag detail/edit page | +| `packages/web/src/routes/tags/[id]/controller.ts` | Tag detail page controller | + +### Modified Files + +| File | Change | +|------|--------| +| `packages/core/src/db/migrations/mod.ts` | Add `import './migration_v9.ts'` | +| `packages/core/src/models/mod.ts` | Add `export {TagRule} from './tag_rule.ts'` | +| `packages/core/src/models/tag.ts` | Remove `alias_tag_id` from schema and `#create` query | +| `packages/core/src/actions/tag_actions.ts` | Add `get`, `update`, `add_rule`, `remove_rule` methods | +| `packages/core/src/actions/lib/base.ts` | Remove `alias_tag_id: null` from `tag_create()` | +| `packages/core/src/actions/series_actions.ts` | Remove `alias_tag_id: null` from `get_or_create` calls | +| `packages/core/src/inputs/lib/inputs_parsers.ts` | Add `export * from '~/inputs/tag_rule_inputs.ts'` | +| `packages/core/src/inputs/lib/inputs_types.ts` | Add `TagGet`, `TagUpdate`, `TagRuleCreate`, `TagRuleDelete` types | +| `packages/web/src/lib/api.ts` | Expose `get`, `update`, `add_rule`, `remove_rule` on `ForagerTagApi` | + +--- + +## Implementation Order + +1. Migration v9 (Phase 1) +2. `TagRule` model (Phase 2) +3. Remove `alias_tag_id` (Phase 3) +4. Input schemas (Phase 4) +5. `TagActions` expansion (Phase 5) +6. Tests (Phase 9 — run early to validate backend) +7. RPC API (Phase 6) +8. Tag search page `/tags` (Phase 7) +9. Tag detail page `/tags/[id]` (Phase 8) From 63b790527f1fbd0e72746f24fa9ef0e15d2f105d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 00:46:00 +0000 Subject: [PATCH 02/53] docs: address PR review feedback on tag management design - Use DROP COLUMN instead of table-rebuild for alias_tag_id removal - tag_rule uses slug-based references (source_tag_slug/target_tag_slug) - Add slug field to Tag model for fast lookups - Alias tags have zero media_reference_tag rows; add_rule migrates existing rows - Rename remove_rule to delete_rule for consistency - Add aliases and parents columns to tag search results table - Use slug instead of id for tag detail page route (/tags/[slug]) - Update all sections for slug-based design throughout Co-authored-by: andykais-claude --- docs/design/tag-management-features.md | 214 +++++++++++++++---------- 1 file changed, 126 insertions(+), 88 deletions(-) diff --git a/docs/design/tag-management-features.md b/docs/design/tag-management-features.md index a23d5111..01568804 100644 --- a/docs/design/tag-management-features.md +++ b/docs/design/tag-management-features.md @@ -4,53 +4,73 @@ This document outlines the plan for adding tag management capabilities to Forager. The work spans both `@forager/core` (backend) and `@forager/web` (frontend), introducing: -- A new `tag_rule` database table for alias and parent/child relationships +- A new `tag_rule` database table for alias and parent/child relationships, keyed by tag slugs +- A new `slug` column on the `tag` table for fast slug-based lookups - New action methods on `Forager::tag` for CRUD operations on tags and rules -- Two new web pages: a tag search page (`/tags`) and a tag detail/edit page (`/tags/[id]`) +- Two new web pages: a tag search page (`/tags`) and a tag detail/edit page (`/tags/[slug]`) - Removal of the existing naive `tag.alias_tag_id` column +### Key Design Decision: Slug-Based References in `tag_rule` + +Alias tags should have zero `media_reference_tag` relationships — when a tag is made an alias for a canonical tag, all existing `media_reference_tag` rows pointing at the alias are migrated to the canonical tag. Since tags with zero references get cleaned up by `Tag::delete_unreferenced`, the `tag_rule` table references tags by their slugified identifier (`${group}:${name}`) rather than by foreign key ID. This has two benefits: + +1. Rules survive tag deletion/recreation cycles +2. Rules can be imported *ahead* of the tags actually existing in the database + --- -## Phase 1: Database — New `tag_rule` Table & Migration +## Phase 1: Database — New `tag_rule` Table, `tag.slug` Column & Migration ### New file: `packages/core/src/db/migrations/migration_v9.ts` A new migration that: -1. Creates the `tag_rule` table -2. Removes the `alias_tag_id` column from `tag` +1. Adds a `slug` column to the `tag` table +2. Creates the `tag_rule` table (slug-referenced) +3. Removes the `alias_tag_id` column from `tag` + +#### `tag.slug` column + +```sql +ALTER TABLE tag ADD COLUMN slug TEXT; +UPDATE tag SET slug = CASE + WHEN tag_group.name = '' THEN tag.name + ELSE tag_group.name || ':' || tag.name +END +FROM tag_group WHERE tag_group.id = tag.tag_group_id; + +CREATE UNIQUE INDEX tag_slug ON tag (slug); +``` + +The `slug` column stores the canonical `group:name` identifier (e.g. `genre:adventure`, or just `favorite` for the default group). It is set on tag creation and updated whenever name or group changes. #### `tag_rule` schema ```sql CREATE TABLE tag_rule ( id INTEGER PRIMARY KEY NOT NULL, - tag_id INTEGER NOT NULL, - related_tag_id INTEGER NOT NULL, + source_tag_slug TEXT NOT NULL, + target_tag_slug TEXT NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('alias', 'parent')), updated_at TIMESTAMP DATETIME DEFAULT(STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')), created_at TIMESTAMP DATETIME DEFAULT(STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')), - FOREIGN KEY (tag_id) REFERENCES tag(id), - FOREIGN KEY (related_tag_id) REFERENCES tag(id), - UNIQUE(tag_id, related_tag_id, kind) + UNIQUE(source_tag_slug, target_tag_slug, kind) ); ``` +Note: no foreign keys to `tag` — rules reference tags by slug and can exist independently of the tags themselves. + The `kind` column distinguishes rule types: -- **`alias`**: `tag_id` is an alias for `related_tag_id` (the canonical tag). When media is tagged with `tag_id`, it resolves to `related_tag_id`. -- **`parent`**: `tag_id` is a child of `related_tag_id`. When `related_tag_id` (the parent) is applied, `tag_id` (the child) is implicitly included. +- **`alias`**: `source_tag_slug` is an alias for `target_tag_slug` (the canonical tag). When media is tagged with the source, all `media_reference_tag` rows are migrated to point at the target. +- **`parent`**: `source_tag_slug` is a child of `target_tag_slug`. When `target_tag_slug` (the parent) is applied, `source_tag_slug` (the child) is implicitly included. #### Removing `alias_tag_id` -Since SQLite has limited `ALTER TABLE` support, the migration uses the table-rebuild pattern: - -1. Create `tag_new` without `alias_tag_id` -2. Copy data from `tag` to `tag_new` -3. Drop `tag` -4. Rename `tag_new` to `tag` -5. Recreate triggers and indexes on `tag` +```sql +ALTER TABLE tag DROP COLUMN alias_tag_id; +``` ### Modified file: `packages/core/src/db/migrations/mod.ts` @@ -65,12 +85,12 @@ Add `import './migration_v9.ts'` to the migration list. ```typescript class TagRule extends Model { static schema = schema('tag_rule', { - id: field.number(), - tag_id: field.number(), - related_tag_id: field.number(), - kind: field.string(), // 'alias' | 'parent' - updated_at: field.datetime(), - created_at: field.datetime(), + id: field.number(), + source_tag_slug: field.string(), + target_tag_slug: field.string(), + kind: field.string(), // 'alias' | 'parent' + updated_at: field.datetime(), + created_at: field.datetime(), }) } ``` @@ -81,12 +101,13 @@ Queries to include: |-------|-------------| | `#create` | `INSERT ... RETURNING id` | | `#delete_by_id` | `DELETE ... WHERE id = ?` | -| `#select_aliases_for_tag` | `SELECT ... WHERE related_tag_id = ? AND kind = 'alias'` — tags that are aliases *for* a canonical tag | -| `#select_alias_target` | `SELECT ... WHERE tag_id = ? AND kind = 'alias'` — the canonical tag an alias points to | -| `#select_children` | `SELECT ... WHERE related_tag_id = ? AND kind = 'parent'` — child tags of a parent | -| `#select_parents` | `SELECT ... WHERE tag_id = ? AND kind = 'parent'` — parent tags of a child | +| `#select_aliases_for_tag` | `SELECT ... WHERE target_tag_slug = ? AND kind = 'alias'` — slugs that are aliases *for* a canonical tag | +| `#select_alias_target` | `SELECT ... WHERE source_tag_slug = ? AND kind = 'alias'` — the canonical tag slug an alias points to | +| `#select_children` | `SELECT ... WHERE target_tag_slug = ? AND kind = 'parent'` — child tag slugs of a parent | +| `#select_parents` | `SELECT ... WHERE source_tag_slug = ? AND kind = 'parent'` — parent tag slugs of a child | +| `#select_by_slug` | `SELECT ... WHERE source_tag_slug = ? OR target_tag_slug = ?` — all rules involving a tag slug | -These queries should join through `tag` and `tag_group` to return full tag info (name, group, color) for related tags. +Note: these queries do *not* join through `tag`/`tag_group` since the referenced tags may not exist yet. The action layer is responsible for resolving slugs to full tag records when available. ### Modified file: `packages/core/src/models/mod.ts` @@ -94,16 +115,20 @@ Add `export {TagRule} from './tag_rule.ts'`. This automatically registers it wit --- -## Phase 3: Remove `alias_tag_id` from Tag Model +## Phase 3: Update Tag Model — Add `slug`, Remove `alias_tag_id` ### Modified file: `packages/core/src/models/tag.ts` - Remove `alias_tag_id` from `Tag.schema` - Remove `alias_tag_id` from the `#create` query's INSERT column list and VALUES +- Add `slug: field.string()` to `Tag.schema` +- Add `slug` to the `#create` query (computed from group + name via `tag_slug_format`) +- Add a `#select_by_slug` query for slug-based lookups +- Update `#select_one_impl` to support lookup by slug ### Modified file: `packages/core/src/actions/lib/base.ts` -In `tag_create()`, remove `alias_tag_id: null` from the `Tag.get_or_create(...)` call: +In `tag_create()`, remove `alias_tag_id: null` and add `slug` to the `Tag.get_or_create(...)` call: ```typescript // Before @@ -113,13 +138,13 @@ const tag_record = this.models.Tag.get_or_create({ // After const tag_record = this.models.Tag.get_or_create({ - name: tag.name, tag_group_id: tag_group.id, ... + name: tag.name, tag_group_id: tag_group.id, slug: tag.slug, ... }) ``` ### Modified file: `packages/core/src/actions/series_actions.ts` -Same removal of `alias_tag_id: null` from `Tag.get_or_create(...)` calls (~lines 47 and 85). +Same removal of `alias_tag_id: null` and addition of `slug` in `Tag.get_or_create(...)` calls (~lines 47 and 85). --- @@ -131,19 +156,19 @@ Zod schemas for the new operations: ```typescript export const TagGet = z.object({ - id: z.number().int(), + slug: z.string(), }) export const TagUpdate = z.object({ - id: z.number().int(), + slug: z.string(), name: z.string().optional(), group: z.string().optional(), description: z.string().optional(), }) export const TagRuleCreate = z.object({ - tag_id: z.number().int(), - related_tag_id: z.number().int(), + source_tag_slug: z.string(), + target_tag_slug: z.string(), kind: z.enum(['alias', 'parent']), }) @@ -181,23 +206,25 @@ New methods on the `TagActions` class, exposed as `Forager::tag::*`: get = (params: inputs.TagGet) => TagDetail ``` -Fetches a single tag by ID along with its full relationship graph. Returns: +Fetches a single tag by slug along with its full relationship graph. Returns: ```typescript interface TagDetail { - tag: Tag & { group: string; color: string } - aliases: Array - alias_target: (Tag & { group: string; color: string }) | null - children: Array - parents: Array + tag: Tag & { group: string; color: string; slug: string } + aliases: Array<{ slug: string; tag?: Tag & { group: string; color: string } }> + alias_target: { slug: string; tag?: Tag & { group: string; color: string } } | null + children: Array<{ slug: string; tag?: Tag & { group: string; color: string } }> + parents: Array<{ slug: string; tag?: Tag & { group: string; color: string } }> } ``` -- `tag` — the tag record itself with group name and color -- `aliases` — other tags that are aliases *for* this tag (this tag is canonical) -- `alias_target` — if this tag is itself an alias, the canonical tag it points to; otherwise `null` -- `children` — tags automatically included when this tag is applied (this tag is the parent) -- `parents` — tags that, when applied, automatically include this tag (this tag is the child) +- `tag` — the tag record itself with group name, color, and slug +- `aliases` — slugs (and resolved tags when they exist) that are aliases *for* this tag (this tag is canonical) +- `alias_target` — if this tag is itself an alias, the canonical tag slug it points to (with resolved tag if it exists); otherwise `null` +- `children` — tag slugs (and resolved tags) automatically included when this tag is applied (this tag is the parent) +- `parents` — tag slugs (and resolved tags) that, when applied, automatically include this tag (this tag is the child) + +Since rules are slug-based and can exist independently of the tags, each relationship entry includes the `slug` and an optional `tag` that is populated only if the referenced tag currently exists in the database. #### `Forager::tag::update` @@ -205,7 +232,7 @@ interface TagDetail { update = (params: inputs.TagUpdate) => void ``` -Updates a tag's name, group, and/or description. If the group name changes, the tag is moved to the new group (creating it via `TagGroup.get_or_create` if needed). Validates the new name/group don't collide with an existing tag. +Updates a tag's name, group, and/or description. If the group name changes, the tag is moved to the new group (creating it via `TagGroup.get_or_create` if needed). Also updates the `slug` column and any `tag_rule` rows that reference the old slug. Validates the new name/group don't collide with an existing tag. #### `Forager::tag::add_rule` @@ -213,17 +240,25 @@ Updates a tag's name, group, and/or description. If the group name changes, the add_rule = (params: inputs.TagRuleCreate) => { id: number } ``` -Creates an alias or parent/child relationship. Validations: +Creates an alias or parent/child relationship between two tag slugs. Validations: -- Both `tag_id` and `related_tag_id` must exist -- `tag_id !== related_tag_id` (no self-references) +- `source_tag_slug !== target_tag_slug` (no self-references) - For alias rules: a tag cannot be both a canonical tag and an alias simultaneously - For parent rules: no circular parent/child chains -#### `Forager::tag::remove_rule` +**Alias side-effects**: When an alias rule is created and the source tag currently exists with `media_reference_tag` rows, those rows are migrated to the target tag: + +1. Resolve `source_tag_slug` to a tag record (if it exists) +2. Resolve `target_tag_slug` to a tag record (create it if it doesn't exist, since it's the canonical tag) +3. Update all `media_reference_tag` rows where `tag_id = source_tag.id` to point at `target_tag.id` +4. Recalculate `media_reference_count` and `unread_media_reference_count` on both tags + +This ensures alias tags always have zero `media_reference_tag` relationships. + +#### `Forager::tag::delete_rule` ```typescript -remove_rule = (params: inputs.TagRuleDelete) => void +delete_rule = (params: inputs.TagRuleDelete) => void ``` Deletes a tag rule by ID. Raises `NotFoundError` if the rule doesn't exist. @@ -246,7 +281,7 @@ class ForagerTagApi extends rpc.ApiController { get = this.context.forager.tag.get update = this.context.forager.tag.update add_rule = this.context.forager.tag.add_rule - remove_rule = this.context.forager.tag.remove_rule + delete_rule = this.context.forager.tag.delete_rule } ``` @@ -290,10 +325,12 @@ Renders search results as a table with columns: | Column | Source | |--------|--------| -| Tag (colored, with group prefix) | `name`, `group`, `color` — links to `/tags/[id]` | +| Tag (colored, with group prefix) | `name`, `group`, `color` — links to `/tags/[slug]` | | Group | `group` | | Media Count | `media_reference_count` | | Unread Count | `unread_media_reference_count` | +| Aliases | alias rules where this tag is source or target | +| Parents | parent rules where this tag is a child | | Created At | `created_at` | | Updated At | `updated_at` | @@ -301,44 +338,44 @@ Includes pagination controls (next/previous) driven by the cursor from the searc --- -## Phase 8: Web — Tag Detail/Edit Page (`/tags/[id]`) +## Phase 8: Web — Tag Detail/Edit Page (`/tags/[slug]`) -A page for viewing and editing a single tag's properties and relationships. +A page for viewing and editing a single tag's properties and relationships. The route uses the tag slug (e.g. `/tags/genre:adventure` or `/tags/favorite`) as the URL parameter. -### New file: `packages/web/src/routes/tags/[id]/+page.svelte` +### New file: `packages/web/src/routes/tags/[slug]/+page.svelte` -Top-level page component. Loads tag details on mount via `client.forager.tag.get({ id })`, then renders the following sections: +Top-level page component. Extracts the slug from the route param and loads tag details on mount via `client.forager.tag.get({ slug })`, then renders the following sections: #### Section 1: Tag Info (editable) - **Name**: text input, pre-filled with current name - **Group**: text input (or dropdown of existing groups), pre-filled with current group - **Description**: textarea, pre-filled with current description -- **Save button**: calls `client.forager.tag.update({ id, name, group, description })` and reloads +- **Save button**: calls `client.forager.tag.update({ slug, name, group, description })` and reloads. If name or group changed, the slug changes too — navigate to the new `/tags/[new_slug]` URL after save. #### Section 2: Aliases -- **Alias target**: if this tag is an alias for another tag, display the canonical tag (linked to its `/tags/[id]` page) with a "Remove" button that calls `remove_rule` +- **Alias target**: if this tag is an alias for another tag, display the canonical tag (linked to its `/tags/[slug]` page) with a "Remove" button that calls `delete_rule` - **Alias list**: tags that are aliases *for* this tag (this tag is canonical). Each row shows the alias tag (linked) with a "Remove" button -- **Add alias**: a `TagAutoCompleteInput` to search for and select a tag, then a button to call `client.forager.tag.add_rule({ tag_id: selected_tag.id, related_tag_id: this_tag.id, kind: 'alias' })` +- **Add alias**: a `TagAutoCompleteInput` to search for and select a tag, then a button to call `client.forager.tag.add_rule({ source_tag_slug: selected_tag.slug, target_tag_slug: this_tag.slug, kind: 'alias' })` #### Section 3: Parent/Child Relationships - **Children** (tags automatically included when this tag is applied): - - List of child tags, each linked to `/tags/[id]` with a "Remove" button - - "Add child" input using `TagAutoCompleteInput`, calls `add_rule({ tag_id: child.id, related_tag_id: this_tag.id, kind: 'parent' })` + - List of child tags, each linked to `/tags/[slug]` with a "Remove" button + - "Add child" input using `TagAutoCompleteInput`, calls `add_rule({ source_tag_slug: child.slug, target_tag_slug: this_tag.slug, kind: 'parent' })` - **Parents** (tags that automatically include this tag): - - List of parent tags, each linked to `/tags/[id]` with a "Remove" button - - "Add parent" input using `TagAutoCompleteInput`, calls `add_rule({ tag_id: this_tag.id, related_tag_id: parent.id, kind: 'parent' })` + - List of parent tags, each linked to `/tags/[slug]` with a "Remove" button + - "Add parent" input using `TagAutoCompleteInput`, calls `add_rule({ source_tag_slug: this_tag.slug, target_tag_slug: parent.slug, kind: 'parent' })` -### New file: `packages/web/src/routes/tags/[id]/controller.ts` +### New file: `packages/web/src/routes/tags/[slug]/controller.ts` `TagDetailController` extending `BaseController` with: - Reactive state for the loaded `TagDetail` - Editable draft state for name/group/description -- Methods: `load()`, `save()`, `add_rule()`, `remove_rule()` that call the RPC client and refresh state +- Methods: `load()`, `save()`, `add_rule()`, `delete_rule()` that call the RPC client and refresh state --- @@ -348,13 +385,14 @@ Top-level page component. Loads tag details on mount via `client.forager.tag.get Tests for the new backend functionality: -- **`Forager::tag::get`** — fetch a tag, verify aliases/parents/children are empty initially -- **`Forager::tag::update`** — update name, group, description; verify changes persist; verify group migration works -- **`Forager::tag::add_rule` (alias)** — create an alias rule, verify it appears in `get` for both tags +- **`Forager::tag::get`** — fetch a tag by slug, verify aliases/parents/children are empty initially +- **`Forager::tag::update`** — update name, group, description; verify changes persist; verify group migration works; verify slug updates and `tag_rule` rows referencing the old slug are updated +- **`Forager::tag::add_rule` (alias)** — create an alias rule, verify it appears in `get` for both tags; verify `media_reference_tag` rows are migrated from alias to canonical tag; verify alias tag ends up with zero `media_reference_count` - **`Forager::tag::add_rule` (parent)** — create a parent rule, verify children/parents in `get` -- **`Forager::tag::remove_rule`** — remove a rule, verify it disappears from `get` +- **`Forager::tag::delete_rule`** — delete a rule, verify it disappears from `get` - **Validation** — self-reference rejected, circular alias rejected, duplicate rule rejected -- **Migration v9** — verify `alias_tag_id` is gone, `tag_rule` table exists +- **Migration v9** — verify `alias_tag_id` is gone, `slug` column exists, `tag_rule` table exists +- **Slug-based rule persistence** — verify rules survive when the referenced tag doesn't exist yet --- @@ -364,8 +402,8 @@ Tests for the new backend functionality: | File | Description | |------|-------------| -| `packages/core/src/db/migrations/migration_v9.ts` | Migration: create `tag_rule` table, remove `alias_tag_id` from `tag` | -| `packages/core/src/models/tag_rule.ts` | `TagRule` model with CRUD queries | +| `packages/core/src/db/migrations/migration_v9.ts` | Migration: create `tag_rule` table, add `slug` to `tag`, remove `alias_tag_id` | +| `packages/core/src/models/tag_rule.ts` | `TagRule` model with CRUD queries (slug-based) | | `packages/core/src/inputs/tag_rule_inputs.ts` | Zod schemas for `TagGet`, `TagUpdate`, `TagRuleCreate`, `TagRuleDelete` | | `packages/core/test/tag.test.ts` | Tests for new tag action methods | | `packages/web/src/routes/tags/+page.svelte` | Tag search page | @@ -373,8 +411,8 @@ Tests for the new backend functionality: | `packages/web/src/routes/tags/runes/queryparams.svelte.ts` | Tag search query params rune | | `packages/web/src/routes/tags/components/TagSearchParams.svelte` | Tag search controls component | | `packages/web/src/routes/tags/components/TagSearchResults.svelte` | Tag search results table component | -| `packages/web/src/routes/tags/[id]/+page.svelte` | Tag detail/edit page | -| `packages/web/src/routes/tags/[id]/controller.ts` | Tag detail page controller | +| `packages/web/src/routes/tags/[slug]/+page.svelte` | Tag detail/edit page | +| `packages/web/src/routes/tags/[slug]/controller.ts` | Tag detail page controller | ### Modified Files @@ -382,24 +420,24 @@ Tests for the new backend functionality: |------|--------| | `packages/core/src/db/migrations/mod.ts` | Add `import './migration_v9.ts'` | | `packages/core/src/models/mod.ts` | Add `export {TagRule} from './tag_rule.ts'` | -| `packages/core/src/models/tag.ts` | Remove `alias_tag_id` from schema and `#create` query | -| `packages/core/src/actions/tag_actions.ts` | Add `get`, `update`, `add_rule`, `remove_rule` methods | -| `packages/core/src/actions/lib/base.ts` | Remove `alias_tag_id: null` from `tag_create()` | -| `packages/core/src/actions/series_actions.ts` | Remove `alias_tag_id: null` from `get_or_create` calls | +| `packages/core/src/models/tag.ts` | Add `slug` field, remove `alias_tag_id` from schema and `#create` query, add `#select_by_slug` query | +| `packages/core/src/actions/tag_actions.ts` | Add `get`, `update`, `add_rule`, `delete_rule` methods | +| `packages/core/src/actions/lib/base.ts` | Remove `alias_tag_id: null`, add `slug` to `tag_create()` | +| `packages/core/src/actions/series_actions.ts` | Remove `alias_tag_id: null`, add `slug` to `get_or_create` calls | | `packages/core/src/inputs/lib/inputs_parsers.ts` | Add `export * from '~/inputs/tag_rule_inputs.ts'` | | `packages/core/src/inputs/lib/inputs_types.ts` | Add `TagGet`, `TagUpdate`, `TagRuleCreate`, `TagRuleDelete` types | -| `packages/web/src/lib/api.ts` | Expose `get`, `update`, `add_rule`, `remove_rule` on `ForagerTagApi` | +| `packages/web/src/lib/api.ts` | Expose `get`, `update`, `add_rule`, `delete_rule` on `ForagerTagApi` | --- ## Implementation Order -1. Migration v9 (Phase 1) +1. Migration v9 — `tag_rule` table, `tag.slug` column, drop `alias_tag_id` (Phase 1) 2. `TagRule` model (Phase 2) -3. Remove `alias_tag_id` (Phase 3) +3. Update `Tag` model — add `slug`, remove `alias_tag_id` (Phase 3) 4. Input schemas (Phase 4) -5. `TagActions` expansion (Phase 5) +5. `TagActions` expansion — `get`, `update`, `add_rule`, `delete_rule` (Phase 5) 6. Tests (Phase 9 — run early to validate backend) 7. RPC API (Phase 6) 8. Tag search page `/tags` (Phase 7) -9. Tag detail page `/tags/[id]` (Phase 8) +9. Tag detail page `/tags/[slug]` (Phase 8) From ef30891abc242eddfd90f3a1848e740062ff5caf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 00:53:13 +0000 Subject: [PATCH 03/53] docs: split tag_rule into separate tag_alias and tag_parent tables The polymorphic tag_rule table provided no real benefit since aliases and parents have fundamentally different logic, side-effects, validation, UI, and action methods. Separate tables and models keep each concept self-contained. Action interface updated to: Forager::tag::alias_create / alias_delete Forager::tag::parent_create / parent_delete Input schemas, models, migration, RPC, and web pages updated throughout. Co-authored-by: andykais-claude --- docs/design/tag-management-features.md | 223 +++++++++++++++++-------- 1 file changed, 155 insertions(+), 68 deletions(-) diff --git a/docs/design/tag-management-features.md b/docs/design/tag-management-features.md index 01568804..35f4feae 100644 --- a/docs/design/tag-management-features.md +++ b/docs/design/tag-management-features.md @@ -4,30 +4,40 @@ This document outlines the plan for adding tag management capabilities to Forager. The work spans both `@forager/core` (backend) and `@forager/web` (frontend), introducing: -- A new `tag_rule` database table for alias and parent/child relationships, keyed by tag slugs +- New `tag_alias` and `tag_parent` database tables for alias and parent/child relationships, keyed by tag slugs - A new `slug` column on the `tag` table for fast slug-based lookups -- New action methods on `Forager::tag` for CRUD operations on tags and rules +- New action methods on `Forager::tag` for CRUD operations on tags, aliases, and parents - Two new web pages: a tag search page (`/tags`) and a tag detail/edit page (`/tags/[slug]`) - Removal of the existing naive `tag.alias_tag_id` column -### Key Design Decision: Slug-Based References in `tag_rule` +### Key Design Decision: Slug-Based References -Alias tags should have zero `media_reference_tag` relationships — when a tag is made an alias for a canonical tag, all existing `media_reference_tag` rows pointing at the alias are migrated to the canonical tag. Since tags with zero references get cleaned up by `Tag::delete_unreferenced`, the `tag_rule` table references tags by their slugified identifier (`${group}:${name}`) rather than by foreign key ID. This has two benefits: +Alias tags should have zero `media_reference_tag` relationships — when a tag is made an alias for a canonical tag, all existing `media_reference_tag` rows pointing at the alias are migrated to the canonical tag. Since tags with zero references get cleaned up by `Tag::delete_unreferenced`, the `tag_alias` and `tag_parent` tables reference tags by their slugified identifier (`${group}:${name}`) rather than by foreign key ID. This has two benefits: 1. Rules survive tag deletion/recreation cycles 2. Rules can be imported *ahead* of the tags actually existing in the database +### Key Design Decision: Separate Tables for Aliases and Parents + +Aliases and parent/child relationships are stored in separate tables (`tag_alias` and `tag_parent`) rather than a single polymorphic `tag_rule` table. The two concepts are fundamentally different: + +- **Aliases** have heavy side-effects (migrating `media_reference_tag` rows, the source tag potentially being garbage-collected) +- **Parents** are purely declarative (no row migration, both tags remain active) + +They have different validation logic, different UI sections, different action methods, and their schemas may evolve independently (e.g. `tag_parent` might gain an `order` column). Separate tables and models keep each concept self-contained with no runtime type discrimination. + --- -## Phase 1: Database — New `tag_rule` Table, `tag.slug` Column & Migration +## Phase 1: Database — New Tables, `tag.slug` Column & Migration ### New file: `packages/core/src/db/migrations/migration_v9.ts` A new migration that: 1. Adds a `slug` column to the `tag` table -2. Creates the `tag_rule` table (slug-referenced) -3. Removes the `alias_tag_id` column from `tag` +2. Creates the `tag_alias` table +3. Creates the `tag_parent` table +4. Removes the `alias_tag_id` column from `tag` #### `tag.slug` column @@ -44,27 +54,41 @@ CREATE UNIQUE INDEX tag_slug ON tag (slug); The `slug` column stores the canonical `group:name` identifier (e.g. `genre:adventure`, or just `favorite` for the default group). It is set on tag creation and updated whenever name or group changes. -#### `tag_rule` schema +#### `tag_alias` schema ```sql -CREATE TABLE tag_rule ( +CREATE TABLE tag_alias ( id INTEGER PRIMARY KEY NOT NULL, source_tag_slug TEXT NOT NULL, target_tag_slug TEXT NOT NULL, - kind TEXT NOT NULL CHECK (kind IN ('alias', 'parent')), updated_at TIMESTAMP DATETIME DEFAULT(STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')), created_at TIMESTAMP DATETIME DEFAULT(STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')), - UNIQUE(source_tag_slug, target_tag_slug, kind) + UNIQUE(source_tag_slug, target_tag_slug) ); ``` -Note: no foreign keys to `tag` — rules reference tags by slug and can exist independently of the tags themselves. +`source_tag_slug` is the alias, `target_tag_slug` is the canonical tag. When media is tagged with the source, all `media_reference_tag` rows are migrated to point at the target. -The `kind` column distinguishes rule types: +No foreign keys to `tag` — aliases reference tags by slug and can exist independently. + +#### `tag_parent` schema + +```sql +CREATE TABLE tag_parent ( + id INTEGER PRIMARY KEY NOT NULL, + source_tag_slug TEXT NOT NULL, + target_tag_slug TEXT NOT NULL, + updated_at TIMESTAMP DATETIME DEFAULT(STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')), + created_at TIMESTAMP DATETIME DEFAULT(STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'NOW')), + + UNIQUE(source_tag_slug, target_tag_slug) +); +``` -- **`alias`**: `source_tag_slug` is an alias for `target_tag_slug` (the canonical tag). When media is tagged with the source, all `media_reference_tag` rows are migrated to point at the target. -- **`parent`**: `source_tag_slug` is a child of `target_tag_slug`. When `target_tag_slug` (the parent) is applied, `source_tag_slug` (the child) is implicitly included. +`source_tag_slug` is the child, `target_tag_slug` is the parent. When the parent is applied, the child is implicitly included. + +No foreign keys to `tag` — parent rules reference tags by slug and can exist independently. #### Removing `alias_tag_id` @@ -78,17 +102,16 @@ Add `import './migration_v9.ts'` to the migration list. --- -## Phase 2: New Model — `TagRule` +## Phase 2: New Models — `TagAlias` and `TagParent` -### New file: `packages/core/src/models/tag_rule.ts` +### New file: `packages/core/src/models/tag_alias.ts` ```typescript -class TagRule extends Model { - static schema = schema('tag_rule', { +class TagAlias extends Model { + static schema = schema('tag_alias', { id: field.number(), source_tag_slug: field.string(), target_tag_slug: field.string(), - kind: field.string(), // 'alias' | 'parent' updated_at: field.datetime(), created_at: field.datetime(), }) @@ -101,17 +124,49 @@ Queries to include: |-------|-------------| | `#create` | `INSERT ... RETURNING id` | | `#delete_by_id` | `DELETE ... WHERE id = ?` | -| `#select_aliases_for_tag` | `SELECT ... WHERE target_tag_slug = ? AND kind = 'alias'` — slugs that are aliases *for* a canonical tag | -| `#select_alias_target` | `SELECT ... WHERE source_tag_slug = ? AND kind = 'alias'` — the canonical tag slug an alias points to | -| `#select_children` | `SELECT ... WHERE target_tag_slug = ? AND kind = 'parent'` — child tag slugs of a parent | -| `#select_parents` | `SELECT ... WHERE source_tag_slug = ? AND kind = 'parent'` — parent tag slugs of a child | -| `#select_by_slug` | `SELECT ... WHERE source_tag_slug = ? OR target_tag_slug = ?` — all rules involving a tag slug | +| `#select_by_source` | `SELECT ... WHERE source_tag_slug = ?` — the canonical tag this alias points to | +| `#select_by_target` | `SELECT ... WHERE target_tag_slug = ?` — all aliases that point to a canonical tag | +| `#select_by_slug` | `SELECT ... WHERE source_tag_slug = ? OR target_tag_slug = ?` — all alias rules involving a slug | +| `#update_slug` | `UPDATE ... SET source_tag_slug/target_tag_slug = ? WHERE ... = ?` — for slug renames | + +Note: these queries do *not* join through `tag`/`tag_group` since the referenced tags may not exist yet. The action layer resolves slugs to full tag records when available. + +### New file: `packages/core/src/models/tag_parent.ts` + +```typescript +class TagParent extends Model { + static schema = schema('tag_parent', { + id: field.number(), + source_tag_slug: field.string(), + target_tag_slug: field.string(), + updated_at: field.datetime(), + created_at: field.datetime(), + }) +} +``` -Note: these queries do *not* join through `tag`/`tag_group` since the referenced tags may not exist yet. The action layer is responsible for resolving slugs to full tag records when available. +Queries to include: + +| Query | Description | +|-------|-------------| +| `#create` | `INSERT ... RETURNING id` | +| `#delete_by_id` | `DELETE ... WHERE id = ?` | +| `#select_children` | `SELECT ... WHERE target_tag_slug = ?` — child tag slugs of a parent | +| `#select_parents` | `SELECT ... WHERE source_tag_slug = ?` — parent tag slugs of a child | +| `#select_by_slug` | `SELECT ... WHERE source_tag_slug = ? OR target_tag_slug = ?` — all parent rules involving a slug | +| `#update_slug` | `UPDATE ... SET source_tag_slug/target_tag_slug = ? WHERE ... = ?` — for slug renames | + +Same note as above: no joins to `tag`/`tag_group`. ### Modified file: `packages/core/src/models/mod.ts` -Add `export {TagRule} from './tag_rule.ts'`. This automatically registers it with `ForagerTorm` via the existing `forager_models` iteration in `db/mod.ts`. +Add: +```typescript +export {TagAlias} from './tag_alias.ts' +export {TagParent} from './tag_parent.ts' +``` + +Both are automatically registered with `ForagerTorm` via the existing `forager_models` iteration in `db/mod.ts`. --- @@ -150,7 +205,7 @@ Same removal of `alias_tag_id: null` and addition of `slug` in `Tag.get_or_creat ## Phase 4: New Input Schemas -### New file: `packages/core/src/inputs/tag_rule_inputs.ts` +### New file: `packages/core/src/inputs/tag_management_inputs.ts` Zod schemas for the new operations: @@ -166,20 +221,28 @@ export const TagUpdate = z.object({ description: z.string().optional(), }) -export const TagRuleCreate = z.object({ +export const TagAliasCreate = z.object({ + source_tag_slug: z.string(), + target_tag_slug: z.string(), +}) + +export const TagAliasDelete = z.object({ + id: z.number().int(), +}) + +export const TagParentCreate = z.object({ source_tag_slug: z.string(), target_tag_slug: z.string(), - kind: z.enum(['alias', 'parent']), }) -export const TagRuleDelete = z.object({ +export const TagParentDelete = z.object({ id: z.number().int(), }) ``` ### Modified file: `packages/core/src/inputs/lib/inputs_parsers.ts` -Add `export * from '~/inputs/tag_rule_inputs.ts'` +Add `export * from '~/inputs/tag_management_inputs.ts'` ### Modified file: `packages/core/src/inputs/lib/inputs_types.ts` @@ -188,8 +251,10 @@ Add input types: ```typescript export type TagGet = z.input export type TagUpdate = z.input -export type TagRuleCreate = z.input -export type TagRuleDelete = z.input +export type TagAliasCreate = z.input +export type TagAliasDelete = z.input +export type TagParentCreate = z.input +export type TagParentDelete = z.input ``` --- @@ -232,21 +297,20 @@ Since rules are slug-based and can exist independently of the tags, each relatio update = (params: inputs.TagUpdate) => void ``` -Updates a tag's name, group, and/or description. If the group name changes, the tag is moved to the new group (creating it via `TagGroup.get_or_create` if needed). Also updates the `slug` column and any `tag_rule` rows that reference the old slug. Validates the new name/group don't collide with an existing tag. +Updates a tag's name, group, and/or description. If the group name changes, the tag is moved to the new group (creating it via `TagGroup.get_or_create` if needed). Also updates the `slug` column and any `tag_alias` / `tag_parent` rows that reference the old slug. Validates the new name/group don't collide with an existing tag. -#### `Forager::tag::add_rule` +#### `Forager::tag::alias_create` ```typescript -add_rule = (params: inputs.TagRuleCreate) => { id: number } +alias_create = (params: inputs.TagAliasCreate) => { id: number } ``` -Creates an alias or parent/child relationship between two tag slugs. Validations: +Creates an alias relationship. `source_tag_slug` becomes an alias for `target_tag_slug` (the canonical tag). Validations: - `source_tag_slug !== target_tag_slug` (no self-references) -- For alias rules: a tag cannot be both a canonical tag and an alias simultaneously -- For parent rules: no circular parent/child chains +- A tag cannot be both a canonical tag and an alias simultaneously -**Alias side-effects**: When an alias rule is created and the source tag currently exists with `media_reference_tag` rows, those rows are migrated to the target tag: +**Side-effects**: When an alias is created and the source tag currently exists with `media_reference_tag` rows, those rows are migrated to the target tag: 1. Resolve `source_tag_slug` to a tag record (if it exists) 2. Resolve `target_tag_slug` to a tag record (create it if it doesn't exist, since it's the canonical tag) @@ -255,13 +319,32 @@ Creates an alias or parent/child relationship between two tag slugs. Validations This ensures alias tags always have zero `media_reference_tag` relationships. -#### `Forager::tag::delete_rule` +#### `Forager::tag::alias_delete` + +```typescript +alias_delete = (params: inputs.TagAliasDelete) => void +``` + +Deletes a tag alias by ID. Raises `NotFoundError` if the alias doesn't exist. + +#### `Forager::tag::parent_create` + +```typescript +parent_create = (params: inputs.TagParentCreate) => { id: number } +``` + +Creates a parent/child relationship. `source_tag_slug` (child) is implicitly included when `target_tag_slug` (parent) is applied. Validations: + +- `source_tag_slug !== target_tag_slug` (no self-references) +- No circular parent/child chains + +#### `Forager::tag::parent_delete` ```typescript -delete_rule = (params: inputs.TagRuleDelete) => void +parent_delete = (params: inputs.TagParentDelete) => void ``` -Deletes a tag rule by ID. Raises `NotFoundError` if the rule doesn't exist. +Deletes a parent rule by ID. Raises `NotFoundError` if the rule doesn't exist. #### Unchanged @@ -280,8 +363,10 @@ class ForagerTagApi extends rpc.ApiController { search = this.context.forager.tag.search get = this.context.forager.tag.get update = this.context.forager.tag.update - add_rule = this.context.forager.tag.add_rule - delete_rule = this.context.forager.tag.delete_rule + alias_create = this.context.forager.tag.alias_create + alias_delete = this.context.forager.tag.alias_delete + parent_create = this.context.forager.tag.parent_create + parent_delete = this.context.forager.tag.parent_delete } ``` @@ -355,19 +440,19 @@ Top-level page component. Extracts the slug from the route param and loads tag d #### Section 2: Aliases -- **Alias target**: if this tag is an alias for another tag, display the canonical tag (linked to its `/tags/[slug]` page) with a "Remove" button that calls `delete_rule` +- **Alias target**: if this tag is an alias for another tag, display the canonical tag (linked to its `/tags/[slug]` page) with a "Remove" button that calls `alias_delete` - **Alias list**: tags that are aliases *for* this tag (this tag is canonical). Each row shows the alias tag (linked) with a "Remove" button -- **Add alias**: a `TagAutoCompleteInput` to search for and select a tag, then a button to call `client.forager.tag.add_rule({ source_tag_slug: selected_tag.slug, target_tag_slug: this_tag.slug, kind: 'alias' })` +- **Add alias**: a `TagAutoCompleteInput` to search for and select a tag, then a button to call `client.forager.tag.alias_create({ source_tag_slug: selected_tag.slug, target_tag_slug: this_tag.slug })` #### Section 3: Parent/Child Relationships - **Children** (tags automatically included when this tag is applied): - List of child tags, each linked to `/tags/[slug]` with a "Remove" button - - "Add child" input using `TagAutoCompleteInput`, calls `add_rule({ source_tag_slug: child.slug, target_tag_slug: this_tag.slug, kind: 'parent' })` + - "Add child" input using `TagAutoCompleteInput`, calls `parent_create({ source_tag_slug: child.slug, target_tag_slug: this_tag.slug })` - **Parents** (tags that automatically include this tag): - List of parent tags, each linked to `/tags/[slug]` with a "Remove" button - - "Add parent" input using `TagAutoCompleteInput`, calls `add_rule({ source_tag_slug: this_tag.slug, target_tag_slug: parent.slug, kind: 'parent' })` + - "Add parent" input using `TagAutoCompleteInput`, calls `parent_create({ source_tag_slug: this_tag.slug, target_tag_slug: parent.slug })` ### New file: `packages/web/src/routes/tags/[slug]/controller.ts` @@ -375,7 +460,7 @@ Top-level page component. Extracts the slug from the route param and loads tag d - Reactive state for the loaded `TagDetail` - Editable draft state for name/group/description -- Methods: `load()`, `save()`, `add_rule()`, `delete_rule()` that call the RPC client and refresh state +- Methods: `load()`, `save()`, `alias_create()`, `alias_delete()`, `parent_create()`, `parent_delete()` that call the RPC client and refresh state --- @@ -386,12 +471,13 @@ Top-level page component. Extracts the slug from the route param and loads tag d Tests for the new backend functionality: - **`Forager::tag::get`** — fetch a tag by slug, verify aliases/parents/children are empty initially -- **`Forager::tag::update`** — update name, group, description; verify changes persist; verify group migration works; verify slug updates and `tag_rule` rows referencing the old slug are updated -- **`Forager::tag::add_rule` (alias)** — create an alias rule, verify it appears in `get` for both tags; verify `media_reference_tag` rows are migrated from alias to canonical tag; verify alias tag ends up with zero `media_reference_count` -- **`Forager::tag::add_rule` (parent)** — create a parent rule, verify children/parents in `get` -- **`Forager::tag::delete_rule`** — delete a rule, verify it disappears from `get` -- **Validation** — self-reference rejected, circular alias rejected, duplicate rule rejected -- **Migration v9** — verify `alias_tag_id` is gone, `slug` column exists, `tag_rule` table exists +- **`Forager::tag::update`** — update name, group, description; verify changes persist; verify group migration works; verify slug updates and `tag_alias`/`tag_parent` rows referencing the old slug are updated +- **`Forager::tag::alias_create`** — create an alias, verify it appears in `get` for both tags; verify `media_reference_tag` rows are migrated from alias to canonical tag; verify alias tag ends up with zero `media_reference_count` +- **`Forager::tag::alias_delete`** — delete an alias, verify it disappears from `get` +- **`Forager::tag::parent_create`** — create a parent rule, verify children/parents in `get` +- **`Forager::tag::parent_delete`** — delete a parent rule, verify it disappears from `get` +- **Validation** — self-reference rejected, circular alias rejected, circular parent rejected, duplicate rule rejected +- **Migration v9** — verify `alias_tag_id` is gone, `slug` column exists, `tag_alias` and `tag_parent` tables exist - **Slug-based rule persistence** — verify rules survive when the referenced tag doesn't exist yet --- @@ -402,9 +488,10 @@ Tests for the new backend functionality: | File | Description | |------|-------------| -| `packages/core/src/db/migrations/migration_v9.ts` | Migration: create `tag_rule` table, add `slug` to `tag`, remove `alias_tag_id` | -| `packages/core/src/models/tag_rule.ts` | `TagRule` model with CRUD queries (slug-based) | -| `packages/core/src/inputs/tag_rule_inputs.ts` | Zod schemas for `TagGet`, `TagUpdate`, `TagRuleCreate`, `TagRuleDelete` | +| `packages/core/src/db/migrations/migration_v9.ts` | Migration: create `tag_alias` + `tag_parent` tables, add `slug` to `tag`, remove `alias_tag_id` | +| `packages/core/src/models/tag_alias.ts` | `TagAlias` model with CRUD queries (slug-based) | +| `packages/core/src/models/tag_parent.ts` | `TagParent` model with CRUD queries (slug-based) | +| `packages/core/src/inputs/tag_management_inputs.ts` | Zod schemas for `TagGet`, `TagUpdate`, `TagAliasCreate`, `TagAliasDelete`, `TagParentCreate`, `TagParentDelete` | | `packages/core/test/tag.test.ts` | Tests for new tag action methods | | `packages/web/src/routes/tags/+page.svelte` | Tag search page | | `packages/web/src/routes/tags/controller.ts` | Tag search page controller | @@ -419,24 +506,24 @@ Tests for the new backend functionality: | File | Change | |------|--------| | `packages/core/src/db/migrations/mod.ts` | Add `import './migration_v9.ts'` | -| `packages/core/src/models/mod.ts` | Add `export {TagRule} from './tag_rule.ts'` | +| `packages/core/src/models/mod.ts` | Add `export {TagAlias}` and `export {TagParent}` | | `packages/core/src/models/tag.ts` | Add `slug` field, remove `alias_tag_id` from schema and `#create` query, add `#select_by_slug` query | -| `packages/core/src/actions/tag_actions.ts` | Add `get`, `update`, `add_rule`, `delete_rule` methods | +| `packages/core/src/actions/tag_actions.ts` | Add `get`, `update`, `alias_create`, `alias_delete`, `parent_create`, `parent_delete` methods | | `packages/core/src/actions/lib/base.ts` | Remove `alias_tag_id: null`, add `slug` to `tag_create()` | | `packages/core/src/actions/series_actions.ts` | Remove `alias_tag_id: null`, add `slug` to `get_or_create` calls | -| `packages/core/src/inputs/lib/inputs_parsers.ts` | Add `export * from '~/inputs/tag_rule_inputs.ts'` | -| `packages/core/src/inputs/lib/inputs_types.ts` | Add `TagGet`, `TagUpdate`, `TagRuleCreate`, `TagRuleDelete` types | -| `packages/web/src/lib/api.ts` | Expose `get`, `update`, `add_rule`, `delete_rule` on `ForagerTagApi` | +| `packages/core/src/inputs/lib/inputs_parsers.ts` | Add `export * from '~/inputs/tag_management_inputs.ts'` | +| `packages/core/src/inputs/lib/inputs_types.ts` | Add `TagGet`, `TagUpdate`, `TagAliasCreate`, `TagAliasDelete`, `TagParentCreate`, `TagParentDelete` types | +| `packages/web/src/lib/api.ts` | Expose `get`, `update`, `alias_create`, `alias_delete`, `parent_create`, `parent_delete` on `ForagerTagApi` | --- ## Implementation Order -1. Migration v9 — `tag_rule` table, `tag.slug` column, drop `alias_tag_id` (Phase 1) -2. `TagRule` model (Phase 2) +1. Migration v9 — `tag_alias` + `tag_parent` tables, `tag.slug` column, drop `alias_tag_id` (Phase 1) +2. `TagAlias` + `TagParent` models (Phase 2) 3. Update `Tag` model — add `slug`, remove `alias_tag_id` (Phase 3) 4. Input schemas (Phase 4) -5. `TagActions` expansion — `get`, `update`, `add_rule`, `delete_rule` (Phase 5) +5. `TagActions` expansion — `get`, `update`, `alias_create`, `alias_delete`, `parent_create`, `parent_delete` (Phase 5) 6. Tests (Phase 9 — run early to validate backend) 7. RPC API (Phase 6) 8. Tag search page `/tags` (Phase 7) From 5076a4c0af2b2ef826cbc12065e784e04a950318 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 01:13:28 +0000 Subject: [PATCH 04/53] =?UTF-8?q?impl(core):=20migration=20v9=20=E2=80=94?= =?UTF-8?q?=20tag=5Falias,=20tag=5Fparent=20tables,=20tag.slug,=20drop=20a?= =?UTF-8?q?lias=5Ftag=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add slug column to tag table (populated from tag_group.name:tag.name) - Create tag_alias table with source_tag_slug/target_tag_slug (slug-based) - Create tag_parent table with source_tag_slug/target_tag_slug (slug-based) - Rebuild tag table to remove alias_tag_id (FK constraint requires full rebuild) - Drop and recreate all triggers referencing the tag table - Update seed migration to version 9 with new schema - Update Tag model schema: add slug, remove alias_tag_id - Remove alias_tag_id usage from tag_create() and series_actions - Update migration test expectations for v9 Co-authored-by: andykais-claude --- packages/core/src/actions/lib/base.ts | 2 +- packages/core/src/actions/series_actions.ts | 4 +- .../core/src/db/migrations/migration_v10.ts | 138 ++++++++++++++++++ packages/core/src/db/migrations/mod.ts | 1 + .../core/src/db/migrations/seed_migration.ts | 28 +++- packages/core/src/models/tag.ts | 8 +- packages/core/test/migrations.test.ts | 7 +- 7 files changed, 176 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/db/migrations/migration_v10.ts diff --git a/packages/core/src/actions/lib/base.ts b/packages/core/src/actions/lib/base.ts index 5d13c333..2504cda9 100644 --- a/packages/core/src/actions/lib/base.ts +++ b/packages/core/src/actions/lib/base.ts @@ -269,7 +269,7 @@ abstract class Actions extends Emitter { + console.log(`Adding slug column to tag table`) + this.driver.exec(`ALTER TABLE tag ADD COLUMN slug TEXT`) + this.driver.exec(` + UPDATE tag SET slug = CASE + WHEN tag_group.name = '' THEN tag.name + ELSE tag_group.name || ':' || tag.name + END + FROM tag_group WHERE tag_group.id = tag.tag_group_id + `) + + // DROP COLUMN does not work for alias_tag_id because it has a FOREIGN KEY + // constraint. We must rebuild the table without it. This requires disabling + // foreign key checks, which cannot be done inside a transaction. + this.driver.exec(`PRAGMA foreign_keys = OFF`) + + console.log(`Rebuilding tag table: add slug NOT NULL, remove alias_tag_id`) + this.driver.exec(`BEGIN TRANSACTION`) + + this.driver.exec(`DROP TRIGGER IF EXISTS media_reference_tag_count_inc`) + this.driver.exec(`DROP TRIGGER IF EXISTS media_reference_tag_count_dec`) + this.driver.exec(`DROP TRIGGER IF EXISTS tag_group_count_inc`) + this.driver.exec(`DROP TRIGGER IF EXISTS tag_group_count_dec`) + this.driver.exec(`DROP TRIGGER IF EXISTS unread_media_reference_tag_count_change`) + + this.driver.exec(` + CREATE TABLE tag_new ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + tag_group_id INTEGER NOT NULL, + slug TEXT NOT NULL, + description TEXT, + metadata JSON, + updated_at ${TIMESTAMP_COLUMN}, + created_at ${TIMESTAMP_COLUMN}, + media_reference_count INTEGER NOT NULL DEFAULT 0, + unread_media_reference_count INTEGER NOT NULL DEFAULT 0, + + FOREIGN KEY (tag_group_id) REFERENCES tag_group(id) + ) + `) + this.driver.exec(` + INSERT INTO tag_new (id, name, tag_group_id, slug, description, metadata, updated_at, created_at, media_reference_count, unread_media_reference_count) + SELECT id, name, tag_group_id, slug, description, metadata, updated_at, created_at, media_reference_count, unread_media_reference_count + FROM tag + `) + this.driver.exec(`DROP TABLE tag`) + this.driver.exec(`ALTER TABLE tag_new RENAME TO tag`) + + console.log(`Recreating tag indexes`) + this.driver.exec(`CREATE UNIQUE INDEX tag_name ON tag (name, tag_group_id)`) + this.driver.exec(`CREATE UNIQUE INDEX tag_slug ON tag (slug)`) + + console.log(`Recreating triggers`) + this.driver.exec(` + CREATE TRIGGER media_reference_tag_count_inc AFTER INSERT ON media_reference_tag BEGIN + UPDATE media_reference SET tag_count = tag_count + 1 WHERE NEW.media_reference_id = id; + UPDATE tag SET + updated_at = ${TIMESTAMP_SQLITE}, + media_reference_count = media_reference_count + 1, + unread_media_reference_count = unread_media_reference_count + (SELECT view_count = 0 FROM media_reference WHERE media_reference.id = NEW.media_reference_id) + WHERE NEW.tag_id = id; + END + `) + this.driver.exec(` + CREATE TRIGGER media_reference_tag_count_dec AFTER DELETE ON media_reference_tag BEGIN + UPDATE media_reference SET tag_count = tag_count - 1 WHERE OLD.media_reference_id = id; + UPDATE tag SET + updated_at = ${TIMESTAMP_SQLITE}, + media_reference_count = media_reference_count - 1, + unread_media_reference_count = unread_media_reference_count - (SELECT view_count = 0 FROM media_reference WHERE media_reference.id = OLD.media_reference_id) + WHERE OLD.tag_id = id; + END + `) + this.driver.exec(` + CREATE TRIGGER tag_group_count_inc AFTER INSERT ON tag BEGIN + UPDATE tag_group SET tag_count = tag_count + 1 WHERE NEW.tag_group_id = id; + END + `) + this.driver.exec(` + CREATE TRIGGER tag_group_count_dec AFTER DELETE ON tag BEGIN + UPDATE tag_group SET tag_count = tag_count - 1 WHERE OLD.tag_group_id = id; + END + `) + this.driver.exec(` + CREATE TRIGGER unread_media_reference_tag_count_change AFTER UPDATE ON media_reference + WHEN (NEW.view_count > 0 AND OLD.view_count = 0) OR (NEW.view_count = 0 AND OLD.view_count > 0) + BEGIN + UPDATE tag SET + unread_media_reference_count = unread_media_reference_count - (NEW.view_count > 0) + (NEW.view_count = 0) + WHERE tag.id IN ( + SELECT tag_id as id FROM media_reference_tag WHERE media_reference_id = NEW.id + ); + END + `) + + this.driver.exec(`COMMIT`) + this.driver.exec(`PRAGMA foreign_keys = ON`) + this.driver.exec(`PRAGMA foreign_key_check`) + + console.log(`Creating tag_alias table`) + this.driver.exec(` + CREATE TABLE tag_alias ( + id INTEGER PRIMARY KEY NOT NULL, + source_tag_slug TEXT NOT NULL, + target_tag_slug TEXT NOT NULL, + updated_at ${TIMESTAMP_COLUMN}, + created_at ${TIMESTAMP_COLUMN}, + + UNIQUE(source_tag_slug, target_tag_slug) + ) + `) + + console.log(`Creating tag_parent table`) + this.driver.exec(` + CREATE TABLE tag_parent ( + id INTEGER PRIMARY KEY NOT NULL, + source_tag_slug TEXT NOT NULL, + target_tag_slug TEXT NOT NULL, + updated_at ${TIMESTAMP_COLUMN}, + created_at ${TIMESTAMP_COLUMN}, + + UNIQUE(source_tag_slug, target_tag_slug) + ) + `) + } +} diff --git a/packages/core/src/db/migrations/mod.ts b/packages/core/src/db/migrations/mod.ts index 2b8824ba..6688868e 100644 --- a/packages/core/src/db/migrations/mod.ts +++ b/packages/core/src/db/migrations/mod.ts @@ -7,5 +7,6 @@ import './migration_v6.ts' import './migration_v7.ts' import './migration_v8.ts' import './migration_v9.ts' +import './migration_v10.ts' export { migrations } from './registry.ts' diff --git a/packages/core/src/db/migrations/seed_migration.ts b/packages/core/src/db/migrations/seed_migration.ts index f610f1c4..de861d1c 100644 --- a/packages/core/src/db/migrations/seed_migration.ts +++ b/packages/core/src/db/migrations/seed_migration.ts @@ -4,7 +4,7 @@ import { migrations, sql, TIMESTAMP_SQLITE, TIMESTAMP_COLUMN, TIMESTAMP_COLUMN_O @migrations.register() export class Migration extends torm.SeedMigration { - version = 9 + version = 10 sql = sql` CREATE TABLE media_file ( @@ -143,8 +143,7 @@ export class Migration extends torm.SeedMigration { id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, tag_group_id INTEGER NOT NULL, - -- some tags will just be aliases for others. We have to be careful not to have cyclical references here - alias_tag_id INTEGER, + slug TEXT NOT NULL, description TEXT, metadata JSON, updated_at ${TIMESTAMP_COLUMN}, @@ -153,11 +152,31 @@ export class Migration extends torm.SeedMigration { media_reference_count INTEGER NOT NULL DEFAULT 0, unread_media_reference_count INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY (alias_tag_id) REFERENCES tag(id), FOREIGN KEY (tag_group_id) REFERENCES tag_group(id) ); + CREATE TABLE tag_alias ( + id INTEGER PRIMARY KEY NOT NULL, + source_tag_slug TEXT NOT NULL, + target_tag_slug TEXT NOT NULL, + updated_at ${TIMESTAMP_COLUMN}, + created_at ${TIMESTAMP_COLUMN}, + + UNIQUE(source_tag_slug, target_tag_slug) + ); + + CREATE TABLE tag_parent ( + id INTEGER PRIMARY KEY NOT NULL, + source_tag_slug TEXT NOT NULL, + target_tag_slug TEXT NOT NULL, + updated_at ${TIMESTAMP_COLUMN}, + created_at ${TIMESTAMP_COLUMN}, + + UNIQUE(source_tag_slug, target_tag_slug) + ); + + CREATE TABLE tag_group ( id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL UNIQUE, @@ -264,6 +283,7 @@ export class Migration extends torm.SeedMigration { CREATE UNIQUE INDEX media_series_name ON media_reference (media_series_name) WHERE media_series_name IS NOT NULL; CREATE UNIQUE INDEX media_series_index ON media_series_item (media_reference_id, series_index); CREATE UNIQUE INDEX tag_name ON tag (name, tag_group_id); + CREATE UNIQUE INDEX tag_slug ON tag (slug); CREATE UNIQUE INDEX media_file_reference ON media_file (media_reference_id); CREATE UNIQUE INDEX media_filepath ON media_file (filepath); CREATE INDEX media_reference_edit_log ON edit_log (media_reference_id, created_at); diff --git a/packages/core/src/models/tag.ts b/packages/core/src/models/tag.ts index 71007a52..fa660cf7 100644 --- a/packages/core/src/models/tag.ts +++ b/packages/core/src/models/tag.ts @@ -18,8 +18,8 @@ class Tag extends Model { static schema = torm.schema('tag', { id: field.number(), name: field.string(), - tag_group_id: field.number(), // TODO support schema references (TagGroup::schema::id) - alias_tag_id: field.number().optional(), // TODO support lazy references? This is a Tag::schema::id + tag_group_id: field.number(), + slug: field.string(), description: field.string().optional(), metadata: field.json().optional(), media_reference_count: field.number(), @@ -36,13 +36,13 @@ class Tag extends Model { INSERT INTO tag ( tag_group_id, name, - alias_tag_id, + slug, description, metadata ) VALUES (${[ Tag.params.tag_group_id, Tag.params.name, - Tag.params.alias_tag_id, + Tag.params.slug, Tag.params.description, Tag.params.metadata ]}) RETURNING ${Tag.result.id}, ${Tag.result.tag_group_id}` diff --git a/packages/core/test/migrations.test.ts b/packages/core/test/migrations.test.ts index 816da6d2..ec238805 100644 --- a/packages/core/test/migrations.test.ts +++ b/packages/core/test/migrations.test.ts @@ -3,7 +3,7 @@ import * as fs from '@std/fs' import * as path from '@std/path' import { Forager } from '~/mod.ts' -const CURRENT_VERSION = 9 +const CURRENT_VERSION = 10 test('migrate from v1 schema', async (ctx) => { const forager_v1_path = ctx.create_fixture_path('forager_v1') @@ -62,6 +62,11 @@ test('migrate from v1 schema', async (ctx) => { next_version: 9, backup: true, }, + { + start_version: 9, + next_version: 10, + backup: true, + }, ] ctx.assert.equals(v1_migration_info.db.migration_operations, expected_migration_operations) From 1122f88eb53776e9f8c973e6364d8cbefd6ff29d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 01:43:33 +0000 Subject: [PATCH 05/53] impl(core): use explicit indexes for tag_alias and tag_parent uniqueness Replace inline UNIQUE constraints with named indexes for both tables: - tag_alias_pair (unique), tag_alias_source, tag_alias_target - tag_parent_pair (unique), tag_parent_source, tag_parent_target These indexes will be needed for efficient slug-based lookups in the TagAlias and TagParent models. Co-authored-by: andykais-claude --- packages/core/src/db/migrations/migration_v10.ts | 14 ++++++++------ packages/core/src/db/migrations/seed_migration.ts | 14 ++++++++------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/core/src/db/migrations/migration_v10.ts b/packages/core/src/db/migrations/migration_v10.ts index 5152b4a0..222ab0e5 100644 --- a/packages/core/src/db/migrations/migration_v10.ts +++ b/packages/core/src/db/migrations/migration_v10.ts @@ -116,11 +116,12 @@ export class Migration extends torm.Migration { source_tag_slug TEXT NOT NULL, target_tag_slug TEXT NOT NULL, updated_at ${TIMESTAMP_COLUMN}, - created_at ${TIMESTAMP_COLUMN}, - - UNIQUE(source_tag_slug, target_tag_slug) + created_at ${TIMESTAMP_COLUMN} ) `) + this.driver.exec(`CREATE UNIQUE INDEX tag_alias_pair ON tag_alias (source_tag_slug, target_tag_slug)`) + this.driver.exec(`CREATE INDEX tag_alias_source ON tag_alias (source_tag_slug)`) + this.driver.exec(`CREATE INDEX tag_alias_target ON tag_alias (target_tag_slug)`) console.log(`Creating tag_parent table`) this.driver.exec(` @@ -129,10 +130,11 @@ export class Migration extends torm.Migration { source_tag_slug TEXT NOT NULL, target_tag_slug TEXT NOT NULL, updated_at ${TIMESTAMP_COLUMN}, - created_at ${TIMESTAMP_COLUMN}, - - UNIQUE(source_tag_slug, target_tag_slug) + created_at ${TIMESTAMP_COLUMN} ) `) + this.driver.exec(`CREATE UNIQUE INDEX tag_parent_pair ON tag_parent (source_tag_slug, target_tag_slug)`) + this.driver.exec(`CREATE INDEX tag_parent_source ON tag_parent (source_tag_slug)`) + this.driver.exec(`CREATE INDEX tag_parent_target ON tag_parent (target_tag_slug)`) } } diff --git a/packages/core/src/db/migrations/seed_migration.ts b/packages/core/src/db/migrations/seed_migration.ts index de861d1c..d63d9a58 100644 --- a/packages/core/src/db/migrations/seed_migration.ts +++ b/packages/core/src/db/migrations/seed_migration.ts @@ -161,9 +161,7 @@ export class Migration extends torm.SeedMigration { source_tag_slug TEXT NOT NULL, target_tag_slug TEXT NOT NULL, updated_at ${TIMESTAMP_COLUMN}, - created_at ${TIMESTAMP_COLUMN}, - - UNIQUE(source_tag_slug, target_tag_slug) + created_at ${TIMESTAMP_COLUMN} ); CREATE TABLE tag_parent ( @@ -171,9 +169,7 @@ export class Migration extends torm.SeedMigration { source_tag_slug TEXT NOT NULL, target_tag_slug TEXT NOT NULL, updated_at ${TIMESTAMP_COLUMN}, - created_at ${TIMESTAMP_COLUMN}, - - UNIQUE(source_tag_slug, target_tag_slug) + created_at ${TIMESTAMP_COLUMN} ); @@ -284,6 +280,12 @@ export class Migration extends torm.SeedMigration { CREATE UNIQUE INDEX media_series_index ON media_series_item (media_reference_id, series_index); CREATE UNIQUE INDEX tag_name ON tag (name, tag_group_id); CREATE UNIQUE INDEX tag_slug ON tag (slug); + CREATE UNIQUE INDEX tag_alias_pair ON tag_alias (source_tag_slug, target_tag_slug); + CREATE INDEX tag_alias_source ON tag_alias (source_tag_slug); + CREATE INDEX tag_alias_target ON tag_alias (target_tag_slug); + CREATE UNIQUE INDEX tag_parent_pair ON tag_parent (source_tag_slug, target_tag_slug); + CREATE INDEX tag_parent_source ON tag_parent (source_tag_slug); + CREATE INDEX tag_parent_target ON tag_parent (target_tag_slug); CREATE UNIQUE INDEX media_file_reference ON media_file (media_reference_id); CREATE UNIQUE INDEX media_filepath ON media_file (filepath); CREATE INDEX media_reference_edit_log ON edit_log (media_reference_id, created_at); From c0a3af8b8983d2c2311b2786c31fb8f3e2e4e30f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 02:09:49 +0000 Subject: [PATCH 06/53] impl(core): add TagAlias and TagParent models - TagAlias: create, delete, select_by_source, select_all_by_target, update_source_slug, update_target_slug - TagParent: create, delete, select_children, select_parents, update_source_slug, update_target_slug - Register both in models/mod.ts and add result types Co-authored-by: andykais-claude --- packages/core/src/models/lib/result_types.ts | 2 + packages/core/src/models/mod.ts | 2 + packages/core/src/models/tag_alias.ts | 71 +++++++++++++++++++ packages/core/src/models/tag_parent.ts | 73 ++++++++++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 packages/core/src/models/tag_alias.ts create mode 100644 packages/core/src/models/tag_parent.ts diff --git a/packages/core/src/models/lib/result_types.ts b/packages/core/src/models/lib/result_types.ts index 0cde47f5..e79e8bf2 100644 --- a/packages/core/src/models/lib/result_types.ts +++ b/packages/core/src/models/lib/result_types.ts @@ -8,6 +8,8 @@ export type MediaFile = InferSchemaTypes export type MediaThumbnail = InferSchemaTypes export type TagGroup = InferSchemaTypes export type Tag = InferSchemaTypes & {group: TagGroup['name']; color: TagGroup['color']} +export type TagAlias = InferSchemaTypes +export type TagParent = InferSchemaTypes export type MediaKeypoint = InferSchemaTypes export type View = InferSchemaTypes export type EditLog = InferSchemaTypes diff --git a/packages/core/src/models/mod.ts b/packages/core/src/models/mod.ts index 2c72758b..4586db0f 100644 --- a/packages/core/src/models/mod.ts +++ b/packages/core/src/models/mod.ts @@ -3,6 +3,8 @@ export {MediaReference} from './media_reference.ts' export {MediaThumbnail} from './media_thumbnail.ts' export {TagGroup} from './tag_group.ts' export {Tag} from './tag.ts' +export {TagAlias} from './tag_alias.ts' +export {TagParent} from './tag_parent.ts' export {MediaReferenceTag} from './media_reference_tag.ts' export {MediaSeriesItem} from './media_series_item.ts' export {MediaKeypoint} from './media_keypoint.ts' diff --git a/packages/core/src/models/tag_alias.ts b/packages/core/src/models/tag_alias.ts new file mode 100644 index 00000000..645ad5b2 --- /dev/null +++ b/packages/core/src/models/tag_alias.ts @@ -0,0 +1,71 @@ +import * as torm from '@torm/sqlite' +import { Model, field } from '~/models/lib/base.ts' + + +class TagAlias extends Model { + static schema = torm.schema('tag_alias', { + id: field.number(), + source_tag_slug: field.string(), + target_tag_slug: field.string(), + updated_at: field.datetime(), + created_at: field.datetime(), + }) + static params = this.schema.params + static result = this.schema.result + + #create = this.query.one` + INSERT INTO tag_alias (source_tag_slug, target_tag_slug) + VALUES (${[TagAlias.params.source_tag_slug, TagAlias.params.target_tag_slug]}) + RETURNING ${TagAlias.result.id}` + + #delete_by_id = this.query.exec` + DELETE FROM tag_alias WHERE id = ${TagAlias.params.id}` + + #select_by_id = this.query` + SELECT ${TagAlias.result['*']} FROM tag_alias + WHERE id = ${TagAlias.params.id}` + + #select_by_source = this.query` + SELECT ${TagAlias.result['*']} FROM tag_alias + WHERE source_tag_slug = ${TagAlias.params.source_tag_slug}` + + #select_by_target = this.query` + SELECT ${TagAlias.result['*']} FROM tag_alias + WHERE target_tag_slug = ${TagAlias.params.target_tag_slug}` + + #update_source_slug = this.query.exec` + UPDATE tag_alias SET source_tag_slug = ${TagAlias.params.source_tag_slug} + WHERE source_tag_slug = ${TagAlias.params.target_tag_slug}` + + #update_target_slug = this.query.exec` + UPDATE tag_alias SET target_tag_slug = ${TagAlias.params.target_tag_slug} + WHERE target_tag_slug = ${TagAlias.params.source_tag_slug}` + + public create = this.create_fn(this.#create) + + public delete = this.delete_fn(this.#delete_by_id) + + public select_one = this.select_one_fn(this.#select_by_id.one) + + /** Get the alias target for a source tag (what this tag is an alias of) */ + public select_by_source(params: { source_tag_slug: string }) { + return this.#select_by_source.one(params) + } + + /** Get all aliases that point to a canonical tag */ + public select_all_by_target(params: { target_tag_slug: string }) { + return this.#select_by_target.all(params) + } + + /** Rename a slug across all alias rows where it appears as source */ + public update_source_slug(params: { source_tag_slug: string; target_tag_slug: string }) { + return this.#update_source_slug({ source_tag_slug: params.source_tag_slug, target_tag_slug: params.target_tag_slug }) + } + + /** Rename a slug across all alias rows where it appears as target */ + public update_target_slug(params: { source_tag_slug: string; target_tag_slug: string }) { + return this.#update_target_slug({ source_tag_slug: params.source_tag_slug, target_tag_slug: params.target_tag_slug }) + } +} + +export { TagAlias } diff --git a/packages/core/src/models/tag_parent.ts b/packages/core/src/models/tag_parent.ts new file mode 100644 index 00000000..b01947ac --- /dev/null +++ b/packages/core/src/models/tag_parent.ts @@ -0,0 +1,73 @@ +import * as torm from '@torm/sqlite' +import { Model, field } from '~/models/lib/base.ts' + + +class TagParent extends Model { + static schema = torm.schema('tag_parent', { + id: field.number(), + source_tag_slug: field.string(), + target_tag_slug: field.string(), + updated_at: field.datetime(), + created_at: field.datetime(), + }) + static params = this.schema.params + static result = this.schema.result + + #create = this.query.one` + INSERT INTO tag_parent (source_tag_slug, target_tag_slug) + VALUES (${[TagParent.params.source_tag_slug, TagParent.params.target_tag_slug]}) + RETURNING ${TagParent.result.id}` + + #delete_by_id = this.query.exec` + DELETE FROM tag_parent WHERE id = ${TagParent.params.id}` + + #select_by_id = this.query` + SELECT ${TagParent.result['*']} FROM tag_parent + WHERE id = ${TagParent.params.id}` + + /** Select children of a parent tag (source is child, target is parent) */ + #select_children = this.query` + SELECT ${TagParent.result['*']} FROM tag_parent + WHERE target_tag_slug = ${TagParent.params.target_tag_slug}` + + /** Select parents of a child tag */ + #select_parents = this.query` + SELECT ${TagParent.result['*']} FROM tag_parent + WHERE source_tag_slug = ${TagParent.params.source_tag_slug}` + + #update_source_slug = this.query.exec` + UPDATE tag_parent SET source_tag_slug = ${TagParent.params.source_tag_slug} + WHERE source_tag_slug = ${TagParent.params.target_tag_slug}` + + #update_target_slug = this.query.exec` + UPDATE tag_parent SET target_tag_slug = ${TagParent.params.target_tag_slug} + WHERE target_tag_slug = ${TagParent.params.source_tag_slug}` + + public create = this.create_fn(this.#create) + + public delete = this.delete_fn(this.#delete_by_id) + + public select_one = this.select_one_fn(this.#select_by_id.one) + + /** Get child tag slugs for a parent */ + public select_children(params: { target_tag_slug: string }) { + return this.#select_children.all(params) + } + + /** Get parent tag slugs for a child */ + public select_parents(params: { source_tag_slug: string }) { + return this.#select_parents.all(params) + } + + /** Rename a slug across all parent rows where it appears as source (child) */ + public update_source_slug(params: { source_tag_slug: string; target_tag_slug: string }) { + return this.#update_source_slug({ source_tag_slug: params.source_tag_slug, target_tag_slug: params.target_tag_slug }) + } + + /** Rename a slug across all parent rows where it appears as target (parent) */ + public update_target_slug(params: { source_tag_slug: string; target_tag_slug: string }) { + return this.#update_target_slug({ source_tag_slug: params.source_tag_slug, target_tag_slug: params.target_tag_slug }) + } +} + +export { TagParent } From f4a5e8e17ec79c83d7747025ca9991de96c8f466 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 02:25:43 +0000 Subject: [PATCH 07/53] impl(core): add slug-based lookup to Tag model Add #select_by_slug query and wire it into #select_one_impl so tags can be looked up by slug (e.g. Tag.select_one({slug: 'genre:adventure'})). Co-authored-by: andykais-claude --- packages/core/src/models/tag.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/core/src/models/tag.ts b/packages/core/src/models/tag.ts index fa660cf7..de5e96d3 100644 --- a/packages/core/src/models/tag.ts +++ b/packages/core/src/models/tag.ts @@ -69,6 +69,11 @@ class Tag extends Model { INNER JOIN tag_group ON tag_group.id = tag.tag_group_id WHERE tag_group_id = ${Tag.params.tag_group_id} AND tag.name = ${Tag.params.name}` + #select_by_slug = this.query` + SELECT ${Tag.result['*']}, ${TagGroup.result.name.as('group')}, ${TagGroup.result.color} FROM tag + INNER JOIN tag_group ON tag_group.id = tag.tag_group_id + WHERE tag.slug = ${Tag.params.slug}` + #select_by_media_reference_id = this.query` SELECT ${Tag.result['*']}, ${TagGroup.result.name.as('group')}, ${TagGroup.result.color}, ${MediaReferenceTag.result.editor} FROM tag INNER JOIN media_reference_tag ON media_reference_tag.tag_id = tag.id @@ -84,12 +89,18 @@ class Tag extends Model { name?: string tag_group_id?: number group?: string + slug?: string }): (torm.InferSchemaTypes & {group: string; color: string}) | undefined { if ( params.id !== undefined && Object.keys(params).length === 1 ) { return this.#select_by_id.one({id: params.id}) + } else if ( + params.slug !== undefined && + Object.keys(params).length === 1 + ) { + return this.#select_by_slug.one({slug: params.slug}) } else if ( params.name !== undefined && params.group !== undefined && From f47b58be5fbb6fb896479bf4897ec6e716b00cda Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 02:33:08 +0000 Subject: [PATCH 08/53] impl(core): add input schemas for tag management New Zod schemas in tag_management_inputs.ts: - TagGet, TagUpdate - TagAliasCreate, TagAliasDelete - TagParentCreate, TagParentDelete Exported via inputs_parsers.ts and inputs_types.ts. Co-authored-by: andykais-claude --- .../core/src/inputs/lib/inputs_parsers.ts | 1 + packages/core/src/inputs/lib/inputs_types.ts | 6 ++++ .../core/src/inputs/tag_management_inputs.ts | 31 +++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 packages/core/src/inputs/tag_management_inputs.ts diff --git a/packages/core/src/inputs/lib/inputs_parsers.ts b/packages/core/src/inputs/lib/inputs_parsers.ts index 301780ee..927c6fad 100644 --- a/packages/core/src/inputs/lib/inputs_parsers.ts +++ b/packages/core/src/inputs/lib/inputs_parsers.ts @@ -8,3 +8,4 @@ export * from '~/inputs/keypoint_inputs.ts' export * from '~/inputs/view_inputs.ts' export * from '~/inputs/forager_config_inputs.ts' export * from '~/inputs/ingest_inputs.ts' +export * from '~/inputs/tag_management_inputs.ts' diff --git a/packages/core/src/inputs/lib/inputs_types.ts b/packages/core/src/inputs/lib/inputs_types.ts index 73429c0c..7b992b92 100644 --- a/packages/core/src/inputs/lib/inputs_types.ts +++ b/packages/core/src/inputs/lib/inputs_types.ts @@ -17,6 +17,12 @@ export type MediaThumbnailGet = z.input export type Tag = z.input export type TagSearch = z.input export type TagList = z.input +export type TagGet = z.input +export type TagUpdate = z.input +export type TagAliasCreate = z.input +export type TagAliasDelete = z.input +export type TagParentCreate = z.input +export type TagParentDelete = z.input export type SeriesItem = z.input export type SeriesGet = z.input diff --git a/packages/core/src/inputs/tag_management_inputs.ts b/packages/core/src/inputs/tag_management_inputs.ts new file mode 100644 index 00000000..fd2a9b90 --- /dev/null +++ b/packages/core/src/inputs/tag_management_inputs.ts @@ -0,0 +1,31 @@ +import z from 'zod' + + +export const TagGet = z.object({ + slug: z.string(), +}) + +export const TagUpdate = z.object({ + slug: z.string(), + name: z.string().optional(), + group: z.string().optional(), + description: z.string().optional(), +}) + +export const TagAliasCreate = z.object({ + source_tag_slug: z.string(), + target_tag_slug: z.string(), +}) + +export const TagAliasDelete = z.object({ + id: z.number().int(), +}) + +export const TagParentCreate = z.object({ + source_tag_slug: z.string(), + target_tag_slug: z.string(), +}) + +export const TagParentDelete = z.object({ + id: z.number().int(), +}) From 7d398f21388669cac5c620e2504f3b38f67ec915 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 02:50:07 +0000 Subject: [PATCH 09/53] impl(core): expand TagActions with get, update, alias, and parent methods New action methods on Forager::tag: - get(slug): fetch tag detail with aliases, parents, children - update(slug, name?, group?, description?): edit tag properties, recompute slug, update tag_alias/tag_parent references - alias_create(source, target): create alias with media_reference_tag migration from source to target - alias_delete(id): remove alias - parent_create(source, target): create parent/child with cycle detection - parent_delete(id): remove parent/child Supporting changes: - Tag model: add #update query - MediaReferenceTag model: add select_all_by_tag_id query Co-authored-by: andykais-claude --- packages/core/src/actions/tag_actions.ts | 204 ++++++++++++++++++ .../core/src/models/media_reference_tag.ts | 8 + packages/core/src/models/tag.ts | 10 + 3 files changed, 222 insertions(+) diff --git a/packages/core/src/actions/tag_actions.ts b/packages/core/src/actions/tag_actions.ts index 21495069..8d7b2ea2 100644 --- a/packages/core/src/actions/tag_actions.ts +++ b/packages/core/src/actions/tag_actions.ts @@ -1,12 +1,37 @@ import { Actions } from '~/actions/lib/base.ts' import { type inputs, parsers } from '~/inputs/mod.ts' import type { SelectManyFilters } from '~/models/media_reference.ts' +import { tag_slug_format } from '~/inputs/tag_inputs.ts' +import { get_hash_color } from '~/lib/text_processor.ts' +import * as errors from '~/lib/errors.ts' +import type * as result_types from '~/models/lib/result_types.ts' + + +interface TagRuleRef { + slug: string + tag?: result_types.Tag +} + +/** + * The full detail view for a single tag, including alias and parent/child relationships. + */ +export interface TagDetail { + tag: result_types.Tag + aliases: TagRuleRef[] + alias_target: TagRuleRef | null + children: TagRuleRef[] + parents: TagRuleRef[] +} /** * Actions associated with tag management in forager */ class TagActions extends Actions { + + /** + * Search for tags with optional filtering and pagination. + */ search = (params?: inputs.TagSearch) => { const parsed = parsers.TagSearch.parse(params ?? {}) @@ -44,6 +69,185 @@ class TagActions extends Actions { contextual_query: contextual_query, }) } + + /** + * Get a single tag by slug with its full relationship graph (aliases, parents, children). + */ + get = (params: inputs.TagGet): TagDetail => { + const parsed = parsers.TagGet.parse(params) + const tag = this.models.Tag.select_one({ slug: parsed.slug }, { or_raise: true }) + + const alias_target_row = this.models.TagAlias.select_by_source({ source_tag_slug: tag.slug }) + const alias_rows = this.models.TagAlias.select_all_by_target({ target_tag_slug: tag.slug }) + const child_rows = this.models.TagParent.select_children({ target_tag_slug: tag.slug }) + const parent_rows = this.models.TagParent.select_parents({ source_tag_slug: tag.slug }) + + const resolve_slug = (slug: string): TagRuleRef => { + const resolved = this.models.Tag.select_one({ slug }) + return resolved ? { slug, tag: resolved } : { slug } + } + + return { + tag, + alias_target: alias_target_row ? resolve_slug(alias_target_row.target_tag_slug) : null, + aliases: alias_rows.map(row => resolve_slug(row.source_tag_slug)), + children: child_rows.map(row => resolve_slug(row.source_tag_slug)), + parents: parent_rows.map(row => resolve_slug(row.target_tag_slug)), + } + } + + /** + * Update a tag's name, group, and/or description. If name or group changes, the slug is + * recomputed and any tag_alias/tag_parent rows referencing the old slug are updated. + */ + update = (params: inputs.TagUpdate): void => { + const parsed = parsers.TagUpdate.parse(params) + const tag = this.models.Tag.select_one({ slug: parsed.slug }, { or_raise: true }) + + const new_name = parsed.name ?? tag.name + const new_group = parsed.group ?? tag.group + const new_slug = tag_slug_format({ name: new_name, group: new_group }) + + let new_tag_group_id: number | undefined + if (parsed.group !== undefined && parsed.group !== tag.group) { + const color = get_hash_color(parsed.group, 'hsl') + const tag_group = this.models.TagGroup.get_or_create({ name: parsed.group, color })! + new_tag_group_id = tag_group.id + } + + if (new_slug !== tag.slug) { + const existing = this.models.Tag.select_one({ slug: new_slug }) + if (existing) { + throw new errors.BadInputError(`Tag with slug '${new_slug}' already exists`) + } + } + + this.models.Tag.update({ + id: tag.id, + name: new_name, + tag_group_id: new_tag_group_id ?? tag.tag_group_id, + slug: new_slug, + description: parsed.description ?? tag.description, + }) + + if (new_slug !== tag.slug) { + this.models.TagAlias.update_source_slug({ source_tag_slug: new_slug, target_tag_slug: tag.slug }) + this.models.TagAlias.update_target_slug({ source_tag_slug: tag.slug, target_tag_slug: new_slug }) + this.models.TagParent.update_source_slug({ source_tag_slug: new_slug, target_tag_slug: tag.slug }) + this.models.TagParent.update_target_slug({ source_tag_slug: tag.slug, target_tag_slug: new_slug }) + } + } + + /** + * Create an alias relationship. The source tag becomes an alias for the target (canonical) tag. + * Any existing media_reference_tag rows on the source are migrated to the target. + */ + alias_create = (params: inputs.TagAliasCreate): { id: number } => { + const parsed = parsers.TagAliasCreate.parse(params) + + if (parsed.source_tag_slug === parsed.target_tag_slug) { + throw new errors.BadInputError(`A tag cannot be an alias of itself`) + } + + const existing_alias = this.models.TagAlias.select_by_source({ source_tag_slug: parsed.source_tag_slug }) + if (existing_alias) { + throw new errors.BadInputError(`Tag '${parsed.source_tag_slug}' is already an alias of '${existing_alias.target_tag_slug}'`) + } + + const target_is_alias = this.models.TagAlias.select_by_source({ source_tag_slug: parsed.target_tag_slug }) + if (target_is_alias) { + throw new errors.BadInputError(`Tag '${parsed.target_tag_slug}' is itself an alias of '${target_is_alias.target_tag_slug}' and cannot be a canonical tag`) + } + + const { id } = this.models.TagAlias.create({ + source_tag_slug: parsed.source_tag_slug, + target_tag_slug: parsed.target_tag_slug, + }) + + this.#migrate_alias_tags(parsed.source_tag_slug, parsed.target_tag_slug) + + return { id } + } + + /** + * Delete an alias relationship by ID. + */ + alias_delete = (params: inputs.TagAliasDelete): void => { + const parsed = parsers.TagAliasDelete.parse(params) + this.models.TagAlias.select_one({ id: parsed.id }, { or_raise: true }) + this.models.TagAlias.delete({ id: parsed.id }, { expected_deletes: 1 }) + } + + /** + * Create a parent/child relationship. The source tag (child) is implicitly included + * when the target tag (parent) is applied. + */ + parent_create = (params: inputs.TagParentCreate): { id: number } => { + const parsed = parsers.TagParentCreate.parse(params) + + if (parsed.source_tag_slug === parsed.target_tag_slug) { + throw new errors.BadInputError(`A tag cannot be its own parent`) + } + + // Check for circular parent chains: target must not already be a descendant of source + this.#check_circular_parents(parsed.source_tag_slug, parsed.target_tag_slug) + + const { id } = this.models.TagParent.create({ + source_tag_slug: parsed.source_tag_slug, + target_tag_slug: parsed.target_tag_slug, + }) + + return { id } + } + + /** + * Delete a parent/child relationship by ID. + */ + parent_delete = (params: inputs.TagParentDelete): void => { + const parsed = parsers.TagParentDelete.parse(params) + this.models.TagParent.select_one({ id: parsed.id }, { or_raise: true }) + this.models.TagParent.delete({ id: parsed.id }, { expected_deletes: 1 }) + } + + /** + * Migrate media_reference_tag rows from the source (alias) tag to the target (canonical) tag. + * Uses delete + get_or_create to properly trigger count updates. + */ + #migrate_alias_tags(source_slug: string, target_slug: string) { + const source_tag = this.models.Tag.select_one({ slug: source_slug }) + if (!source_tag || source_tag.media_reference_count === 0) return + + const target_tag_record = this.models.Tag.select_one({ slug: target_slug }) + if (!target_tag_record) return + + const source_rows = this.models.MediaReferenceTag.select_all_by_tag_id({ tag_id: source_tag.id }) + for (const row of source_rows) { + this.models.MediaReferenceTag.delete({ media_reference_id: row.media_reference_id, tag_id: source_tag.id }) + this.models.MediaReferenceTag.get_or_create({ + media_reference_id: row.media_reference_id, + tag_id: target_tag_record.id, + tag_group_id: target_tag_record.tag_group_id, + editor: row.editor, + }) + } + } + + /** Walk the parent chain to detect circular references before inserting. */ + #check_circular_parents(source_slug: string, target_slug: string) { + const visited = new Set([source_slug]) + const queue = [target_slug] + while (queue.length > 0) { + const current = queue.pop()! + if (visited.has(current)) { + throw new errors.BadInputError(`Circular parent/child relationship detected: '${source_slug}' -> '${target_slug}' would create a cycle`) + } + visited.add(current) + const parents = this.models.TagParent.select_parents({ source_tag_slug: current }) + for (const parent of parents) { + queue.push(parent.target_tag_slug) + } + } + } } diff --git a/packages/core/src/models/media_reference_tag.ts b/packages/core/src/models/media_reference_tag.ts index 1243be3e..8ebf6aa3 100644 --- a/packages/core/src/models/media_reference_tag.ts +++ b/packages/core/src/models/media_reference_tag.ts @@ -41,6 +41,10 @@ class MediaReferenceTag extends Model { DELETE FROM media_reference_tag WHERE media_reference_id = ${MediaReferenceTag.params.media_reference_id} AND tag_id = ${MediaReferenceTag.params.tag_id}` + #select_by_tag_id = this.query` + SELECT ${MediaReferenceTag.result['*']} FROM media_reference_tag + WHERE tag_id = ${MediaReferenceTag.params.tag_id}` + #delete_impl(params: { media_reference_id: number tag_id?: number @@ -59,6 +63,10 @@ class MediaReferenceTag extends Model { public select_one = this.select_one_fn(this.#select_one_by_media_reference_and_tag) + public select_all_by_tag_id(params: { tag_id: number }) { + return this.#select_by_tag_id.all(params) + } + public get_or_create(params: Parameters[0]) { try { return this.create(params) diff --git a/packages/core/src/models/tag.ts b/packages/core/src/models/tag.ts index de5e96d3..212d295f 100644 --- a/packages/core/src/models/tag.ts +++ b/packages/core/src/models/tag.ts @@ -80,6 +80,14 @@ class Tag extends Model { INNER JOIN tag_group ON tag_group.id = tag.tag_group_id WHERE media_reference_tag.media_reference_id = ${MediaReferenceTag.params.media_reference_id} ORDER BY tag.media_reference_count DESC, tag.updated_at DESC, tag.id DESC` + #update = this.query.exec` + UPDATE tag SET + name = ${Tag.params.name}, + tag_group_id = ${Tag.params.tag_group_id}, + slug = ${Tag.params.slug}, + description = ${Tag.params.description} + WHERE id = ${Tag.params.id}` + #delete_by_count = this.query` DELETE FROM tag WHERE tag.media_reference_count = 0` @@ -122,6 +130,8 @@ class Tag extends Model { public create = this.create_fn(this.#create) + public update = this.#update + public select_paginated(params: { limit: number cursor: PaginatedResult['cursor'] From b3881eca2d3b7d81ad4f77dc8df5e97fd80943f6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 03:17:15 +0000 Subject: [PATCH 10/53] impl(core): address PR feedback on tag management - Replace TagRuleRef with result_types.Tag in TagDetail interface - Move tag_slug_format to private #format_slug method on TagActions - TagAliasCreate/TagParentCreate inputs now accept Tag structure (name+group or shorthand) instead of raw slug strings - alias_create wraps create + migration in a transaction - parent_create wraps create + media propagation in a transaction - parent_create adds parent tag to all existing media with child tag - tag.update throws if tag rules exist when renaming (name/group change) - tag.update collision error includes other tag's id - TagAlias model: field comments explaining source=alias, target=canonical - TagParent model: field comments explaining source=child, target=parent Co-authored-by: andykais-claude --- packages/core/src/actions/tag_actions.ts | 136 +++++++++++------- packages/core/src/inputs/lib/inputs_types.ts | 1 + .../core/src/inputs/tag_management_inputs.ts | 9 +- packages/core/src/models/tag_alias.ts | 2 + packages/core/src/models/tag_parent.ts | 2 + 5 files changed, 96 insertions(+), 54 deletions(-) diff --git a/packages/core/src/actions/tag_actions.ts b/packages/core/src/actions/tag_actions.ts index 8d7b2ea2..0740a1ae 100644 --- a/packages/core/src/actions/tag_actions.ts +++ b/packages/core/src/actions/tag_actions.ts @@ -7,20 +7,15 @@ import * as errors from '~/lib/errors.ts' import type * as result_types from '~/models/lib/result_types.ts' -interface TagRuleRef { - slug: string - tag?: result_types.Tag -} - /** * The full detail view for a single tag, including alias and parent/child relationships. */ export interface TagDetail { tag: result_types.Tag - aliases: TagRuleRef[] - alias_target: TagRuleRef | null - children: TagRuleRef[] - parents: TagRuleRef[] + aliases: result_types.Tag[] + alias_target: result_types.Tag | null + children: result_types.Tag[] + parents: result_types.Tag[] } @@ -29,6 +24,10 @@ export interface TagDetail { */ class TagActions extends Actions { + #format_slug(tag: { name: string; group?: string }): string { + return tag_slug_format(tag) + } + /** * Search for tags with optional filtering and pagination. */ @@ -82,23 +81,23 @@ class TagActions extends Actions { const child_rows = this.models.TagParent.select_children({ target_tag_slug: tag.slug }) const parent_rows = this.models.TagParent.select_parents({ source_tag_slug: tag.slug }) - const resolve_slug = (slug: string): TagRuleRef => { - const resolved = this.models.Tag.select_one({ slug }) - return resolved ? { slug, tag: resolved } : { slug } + const resolve_slug = (slug: string): result_types.Tag | undefined => { + return this.models.Tag.select_one({ slug }) } return { tag, - alias_target: alias_target_row ? resolve_slug(alias_target_row.target_tag_slug) : null, - aliases: alias_rows.map(row => resolve_slug(row.source_tag_slug)), - children: child_rows.map(row => resolve_slug(row.source_tag_slug)), - parents: parent_rows.map(row => resolve_slug(row.target_tag_slug)), + alias_target: alias_target_row ? (resolve_slug(alias_target_row.target_tag_slug) ?? null) : null, + aliases: alias_rows.map(row => resolve_slug(row.source_tag_slug)).filter((t): t is result_types.Tag => t !== undefined), + children: child_rows.map(row => resolve_slug(row.source_tag_slug)).filter((t): t is result_types.Tag => t !== undefined), + parents: parent_rows.map(row => resolve_slug(row.target_tag_slug)).filter((t): t is result_types.Tag => t !== undefined), } } /** * Update a tag's name, group, and/or description. If name or group changes, the slug is * recomputed and any tag_alias/tag_parent rows referencing the old slug are updated. + * Throws if any tag rules reference this tag and name/group is being changed. */ update = (params: inputs.TagUpdate): void => { const parsed = parsers.TagUpdate.parse(params) @@ -106,7 +105,22 @@ class TagActions extends Actions { const new_name = parsed.name ?? tag.name const new_group = parsed.group ?? tag.group - const new_slug = tag_slug_format({ name: new_name, group: new_group }) + const new_slug = this.#format_slug({ name: new_name, group: new_group }) + + if (new_slug !== tag.slug) { + const existing = this.models.Tag.select_one({ slug: new_slug }) + if (existing) { + throw new errors.BadInputError(`Tag with slug '${new_slug}' already exists (id: ${existing.id})`) + } + + const has_alias_as_source = this.models.TagAlias.select_by_source({ source_tag_slug: tag.slug }) + const has_alias_as_target = this.models.TagAlias.select_all_by_target({ target_tag_slug: tag.slug }) + const has_parent_as_source = this.models.TagParent.select_parents({ source_tag_slug: tag.slug }) + const has_parent_as_target = this.models.TagParent.select_children({ target_tag_slug: tag.slug }) + if (has_alias_as_source || has_alias_as_target.length || has_parent_as_source.length || has_parent_as_target.length) { + throw new errors.BadInputError(`Cannot rename tag '${tag.slug}' because it has existing tag rules. Remove the rules first.`) + } + } let new_tag_group_id: number | undefined if (parsed.group !== undefined && parsed.group !== tag.group) { @@ -115,13 +129,6 @@ class TagActions extends Actions { new_tag_group_id = tag_group.id } - if (new_slug !== tag.slug) { - const existing = this.models.Tag.select_one({ slug: new_slug }) - if (existing) { - throw new errors.BadInputError(`Tag with slug '${new_slug}' already exists`) - } - } - this.models.Tag.update({ id: tag.id, name: new_name, @@ -129,13 +136,6 @@ class TagActions extends Actions { slug: new_slug, description: parsed.description ?? tag.description, }) - - if (new_slug !== tag.slug) { - this.models.TagAlias.update_source_slug({ source_tag_slug: new_slug, target_tag_slug: tag.slug }) - this.models.TagAlias.update_target_slug({ source_tag_slug: tag.slug, target_tag_slug: new_slug }) - this.models.TagParent.update_source_slug({ source_tag_slug: new_slug, target_tag_slug: tag.slug }) - this.models.TagParent.update_target_slug({ source_tag_slug: tag.slug, target_tag_slug: new_slug }) - } } /** @@ -144,29 +144,35 @@ class TagActions extends Actions { */ alias_create = (params: inputs.TagAliasCreate): { id: number } => { const parsed = parsers.TagAliasCreate.parse(params) + const source_slug = this.#format_slug(parsed.source_tag) + const target_slug = this.#format_slug(parsed.target_tag) - if (parsed.source_tag_slug === parsed.target_tag_slug) { + if (source_slug === target_slug) { throw new errors.BadInputError(`A tag cannot be an alias of itself`) } - const existing_alias = this.models.TagAlias.select_by_source({ source_tag_slug: parsed.source_tag_slug }) + const existing_alias = this.models.TagAlias.select_by_source({ source_tag_slug: source_slug }) if (existing_alias) { - throw new errors.BadInputError(`Tag '${parsed.source_tag_slug}' is already an alias of '${existing_alias.target_tag_slug}'`) + throw new errors.BadInputError(`Tag '${source_slug}' is already an alias of '${existing_alias.target_tag_slug}'`) } - const target_is_alias = this.models.TagAlias.select_by_source({ source_tag_slug: parsed.target_tag_slug }) + const target_is_alias = this.models.TagAlias.select_by_source({ source_tag_slug: target_slug }) if (target_is_alias) { - throw new errors.BadInputError(`Tag '${parsed.target_tag_slug}' is itself an alias of '${target_is_alias.target_tag_slug}' and cannot be a canonical tag`) + throw new errors.BadInputError(`Tag '${target_slug}' is itself an alias of '${target_is_alias.target_tag_slug}' and cannot be a canonical tag`) } - const { id } = this.models.TagAlias.create({ - source_tag_slug: parsed.source_tag_slug, - target_tag_slug: parsed.target_tag_slug, - }) + const transaction = this.ctx.db.transaction_sync(() => { + const { id } = this.models.TagAlias.create({ + source_tag_slug: source_slug, + target_tag_slug: target_slug, + }) - this.#migrate_alias_tags(parsed.source_tag_slug, parsed.target_tag_slug) + this.#migrate_alias_tags(source_slug, target_slug) + + return { id } + }) - return { id } + return transaction() } /** @@ -180,24 +186,32 @@ class TagActions extends Actions { /** * Create a parent/child relationship. The source tag (child) is implicitly included - * when the target tag (parent) is applied. + * when the target tag (parent) is applied. Adds the parent tag to all media references + * that currently have the child tag. */ parent_create = (params: inputs.TagParentCreate): { id: number } => { const parsed = parsers.TagParentCreate.parse(params) + const source_slug = this.#format_slug(parsed.source_tag) + const target_slug = this.#format_slug(parsed.target_tag) - if (parsed.source_tag_slug === parsed.target_tag_slug) { + if (source_slug === target_slug) { throw new errors.BadInputError(`A tag cannot be its own parent`) } - // Check for circular parent chains: target must not already be a descendant of source - this.#check_circular_parents(parsed.source_tag_slug, parsed.target_tag_slug) + this.#check_circular_parents(source_slug, target_slug) - const { id } = this.models.TagParent.create({ - source_tag_slug: parsed.source_tag_slug, - target_tag_slug: parsed.target_tag_slug, + const transaction = this.ctx.db.transaction_sync(() => { + const { id } = this.models.TagParent.create({ + source_tag_slug: source_slug, + target_tag_slug: target_slug, + }) + + this.#apply_parent_to_existing_media(source_slug, target_slug) + + return { id } }) - return { id } + return transaction() } /** @@ -232,6 +246,28 @@ class TagActions extends Actions { } } + /** + * When a parent rule is created, add the parent tag to all media references + * that currently have the child (source) tag. + */ + #apply_parent_to_existing_media(child_slug: string, parent_slug: string) { + const child_tag = this.models.Tag.select_one({ slug: child_slug }) + if (!child_tag || child_tag.media_reference_count === 0) return + + const parent_tag = this.models.Tag.select_one({ slug: parent_slug }) + if (!parent_tag) return + + const child_rows = this.models.MediaReferenceTag.select_all_by_tag_id({ tag_id: child_tag.id }) + for (const row of child_rows) { + this.models.MediaReferenceTag.get_or_create({ + media_reference_id: row.media_reference_id, + tag_id: parent_tag.id, + tag_group_id: parent_tag.tag_group_id, + editor: row.editor, + }) + } + } + /** Walk the parent chain to detect circular references before inserting. */ #check_circular_parents(source_slug: string, target_slug: string) { const visited = new Set([source_slug]) diff --git a/packages/core/src/inputs/lib/inputs_types.ts b/packages/core/src/inputs/lib/inputs_types.ts index 7b992b92..c8bc8198 100644 --- a/packages/core/src/inputs/lib/inputs_types.ts +++ b/packages/core/src/inputs/lib/inputs_types.ts @@ -23,6 +23,7 @@ export type TagAliasCreate = z.input export type TagAliasDelete = z.input export type TagParentCreate = z.input export type TagParentDelete = z.input +export type TagDetail = import('~/actions/tag_actions.ts').TagDetail export type SeriesItem = z.input export type SeriesGet = z.input diff --git a/packages/core/src/inputs/tag_management_inputs.ts b/packages/core/src/inputs/tag_management_inputs.ts index fd2a9b90..d80a91cf 100644 --- a/packages/core/src/inputs/tag_management_inputs.ts +++ b/packages/core/src/inputs/tag_management_inputs.ts @@ -1,4 +1,5 @@ import z from 'zod' +import { Tag } from '~/inputs/tag_inputs.ts' export const TagGet = z.object({ @@ -13,8 +14,8 @@ export const TagUpdate = z.object({ }) export const TagAliasCreate = z.object({ - source_tag_slug: z.string(), - target_tag_slug: z.string(), + source_tag: Tag, + target_tag: Tag, }) export const TagAliasDelete = z.object({ @@ -22,8 +23,8 @@ export const TagAliasDelete = z.object({ }) export const TagParentCreate = z.object({ - source_tag_slug: z.string(), - target_tag_slug: z.string(), + source_tag: Tag, + target_tag: Tag, }) export const TagParentDelete = z.object({ diff --git a/packages/core/src/models/tag_alias.ts b/packages/core/src/models/tag_alias.ts index 645ad5b2..079db411 100644 --- a/packages/core/src/models/tag_alias.ts +++ b/packages/core/src/models/tag_alias.ts @@ -5,7 +5,9 @@ import { Model, field } from '~/models/lib/base.ts' class TagAlias extends Model { static schema = torm.schema('tag_alias', { id: field.number(), + /** The slug of the tag that is only an alias (will have zero media_reference_tag rows) */ source_tag_slug: field.string(), + /** The slug of the canonical tag that persists and receives all media associations */ target_tag_slug: field.string(), updated_at: field.datetime(), created_at: field.datetime(), diff --git a/packages/core/src/models/tag_parent.ts b/packages/core/src/models/tag_parent.ts index b01947ac..93c60f77 100644 --- a/packages/core/src/models/tag_parent.ts +++ b/packages/core/src/models/tag_parent.ts @@ -5,7 +5,9 @@ import { Model, field } from '~/models/lib/base.ts' class TagParent extends Model { static schema = torm.schema('tag_parent', { id: field.number(), + /** The slug of the child tag that is implicitly included when the parent is applied */ source_tag_slug: field.string(), + /** The slug of the parent tag — when this tag is applied to media, the child tag is also included */ target_tag_slug: field.string(), updated_at: field.datetime(), created_at: field.datetime(), From 975154677ff13aefa77d0dae8bfd47e353c08a0f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 03:30:03 +0000 Subject: [PATCH 11/53] impl(core): address round 2 PR feedback on TagActions - Rename #migrate_alias_tags to #apply_alias_to_existing_media - Remove #format_slug indirection, use tag_slug_format directly - alias_create now returns TagDetail (for the target/canonical tag) - parent_create now returns TagDetail (for the target/parent tag) - Extract #build_tag_detail helper shared by get/alias_create/parent_create Co-authored-by: andykais-claude --- packages/core/src/actions/tag_actions.ts | 77 ++++++++++++------------ 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/packages/core/src/actions/tag_actions.ts b/packages/core/src/actions/tag_actions.ts index 0740a1ae..10dc03a4 100644 --- a/packages/core/src/actions/tag_actions.ts +++ b/packages/core/src/actions/tag_actions.ts @@ -24,10 +24,6 @@ export interface TagDetail { */ class TagActions extends Actions { - #format_slug(tag: { name: string; group?: string }): string { - return tag_slug_format(tag) - } - /** * Search for tags with optional filtering and pagination. */ @@ -76,22 +72,7 @@ class TagActions extends Actions { const parsed = parsers.TagGet.parse(params) const tag = this.models.Tag.select_one({ slug: parsed.slug }, { or_raise: true }) - const alias_target_row = this.models.TagAlias.select_by_source({ source_tag_slug: tag.slug }) - const alias_rows = this.models.TagAlias.select_all_by_target({ target_tag_slug: tag.slug }) - const child_rows = this.models.TagParent.select_children({ target_tag_slug: tag.slug }) - const parent_rows = this.models.TagParent.select_parents({ source_tag_slug: tag.slug }) - - const resolve_slug = (slug: string): result_types.Tag | undefined => { - return this.models.Tag.select_one({ slug }) - } - - return { - tag, - alias_target: alias_target_row ? (resolve_slug(alias_target_row.target_tag_slug) ?? null) : null, - aliases: alias_rows.map(row => resolve_slug(row.source_tag_slug)).filter((t): t is result_types.Tag => t !== undefined), - children: child_rows.map(row => resolve_slug(row.source_tag_slug)).filter((t): t is result_types.Tag => t !== undefined), - parents: parent_rows.map(row => resolve_slug(row.target_tag_slug)).filter((t): t is result_types.Tag => t !== undefined), - } + return this.#build_tag_detail(tag) } /** @@ -105,7 +86,7 @@ class TagActions extends Actions { const new_name = parsed.name ?? tag.name const new_group = parsed.group ?? tag.group - const new_slug = this.#format_slug({ name: new_name, group: new_group }) + const new_slug = tag_slug_format({ name: new_name, group: new_group }) if (new_slug !== tag.slug) { const existing = this.models.Tag.select_one({ slug: new_slug }) @@ -142,10 +123,10 @@ class TagActions extends Actions { * Create an alias relationship. The source tag becomes an alias for the target (canonical) tag. * Any existing media_reference_tag rows on the source are migrated to the target. */ - alias_create = (params: inputs.TagAliasCreate): { id: number } => { + alias_create = (params: inputs.TagAliasCreate): TagDetail => { const parsed = parsers.TagAliasCreate.parse(params) - const source_slug = this.#format_slug(parsed.source_tag) - const target_slug = this.#format_slug(parsed.target_tag) + const source_slug = tag_slug_format(parsed.source_tag) + const target_slug = tag_slug_format(parsed.target_tag) if (source_slug === target_slug) { throw new errors.BadInputError(`A tag cannot be an alias of itself`) @@ -162,17 +143,18 @@ class TagActions extends Actions { } const transaction = this.ctx.db.transaction_sync(() => { - const { id } = this.models.TagAlias.create({ + this.models.TagAlias.create({ source_tag_slug: source_slug, target_tag_slug: target_slug, }) - this.#migrate_alias_tags(source_slug, target_slug) - - return { id } + this.#apply_alias_to_existing_media(source_slug, target_slug) }) - return transaction() + transaction() + + const target_tag = this.models.Tag.select_one({ slug: target_slug }, { or_raise: true }) + return this.#build_tag_detail(target_tag) } /** @@ -189,10 +171,10 @@ class TagActions extends Actions { * when the target tag (parent) is applied. Adds the parent tag to all media references * that currently have the child tag. */ - parent_create = (params: inputs.TagParentCreate): { id: number } => { + parent_create = (params: inputs.TagParentCreate): TagDetail => { const parsed = parsers.TagParentCreate.parse(params) - const source_slug = this.#format_slug(parsed.source_tag) - const target_slug = this.#format_slug(parsed.target_tag) + const source_slug = tag_slug_format(parsed.source_tag) + const target_slug = tag_slug_format(parsed.target_tag) if (source_slug === target_slug) { throw new errors.BadInputError(`A tag cannot be its own parent`) @@ -201,17 +183,18 @@ class TagActions extends Actions { this.#check_circular_parents(source_slug, target_slug) const transaction = this.ctx.db.transaction_sync(() => { - const { id } = this.models.TagParent.create({ + this.models.TagParent.create({ source_tag_slug: source_slug, target_tag_slug: target_slug, }) this.#apply_parent_to_existing_media(source_slug, target_slug) - - return { id } }) - return transaction() + transaction() + + const target_tag = this.models.Tag.select_one({ slug: target_slug }, { or_raise: true }) + return this.#build_tag_detail(target_tag) } /** @@ -223,11 +206,31 @@ class TagActions extends Actions { this.models.TagParent.delete({ id: parsed.id }, { expected_deletes: 1 }) } + /** Build a TagDetail response for a given tag record. */ + #build_tag_detail(tag: result_types.Tag): TagDetail { + const alias_target_row = this.models.TagAlias.select_by_source({ source_tag_slug: tag.slug }) + const alias_rows = this.models.TagAlias.select_all_by_target({ target_tag_slug: tag.slug }) + const child_rows = this.models.TagParent.select_children({ target_tag_slug: tag.slug }) + const parent_rows = this.models.TagParent.select_parents({ source_tag_slug: tag.slug }) + + const resolve_slug = (slug: string): result_types.Tag | undefined => { + return this.models.Tag.select_one({ slug }) + } + + return { + tag, + alias_target: alias_target_row ? (resolve_slug(alias_target_row.target_tag_slug) ?? null) : null, + aliases: alias_rows.map(row => resolve_slug(row.source_tag_slug)).filter((t): t is result_types.Tag => t !== undefined), + children: child_rows.map(row => resolve_slug(row.source_tag_slug)).filter((t): t is result_types.Tag => t !== undefined), + parents: parent_rows.map(row => resolve_slug(row.target_tag_slug)).filter((t): t is result_types.Tag => t !== undefined), + } + } + /** * Migrate media_reference_tag rows from the source (alias) tag to the target (canonical) tag. * Uses delete + get_or_create to properly trigger count updates. */ - #migrate_alias_tags(source_slug: string, target_slug: string) { + #apply_alias_to_existing_media(source_slug: string, target_slug: string) { const source_tag = this.models.Tag.select_one({ slug: source_slug }) if (!source_tag || source_tag.media_reference_count === 0) return From ec7a125bb231fc8eaae0aca71d888cd8525d2039 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 03:37:16 +0000 Subject: [PATCH 12/53] impl(core): alias_create returns TagAliasResponse, parent_create returns TagParentResponse Both methods now return full response structures with TagDetail for both sides of the relationship plus the rule record itself: - TagAliasResponse: { alias, alias_target, rule } - TagParentResponse: { parent, child, rule } Co-authored-by: andykais-claude --- packages/core/src/actions/tag_actions.ts | 48 +++++++++++++++++++----- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/packages/core/src/actions/tag_actions.ts b/packages/core/src/actions/tag_actions.ts index 10dc03a4..64ec3cf5 100644 --- a/packages/core/src/actions/tag_actions.ts +++ b/packages/core/src/actions/tag_actions.ts @@ -18,6 +18,18 @@ export interface TagDetail { parents: result_types.Tag[] } +export interface TagAliasResponse { + alias: TagDetail + alias_target: TagDetail + rule: result_types.TagAlias +} + +export interface TagParentResponse { + parent: TagDetail + child: TagDetail + rule: result_types.TagParent +} + /** * Actions associated with tag management in forager @@ -123,7 +135,7 @@ class TagActions extends Actions { * Create an alias relationship. The source tag becomes an alias for the target (canonical) tag. * Any existing media_reference_tag rows on the source are migrated to the target. */ - alias_create = (params: inputs.TagAliasCreate): TagDetail => { + alias_create = (params: inputs.TagAliasCreate): TagAliasResponse => { const parsed = parsers.TagAliasCreate.parse(params) const source_slug = tag_slug_format(parsed.source_tag) const target_slug = tag_slug_format(parsed.target_tag) @@ -143,18 +155,26 @@ class TagActions extends Actions { } const transaction = this.ctx.db.transaction_sync(() => { - this.models.TagAlias.create({ + const { id } = this.models.TagAlias.create({ source_tag_slug: source_slug, target_tag_slug: target_slug, }) this.#apply_alias_to_existing_media(source_slug, target_slug) - }) - transaction() + return id + }) + const rule_id = transaction() + const rule = this.models.TagAlias.select_one({ id: rule_id }, { or_raise: true }) + const source_tag = this.models.Tag.select_one({ slug: source_slug }, { or_raise: true }) const target_tag = this.models.Tag.select_one({ slug: target_slug }, { or_raise: true }) - return this.#build_tag_detail(target_tag) + + return { + alias: this.#build_tag_detail(source_tag), + alias_target: this.#build_tag_detail(target_tag), + rule, + } } /** @@ -171,7 +191,7 @@ class TagActions extends Actions { * when the target tag (parent) is applied. Adds the parent tag to all media references * that currently have the child tag. */ - parent_create = (params: inputs.TagParentCreate): TagDetail => { + parent_create = (params: inputs.TagParentCreate): TagParentResponse => { const parsed = parsers.TagParentCreate.parse(params) const source_slug = tag_slug_format(parsed.source_tag) const target_slug = tag_slug_format(parsed.target_tag) @@ -183,18 +203,26 @@ class TagActions extends Actions { this.#check_circular_parents(source_slug, target_slug) const transaction = this.ctx.db.transaction_sync(() => { - this.models.TagParent.create({ + const { id } = this.models.TagParent.create({ source_tag_slug: source_slug, target_tag_slug: target_slug, }) this.#apply_parent_to_existing_media(source_slug, target_slug) - }) - transaction() + return id + }) + const rule_id = transaction() + const rule = this.models.TagParent.select_one({ id: rule_id }, { or_raise: true }) + const source_tag = this.models.Tag.select_one({ slug: source_slug }, { or_raise: true }) const target_tag = this.models.Tag.select_one({ slug: target_slug }, { or_raise: true }) - return this.#build_tag_detail(target_tag) + + return { + child: this.#build_tag_detail(source_tag), + parent: this.#build_tag_detail(target_tag), + rule, + } } /** From db8d029f462d6b4d4b6436602c652e665279a5e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 16:26:42 +0000 Subject: [PATCH 13/53] test(core): add tag management tests for alias, parent, and update Three top-level tests covering new tag management functionality: - tag alias: create with migration, get relationships, duplicate/self/chain validation, delete, and alias-with-parent overlap - tag parent: create with propagation, get relationships, self/circular validation, delete - tag update: rename name/group/description, slug collision, rename blocked by existing rules, nonexistent tag Co-authored-by: andykais-claude --- packages/core/test/tag_management.test.ts | 206 ++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 packages/core/test/tag_management.test.ts diff --git a/packages/core/test/tag_management.test.ts b/packages/core/test/tag_management.test.ts new file mode 100644 index 00000000..15a4f1f4 --- /dev/null +++ b/packages/core/test/tag_management.test.ts @@ -0,0 +1,206 @@ +import { test } from 'forager-test' +import { Forager, errors } from '~/mod.ts' + + +test('tag alias', async (ctx) => { + using forager = new Forager(ctx.get_test_config()) + forager.init() + + await forager.media.create(ctx.resources.media_files['koch.tif'], {}, ['animal:cat', 'wallpaper']) + await forager.media.create(ctx.resources.media_files['ed-edd-eddy.png'], {}, ['animal:cat', 'animal:dog']) + await forager.media.create(ctx.resources.media_files['cat_doodle.jpg'], {}, ['animal:kitty']) + + await ctx.subtest('create alias migrates media_reference_tag rows', () => { + const result = forager.tag.alias_create({ + source_tag: 'animal:kitty', + target_tag: 'animal:cat', + }) + + ctx.assert.object_match(result.rule, { + source_tag_slug: 'animal:kitty', + target_tag_slug: 'animal:cat', + }) + ctx.assert.object_match(result.alias.tag, { name: 'kitty', group: 'animal', media_reference_count: 0 }) + ctx.assert.object_match(result.alias_target.tag, { name: 'cat', group: 'animal', media_reference_count: 3 }) + }) + + await ctx.subtest('get shows alias relationships', () => { + const kitty_detail = forager.tag.get({ slug: 'animal:kitty' }) + ctx.assert.object_match(kitty_detail.alias_target!, { name: 'cat', group: 'animal' }) + ctx.assert.equals(kitty_detail.aliases.length, 0) + + const cat_detail = forager.tag.get({ slug: 'animal:cat' }) + ctx.assert.equals(cat_detail.alias_target, null) + ctx.assert.equals(cat_detail.aliases.length, 1) + ctx.assert.object_match(cat_detail.aliases[0], { name: 'kitty', group: 'animal' }) + }) + + await ctx.subtest('cannot create duplicate alias', () => { + ctx.assert.throws( + () => forager.tag.alias_create({ source_tag: 'animal:kitty', target_tag: 'animal:cat' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('cannot alias a tag to itself', () => { + ctx.assert.throws( + () => forager.tag.alias_create({ source_tag: 'animal:cat', target_tag: 'animal:cat' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('cannot alias to a tag that is itself an alias', () => { + ctx.assert.throws( + () => forager.tag.alias_create({ source_tag: 'wallpaper', target_tag: 'animal:kitty' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('delete alias', () => { + const cat_before = forager.tag.get({ slug: 'animal:cat' }) + ctx.assert.equals(cat_before.aliases.length, 1) + + // create a second alias and then delete it + const wallpaper_result = forager.tag.alias_create({ source_tag: 'wallpaper', target_tag: 'animal:cat' }) + const cat_with_two = forager.tag.get({ slug: 'animal:cat' }) + ctx.assert.equals(cat_with_two.aliases.length, 2) + + forager.tag.alias_delete({ id: wallpaper_result.rule.id }) + + const cat_after = forager.tag.get({ slug: 'animal:cat' }) + ctx.assert.equals(cat_after.aliases.length, 1) + ctx.assert.object_match(cat_after.aliases[0], { name: 'kitty' }) + }) + + await ctx.subtest('alias tag that has a parent relationship', () => { + // dog has a parent "animal:cat" (as a parent rule) + forager.tag.parent_create({ source_tag: 'animal:dog', target_tag: 'animal:cat' }) + + const dog_before = forager.tag.get({ slug: 'animal:dog' }) + ctx.assert.equals(dog_before.tag.media_reference_count, 1) + ctx.assert.equals(dog_before.parents.length, 1) + + // now alias dog to cat — dog's media should move to cat + const result = forager.tag.alias_create({ source_tag: 'animal:dog', target_tag: 'animal:cat' }) + ctx.assert.equals(result.alias.tag.media_reference_count, 0) + // cat already had 3 from earlier alias, and wallpaper alias was deleted restoring 1, so cat=3 + dog's 1 = 4 + // but dog's media_reference was ed-edd-eddy which already has animal:cat, so get_or_create is a no-op + ctx.assert.equals(result.alias_target.tag.media_reference_count, 3) + }) +}) + + +test('tag parent', async (ctx) => { + using forager = new Forager(ctx.get_test_config()) + forager.init() + + await forager.media.create(ctx.resources.media_files['koch.tif'], {}, ['genre:fractal', 'genre:animation']) + await forager.media.create(ctx.resources.media_files['ed-edd-eddy.png'], {}, ['genre:cartoon']) + await forager.media.create(ctx.resources.media_files['cat_doodle.jpg'], {}, ['genre:cartoon', 'genre:fractal']) + + await ctx.subtest('create parent propagates to existing media', () => { + // animation has 1 media ref (koch.tif). cartoon has 2 (ed-edd-eddy, cat_doodle). + // making animation the parent of cartoon should add animation to ed-edd-eddy and cat_doodle + const result = forager.tag.parent_create({ + source_tag: 'genre:cartoon', + target_tag: 'genre:animation', + }) + + ctx.assert.object_match(result.rule, { + source_tag_slug: 'genre:cartoon', + target_tag_slug: 'genre:animation', + }) + ctx.assert.object_match(result.child.tag, { name: 'cartoon', media_reference_count: 2 }) + // animation: koch.tif (already had it) + ed-edd-eddy + cat_doodle = 3 + ctx.assert.object_match(result.parent.tag, { name: 'animation', media_reference_count: 3 }) + }) + + await ctx.subtest('get shows parent/child relationships', () => { + const cartoon_detail = forager.tag.get({ slug: 'genre:cartoon' }) + ctx.assert.equals(cartoon_detail.parents.length, 1) + ctx.assert.object_match(cartoon_detail.parents[0], { name: 'animation', group: 'genre' }) + ctx.assert.equals(cartoon_detail.children.length, 0) + + const animation_detail = forager.tag.get({ slug: 'genre:animation' }) + ctx.assert.equals(animation_detail.children.length, 1) + ctx.assert.object_match(animation_detail.children[0], { name: 'cartoon', group: 'genre' }) + ctx.assert.equals(animation_detail.parents.length, 0) + }) + + await ctx.subtest('cannot parent a tag to itself', () => { + ctx.assert.throws( + () => forager.tag.parent_create({ source_tag: 'genre:cartoon', target_tag: 'genre:cartoon' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('circular parent detection', () => { + ctx.assert.throws( + () => forager.tag.parent_create({ source_tag: 'genre:animation', target_tag: 'genre:cartoon' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('delete parent', () => { + // create a second parent rule and delete it + const result = forager.tag.parent_create({ source_tag: 'genre:fractal', target_tag: 'genre:animation' }) + + const animation_detail = forager.tag.get({ slug: 'genre:animation' }) + ctx.assert.equals(animation_detail.children.length, 2) + + forager.tag.parent_delete({ id: result.rule.id }) + + const animation_after = forager.tag.get({ slug: 'genre:animation' }) + ctx.assert.equals(animation_after.children.length, 1) + ctx.assert.object_match(animation_after.children[0], { name: 'cartoon' }) + }) +}) + + +test('tag update', async (ctx) => { + using forager = new Forager(ctx.get_test_config()) + forager.init() + + await forager.media.create(ctx.resources.media_files['koch.tif'], {}, ['genre:fractal', 'wallpaper']) + + await ctx.subtest('update tag name', () => { + forager.tag.update({ slug: 'genre:fractal', name: 'fractals' }) + const detail = forager.tag.get({ slug: 'genre:fractals' }) + ctx.assert.object_match(detail.tag, { name: 'fractals', group: 'genre' }) + }) + + await ctx.subtest('update tag group', () => { + forager.tag.update({ slug: 'genre:fractals', group: 'art' }) + const detail = forager.tag.get({ slug: 'art:fractals' }) + ctx.assert.object_match(detail.tag, { name: 'fractals', group: 'art' }) + }) + + await ctx.subtest('update tag description', () => { + forager.tag.update({ slug: 'art:fractals', description: 'Mathematical art' }) + const detail = forager.tag.get({ slug: 'art:fractals' }) + ctx.assert.object_match(detail.tag, { description: 'Mathematical art' }) + }) + + await ctx.subtest('cannot rename to existing slug', () => { + ctx.assert.throws( + () => forager.tag.update({ slug: 'art:fractals', name: 'wallpaper', group: '' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('cannot rename tag with existing rules', () => { + // create alias using wallpaper (which exists) as source + forager.tag.alias_create({ source_tag: 'wallpaper', target_tag: 'art:fractals' }) + ctx.assert.throws( + () => forager.tag.update({ slug: 'art:fractals', name: 'fractal_images' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('get nonexistent tag throws NotFoundError', () => { + ctx.assert.throws( + () => forager.tag.get({ slug: 'nonexistent:tag' }), + errors.NotFoundError, + ) + }) +}) From a7440dac76f239c771dd7a519923d58e7aeb44f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 16:47:28 +0000 Subject: [PATCH 14/53] test(core): address PR feedback and expand tag management tests - Move all tests into tag.test.ts (delete tag_management.test.ts) - Use ctx.assert.list_partial for alias/parent/children assertions - Fix alias_create/parent_create to handle missing source tags (TagAliasResponse.alias and TagParentResponse.child are now nullable) - Add test: alias migration when source+target share a media reference - Add test: multi-level circular parent detection (A->B->C, then C->A) - Add test: auto cleanup interaction with aliases - Add test: alias_create when source tag doesn't exist yet - Add test: parent_create when source tag doesn't exist yet Co-authored-by: andykais-claude --- packages/core/src/actions/tag_actions.ts | 12 +- packages/core/test/tag.test.ts | 280 +++++++++++++++++++++- packages/core/test/tag_management.test.ts | 206 ---------------- 3 files changed, 285 insertions(+), 213 deletions(-) delete mode 100644 packages/core/test/tag_management.test.ts diff --git a/packages/core/src/actions/tag_actions.ts b/packages/core/src/actions/tag_actions.ts index 64ec3cf5..c08084cc 100644 --- a/packages/core/src/actions/tag_actions.ts +++ b/packages/core/src/actions/tag_actions.ts @@ -19,14 +19,14 @@ export interface TagDetail { } export interface TagAliasResponse { - alias: TagDetail + alias: TagDetail | null alias_target: TagDetail rule: result_types.TagAlias } export interface TagParentResponse { parent: TagDetail - child: TagDetail + child: TagDetail | null rule: result_types.TagParent } @@ -167,11 +167,11 @@ class TagActions extends Actions { const rule_id = transaction() const rule = this.models.TagAlias.select_one({ id: rule_id }, { or_raise: true }) - const source_tag = this.models.Tag.select_one({ slug: source_slug }, { or_raise: true }) + const source_tag = this.models.Tag.select_one({ slug: source_slug }) const target_tag = this.models.Tag.select_one({ slug: target_slug }, { or_raise: true }) return { - alias: this.#build_tag_detail(source_tag), + alias: source_tag ? this.#build_tag_detail(source_tag) : null, alias_target: this.#build_tag_detail(target_tag), rule, } @@ -215,11 +215,11 @@ class TagActions extends Actions { const rule_id = transaction() const rule = this.models.TagParent.select_one({ id: rule_id }, { or_raise: true }) - const source_tag = this.models.Tag.select_one({ slug: source_slug }, { or_raise: true }) + const source_tag = this.models.Tag.select_one({ slug: source_slug }) const target_tag = this.models.Tag.select_one({ slug: target_slug }, { or_raise: true }) return { - child: this.#build_tag_detail(source_tag), + child: source_tag ? this.#build_tag_detail(source_tag) : null, parent: this.#build_tag_detail(target_tag), rule, } diff --git a/packages/core/test/tag.test.ts b/packages/core/test/tag.test.ts index 572fc4b4..94ef4a94 100644 --- a/packages/core/test/tag.test.ts +++ b/packages/core/test/tag.test.ts @@ -1,5 +1,5 @@ import { test } from 'forager-test' -import { Forager } from '~/mod.ts' +import { Forager, errors } from '~/mod.ts' test('tag actions', async (ctx) => { @@ -254,3 +254,281 @@ test('tag contextual search', async ctx => { ] }) }) + + +test('tag alias', async (ctx) => { + using forager = new Forager(ctx.get_test_config()) + forager.init() + + await forager.media.create(ctx.resources.media_files['koch.tif'], {}, ['animal:cat', 'wallpaper']) + await forager.media.create(ctx.resources.media_files['ed-edd-eddy.png'], {}, ['animal:cat', 'animal:dog']) + await forager.media.create(ctx.resources.media_files['cat_doodle.jpg'], {}, ['animal:kitty']) + + await ctx.subtest('create alias migrates media_reference_tag rows', () => { + const result = forager.tag.alias_create({ + source_tag: 'animal:kitty', + target_tag: 'animal:cat', + }) + + ctx.assert.object_match(result.rule, { + source_tag_slug: 'animal:kitty', + target_tag_slug: 'animal:cat', + }) + ctx.assert.object_match(result.alias!.tag, { name: 'kitty', group: 'animal', media_reference_count: 0 }) + ctx.assert.object_match(result.alias_target.tag, { name: 'cat', group: 'animal', media_reference_count: 3 }) + }) + + await ctx.subtest('get shows alias relationships', () => { + const kitty_detail = forager.tag.get({ slug: 'animal:kitty' }) + ctx.assert.object_match(kitty_detail.alias_target!, { name: 'cat', group: 'animal' }) + ctx.assert.list_partial(kitty_detail.aliases, []) + + const cat_detail = forager.tag.get({ slug: 'animal:cat' }) + ctx.assert.equals(cat_detail.alias_target, null) + ctx.assert.list_partial(cat_detail.aliases, [ + { slug: 'animal:kitty', name: 'kitty' }, + ]) + }) + + await ctx.subtest('cannot create duplicate alias', () => { + ctx.assert.throws( + () => forager.tag.alias_create({ source_tag: 'animal:kitty', target_tag: 'animal:cat' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('cannot alias a tag to itself', () => { + ctx.assert.throws( + () => forager.tag.alias_create({ source_tag: 'animal:cat', target_tag: 'animal:cat' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('cannot alias to a tag that is itself an alias', () => { + ctx.assert.throws( + () => forager.tag.alias_create({ source_tag: 'wallpaper', target_tag: 'animal:kitty' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('delete alias', () => { + const wallpaper_result = forager.tag.alias_create({ source_tag: 'wallpaper', target_tag: 'animal:cat' }) + ctx.assert.list_partial(forager.tag.get({ slug: 'animal:cat' }).aliases, [ + { slug: 'animal:kitty' }, + { slug: 'wallpaper' }, + ]) + + forager.tag.alias_delete({ id: wallpaper_result.rule.id }) + + ctx.assert.list_partial(forager.tag.get({ slug: 'animal:cat' }).aliases, [ + { slug: 'animal:kitty' }, + ]) + }) + + await ctx.subtest('alias migration when source and target share a media reference', () => { + // ed-edd-eddy has both animal:cat and animal:dog. Aliasing dog->cat should be + // a no-op for that media reference (get_or_create finds the existing row) + const dog_before = forager.tag.get({ slug: 'animal:dog' }) + ctx.assert.equals(dog_before.tag.media_reference_count, 1) + + const result = forager.tag.alias_create({ source_tag: 'animal:dog', target_tag: 'animal:cat' }) + ctx.assert.equals(result.alias!.tag.media_reference_count, 0) + // cat stays at 3 because ed-edd-eddy already had animal:cat + ctx.assert.equals(result.alias_target.tag.media_reference_count, 3) + }) + + await ctx.subtest('alias tag that has a parent relationship', () => { + // create a fresh parent rule on a tag, then alias it away + forager.tag.parent_create({ source_tag: 'animal:kitty', target_tag: 'animal:cat' }) + const kitty = forager.tag.get({ slug: 'animal:kitty' }) + ctx.assert.list_partial(kitty.parents, [{ slug: 'animal:cat' }]) + // kitty is already an alias of cat and has 0 media refs, so the alias+parent overlap is benign + ctx.assert.equals(kitty.tag.media_reference_count, 0) + }) + + await ctx.subtest('alias when source tag does not exist yet', () => { + const result = forager.tag.alias_create({ source_tag: 'animal:kitten', target_tag: 'animal:cat' }) + ctx.assert.object_match(result.rule, { + source_tag_slug: 'animal:kitten', + target_tag_slug: 'animal:cat', + }) + // source tag has no DB record, so alias detail is null + ctx.assert.equals(result.alias, null) + ctx.assert.object_match(result.alias_target.tag, { name: 'cat' }) + + // animal:kitten has no DB record, so it won't appear in the resolved aliases list + const cat = forager.tag.get({ slug: 'animal:cat' }) + ctx.assert.list_partial(cat.aliases, [ + { slug: 'animal:kitty' }, + { slug: 'animal:dog' }, + ]) + }) + + await ctx.subtest('auto cleanup interaction with aliases', async () => { + // kitty has 0 media_reference_count after alias migration. + // With auto_cleanup on, updating media tags triggers delete_unreferenced which + // removes the kitty tag record. But the alias rule (slug-based) survives. + const kitty_before = forager.tag.get({ slug: 'animal:kitty' }) + ctx.assert.equals(kitty_before.tag.media_reference_count, 0) + + // trigger auto cleanup by adding and removing a tag on some media + const media = forager.media.search() + const media_ref_id = media.results[0].media_reference.id + forager.media.update(media_ref_id, {}, { add: ['temp_tag'] }) + forager.media.update(media_ref_id, {}, { remove: ['temp_tag'] }) + + // kitty tag record should be gone now (0 refs + auto_cleanup) + ctx.assert.throws( + () => forager.tag.get({ slug: 'animal:kitty' }), + errors.NotFoundError, + ) + + // but the alias rule still exists — cat no longer lists kitty as resolved alias + const cat = forager.tag.get({ slug: 'animal:cat' }) + // kitty and dog are gone from resolved aliases (both have 0 refs and were cleaned) + // only unresolved rules remain in the DB, but they're filtered from the response + ctx.assert.list_partial(cat.aliases, []) + }) +}) + + +test('tag parent', async (ctx) => { + using forager = new Forager(ctx.get_test_config()) + forager.init() + + await forager.media.create(ctx.resources.media_files['koch.tif'], {}, ['genre:fractal', 'genre:animation']) + await forager.media.create(ctx.resources.media_files['ed-edd-eddy.png'], {}, ['genre:cartoon']) + await forager.media.create(ctx.resources.media_files['cat_doodle.jpg'], {}, ['genre:cartoon', 'genre:fractal']) + + await ctx.subtest('create parent propagates to existing media', () => { + const result = forager.tag.parent_create({ + source_tag: 'genre:cartoon', + target_tag: 'genre:animation', + }) + + ctx.assert.object_match(result.rule, { + source_tag_slug: 'genre:cartoon', + target_tag_slug: 'genre:animation', + }) + ctx.assert.object_match(result.child!.tag, { name: 'cartoon', media_reference_count: 2 }) + // animation: koch.tif (already had it) + ed-edd-eddy + cat_doodle = 3 + ctx.assert.object_match(result.parent.tag, { name: 'animation', media_reference_count: 3 }) + }) + + await ctx.subtest('get shows parent/child relationships', () => { + const cartoon_detail = forager.tag.get({ slug: 'genre:cartoon' }) + ctx.assert.list_partial(cartoon_detail.parents, [ + { slug: 'genre:animation', name: 'animation' }, + ]) + ctx.assert.list_partial(cartoon_detail.children, []) + + const animation_detail = forager.tag.get({ slug: 'genre:animation' }) + ctx.assert.list_partial(animation_detail.children, [ + { slug: 'genre:cartoon', name: 'cartoon' }, + ]) + ctx.assert.list_partial(animation_detail.parents, []) + }) + + await ctx.subtest('cannot parent a tag to itself', () => { + ctx.assert.throws( + () => forager.tag.parent_create({ source_tag: 'genre:cartoon', target_tag: 'genre:cartoon' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('circular parent detection', () => { + ctx.assert.throws( + () => forager.tag.parent_create({ source_tag: 'genre:animation', target_tag: 'genre:cartoon' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('multi-level circular parent detection', () => { + // animation -> cartoon already exists. add cartoon -> fractal + forager.tag.parent_create({ source_tag: 'genre:fractal', target_tag: 'genre:cartoon' }) + // now fractal -> animation should be rejected (fractal -> cartoon -> animation is a cycle) + ctx.assert.throws( + () => forager.tag.parent_create({ source_tag: 'genre:animation', target_tag: 'genre:fractal' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('delete parent', () => { + const result = forager.tag.parent_create({ source_tag: 'genre:fractal', target_tag: 'genre:animation' }) + ctx.assert.list_partial(forager.tag.get({ slug: 'genre:animation' }).children, [ + { slug: 'genre:cartoon' }, + { slug: 'genre:fractal' }, + ]) + + forager.tag.parent_delete({ id: result.rule.id }) + + ctx.assert.list_partial(forager.tag.get({ slug: 'genre:animation' }).children, [ + { slug: 'genre:cartoon' }, + ]) + }) + + await ctx.subtest('parent when source tag does not exist yet', () => { + const result = forager.tag.parent_create({ source_tag: 'genre:anime', target_tag: 'genre:animation' }) + ctx.assert.object_match(result.rule, { + source_tag_slug: 'genre:anime', + target_tag_slug: 'genre:animation', + }) + // source tag has no DB record, so child detail is null + ctx.assert.equals(result.child, null) + ctx.assert.object_match(result.parent.tag, { name: 'animation' }) + + // anime has no DB record, so it won't appear in resolved children + const animation = forager.tag.get({ slug: 'genre:animation' }) + ctx.assert.list_partial(animation.children, [ + { slug: 'genre:cartoon' }, + ]) + }) +}) + + +test('tag update', async (ctx) => { + using forager = new Forager(ctx.get_test_config()) + forager.init() + + await forager.media.create(ctx.resources.media_files['koch.tif'], {}, ['genre:fractal', 'wallpaper']) + + await ctx.subtest('update tag name', () => { + forager.tag.update({ slug: 'genre:fractal', name: 'fractals' }) + const detail = forager.tag.get({ slug: 'genre:fractals' }) + ctx.assert.object_match(detail.tag, { name: 'fractals', group: 'genre' }) + }) + + await ctx.subtest('update tag group', () => { + forager.tag.update({ slug: 'genre:fractals', group: 'art' }) + const detail = forager.tag.get({ slug: 'art:fractals' }) + ctx.assert.object_match(detail.tag, { name: 'fractals', group: 'art' }) + }) + + await ctx.subtest('update tag description', () => { + forager.tag.update({ slug: 'art:fractals', description: 'Mathematical art' }) + const detail = forager.tag.get({ slug: 'art:fractals' }) + ctx.assert.object_match(detail.tag, { description: 'Mathematical art' }) + }) + + await ctx.subtest('cannot rename to existing slug', () => { + ctx.assert.throws( + () => forager.tag.update({ slug: 'art:fractals', name: 'wallpaper', group: '' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('cannot rename tag with existing rules', () => { + forager.tag.alias_create({ source_tag: 'wallpaper', target_tag: 'art:fractals' }) + ctx.assert.throws( + () => forager.tag.update({ slug: 'art:fractals', name: 'fractal_images' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('get nonexistent tag throws NotFoundError', () => { + ctx.assert.throws( + () => forager.tag.get({ slug: 'nonexistent:tag' }), + errors.NotFoundError, + ) + }) +}) diff --git a/packages/core/test/tag_management.test.ts b/packages/core/test/tag_management.test.ts deleted file mode 100644 index 15a4f1f4..00000000 --- a/packages/core/test/tag_management.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { test } from 'forager-test' -import { Forager, errors } from '~/mod.ts' - - -test('tag alias', async (ctx) => { - using forager = new Forager(ctx.get_test_config()) - forager.init() - - await forager.media.create(ctx.resources.media_files['koch.tif'], {}, ['animal:cat', 'wallpaper']) - await forager.media.create(ctx.resources.media_files['ed-edd-eddy.png'], {}, ['animal:cat', 'animal:dog']) - await forager.media.create(ctx.resources.media_files['cat_doodle.jpg'], {}, ['animal:kitty']) - - await ctx.subtest('create alias migrates media_reference_tag rows', () => { - const result = forager.tag.alias_create({ - source_tag: 'animal:kitty', - target_tag: 'animal:cat', - }) - - ctx.assert.object_match(result.rule, { - source_tag_slug: 'animal:kitty', - target_tag_slug: 'animal:cat', - }) - ctx.assert.object_match(result.alias.tag, { name: 'kitty', group: 'animal', media_reference_count: 0 }) - ctx.assert.object_match(result.alias_target.tag, { name: 'cat', group: 'animal', media_reference_count: 3 }) - }) - - await ctx.subtest('get shows alias relationships', () => { - const kitty_detail = forager.tag.get({ slug: 'animal:kitty' }) - ctx.assert.object_match(kitty_detail.alias_target!, { name: 'cat', group: 'animal' }) - ctx.assert.equals(kitty_detail.aliases.length, 0) - - const cat_detail = forager.tag.get({ slug: 'animal:cat' }) - ctx.assert.equals(cat_detail.alias_target, null) - ctx.assert.equals(cat_detail.aliases.length, 1) - ctx.assert.object_match(cat_detail.aliases[0], { name: 'kitty', group: 'animal' }) - }) - - await ctx.subtest('cannot create duplicate alias', () => { - ctx.assert.throws( - () => forager.tag.alias_create({ source_tag: 'animal:kitty', target_tag: 'animal:cat' }), - errors.BadInputError, - ) - }) - - await ctx.subtest('cannot alias a tag to itself', () => { - ctx.assert.throws( - () => forager.tag.alias_create({ source_tag: 'animal:cat', target_tag: 'animal:cat' }), - errors.BadInputError, - ) - }) - - await ctx.subtest('cannot alias to a tag that is itself an alias', () => { - ctx.assert.throws( - () => forager.tag.alias_create({ source_tag: 'wallpaper', target_tag: 'animal:kitty' }), - errors.BadInputError, - ) - }) - - await ctx.subtest('delete alias', () => { - const cat_before = forager.tag.get({ slug: 'animal:cat' }) - ctx.assert.equals(cat_before.aliases.length, 1) - - // create a second alias and then delete it - const wallpaper_result = forager.tag.alias_create({ source_tag: 'wallpaper', target_tag: 'animal:cat' }) - const cat_with_two = forager.tag.get({ slug: 'animal:cat' }) - ctx.assert.equals(cat_with_two.aliases.length, 2) - - forager.tag.alias_delete({ id: wallpaper_result.rule.id }) - - const cat_after = forager.tag.get({ slug: 'animal:cat' }) - ctx.assert.equals(cat_after.aliases.length, 1) - ctx.assert.object_match(cat_after.aliases[0], { name: 'kitty' }) - }) - - await ctx.subtest('alias tag that has a parent relationship', () => { - // dog has a parent "animal:cat" (as a parent rule) - forager.tag.parent_create({ source_tag: 'animal:dog', target_tag: 'animal:cat' }) - - const dog_before = forager.tag.get({ slug: 'animal:dog' }) - ctx.assert.equals(dog_before.tag.media_reference_count, 1) - ctx.assert.equals(dog_before.parents.length, 1) - - // now alias dog to cat — dog's media should move to cat - const result = forager.tag.alias_create({ source_tag: 'animal:dog', target_tag: 'animal:cat' }) - ctx.assert.equals(result.alias.tag.media_reference_count, 0) - // cat already had 3 from earlier alias, and wallpaper alias was deleted restoring 1, so cat=3 + dog's 1 = 4 - // but dog's media_reference was ed-edd-eddy which already has animal:cat, so get_or_create is a no-op - ctx.assert.equals(result.alias_target.tag.media_reference_count, 3) - }) -}) - - -test('tag parent', async (ctx) => { - using forager = new Forager(ctx.get_test_config()) - forager.init() - - await forager.media.create(ctx.resources.media_files['koch.tif'], {}, ['genre:fractal', 'genre:animation']) - await forager.media.create(ctx.resources.media_files['ed-edd-eddy.png'], {}, ['genre:cartoon']) - await forager.media.create(ctx.resources.media_files['cat_doodle.jpg'], {}, ['genre:cartoon', 'genre:fractal']) - - await ctx.subtest('create parent propagates to existing media', () => { - // animation has 1 media ref (koch.tif). cartoon has 2 (ed-edd-eddy, cat_doodle). - // making animation the parent of cartoon should add animation to ed-edd-eddy and cat_doodle - const result = forager.tag.parent_create({ - source_tag: 'genre:cartoon', - target_tag: 'genre:animation', - }) - - ctx.assert.object_match(result.rule, { - source_tag_slug: 'genre:cartoon', - target_tag_slug: 'genre:animation', - }) - ctx.assert.object_match(result.child.tag, { name: 'cartoon', media_reference_count: 2 }) - // animation: koch.tif (already had it) + ed-edd-eddy + cat_doodle = 3 - ctx.assert.object_match(result.parent.tag, { name: 'animation', media_reference_count: 3 }) - }) - - await ctx.subtest('get shows parent/child relationships', () => { - const cartoon_detail = forager.tag.get({ slug: 'genre:cartoon' }) - ctx.assert.equals(cartoon_detail.parents.length, 1) - ctx.assert.object_match(cartoon_detail.parents[0], { name: 'animation', group: 'genre' }) - ctx.assert.equals(cartoon_detail.children.length, 0) - - const animation_detail = forager.tag.get({ slug: 'genre:animation' }) - ctx.assert.equals(animation_detail.children.length, 1) - ctx.assert.object_match(animation_detail.children[0], { name: 'cartoon', group: 'genre' }) - ctx.assert.equals(animation_detail.parents.length, 0) - }) - - await ctx.subtest('cannot parent a tag to itself', () => { - ctx.assert.throws( - () => forager.tag.parent_create({ source_tag: 'genre:cartoon', target_tag: 'genre:cartoon' }), - errors.BadInputError, - ) - }) - - await ctx.subtest('circular parent detection', () => { - ctx.assert.throws( - () => forager.tag.parent_create({ source_tag: 'genre:animation', target_tag: 'genre:cartoon' }), - errors.BadInputError, - ) - }) - - await ctx.subtest('delete parent', () => { - // create a second parent rule and delete it - const result = forager.tag.parent_create({ source_tag: 'genre:fractal', target_tag: 'genre:animation' }) - - const animation_detail = forager.tag.get({ slug: 'genre:animation' }) - ctx.assert.equals(animation_detail.children.length, 2) - - forager.tag.parent_delete({ id: result.rule.id }) - - const animation_after = forager.tag.get({ slug: 'genre:animation' }) - ctx.assert.equals(animation_after.children.length, 1) - ctx.assert.object_match(animation_after.children[0], { name: 'cartoon' }) - }) -}) - - -test('tag update', async (ctx) => { - using forager = new Forager(ctx.get_test_config()) - forager.init() - - await forager.media.create(ctx.resources.media_files['koch.tif'], {}, ['genre:fractal', 'wallpaper']) - - await ctx.subtest('update tag name', () => { - forager.tag.update({ slug: 'genre:fractal', name: 'fractals' }) - const detail = forager.tag.get({ slug: 'genre:fractals' }) - ctx.assert.object_match(detail.tag, { name: 'fractals', group: 'genre' }) - }) - - await ctx.subtest('update tag group', () => { - forager.tag.update({ slug: 'genre:fractals', group: 'art' }) - const detail = forager.tag.get({ slug: 'art:fractals' }) - ctx.assert.object_match(detail.tag, { name: 'fractals', group: 'art' }) - }) - - await ctx.subtest('update tag description', () => { - forager.tag.update({ slug: 'art:fractals', description: 'Mathematical art' }) - const detail = forager.tag.get({ slug: 'art:fractals' }) - ctx.assert.object_match(detail.tag, { description: 'Mathematical art' }) - }) - - await ctx.subtest('cannot rename to existing slug', () => { - ctx.assert.throws( - () => forager.tag.update({ slug: 'art:fractals', name: 'wallpaper', group: '' }), - errors.BadInputError, - ) - }) - - await ctx.subtest('cannot rename tag with existing rules', () => { - // create alias using wallpaper (which exists) as source - forager.tag.alias_create({ source_tag: 'wallpaper', target_tag: 'art:fractals' }) - ctx.assert.throws( - () => forager.tag.update({ slug: 'art:fractals', name: 'fractal_images' }), - errors.BadInputError, - ) - }) - - await ctx.subtest('get nonexistent tag throws NotFoundError', () => { - ctx.assert.throws( - () => forager.tag.get({ slug: 'nonexistent:tag' }), - errors.NotFoundError, - ) - }) -}) From 8247cd9478eddab9edddc98709006c03072a9bbb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 17:03:14 +0000 Subject: [PATCH 15/53] impl(core): rename alias_target to alias_for, add field comments to TagDetail Rename alias_target -> alias_for throughout TagDetail, TagAliasResponse, tag_actions.ts, tests, and design doc for clarity. Add docstring comments to all TagDetail, TagAliasResponse, and TagParentResponse fields describing their purpose. Co-authored-by: andykais-claude --- docs/design/tag-management-features.md | 4 ++-- packages/core/src/actions/tag_actions.ts | 19 ++++++++++++++----- packages/core/test/tag.test.ts | 10 +++++----- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/design/tag-management-features.md b/docs/design/tag-management-features.md index 35f4feae..be0fc8b9 100644 --- a/docs/design/tag-management-features.md +++ b/docs/design/tag-management-features.md @@ -277,7 +277,7 @@ Fetches a single tag by slug along with its full relationship graph. Returns: interface TagDetail { tag: Tag & { group: string; color: string; slug: string } aliases: Array<{ slug: string; tag?: Tag & { group: string; color: string } }> - alias_target: { slug: string; tag?: Tag & { group: string; color: string } } | null + alias_for: { slug: string; tag?: Tag & { group: string; color: string } } | null children: Array<{ slug: string; tag?: Tag & { group: string; color: string } }> parents: Array<{ slug: string; tag?: Tag & { group: string; color: string } }> } @@ -285,7 +285,7 @@ interface TagDetail { - `tag` — the tag record itself with group name, color, and slug - `aliases` — slugs (and resolved tags when they exist) that are aliases *for* this tag (this tag is canonical) -- `alias_target` — if this tag is itself an alias, the canonical tag slug it points to (with resolved tag if it exists); otherwise `null` +- `alias_for` — if this tag is itself an alias, the canonical tag slug it points to (with resolved tag if it exists); otherwise `null` - `children` — tag slugs (and resolved tags) automatically included when this tag is applied (this tag is the parent) - `parents` — tag slugs (and resolved tags) that, when applied, automatically include this tag (this tag is the child) diff --git a/packages/core/src/actions/tag_actions.ts b/packages/core/src/actions/tag_actions.ts index c08084cc..505dd00a 100644 --- a/packages/core/src/actions/tag_actions.ts +++ b/packages/core/src/actions/tag_actions.ts @@ -11,21 +11,30 @@ import type * as result_types from '~/models/lib/result_types.ts' * The full detail view for a single tag, including alias and parent/child relationships. */ export interface TagDetail { + /** The tag record itself with group name, color, and slug */ tag: result_types.Tag + /** Tags that are aliases pointing to this tag (this tag is the canonical version) */ aliases: result_types.Tag[] - alias_target: result_types.Tag | null + /** If this tag is an alias, the canonical tag it resolves to; null if this tag is not an alias */ + alias_for: result_types.Tag | null + /** Tags that are automatically included when this tag is applied (this tag is the parent) */ children: result_types.Tag[] + /** Tags that, when applied, automatically include this tag (this tag is the child) */ parents: result_types.Tag[] } export interface TagAliasResponse { + /** Detail view for the alias tag (the tag being aliased away); null if the tag has no DB record yet */ alias: TagDetail | null - alias_target: TagDetail + /** Detail view for the canonical tag that the alias resolves to */ + alias_for: TagDetail rule: result_types.TagAlias } export interface TagParentResponse { + /** Detail view for the parent tag */ parent: TagDetail + /** Detail view for the child tag; null if the tag has no DB record yet */ child: TagDetail | null rule: result_types.TagParent } @@ -172,7 +181,7 @@ class TagActions extends Actions { return { alias: source_tag ? this.#build_tag_detail(source_tag) : null, - alias_target: this.#build_tag_detail(target_tag), + alias_for: this.#build_tag_detail(target_tag), rule, } } @@ -236,7 +245,7 @@ class TagActions extends Actions { /** Build a TagDetail response for a given tag record. */ #build_tag_detail(tag: result_types.Tag): TagDetail { - const alias_target_row = this.models.TagAlias.select_by_source({ source_tag_slug: tag.slug }) + const alias_for_row = this.models.TagAlias.select_by_source({ source_tag_slug: tag.slug }) const alias_rows = this.models.TagAlias.select_all_by_target({ target_tag_slug: tag.slug }) const child_rows = this.models.TagParent.select_children({ target_tag_slug: tag.slug }) const parent_rows = this.models.TagParent.select_parents({ source_tag_slug: tag.slug }) @@ -247,7 +256,7 @@ class TagActions extends Actions { return { tag, - alias_target: alias_target_row ? (resolve_slug(alias_target_row.target_tag_slug) ?? null) : null, + alias_for: alias_for_row ? (resolve_slug(alias_for_row.target_tag_slug) ?? null) : null, aliases: alias_rows.map(row => resolve_slug(row.source_tag_slug)).filter((t): t is result_types.Tag => t !== undefined), children: child_rows.map(row => resolve_slug(row.source_tag_slug)).filter((t): t is result_types.Tag => t !== undefined), parents: parent_rows.map(row => resolve_slug(row.target_tag_slug)).filter((t): t is result_types.Tag => t !== undefined), diff --git a/packages/core/test/tag.test.ts b/packages/core/test/tag.test.ts index 94ef4a94..98e62c6a 100644 --- a/packages/core/test/tag.test.ts +++ b/packages/core/test/tag.test.ts @@ -275,16 +275,16 @@ test('tag alias', async (ctx) => { target_tag_slug: 'animal:cat', }) ctx.assert.object_match(result.alias!.tag, { name: 'kitty', group: 'animal', media_reference_count: 0 }) - ctx.assert.object_match(result.alias_target.tag, { name: 'cat', group: 'animal', media_reference_count: 3 }) + ctx.assert.object_match(result.alias_for.tag, { name: 'cat', group: 'animal', media_reference_count: 3 }) }) await ctx.subtest('get shows alias relationships', () => { const kitty_detail = forager.tag.get({ slug: 'animal:kitty' }) - ctx.assert.object_match(kitty_detail.alias_target!, { name: 'cat', group: 'animal' }) + ctx.assert.object_match(kitty_detail.alias_for!, { name: 'cat', group: 'animal' }) ctx.assert.list_partial(kitty_detail.aliases, []) const cat_detail = forager.tag.get({ slug: 'animal:cat' }) - ctx.assert.equals(cat_detail.alias_target, null) + ctx.assert.equals(cat_detail.alias_for, null) ctx.assert.list_partial(cat_detail.aliases, [ { slug: 'animal:kitty', name: 'kitty' }, ]) @@ -334,7 +334,7 @@ test('tag alias', async (ctx) => { const result = forager.tag.alias_create({ source_tag: 'animal:dog', target_tag: 'animal:cat' }) ctx.assert.equals(result.alias!.tag.media_reference_count, 0) // cat stays at 3 because ed-edd-eddy already had animal:cat - ctx.assert.equals(result.alias_target.tag.media_reference_count, 3) + ctx.assert.equals(result.alias_for.tag.media_reference_count, 3) }) await ctx.subtest('alias tag that has a parent relationship', () => { @@ -354,7 +354,7 @@ test('tag alias', async (ctx) => { }) // source tag has no DB record, so alias detail is null ctx.assert.equals(result.alias, null) - ctx.assert.object_match(result.alias_target.tag, { name: 'cat' }) + ctx.assert.object_match(result.alias_for.tag, { name: 'cat' }) // animal:kitten has no DB record, so it won't appear in the resolved aliases list const cat = forager.tag.get({ slug: 'animal:cat' }) From c8aeaa97afaf33f2330749d13185206a0e7914f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 17:40:47 +0000 Subject: [PATCH 16/53] impl(web): expose tag management methods on RPC API Add get, update, alias_create, alias_delete, parent_create, and parent_delete to ForagerTagApi. Co-authored-by: andykais-claude --- packages/web/src/lib/api.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 0be1dbc5..039ddc95 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -10,6 +10,12 @@ interface Context { class ForagerTagApi extends rpc.ApiController { search = this.context.forager.tag.search + get = this.context.forager.tag.get + update = this.context.forager.tag.update + alias_create = this.context.forager.tag.alias_create + alias_delete = this.context.forager.tag.alias_delete + parent_create = this.context.forager.tag.parent_create + parent_delete = this.context.forager.tag.parent_delete } class ForagerApi extends rpc.ApiController { From 06d8ba82181a5e6791e9e026071ccf8fefca3986 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 19:10:51 +0000 Subject: [PATCH 17/53] impl(web): add tag search page at /tags New route with controller, queryparams rune, and components: - /tags/+page.svelte: page with header, search controls, results table - TagsController: extends BaseController with TagQueryParams rune - TagQueryParams: manages search state, URL sync, and RPC calls - TagSearchParams: search input, sort dropdown, order toggle - TagSearchResults: table with tag name, group, media/unread counts, created/updated dates; each tag links to /tags/[slug] Co-authored-by: andykais-claude --- packages/web/src/routes/tags/+page.svelte | 19 ++++ .../tags/components/TagSearchParams.svelte | 57 +++++++++++ .../tags/components/TagSearchResults.svelte | 51 ++++++++++ packages/web/src/routes/tags/controller.ts | 27 ++++++ .../routes/tags/runes/queryparams.svelte.ts | 94 +++++++++++++++++++ 5 files changed, 248 insertions(+) create mode 100644 packages/web/src/routes/tags/+page.svelte create mode 100644 packages/web/src/routes/tags/components/TagSearchParams.svelte create mode 100644 packages/web/src/routes/tags/components/TagSearchResults.svelte create mode 100644 packages/web/src/routes/tags/controller.ts create mode 100644 packages/web/src/routes/tags/runes/queryparams.svelte.ts diff --git a/packages/web/src/routes/tags/+page.svelte b/packages/web/src/routes/tags/+page.svelte new file mode 100644 index 00000000..26af3b27 --- /dev/null +++ b/packages/web/src/routes/tags/+page.svelte @@ -0,0 +1,19 @@ + + +
+
+

Tags

+ Browse Media +
+ +
+ +
+
diff --git a/packages/web/src/routes/tags/components/TagSearchParams.svelte b/packages/web/src/routes/tags/components/TagSearchParams.svelte new file mode 100644 index 00000000..fe14c673 --- /dev/null +++ b/packages/web/src/routes/tags/components/TagSearchParams.svelte @@ -0,0 +1,57 @@ + + +
{ + e.preventDefault() + await queryparams.submit() + }}> +
+ + +
+ + + +
+
+ +
diff --git a/packages/web/src/routes/tags/components/TagSearchResults.svelte b/packages/web/src/routes/tags/components/TagSearchResults.svelte new file mode 100644 index 00000000..4f709385 --- /dev/null +++ b/packages/web/src/routes/tags/components/TagSearchResults.svelte @@ -0,0 +1,51 @@ + + +
+ {#if queryparams.loading} +

Loading...

+ {:else if queryparams.results.length === 0} +

No tags found.

+ {:else} +
{queryparams.total} tags
+ + + + + + + + + + + + + {#each queryparams.results as tag (tag.id)} + + + + + + + + + {/each} + +
TagGroupMediaUnreadCreatedUpdated
+ + + + {tag.group || '(default)'}{tag.media_reference_count}{tag.unread_media_reference_count}{format_date(tag.created_at)}{format_date(tag.updated_at)}
+ {/if} +
diff --git a/packages/web/src/routes/tags/controller.ts b/packages/web/src/routes/tags/controller.ts new file mode 100644 index 00000000..d8b5bee2 --- /dev/null +++ b/packages/web/src/routes/tags/controller.ts @@ -0,0 +1,27 @@ +import type { Config } from '$lib/server/config.ts' +import { BaseController } from '$lib/base_controller.ts' +import { create_focuser, SettingsRune } from '$lib/runes/index.ts' +import { TagQueryParams } from './runes/queryparams.svelte.ts' + + +class TagsController extends BaseController { + runes: { + focus: ReturnType + settings: SettingsRune + queryparams: TagQueryParams + } + + handlers = {} + + public constructor(config: Config) { + super(config) + + this.runes = { + focus: create_focuser(), + settings: new SettingsRune(this.client, this.config), + queryparams: new TagQueryParams(this.client), + } + } +} + +export { TagsController } diff --git a/packages/web/src/routes/tags/runes/queryparams.svelte.ts b/packages/web/src/routes/tags/runes/queryparams.svelte.ts new file mode 100644 index 00000000..f160458d --- /dev/null +++ b/packages/web/src/routes/tags/runes/queryparams.svelte.ts @@ -0,0 +1,94 @@ +import type { Forager } from '@forager/core' +import { onMount } from 'svelte' +import { pushState } from '$app/navigation' +import { Rune } from '$lib/runes/rune.ts' +import type { BaseController } from '$lib/base_controller.ts' + +type TagSearchResult = ReturnType +type SortBy = 'media_reference_count' | 'unread_media_reference_count' | 'created_at' | 'updated_at' + +interface SearchParams { + search_string: string + sort_by: SortBy + order: 'asc' | 'desc' +} + +const DEFAULTS: SearchParams = { + search_string: '', + sort_by: 'media_reference_count', + order: 'desc', +} + +export class TagQueryParams extends Rune { + public draft: SearchParams = $state({ ...DEFAULTS }) + public current: SearchParams = $state({ ...DEFAULTS }) + public results: TagSearchResult['results'] = $state([]) + public total: number = $state(0) + public loading: boolean = $state(false) + + constructor(client: BaseController['client']) { + super(client) + + onMount(async () => { + const params = this.#parse_url(new URL(window.location.toString())) + this.current = params + this.draft = { ...params } + await this.#execute_search(params) + + window.addEventListener('popstate', async () => { + const params = this.#parse_url(new URL(window.location.toString())) + this.current = params + this.draft = { ...params } + await this.#execute_search(params) + }) + }) + } + + #parse_url(url: URL): SearchParams { + const params: SearchParams = { ...DEFAULTS } + const search = url.searchParams + if (search.has('q')) params.search_string = search.get('q')! + if (search.has('sort_by')) params.sort_by = search.get('sort_by')! as SortBy + if (search.has('order')) params.order = search.get('order')! as 'asc' | 'desc' + return params + } + + #serialize(params: SearchParams): string | null { + const url_params = new URLSearchParams() + if (params.search_string) url_params.set('q', params.search_string) + if (params.sort_by !== DEFAULTS.sort_by) url_params.set('sort_by', params.sort_by) + if (params.order !== DEFAULTS.order) url_params.set('order', params.order) + const qs = url_params.toString() + return qs ? '?' + qs : null + } + + async #execute_search(params: SearchParams): Promise { + this.loading = true + try { + const search_options: Parameters[0] = { + sort_by: params.sort_by, + order: params.order, + limit: 50, + } + if (params.search_string) { + search_options.query = { tag_match: params.search_string } + } + const result = await this.client.forager.tag.search(search_options) + this.results = result.results + this.total = result.total + } finally { + this.loading = false + } + } + + public async submit(): Promise { + const serialized = this.#serialize(this.draft) + this.current = { ...this.draft } + if (serialized) { + pushState(serialized, {}) + } else { + pushState(window.location.pathname, {}) + } + await this.#execute_search(this.draft) + } +} From 7d0b7c44ace501f3a0b70300d4f6a6afcabcb26d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 20:08:07 +0000 Subject: [PATCH 18/53] impl(web): add shared Datetime component, use in tag search results New /components/Datetime.svelte renders a