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>
113 KiB
Character–Fandom Association Refactor Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Store tag kind in the kind column only (strip kind: prefix from name) and store character-fandom association via fandom_id only (strip (Fandom) suffix from name); update every reader/writer in the app accordingly.
Architecture: Single Alembic migration rewrites every tag row in two phases — prefix strip for all kinds, then fandom suffix extraction for characters. A new Tag.display_name @property becomes the single source of truth for rendering. A one-place _parse_kind_prefix helper accepts the user-facing character:Saber input shortcut. Gallery URLs become ?tag_id=N. The modal gets an inline fandom picker that slides in when character: is typed. WD14 accept path handles 0/1/N candidate resolution with an HTTP 409 disambiguation flow. A ~53-site mechanical sweep replaces every f-string prefix construction and name.split(':', 1) parse.
Tech Stack: Flask + SQLAlchemy + pgvector + Alembic migrations, Jinja2 templates, vanilla JS + CSS in app/static/, Celery for the maintenance task being removed, Postgres for partial unique indices.
Spec: docs/superpowers/specs/2026-04-21-character-fandom-association-design.md
Context for the Implementer
The codebase has no automated test suite. There is no tests/ directory, no conftest.py, no pytest fixtures. The existing convention (see migrations h26041901, i26041901 and every feature commit on the branch) is manual verification: run the server in docker compose, exercise the feature in the browser, poke at the database via docker compose exec web flask shell or docker compose exec postgres psql -U imagerepo imagerepo.
Do not invent a test suite. Each task below ships with explicit manual verification: a shell command, a SQL query, or a UI interaction with an expected result. These are the project's conventions — honor them.
TDD spirit, not TDD ceremony. Where a task is easy to smoke-test with a one-off Python script (docker compose exec web python -c '...'), do it. Where it needs eyes on a page, open the page. Each task's "Verify" step states exactly how.
Development loop (the user's normal flow):
# Rebuild and restart all containers from docker-compose.yml
docker compose up -d --build
# Tail logs for the web container while you exercise the app
docker compose logs -f web
# Alembic shell for migrations
docker compose exec web flask db upgrade # apply
docker compose exec web flask db downgrade # revert
docker compose exec web flask db history # list revisions
# Postgres shell (inside the db container)
docker compose exec postgres psql -U imagerepo imagerepo
Commit cadence: one commit per task. The app/main.py file is large; commit per logical route or helper change so git blame stays useful.
File Structure
Files created
| Path | Responsibility |
|---|---|
migrations/versions/j26042101_bare_tag_names_and_fandom_id_authoritative.py |
Three-phase migration: strip kind prefixes, extract character fandom suffix, swap indices. |
app/utils/tag_prefix.py |
KNOWN_KINDS constant + _parse_kind_prefix helper, imported by add_tag, bulk_add_tag, and the migration's sanity check. |
Files modified
| Path | What changes |
|---|---|
app/models.py |
Drop unique=True on Tag.name, add partial indices via __table_args__, add display_name @property. |
app/main.py |
_ensure_fandom_tag stores bare names; add_tag, bulk_add_tag, accept_image_suggestion, set_tag_fandom, update_tag, get_tag, list_tags, search_tags, tag_list, gallery routes refactored; _parse_character_fandom deleted; all name.split(':', 1) occurrences replaced by tag.name or tag.display_name; URL builders switch tag= to tag_id=; trigger_sync_character_fandoms route deleted. |
app/services/tag_suggestions.py |
_canonicalize_wd14_name simplified; _FANDOM_SUFFIX_RE, _fandom_suffix_prefixes, applied_fandom_prefixes branch deleted. |
app/utils/image_importer.py |
Archive tag construction switches from f"archive:{artist}:{name}" to Tag(kind='archive', name=f"{artist}:{name}"); prefix-ILIKE query becomes kind='archive' filter. |
app/utils/metadata_enrichment.py |
Post tag construction drops post: outer prefix. |
app/tasks/import_file.py |
Artist tag construction drops artist: prefix; archive tag construction same treatment. |
app/tasks/maintenance.py |
Delete sync_character_fandoms task body entirely; file becomes empty stub or is deleted (we keep the file with a comment for revision history). |
app/celery_app.py |
Remove the sync_character_fandoms task routing entry. |
app/templates/_gallery_item.html |
Six-branch prefix-strip expression collapses into KIND_EMOJI dict lookup + t.display_name. Anchor href switches to tag_id. |
app/templates/_gallery_modal.html |
Add the fandom-picker HTML block inside .modal-tags-section. |
app/templates/_tag_cards.html |
tag.name → tag.display_name; tag=tag.tag → tag_id=tag.id. |
app/templates/tags_list.html |
Add fandom-less character nudges (chip + assign button for kind='character' AND fandom_id IS NULL); add header counter. |
app/templates/reader.html |
tag.name → tag.display_name; tag=tag.name → tag_id=tag.id. |
app/templates/showcase.html |
No URL change (uses kind= filter, not tag=). Skip unless grep finds something. |
app/templates/layout.html |
No URL change. Skip unless grep finds something. |
app/templates/settings.html |
Remove the sync-character-fandoms maintenance button/form. |
app/static/js/view-modal.js |
Delete the tag.name.split(':', 2)[1] branch at line ~388 (use display_name from API); wire up fandom picker reveal + autocomplete + submit; implement ambiguous-accept 409 disambiguation UI. |
app/static/js/bulk-select.js |
Propagate display_name through its autocomplete rendering. |
app/static/js/tag-editor.js |
Same — use display_name where a tag is rendered to the user. |
app/static/style.css |
Add .fandom-picker-wrapper, .fandom-picker-input, .fandom-picker-label, .fandom-picker-input-row styles. Add .character-no-fandom-chip for tags-list nudge. Add .suggestion-ambiguous-picker for the 409 flow. |
Files deleted
None. app/tasks/maintenance.py stays (empty body, retains git history); the Celery task is just removed from its contents. sync-character-fandoms button HTML is removed from settings.html.
Task 0: Pre-flight baseline snapshot
Why this task exists: before touching anything, capture the current state of the tag table so we can verify post-migration that no data was lost. A migration that silently corrupts data is the one thing we cannot tolerate in this refactor.
Files:
-
No code changes. Pure verification + backup.
-
Step 1: Snapshot tag counts by kind
Run in shell:
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_nameproperty 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=Truefrom 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_tagfunction) -
Step 1: Update
_ensure_fandom_taginapp/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_tagaround line 862-931) -
Step 1: Replace the
add_tagfunction 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>"
Task 10: Gallery routes — switch from ?tag=<name> to ?tag_id=<int>
Why: URL routing is the last layer still keyed on the name string. Clean-break switch to tag_id removes every name-parsing branch from route handlers.
Files:
-
Modify:
app/main.pyaround 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_fandomwith 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_fandomat line 20-25 (delete)get_tagaround line 952-966list_tagsaround line 695-760update_tagaround line 1127-1234delete_tagaround 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 remainingname.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.pyaround 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.pyaround 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 anyf"{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_REand_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 reif 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(deletetrigger_sync_character_fandomsroute 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_fandomsroute
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>"
Task 19: Template sweep — _gallery_item.html emoji dict + display_name
Why: the six-branch prefix-strip expression is a DRY violation that duplicates tag.name.split(':', 1)[1] six times. Replace with a single KIND_EMOJI dict + t.display_name.
Files:
-
Modify:
app/templates/_gallery_item.html -
Step 1: Read the current template
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_listandapi_tags_listroutes — computenull_fandom_character_count) -
Modify:
app/templates/tags_list.html -
Step 1: Update
_get_tags_with_previewsto includefandom_idin 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_fandomis gone
grep -rn "_parse_character_fandom\|parse_character_fandom" app/
Expected: zero matches.
- Step 5:
sync_character_fandomsreferences 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_REis 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:NewNamereveals 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_prefixhelper → Task 2_ensure_fandom_tagsimplified → Task 5add_tagrefactor → Task 6bulk_add_tagrefactor → Task 7accept_image_suggestion0/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_fandomsimplified → 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_fandomsdeletion → Task 18- Misc JS plumbing (
display_nameeverywhere) → 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_prefixis imported asfrom app.utils.tag_prefix import parse_kind_prefixin Tasks 6 and 7 — matches the function defined in Task 2.KNOWN_KINDSis the constant in Task 2 and referenced by documentation in Tasks 2 and 18 — matches.display_nameproperty 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_idas 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_onlyflag in_get_tags_with_previewsreferenced consistently in Task 21 — matches.