diff --git a/deno.lock b/deno.lock index acd8ac9d..e55205d4 100644 --- a/deno.lock +++ b/deno.lock @@ -6,6 +6,10 @@ "jsr:@cliffy/flags@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/internal@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/table@1.0.0-rc.8": "1.0.0-rc.8", + "jsr:@david/console-static-text@0.3": "0.3.0", + "jsr:@david/dax@*": "0.44.2", + "jsr:@david/path@0.2": "0.2.0", + "jsr:@david/which@~0.4.1": "0.4.1", "jsr:@oak/commons@0.10": "0.10.2", "jsr:@oak/oak@16.0.0": "16.0.0", "jsr:@std/assert@*": "1.0.14", @@ -14,6 +18,7 @@ "jsr:@std/assert@0.226": "0.226.0", "jsr:@std/bytes@0.223": "0.223.0", "jsr:@std/bytes@0.224": "0.224.0", + "jsr:@std/bytes@^1.0.5": "1.0.6", "jsr:@std/cli@~0.224.7": "0.224.7", "jsr:@std/crypto@0.223": "0.223.0", "jsr:@std/crypto@0.224": "0.224.0", @@ -23,18 +28,23 @@ "jsr:@std/encoding@1.0.0-rc.2": "1.0.0-rc.2", "jsr:@std/fmt@0.225.4": "0.225.4", "jsr:@std/fmt@0.225.6": "0.225.6", + "jsr:@std/fmt@1": "1.0.8", "jsr:@std/fmt@^1.0.5": "1.0.8", "jsr:@std/fmt@~0.225.4": "0.225.6", "jsr:@std/fmt@~1.0.2": "1.0.8", "jsr:@std/fs@*": "1.0.19", "jsr:@std/fs@0.229.3": "0.229.3", - "jsr:@std/fs@^1.0.11": "1.0.19", + "jsr:@std/fs@1": "1.0.22", + "jsr:@std/fs@^1.0.11": "1.0.22", + "jsr:@std/fs@^1.0.20": "1.0.22", "jsr:@std/http@0.223": "0.223.0", "jsr:@std/http@0.224": "0.224.5", "jsr:@std/http@0.224.5": "0.224.5", - "jsr:@std/internal@^1.0.10": "1.0.10", + "jsr:@std/internal@^1.0.10": "1.0.12", + "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/internal@^1.0.9": "1.0.10", "jsr:@std/io@0.223": "0.223.0", + "jsr:@std/io@0.225": "0.225.2", "jsr:@std/io@~0.225.2": "0.225.2", "jsr:@std/log@~0.224.6": "0.224.14", "jsr:@std/media-types@0.223": "0.223.0", @@ -44,10 +54,12 @@ "jsr:@std/net@~0.224.3": "0.224.5", "jsr:@std/path@*": "1.0.3", "jsr:@std/path@0.223": "0.223.0", + "jsr:@std/path@1": "1.1.2", "jsr:@std/path@1.0.0-rc.1": "1.0.0-rc.1", "jsr:@std/path@1.0.0-rc.2": "1.0.0-rc.2", "jsr:@std/path@1.0.3": "1.0.3", "jsr:@std/path@^1.1.1": "1.1.2", + "jsr:@std/path@^1.1.4": "1.1.4", "jsr:@std/streams@~0.224.5": "0.224.5", "jsr:@std/text@~1.0.7": "1.0.16", "jsr:@std/yaml@*": "1.0.9", @@ -112,6 +124,31 @@ "jsr:@std/fmt@~1.0.2" ] }, + "@david/console-static-text@0.3.0": { + "integrity": "2dfb46ecee525755f7989f94ece30bba85bd8ffe3e8666abc1bf926e1ee0698d" + }, + "@david/dax@0.44.2": { + "integrity": "26f5985f66a4340d55fb05ca90a0063bb5f0d670a326e14cb33a974aafcbb8d9", + "dependencies": [ + "jsr:@david/console-static-text", + "jsr:@david/path", + "jsr:@david/which", + "jsr:@std/fmt@1", + "jsr:@std/fs@^1.0.20", + "jsr:@std/io@0.225", + "jsr:@std/path@1" + ] + }, + "@david/path@0.2.0": { + "integrity": "f2d7aa7f02ce5a55e27c09f9f1381794acb09d328f8d3c8a2e3ab3ffc294dccd", + "dependencies": [ + "jsr:@std/fs@1", + "jsr:@std/path@1" + ] + }, + "@david/which@0.4.1": { + "integrity": "896a682b111f92ab866cc70c5b4afab2f5899d2f9bde31ed00203b9c250f225e" + }, "@oak/commons@0.10.2": { "integrity": "f47aebe12c3c73372ebe96250dbcd469d11f9e9df0cd89169d245bf674a347d5", "dependencies": [ @@ -157,6 +194,9 @@ "@std/bytes@0.224.0": { "integrity": "a2250e1d0eb7d1c5a426f21267ab9bdeac2447fa87a3d0d1a467d3f7a6058e49" }, + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + }, "@std/cli@0.224.7": { "integrity": "654ca6477518e5e3a0d3fabafb2789e92b8c0febf1a1d24ba4b567aba94b5977" }, @@ -204,6 +244,13 @@ "jsr:@std/path@^1.1.1" ] }, + "@std/fs@1.0.22": { + "integrity": "de0f277a58a867147a8a01bc1b181d0dfa80bfddba8c9cf2bacd6747bcec9308", + "dependencies": [ + "jsr:@std/internal@^1.0.12", + "jsr:@std/path@^1.1.4" + ] + }, "@std/http@0.223.0": { "integrity": "15ab8a0c5a7e9d5be017a15b01600f20f66602ceec48b378939fa24fcec522aa", "dependencies": [ @@ -226,6 +273,9 @@ "@std/internal@1.0.10": { "integrity": "e3be62ce42cab0e177c27698e5d9800122f67b766a0bea6ca4867886cbde8cf7" }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, "@std/io@0.223.0": { "integrity": "2d8c3c2ab3a515619b90da2c6ff5ea7b75a94383259ef4d02116b228393f84f1", "dependencies": [ @@ -233,7 +283,10 @@ ] }, "@std/io@0.225.2": { - "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7" + "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7", + "dependencies": [ + "jsr:@std/bytes@^1.0.5" + ] }, "@std/log@0.224.14": { "integrity": "257f7adceee3b53bb2bc86c7242e7d1bc59729e57d4981c4a7e5b876c808f05e", @@ -276,6 +329,12 @@ "jsr:@std/internal@^1.0.10" ] }, + "@std/path@1.1.4": { + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, "@std/streams@0.224.5": { "integrity": "bcde7818dd5460d474cdbd674b15f6638b9cd73cd64e52bd852fba2bd4d8ec91" }, diff --git a/docs/design/tag-management-features.md b/docs/design/tag-management-features.md new file mode 100644 index 00000000..be0fc8b9 --- /dev/null +++ b/docs/design/tag-management-features.md @@ -0,0 +1,530 @@ +# 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: + +- 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, 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 + +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 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_alias` table +3. Creates the `tag_parent` table +4. 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_alias` schema + +```sql +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 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) +); +``` + +`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. + +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) +); +``` + +`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` + +```sql +ALTER TABLE tag DROP COLUMN alias_tag_id; +``` + +### Modified file: `packages/core/src/db/migrations/mod.ts` + +Add `import './migration_v9.ts'` to the migration list. + +--- + +## Phase 2: New Models — `TagAlias` and `TagParent` + +### New file: `packages/core/src/models/tag_alias.ts` + +```typescript +class TagAlias extends Model { + static schema = schema('tag_alias', { + id: field.number(), + source_tag_slug: field.string(), + target_tag_slug: field.string(), + updated_at: field.datetime(), + created_at: field.datetime(), + }) +} +``` + +Queries to include: + +| Query | Description | +|-------|-------------| +| `#create` | `INSERT ... RETURNING id` | +| `#delete_by_id` | `DELETE ... WHERE id = ?` | +| `#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(), + }) +} +``` + +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: +```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`. + +--- + +## 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` and add `slug` to 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, slug: tag.slug, ... +}) +``` + +### Modified file: `packages/core/src/actions/series_actions.ts` + +Same removal of `alias_tag_id: null` and addition of `slug` in `Tag.get_or_create(...)` calls (~lines 47 and 85). + +--- + +## Phase 4: New Input Schemas + +### New file: `packages/core/src/inputs/tag_management_inputs.ts` + +Zod schemas for the new operations: + +```typescript +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(), +}) +``` + +### Modified file: `packages/core/src/inputs/lib/inputs_parsers.ts` + +Add `export * from '~/inputs/tag_management_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 TagAliasCreate = z.input +export type TagAliasDelete = z.input +export type TagParentCreate = z.input +export type TagParentDelete = 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 slug along with its full relationship graph. Returns: + +```typescript +interface TagDetail { + tag: Tag & { group: string; color: string; slug: string } + aliases: Array<{ slug: string; tag?: Tag & { group: string; color: string } }> + 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 } }> +} +``` + +- `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_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) + +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` + +```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). 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::alias_create` + +```typescript +alias_create = (params: inputs.TagAliasCreate) => { id: number } +``` + +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) +- A tag cannot be both a canonical tag and an alias simultaneously + +**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) +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::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 +parent_delete = (params: inputs.TagParentDelete) => void +``` + +Deletes a parent 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 + 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 +} +``` + +--- + +## 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/[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` | + +Includes pagination controls (next/previous) driven by the cursor from the search response. + +--- + +## Phase 8: Web — Tag Detail/Edit Page (`/tags/[slug]`) + +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/[slug]/+page.svelte` + +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({ 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/[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.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 `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 `parent_create({ source_tag_slug: this_tag.slug, target_tag_slug: parent.slug })` + +### 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()`, `alias_create()`, `alias_delete()`, `parent_create()`, `parent_delete()` 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 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_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 + +--- + +## File Change Summary + +### New Files + +| File | Description | +|------|-------------| +| `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 | +| `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/[slug]/+page.svelte` | Tag detail/edit page | +| `packages/web/src/routes/tags/[slug]/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 {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`, `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_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_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`, `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) +9. Tag detail page `/tags/[slug]` (Phase 8) diff --git a/packages/core/src/actions/lib/base.ts b/packages/core/src/actions/lib/base.ts index 5d13c333..aee8c159 100644 --- a/packages/core/src/actions/lib/base.ts +++ b/packages/core/src/actions/lib/base.ts @@ -265,12 +265,73 @@ abstract class Actions extends Emitter) { const group = tag.group ?? '' const color = get_hash_color(group, 'hsl') const tag_group = this.models.TagGroup.get_or_create({ name: group, color })! - const tag_record = this.models.Tag.get_or_create({ alias_tag_id: null, name: tag.name, tag_group_id: tag_group.id, description: tag.description, metadata: tag.metadata }) - return tag_record + return this.models.Tag.get_or_create({ name: tag.name, tag_group_id: tag_group.id, slug: tag.slug, description: tag.description, metadata: tag.metadata }) + } + + /** + * Add a tag to a media reference, resolving aliases and propagating parent tags. + * Returns the resolved tag record (canonical if alias was resolved). + */ + protected media_add_tag(media_reference_id: number, tag: z.output, editor?: string) { + const tag_record = this.tag_create(tag) + + // alias resolution: if this tag is an alias, use the canonical tag instead + let resolved_tag = tag_record + let tag_alias_id: number | null = null + + const alias = this.models.TagAlias.select_by_alias({ alias_tag_slug: tag.slug }) + if (alias) { + const canonical = this.models.Tag.select_one({ slug: alias.alias_for_tag_slug }) + if (canonical) { + resolved_tag = canonical + tag_alias_id = alias.id + } + } + + this.models.MediaReferenceTag.get_or_create({ + media_reference_id, + tag_id: resolved_tag.id, + tag_group_id: resolved_tag.tag_group_id, + editor, + tag_alias_id, + tag_parent_id: null, + }) + + // parent propagation: walk the parent chain and add each parent tag. + // NOTE: parent resolution does not resolve aliases on parent tags. A parent + // slug is looked up directly — if the parent tag itself is an alias, it won't + // be resolved to its canonical form. This is an intentional simplification. + const visited = new Set([resolved_tag.slug]) + const queue = [resolved_tag.slug] + while (queue.length > 0) { + const current = queue.pop()! + const parent_rules = this.models.TagParent.select_parents({ child_tag_slug: current }) + for (const rule of parent_rules) { + if (visited.has(rule.parent_tag_slug)) continue + visited.add(rule.parent_tag_slug) + + const parent_tag = this.models.Tag.select_one({ slug: rule.parent_tag_slug }) + if (parent_tag) { + this.models.MediaReferenceTag.get_or_create({ + media_reference_id, + tag_id: parent_tag.id, + tag_group_id: parent_tag.tag_group_id, + editor, + tag_alias_id: null, + tag_parent_id: rule.id, + }) + queue.push(rule.parent_tag_slug) + } + } + } + + // we reselect the tag here because the media_reference_count has been updated + return this.models.Tag.select_one({ id: resolved_tag.id }, { or_raise: true }) } protected get_media_file_result(params: { @@ -312,22 +373,20 @@ abstract class Actions extends Emitter[] = [] for (const tag of parsed.tags) { - const group = tag.group ?? '' - // const color = get_hash_color(group, 'hsl') - const color = '' - const tag_group = this.models.TagGroup.get_or_create({ name: group, color })! - const {id: tag_id} = this.models.Tag.get_or_create({ alias_tag_id: null, name: tag.name, tag_group_id: tag_group.id, description: tag.description, metadata: tag.metadata }) - this.models.MediaReferenceTag.create({ media_reference_id: media_reference.id, tag_id, tag_group_id: tag_group.id }) - - const tag_record = this.models.Tag.select_one({id: tag_id}, {or_raise: true}) + const tag_record = this.media_add_tag(media_reference.id, tag) tags.push(tag_record) } return { media_reference, tags } @@ -78,12 +71,7 @@ class SeriesActions extends Actions { ...parsed.media_info, }) for (const tag of parsed.tags) { - const group = tag.group ?? '' - // const color = get_hash_color(group, 'hsl') - const color = '' - const tag_group = this.models.TagGroup.get_or_create({ name: group, color })! - const tag_record = this.models.Tag.get_or_create({ alias_tag_id: null, name: tag.name, tag_group_id: tag_group.id, description: tag.description, metadata: tag.metadata }) - this.models.MediaReferenceTag.get_or_create({ media_reference_id: parsed.series_id, tag_id: tag_record.id, tag_group_id: tag_record.tag_group_id }) + this.media_add_tag(parsed.series_id, tag) } }) diff --git a/packages/core/src/actions/tag_actions.ts b/packages/core/src/actions/tag_actions.ts index 21495069..c6b2b24e 100644 --- a/packages/core/src/actions/tag_actions.ts +++ b/packages/core/src/actions/tag_actions.ts @@ -1,12 +1,64 @@ 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 TagWithRuleId extends result_types.Tag { + rule_id: number +} + +/** + * 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), with their alias rule IDs */ + aliases: TagWithRuleId[] + /** If this tag is an alias, the canonical tag it resolves to (with rule ID); null if this tag is not an alias */ + alias_for: TagWithRuleId | null + /** Tags that are children of this tag — when a child is applied, this tag (the parent) is also applied */ + children: TagWithRuleId[] + /** Tags that are parents of this tag — when this tag is applied, the parent is also applied */ + parents: TagWithRuleId[] +} + +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 + /** 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 +} + +export interface TagSearchResultItem extends result_types.Tag { + /** Number of alias rules involving this tag (as alias or canonical) */ + alias_count: number + /** Number of parent rules where this tag is a child */ + parent_count: number +} /** * 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 ?? {}) @@ -36,13 +88,306 @@ class TagActions extends Actions { } } - return this.models.Tag.select_paginated({ + const paginated = this.models.Tag.select_paginated({ sort_by: parsed.sort_by, + order: parsed.order, limit: parsed.limit, cursor: parsed.cursor, tag_match: parsed.query.tag_match, contextual_query: contextual_query, }) + + const results: TagSearchResultItem[] = paginated.results.map(tag => { + return { + ...tag, + alias_count: this.models.TagAlias.count_by_alias_for({ alias_for_tag_slug: tag.slug }) + + this.models.TagAlias.count_by_alias({ alias_tag_slug: tag.slug }), + parent_count: this.models.TagParent.count_parents({ child_tag_slug: tag.slug }), + } + }) + + return { + ...paginated, + results, + } + } + + /** + * 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 }) + + return this.#build_tag_detail(tag) + } + + /** + * Update a tag's name, group, and/or description. If name or group changes, the slug is + * recomputed. 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) + 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 }) + + 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_rules = + this.models.TagAlias.count_by_alias({ alias_tag_slug: tag.slug }) > 0 + || this.models.TagAlias.count_by_alias_for({ alias_for_tag_slug: tag.slug }) > 0 + || this.models.TagParent.count_parents({ child_tag_slug: tag.slug }) > 0 + || this.models.TagParent.count_children({ parent_tag_slug: tag.slug }) > 0 + if (has_rules) { + 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) { + 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 + } + + 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 (tag.slug !== new_slug) { + this.ctx.logger.info(`Updated tag ${tag.slug} to ${new_slug}`) + } else { + this.ctx.logger.info(`Updated tag ${tag.slug}`) + } + } + + /** + * Create an alias relationship. The alias tag becomes an alias for the canonical tag. + * Any existing media_reference_tag rows on the alias are migrated to the canonical tag. + */ + alias_create = (params: inputs.TagAliasCreate): TagAliasResponse => { + const parsed = parsers.TagAliasCreate.parse(params) + const alias_slug = tag_slug_format(parsed.alias_tag) + const alias_for_slug = tag_slug_format(parsed.alias_for_tag) + + if (alias_slug === alias_for_slug) { + throw new errors.BadInputError(`A tag cannot be an alias of itself`) + } + + const existing_alias = this.models.TagAlias.select_by_alias({ alias_tag_slug: alias_slug }) + if (existing_alias) { + throw new errors.BadInputError(`Tag '${alias_slug}' is already an alias of '${existing_alias.alias_for_tag_slug}'`) + } + + const canonical_is_alias = this.models.TagAlias.select_by_alias({ alias_tag_slug: alias_for_slug }) + if (canonical_is_alias) { + throw new errors.BadInputError(`Tag '${alias_for_slug}' is itself an alias of '${canonical_is_alias.alias_for_tag_slug}' and cannot be a canonical tag`) + } + + const transaction = this.ctx.db.transaction_sync(() => { + const { id } = this.models.TagAlias.create({ + alias_tag_slug: alias_slug, + alias_for_tag_slug: alias_for_slug, + }) + const rule = this.models.TagAlias.select_one({ id }, { or_raise: true }) + + const media_reference_tags_changed = this.#apply_alias_to_existing_media(rule) + + return {rule, media_reference_tags_changed } + }) + + const {rule, media_reference_tags_changed } = transaction() + this.ctx.logger.info(`Set ${alias_slug} as an alias for ${alias_for_slug}. ${media_reference_tags_changed} ${alias_for_slug} media tags updated to ${alias_slug}.`) + const alias_tag = this.models.Tag.select_one({ slug: alias_slug }) + const alias_for_tag = this.models.Tag.select_one({ slug: alias_for_slug }, { or_raise: true }) + + return { + alias: alias_tag ? this.#build_tag_detail(alias_tag) : null, + alias_for: this.#build_tag_detail(alias_for_tag), + rule, + } + } + + /** + * Delete an alias relationship by ID. Migrates media_reference_tag rows that + * were created by this alias rule back to the original alias tag, then deletes + * the rule. + */ + alias_delete = (params: inputs.TagAliasDelete): void => { + const parsed = parsers.TagAliasDelete.parse(params) + const rule = this.models.TagAlias.select_one({ id: parsed.id }, { or_raise: true }) + const transaction = this.ctx.db.transaction_sync(() => { + const alias_tag = this.tag_create(parsers.Tag.parse(rule.alias_tag_slug)) + const alias_tag_record = this.models.Tag.select_one({ id: alias_tag.id }, { or_raise: true }) + + const rows = this.models.MediaReferenceTag.select_all_by_tag_alias_id({ tag_alias_id: parsed.id }) + for (const row of rows) { + this.models.MediaReferenceTag.delete({ media_reference_id: row.media_reference_id, tag_id: row.tag_id }) + this.models.MediaReferenceTag.get_or_create({ + media_reference_id: row.media_reference_id, + tag_id: alias_tag_record.id, + tag_group_id: alias_tag_record.tag_group_id, + editor: row.editor, + tag_alias_id: null, + tag_parent_id: null, + }) + } + + this.models.TagAlias.delete({ id: parsed.id }, { expected_deletes: 1 }) + return {media_reference_tags_deleted: rows.length} + }) + const { media_reference_tags_deleted } = transaction() + this.ctx.logger.info(`Removed alias ${rule.alias_tag_slug} for tag ${rule.alias_for_tag_slug}. ${media_reference_tags_deleted} media tags reverted from ${rule.alias_tag_slug} to ${rule.alias_for_tag_slug}.`) + } + + /** + * Create a parent/child relationship. When the child tag is applied to media, + * the parent tag is also automatically applied. Adds the parent tag to all + * media references that currently have the child tag. + */ + parent_create = (params: inputs.TagParentCreate): TagParentResponse => { + const parsed = parsers.TagParentCreate.parse(params) + const child_slug = tag_slug_format(parsed.child_tag) + const parent_slug = tag_slug_format(parsed.parent_tag) + + if (child_slug === parent_slug) { + throw new errors.BadInputError(`A tag cannot be its own parent`) + } + + this.#check_circular_parents(child_slug, parent_slug) + + const transaction = this.ctx.db.transaction_sync(() => { + const { id } = this.models.TagParent.create({ + child_tag_slug: child_slug, + parent_tag_slug: parent_slug, + }) + const rule = this.models.TagParent.select_one({ id }, { or_raise: true }) + + const media_reference_tags_updated = this.#apply_parent_to_existing_media(rule) + + return {rule, media_reference_tags_updated} + }) + + const {rule, media_reference_tags_updated} = transaction() + this.ctx.logger.info(`Set ${parent_slug} tag as parent to ${child_slug}. Added ${parent_slug} to ${media_reference_tags_updated} tags.`) + const child_tag = this.models.Tag.select_one({ slug: child_slug }) + const parent_tag = this.models.Tag.select_one({ slug: parent_slug }, { or_raise: true }) + + return { + child: child_tag ? this.#build_tag_detail(child_tag) : null, + parent: this.#build_tag_detail(parent_tag), + rule, + } + } + + /** + * Delete a parent/child relationship by ID. Removes any media_reference_tag rows + * that were created by this parent rule before deleting the rule itself. + */ + parent_delete = (params: inputs.TagParentDelete): void => { + const parsed = parsers.TagParentDelete.parse(params) + const tag_rule = this.models.TagParent.select_one({ id: parsed.id }, { or_raise: true }) + const transaction = this.ctx.db.transaction_sync(() => { + const result = this.models.MediaReferenceTag.delete_by_tag_parent_id({ tag_parent_id: parsed.id }) + this.models.TagParent.delete({ id: parsed.id }, { expected_deletes: 1 }) + return result.changes + }) + const media_reference_tags_deleted = transaction() + this.ctx.logger.info(`Removed ${tag_rule.parent_tag_slug} parent tag from media tagged with ${tag_rule.child_tag_slug}. ${media_reference_tags_deleted} media tags removed.`) + } + + /** Build a TagDetail response for a given tag record. */ + #build_tag_detail(tag: result_types.Tag): TagDetail { + const alias_for = this.models.TagAlias.select_by_alias_with_tags({ alias_tag_slug: tag.slug }) ?? null + const aliases = this.models.TagAlias.select_all_by_alias_for_with_tags({ alias_for_tag_slug: tag.slug }) + const children = this.models.TagParent.select_children_with_tags({ parent_tag_slug: tag.slug }) + const parents = this.models.TagParent.select_parents_with_tags({ child_tag_slug: tag.slug }) + + return { + tag, + alias_for, + aliases, + children, + parents, + } + } + + /** + * Migrate media_reference_tag rows from the alias tag to the canonical tag. + * Uses delete + get_or_create to properly trigger count updates. + */ + #apply_alias_to_existing_media(alias_rule: result_types.TagAlias) { + const alias_tag = this.models.Tag.select_one({ slug: alias_rule.alias_tag_slug }) + if (!alias_tag || alias_tag.media_reference_count === 0) return + + const canonical_tag = this.models.Tag.select_one({ slug: alias_rule.alias_for_tag_slug }) + if (!canonical_tag) return + + const alias_rows = this.models.MediaReferenceTag.select_all_by_tag_id({ tag_id: alias_tag.id }) + for (const row of alias_rows) { + this.models.MediaReferenceTag.delete({ media_reference_id: row.media_reference_id, tag_id: alias_tag.id }) + this.models.MediaReferenceTag.get_or_create({ + media_reference_id: row.media_reference_id, + tag_id: canonical_tag.id, + tag_group_id: canonical_tag.tag_group_id, + editor: row.editor, + tag_alias_id: alias_rule.id, + tag_parent_id: null, + }) + } + return alias_rows.length + } + + /** + * When a parent rule is created, add the parent tag to all media references + * that currently have the child tag. + */ + #apply_parent_to_existing_media(parent_rule: result_types.TagParent) { + const child_tag = this.models.Tag.select_one({ slug: parent_rule.child_tag_slug }) + if (!child_tag || child_tag.media_reference_count === 0) return + + const parent_tag = this.models.Tag.select_one({ slug: parent_rule.parent_tag_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, + tag_alias_id: null, + tag_parent_id: parent_rule.id, + }) + } + return child_rows.length + } + + /** Walk the parent chain to detect circular references before inserting. */ + #check_circular_parents(child_slug: string, parent_slug: string) { + const visited = new Set([child_slug]) + const queue = [parent_slug] + while (queue.length > 0) { + const current = queue.pop()! + if (visited.has(current)) { + throw new errors.BadInputError(`Circular parent/child relationship detected: '${child_slug}' -> '${parent_slug}' would create a cycle`) + } + visited.add(current) + const parents = this.models.TagParent.select_parents({ child_tag_slug: current }) + for (const parent of parents) { + queue.push(parent.parent_tag_slug) + } + } } } diff --git a/packages/core/src/db/migrations/migration_v10.ts b/packages/core/src/db/migrations/migration_v10.ts new file mode 100644 index 00000000..4d911a48 --- /dev/null +++ b/packages/core/src/db/migrations/migration_v10.ts @@ -0,0 +1,142 @@ +import * as torm from '@torm/sqlite' +import { migrations, TIMESTAMP_SQLITE, TIMESTAMP_COLUMN } from './registry.ts' + + +@migrations.register() +export class Migration extends torm.Migration { + version = 10 + // PRAGMA foreign_keys cannot be changed inside a transaction, so we manage + // the transaction ourselves during the table rebuild. + override TRANSACTION = false + + call = () => { + 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 + `) + + // we turned off foreign key constraints, so lets check that the foreign keys are correct inside the transaction + 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, + alias_tag_slug TEXT NOT NULL, + alias_for_tag_slug TEXT NOT NULL, + updated_at ${TIMESTAMP_COLUMN}, + created_at ${TIMESTAMP_COLUMN} + ) + `) + this.driver.exec(`CREATE UNIQUE INDEX tag_alias_pair ON tag_alias (alias_tag_slug, alias_for_tag_slug)`) + this.driver.exec(`CREATE INDEX tag_alias_slug ON tag_alias (alias_tag_slug)`) + this.driver.exec(`CREATE INDEX tag_alias_for_slug ON tag_alias (alias_for_tag_slug)`) + + console.log(`Creating tag_parent table`) + this.driver.exec(` + CREATE TABLE tag_parent ( + id INTEGER PRIMARY KEY NOT NULL, + child_tag_slug TEXT NOT NULL, + parent_tag_slug TEXT NOT NULL, + updated_at ${TIMESTAMP_COLUMN}, + created_at ${TIMESTAMP_COLUMN} + ) + `) + this.driver.exec(`CREATE UNIQUE INDEX tag_parent_pair ON tag_parent (child_tag_slug, parent_tag_slug)`) + this.driver.exec(`CREATE INDEX tag_parent_child_slug ON tag_parent (child_tag_slug)`) + this.driver.exec(`CREATE INDEX tag_parent_slug ON tag_parent (parent_tag_slug)`) + + this.driver.exec(`COMMIT`) + this.driver.exec(`PRAGMA foreign_keys = ON`) + } +} diff --git a/packages/core/src/db/migrations/migration_v11.ts b/packages/core/src/db/migrations/migration_v11.ts new file mode 100644 index 00000000..68007fa1 --- /dev/null +++ b/packages/core/src/db/migrations/migration_v11.ts @@ -0,0 +1,87 @@ +import * as torm from '@torm/sqlite' +import { migrations, TIMESTAMP_SQLITE, TIMESTAMP_COLUMN } from './registry.ts' + + +@migrations.register() +export class Migration extends torm.Migration { + version = 11 + override TRANSACTION = false + + call = () => { + this.driver.exec(`PRAGMA foreign_keys = OFF`) + + console.log(`Rebuilding media_reference_tag with tag_alias_id and tag_parent_id columns`) + 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 unread_media_reference_tag_count_change`) + + this.driver.exec(` + CREATE TABLE media_reference_tag_new ( + media_reference_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + editor TEXT, + tag_group_id INTEGER NOT NULL, + tag_alias_id INTEGER, + tag_parent_id INTEGER, + updated_at ${TIMESTAMP_COLUMN}, + created_at ${TIMESTAMP_COLUMN}, + + PRIMARY KEY (media_reference_id, tag_id), + FOREIGN KEY (media_reference_id) REFERENCES media_reference(id), + FOREIGN KEY (tag_id) REFERENCES tag(id), + FOREIGN KEY (tag_alias_id) REFERENCES tag_alias(id), + FOREIGN KEY (tag_parent_id) REFERENCES tag_parent(id) + ) + `) + this.driver.exec(` + INSERT INTO media_reference_tag_new (media_reference_id, tag_id, editor, tag_group_id, updated_at, created_at) + SELECT media_reference_id, tag_id, editor, tag_group_id, updated_at, created_at + FROM media_reference_tag + `) + this.driver.exec(`DROP TABLE media_reference_tag`) + this.driver.exec(`ALTER TABLE media_reference_tag_new RENAME TO media_reference_tag`) + + console.log(`Recreating media_reference_tag indexes`) + this.driver.exec(`CREATE UNIQUE INDEX media_tag_by_reference ON media_reference_tag (tag_id, media_reference_id)`) + + console.log(`Recreating media_reference_tag 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 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(`PRAGMA foreign_key_check`) + this.driver.exec(`COMMIT`) + this.driver.exec(`PRAGMA foreign_keys = ON`) + } +} diff --git a/packages/core/src/db/migrations/mod.ts b/packages/core/src/db/migrations/mod.ts index 2b8824ba..abf9f68f 100644 --- a/packages/core/src/db/migrations/mod.ts +++ b/packages/core/src/db/migrations/mod.ts @@ -7,5 +7,7 @@ import './migration_v6.ts' import './migration_v7.ts' import './migration_v8.ts' import './migration_v9.ts' +import './migration_v10.ts' +import './migration_v11.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..30e0d3de 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 = 11 sql = sql` CREATE TABLE media_file ( @@ -128,12 +128,16 @@ export class Migration extends torm.SeedMigration { tag_id INTEGER NOT NULL, editor TEXT, tag_group_id INTEGER NOT NULL, + tag_alias_id INTEGER, + tag_parent_id INTEGER, updated_at ${TIMESTAMP_COLUMN}, created_at ${TIMESTAMP_COLUMN}, PRIMARY KEY (media_reference_id, tag_id), FOREIGN KEY (media_reference_id) REFERENCES media_reference(id), - FOREIGN KEY (tag_id) REFERENCES tag(id) + FOREIGN KEY (tag_id) REFERENCES tag(id), + FOREIGN KEY (tag_alias_id) REFERENCES tag_alias(id), + FOREIGN KEY (tag_parent_id) REFERENCES tag_parent(id) ); @@ -143,8 +147,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 +156,27 @@ 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, + alias_tag_slug TEXT NOT NULL, + alias_for_tag_slug TEXT NOT NULL, + updated_at ${TIMESTAMP_COLUMN}, + created_at ${TIMESTAMP_COLUMN} + ); + + CREATE TABLE tag_parent ( + id INTEGER PRIMARY KEY NOT NULL, + child_tag_slug TEXT NOT NULL, + parent_tag_slug TEXT NOT NULL, + updated_at ${TIMESTAMP_COLUMN}, + created_at ${TIMESTAMP_COLUMN} + ); + + CREATE TABLE tag_group ( id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL UNIQUE, @@ -264,6 +283,13 @@ 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 tag_alias_pair ON tag_alias (alias_tag_slug, alias_for_tag_slug); + CREATE INDEX tag_alias_slug ON tag_alias (alias_tag_slug); + CREATE INDEX tag_alias_for_slug ON tag_alias (alias_for_tag_slug); + CREATE UNIQUE INDEX tag_parent_pair ON tag_parent (child_tag_slug, parent_tag_slug); + CREATE INDEX tag_parent_child_slug ON tag_parent (child_tag_slug); + CREATE INDEX tag_parent_slug ON tag_parent (parent_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/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..c8bc8198 100644 --- a/packages/core/src/inputs/lib/inputs_types.ts +++ b/packages/core/src/inputs/lib/inputs_types.ts @@ -17,6 +17,13 @@ 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 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 new file mode 100644 index 00000000..9b836a67 --- /dev/null +++ b/packages/core/src/inputs/tag_management_inputs.ts @@ -0,0 +1,32 @@ +import z from 'zod' +import { Tag } from '~/inputs/tag_inputs.ts' + + +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({ + alias_tag: Tag, + alias_for_tag: Tag, +}) + +export const TagAliasDelete = z.object({ + id: z.number().int(), +}) + +export const TagParentCreate = z.object({ + child_tag: Tag, + parent_tag: Tag, +}) + +export const TagParentDelete = z.object({ + id: z.number().int(), +}) 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/media_reference_tag.ts b/packages/core/src/models/media_reference_tag.ts index 1243be3e..eb7f20ab 100644 --- a/packages/core/src/models/media_reference_tag.ts +++ b/packages/core/src/models/media_reference_tag.ts @@ -8,6 +8,8 @@ class MediaReferenceTag extends Model { tag_id: field.number(), tag_group_id: field.number(), editor: field.string().optional(), + tag_alias_id: field.number().optional(), + tag_parent_id: field.number().optional(), // auto generated fields updated_at: field.datetime(), created_at: field.datetime(), @@ -20,12 +22,16 @@ class MediaReferenceTag extends Model { media_reference_id, tag_id, tag_group_id, - editor + editor, + tag_alias_id, + tag_parent_id ) VALUES (${[ MediaReferenceTag.params.media_reference_id, MediaReferenceTag.params.tag_id, MediaReferenceTag.params.tag_group_id, MediaReferenceTag.params.editor, + MediaReferenceTag.params.tag_alias_id, + MediaReferenceTag.params.tag_parent_id, ]}) RETURNING ${MediaReferenceTag.result['*']}` #select_one_by_media_reference_and_tag = this.query.one` @@ -36,11 +42,26 @@ class MediaReferenceTag extends Model { DELETE FROM media_reference_tag WHERE media_reference_id = ${MediaReferenceTag.params.media_reference_id}` - // NOTE SQLite doesnt support LIMIT on DELETE commands, we could make this query fancier to support that, but since we have media_reference_id and tag_id filters, and there is a unique constraint on those two, we should be fine here #delete_by_media_reference_and_tag = this.query.exec` 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}` + + #select_by_tag_alias_id = this.query` + SELECT ${MediaReferenceTag.result['*']} FROM media_reference_tag + WHERE tag_alias_id = ${MediaReferenceTag.params.tag_alias_id}` + + #delete_by_tag_alias_id = this.query.exec` + DELETE FROM media_reference_tag + WHERE tag_alias_id = ${MediaReferenceTag.params.tag_alias_id}` + + #delete_by_tag_parent_id = this.query.exec` + DELETE FROM media_reference_tag + WHERE tag_parent_id = ${MediaReferenceTag.params.tag_parent_id}` + #delete_impl(params: { media_reference_id: number tag_id?: number @@ -59,6 +80,22 @@ 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 select_all_by_tag_alias_id(params: { tag_alias_id: number }) { + return this.#select_by_tag_alias_id.all(params) + } + + public delete_by_tag_alias_id(params: { tag_alias_id: number }) { + return this.#delete_by_tag_alias_id(params) + } + + public delete_by_tag_parent_id(params: { tag_parent_id: number }) { + return this.#delete_by_tag_parent_id(params) + } + public get_or_create(params: Parameters[0]) { try { return this.create(params) 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.ts b/packages/core/src/models/tag.ts index 71007a52..7acd05bf 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,16 +36,16 @@ 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}` + ]}) RETURNING ${Tag.result.id}, ${Tag.result.slug}, ${Tag.result.tag_group_id}` #count = this.query` SELECT COUNT(1) AS ${PaginationVars.result.total} FROM tag` @@ -69,12 +69,25 @@ 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 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` @@ -84,12 +97,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 && @@ -111,10 +130,13 @@ class Tag extends Model { public create = this.create_fn(this.#create) + public update = this.#update + public select_paginated(params: { limit: number cursor: PaginatedResult['cursor'] sort_by: 'unread_media_reference_count' | 'media_reference_count' | 'created_at' | 'updated_at' + order: 'asc' | 'desc' tag_match?: { name: string group: string | undefined @@ -137,14 +159,15 @@ class Tag extends Model { TagGroup.result.color, ]) + const dir = params.order === 'asc' ? 'ASC' : 'DESC' if (params.sort_by === 'media_reference_count') { - tags_sql_builder.set_order_by_clause(`ORDER BY tag.media_reference_count DESC, tag.updated_at DESC, tag.id DESC`) + tags_sql_builder.set_order_by_clause(`ORDER BY tag.media_reference_count ${dir}, tag.updated_at ${dir}, tag.id ${dir}`) } else if (params.sort_by === 'unread_media_reference_count') { - tags_sql_builder.set_order_by_clause(`ORDER BY tag.unread_media_reference_count DESC, tag.updated_at DESC, tag.id DESC`) + tags_sql_builder.set_order_by_clause(`ORDER BY tag.unread_media_reference_count ${dir}, tag.updated_at ${dir}, tag.id ${dir}`) } else if (params.sort_by === 'updated_at') { - tags_sql_builder.set_order_by_clause(`ORDER BY tag.updated_at DESC, tag.id DESC`) + tags_sql_builder.set_order_by_clause(`ORDER BY tag.updated_at ${dir}, tag.id ${dir}`) } else if (params.sort_by === 'created_at') { - tags_sql_builder.set_order_by_clause(`ORDER BY tag.created_at DESC, tag.id DESC`) + tags_sql_builder.set_order_by_clause(`ORDER BY tag.created_at ${dir}, tag.id ${dir}`) } else { throw new Error(`unexpected tag sort_by: ${params.sort_by}`) } diff --git a/packages/core/src/models/tag_alias.ts b/packages/core/src/models/tag_alias.ts new file mode 100644 index 00000000..7ca07892 --- /dev/null +++ b/packages/core/src/models/tag_alias.ts @@ -0,0 +1,104 @@ +import * as torm from '@torm/sqlite' +import { Model, field, PaginationVars } from '~/models/lib/base.ts' +import { Tag } from '~/models/tag.ts' +import { TagGroup } from "./tag_group.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) */ + alias_tag_slug: field.string(), + /** The slug of the canonical tag that persists and receives all media associations */ + alias_for_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 (alias_tag_slug, alias_for_tag_slug) + VALUES (${[TagAlias.params.alias_tag_slug, TagAlias.params.alias_for_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_alias = this.query` + SELECT ${TagAlias.result['*']} FROM tag_alias + WHERE alias_tag_slug = ${TagAlias.params.alias_tag_slug}` + + #select_by_alias_with_tags = this.query` + SELECT ${Tag.result['*']}, ${TagGroup.result.name.as('group')}, ${TagGroup.result.color}, ${TagAlias.result.id.as('rule_id')} FROM tag_alias + INNER JOIN tag ON tag.slug = tag_alias.alias_for_tag_slug + INNER JOIN tag_group ON tag_group.id = tag.tag_group_id + WHERE alias_tag_slug = ${TagAlias.params.alias_tag_slug}` + + #select_by_alias_for_with_tags = this.query` + SELECT ${Tag.result['*']}, ${TagGroup.result.name.as('group')}, ${TagGroup.result.color}, ${TagAlias.result.id.as('rule_id')} FROM tag_alias + INNER JOIN tag ON tag.slug = tag_alias.alias_tag_slug + INNER JOIN tag_group ON tag_group.id = tag.tag_group_id + WHERE alias_for_tag_slug = ${TagAlias.params.alias_for_tag_slug}` + + #count_by_alias = this.query` + SELECT COUNT(1) AS ${PaginationVars.result.total} FROM tag_alias + WHERE alias_tag_slug = ${TagAlias.params.alias_tag_slug}` + + #count_by_alias_for = this.query` + SELECT COUNT(1) AS ${PaginationVars.result.total} FROM tag_alias + WHERE alias_for_tag_slug = ${TagAlias.params.alias_for_tag_slug}` + + #update_alias_slug = this.query.exec` + UPDATE tag_alias SET alias_tag_slug = ${TagAlias.params.alias_tag_slug} + WHERE alias_tag_slug = ${TagAlias.params.alias_for_tag_slug}` + + #update_alias_for_slug = this.query.exec` + UPDATE tag_alias SET alias_for_tag_slug = ${TagAlias.params.alias_for_tag_slug} + WHERE alias_for_tag_slug = ${TagAlias.params.alias_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 rule for a tag that is an alias (what it's an alias for) */ + public select_by_alias(params: { alias_tag_slug: string }) { + return this.#select_by_alias.one(params) + } + + public select_by_alias_with_tags(params: { alias_tag_slug: string }) { + return this.#select_by_alias_with_tags.one(params) + } + + public select_all_by_alias_for_with_tags(params: { alias_for_tag_slug: string }) { + return this.#select_by_alias_for_with_tags.all(params) + } + + /** Count alias rules where this tag is the alias */ + public count_by_alias(params: { alias_tag_slug: string }): number { + return this.#count_by_alias.one(params)!.total + } + + /** Count alias rules pointing to a canonical tag */ + public count_by_alias_for(params: { alias_for_tag_slug: string }): number { + return this.#count_by_alias_for.one(params)!.total + } + + /** Rename a slug across all alias rows where it appears as the alias */ + public update_alias_slug(params: { alias_tag_slug: string; alias_for_tag_slug: string }) { + return this.#update_alias_slug(params) + } + + /** Rename a slug across all alias rows where it appears as the canonical tag */ + public update_alias_for_slug(params: { alias_tag_slug: string; alias_for_tag_slug: string }) { + return this.#update_alias_for_slug(params) + } +} + +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..caa67145 --- /dev/null +++ b/packages/core/src/models/tag_parent.ts @@ -0,0 +1,105 @@ +import * as torm from '@torm/sqlite' +import { Model, field, PaginationVars } from '~/models/lib/base.ts' +import { Tag, TagGroup } from "~/models/mod.ts"; + + +class TagParent extends Model { + static schema = torm.schema('tag_parent', { + id: field.number(), + /** The slug of the child tag — when this tag is applied to media, the parent is also included */ + child_tag_slug: field.string(), + /** The slug of the parent tag that is automatically applied whenever the child tag is applied */ + parent_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 (child_tag_slug, parent_tag_slug) + VALUES (${[TagParent.params.child_tag_slug, TagParent.params.parent_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_with_tags = this.query` + SELECT ${Tag.result['*']}, ${TagGroup.result.color}, ${TagGroup.result.name.as('group')}, ${TagParent.result.id.as('rule_id')} FROM tag_parent + INNER JOIN tag ON tag.slug = tag_parent.child_tag_slug + INNER JOIN tag_group ON tag_group.id = tag.tag_group_id + WHERE parent_tag_slug = ${TagParent.params.parent_tag_slug}` + + #select_parents = this.query` + SELECT ${TagParent.result['*']} FROM tag_parent + WHERE child_tag_slug = ${TagParent.params.child_tag_slug}` + + #select_parents_with_tags = this.query` + SELECT ${Tag.result['*']}, ${TagGroup.result.color}, ${TagGroup.result.name.as('group')}, ${TagParent.result.id.as('rule_id')} FROM tag_parent + INNER JOIN tag ON tag.slug = tag_parent.parent_tag_slug + INNER JOIN tag_group ON tag_group.id = tag.tag_group_id + WHERE child_tag_slug = ${TagParent.params.child_tag_slug}` + + #count_children = this.query` + SELECT COUNT(1) AS ${PaginationVars.result.total} FROM tag_parent + WHERE parent_tag_slug = ${TagParent.params.parent_tag_slug}` + + #count_parents = this.query` + SELECT COUNT(1) AS ${PaginationVars.result.total} FROM tag_parent + WHERE child_tag_slug = ${TagParent.params.child_tag_slug}` + + #update_child_slug = this.query.exec` + UPDATE tag_parent SET child_tag_slug = ${TagParent.params.child_tag_slug} + WHERE child_tag_slug = ${TagParent.params.parent_tag_slug}` + + #update_parent_slug = this.query.exec` + UPDATE tag_parent SET parent_tag_slug = ${TagParent.params.parent_tag_slug} + WHERE parent_tag_slug = ${TagParent.params.child_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) + + /** Count child tags for a parent */ + public count_children(params: { parent_tag_slug: string }): number { + return this.#count_children.one(params)!.total + } + + /** Count parent tags for a child */ + public count_parents(params: { child_tag_slug: string }): number { + return this.#count_parents.one(params)!.total + } + + /** Get child tag slugs for a parent with joined tags */ + public select_children_with_tags(params: { parent_tag_slug: string }) { + return this.#select_children_with_tags.all(params) + } + + /** Get parent tag slugs for a child */ + public select_parents(params: { child_tag_slug: string }) { + return this.#select_parents.all(params) + } + + /** Get parent tag slugs for a child with joined tags */ + public select_parents_with_tags(params: { child_tag_slug: string }) { + return this.#select_parents_with_tags.all(params) + } + + /** Rename a slug across all parent rows where it appears as child */ + public update_child_slug(params: { child_tag_slug: string; parent_tag_slug: string }) { + return this.#update_child_slug(params) + } + + /** Rename a slug across all parent rows where it appears as parent */ + public update_parent_slug(params: { child_tag_slug: string; parent_tag_slug: string }) { + return this.#update_parent_slug(params) + } +} + +export { TagParent } diff --git a/packages/core/test/migrations.test.ts b/packages/core/test/migrations.test.ts index 816da6d2..028a2535 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 = 11 test('migrate from v1 schema', async (ctx) => { const forager_v1_path = ctx.create_fixture_path('forager_v1') @@ -62,6 +62,16 @@ test('migrate from v1 schema', async (ctx) => { next_version: 9, backup: true, }, + { + start_version: 9, + next_version: 10, + backup: true, + }, + { + start_version: 10, + next_version: 11, + backup: true, + }, ] ctx.assert.equals(v1_migration_info.db.migration_operations, expected_migration_operations) diff --git a/packages/core/test/tag.test.ts b/packages/core/test/tag.test.ts index 572fc4b4..80af044c 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,473 @@ test('tag contextual search', async ctx => { ] }) }) + + +test('tag alias', async (ctx) => { + using forager = new Forager(ctx.get_test_config()) + forager.init() + + const media_generated = await forager.media.create(ctx.resources.media_files['koch.tif'], {}, ['animal:cat', 'wallpaper']) + const media_cartoon = await forager.media.create(ctx.resources.media_files['ed-edd-eddy.png'], {}, ['animal:cat', 'animal:dog']) + const media_doodle = 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({ + alias_tag: 'animal:kitty', + alias_for_tag: 'animal:cat', + }) + + ctx.assert.object_match(result.rule, { + alias_tag_slug: 'animal:kitty', + alias_for_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_for.tag, { name: 'cat', group: 'animal', media_reference_count: 3 }) + + ctx.assert.list_partial(forager.media.get({media_reference_id: media_generated.media_reference.id }).tags, [ + { slug: 'animal:cat' }, + { slug: 'wallpaper' }, + ]) + ctx.assert.list_partial(forager.media.get({media_reference_id: media_cartoon.media_reference.id }).tags, [ + { slug: 'animal:cat' }, + { slug: 'animal:dog' }, + ]) + ctx.assert.list_partial(forager.media.get({media_reference_id: media_doodle.media_reference.id }).tags, [ + { slug: 'animal:cat' }, + ]) + }) + + await ctx.subtest('get shows alias relationships', () => { + const kitty_detail = forager.tag.get({ slug: 'animal:kitty' }) + 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_for, 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({ alias_tag: 'animal:kitty', alias_for_tag: 'animal:cat' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('cannot alias a tag to itself', () => { + ctx.assert.throws( + () => forager.tag.alias_create({ alias_tag: 'animal:cat', alias_for_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({ alias_tag: 'wallpaper', alias_for_tag: 'animal:kitty' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('delete alias', () => { + const wallpaper_result = forager.tag.alias_create({ alias_tag: 'wallpaper', alias_for_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({ alias_tag: 'animal:dog', alias_for_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_for.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({ child_tag: 'animal:kitty', parent_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({ alias_tag: 'animal:kitten', alias_for_tag: 'animal:cat' }) + ctx.assert.object_match(result.rule, { + alias_tag_slug: 'animal:kitten', + alias_for_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_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' }) + 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() + + const media_generated = await forager.media.create(ctx.resources.media_files['koch.tif'], {}, ['genre:fractal', 'genre:animation']) + const media_cartoon = await forager.media.create(ctx.resources.media_files['ed-edd-eddy.png'], {}, ['genre:cartoon']) + const media_doodle = 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({ + child_tag: 'genre:cartoon', + parent_tag: 'genre:animation', + }) + + ctx.assert.object_match(result.rule, { + child_tag_slug: 'genre:cartoon', + parent_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 }) + + ctx.assert.list_partial(forager.media.get({media_reference_id: media_generated.media_reference.id}).tags, [ + { slug: 'genre:animation' }, + { slug: 'genre:fractal' }, + ]) + + ctx.assert.list_partial(forager.media.get({media_reference_id: media_cartoon.media_reference.id}).tags, [ + { slug: 'genre:animation' }, + { slug: 'genre:cartoon' }, + ]) + + ctx.assert.list_partial(forager.media.get({media_reference_id: media_doodle.media_reference.id}).tags, [ + { slug: 'genre:animation' }, + { slug: 'genre:cartoon' }, + { slug: 'genre:fractal' }, + ], (a, b) => a.slug.localeCompare(b.slug)) + }) + + 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({ child_tag: 'genre:cartoon', parent_tag: 'genre:cartoon' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('circular parent detection', () => { + ctx.assert.throws( + () => forager.tag.parent_create({ child_tag: 'genre:animation', parent_tag: 'genre:cartoon' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('multi-level circular parent detection', () => { + // animation -> cartoon already exists. add cartoon -> fractal + forager.tag.parent_create({ child_tag: 'genre:fractal', parent_tag: 'genre:cartoon' }) + // now fractal -> animation should be rejected (fractal -> cartoon -> animation is a cycle) + ctx.assert.throws( + () => forager.tag.parent_create({ child_tag: 'genre:animation', parent_tag: 'genre:fractal' }), + errors.BadInputError, + ) + }) + + await ctx.subtest('delete parent', () => { + const result = forager.tag.parent_create({ child_tag: 'genre:fractal', parent_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({ child_tag: 'genre:anime', parent_tag: 'genre:animation' }) + ctx.assert.object_match(result.rule, { + child_tag_slug: 'genre:anime', + parent_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({ alias_tag: 'wallpaper', alias_for_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, + ) + }) +}) + + +test('tag rules respected during media operations', async (ctx) => { + using forager = new Forager(ctx.get_test_config()) + forager.init() + + // seed some media with tags so the tags exist in the DB + await forager.media.create(ctx.resources.media_files['koch.tif'], {}, ['animal:cat', 'animal:kitty', 'genre:cartoon', 'genre:animation']) + await forager.media.create(ctx.resources.media_files['ed-edd-eddy.png'], {}, ['mood:relaxed', 'mood:chill', 'style:pixel_art', 'style:digital']) + + await ctx.subtest('media.create resolves alias tags to canonical', async () => { + forager.tag.alias_create({ alias_tag: 'animal:kitty', alias_for_tag: 'animal:cat' }) + + const media = await forager.media.create(ctx.resources.media_files['cat_doodle.jpg'], {}, ['animal:kitty']) + // kitty is an alias for cat, so the media should have cat instead + ctx.assert.list_partial(media.tags, [ + { name: 'cat', group: 'animal' }, + ]) + }) + + await ctx.subtest('media.update resolves alias tags to canonical', () => { + const media = forager.media.search() + const media_ref_id = media.results[0].media_reference.id + + forager.tag.alias_create({ alias_tag: 'mood:chill', alias_for_tag: 'mood:relaxed' }) + forager.media.update(media_ref_id, {}, { add: ['mood:chill'] }) + + const updated = forager.media.get({ media_reference_id: media_ref_id }) + const tag_slugs = updated.tags.map(t => t.slug) + ctx.assert.list_includes(tag_slugs, ['mood:relaxed']) + }) + + await ctx.subtest('media.create applies parent tags', async () => { + // remove existing tags from cat_doodle so we can test fresh + const cat_doodle = forager.media.search().results.find(r => r.media_type === 'media_file' && r.media_file.filename === 'cat_doodle.jpg')! + forager.media.update(cat_doodle.media_reference.id, {}, { replace: [] }) + + forager.tag.parent_create({ child_tag: 'genre:cartoon', parent_tag: 'genre:animation' }) + + forager.media.update(cat_doodle.media_reference.id, {}, { add: ['genre:cartoon'] }) + const updated = forager.media.get({ media_reference_id: cat_doodle.media_reference.id }) + const tag_slugs = updated.tags.map(t => t.slug) + ctx.assert.list_includes(tag_slugs, ['genre:cartoon', 'genre:animation']) + }) + + await ctx.subtest('media.update applies parent tags', () => { + const media = forager.media.search() + const media_ref_id = media.results[0].media_reference.id + + forager.tag.parent_create({ child_tag: 'style:pixel_art', parent_tag: 'style:digital' }) + forager.media.update(media_ref_id, {}, { add: ['style:pixel_art'] }) + + const updated = forager.media.get({ media_reference_id: media_ref_id }) + const tag_slugs = updated.tags.map(t => t.slug) + ctx.assert.list_includes(tag_slugs, ['style:pixel_art', 'style:digital']) + }) +}) + + +test('tag rule undo', async (ctx) => { + using forager = new Forager(ctx.get_test_config()) + forager.init() + + await forager.media.create(ctx.resources.media_files['koch.tif'], {}, ['animal:cat']) + await forager.media.create(ctx.resources.media_files['ed-edd-eddy.png'], {}, ['animal:kitty']) + await forager.media.create(ctx.resources.media_files['cat_doodle.jpg'], {}, ['genre:cartoon', 'genre:animation']) + + await ctx.subtest('alias_delete undoes media_reference_tag changes', () => { + const cat_before = forager.tag.get({ slug: 'animal:cat' }) + ctx.assert.equals(cat_before.tag.media_reference_count, 1) + + const result = forager.tag.alias_create({ alias_tag: 'animal:kitty', alias_for_tag: 'animal:cat' }) + ctx.assert.object_match(forager.tag.get({ slug: 'animal:cat' }).tag, { media_reference_count: 2 }) + + forager.tag.alias_delete({ id: result.rule.id }) + ctx.assert.object_match(forager.tag.get({ slug: 'animal:cat' }).tag, { media_reference_count: 1 }) + }) + + await ctx.subtest('parent_delete undoes media_reference_tag changes', () => { + // cat_doodle has cartoon and animation. Create parent rule cartoon->animation. + // animation is already on cat_doodle so get_or_create is a no-op for that media. + // But koch.tif doesn't have cartoon, so nothing propagates there. + const animation_before = forager.tag.get({ slug: 'genre:animation' }) + ctx.assert.equals(animation_before.tag.media_reference_count, 1) + + // add cartoon to koch.tif so the parent rule propagates animation to it + forager.media.update( + forager.media.search().results.find(r => r.media_type === 'media_file' && r.media_file.filename === 'koch.tif')!.media_reference.id, + {}, { add: ['genre:cartoon'] } + ) + + const result = forager.tag.parent_create({ child_tag: 'genre:cartoon', parent_tag: 'genre:animation' }) + // animation should now be on koch.tif too (from the parent rule) + ctx.assert.object_match(forager.tag.get({ slug: 'genre:animation' }).tag, { media_reference_count: 2 }) + + forager.tag.parent_delete({ id: result.rule.id }) + // only the rule-created row (koch.tif) should be removed; cat_doodle's manual animation stays + ctx.assert.object_match(forager.tag.get({ slug: 'genre:animation' }).tag, { media_reference_count: 1 }) + }) + + await ctx.subtest('alias_delete does not remove manually-added tags', () => { + const cat_doodle = forager.media.search().results.find(r => r.media_type === 'media_file' && r.media_file.filename === 'cat_doodle.jpg')! + const ed = forager.media.search().results.find(r => r.media_type === 'media_file' && r.media_file.filename === 'ed-edd-eddy.png')! + + // restore kitty on ed-edd-eddy (lost from subtest 1's undo) + forager.media.update(ed.media_reference.id, {}, { add: ['animal:kitty'] }) + // manually add animal:cat to cat_doodle (no rule involved) + forager.media.update(cat_doodle.media_reference.id, {}, { add: ['animal:cat'] }) + + const cat_count_before = forager.tag.get({ slug: 'animal:cat' }).tag.media_reference_count + + // create alias kitty->cat, migrating ed-edd-eddy's kitty to cat + const result = forager.tag.alias_create({ alias_tag: 'animal:kitty', alias_for_tag: 'animal:cat' }) + ctx.assert.equals( + forager.tag.get({ slug: 'animal:cat' }).tag.media_reference_count, + cat_count_before + 1, + ) + + // delete the alias — should only undo the rule-created row, not the manual one + forager.tag.alias_delete({ id: result.rule.id }) + ctx.assert.equals( + forager.tag.get({ slug: 'animal:cat' }).tag.media_reference_count, + cat_count_before, + ) + }) + + await ctx.subtest('media.create with alias tracks tag_alias_id', async () => { + const ed = forager.media.search().results.find(r => r.media_type === 'media_file' && r.media_file.filename === 'ed-edd-eddy.png')! + forager.media.update(ed.media_reference.id, {}, { add: ['animal:kitty'] }) + + const alias_result = forager.tag.alias_create({ alias_tag: 'animal:kitty', alias_for_tag: 'animal:cat' }) + + const cat_before = forager.tag.get({ slug: 'animal:cat' }).tag.media_reference_count + + // create media with the alias tag — should resolve to cat via alias + const media = await forager.media.create(ctx.resources.media_files['blink.gif'], {}, ['animal:kitty']) + ctx.assert.list_partial(media.tags, [{ name: 'cat', group: 'animal' }]) + ctx.assert.equals(forager.tag.get({ slug: 'animal:cat' }).tag.media_reference_count, cat_before + 1) + + // delete the alias rule — removes ALL rows created by this alias (migration + media.create) + forager.tag.alias_delete({ id: alias_result.rule.id }) + + // cat_before included the migrated ed-edd-eddy row, so count drops below cat_before + ctx.assert.equals(forager.tag.get({ slug: 'animal:cat' }).tag.media_reference_count, cat_before - 1) + }) + + await ctx.subtest('media.update with parent tracks tag_parent_id', () => { + const ed = forager.media.search().results.find(r => r.media_type === 'media_file' && r.media_file.filename === 'ed-edd-eddy.png')! + const media_ref_id = ed.media_reference.id + + // use tags that already exist in the DB + const result = forager.tag.parent_create({ child_tag: 'genre:cartoon', parent_tag: 'genre:animation' }) + forager.media.update(media_ref_id, {}, { add: ['genre:cartoon'] }) + + const updated = forager.media.get({ media_reference_id: media_ref_id }) + ctx.assert.list_includes(updated.tags.map(t => t.slug), ['genre:animation']) + + // delete the parent rule — animation should be removed from ed-edd-eddy + forager.tag.parent_delete({ id: result.rule.id }) + + const after = forager.media.get({ media_reference_id: media_ref_id }) + ctx.assert.equals(after.tags.map(t => t.slug).includes('genre:animation'), false) + }) +}) 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 { diff --git a/packages/web/src/lib/components/Datetime.svelte b/packages/web/src/lib/components/Datetime.svelte new file mode 100644 index 00000000..9b324bfa --- /dev/null +++ b/packages/web/src/lib/components/Datetime.svelte @@ -0,0 +1,9 @@ + + + diff --git a/packages/web/src/lib/components/Header.svelte b/packages/web/src/lib/components/Header.svelte new file mode 100644 index 00000000..4ed3a0e7 --- /dev/null +++ b/packages/web/src/lib/components/Header.svelte @@ -0,0 +1,42 @@ + + +
+ + {props.title} +
+ {@render props.children?.()} +
+
diff --git a/packages/web/src/lib/components/Icon.svelte b/packages/web/src/lib/components/Icon.svelte index 674d31c1..e9086355 100644 --- a/packages/web/src/lib/components/Icon.svelte +++ b/packages/web/src/lib/components/Icon.svelte @@ -8,6 +8,7 @@ height = size, color = "currentColor", stroke = color, + stroke_width = undefined as string | number | undefined, fill = color, title = "", ...props @@ -32,6 +33,7 @@ {height} {viewBox} {stroke} + stroke-width={stroke_width} {fill} {...props} > diff --git a/packages/web/src/lib/components/Numeric.svelte b/packages/web/src/lib/components/Numeric.svelte new file mode 100644 index 00000000..a5ce6c5c --- /dev/null +++ b/packages/web/src/lib/components/Numeric.svelte @@ -0,0 +1,28 @@ + + +{#if hide_zero && number === 0} +{''} +{:else} +{pretty_number} +{/if} diff --git a/packages/web/src/lib/components/Tag.svelte b/packages/web/src/lib/components/Tag.svelte index 54ec1bc0..ba630442 100644 --- a/packages/web/src/lib/components/Tag.svelte +++ b/packages/web/src/lib/components/Tag.svelte @@ -1,9 +1,10 @@
-
+
+ +
diff --git a/packages/web/src/routes/browse/components/Footer.svelte b/packages/web/src/routes/browse/components/Footer.svelte index 454ee52e..3c6445f2 100644 --- a/packages/web/src/routes/browse/components/Footer.svelte +++ b/packages/web/src/routes/browse/components/Footer.svelte @@ -51,7 +51,7 @@