diff --git a/docs/superpowers/plans/2026-04-21-character-fandom-association-refactor.md b/docs/superpowers/plans/2026-04-21-character-fandom-association-refactor.md new file mode 100644 index 0000000..8fa14ad --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-character-fandom-association-refactor.md @@ -0,0 +1,3314 @@ +# Character–Fandom Association Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Store tag kind in the `kind` column only (strip `kind:` prefix from `name`) and store character-fandom association via `fandom_id` only (strip `(Fandom)` suffix from `name`); update every reader/writer in the app accordingly. + +**Architecture:** Single Alembic migration rewrites every tag row in two phases — prefix strip for all kinds, then fandom suffix extraction for characters. A new `Tag.display_name` @property becomes the single source of truth for rendering. A one-place `_parse_kind_prefix` helper accepts the user-facing `character:Saber` input shortcut. Gallery URLs become `?tag_id=N`. The modal gets an inline fandom picker that slides in when `character:` is typed. WD14 accept path handles 0/1/N candidate resolution with an HTTP 409 disambiguation flow. A ~53-site mechanical sweep replaces every f-string prefix construction and `name.split(':', 1)` parse. + +**Tech Stack:** Flask + SQLAlchemy + pgvector + Alembic migrations, Jinja2 templates, vanilla JS + CSS in `app/static/`, Celery for the maintenance task being removed, Postgres for partial unique indices. + +**Spec:** `docs/superpowers/specs/2026-04-21-character-fandom-association-design.md` + +--- + +## Context for the Implementer + +**The codebase has no automated test suite.** There is no `tests/` directory, no `conftest.py`, no pytest fixtures. The existing convention (see migrations `h26041901`, `i26041901` and every feature commit on the branch) is manual verification: run the server in `docker compose`, exercise the feature in the browser, poke at the database via `docker compose exec web flask shell` or `docker compose exec db psql -U imagerepo imagerepo`. + +**Do not invent a test suite.** Each task below ships with explicit manual verification: a shell command, a SQL query, or a UI interaction with an expected result. These are the project's conventions — honor them. + +**TDD spirit, not TDD ceremony.** Where a task is easy to smoke-test with a one-off Python script (`docker compose exec web python -c '...'`), do it. Where it needs eyes on a page, open the page. Each task's "Verify" step states exactly how. + +**Development loop (the user's normal flow):** + +```bash +# Rebuild and restart all containers from docker-compose.yml +docker compose up -d --build + +# Tail logs for the web container while you exercise the app +docker compose logs -f web + +# Alembic shell for migrations +docker compose exec web flask db upgrade # apply +docker compose exec web flask db downgrade # revert +docker compose exec web flask db history # list revisions + +# Postgres shell (inside the db container) +docker compose exec db psql -U imagerepo imagerepo +``` + +**Commit cadence:** one commit per task. The `app/main.py` file is large; commit per logical route or helper change so git blame stays useful. + +--- + +## File Structure + +### Files created + +| Path | Responsibility | +|---|---| +| `migrations/versions/j26042101_bare_tag_names_and_fandom_id_authoritative.py` | Three-phase migration: strip kind prefixes, extract character fandom suffix, swap indices. | +| `app/utils/tag_prefix.py` | `KNOWN_KINDS` constant + `_parse_kind_prefix` helper, imported by `add_tag`, `bulk_add_tag`, and the migration's sanity check. | + +### Files modified + +| Path | What changes | +|---|---| +| `app/models.py` | Drop `unique=True` on `Tag.name`, add partial indices via `__table_args__`, add `display_name` @property. | +| `app/main.py` | `_ensure_fandom_tag` stores bare names; `add_tag`, `bulk_add_tag`, `accept_image_suggestion`, `set_tag_fandom`, `update_tag`, `get_tag`, `list_tags`, `search_tags`, `tag_list`, `gallery` routes refactored; `_parse_character_fandom` deleted; all `name.split(':', 1)` occurrences replaced by `tag.name` or `tag.display_name`; URL builders switch `tag=` to `tag_id=`; `trigger_sync_character_fandoms` route deleted. | +| `app/services/tag_suggestions.py` | `_canonicalize_wd14_name` simplified; `_FANDOM_SUFFIX_RE`, `_fandom_suffix_prefixes`, `applied_fandom_prefixes` branch deleted. | +| `app/utils/image_importer.py` | Archive tag construction switches from `f"archive:{artist}:{name}"` to `Tag(kind='archive', name=f"{artist}:{name}")`; prefix-ILIKE query becomes `kind='archive'` filter. | +| `app/utils/metadata_enrichment.py` | Post tag construction drops `post:` outer prefix. | +| `app/tasks/import_file.py` | Artist tag construction drops `artist:` prefix; archive tag construction same treatment. | +| `app/tasks/maintenance.py` | Delete `sync_character_fandoms` task body entirely; file becomes empty stub or is deleted (we keep the file with a comment for revision history). | +| `app/celery_app.py` | Remove the `sync_character_fandoms` task routing entry. | +| `app/templates/_gallery_item.html` | Six-branch prefix-strip expression collapses into `KIND_EMOJI` dict lookup + `t.display_name`. Anchor href switches to `tag_id`. | +| `app/templates/_gallery_modal.html` | Add the fandom-picker HTML block inside `.modal-tags-section`. | +| `app/templates/_tag_cards.html` | `tag.name` → `tag.display_name`; `tag=tag.tag` → `tag_id=tag.id`. | +| `app/templates/tags_list.html` | Add fandom-less character nudges (chip + assign button for `kind='character' AND fandom_id IS NULL`); add header counter. | +| `app/templates/reader.html` | `tag.name` → `tag.display_name`; `tag=tag.name` → `tag_id=tag.id`. | +| `app/templates/showcase.html` | No URL change (uses `kind=` filter, not `tag=`). Skip unless grep finds something. | +| `app/templates/layout.html` | No URL change. Skip unless grep finds something. | +| `app/templates/settings.html` | Remove the `sync-character-fandoms` maintenance button/form. | +| `app/static/js/view-modal.js` | Delete the `tag.name.split(':', 2)[1]` branch at line ~388 (use `display_name` from API); wire up fandom picker reveal + autocomplete + submit; implement ambiguous-accept 409 disambiguation UI. | +| `app/static/js/bulk-select.js` | Propagate `display_name` through its autocomplete rendering. | +| `app/static/js/tag-editor.js` | Same — use `display_name` where a tag is rendered to the user. | +| `app/static/style.css` | Add `.fandom-picker-wrapper`, `.fandom-picker-input`, `.fandom-picker-label`, `.fandom-picker-input-row` styles. Add `.character-no-fandom-chip` for tags-list nudge. Add `.suggestion-ambiguous-picker` for the 409 flow. | + +### Files deleted + +None. `app/tasks/maintenance.py` stays (empty body, retains git history); the Celery task is just removed from its contents. `sync-character-fandoms` button HTML is removed from `settings.html`. + +--- + +## Task 0: Pre-flight baseline snapshot + +**Why this task exists:** before touching anything, capture the current state of the `tag` table so we can verify post-migration that no data was lost. A migration that silently corrupts data is the one thing we cannot tolerate in this refactor. + +**Files:** +- No code changes. Pure verification + backup. + +- [ ] **Step 1: Snapshot tag counts by kind** + +Run in shell: + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c " + SELECT + COALESCE(kind, '(null)') AS kind, + COUNT(*) AS total, + COUNT(CASE WHEN name LIKE '%:%' THEN 1 END) AS has_colon_prefix, + COUNT(CASE WHEN kind = 'character' AND name LIKE '% (%)' THEN 1 END) AS has_fandom_suffix, + COUNT(CASE WHEN kind = 'character' AND fandom_id IS NOT NULL THEN 1 END) AS has_fandom_id + FROM tag + GROUP BY kind + ORDER BY kind; +" +``` + +Expected: a table with rows per kind. Record these numbers in your scratchpad — the post-migration verification will compare against them. + +- [ ] **Step 2: Export full tag list for diff-comparison after migration** + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c " + COPY (SELECT id, name, kind, fandom_id FROM tag ORDER BY id) TO STDOUT WITH CSV HEADER +" > /tmp/tag_snapshot_pre.csv +wc -l /tmp/tag_snapshot_pre.csv +``` + +Expected: CSV file of every tag row, one row per line plus header. Keep it — the post-migration task will compare. + +- [ ] **Step 3: Snapshot image_tags counts (will verify zero lost after migration)** + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c " + SELECT COUNT(*) AS total_image_tag_rows FROM image_tags; +" +``` + +Record this number. The migration must never reduce it (except intentionally via the auto-merge branch, which reassigns rows, not deletes them). + +- [ ] **Step 4: Verify database is backed up (user-facing reminder)** + +```bash +docker compose exec -T db pg_dump -U imagerepo imagerepo > /tmp/imagerepo_pre_refactor_backup.sql +ls -lh /tmp/imagerepo_pre_refactor_backup.sql +``` + +Expected: a `.sql` dump on disk. This is the abort-button if anything goes wrong — keep it until all tasks are merged and the feature is verified working. + +No commit — this task produces only snapshots for later verification. + +--- + +## Task 1: Add `display_name` property to Tag model + +**Why first:** the property is backwards-compatible. `display_name` on today's `Tag(name="character:Ruby Rose (RWBY)")` returns that same string (via the `else` branch), so nothing breaks. Adding it first lets later tasks start using `{{ tag.display_name }}` opportunistically even before the migration runs. + +**Files:** +- Modify: `app/models.py` (Tag class, around line 44-55) + +- [ ] **Step 1: Add the `display_name` property to the Tag class** + +Open `app/models.py` and find the `Tag` class (around line 44). Add a `display_name` property immediately after the `images` relationship: + +```python +class Tag(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), unique=True, nullable=False) + kind = db.Column(db.String(64), nullable=True) + fandom_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="SET NULL"), nullable=True) + fandom = db.relationship("Tag", remote_side="Tag.id", foreign_keys=[fandom_id]) + images = db.relationship( + "ImageRecord", + secondary=image_tags, + back_populates="tags", + passive_deletes=True, + ) + + @property + def display_name(self) -> str: + """Human-readable rendering of this tag. + + After the bare-name refactor, `name` holds the display text directly for + every kind except character+fandom, which gets the '(Fandom)' suffix + appended here. Pre-migration data (with 'kind:' prefix in name) still + renders sanely via the else branch — callers see the raw stored string. + """ + if self.kind == 'character' and self.fandom is not None: + return f"{self.name} ({self.fandom.name})" + return self.name +``` + +- [ ] **Step 2: Verify the property works on existing (pre-migration) data** + +```bash +docker compose restart web +docker compose exec web flask shell -c " +from app.models import Tag +t = Tag.query.filter_by(kind='character').first() +if t: + print(f'name={t.name!r} display_name={t.display_name!r} fandom={t.fandom.name if t.fandom else None!r}') +else: + print('No character tags present — test with any tag:') + t = Tag.query.first() + print(f'name={t.name!r} display_name={t.display_name!r}') +" +``` + +Expected: `display_name` either matches `name` (fandom is None) or matches `name` + ' (fandom.name)' if a fandom is attached. Either way: no exception, no AttributeError. + +- [ ] **Step 3: Commit** + +```bash +git add app/models.py +git commit -m "feat(models): add Tag.display_name @property + +Single source of truth for human-readable tag rendering. Falls back to +tag.name when no fandom is attached, preserving pre-migration data shapes. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 2: Create `app/utils/tag_prefix.py` with `KNOWN_KINDS` and `_parse_kind_prefix` + +**Why:** the user-facing input shortcut `character:Saber` needs to keep working post-refactor. Parse happens *once* at the API boundary. This helper lives in one place so `add_tag`, `bulk_add_tag`, and any future caller stay consistent. + +**Files:** +- Create: `app/utils/tag_prefix.py` + +- [ ] **Step 1: Create `app/utils/tag_prefix.py`** + +```python +"""Parse the user-facing `kind:name` shortcut used by the add-tag input. + +After the bare-name refactor, `Tag.name` no longer stores the `kind:` prefix. +The prefix lives only as an input convention in a handful of user-facing +places (modal add-tag input, bulk-add-tag form, import paths that receive +user-typed strings). This module owns that parser. +""" +from __future__ import annotations + +# The set of tag kinds the app recognizes as prefixable at the input boundary. +# `user` is intentionally excluded — user-typed general tags never carry a +# prefix. Strings like "http://example" keep their colon as literal text +# because "http" is not in this set. +KNOWN_KINDS: frozenset[str] = frozenset({ + 'character', + 'fandom', + 'artist', + 'series', + 'post', + 'archive', + 'meta', +}) + + +def parse_kind_prefix(raw: str) -> tuple[str | None, str]: + """Split a raw user-typed tag string into (kind, name). + + Returns: + (kind, name) where kind is one of KNOWN_KINDS, or + (None, raw) if no recognized prefix is present. + + Examples: + parse_kind_prefix('character:Saber') -> ('character', 'Saber') + parse_kind_prefix('sunset') -> (None, 'sunset') + parse_kind_prefix('http://example') -> (None, 'http://example') + parse_kind_prefix('artist: Eric ') -> ('artist', 'Eric') + """ + if ':' in raw: + prefix, rest = raw.split(':', 1) + if prefix in KNOWN_KINDS: + return prefix, rest.strip() + return None, raw.strip() +``` + +- [ ] **Step 2: Verify the parser with representative inputs** + +```bash +docker compose exec web python -c " +from app.utils.tag_prefix import parse_kind_prefix, KNOWN_KINDS + +cases = [ + ('character:Saber', ('character', 'Saber')), + ('fandom:RWBY', ('fandom', 'RWBY')), + ('artist: Eric Canete ', ('artist', 'Eric Canete')), + ('sunset', (None, 'sunset')), + ('http://example', (None, 'http://example')), + (' ', (None, '')), + ('post:patreon:eric:12345', ('post', 'patreon:eric:12345')), +] +for inp, expected in cases: + got = parse_kind_prefix(inp) + status = 'OK' if got == expected else 'FAIL' + print(f'{status}: {inp!r} -> {got} (expected {expected})') +print(f'KNOWN_KINDS: {sorted(KNOWN_KINDS)}') +" +``` + +Expected: every line prints `OK`. + +- [ ] **Step 3: Commit** + +```bash +git add app/utils/tag_prefix.py +git commit -m "feat(utils): add parse_kind_prefix helper + +Owns the 'character:Saber' user-input shortcut parser in one place. +Invoked by add_tag, bulk_add_tag, and the migration's sanity check. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 3: Alembic migration — three-phase data transformation + +**Why the most-involved task:** this is the irreversible step. Phase 1 strips `kind:` prefix from every non-`user` tag name. Phase 2 extracts `(Fandom)` suffix from character names into `fandom_id`. Phase 3 swaps `UNIQUE(name)` for four partial indices. `tag_reference_embedding.tag_name` is keyed by string, so every rename must cascade into it (same pattern as `h26041901` / `i26041901`). The migration includes auto-merge for phase-2 collisions and a report of phase-2 orphans (bare-name + suffixed coexistence). + +**Files:** +- Create: `migrations/versions/j26042101_bare_tag_names_and_fandom_id_authoritative.py` + +- [ ] **Step 1: Identify the latest migration revision to chain onto** + +```bash +docker compose exec web flask db heads +``` + +Expected: a single revision id. On the current repo state, this should be `i26041901`. Use whatever it prints as `down_revision` in the next step. + +- [ ] **Step 2: Create the migration file** + +Create `migrations/versions/j26042101_bare_tag_names_and_fandom_id_authoritative.py`: + +```python +"""Strip kind: prefix from tag names, extract character (Fandom) suffix into +fandom_id, and swap UNIQUE(name) for four partial unique indices keyed on +(name, kind) with special handling for characters. + +Revision ID: j26042101 +Revises: i26041901 +Create Date: 2026-04-21 + +Three phases run in one transaction: + + Phase 1 — Strip kind prefix from tag.name for every non-user tag. + 'character:Ruby Rose (RWBY)' -> 'Ruby Rose (RWBY)' + 'artist:Eric Canete' -> 'Eric Canete' + 'post:patreon:eric:12345' -> 'patreon:eric:12345' + User tags (kind='user', already bare) are untouched. + + Phase 2 — For every kind='character' tag whose name still matches + 'Name (Fandom)' after phase 1, ensure the fandom tag exists, + update the character's fandom_id, and strip the suffix from its name. + True duplicates (same bare_name + same fandom_id) auto-merge with + image_tags reassigned and the duplicate row deleted. + + Phase 3 — Drop UNIQUE(tag.name); create four partial unique indices: + tag_character_with_fandom_uniq UNIQUE(name, fandom_id) WHERE kind='character' AND fandom_id IS NOT NULL + tag_character_null_fandom_uniq UNIQUE(name) WHERE kind='character' AND fandom_id IS NULL + tag_other_kinds_uniq UNIQUE(name, kind) WHERE kind != 'character' + tag_kind_idx BTREE(kind) + +Also cascades renames into tag_reference_embedding.tag_name (keyed by name +string, same pattern as h26041901 / i26041901). + +Downgrade raises — merges are destructive; roll back via the +/tmp/imagerepo_pre_refactor_backup.sql dump captured in pre-flight. +""" +from __future__ import annotations + +import re + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text + + +revision = 'j26042101' +down_revision = 'i26041901' +branch_labels = None +depends_on = None + + +# Same kinds app-side (app/utils/tag_prefix.KNOWN_KINDS), with colon suffix. +# 'user' intentionally excluded — those tags have always been stored bare. +KNOWN_PREFIXES = ( + 'character:', 'fandom:', 'artist:', 'series:', + 'post:', 'archive:', 'meta:', +) + +FANDOM_SUFFIX_RE = re.compile(r'^(.+?) \(([^()]+)\)$') + + +def _strip_prefix(name: str) -> tuple[str, str | None]: + """Return (stripped_name, prefix_without_colon). No prefix -> (name, None).""" + for prefix in KNOWN_PREFIXES: + if name.startswith(prefix): + return name[len(prefix):], prefix.rstrip(':') + return name, None + + +def _reassign_image_tags(conn, from_id: int, to_id: int) -> None: + """Move every image_tags row pointing at from_id over to to_id, dedup via + ON CONFLICT.""" + conn.execute( + text(""" + INSERT INTO image_tags (image_id, tag_id) + SELECT DISTINCT image_id, :to_id + FROM image_tags + WHERE tag_id = :from_id + ON CONFLICT DO NOTHING + """), + {'from_id': from_id, 'to_id': to_id}, + ) + conn.execute( + text("DELETE FROM image_tags WHERE tag_id = :from_id"), + {'from_id': from_id}, + ) + + +def _rename_tag_reference_embedding(conn, old_name: str, new_name: str) -> None: + """Cascade a tag rename into tag_reference_embedding.tag_name, which is a + PK string. Copies the loser's rows onto the winner's name where the winner + has no row for that model_version; deletes the rest.""" + if old_name == new_name: + return + conn.execute( + text(""" + UPDATE tag_reference_embedding + SET tag_name = :new_name + WHERE tag_name = :old_name + AND NOT EXISTS ( + SELECT 1 FROM tag_reference_embedding w + WHERE w.tag_name = :new_name + AND w.model_version = tag_reference_embedding.model_version + ) + """), + {'old_name': old_name, 'new_name': new_name}, + ) + conn.execute( + text("DELETE FROM tag_reference_embedding WHERE tag_name = :old_name"), + {'old_name': old_name}, + ) + + +def upgrade(): + conn = op.get_bind() + + # ------------------------------------------------------------------------- + # Phase 1 — strip kind: prefix from every non-user tag's name + # ------------------------------------------------------------------------- + phase1_renames = 0 + rows = conn.execute( + text("SELECT id, name, kind FROM tag ORDER BY id") + ).fetchall() + + for tag_id, old_name, kind in rows: + new_name, stripped_prefix = _strip_prefix(old_name) + if new_name == old_name: + continue + # Sanity: the stripped prefix should match the kind column. If not, + # the data is inconsistent; log and trust the kind column. + if stripped_prefix != kind: + print( + f"phase1 warning: tag {tag_id} name prefix={stripped_prefix!r} " + f"but kind column={kind!r}; trusting kind" + ) + conn.execute( + text("UPDATE tag SET name = :new_name WHERE id = :id"), + {'new_name': new_name, 'id': tag_id}, + ) + _rename_tag_reference_embedding(conn, old_name, new_name) + phase1_renames += 1 + + # ------------------------------------------------------------------------- + # Phase 2 — extract (Fandom) suffix from character names into fandom_id + # ------------------------------------------------------------------------- + phase2_extracted = 0 + phase2_merged = 0 + phase2_orphans: list[tuple[int, str]] = [] + phase2_malformed: list[tuple[int, str]] = [] + + char_rows = conn.execute( + text("SELECT id, name, fandom_id FROM tag WHERE kind = 'character' ORDER BY id") + ).fetchall() + + for tag_id, char_name, existing_fandom_id in char_rows: + m = FANDOM_SUFFIX_RE.match(char_name) + if not m: + # No suffix. Check if a suffixed sibling exists — if so, this is + # an orphan worth reporting so the user can merge manually. + sibling = conn.execute( + text(""" + SELECT id FROM tag + WHERE kind = 'character' + AND name LIKE :pattern + AND id != :tag_id + """), + {'pattern': f"{char_name} (%)", 'tag_id': tag_id}, + ).fetchone() + if sibling: + phase2_orphans.append((tag_id, char_name)) + continue + + bare_name, fandom_name = m.group(1), m.group(2) + bare_name = bare_name.strip() + fandom_name = fandom_name.strip() + if not bare_name or not fandom_name: + phase2_malformed.append((tag_id, char_name)) + continue + + # Ensure the fandom tag exists. Phase 1 has already stripped any + # 'fandom:' prefix, so we look up by bare name. + fandom_row = conn.execute( + text("SELECT id FROM tag WHERE kind = 'fandom' AND name = :name"), + {'name': fandom_name}, + ).fetchone() + if fandom_row is None: + result = conn.execute( + text("INSERT INTO tag (name, kind) VALUES (:name, 'fandom') RETURNING id"), + {'name': fandom_name}, + ) + new_fandom_id = result.scalar_one() + else: + new_fandom_id = fandom_row[0] + + # Collision check: is there already a (name=bare_name, fandom_id=new_fandom_id, kind='character')? + collision = conn.execute( + text(""" + SELECT id, name FROM tag + WHERE kind = 'character' + AND name = :bare + AND fandom_id = :fid + AND id != :tag_id + """), + {'bare': bare_name, 'fid': new_fandom_id, 'tag_id': tag_id}, + ).fetchone() + + if collision: + # Auto-merge. Reassign image_tags, cascade tag_reference_embedding + # rename (from current tag's existing name into the collision's + # name), delete current tag. + collision_id, collision_name = collision + _reassign_image_tags(conn, from_id=tag_id, to_id=collision_id) + _rename_tag_reference_embedding(conn, old_name=char_name, new_name=collision_name) + conn.execute( + text("DELETE FROM tag WHERE id = :id"), + {'id': tag_id}, + ) + phase2_merged += 1 + continue + + # Happy path: update name + fandom_id, cascade rename in tag_reference_embedding. + conn.execute( + text("UPDATE tag SET name = :bare, fandom_id = :fid WHERE id = :id"), + {'bare': bare_name, 'fid': new_fandom_id, 'id': tag_id}, + ) + _rename_tag_reference_embedding(conn, old_name=char_name, new_name=bare_name) + phase2_extracted += 1 + + # ------------------------------------------------------------------------- + # Phase 3 — drop old unique, create partial indices + # ------------------------------------------------------------------------- + # The unique index on tag.name was created as part of the initial table + # definition; its name in Postgres is typically tag_name_key (SQLAlchemy's + # default for UniqueConstraint on column tag.name). If your DB has a + # different name, adjust here. The drop + create is atomic with the rest + # of the migration. + op.drop_constraint('tag_name_key', 'tag', type_='unique') + + op.create_index( + 'tag_character_with_fandom_uniq', 'tag', ['name', 'fandom_id'], + unique=True, + postgresql_where=sa.text("kind = 'character' AND fandom_id IS NOT NULL"), + ) + op.create_index( + 'tag_character_null_fandom_uniq', 'tag', ['name'], + unique=True, + postgresql_where=sa.text("kind = 'character' AND fandom_id IS NULL"), + ) + op.create_index( + 'tag_other_kinds_uniq', 'tag', ['name', 'kind'], + unique=True, + postgresql_where=sa.text("kind != 'character'"), + ) + op.create_index('tag_kind_idx', 'tag', ['kind']) + + # ------------------------------------------------------------------------- + # Report + # ------------------------------------------------------------------------- + print("=" * 72) + print(f"j26042101: Phase 1 prefix-strips: {phase1_renames}") + print(f"j26042101: Phase 2 character extractions: {phase2_extracted}") + print(f"j26042101: Phase 2 auto-merged duplicates: {phase2_merged}") + print(f"j26042101: Phase 2 bare-name orphans (review in /tags): {len(phase2_orphans)}") + for tid, nm in phase2_orphans: + print(f" - tag {tid} '{nm}' coexists with a suffixed sibling") + print(f"j26042101: Phase 2 malformed names skipped: {len(phase2_malformed)}") + for tid, nm in phase2_malformed: + print(f" - tag {tid} '{nm}'") + print("=" * 72) + + +def downgrade(): + raise NotImplementedError( + "j26042101 strips kind prefixes and extracts character fandoms. " + "Both phases are destructive (auto-merges delete tag rows; partial " + "indices differ from the old unique constraint). Restore from the " + "pre-migration backup at /tmp/imagerepo_pre_refactor_backup.sql " + "if you need to roll back." + ) +``` + +- [ ] **Step 3: Run the migration against the live DB** + +```bash +docker compose exec web flask db upgrade +``` + +Expected: output ends with the report block. Note the four counts (Phase 1 renames, Phase 2 extractions, Phase 2 merges, Phase 2 orphans). + +- [ ] **Step 4: Verify no colons remain in non-post/non-archive tag names** + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c " + SELECT kind, COUNT(*) AS n + FROM tag + WHERE name LIKE '%:%' AND kind NOT IN ('post', 'archive') + GROUP BY kind; +" +``` + +Expected: zero rows returned. (Post/archive tags legitimately retain inner `:` in their identifiers.) + +- [ ] **Step 5: Verify no character tag name contains a fandom suffix** + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c " + SELECT id, name FROM tag + WHERE kind = 'character' + AND name ~ '^.+ \(.+\)$' + LIMIT 20; +" +``` + +Expected: zero rows. If any remain, they're malformed orphans that the migration logged — investigate those specifically and decide whether to merge manually. + +- [ ] **Step 6: Verify the four partial indices exist** + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c " + SELECT indexname, indexdef FROM pg_indexes + WHERE tablename = 'tag' + AND indexname IN ( + 'tag_character_with_fandom_uniq', + 'tag_character_null_fandom_uniq', + 'tag_other_kinds_uniq', + 'tag_kind_idx' + ); +" +``` + +Expected: four rows, each with the partial-WHERE clause visible in `indexdef`. + +- [ ] **Step 7: Verify image_tags count unchanged (no data loss)** + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c "SELECT COUNT(*) FROM image_tags;" +``` + +Compare against the baseline from Task 0 Step 3. Acceptable outcomes: +- Exactly equal: no merges happened, perfect. +- Slightly smaller: one or more images had the same character tag attached via both a suffixed *and* a bare-name version, which collapse on merge. Confirm the loss count matches `phase2_merged × (average duplicate attachments)` from the migration report. +- Larger: **not possible — abort**. + +- [ ] **Step 8: Commit** + +```bash +git add migrations/versions/j26042101_bare_tag_names_and_fandom_id_authoritative.py +git commit -m "feat(migration): bare tag names; fandom_id authoritative + +Three-phase Alembic migration j26042101 strips 'kind:' prefix from every +non-user tag, extracts character '(Fandom)' suffix into fandom_id with +auto-merge on duplicates, and swaps UNIQUE(name) for four partial indices +keyed on (name, kind) with special handling for the (character, fandom_id) +combination. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 4: Update Tag model's constraints to match the new indices + +**Why:** the model still has `unique=True` on `name`. Postgres won't enforce it anymore (the migration dropped that constraint), but Alembic's autogenerate will re-create it on the next migration unless we fix the model. Also, we add the partial indices to `__table_args__` so the ORM-level schema stays in sync. + +**Files:** +- Modify: `app/models.py` (Tag class) + +- [ ] **Step 1: Remove `unique=True` from Tag.name and add `__table_args__`** + +In `app/models.py`, update the Tag class. Replace: + +```python +class Tag(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), unique=True, nullable=False) + kind = db.Column(db.String(64), nullable=True) + fandom_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="SET NULL"), nullable=True) + fandom = db.relationship("Tag", remote_side="Tag.id", foreign_keys=[fandom_id]) + images = db.relationship( + "ImageRecord", + secondary=image_tags, + back_populates="tags", + passive_deletes=True, + ) + + @property + def display_name(self) -> str: +``` + +with: + +```python +class Tag(db.Model): + __table_args__ = ( + db.Index( + 'tag_character_with_fandom_uniq', 'name', 'fandom_id', + unique=True, + postgresql_where=db.text("kind = 'character' AND fandom_id IS NOT NULL"), + ), + db.Index( + 'tag_character_null_fandom_uniq', 'name', + unique=True, + postgresql_where=db.text("kind = 'character' AND fandom_id IS NULL"), + ), + db.Index( + 'tag_other_kinds_uniq', 'name', 'kind', + unique=True, + postgresql_where=db.text("kind != 'character'"), + ), + db.Index('tag_kind_idx', 'kind'), + ) + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), nullable=False) + kind = db.Column(db.String(64), nullable=True) + fandom_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="SET NULL"), nullable=True) + fandom = db.relationship("Tag", remote_side="Tag.id", foreign_keys=[fandom_id]) + images = db.relationship( + "ImageRecord", + secondary=image_tags, + back_populates="tags", + passive_deletes=True, + ) + + @property + def display_name(self) -> str: +``` + +(Everything after `@property` is unchanged from Task 1.) + +- [ ] **Step 2: Verify Alembic autogenerate sees no schema drift** + +```bash +docker compose exec web flask db revision --autogenerate -m "temp_check_drift" +ls -1t migrations/versions/ | head -1 +``` + +Inspect the newest file in `migrations/versions/`. Its `upgrade()` body should be empty (or contain only `pass`) — no index drop/add, no alterations. If it's empty, delete it: + +```bash +LATEST=$(ls -1t migrations/versions/*.py | head -1) +grep -A 5 "def upgrade" "$LATEST" +# If the body is just 'pass' or empty, delete: +rm "$LATEST" +``` + +If the autogenerated migration contains real drift (index changes, constraint changes), stop and investigate — the model doesn't match what Task 3's migration produced. + +- [ ] **Step 3: Commit** + +```bash +git add app/models.py +git commit -m "feat(models): Tag.__table_args__ matches j26042101 indices + +Drop unique=True on Tag.name (the migration replaced UNIQUE(name) with +four partial indices). Declare the partial indices in __table_args__ so +Alembic autogenerate stops seeing drift on future schema changes. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 5: Rewrite `_ensure_fandom_tag` helper to store bare names + +**Why:** this helper is used by `add_tag`, `bulk_add_tag`, and `set_tag_fandom`. It currently creates rows with `name=f"fandom:{normalized}"`. Post-migration those names are stored bare. + +**Files:** +- Modify: `app/main.py` (lines 28-40, the `_ensure_fandom_tag` function) + +- [ ] **Step 1: Update `_ensure_fandom_tag` in `app/main.py`** + +Replace the existing function (around line 28): + +```python +def _ensure_fandom_tag(fandom_name): + """Find or create a fandom tag by name. Returns the Tag object. + + Normalizes the display name so callers don't have to remember. + """ + normalized = normalize_display_name(fandom_name.strip()) + full_name = f"fandom:{normalized}" + fandom_tag = Tag.query.filter_by(name=full_name).first() + if not fandom_tag: + fandom_tag = Tag(name=full_name, kind="fandom") + db.session.add(fandom_tag) + db.session.flush() + return fandom_tag +``` + +with: + +```python +def _ensure_fandom_tag(fandom_name): + """Find or create a fandom tag by bare name. Returns the Tag object. + + Normalizes the display name so callers don't have to remember. + """ + normalized = normalize_display_name(fandom_name.strip()) + fandom_tag = Tag.query.filter_by(kind='fandom', name=normalized).first() + if not fandom_tag: + fandom_tag = Tag(kind='fandom', name=normalized) + db.session.add(fandom_tag) + db.session.flush() + return fandom_tag +``` + +- [ ] **Step 2: Verify via Flask shell** + +```bash +docker compose exec web flask shell -c " +from app.main import _ensure_fandom_tag +from app import db +t = _ensure_fandom_tag('Test Fandom Ephemeral') +print(f'name={t.name!r} kind={t.kind!r}') +assert t.name == 'Test Fandom Ephemeral', f'expected bare name, got {t.name!r}' +assert t.kind == 'fandom' +# Clean up so we don't pollute the DB +db.session.delete(t) +db.session.commit() +print('OK') +" +``` + +Expected: `name='Test Fandom Ephemeral' kind='fandom'` then `OK`. + +- [ ] **Step 3: Commit** + +```bash +git add app/main.py +git commit -m "refactor(main): _ensure_fandom_tag stores bare names + +Post-migration tag.name no longer embeds 'fandom:' prefix. Helper now +creates/finds by (kind='fandom', name=bare). + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 6: Rewrite `add_tag` endpoint to use `parse_kind_prefix` and bare storage + +**Why:** this is the main user-facing entry point for new tags. It must accept the user-facing `character:Saber` shortcut, parse kind out at the boundary, and store bare. Character path gains explicit `fandom_id` handling. + +**Files:** +- Modify: `app/main.py` (`add_tag` around line 862-931) + +- [ ] **Step 1: Replace the `add_tag` function body** + +Find the existing `add_tag` (decorator `@main.post("/image//tags/add")` around line 862) and replace the whole function with: + +```python +@main.post("/image//tags/add") +def add_tag(image_id): + """ + JSON endpoint to add a tag to an image. + + Accepts the user-facing 'kind:name' shortcut in the 'name' field + (e.g. 'character:Saber'). Bare strings without a recognized prefix are + treated as user tags. + + For character tags, optional 'fandom_id' (preferred) or 'fandom_name' + attaches the character to a fandom. Both omitted = null fandom (allowed). + """ + from app.utils.tag_prefix import parse_kind_prefix + from app.utils.tag_names import underscores_to_spaces + + raw = (request.form.get("name") or "").strip() + if not raw: + abort(400) + + kind, name = parse_kind_prefix(raw) + if kind is None: + # No recognized prefix — treat as a user-typed general tag. + kind = "user" + name = underscores_to_spaces(name) + elif kind in ("character", "fandom"): + name = normalize_display_name(name) + + img = ImageRecord.query.get_or_404(image_id) + + fandom_tag = None + if kind == "character": + fandom_id = request.form.get("fandom_id", type=int) + fandom_name = (request.form.get("fandom_name") or "").strip() or None + + if fandom_id: + fandom_tag = Tag.query.filter_by(id=fandom_id, kind="fandom").first() + if fandom_tag is None: + return jsonify(ok=False, error="fandom_not_found"), 400 + elif fandom_name: + fandom_tag = _ensure_fandom_tag(fandom_name) + + tag = Tag.query.filter_by( + kind="character", + name=name, + fandom_id=fandom_tag.id if fandom_tag else None, + ).first() + if tag is None: + tag = Tag( + kind="character", + name=name, + fandom_id=fandom_tag.id if fandom_tag else None, + ) + db.session.add(tag) + db.session.flush() + else: + tag = Tag.query.filter_by(kind=kind, name=name).first() + if tag is None: + tag = Tag(kind=kind, name=name) + db.session.add(tag) + db.session.flush() + + if tag not in img.tags: + img.tags.append(tag) + + # Auto-apply fandom tag to the image (preserves today's UX feature). + if fandom_tag and fandom_tag not in img.tags: + img.tags.append(fandom_tag) + + db.session.commit() + + result = { + "id": tag.id, + "name": tag.name, + "display_name": tag.display_name, + "kind": tag.kind, + } + if fandom_tag: + result["fandom_tag"] = { + "id": fandom_tag.id, + "name": fandom_tag.name, + "display_name": fandom_tag.display_name, + "kind": fandom_tag.kind, + } + return jsonify(ok=True, tag=result) +``` + +- [ ] **Step 2: Exercise the endpoint against an existing image** + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c "SELECT id FROM image_record LIMIT 1;" +``` + +Copy the returned id (call it `$IMG`). Then: + +```bash +IMG= +# General tag (no prefix) +curl -sX POST "http://localhost:5000/image/$IMG/tags/add" \ + -d "name=test_general_ephemeral" | python -m json.tool + +# Character with explicit fandom_name +curl -sX POST "http://localhost:5000/image/$IMG/tags/add" \ + -d "name=character:Test Char Ephemeral&fandom_name=Test Fandom Ephemeral" \ + | python -m json.tool +``` + +Expected for the first: `{"ok": true, "tag": {"id": ..., "name": "test general ephemeral", "display_name": "test general ephemeral", "kind": "user"}}` (no fandom_tag key). + +Expected for the second: `ok=true`, `tag.name="Test Char Ephemeral"`, `tag.kind="character"`, `tag.display_name="Test Char Ephemeral (Test Fandom Ephemeral)"`, and a sibling `fandom_tag` block. + +- [ ] **Step 3: Clean up the ephemeral test tags** + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c " + DELETE FROM image_tags WHERE tag_id IN ( + SELECT id FROM tag WHERE name IN ( + 'test general ephemeral', 'Test Char Ephemeral', 'Test Fandom Ephemeral' + ) + ); + DELETE FROM tag WHERE name IN ( + 'test general ephemeral', 'Test Char Ephemeral', 'Test Fandom Ephemeral' + ); +" +``` + +- [ ] **Step 4: Commit** + +```bash +git add app/main.py +git commit -m "refactor(main): add_tag parses prefix at boundary; bare storage + +Accepts 'character:Saber' user shortcut, parses via parse_kind_prefix, +stores tag.name bare. Character path accepts fandom_id (preferred) or +fandom_name for fandom association. Returns display_name in the +response so the client can render pills without a second round-trip. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 7: Rewrite `bulk_add_tag` to match the new `add_tag` shape + +**Why:** the bulk endpoint applies one tag to many images. It has the same prefix-parsing logic — extract it to use the shared `parse_kind_prefix` helper and match the refactored storage shape. + +**Files:** +- Modify: `app/main.py` (`bulk_add_tag`, around line 2320-2403) + +- [ ] **Step 1: Read the current bulk_add_tag** + +```bash +docker compose exec web python -c " +import re +with open('/app/app/main.py') as f: + txt = f.read() +# find def bulk_add_tag( +start = txt.index('def bulk_add_tag(') +end = txt.index('\n@main.', start) +print(txt[start:end]) +" 2>/dev/null || sed -n '2320,2405p' app/main.py +``` + +- [ ] **Step 2: Replace bulk_add_tag with the refactored version** + +Open `app/main.py` and find `def bulk_add_tag(` (around line 2321). Replace the entire function body (from the `def` line through to the next blank line before the next `@main.` decorator) with: + +```python +@main.route('/api/images/bulk-add-tag', methods=['POST']) +def bulk_add_tag(): + """ + Bulk-apply one tag to many images in a single transaction. + + Accepts the same 'kind:name' shortcut as /image//tags/add. For + character tags, accepts optional fandom_id / fandom_name just like + the single-image endpoint. + """ + from app.utils.tag_prefix import parse_kind_prefix + from app.utils.tag_names import underscores_to_spaces + + data = request.get_json() or {} + image_ids = data.get("image_ids") or [] + raw = (data.get("tag_name") or "").strip() + if not image_ids or not raw: + return jsonify(ok=False, error="image_ids and tag_name required"), 400 + + kind, name = parse_kind_prefix(raw) + if kind is None: + kind = "user" + name = underscores_to_spaces(name) + elif kind in ("character", "fandom"): + name = normalize_display_name(name) + + fandom_tag = None + if kind == "character": + fandom_id = data.get("fandom_id") + fandom_name = (data.get("fandom_name") or "").strip() or None + if fandom_id: + fandom_tag = Tag.query.filter_by(id=fandom_id, kind="fandom").first() + if fandom_tag is None: + return jsonify(ok=False, error="fandom_not_found"), 400 + elif fandom_name: + fandom_tag = _ensure_fandom_tag(fandom_name) + + tag = Tag.query.filter_by( + kind="character", + name=name, + fandom_id=fandom_tag.id if fandom_tag else None, + ).first() + if tag is None: + tag = Tag( + kind="character", + name=name, + fandom_id=fandom_tag.id if fandom_tag else None, + ) + db.session.add(tag) + db.session.flush() + else: + tag = Tag.query.filter_by(kind=kind, name=name).first() + if tag is None: + tag = Tag(kind=kind, name=name) + db.session.add(tag) + db.session.flush() + + added_count = 0 + for image_id in image_ids: + img = ImageRecord.query.get(image_id) + if img is None: + continue + if tag not in img.tags: + img.tags.append(tag) + added_count += 1 + if fandom_tag and fandom_tag not in img.tags: + img.tags.append(fandom_tag) + + db.session.commit() + return jsonify( + ok=True, + added_count=added_count, + total=len(image_ids), + tag={ + "id": tag.id, + "name": tag.name, + "display_name": tag.display_name, + "kind": tag.kind, + }, + ) +``` + +- [ ] **Step 3: Verify with two dummy images** + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c "SELECT id FROM image_record LIMIT 2;" +``` + +Take the two ids (call them IMG1, IMG2): + +```bash +IMG1= +IMG2= + +curl -sX POST "http://localhost:5000/api/images/bulk-add-tag" \ + -H "Content-Type: application/json" \ + -d "{\"image_ids\": [$IMG1, $IMG2], \"tag_name\": \"bulk_test_ephemeral\"}" \ + | python -m json.tool +``` + +Expected: `ok=true`, `added_count=2` (or less if the images already had that tag), `tag.name="bulk test ephemeral"` (underscore stripped), `tag.kind="user"`. + +Clean up: + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c " + DELETE FROM image_tags WHERE tag_id IN (SELECT id FROM tag WHERE name = 'bulk test ephemeral'); + DELETE FROM tag WHERE name = 'bulk test ephemeral'; +" +``` + +- [ ] **Step 4: Commit** + +```bash +git add app/main.py +git commit -m "refactor(main): bulk_add_tag uses parse_kind_prefix; bare storage + +Mirrors add_tag's new shape. Removes inline prefix-parsing, returns +display_name in the response. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 8: Rewrite `accept_image_suggestion` for 0/1/N candidate resolution + +**Why:** WD14 emits bare names. Post-migration, `Tag.name` is also bare. A bare WD14 character suggestion ("Ash") may match zero, one, or multiple existing characters. This task implements the 409-conflict branch + `explicit_tag_id` disambiguation path. + +**Files:** +- Modify: `app/main.py` (`accept_image_suggestion`, currently around line 767-860) + +- [ ] **Step 1: Find the current accept_image_suggestion route** + +```bash +grep -n "def accept_image_suggestion\|accept-suggestion\|/suggestions/accept" app/main.py +``` + +Locate the route and note its line range. + +- [ ] **Step 2: Replace the function body** + +Replace the entire function with: + +```python +@main.post("/image//suggestions/accept") +def accept_image_suggestion(image_id): + """ + Accept a single tag suggestion for an image. + + Post-refactor resolution: + - If `tag_id` is provided, attach that exact tag (used by the + frontend after the user resolves an ambiguous suggestion). + - Else for character category: search by (kind='character', name=bare). + 0 matches -> create null-fandom tag. 1 match -> attach. N matches -> + return HTTP 409 with candidate list for the frontend to disambiguate. + - Else for fandom/general: find-or-create by (kind, name). + """ + from app.services.tag_suggestions import _WD14_CATEGORY_TO_KIND + + data = request.get_json() or {} + category = data.get("category") + name = (data.get("name") or "").strip() + explicit_tag_id = data.get("tag_id") + + img = ImageRecord.query.get_or_404(image_id) + kind = _WD14_CATEGORY_TO_KIND.get(category) + + if explicit_tag_id: + tag = Tag.query.get(explicit_tag_id) + if tag is None: + return jsonify(ok=False, error="tag_not_found"), 404 + elif kind == "character": + candidates = Tag.query.filter_by(kind="character", name=name).all() + if len(candidates) == 1: + tag = candidates[0] + elif len(candidates) == 0: + tag = Tag(kind="character", name=name, fandom_id=None) + db.session.add(tag) + db.session.flush() + else: + return jsonify( + ok=False, + error="ambiguous", + candidates=[ + { + "id": c.id, + "display_name": c.display_name, + "fandom_id": c.fandom_id, + "fandom_name": c.fandom.name if c.fandom else None, + } + for c in candidates + ], + ), 409 + else: + effective_kind = kind if kind else "user" + tag = Tag.query.filter_by(kind=effective_kind, name=name).first() + if tag is None: + tag = Tag(kind=effective_kind, name=name) + db.session.add(tag) + db.session.flush() + + # Auto-apply the character's fandom if set. + fandom_tag = None + if tag.kind == "character" and tag.fandom_id: + fandom_tag = Tag.query.get(tag.fandom_id) + + if tag not in img.tags: + img.tags.append(tag) + if fandom_tag and fandom_tag not in img.tags: + img.tags.append(fandom_tag) + + db.session.commit() + return jsonify( + ok=True, + tag={ + "id": tag.id, + "name": tag.name, + "display_name": tag.display_name, + "kind": tag.kind, + }, + fandom_tag=( + { + "id": fandom_tag.id, + "name": fandom_tag.name, + "display_name": fandom_tag.display_name, + "kind": fandom_tag.kind, + } + if fandom_tag else None + ), + ) +``` + +- [ ] **Step 3: Smoke-test the 0-candidate branch** + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c "SELECT id FROM image_record LIMIT 1;" +IMG= + +curl -sX POST "http://localhost:5000/image/$IMG/suggestions/accept" \ + -H "Content-Type: application/json" \ + -d '{"category": "character", "name": "Nonexistent Test Character"}' \ + | python -m json.tool +``` + +Expected: `ok=true`, `tag.kind="character"`, `tag.name="Nonexistent Test Character"`, `tag.display_name` matches `tag.name` (fandom is null). + +- [ ] **Step 4: Smoke-test the N-candidate (ambiguous) branch** + +First create two same-name characters with different fandoms: + +```bash +docker compose exec web flask shell -c " +from app.models import Tag +from app import db +f1 = Tag(kind='fandom', name='Ambig Fandom One') +f2 = Tag(kind='fandom', name='Ambig Fandom Two') +db.session.add_all([f1, f2]); db.session.flush() +c1 = Tag(kind='character', name='Ambig Test Char', fandom_id=f1.id) +c2 = Tag(kind='character', name='Ambig Test Char', fandom_id=f2.id) +db.session.add_all([c1, c2]) +db.session.commit() +print(f'created chars {c1.id}, {c2.id} in fandoms {f1.id}, {f2.id}') +" +``` + +Then try to accept the ambiguous suggestion: + +```bash +curl -sX POST "http://localhost:5000/image/$IMG/suggestions/accept" \ + -H "Content-Type: application/json" \ + -d '{"category": "character", "name": "Ambig Test Char"}' \ + -w "\nHTTP: %{http_code}\n" \ + | head -20 +``` + +Expected: HTTP 409, JSON body with `error="ambiguous"` and a `candidates` array of two entries (one for each fandom). + +Clean up: + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c " + DELETE FROM image_tags WHERE tag_id IN (SELECT id FROM tag WHERE name LIKE '%Ambig%' OR name = 'Nonexistent Test Character'); + DELETE FROM tag WHERE name LIKE '%Ambig%' OR name = 'Nonexistent Test Character'; +" +``` + +- [ ] **Step 5: Commit** + +```bash +git add app/main.py +git commit -m "refactor(main): accept_image_suggestion 0/1/N candidate resolution + +Removes the ilike-suffix fallback. Bare-name character matches are +explicit: 0 -> create null-fandom, 1 -> attach, N -> 409 with candidate +list for frontend disambiguation. Accepts explicit tag_id to complete +the disambiguation round-trip. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 9: Rewrite `/api/tags/search` to JOIN fandom and match on CONCAT display expression + +**Why:** autocomplete typing "ruby rwby" should match the character "Ruby Rose (RWBY)". Post-refactor `tag.name` is bare, so SQL needs to JOIN the fandom tag and match against the concatenated display form. + +**Files:** +- Modify: `app/main.py` (`search_tags`, around line 636-691) + +- [ ] **Step 1: Replace the search_tags function** + +Find `def search_tags():` and replace the body with: + +```python +@main.get("/api/tags/search") +def search_tags(): + """ + Search existing tags for autocomplete. + Query params: + - q: search query (matches against display_name, case-insensitive) + - limit: max results (default 10) + - exclude_kind: comma-separated list of tag kinds to exclude + - kind: filter to only include tags of this kind + """ + from sqlalchemy import case + + query = request.args.get("q", "").strip().lower() + limit = request.args.get("limit", 10, type=int) + exclude_kind = request.args.get("exclude_kind", "").strip() + exclude_kinds = [k.strip() for k in exclude_kind.split(",") if k.strip()] + include_kind = request.args.get("kind", "").strip() + + f = aliased(Tag) + display_expr = case( + ( + and_(Tag.kind == "character", Tag.fandom_id.isnot(None)), + func.concat(Tag.name, " (", f.name, ")"), + ), + else_=Tag.name, + ) + + base_query = ( + db.session.query(Tag) + .outerjoin(f, f.id == Tag.fandom_id) + .options(joinedload(Tag.fandom)) + ) + if exclude_kinds: + base_query = base_query.filter(~Tag.kind.in_(exclude_kinds)) + if include_kind: + base_query = base_query.filter(Tag.kind == include_kind) + + if not query: + # No query: most-used tags. + base_query = base_query.join(image_tags, image_tags.c.tag_id == Tag.id).group_by(Tag.id, f.id) + tags = ( + base_query + .order_by(func.count(image_tags.c.image_id).desc()) + .limit(limit) + .all() + ) + else: + base_query = base_query.filter(display_expr.ilike(f"%{query}%")) + tags = ( + base_query + .order_by(display_expr) + .limit(limit) + .all() + ) + + return jsonify(tags=[ + { + "id": t.id, + "name": t.name, + "display_name": t.display_name, + "kind": t.kind, + "fandom_id": t.fandom_id if t.kind == "character" else None, + "fandom_name": (t.fandom.name if t.fandom else None) if t.kind == "character" else None, + } + for t in tags + ]) +``` + +- [ ] **Step 2: Verify plain search works** + +```bash +curl -s "http://localhost:5000/api/tags/search?q=rose" | python -m json.tool | head -30 +``` + +Expected: JSON with a `tags` array. Any character named "Ruby Rose" should come back with `display_name="Ruby Rose (RWBY)"` (the fandom suffix present) and `name="Ruby Rose"` (bare). + +- [ ] **Step 3: Verify fandom-scoped search works (used by the modal fandom picker)** + +```bash +curl -s "http://localhost:5000/api/tags/search?q=&kind=fandom&limit=5" | python -m json.tool +``` + +Expected: JSON array of tags, each `kind="fandom"`. Names are bare (no `fandom:` prefix). + +- [ ] **Step 4: Verify concat match finds characters by fandom suffix** + +```bash +curl -s "http://localhost:5000/api/tags/search?q=rwby" | python -m json.tool | head -30 +``` + +Expected: the query matches via the `CONCAT(name, ' (', f.name, ')')` expression — characters whose fandom is "RWBY" appear even though their bare `name` doesn't contain "rwby". + +- [ ] **Step 5: Commit** + +```bash +git add app/main.py +git commit -m "refactor(main): search_tags JOINs fandom; matches on display CONCAT + +Post-refactor tag.name is bare. Autocomplete needs to match the +display form (name + ' (' + fandom.name + ')') so users can still +find 'Ruby Rose (RWBY)' by typing 'rwby'. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 10: Gallery routes — switch from `?tag=` to `?tag_id=` + +**Why:** URL routing is the last layer still keyed on the name string. Clean-break switch to tag_id removes every name-parsing branch from route handlers. + +**Files:** +- Modify: `app/main.py` around line 112-175 (main gallery route) and line 267-285 (secondary gallery route) + +- [ ] **Step 1: Read the current gallery route** + +```bash +sed -n '100,180p' app/main.py +``` + +Note the existing variable names (`tag_name`, `active_tag_obj`, `q = ImageRecord.query...`). + +- [ ] **Step 2: Replace the gallery route's filter logic** + +Find the block starting near line 112 (`tag_name = request.args.get('tag')`). Replace the tag-filter section (from `tag_name = ...` through the series-tag if-block around line 170) with: + +```python + tag_id = request.args.get('tag_id', type=int) + active_tag_obj = ( + Tag.query.options(joinedload(Tag.fandom)).get(tag_id) + if tag_id else None + ) + if active_tag_obj: + q = q.join(ImageRecord.tags).filter(Tag.id == active_tag_obj.id) + + # Fetch post metadata if filtering by a post tag + post_metadata = None + if active_tag_obj and active_tag_obj.kind == 'post': + from app.models import PostMetadata + pm = PostMetadata.query.filter_by(tag_id=active_tag_obj.id).first() + if pm: + post_metadata = { + 'platform': pm.platform, + 'post_id': pm.post_id, + 'artist': pm.artist, + 'title': pm.title, + 'description': pm.description, + 'source_url': pm.source_url, + 'attachment_count': pm.attachment_count, + 'published_at': pm.published_at.isoformat() if pm.published_at else None, + } + + # Fetch series info if filtering by a series tag + series_info = None + if active_tag_obj and active_tag_obj.kind == 'series': + page_count = SeriesPage.query.filter_by(series_tag_id=active_tag_obj.id).count() + series_info = { + 'tag_id': active_tag_obj.id, + 'display_name': active_tag_obj.display_name, + 'page_count': page_count, + } +``` + +And in the template context (at the bottom of the route), make sure `active_tag_obj` is passed through (it likely is already) and that any `name` / `tag_name` local variable references have been removed. + +- [ ] **Step 3: Also update the secondary route at line ~268** + +```bash +sed -n '260,290p' app/main.py +``` + +Find the other `tag_name = request.args.get('tag')` occurrence and apply the same refactor: + +```python + tag_id = request.args.get('tag_id', type=int) + active_tag_obj = ( + Tag.query.options(joinedload(Tag.fandom)).get(tag_id) + if tag_id else None + ) + if active_tag_obj: + q = q.join(ImageRecord.tags).filter(Tag.id == active_tag_obj.id) +``` + +Remove any further downstream code that used the old `tag_name` variable; replace references with `active_tag_obj.name` or `active_tag_obj.display_name` as appropriate. + +- [ ] **Step 4: Verify gallery renders with `?tag_id=...`** + +```bash +# Pick any existing tag +docker compose exec -T db psql -U imagerepo imagerepo -c "SELECT id, name, kind FROM tag WHERE kind = 'character' LIMIT 1;" +TAG_ID= + +curl -s -o /dev/null -w "%{http_code}" "http://localhost:5000/?tag_id=$TAG_ID" +``` + +Expected: `200`. + +- [ ] **Step 5: Verify old `?tag=` URLs return no filter (silently ignored)** + +```bash +curl -s -o /dev/null -w "%{http_code}" "http://localhost:5000/?tag=anything" +``` + +Expected: `200` — the old param is simply not consumed, so the gallery renders without any filter. + +- [ ] **Step 6: Commit** + +```bash +git add app/main.py +git commit -m "refactor(main): gallery routes use ?tag_id=int + +Drops the ?tag= lookup. URLs become unambiguous (distinct +same-name characters each have their own id-keyed URL). Eager-loads +Tag.fandom for the active-tag header rendering. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 11: Refactor `set_tag_fandom` to pure fandom_id update + +**Why:** the function currently parses the name, renames the tag, and runs a retroactive backfill. Post-refactor none of that is needed — the display_name @property reflects fandom_id changes instantly. + +**Files:** +- Modify: `app/main.py` (`set_tag_fandom`, around line 969-1057) + +- [ ] **Step 1: Replace `set_tag_fandom` with the minimal version** + +```python +@main.post("/api/tag//set-fandom") +def set_tag_fandom(tag_id): + """ + Set or clear a character tag's fandom association. + + Form params: + fandom_id: ID of an existing fandom tag (preferred). + fandom_name: Name to find/create a fandom tag (used if fandom_id + not given). If both omitted, the fandom is cleared. + """ + tag = Tag.query.get_or_404(tag_id) + if tag.kind != "character": + return jsonify(ok=False, error="Only character tags can have fandoms"), 400 + + fandom_id = request.form.get("fandom_id", type=int) + fandom_name = (request.form.get("fandom_name") or "").strip() + + if fandom_id: + fandom_tag = Tag.query.filter_by(id=fandom_id, kind="fandom").first() + if fandom_tag is None: + return jsonify(ok=False, error="Invalid fandom tag"), 400 + tag.fandom_id = fandom_tag.id + elif fandom_name: + fandom_tag = _ensure_fandom_tag(fandom_name) + tag.fandom_id = fandom_tag.id + else: + tag.fandom_id = None + fandom_tag = None + + db.session.commit() + + # Auto-apply the fandom tag to images already tagged with this character. + if fandom_tag is not None: + try: + db.session.execute( + text(""" + INSERT INTO image_tags (image_id, tag_id) + SELECT it.image_id, :fandom_id + FROM image_tags it + WHERE it.tag_id = :char_id + ON CONFLICT DO NOTHING + """), + {"char_id": tag.id, "fandom_id": fandom_tag.id}, + ) + db.session.commit() + except Exception as e: + db.session.rollback() + current_app.logger.warning( + "set_tag_fandom auto-apply failed for tag %s -> fandom %s: %s", + tag.id, fandom_tag.id, e, + ) + + return jsonify(ok=True, tag={ + "id": tag.id, + "name": tag.name, + "display_name": tag.display_name, + "kind": tag.kind, + "fandom_id": tag.fandom_id, + "fandom_name": fandom_tag.name if fandom_tag else None, + }) +``` + +- [ ] **Step 2: Verify via curl** + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c " + SELECT t.id, t.name, t.fandom_id + FROM tag t + WHERE t.kind = 'character' + ORDER BY t.id + LIMIT 1; +" +TAG_ID= + +# Set fandom by name +curl -sX POST "http://localhost:5000/api/tag/$TAG_ID/set-fandom" \ + -d "fandom_name=Test Fandom For Set" | python -m json.tool +``` + +Expected: JSON with `ok=true`, `display_name` showing `"(Test Fandom For Set)"` suffix, `fandom_id` populated. + +Clear it: + +```bash +curl -sX POST "http://localhost:5000/api/tag/$TAG_ID/set-fandom" \ + -d "" | python -m json.tool +``` + +Expected: `ok=true`, `fandom_id: null`, `display_name` matches `name` (no suffix). Then clean up the ephemeral fandom: + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c " + DELETE FROM tag WHERE kind='fandom' AND name='Test Fandom For Set'; +" +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/main.py +git commit -m "refactor(main): set_tag_fandom is pure fandom_id update + +No rename, no retroactive-backfill-in-second-txn. display_name @property +reflects the change instantly. The auto-apply side effect (attach fandom +tag to images with the character) is preserved but simplified. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 12: Refactor `update_tag`, `get_tag`, `list_tags`, and remove `_parse_character_fandom` + +**Why:** these three routes each contain `name.split(':', 1)` branches plus `_parse_character_fandom` calls. After the migration those branches are dead. Consolidating them removes the helper in one sweep. + +**Files:** +- Modify: `app/main.py`: + - `_parse_character_fandom` at line 20-25 (delete) + - `get_tag` around line 952-966 + - `list_tags` around line 695-760 + - `update_tag` around line 1127-1234 + - `delete_tag` around line 1235-1250 (inspect, likely unchanged) + +- [ ] **Step 1: Delete `_parse_character_fandom`** + +Remove lines 20-25 of `app/main.py`: + +```python +def _parse_character_fandom(name_after_prefix): + """Parse 'Name (Fandom)' -> ('Name', 'Fandom') or ('Name', None).""" + m = re.match(r'^(.+?)\s*\((.+)\)$', name_after_prefix) + if m: + return m.group(1).strip(), m.group(2).strip() + return name_after_prefix, None +``` + +Leave the `import re` at line 14 — it's used elsewhere. Check: + +```bash +grep -n "re\." app/main.py | head -5 +``` + +If `re.` appears elsewhere, keep the import. Otherwise remove it. + +- [ ] **Step 2: Rewrite `get_tag`** + +Find `def get_tag(tag_id):` (around line 953). Replace the function body: + +```python +@main.get("/api/tag/") +def get_tag(tag_id): + """Get a single tag's details.""" + tag = Tag.query.options(joinedload(Tag.fandom)).get_or_404(tag_id) + result = { + "id": tag.id, + "name": tag.name, + "display_name": tag.display_name, + "kind": tag.kind, + "fandom_id": tag.fandom_id, + "fandom_name": tag.fandom.name if tag.fandom else None, + } + return jsonify(ok=True, tag=result) +``` + +- [ ] **Step 3: Rewrite `list_tags` (image tags)** + +Find `def list_tags(image_id):` (around line 695). Replace: + +```python +@main.get("/image//tags") +def list_tags(image_id): + img = ImageRecord.query.get_or_404(image_id) + tags_with_fandom = ( + db.session.query(Tag) + .join(image_tags, image_tags.c.tag_id == Tag.id) + .filter(image_tags.c.image_id == image_id) + .options(joinedload(Tag.fandom)) + .order_by(Tag.kind.asc(), Tag.name.asc()) + .all() + ) + return jsonify(tags=[ + { + "id": t.id, + "name": t.name, + "display_name": t.display_name, + "kind": t.kind, + "fandom_id": t.fandom_id if t.kind == "character" else None, + "fandom_name": (t.fandom.name if t.fandom else None) if t.kind == "character" else None, + } + for t in tags_with_fandom + ]) +``` + +- [ ] **Step 4: Rewrite `update_tag`** + +Find `def update_tag(tag_id):` (around line 1128). Replace the full function: + +```python +@main.post("/api/tag//update") +def update_tag(tag_id): + """ + Update a tag's name and/or kind. + + For character tags: rename = update Tag.name (bare). Fandom association + is NOT changed here — use /api/tag//set-fandom for that. + + If the new (name, kind [, fandom_id]) combination collides with an + existing tag, offer a merge (force_merge=true to commit). + """ + tag = Tag.query.get_or_404(tag_id) + + new_name = (request.form.get("name") or "").strip() + new_kind = (request.form.get("kind") or "user").strip() + force_merge = request.form.get("merge") == "true" + + if not new_name: + return jsonify(ok=False, error="Name is required"), 400 + + if new_kind in ("character", "fandom"): + new_name = normalize_display_name(new_name) + + # Check for duplicates using the new uniqueness shape + if new_kind == "character": + existing_q = Tag.query.filter( + Tag.kind == "character", + Tag.name == new_name, + Tag.fandom_id.is_(tag.fandom_id) if tag.fandom_id is None + else Tag.fandom_id == tag.fandom_id, + Tag.id != tag_id, + ) + else: + existing_q = Tag.query.filter( + Tag.kind == new_kind, + Tag.name == new_name, + Tag.id != tag_id, + ) + existing = existing_q.first() + + if existing: + if not force_merge: + return jsonify( + ok=False, + error="A tag with this name already exists", + can_merge=True, + target_tag={ + "id": existing.id, + "name": existing.name, + "display_name": existing.display_name, + "kind": existing.kind, + }, + ), 409 + + # Merge: move all images from source tag to target tag + source_images = tag.images[:] + merged_count = 0 + for img in source_images: + if existing not in img.tags: + img.tags.append(existing) + merged_count += 1 + img.tags.remove(tag) + db.session.delete(tag) + db.session.commit() + return jsonify(ok=True, merged=True, merged_count=merged_count, tag={ + "id": existing.id, + "name": existing.name, + "display_name": existing.display_name, + "kind": existing.kind, + }) + + # Plain rename + tag.name = new_name + tag.kind = new_kind + db.session.commit() + return jsonify(ok=True, tag={ + "id": tag.id, + "name": tag.name, + "display_name": tag.display_name, + "kind": tag.kind, + "fandom_id": tag.fandom_id, + }) +``` + +- [ ] **Step 5: Verify via curl** + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c " + SELECT id, name, kind FROM tag WHERE kind = 'user' LIMIT 1; +" +TAG_ID= + +curl -s "http://localhost:5000/api/tag/$TAG_ID" | python -m json.tool +``` + +Expected: `ok=true`, `tag.display_name` = `tag.name` (user kind has no fandom), `tag.kind="user"`. + +- [ ] **Step 6: Commit** + +```bash +git add app/main.py +git commit -m "refactor(main): simplify get_tag/list_tags/update_tag + +Removes _parse_character_fandom (no longer needed — names are bare). +update_tag respects the new (name, kind[, fandom_id]) uniqueness shape. +get_tag and list_tags return display_name so clients render without +re-parsing. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 13: Sweep remaining `name.split(':', 1)` sites in `app/main.py` + +**Why:** individual call sites scattered around: at lines 173 (gallery header display), 487 (`_get_tags_with_previews`), 1204, 1214-1215, 1220, 2470 (series page rendering), and ~901-904 if any remain from earlier tasks. + +**Files:** +- Modify: `app/main.py` — every remaining `name.split(':', 1)` occurrence + +- [ ] **Step 1: Find the remaining occurrences** + +```bash +grep -n "\.split(['\"]:['\"]" app/main.py +``` + +Skip any occurrence inside `app/utils/tag_prefix.py` (that's the parser's legitimate use). Every other occurrence must be replaced. + +- [ ] **Step 2: Replace each occurrence** + +For each match, apply one of these patterns: + +| Context | Replace `t.name.split(':', 1)[1] if ':' in t.name else t.name` with | +|---|---| +| Rendering to a user (gallery header, template name, tag pill) | `t.display_name` | +| Extracting the "post-prefix" part when the full name still had a prefix | `t.name` (post-migration the prefix is gone) | +| Series page names (e.g. `app/main.py:2470`) | `t.name` | +| Fandom extraction in `search_tags` / `get_tag` / `list_tags` | Already done in prior tasks — delete leftover duplicate code if any. | + +Concrete examples: + +Line 173 (gallery route, filter header display): +```python +# BEFORE +'name': active_tag_obj.name.split(':', 1)[1] if ':' in active_tag_obj.name else active_tag_obj.name, +# AFTER +'name': active_tag_obj.display_name, +``` + +Line 487 (`_get_tags_with_previews`): +```python +# BEFORE +label = t.name.split(":", 1)[1] if (":" in t.name) else t.name +# AFTER +label = t.display_name +``` + +Line 2470 (reader series name): +```python +# BEFORE +series_name = tag.name.split(':', 1)[1] if ':' in tag.name else tag.name +# AFTER +series_name = tag.name # series tags are now bare +``` + +- [ ] **Step 3: Verify no straggler splits remain** + +```bash +grep -n "\.split(['\"]:['\"]" app/main.py +``` + +Expected: only occurrences inside `add_tag` / `bulk_add_tag` bodies that call `parse_kind_prefix` (which itself uses `split(':', 1)` legitimately). Zero standalone `name.split(':', 1)[1]` expressions. + +- [ ] **Step 4: Smoke-test the gallery by filter** + +```bash +# Start web, visit http://localhost:5000/?tag_id= in a browser +# Verify the header shows "Ruby Rose (RWBY)" style display text, not bare name. +``` + +- [ ] **Step 5: Commit** + +```bash +git add app/main.py +git commit -m "refactor(main): sweep name.split(':', 1) -> display_name + +Every standalone prefix-strip expression in app/main.py replaced with +tag.display_name (user-facing) or tag.name (identifier contexts). The +only remaining colon-splits are inside parse_kind_prefix and tag +suggestions canonicalization. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 14: Rewrite artist/archive tag construction in `app/utils/image_importer.py` + +**Why:** two sites build `f"archive:{artist}:{archive_name}"` names. Post-refactor those become `Tag(kind='archive', name=f"{artist}:{archive_name}")` with the inner `:` kept. + +**Files:** +- Modify: `app/utils/image_importer.py` around line 961 (archive tag creation) and line 986 (prefix-scan) + +- [ ] **Step 1: Read the current state** + +```bash +sed -n '955,995p' app/utils/image_importer.py +``` + +- [ ] **Step 2: Update the archive tag construction** + +Around line 961, replace the tag build: + +```python +# BEFORE +base_tag = f"archive:{artist}:{archive_name}" +existing = Tag.query.filter_by(name=base_tag, kind='archive').first() + +# AFTER +base_name = f"{artist}:{archive_name}" +existing = Tag.query.filter_by(kind='archive', name=base_name).first() +``` + +Update any subsequent references to `base_tag` in the surrounding block to use `base_name`. + +Around line 972, replace the dedup loop: + +```python +# BEFORE +if not Tag.query.filter_by(name=candidate, kind='archive').first(): + +# AFTER +if not Tag.query.filter_by(kind='archive', name=candidate).first(): +``` + +Where `candidate` is built, strip the `archive:` prefix: + +```bash +sed -n '965,985p' app/utils/image_importer.py +``` + +Find whatever builds `candidate` and remove the `archive:` prefix (candidate becomes `f"{artist}:{archive_name} ({i})"` or similar). + +- [ ] **Step 3: Update the prefix-scan at line 986** + +```python +# BEFORE +prefix = f"archive:{artist}/" +# ... Tag.query ... Tag.name.ilike(f"{prefix}%") + +# AFTER (the ILIKE stays, just the prefix string changes) +prefix = f"{artist}/" +# ... Tag.query.filter(Tag.kind == 'archive', Tag.name.ilike(f"{prefix}%")) +``` + +- [ ] **Step 4: Verify by importing a dummy archive (optional — skip if no archive test data available)** + +This path only runs during archive imports. If you have a ZIP/CBZ in your import queue, run: + +```bash +docker compose logs -f celery-worker-import & +# Move a test archive into the import directory and wait for processing +``` + +Expected: any archive tag created in logs shows as `Tag(kind='archive', name='{artist}:{archive_name}')` — no `archive:` prefix in name. + +- [ ] **Step 5: Commit** + +```bash +git add app/utils/image_importer.py +git commit -m "refactor(importer): archive tags drop kind prefix + +Archive identifier string (artist:archive_name) stays as the tag's +opaque name — the archive kind lives in the kind column. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 15: Update post tag construction in `app/utils/metadata_enrichment.py` + +**Why:** the one site that builds `f"post:{platform}:{safe_artist}:{post_id}"`. Drop the outer `post:` prefix. + +**Files:** +- Modify: `app/utils/metadata_enrichment.py` around line 472 + +- [ ] **Step 1: Read surrounding context** + +```bash +sed -n '465,490p' app/utils/metadata_enrichment.py +``` + +- [ ] **Step 2: Strip the outer `post:` prefix** + +Replace: + +```python +"name": f"post:{platform}:{safe_artist}:{post_id}", +``` + +with: + +```python +"name": f"{platform}:{safe_artist}:{post_id}", +``` + +Also verify that `kind: 'post'` is set elsewhere in this data structure (grep for `'kind':` / `"kind":` in the same function). If not, add it. + +- [ ] **Step 3: Verify no other post tag construction is still prefix-based** + +```bash +grep -n "post:" app/utils/metadata_enrichment.py +``` + +Expected: only references inside docstrings/comments. Any remaining `f"post:..."` is a bug. + +- [ ] **Step 4: Commit** + +```bash +git add app/utils/metadata_enrichment.py +git commit -m "refactor(enrichment): post tag name omits post: prefix + +The inner platform:artist:id identifier remains as opaque tag.name. +Kind column carries 'post'. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 16: Update artist tag construction in `app/tasks/import_file.py` + +**Why:** two sites (lines ~508, ~517, ~813) build `f"artist:{artist}"` or `f"archive:..."` names. + +**Files:** +- Modify: `app/tasks/import_file.py` + +- [ ] **Step 1: Grep for `f"artist:"`, `f"archive:"`, or any `f"{kind}:"` pattern** + +```bash +grep -n "f\"artist:\|f\"archive:\|f\"fandom:\|f\"series:\|f\"post:\|f\"character:" app/tasks/import_file.py +``` + +- [ ] **Step 2: Strip prefixes at each match** + +For each occurrence, replace `f"{kind}:{value}"` with the bare value, and ensure the `Tag(kind=..., name=...)` construction is passing `kind` explicitly. + +Example (line ~813): + +```python +# BEFORE +tag_name = f"artist:{artist}" +... +tag = Tag(name=tag_name, kind='artist') + +# AFTER +tag_name = artist +tag = Tag(name=tag_name, kind='artist') +``` + +Also update any `Tag.query.filter_by(name=...)` lookups in the same block to filter by `(kind, name)`: + +```python +# BEFORE +tag = Tag.query.filter_by(name=tag_name).first() + +# AFTER +tag = Tag.query.filter_by(kind='artist', name=tag_name).first() +``` + +- [ ] **Step 3: Verify via a dry-run import (skip if no test image)** + +```bash +# Check that the current imports are still creating tags correctly: +docker compose logs -f celery-worker-import +# Drop a test image into the import directory and tail logs for any crashes +``` + +- [ ] **Step 4: Commit** + +```bash +git add app/tasks/import_file.py +git commit -m "refactor(import): drop artist:/archive: prefix from constructed tag names + +Tag.name holds the bare identifier; Tag.kind already carried the role. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 17: Clean up `app/services/tag_suggestions.py` + +**Why:** `_FANDOM_SUFFIX_RE`, `_fandom_suffix_prefixes`, and the `applied_fandom_prefixes` branch in `_wd14_suggestions` are all band-aids for the old naming kludge. Post-refactor they're dead. + +**Files:** +- Modify: `app/services/tag_suggestions.py` + +- [ ] **Step 1: Remove `_FANDOM_SUFFIX_RE` and `_fandom_suffix_prefixes`** + +Find and delete: + +```python +# Matches `Name (Fandom)` — used to recognize that an applied character/copyright +# tag covers a bare WD14 suggestion like `Name`. +_FANDOM_SUFFIX_RE = re.compile(r'^(.+?) \([^()]+\)$') + + +def _fandom_suffix_prefixes(names: Iterable[str]) -> set[str]: + """Extract the `Name` portion from any `Name (Fandom)` entries in `names`.""" + out: set[str] = set() + for n in names: + m = _FANDOM_SUFFIX_RE.match(n) + if m: + out.add(m.group(1)) + return out +``` + +- [ ] **Step 2: Simplify `_wd14_suggestions`** + +Find `_wd14_suggestions(image_id, cfg, already)`. Remove the `applied_fandom_prefixes` computation and the related skip branch: + +```python +# REMOVE these lines (around lines 133-136 and 145-146 of the pre-refactor file): +applied_fandom_prefixes = _fandom_suffix_prefixes(already) +... +if p.tag_category in ('character', 'copyright') and canonical in applied_fandom_prefixes: + continue +``` + +The rest of `_wd14_suggestions` stays as-is. + +- [ ] **Step 3: Simplify `_canonicalize_wd14_name`** + +Currently (lines 41-54) it has kind-aware branching for prefix preservation. Post-refactor the function just normalizes names for character/fandom and strips underscores elsewhere: + +```python +def _canonicalize_wd14_name(raw: str, category: str) -> str: + """Rewrite a raw WD14 tag name to the canonical form ImageRepo persists. + + - character / copyright: title-case each word. + - everything else: underscore-to-space, preserving user casing. + """ + from app.utils.tag_names import normalize_display_name, underscores_to_spaces + kind = _WD14_CATEGORY_TO_KIND.get(category) + if kind in ('character', 'fandom'): + return normalize_display_name(raw) + return underscores_to_spaces(raw) +``` + +Notice: the old code checked for `:` in the raw string and preserved the `kind:` prefix. Post-refactor WD14 emits bare names and `Tag.name` stores them bare, so there's no prefix to preserve. + +- [ ] **Step 4: Remove the `import re` if no other module-level regex remains** + +```bash +grep -n "^import re\|^from re" app/services/tag_suggestions.py +grep -n "\bre\." app/services/tag_suggestions.py +``` + +If `re.` is not called anywhere in the file after removing `_FANDOM_SUFFIX_RE`, remove the `import re` statement at the top. + +- [ ] **Step 5: Smoke-test suggestions for any image** + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c " + SELECT image_id, COUNT(*) + FROM image_tag_prediction + GROUP BY image_id + ORDER BY COUNT(*) DESC + LIMIT 1; +" +IMG= + +curl -s "http://localhost:5000/image/$IMG/suggestions" | python -m json.tool | head -30 +``` + +Expected: a JSON with `character`/`copyright`/`general` lists, no exceptions. Each item has bare `name` (no prefix). + +- [ ] **Step 6: Commit** + +```bash +git add app/services/tag_suggestions.py +git commit -m "refactor(suggestions): remove name-parsing band-aids + +_FANDOM_SUFFIX_RE, _fandom_suffix_prefixes, and the prefix-suppression +branch in _wd14_suggestions existed to work around the string-embedded +(Fandom) kludge. The bare-name refactor makes them all dead code. +_canonicalize_wd14_name simplifies to a single normalize-or-underscore +swap since no prefix preservation is needed. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 18: Delete the `sync_character_fandoms` Celery task and its settings button + +**Why:** the task was a periodic cleanup for drift between characters and their fandoms. Post-refactor, `set_tag_fandom` + `add_tag` both auto-apply the fandom tag inline, and the migration's report surfaces any remaining inconsistencies. Delete the task, its route, and the settings UI that triggered it. + +**Files:** +- Modify: `app/tasks/maintenance.py` (empty the body, keep the file for git history) +- Modify: `app/celery_app.py` (remove the task routing entry) +- Modify: `app/main.py` (delete `trigger_sync_character_fandoms` route at line 625-629) +- Modify: `app/templates/settings.html` (remove the maintenance button/form at line 464) + +- [ ] **Step 1: Empty `app/tasks/maintenance.py`** + +Replace the file contents with a stub: + +```python +"""Maintenance-queue Celery tasks. Currently empty — the +sync_character_fandoms task was removed on 2026-04-21 as part of the +bare-name refactor (tag.fandom_id is now authoritative; no drift to sync). +""" +``` + +- [ ] **Step 2: Remove the task routing entry** + +```bash +grep -n "sync_character_fandoms" app/celery_app.py +``` + +Find and delete the line `'app.tasks.maintenance.sync_character_fandoms': {'queue': 'maintenance'},` at line 74. + +- [ ] **Step 3: Delete the `trigger_sync_character_fandoms` route** + +In `app/main.py`, delete the block at around line 625-629: + +```python +@main.post('/settings/maintenance/sync-character-fandoms') +def trigger_sync_character_fandoms(): + from app.tasks.maintenance import sync_character_fandoms + sync_character_fandoms.apply_async(queue='maintenance') + return redirect(url_for('main.settings', tab='maintenance')) +``` + +- [ ] **Step 4: Remove the settings button** + +```bash +grep -n "sync-character-fandoms\|sync_character_fandoms" app/templates/settings.html +``` + +Delete the `
` block (around line 464) that posts to `trigger_sync_character_fandoms`. Keep any surrounding `

` or section wrapper only if it has other maintenance actions; otherwise remove the whole section. + +- [ ] **Step 5: Verify the app still boots** + +```bash +docker compose restart web celery-worker-maintenance +docker compose logs web --tail 30 +docker compose logs celery-worker-maintenance --tail 30 +``` + +Expected: no ImportError, no NameError. Visit `http://localhost:5000/settings` — the page loads and the maintenance tab doesn't crash. + +- [ ] **Step 6: Commit** + +```bash +git add app/tasks/maintenance.py app/celery_app.py app/main.py app/templates/settings.html +git commit -m "chore: remove sync_character_fandoms maintenance task + +tag.fandom_id is now authoritative. set_tag_fandom auto-applies the +fandom tag inline; add_tag already did. There's no drift to sync on +a schedule anymore. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 19: Template sweep — `_gallery_item.html` emoji dict + display_name + +**Why:** the six-branch prefix-strip expression is a DRY violation that duplicates `tag.name.split(':', 1)[1]` six times. Replace with a single KIND_EMOJI dict + `t.display_name`. + +**Files:** +- Modify: `app/templates/_gallery_item.html` + +- [ ] **Step 1: Read the current template** + +```bash +cat app/templates/_gallery_item.html +``` + +- [ ] **Step 2: Replace the tag rendering loop** + +Find the block containing the six emoji/split branches (lines 16-32 ish). Replace the loop body with: + +```jinja +{% set KIND_EMOJI = { + 'artist': '🎨', + 'character': '👤', + 'series': '📺', + 'fandom': '🎭', + 'meta': '⚠️', + 'post': '📌', + 'archive': '📦', + 'user': '🏷️', +} %} +{% for t in image.tags %} + + {{ KIND_EMOJI.get(t.kind, '') }} {{ t.display_name }} + +{% endfor %} +``` + +(Preserve any wrapping `
` or other class names that were already present — you're only swapping the inner loop body.) + +- [ ] **Step 3: Verify gallery thumbnails render correctly** + +```bash +# In browser +open http://localhost:5000/ +``` + +Expected: thumbnails show tag chips with the right emoji per kind and the right display text. Character tags that have a fandom show as "Ruby Rose (RWBY)"; others bare. + +- [ ] **Step 4: Verify anchor links use tag_id** + +View page source, search for `href="/?tag_id=`. Expected: all tag chip anchors use that form, not `?tag=`. + +- [ ] **Step 5: Commit** + +```bash +git add app/templates/_gallery_item.html +git commit -m "refactor(templates): _gallery_item.html KIND_EMOJI dict + display_name + +Collapses the six-branch prefix-strip expression into a KIND_EMOJI +jinja dict. Anchor hrefs use ?tag_id=. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 20: Template sweep — `_tag_cards.html`, `reader.html` + +**Why:** remaining templates that render tag names in anchors or labels. + +**Files:** +- Modify: `app/templates/_tag_cards.html` +- Modify: `app/templates/reader.html` + +- [ ] **Step 1: Read and update `_tag_cards.html`** + +```bash +cat app/templates/_tag_cards.html +``` + +Find any `{{ tag.name }}` rendering for user display — replace with `{{ tag.display_name }}`. Find `url_for('main.gallery', tag=tag.tag)` → change to `url_for('main.gallery', tag_id=tag.id)`. + +Since `_tag_cards.html` consumes `tag_data` dicts from `_get_tags_with_previews`, also ensure those dicts include `display_name` and `id`. Verify by checking `app/main.py:488-494`: + +```python +tag_map[t.id] = { + "id": t.id, + "name": label, # from t.display_name now (Task 13) + "tag": t.name, # keep as the bare storage name + "images": [], + "kind": t.kind, + "count": count +} +``` + +If `label` is already set to `t.display_name` from Task 13, then `tag_data[i]['name']` in `_tag_cards.html` is already the display form. The template change is: + +```jinja + +``` + +(instead of `tag=tag.tag`). + +- [ ] **Step 2: Read and update `reader.html`** + +```bash +grep -n "url_for.*main\.gallery\|tag\.name\|t\.name" app/templates/reader.html +``` + +Replace each: + +- `url_for('main.gallery', tag=tag.name)` → `url_for('main.gallery', tag_id=tag.id)` +- `{{ tag.name }}` rendering for user display → `{{ tag.display_name }}` + +- [ ] **Step 3: Verify reader page still loads and links work** + +```bash +# Pick a series tag +docker compose exec -T db psql -U imagerepo imagerepo -c " + SELECT id, name FROM tag WHERE kind = 'series' LIMIT 1; +" +SERIES_ID= + +curl -s -o /dev/null -w "%{http_code}" "http://localhost:5000/read/$SERIES_ID" +``` + +Expected: `200`. + +- [ ] **Step 4: Commit** + +```bash +git add app/templates/_tag_cards.html app/templates/reader.html +git commit -m "refactor(templates): _tag_cards.html and reader.html use display_name + tag_id + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 21: Template sweep — `tags_list.html` (+ fandom-less character nudges) + +**Why:** combines two things: the name-display sweep + the new `kind='character' AND fandom_id IS NULL` nudges (chip + header counter). + +**Files:** +- Modify: `app/main.py` (`tag_list` and `api_tags_list` routes — compute `null_fandom_character_count`) +- Modify: `app/templates/tags_list.html` + +- [ ] **Step 1: Update `_get_tags_with_previews` to include `fandom_id` in the returned dict** + +```bash +sed -n '483,495p' app/main.py +``` + +In the `tag_map` dict (line ~488-495), add `fandom_id`: + +```python +tag_map[t.id] = { + "id": t.id, + "name": t.display_name, + "tag": t.name, + "images": [], + "kind": t.kind, + "fandom_id": t.fandom_id, + "count": count +} +``` + +- [ ] **Step 2: Compute the fandom-less character counter in `tag_list`** + +Find `def tag_list():` (around line 533). Before `return render_template(...)`, compute: + +```python +null_fandom_character_count = ( + db.session.query(func.count(Tag.id)) + .filter(Tag.kind == 'character', Tag.fandom_id.is_(None)) + .scalar() or 0 +) +``` + +Pass it to the template context: + +```python +return render_template('tags_list.html', + tag_data=tag_data, + images_per_tag=images_per_tag, + active_kind=kind, + search_query=search_query, + total_count=total_count, + has_more=has_more, + page_size=limit, + null_fandom_character_count=null_fandom_character_count, + show_null_fandom_filter=request.args.get('null_fandom') == '1') +``` + +Also accept a new `?null_fandom=1` filter at the top of `tag_list`: + +```python +null_fandom_only = request.args.get('null_fandom') == '1' +``` + +And extend `_get_tags_with_previews` to accept a `null_fandom_only: bool = False` param that adds `.filter(Tag.kind == 'character', Tag.fandom_id.is_(None))` when True. + +Update `_get_tags_with_previews` signature: + +```python +def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3, search=None, null_fandom_only=False): + ... + count_q = db.session.query(func.count(Tag.id)) + if kind: + count_q = count_q.filter(Tag.kind == kind) + if search: + count_q = count_q.filter(Tag.name.ilike(f'%{search}%')) + if null_fandom_only: + count_q = count_q.filter(Tag.kind == 'character', Tag.fandom_id.is_(None)) + total_count = count_q.scalar() + ... + q = db.session.query(Tag, func.count(image_tags.c.image_id).label('img_count')).outerjoin(image_tags).group_by(Tag.id) + if kind: + q = q.filter(Tag.kind == kind) + if search: + q = q.filter(Tag.name.ilike(f'%{search}%')) + if null_fandom_only: + q = q.filter(Tag.kind == 'character', Tag.fandom_id.is_(None)) + ... +``` + +Pass the `null_fandom_only` flag from `tag_list` and `api_tags_list` through to the helper. + +- [ ] **Step 3: Update `tags_list.html` — header counter + chip** + +Open `app/templates/tags_list.html`. Near the top of the page (after the kind-filter tabs), add a header link when the counter is > 0 and the filter isn't already active: + +```jinja +{% if null_fandom_character_count > 0 and not show_null_fandom_filter %} + + ⚠ {{ null_fandom_character_count }} character{{ 's' if null_fandom_character_count != 1 }} need a fandom + +{% endif %} + +{% if show_null_fandom_filter %} +
+ Showing characters without a fandom. + Clear filter +
+{% endif %} +``` + +And in the tag card rendering loop, add a chip for character tags with null fandom_id (only if not already in the `null_fandom_only` mode — chip is redundant there): + +```jinja +{% if tag.kind == 'character' and tag.fandom_id is none and not show_null_fandom_filter %} + + ⚠ No fandom + +{% endif %} +``` + +(Place this inside the existing tag card markup, next to the tag name.) + +- [ ] **Step 4: Add CSS for the new elements** + +Append to `app/static/style.css`: + +```css +.null-fandom-counter-link { + display: inline-block; + padding: 0.35rem 0.75rem; + margin: 0.5rem 0; + background: rgba(255, 180, 0, 0.12); + border: 1px solid rgba(255, 180, 0, 0.3); + border-radius: 0.25rem; + color: #ffb400; + text-decoration: none; + font-size: 0.9rem; +} +.null-fandom-counter-link:hover { + background: rgba(255, 180, 0, 0.2); +} + +.null-fandom-filter-active { + padding: 0.5rem 0.75rem; + margin: 0.5rem 0; + background: rgba(255, 180, 0, 0.08); + border-left: 3px solid #ffb400; + font-size: 0.9rem; +} + +.character-no-fandom-chip { + display: inline-block; + padding: 0.1rem 0.4rem; + margin-left: 0.5rem; + font-size: 0.75rem; + background: rgba(255, 180, 0, 0.15); + color: #ffb400; + border-radius: 0.2rem; + cursor: help; +} +``` + +- [ ] **Step 5: Verify** + +```bash +curl -s "http://localhost:5000/tags?kind=character" | grep -E "No fandom|need a fandom" | head -5 +``` + +If there are any characters without fandoms in the DB, expected: one or more matches. Then visit the page in a browser to confirm chip placement and styling. + +- [ ] **Step 6: Commit** + +```bash +git add app/main.py app/templates/tags_list.html app/static/style.css +git commit -m "feat(tags): fandom-less character nudges + +Adds a header counter (⚠ N characters need a fandom) that filters to +?null_fandom=1, plus an inline chip on each qualifying character card. +Backend accepts null_fandom_only in the list query. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 22: Fandom picker — modal HTML + CSS + +**Why:** the inline reveal area below the modal's add-tag input, for assigning a fandom during character creation. + +**Files:** +- Modify: `app/templates/_gallery_modal.html` +- Modify: `app/static/style.css` + +- [ ] **Step 1: Add the HTML block to `_gallery_modal.html`** + +Open `app/templates/_gallery_modal.html` and locate the `` block inside `.modal-tags-section`. Directly after the closing `` and before `
`, insert: + +```html + +``` + +- [ ] **Step 2: Append CSS to `app/static/style.css`** + +```css +.fandom-picker-wrapper { + max-height: 0; + overflow: hidden; + opacity: 0; + transition: max-height 180ms ease-out, opacity 180ms ease-out; + margin-top: 0; +} +.fandom-picker-wrapper.visible { + max-height: 16rem; + opacity: 1; + margin-top: 0.5rem; +} +.fandom-picker-label { + display: block; + font-size: 0.85rem; + color: var(--muted, #888); + margin-bottom: 0.25rem; +} +.fandom-picker-input-row { + display: flex; + gap: 0.25rem; + align-items: center; +} +.fandom-picker-input { + flex: 1; + padding: 0.4rem 0.6rem; + background: var(--input-bg, #111); + border: 1px solid var(--input-border, #333); + border-radius: 0.25rem; + color: var(--fg, #eee); +} +``` + +- [ ] **Step 3: Visual smoke-test in the browser** + +```bash +open http://localhost:5000/ +``` + +Open any image in the modal. At this stage, the picker wrapper is present in the DOM but never becomes visible (no JS yet). Inspect with devtools: + +```js +// In browser console +document.getElementById('fandomPickerWrapper') +``` + +Expected: the element exists and has `aria-hidden="true"`. No visual effect yet. + +- [ ] **Step 4: Commit** + +```bash +git add app/templates/_gallery_modal.html app/static/style.css +git commit -m "feat(modal): fandom picker markup + styles + +HTML/CSS for the inline fandom picker that slides in below the add-tag +input. JS wiring in the next commit. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 23: Fandom picker — JavaScript wiring + +**Why:** make the picker reveal, autocomplete, and submit work. + +**Files:** +- Modify: `app/static/js/view-modal.js` + +- [ ] **Step 1: Locate the modal init block** + +```bash +grep -n "tagInput\|tagForm\|modalTagForm" app/static/js/view-modal.js | head -20 +``` + +Find where `tagInput` is acquired from the DOM (via `document.getElementById('tagInput')` or similar). + +- [ ] **Step 2: Add element references and state near the top of the module** + +Near the other `document.getElementById(...)` calls at the top of `view-modal.js`, add: + +```js +const fandomPickerWrapper = document.getElementById('fandomPickerWrapper'); +const fandomPickerInput = document.getElementById('fandomPickerInput'); +const fandomPickerAutocomplete = document.getElementById('fandomPickerAutocomplete'); +const fandomPickerClear = document.getElementById('fandomPickerClear'); +``` + +- [ ] **Step 3: Wire up the reveal + clear handlers** + +Inside the modal init function (wherever `tagInput` gets its other listeners wired), add: + +```js +function clearFandomPicker() { + if (!fandomPickerInput) return; + fandomPickerInput.value = ''; + delete fandomPickerInput.dataset.fandomId; + clearFandomAutocomplete(); +} + +function clearFandomAutocomplete() { + if (fandomPickerAutocomplete) fandomPickerAutocomplete.innerHTML = ''; +} + +function renderFandomAutocomplete(rows) { + if (!fandomPickerAutocomplete) return; + if (!rows || rows.length === 0) { + fandomPickerAutocomplete.innerHTML = ''; + return; + } + fandomPickerAutocomplete.innerHTML = rows.map(r => ` +
+ ${r.display_name} +
+ `).join(''); +} + +tagInput.addEventListener('input', () => { + const lower = (tagInput.value || '').toLowerCase(); + const isCharacter = lower.startsWith('character:'); + if (fandomPickerWrapper) { + fandomPickerWrapper.classList.toggle('visible', isCharacter); + fandomPickerWrapper.setAttribute('aria-hidden', String(!isCharacter)); + } + if (!isCharacter) clearFandomPicker(); +}); + +if (fandomPickerInput) { + let fandomDebounce = null; + fandomPickerInput.addEventListener('input', () => { + clearTimeout(fandomDebounce); + const term = fandomPickerInput.value.trim(); + delete fandomPickerInput.dataset.fandomId; // any typing invalidates the stashed id + if (!term) { + clearFandomAutocomplete(); + return; + } + fandomDebounce = setTimeout(() => { + fetch(`/api/tags/search?kind=fandom&q=${encodeURIComponent(term)}&limit=8`) + .then(r => r.json()) + .then(body => renderFandomAutocomplete(body.tags || [])); + }, 150); + }); +} + +if (fandomPickerAutocomplete) { + fandomPickerAutocomplete.addEventListener('click', e => { + const row = e.target.closest('[data-fandom-id]'); + if (!row) return; + fandomPickerInput.value = row.dataset.fandomName; + fandomPickerInput.dataset.fandomId = row.dataset.fandomId; + clearFandomAutocomplete(); + }); +} + +if (fandomPickerClear) { + fandomPickerClear.addEventListener('click', clearFandomPicker); +} +``` + +- [ ] **Step 4: Update the tag-submit handler to pass fandom fields** + +Find the existing submit handler for `modalTagForm`. It currently posts `FormData` with just `name=...`. Extend it to include `fandom_id` and `fandom_name` when the picker has been used: + +```js +// Inside the existing submit handler (pseudocode — adapt to the real shape): +const formData = new FormData(); +formData.append('name', tagInput.value.trim()); +if (fandomPickerInput && fandomPickerInput.dataset.fandomId) { + formData.append('fandom_id', fandomPickerInput.dataset.fandomId); +} else if (fandomPickerInput && fandomPickerInput.value.trim()) { + formData.append('fandom_name', fandomPickerInput.value.trim()); +} + +fetch(`/image/${currentImageId}/tags/add`, { + method: 'POST', + body: formData, +}).then(r => r.json()).then(body => { + if (!body.ok) { + showError(body.error || 'Tag add failed'); + return; + } + tagInput.value = ''; + clearFandomPicker(); + fandomPickerWrapper?.classList.remove('visible'); + // re-render tag pills using body.tag.display_name + ... +}); +``` + +(Locate the real submit handler in the file and adapt this pattern to its shape.) + +- [ ] **Step 5: Hook clearFandomPicker into modal close/image-switch** + +Find the existing `updateImage(imageId)` function in `view-modal.js`. At the top of it, call `clearFandomPicker()`. + +Find the modal close handler (probably tied to `modalClose` or ESC). Also call `clearFandomPicker()`. + +- [ ] **Step 6: Visual smoke-test** + +```bash +open http://localhost:5000/ +``` + +Open any image in the modal. In the tag input, type `character:` — the fandom picker area should slide in smoothly below. Type a few letters in the fandom input — autocomplete rows should appear. Click one — input fills, autocomplete clears. Click `×` — picker clears. Clear the tag input or close the modal — picker collapses. + +- [ ] **Step 7: Full submit test** + +In the modal, type `character:Test Char A` in tag input. Type `Test Fandom A` in fandom picker (don't click autocomplete — let it be free-form). Submit. + +Check DB: + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c " + SELECT t.id, t.name, t.kind, t.fandom_id, f.name AS fandom_name + FROM tag t LEFT JOIN tag f ON t.fandom_id = f.id + WHERE t.name = 'Test Char A'; +" +``` + +Expected: one row. `kind='character'`, `name='Test Char A'`, `fandom_name='Test Fandom A'`. + +Clean up: + +```bash +docker compose exec -T db psql -U imagerepo imagerepo -c " + DELETE FROM image_tags WHERE tag_id IN (SELECT id FROM tag WHERE name IN ('Test Char A', 'Test Fandom A')); + DELETE FROM tag WHERE name IN ('Test Char A', 'Test Fandom A'); +" +``` + +- [ ] **Step 8: Commit** + +```bash +git add app/static/js/view-modal.js +git commit -m "feat(modal): fandom picker JS — reveal, autocomplete, submit + +Picker reveals on 'character:' prefix detection, runs its own autocomplete +against /api/tags/search?kind=fandom, stashes fandom_id when a row is +clicked, submits alongside the main add-tag request. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 24: Ambiguous-candidate picker UI in the modal + +**Why:** when `accept_image_suggestion` returns HTTP 409 with a `candidates` array (same-name characters across fandoms), the frontend needs to let the user pick which one to attach. + +**Files:** +- Modify: `app/static/js/view-modal.js` (accept-suggestion handler) +- Modify: `app/static/style.css` + +- [ ] **Step 1: Find the existing accept handler** + +```bash +grep -n "suggestions/accept\|acceptSuggestion" app/static/js/view-modal.js +``` + +Locate the function that handles clicking an "Accept" button on a suggestion row. + +- [ ] **Step 2: Add 409 handling to the accept handler** + +Update the accept call to handle the 409 branch: + +```js +async function acceptSuggestion(suggestionEl) { + const name = suggestionEl.dataset.name; + const category = suggestionEl.dataset.category; + const res = await fetch(`/image/${currentImageId}/suggestions/accept`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({name, category}), + }); + + if (res.status === 409) { + const body = await res.json(); + renderAmbiguousPicker(suggestionEl, body.candidates, name, category); + return; + } + if (!res.ok) { + showError('Accept failed'); + return; + } + const body = await res.json(); + // normal path: remove suggestion row, add tag pill via body.tag.display_name + suggestionEl.remove(); + renderTagPill(body.tag); +} + +function renderAmbiguousPicker(suggestionEl, candidates, name, category) { + const picker = document.createElement('div'); + picker.className = 'suggestion-ambiguous-picker'; + picker.innerHTML = ` +
Which one?
+ ${candidates.map(c => ` + + `).join('')} + + `; + picker.querySelectorAll('.ambiguous-candidate').forEach(btn => { + btn.addEventListener('click', async () => { + const res = await fetch(`/image/${currentImageId}/suggestions/accept`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({name, category, tag_id: parseInt(btn.dataset.tagId, 10)}), + }); + const body = await res.json(); + if (body.ok) { + suggestionEl.remove(); + renderTagPill(body.tag); + } else { + showError(body.error || 'Accept failed'); + } + }); + }); + picker.querySelector('.ambiguous-cancel').addEventListener('click', () => { + picker.remove(); + // restore the original accept button visibility if needed + }); + suggestionEl.appendChild(picker); +} +``` + +(Adapt `currentImageId`, `showError`, `renderTagPill` to their real names in the file.) + +- [ ] **Step 3: Add CSS for the picker** + +Append to `app/static/style.css`: + +```css +.suggestion-ambiguous-picker { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + padding: 0.5rem; + margin-top: 0.3rem; + background: rgba(255, 180, 0, 0.06); + border: 1px solid rgba(255, 180, 0, 0.25); + border-radius: 0.25rem; + font-size: 0.85rem; +} +.suggestion-ambiguous-picker .ambiguous-label { + width: 100%; + font-weight: 600; + color: #ffb400; + margin-bottom: 0.2rem; +} +.suggestion-ambiguous-picker .ambiguous-candidate { + padding: 0.25rem 0.5rem; + background: var(--button-bg, #222); + border: 1px solid var(--button-border, #444); + border-radius: 0.25rem; + color: var(--fg, #eee); + cursor: pointer; + font-size: 0.85rem; +} +.suggestion-ambiguous-picker .ambiguous-candidate:hover { + background: var(--button-hover-bg, #333); +} +.suggestion-ambiguous-picker .ambiguous-cancel { + padding: 0.25rem 0.5rem; + background: transparent; + border: none; + color: var(--muted, #888); + cursor: pointer; + margin-left: auto; +} +``` + +- [ ] **Step 4: Smoke-test the flow** + +Recreate the ambiguous scenario from Task 8 Step 4 (two same-name characters). Open an image in the modal, find a suggestion matching that name (or fake one by running the suggestion ingest for a seed image), click Accept. Expected: the ambiguous picker appears inline with two candidate buttons. + +Click a candidate. Expected: the picker disappears, the tag pill appears in the sidebar with the correct fandom-qualified `display_name`. + +Clean up test data as before. + +- [ ] **Step 5: Commit** + +```bash +git add app/static/js/view-modal.js app/static/style.css +git commit -m "feat(modal): ambiguous-candidate picker for 409 accept responses + +When two same-name characters exist across different fandoms, the accept +handler now renders an inline picker with each candidate's display_name. +User clicks one, which re-POSTs with explicit tag_id. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 25: Remaining JS cleanup and display_name plumbing + +**Why:** tag pills, autocomplete rows in bulk-select and tag-editor, and the `view-modal.js:388` split-colon branch all need to use `display_name` from the server-rendered JSON instead of splitting strings client-side. + +**Files:** +- Modify: `app/static/js/view-modal.js` (line ~388 split expression) +- Modify: `app/static/js/bulk-select.js` +- Modify: `app/static/js/tag-editor.js` + +- [ ] **Step 1: Remove the name.split branch at view-modal.js:388** + +```bash +grep -n "tag\.name\.split\|tag\.name\.includes" app/static/js/view-modal.js +``` + +Find the line (reported earlier at `app/static/js/view-modal.js:388`): + +```js +opt.textContent = tag.name.includes(':') ? tag.name.split(':', 2)[1] : tag.name; +``` + +Replace with: + +```js +opt.textContent = tag.display_name; +``` + +Verify the server sends `display_name` in the API response consumed here. Trace back: this block iterates over tags fetched from some endpoint; if the endpoint is `/api/tags/search` or `/image//tags`, both now include `display_name` (from Tasks 9 and 12). + +- [ ] **Step 2: Update bulk-select.js** + +```bash +grep -n "\.name\b\|tag\.name" app/static/js/bulk-select.js | head -20 +``` + +For every place that displays a tag's `.name` to the user, switch to `.display_name`. Keep `.name` only when constructing a form submit value (where the bare name is what the server stores). + +- [ ] **Step 3: Update tag-editor.js** + +Same treatment: + +```bash +grep -n "\.name\b" app/static/js/tag-editor.js | head -20 +``` + +For user-facing rendering, swap to `.display_name`. + +- [ ] **Step 4: Smoke-test bulk-select and tag-editor** + +Open gallery, enter bulk-select mode, open the bulk-add-tag dropdown. Autocomplete rows should show display form ("Ruby Rose (RWBY)") not bare. + +Open `/tags/` for any character. The fandom picker autocomplete should show fandom `display_name`s. + +- [ ] **Step 5: Commit** + +```bash +git add app/static/js/view-modal.js app/static/js/bulk-select.js app/static/js/tag-editor.js +git commit -m "refactor(js): use display_name from API instead of client-side split + +Every user-facing tag rendering now reads tag.display_name from the +server JSON. The client-side split-on-colon fallback is removed. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Task 26: Dead-code sweep — final verification + +**Why:** last line of defense. Grep for any remaining prefix-construction, prefix-parse, or old-format URL builder. Anything found is a bug from an earlier task that slipped through. + +**Files:** +- Read-only verification across the whole project. + +- [ ] **Step 1: Prefix construction sites should all be gone from production code** + +```bash +grep -n 'f"character:\|f"fandom:\|f"artist:\|f"series:\|f"post:\|f"archive:\|f"meta:' \ + app/ --include='*.py' -r +``` + +Expected: zero matches. Any match in `app/` is a bug. + +Matches inside `docs/` or `migrations/versions/` (historical migrations) are expected and fine. + +- [ ] **Step 2: No standalone prefix-strip expressions** + +```bash +grep -rn "\.name\.split(['\"]:['\"]" app/ --include='*.py' --include='*.html' --include='*.js' +``` + +Expected matches only inside `app/utils/tag_prefix.py` (the parser). Any other match is a bug. + +- [ ] **Step 3: No `url_for('main.gallery', tag=...)` left** + +```bash +grep -rn "url_for.*main\.gallery.*tag=" app/templates/ +``` + +Expected: zero matches. All anchor/link builders should use `tag_id=`. + +- [ ] **Step 4: `_parse_character_fandom` is gone** + +```bash +grep -rn "_parse_character_fandom\|parse_character_fandom" app/ +``` + +Expected: zero matches. + +- [ ] **Step 5: `sync_character_fandoms` references are gone from production code** + +```bash +grep -rn "sync_character_fandoms\|sync-character-fandoms" app/ +``` + +Expected: zero matches in `app/`. (Historical references in `docs/` are fine.) + +- [ ] **Step 6: `_FANDOM_SUFFIX_RE` is gone from production code** + +```bash +grep -rn "_FANDOM_SUFFIX_RE\|_fandom_suffix_prefixes" app/ +``` + +Expected: zero matches. + +- [ ] **Step 7: Run a full-site smoke test** + +Visit each of these pages and confirm each loads, renders correctly, and has no console errors: + +``` +http://localhost:5000/ # showcase +http://localhost:5000/gallery # gallery (no filter) +http://localhost:5000/?tag_id= # gallery with filter +http://localhost:5000/tags # all tags +http://localhost:5000/tags?kind=character # character tags +http://localhost:5000/tags?kind=character&null_fandom=1 # null-fandom filter +http://localhost:5000/tags?kind=artist # artist tags +http://localhost:5000/tags?kind=fandom # fandom tags +http://localhost:5000/tag/ # tag detail (set fandom) +http://localhost:5000/read/ # reader +http://localhost:5000/settings # settings (sync-character-fandoms button gone) +``` + +Open the modal on an image that has character tags. Verify: +- Tag pills show `display_name` (with fandom suffix when applicable). +- Typing `character:NewName` reveals the fandom picker. +- Autocomplete in both main input and fandom picker works. +- Clicking a suggestion works; if ambiguous, the candidate picker appears. +- Provenance / suggestions / series sections render without errors. + +- [ ] **Step 8: Final commit (if any small fixes emerged)** + +If any issues were found and fixed in Step 7, commit them: + +```bash +git add +git commit -m "chore: final fixes from post-refactor smoke test + +Co-Authored-By: Claude Opus 4.7 " +``` + +Otherwise, no commit — this task is pure verification. + +--- + +## Self-Review Checklist (Plan Author) + +**Spec coverage:** Every section of the spec is covered. + +- Data model → Task 3 (migration creates indices), Task 4 (model gets `__table_args__`) +- Migration (phases 1-3) → Task 3 +- Display rendering (`@property`) → Task 1 +- Display rendering (templates + N+1 joinedload) → Tasks 19, 20, 21, 12, 13 +- URL routing (tag_id) → Tasks 10, 19, 20 +- Search/autocomplete (JOIN + CONCAT) → Task 9 +- `parse_kind_prefix` helper → Task 2 +- `_ensure_fandom_tag` simplified → Task 5 +- `add_tag` refactor → Task 6 +- `bulk_add_tag` refactor → Task 7 +- `accept_image_suggestion` 0/1/N resolution → Task 8 +- Ambiguous-candidate picker UI → Task 24 +- Fandom picker (HTML/CSS) → Task 22 +- Fandom picker (JS) → Task 23 +- Fandom-less character nudges → Task 21 +- `set_tag_fandom` simplified → Task 11 +- Code removals (`_parse_character_fandom`, `_FANDOM_SUFFIX_RE`, etc.) → Tasks 12, 17, 18, 26 +- Archive/post/artist construction sweep → Tasks 14, 15, 16 +- `sync_character_fandoms` deletion → Task 18 +- Misc JS plumbing (`display_name` everywhere) → Task 25 + +**Placeholder scan:** grep through the plan for TBD/TODO/fill-in patterns. + +```bash +grep -n "TBD\|TODO\|implement later\|fill in\|appropriate error handling\|Similar to Task" \ + docs/superpowers/plans/2026-04-21-character-fandom-association-refactor.md +``` + +If any matches remain, fix them before handoff. + +**Type consistency:** + +- `parse_kind_prefix` is imported as `from app.utils.tag_prefix import parse_kind_prefix` in Tasks 6 and 7 — matches the function defined in Task 2. +- `KNOWN_KINDS` is the constant in Task 2 and referenced by documentation in Tasks 2 and 18 — matches. +- `display_name` property is added in Task 1 and used in Tasks 6, 7, 8, 9, 11, 12, 19, 20, 21 — matches. +- `_ensure_fandom_tag(name)` takes a bare string after Task 5; all subsequent callers pass bare names — matches. +- `tag_id` as query param throughout — matches. +- Table index names (`tag_character_with_fandom_uniq`, `tag_character_null_fandom_uniq`, `tag_other_kinds_uniq`, `tag_kind_idx`) referenced identically in migration (Task 3) and model (Task 4) — matches. +- `null_fandom_only` flag in `_get_tags_with_previews` referenced consistently in Task 21 — matches.