This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
imagerepo/docs/superpowers/plans/2026-04-21-character-fandom-association-refactor.md
T
bvandeusen 0669098e80 docs: fix docker service name in refactor plan (db → postgres)
Plan used 'db' service name but docker-compose.yml uses 'postgres'.
Correcting pre-flight to avoid tripping every implementer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 09:15:01 -04:00

3315 lines
113 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CharacterFandom 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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/<int:image_id>/tags/add")` around line 862) and replace the whole function with:
```python
@main.post("/image/<int:image_id>/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=<paste_the_id>
# 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 <noreply@anthropic.com>"
```
---
## 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/<id>/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=<first_id>
IMG2=<second_id>
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 <noreply@anthropic.com>"
```
---
## 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/<int:image_id>/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=<paste_id>
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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## Task 10: Gallery routes — switch from `?tag=<name>` to `?tag_id=<int>`
**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=<paste_id>
curl -s -o /dev/null -w "%{http_code}" "http://localhost:5000/?tag_id=$TAG_ID"
```
Expected: `200`.
- [ ] **Step 5: Verify old `?tag=<name>` 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=<name> 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 <noreply@anthropic.com>"
```
---
## 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/<int:tag_id>/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=<paste_character_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 <noreply@anthropic.com>"
```
---
## 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/<int:tag_id>")
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/<int:image_id>/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/<int:tag_id>/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/<id>/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=<paste_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 <noreply@anthropic.com>"
```
---
## 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=<any_character_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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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 <noreply@anthropic.com>"
```
---
## 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=<paste_image_id>
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 <noreply@anthropic.com>"
```
---
## 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 `<form>` block (around line 464) that posts to `trigger_sync_character_fandoms`. Keep any surrounding `<h3>` 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 <noreply@anthropic.com>"
```
---
## 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 %}
<a href="{{ url_for('main.gallery', tag_id=t.id) }}"
class="tag-chip tag-chip-{{ t.kind or 'user' }}">
{{ KIND_EMOJI.get(t.kind, '') }} {{ t.display_name }}
</a>
{% endfor %}
```
(Preserve any wrapping `<div>` 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 <noreply@anthropic.com>"
```
---
## 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
<a href="{{ url_for('main.gallery', tag_id=tag.id) }}" class="tag-card-link">
```
(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=<paste_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 <noreply@anthropic.com>"
```
---
## 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 %}
<a href="{{ url_for('main.tag_list', kind='character', null_fandom=1) }}"
class="null-fandom-counter-link">
{{ null_fandom_character_count }} character{{ 's' if null_fandom_character_count != 1 }} need a fandom
</a>
{% endif %}
{% if show_null_fandom_filter %}
<div class="null-fandom-filter-active">
Showing characters without a fandom.
<a href="{{ url_for('main.tag_list', kind='character') }}">Clear filter</a>
</div>
{% 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 %}
<span class="character-no-fandom-chip" title="This character has no fandom — click to assign">
⚠ No fandom
</span>
{% 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 <noreply@anthropic.com>"
```
---
## 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 `<form id="modalTagForm">` block inside `.modal-tags-section`. Directly after the closing `</form>` and before `<div id="tagAutocomplete">`, insert:
```html
<div id="fandomPickerWrapper" class="fandom-picker-wrapper" aria-hidden="true">
<label class="fandom-picker-label" for="fandomPickerInput">Fandom</label>
<div class="fandom-picker-input-row">
<input type="text" id="fandomPickerInput" class="fandom-picker-input"
placeholder="Pick or create fandom (optional)" autocomplete="off">
<button type="button" id="fandomPickerClear" class="btn-small"
aria-label="Clear fandom" title="Clear fandom">×</button>
</div>
<div id="fandomPickerAutocomplete"
class="tag-autocomplete tag-autocomplete-inline" aria-live="polite"></div>
</div>
```
- [ ] **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 <noreply@anthropic.com>"
```
---
## 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 => `
<div class="tag-autocomplete-item" data-fandom-id="${r.id}" data-fandom-name="${r.name}">
${r.display_name}
</div>
`).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 <noreply@anthropic.com>"
```
---
## 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 = `
<div class="ambiguous-label">Which one?</div>
${candidates.map(c => `
<button type="button" class="ambiguous-candidate"
data-tag-id="${c.id}">
${c.display_name}
</button>
`).join('')}
<button type="button" class="ambiguous-cancel">Cancel</button>
`;
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 <noreply@anthropic.com>"
```
---
## 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/<id>/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/<id>` 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 <noreply@anthropic.com>"
```
---
## 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=<any_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/<any_character_id> # tag detail (set fandom)
http://localhost:5000/read/<any_series_id> # 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 <fixed_files>
git commit -m "chore: final fixes from post-refactor smoke test
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```
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.