# 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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 postgres 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.