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

113 KiB
Raw Blame History

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):

# 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.nametag.display_name; tag=tag.tagtag_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.nametag.display_name; tag=tag.nametag_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:

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
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)
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)
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:

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
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
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

"""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
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
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

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:

"""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
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
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
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
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)
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

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:

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:

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
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:

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
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):

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:

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
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
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:

@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
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:

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
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
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

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:

@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
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):

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:

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
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

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:

@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
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:

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:

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:

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
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:

@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
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)
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
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
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>"

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

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:

    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
sed -n '260,290p' app/main.py

Find the other tag_name = request.args.get('tag') occurrence and apply the same refactor:

    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=...
# 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)
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
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

@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
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:

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:

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
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:

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:

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:

@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:

@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:

@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
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
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

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):

# 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):

# BEFORE
label = t.name.split(":", 1)[1] if (":" in t.name) else t.name
# AFTER
label = t.display_name

Line 2470 (reader series name):

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

sed -n '955,995p' app/utils/image_importer.py
  • Step 2: Update the archive tag construction

Around line 961, replace the tag build:

# 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:

# 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:

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
# 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:

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
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

sed -n '465,490p' app/utils/metadata_enrichment.py
  • Step 2: Strip the outer post: prefix

Replace:

"name": f"post:{platform}:{safe_artist}:{post_id}",

with:

"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
grep -n "post:" app/utils/metadata_enrichment.py

Expected: only references inside docstrings/comments. Any remaining f"post:..." is a bug.

  • Step 4: Commit
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

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):

# 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):

# 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)
# 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
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:

# 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:

# 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:

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
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
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
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:

"""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
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:

@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
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
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
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>"

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

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:

{% 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
# 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
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

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:

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:

<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
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

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

sed -n '483,495p' app/main.py

In the tag_map dict (line ~488-495), add fandom_id:

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:

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:

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:

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:

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:

{% 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):

{% 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:

.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
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
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:

<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
.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
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:

// In browser console
document.getElementById('fandomPickerWrapper')

Expected: the element exists and has aria-hidden="true". No visual effect yet.

  • Step 4: Commit
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

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:

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:

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:

// 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
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:

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:

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
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

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:

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:

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

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):

opt.textContent = tag.name.includes(':') ? tag.name.split(':', 2)[1] : tag.name;

Replace with:

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
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:

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_names.

  • Step 5: Commit
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

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
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
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
grep -rn "_parse_character_fandom\|parse_character_fandom" app/

Expected: zero matches.

  • Step 5: sync_character_fandoms references are gone from production code
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
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:

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.

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.