0669098e80
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>
3315 lines
113 KiB
Markdown
3315 lines
113 KiB
Markdown
# Character–Fandom Association Refactor Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Store tag kind in the `kind` column only (strip `kind:` prefix from `name`) and store character-fandom association via `fandom_id` only (strip `(Fandom)` suffix from `name`); update every reader/writer in the app accordingly.
|
||
|
||
**Architecture:** Single Alembic migration rewrites every tag row in two phases — prefix strip for all kinds, then fandom suffix extraction for characters. A new `Tag.display_name` @property becomes the single source of truth for rendering. A one-place `_parse_kind_prefix` helper accepts the user-facing `character:Saber` input shortcut. Gallery URLs become `?tag_id=N`. The modal gets an inline fandom picker that slides in when `character:` is typed. WD14 accept path handles 0/1/N candidate resolution with an HTTP 409 disambiguation flow. A ~53-site mechanical sweep replaces every f-string prefix construction and `name.split(':', 1)` parse.
|
||
|
||
**Tech Stack:** Flask + SQLAlchemy + pgvector + Alembic migrations, Jinja2 templates, vanilla JS + CSS in `app/static/`, Celery for the maintenance task being removed, Postgres for partial unique indices.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-04-21-character-fandom-association-design.md`
|
||
|
||
---
|
||
|
||
## Context for the Implementer
|
||
|
||
**The codebase has no automated test suite.** There is no `tests/` directory, no `conftest.py`, no pytest fixtures. The existing convention (see migrations `h26041901`, `i26041901` and every feature commit on the branch) is manual verification: run the server in `docker compose`, exercise the feature in the browser, poke at the database via `docker compose exec web flask shell` or `docker compose exec postgres psql -U imagerepo imagerepo`.
|
||
|
||
**Do not invent a test suite.** Each task below ships with explicit manual verification: a shell command, a SQL query, or a UI interaction with an expected result. These are the project's conventions — honor them.
|
||
|
||
**TDD spirit, not TDD ceremony.** Where a task is easy to smoke-test with a one-off Python script (`docker compose exec web python -c '...'`), do it. Where it needs eyes on a page, open the page. Each task's "Verify" step states exactly how.
|
||
|
||
**Development loop (the user's normal flow):**
|
||
|
||
```bash
|
||
# Rebuild and restart all containers from docker-compose.yml
|
||
docker compose up -d --build
|
||
|
||
# Tail logs for the web container while you exercise the app
|
||
docker compose logs -f web
|
||
|
||
# Alembic shell for migrations
|
||
docker compose exec web flask db upgrade # apply
|
||
docker compose exec web flask db downgrade # revert
|
||
docker compose exec web flask db history # list revisions
|
||
|
||
# Postgres shell (inside the db container)
|
||
docker compose exec postgres psql -U imagerepo imagerepo
|
||
```
|
||
|
||
**Commit cadence:** one commit per task. The `app/main.py` file is large; commit per logical route or helper change so git blame stays useful.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### Files created
|
||
|
||
| Path | Responsibility |
|
||
|---|---|
|
||
| `migrations/versions/j26042101_bare_tag_names_and_fandom_id_authoritative.py` | Three-phase migration: strip kind prefixes, extract character fandom suffix, swap indices. |
|
||
| `app/utils/tag_prefix.py` | `KNOWN_KINDS` constant + `_parse_kind_prefix` helper, imported by `add_tag`, `bulk_add_tag`, and the migration's sanity check. |
|
||
|
||
### Files modified
|
||
|
||
| Path | What changes |
|
||
|---|---|
|
||
| `app/models.py` | Drop `unique=True` on `Tag.name`, add partial indices via `__table_args__`, add `display_name` @property. |
|
||
| `app/main.py` | `_ensure_fandom_tag` stores bare names; `add_tag`, `bulk_add_tag`, `accept_image_suggestion`, `set_tag_fandom`, `update_tag`, `get_tag`, `list_tags`, `search_tags`, `tag_list`, `gallery` routes refactored; `_parse_character_fandom` deleted; all `name.split(':', 1)` occurrences replaced by `tag.name` or `tag.display_name`; URL builders switch `tag=` to `tag_id=`; `trigger_sync_character_fandoms` route deleted. |
|
||
| `app/services/tag_suggestions.py` | `_canonicalize_wd14_name` simplified; `_FANDOM_SUFFIX_RE`, `_fandom_suffix_prefixes`, `applied_fandom_prefixes` branch deleted. |
|
||
| `app/utils/image_importer.py` | Archive tag construction switches from `f"archive:{artist}:{name}"` to `Tag(kind='archive', name=f"{artist}:{name}")`; prefix-ILIKE query becomes `kind='archive'` filter. |
|
||
| `app/utils/metadata_enrichment.py` | Post tag construction drops `post:` outer prefix. |
|
||
| `app/tasks/import_file.py` | Artist tag construction drops `artist:` prefix; archive tag construction same treatment. |
|
||
| `app/tasks/maintenance.py` | Delete `sync_character_fandoms` task body entirely; file becomes empty stub or is deleted (we keep the file with a comment for revision history). |
|
||
| `app/celery_app.py` | Remove the `sync_character_fandoms` task routing entry. |
|
||
| `app/templates/_gallery_item.html` | Six-branch prefix-strip expression collapses into `KIND_EMOJI` dict lookup + `t.display_name`. Anchor href switches to `tag_id`. |
|
||
| `app/templates/_gallery_modal.html` | Add the fandom-picker HTML block inside `.modal-tags-section`. |
|
||
| `app/templates/_tag_cards.html` | `tag.name` → `tag.display_name`; `tag=tag.tag` → `tag_id=tag.id`. |
|
||
| `app/templates/tags_list.html` | Add fandom-less character nudges (chip + assign button for `kind='character' AND fandom_id IS NULL`); add header counter. |
|
||
| `app/templates/reader.html` | `tag.name` → `tag.display_name`; `tag=tag.name` → `tag_id=tag.id`. |
|
||
| `app/templates/showcase.html` | No URL change (uses `kind=` filter, not `tag=`). Skip unless grep finds something. |
|
||
| `app/templates/layout.html` | No URL change. Skip unless grep finds something. |
|
||
| `app/templates/settings.html` | Remove the `sync-character-fandoms` maintenance button/form. |
|
||
| `app/static/js/view-modal.js` | Delete the `tag.name.split(':', 2)[1]` branch at line ~388 (use `display_name` from API); wire up fandom picker reveal + autocomplete + submit; implement ambiguous-accept 409 disambiguation UI. |
|
||
| `app/static/js/bulk-select.js` | Propagate `display_name` through its autocomplete rendering. |
|
||
| `app/static/js/tag-editor.js` | Same — use `display_name` where a tag is rendered to the user. |
|
||
| `app/static/style.css` | Add `.fandom-picker-wrapper`, `.fandom-picker-input`, `.fandom-picker-label`, `.fandom-picker-input-row` styles. Add `.character-no-fandom-chip` for tags-list nudge. Add `.suggestion-ambiguous-picker` for the 409 flow. |
|
||
|
||
### Files deleted
|
||
|
||
None. `app/tasks/maintenance.py` stays (empty body, retains git history); the Celery task is just removed from its contents. `sync-character-fandoms` button HTML is removed from `settings.html`.
|
||
|
||
---
|
||
|
||
## Task 0: Pre-flight baseline snapshot
|
||
|
||
**Why this task exists:** before touching anything, capture the current state of the `tag` table so we can verify post-migration that no data was lost. A migration that silently corrupts data is the one thing we cannot tolerate in this refactor.
|
||
|
||
**Files:**
|
||
- No code changes. Pure verification + backup.
|
||
|
||
- [ ] **Step 1: Snapshot tag counts by kind**
|
||
|
||
Run in shell:
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "
|
||
SELECT
|
||
COALESCE(kind, '(null)') AS kind,
|
||
COUNT(*) AS total,
|
||
COUNT(CASE WHEN name LIKE '%:%' THEN 1 END) AS has_colon_prefix,
|
||
COUNT(CASE WHEN kind = 'character' AND name LIKE '% (%)' THEN 1 END) AS has_fandom_suffix,
|
||
COUNT(CASE WHEN kind = 'character' AND fandom_id IS NOT NULL THEN 1 END) AS has_fandom_id
|
||
FROM tag
|
||
GROUP BY kind
|
||
ORDER BY kind;
|
||
"
|
||
```
|
||
|
||
Expected: a table with rows per kind. Record these numbers in your scratchpad — the post-migration verification will compare against them.
|
||
|
||
- [ ] **Step 2: Export full tag list for diff-comparison after migration**
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "
|
||
COPY (SELECT id, name, kind, fandom_id FROM tag ORDER BY id) TO STDOUT WITH CSV HEADER
|
||
" > /tmp/tag_snapshot_pre.csv
|
||
wc -l /tmp/tag_snapshot_pre.csv
|
||
```
|
||
|
||
Expected: CSV file of every tag row, one row per line plus header. Keep it — the post-migration task will compare.
|
||
|
||
- [ ] **Step 3: Snapshot image_tags counts (will verify zero lost after migration)**
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "
|
||
SELECT COUNT(*) AS total_image_tag_rows FROM image_tags;
|
||
"
|
||
```
|
||
|
||
Record this number. The migration must never reduce it (except intentionally via the auto-merge branch, which reassigns rows, not deletes them).
|
||
|
||
- [ ] **Step 4: Verify database is backed up (user-facing reminder)**
|
||
|
||
```bash
|
||
docker compose exec -T postgres pg_dump -U imagerepo imagerepo > /tmp/imagerepo_pre_refactor_backup.sql
|
||
ls -lh /tmp/imagerepo_pre_refactor_backup.sql
|
||
```
|
||
|
||
Expected: a `.sql` dump on disk. This is the abort-button if anything goes wrong — keep it until all tasks are merged and the feature is verified working.
|
||
|
||
No commit — this task produces only snapshots for later verification.
|
||
|
||
---
|
||
|
||
## Task 1: Add `display_name` property to Tag model
|
||
|
||
**Why first:** the property is backwards-compatible. `display_name` on today's `Tag(name="character:Ruby Rose (RWBY)")` returns that same string (via the `else` branch), so nothing breaks. Adding it first lets later tasks start using `{{ tag.display_name }}` opportunistically even before the migration runs.
|
||
|
||
**Files:**
|
||
- Modify: `app/models.py` (Tag class, around line 44-55)
|
||
|
||
- [ ] **Step 1: Add the `display_name` property to the Tag class**
|
||
|
||
Open `app/models.py` and find the `Tag` class (around line 44). Add a `display_name` property immediately after the `images` relationship:
|
||
|
||
```python
|
||
class Tag(db.Model):
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
name = db.Column(db.String(255), unique=True, nullable=False)
|
||
kind = db.Column(db.String(64), nullable=True)
|
||
fandom_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="SET NULL"), nullable=True)
|
||
fandom = db.relationship("Tag", remote_side="Tag.id", foreign_keys=[fandom_id])
|
||
images = db.relationship(
|
||
"ImageRecord",
|
||
secondary=image_tags,
|
||
back_populates="tags",
|
||
passive_deletes=True,
|
||
)
|
||
|
||
@property
|
||
def display_name(self) -> str:
|
||
"""Human-readable rendering of this tag.
|
||
|
||
After the bare-name refactor, `name` holds the display text directly for
|
||
every kind except character+fandom, which gets the '(Fandom)' suffix
|
||
appended here. Pre-migration data (with 'kind:' prefix in name) still
|
||
renders sanely via the else branch — callers see the raw stored string.
|
||
"""
|
||
if self.kind == 'character' and self.fandom is not None:
|
||
return f"{self.name} ({self.fandom.name})"
|
||
return self.name
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the property works on existing (pre-migration) data**
|
||
|
||
```bash
|
||
docker compose restart web
|
||
docker compose exec web flask shell -c "
|
||
from app.models import Tag
|
||
t = Tag.query.filter_by(kind='character').first()
|
||
if t:
|
||
print(f'name={t.name!r} display_name={t.display_name!r} fandom={t.fandom.name if t.fandom else None!r}')
|
||
else:
|
||
print('No character tags present — test with any tag:')
|
||
t = Tag.query.first()
|
||
print(f'name={t.name!r} display_name={t.display_name!r}')
|
||
"
|
||
```
|
||
|
||
Expected: `display_name` either matches `name` (fandom is None) or matches `name` + ' (fandom.name)' if a fandom is attached. Either way: no exception, no AttributeError.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add app/models.py
|
||
git commit -m "feat(models): add Tag.display_name @property
|
||
|
||
Single source of truth for human-readable tag rendering. Falls back to
|
||
tag.name when no fandom is attached, preserving pre-migration data shapes.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Create `app/utils/tag_prefix.py` with `KNOWN_KINDS` and `_parse_kind_prefix`
|
||
|
||
**Why:** the user-facing input shortcut `character:Saber` needs to keep working post-refactor. Parse happens *once* at the API boundary. This helper lives in one place so `add_tag`, `bulk_add_tag`, and any future caller stay consistent.
|
||
|
||
**Files:**
|
||
- Create: `app/utils/tag_prefix.py`
|
||
|
||
- [ ] **Step 1: Create `app/utils/tag_prefix.py`**
|
||
|
||
```python
|
||
"""Parse the user-facing `kind:name` shortcut used by the add-tag input.
|
||
|
||
After the bare-name refactor, `Tag.name` no longer stores the `kind:` prefix.
|
||
The prefix lives only as an input convention in a handful of user-facing
|
||
places (modal add-tag input, bulk-add-tag form, import paths that receive
|
||
user-typed strings). This module owns that parser.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
# The set of tag kinds the app recognizes as prefixable at the input boundary.
|
||
# `user` is intentionally excluded — user-typed general tags never carry a
|
||
# prefix. Strings like "http://example" keep their colon as literal text
|
||
# because "http" is not in this set.
|
||
KNOWN_KINDS: frozenset[str] = frozenset({
|
||
'character',
|
||
'fandom',
|
||
'artist',
|
||
'series',
|
||
'post',
|
||
'archive',
|
||
'meta',
|
||
})
|
||
|
||
|
||
def parse_kind_prefix(raw: str) -> tuple[str | None, str]:
|
||
"""Split a raw user-typed tag string into (kind, name).
|
||
|
||
Returns:
|
||
(kind, name) where kind is one of KNOWN_KINDS, or
|
||
(None, raw) if no recognized prefix is present.
|
||
|
||
Examples:
|
||
parse_kind_prefix('character:Saber') -> ('character', 'Saber')
|
||
parse_kind_prefix('sunset') -> (None, 'sunset')
|
||
parse_kind_prefix('http://example') -> (None, 'http://example')
|
||
parse_kind_prefix('artist: Eric ') -> ('artist', 'Eric')
|
||
"""
|
||
if ':' in raw:
|
||
prefix, rest = raw.split(':', 1)
|
||
if prefix in KNOWN_KINDS:
|
||
return prefix, rest.strip()
|
||
return None, raw.strip()
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the parser with representative inputs**
|
||
|
||
```bash
|
||
docker compose exec web python -c "
|
||
from app.utils.tag_prefix import parse_kind_prefix, KNOWN_KINDS
|
||
|
||
cases = [
|
||
('character:Saber', ('character', 'Saber')),
|
||
('fandom:RWBY', ('fandom', 'RWBY')),
|
||
('artist: Eric Canete ', ('artist', 'Eric Canete')),
|
||
('sunset', (None, 'sunset')),
|
||
('http://example', (None, 'http://example')),
|
||
(' ', (None, '')),
|
||
('post:patreon:eric:12345', ('post', 'patreon:eric:12345')),
|
||
]
|
||
for inp, expected in cases:
|
||
got = parse_kind_prefix(inp)
|
||
status = 'OK' if got == expected else 'FAIL'
|
||
print(f'{status}: {inp!r} -> {got} (expected {expected})')
|
||
print(f'KNOWN_KINDS: {sorted(KNOWN_KINDS)}')
|
||
"
|
||
```
|
||
|
||
Expected: every line prints `OK`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add app/utils/tag_prefix.py
|
||
git commit -m "feat(utils): add parse_kind_prefix helper
|
||
|
||
Owns the 'character:Saber' user-input shortcut parser in one place.
|
||
Invoked by add_tag, bulk_add_tag, and the migration's sanity check.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Alembic migration — three-phase data transformation
|
||
|
||
**Why the most-involved task:** this is the irreversible step. Phase 1 strips `kind:` prefix from every non-`user` tag name. Phase 2 extracts `(Fandom)` suffix from character names into `fandom_id`. Phase 3 swaps `UNIQUE(name)` for four partial indices. `tag_reference_embedding.tag_name` is keyed by string, so every rename must cascade into it (same pattern as `h26041901` / `i26041901`). The migration includes auto-merge for phase-2 collisions and a report of phase-2 orphans (bare-name + suffixed coexistence).
|
||
|
||
**Files:**
|
||
- Create: `migrations/versions/j26042101_bare_tag_names_and_fandom_id_authoritative.py`
|
||
|
||
- [ ] **Step 1: Identify the latest migration revision to chain onto**
|
||
|
||
```bash
|
||
docker compose exec web flask db heads
|
||
```
|
||
|
||
Expected: a single revision id. On the current repo state, this should be `i26041901`. Use whatever it prints as `down_revision` in the next step.
|
||
|
||
- [ ] **Step 2: Create the migration file**
|
||
|
||
Create `migrations/versions/j26042101_bare_tag_names_and_fandom_id_authoritative.py`:
|
||
|
||
```python
|
||
"""Strip kind: prefix from tag names, extract character (Fandom) suffix into
|
||
fandom_id, and swap UNIQUE(name) for four partial unique indices keyed on
|
||
(name, kind) with special handling for characters.
|
||
|
||
Revision ID: j26042101
|
||
Revises: i26041901
|
||
Create Date: 2026-04-21
|
||
|
||
Three phases run in one transaction:
|
||
|
||
Phase 1 — Strip kind prefix from tag.name for every non-user tag.
|
||
'character:Ruby Rose (RWBY)' -> 'Ruby Rose (RWBY)'
|
||
'artist:Eric Canete' -> 'Eric Canete'
|
||
'post:patreon:eric:12345' -> 'patreon:eric:12345'
|
||
User tags (kind='user', already bare) are untouched.
|
||
|
||
Phase 2 — For every kind='character' tag whose name still matches
|
||
'Name (Fandom)' after phase 1, ensure the fandom tag exists,
|
||
update the character's fandom_id, and strip the suffix from its name.
|
||
True duplicates (same bare_name + same fandom_id) auto-merge with
|
||
image_tags reassigned and the duplicate row deleted.
|
||
|
||
Phase 3 — Drop UNIQUE(tag.name); create four partial unique indices:
|
||
tag_character_with_fandom_uniq UNIQUE(name, fandom_id) WHERE kind='character' AND fandom_id IS NOT NULL
|
||
tag_character_null_fandom_uniq UNIQUE(name) WHERE kind='character' AND fandom_id IS NULL
|
||
tag_other_kinds_uniq UNIQUE(name, kind) WHERE kind != 'character'
|
||
tag_kind_idx BTREE(kind)
|
||
|
||
Also cascades renames into tag_reference_embedding.tag_name (keyed by name
|
||
string, same pattern as h26041901 / i26041901).
|
||
|
||
Downgrade raises — merges are destructive; roll back via the
|
||
/tmp/imagerepo_pre_refactor_backup.sql dump captured in pre-flight.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
|
||
from alembic import op
|
||
import sqlalchemy as sa
|
||
from sqlalchemy import text
|
||
|
||
|
||
revision = 'j26042101'
|
||
down_revision = 'i26041901'
|
||
branch_labels = None
|
||
depends_on = None
|
||
|
||
|
||
# Same kinds app-side (app/utils/tag_prefix.KNOWN_KINDS), with colon suffix.
|
||
# 'user' intentionally excluded — those tags have always been stored bare.
|
||
KNOWN_PREFIXES = (
|
||
'character:', 'fandom:', 'artist:', 'series:',
|
||
'post:', 'archive:', 'meta:',
|
||
)
|
||
|
||
FANDOM_SUFFIX_RE = re.compile(r'^(.+?) \(([^()]+)\)$')
|
||
|
||
|
||
def _strip_prefix(name: str) -> tuple[str, str | None]:
|
||
"""Return (stripped_name, prefix_without_colon). No prefix -> (name, None)."""
|
||
for prefix in KNOWN_PREFIXES:
|
||
if name.startswith(prefix):
|
||
return name[len(prefix):], prefix.rstrip(':')
|
||
return name, None
|
||
|
||
|
||
def _reassign_image_tags(conn, from_id: int, to_id: int) -> None:
|
||
"""Move every image_tags row pointing at from_id over to to_id, dedup via
|
||
ON CONFLICT."""
|
||
conn.execute(
|
||
text("""
|
||
INSERT INTO image_tags (image_id, tag_id)
|
||
SELECT DISTINCT image_id, :to_id
|
||
FROM image_tags
|
||
WHERE tag_id = :from_id
|
||
ON CONFLICT DO NOTHING
|
||
"""),
|
||
{'from_id': from_id, 'to_id': to_id},
|
||
)
|
||
conn.execute(
|
||
text("DELETE FROM image_tags WHERE tag_id = :from_id"),
|
||
{'from_id': from_id},
|
||
)
|
||
|
||
|
||
def _rename_tag_reference_embedding(conn, old_name: str, new_name: str) -> None:
|
||
"""Cascade a tag rename into tag_reference_embedding.tag_name, which is a
|
||
PK string. Copies the loser's rows onto the winner's name where the winner
|
||
has no row for that model_version; deletes the rest."""
|
||
if old_name == new_name:
|
||
return
|
||
conn.execute(
|
||
text("""
|
||
UPDATE tag_reference_embedding
|
||
SET tag_name = :new_name
|
||
WHERE tag_name = :old_name
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM tag_reference_embedding w
|
||
WHERE w.tag_name = :new_name
|
||
AND w.model_version = tag_reference_embedding.model_version
|
||
)
|
||
"""),
|
||
{'old_name': old_name, 'new_name': new_name},
|
||
)
|
||
conn.execute(
|
||
text("DELETE FROM tag_reference_embedding WHERE tag_name = :old_name"),
|
||
{'old_name': old_name},
|
||
)
|
||
|
||
|
||
def upgrade():
|
||
conn = op.get_bind()
|
||
|
||
# -------------------------------------------------------------------------
|
||
# Phase 1 — strip kind: prefix from every non-user tag's name
|
||
# -------------------------------------------------------------------------
|
||
phase1_renames = 0
|
||
rows = conn.execute(
|
||
text("SELECT id, name, kind FROM tag ORDER BY id")
|
||
).fetchall()
|
||
|
||
for tag_id, old_name, kind in rows:
|
||
new_name, stripped_prefix = _strip_prefix(old_name)
|
||
if new_name == old_name:
|
||
continue
|
||
# Sanity: the stripped prefix should match the kind column. If not,
|
||
# the data is inconsistent; log and trust the kind column.
|
||
if stripped_prefix != kind:
|
||
print(
|
||
f"phase1 warning: tag {tag_id} name prefix={stripped_prefix!r} "
|
||
f"but kind column={kind!r}; trusting kind"
|
||
)
|
||
conn.execute(
|
||
text("UPDATE tag SET name = :new_name WHERE id = :id"),
|
||
{'new_name': new_name, 'id': tag_id},
|
||
)
|
||
_rename_tag_reference_embedding(conn, old_name, new_name)
|
||
phase1_renames += 1
|
||
|
||
# -------------------------------------------------------------------------
|
||
# Phase 2 — extract (Fandom) suffix from character names into fandom_id
|
||
# -------------------------------------------------------------------------
|
||
phase2_extracted = 0
|
||
phase2_merged = 0
|
||
phase2_orphans: list[tuple[int, str]] = []
|
||
phase2_malformed: list[tuple[int, str]] = []
|
||
|
||
char_rows = conn.execute(
|
||
text("SELECT id, name, fandom_id FROM tag WHERE kind = 'character' ORDER BY id")
|
||
).fetchall()
|
||
|
||
for tag_id, char_name, existing_fandom_id in char_rows:
|
||
m = FANDOM_SUFFIX_RE.match(char_name)
|
||
if not m:
|
||
# No suffix. Check if a suffixed sibling exists — if so, this is
|
||
# an orphan worth reporting so the user can merge manually.
|
||
sibling = conn.execute(
|
||
text("""
|
||
SELECT id FROM tag
|
||
WHERE kind = 'character'
|
||
AND name LIKE :pattern
|
||
AND id != :tag_id
|
||
"""),
|
||
{'pattern': f"{char_name} (%)", 'tag_id': tag_id},
|
||
).fetchone()
|
||
if sibling:
|
||
phase2_orphans.append((tag_id, char_name))
|
||
continue
|
||
|
||
bare_name, fandom_name = m.group(1), m.group(2)
|
||
bare_name = bare_name.strip()
|
||
fandom_name = fandom_name.strip()
|
||
if not bare_name or not fandom_name:
|
||
phase2_malformed.append((tag_id, char_name))
|
||
continue
|
||
|
||
# Ensure the fandom tag exists. Phase 1 has already stripped any
|
||
# 'fandom:' prefix, so we look up by bare name.
|
||
fandom_row = conn.execute(
|
||
text("SELECT id FROM tag WHERE kind = 'fandom' AND name = :name"),
|
||
{'name': fandom_name},
|
||
).fetchone()
|
||
if fandom_row is None:
|
||
result = conn.execute(
|
||
text("INSERT INTO tag (name, kind) VALUES (:name, 'fandom') RETURNING id"),
|
||
{'name': fandom_name},
|
||
)
|
||
new_fandom_id = result.scalar_one()
|
||
else:
|
||
new_fandom_id = fandom_row[0]
|
||
|
||
# Collision check: is there already a (name=bare_name, fandom_id=new_fandom_id, kind='character')?
|
||
collision = conn.execute(
|
||
text("""
|
||
SELECT id, name FROM tag
|
||
WHERE kind = 'character'
|
||
AND name = :bare
|
||
AND fandom_id = :fid
|
||
AND id != :tag_id
|
||
"""),
|
||
{'bare': bare_name, 'fid': new_fandom_id, 'tag_id': tag_id},
|
||
).fetchone()
|
||
|
||
if collision:
|
||
# Auto-merge. Reassign image_tags, cascade tag_reference_embedding
|
||
# rename (from current tag's existing name into the collision's
|
||
# name), delete current tag.
|
||
collision_id, collision_name = collision
|
||
_reassign_image_tags(conn, from_id=tag_id, to_id=collision_id)
|
||
_rename_tag_reference_embedding(conn, old_name=char_name, new_name=collision_name)
|
||
conn.execute(
|
||
text("DELETE FROM tag WHERE id = :id"),
|
||
{'id': tag_id},
|
||
)
|
||
phase2_merged += 1
|
||
continue
|
||
|
||
# Happy path: update name + fandom_id, cascade rename in tag_reference_embedding.
|
||
conn.execute(
|
||
text("UPDATE tag SET name = :bare, fandom_id = :fid WHERE id = :id"),
|
||
{'bare': bare_name, 'fid': new_fandom_id, 'id': tag_id},
|
||
)
|
||
_rename_tag_reference_embedding(conn, old_name=char_name, new_name=bare_name)
|
||
phase2_extracted += 1
|
||
|
||
# -------------------------------------------------------------------------
|
||
# Phase 3 — drop old unique, create partial indices
|
||
# -------------------------------------------------------------------------
|
||
# The unique index on tag.name was created as part of the initial table
|
||
# definition; its name in Postgres is typically tag_name_key (SQLAlchemy's
|
||
# default for UniqueConstraint on column tag.name). If your DB has a
|
||
# different name, adjust here. The drop + create is atomic with the rest
|
||
# of the migration.
|
||
op.drop_constraint('tag_name_key', 'tag', type_='unique')
|
||
|
||
op.create_index(
|
||
'tag_character_with_fandom_uniq', 'tag', ['name', 'fandom_id'],
|
||
unique=True,
|
||
postgresql_where=sa.text("kind = 'character' AND fandom_id IS NOT NULL"),
|
||
)
|
||
op.create_index(
|
||
'tag_character_null_fandom_uniq', 'tag', ['name'],
|
||
unique=True,
|
||
postgresql_where=sa.text("kind = 'character' AND fandom_id IS NULL"),
|
||
)
|
||
op.create_index(
|
||
'tag_other_kinds_uniq', 'tag', ['name', 'kind'],
|
||
unique=True,
|
||
postgresql_where=sa.text("kind != 'character'"),
|
||
)
|
||
op.create_index('tag_kind_idx', 'tag', ['kind'])
|
||
|
||
# -------------------------------------------------------------------------
|
||
# Report
|
||
# -------------------------------------------------------------------------
|
||
print("=" * 72)
|
||
print(f"j26042101: Phase 1 prefix-strips: {phase1_renames}")
|
||
print(f"j26042101: Phase 2 character extractions: {phase2_extracted}")
|
||
print(f"j26042101: Phase 2 auto-merged duplicates: {phase2_merged}")
|
||
print(f"j26042101: Phase 2 bare-name orphans (review in /tags): {len(phase2_orphans)}")
|
||
for tid, nm in phase2_orphans:
|
||
print(f" - tag {tid} '{nm}' coexists with a suffixed sibling")
|
||
print(f"j26042101: Phase 2 malformed names skipped: {len(phase2_malformed)}")
|
||
for tid, nm in phase2_malformed:
|
||
print(f" - tag {tid} '{nm}'")
|
||
print("=" * 72)
|
||
|
||
|
||
def downgrade():
|
||
raise NotImplementedError(
|
||
"j26042101 strips kind prefixes and extracts character fandoms. "
|
||
"Both phases are destructive (auto-merges delete tag rows; partial "
|
||
"indices differ from the old unique constraint). Restore from the "
|
||
"pre-migration backup at /tmp/imagerepo_pre_refactor_backup.sql "
|
||
"if you need to roll back."
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 3: Run the migration against the live DB**
|
||
|
||
```bash
|
||
docker compose exec web flask db upgrade
|
||
```
|
||
|
||
Expected: output ends with the report block. Note the four counts (Phase 1 renames, Phase 2 extractions, Phase 2 merges, Phase 2 orphans).
|
||
|
||
- [ ] **Step 4: Verify no colons remain in non-post/non-archive tag names**
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "
|
||
SELECT kind, COUNT(*) AS n
|
||
FROM tag
|
||
WHERE name LIKE '%:%' AND kind NOT IN ('post', 'archive')
|
||
GROUP BY kind;
|
||
"
|
||
```
|
||
|
||
Expected: zero rows returned. (Post/archive tags legitimately retain inner `:` in their identifiers.)
|
||
|
||
- [ ] **Step 5: Verify no character tag name contains a fandom suffix**
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "
|
||
SELECT id, name FROM tag
|
||
WHERE kind = 'character'
|
||
AND name ~ '^.+ \(.+\)$'
|
||
LIMIT 20;
|
||
"
|
||
```
|
||
|
||
Expected: zero rows. If any remain, they're malformed orphans that the migration logged — investigate those specifically and decide whether to merge manually.
|
||
|
||
- [ ] **Step 6: Verify the four partial indices exist**
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "
|
||
SELECT indexname, indexdef FROM pg_indexes
|
||
WHERE tablename = 'tag'
|
||
AND indexname IN (
|
||
'tag_character_with_fandom_uniq',
|
||
'tag_character_null_fandom_uniq',
|
||
'tag_other_kinds_uniq',
|
||
'tag_kind_idx'
|
||
);
|
||
"
|
||
```
|
||
|
||
Expected: four rows, each with the partial-WHERE clause visible in `indexdef`.
|
||
|
||
- [ ] **Step 7: Verify image_tags count unchanged (no data loss)**
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "SELECT COUNT(*) FROM image_tags;"
|
||
```
|
||
|
||
Compare against the baseline from Task 0 Step 3. Acceptable outcomes:
|
||
- Exactly equal: no merges happened, perfect.
|
||
- Slightly smaller: one or more images had the same character tag attached via both a suffixed *and* a bare-name version, which collapse on merge. Confirm the loss count matches `phase2_merged × (average duplicate attachments)` from the migration report.
|
||
- Larger: **not possible — abort**.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add migrations/versions/j26042101_bare_tag_names_and_fandom_id_authoritative.py
|
||
git commit -m "feat(migration): bare tag names; fandom_id authoritative
|
||
|
||
Three-phase Alembic migration j26042101 strips 'kind:' prefix from every
|
||
non-user tag, extracts character '(Fandom)' suffix into fandom_id with
|
||
auto-merge on duplicates, and swaps UNIQUE(name) for four partial indices
|
||
keyed on (name, kind) with special handling for the (character, fandom_id)
|
||
combination.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Update Tag model's constraints to match the new indices
|
||
|
||
**Why:** the model still has `unique=True` on `name`. Postgres won't enforce it anymore (the migration dropped that constraint), but Alembic's autogenerate will re-create it on the next migration unless we fix the model. Also, we add the partial indices to `__table_args__` so the ORM-level schema stays in sync.
|
||
|
||
**Files:**
|
||
- Modify: `app/models.py` (Tag class)
|
||
|
||
- [ ] **Step 1: Remove `unique=True` from Tag.name and add `__table_args__`**
|
||
|
||
In `app/models.py`, update the Tag class. Replace:
|
||
|
||
```python
|
||
class Tag(db.Model):
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
name = db.Column(db.String(255), unique=True, nullable=False)
|
||
kind = db.Column(db.String(64), nullable=True)
|
||
fandom_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="SET NULL"), nullable=True)
|
||
fandom = db.relationship("Tag", remote_side="Tag.id", foreign_keys=[fandom_id])
|
||
images = db.relationship(
|
||
"ImageRecord",
|
||
secondary=image_tags,
|
||
back_populates="tags",
|
||
passive_deletes=True,
|
||
)
|
||
|
||
@property
|
||
def display_name(self) -> str:
|
||
```
|
||
|
||
with:
|
||
|
||
```python
|
||
class Tag(db.Model):
|
||
__table_args__ = (
|
||
db.Index(
|
||
'tag_character_with_fandom_uniq', 'name', 'fandom_id',
|
||
unique=True,
|
||
postgresql_where=db.text("kind = 'character' AND fandom_id IS NOT NULL"),
|
||
),
|
||
db.Index(
|
||
'tag_character_null_fandom_uniq', 'name',
|
||
unique=True,
|
||
postgresql_where=db.text("kind = 'character' AND fandom_id IS NULL"),
|
||
),
|
||
db.Index(
|
||
'tag_other_kinds_uniq', 'name', 'kind',
|
||
unique=True,
|
||
postgresql_where=db.text("kind != 'character'"),
|
||
),
|
||
db.Index('tag_kind_idx', 'kind'),
|
||
)
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
name = db.Column(db.String(255), nullable=False)
|
||
kind = db.Column(db.String(64), nullable=True)
|
||
fandom_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="SET NULL"), nullable=True)
|
||
fandom = db.relationship("Tag", remote_side="Tag.id", foreign_keys=[fandom_id])
|
||
images = db.relationship(
|
||
"ImageRecord",
|
||
secondary=image_tags,
|
||
back_populates="tags",
|
||
passive_deletes=True,
|
||
)
|
||
|
||
@property
|
||
def display_name(self) -> str:
|
||
```
|
||
|
||
(Everything after `@property` is unchanged from Task 1.)
|
||
|
||
- [ ] **Step 2: Verify Alembic autogenerate sees no schema drift**
|
||
|
||
```bash
|
||
docker compose exec web flask db revision --autogenerate -m "temp_check_drift"
|
||
ls -1t migrations/versions/ | head -1
|
||
```
|
||
|
||
Inspect the newest file in `migrations/versions/`. Its `upgrade()` body should be empty (or contain only `pass`) — no index drop/add, no alterations. If it's empty, delete it:
|
||
|
||
```bash
|
||
LATEST=$(ls -1t migrations/versions/*.py | head -1)
|
||
grep -A 5 "def upgrade" "$LATEST"
|
||
# If the body is just 'pass' or empty, delete:
|
||
rm "$LATEST"
|
||
```
|
||
|
||
If the autogenerated migration contains real drift (index changes, constraint changes), stop and investigate — the model doesn't match what Task 3's migration produced.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add app/models.py
|
||
git commit -m "feat(models): Tag.__table_args__ matches j26042101 indices
|
||
|
||
Drop unique=True on Tag.name (the migration replaced UNIQUE(name) with
|
||
four partial indices). Declare the partial indices in __table_args__ so
|
||
Alembic autogenerate stops seeing drift on future schema changes.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Rewrite `_ensure_fandom_tag` helper to store bare names
|
||
|
||
**Why:** this helper is used by `add_tag`, `bulk_add_tag`, and `set_tag_fandom`. It currently creates rows with `name=f"fandom:{normalized}"`. Post-migration those names are stored bare.
|
||
|
||
**Files:**
|
||
- Modify: `app/main.py` (lines 28-40, the `_ensure_fandom_tag` function)
|
||
|
||
- [ ] **Step 1: Update `_ensure_fandom_tag` in `app/main.py`**
|
||
|
||
Replace the existing function (around line 28):
|
||
|
||
```python
|
||
def _ensure_fandom_tag(fandom_name):
|
||
"""Find or create a fandom tag by name. Returns the Tag object.
|
||
|
||
Normalizes the display name so callers don't have to remember.
|
||
"""
|
||
normalized = normalize_display_name(fandom_name.strip())
|
||
full_name = f"fandom:{normalized}"
|
||
fandom_tag = Tag.query.filter_by(name=full_name).first()
|
||
if not fandom_tag:
|
||
fandom_tag = Tag(name=full_name, kind="fandom")
|
||
db.session.add(fandom_tag)
|
||
db.session.flush()
|
||
return fandom_tag
|
||
```
|
||
|
||
with:
|
||
|
||
```python
|
||
def _ensure_fandom_tag(fandom_name):
|
||
"""Find or create a fandom tag by bare name. Returns the Tag object.
|
||
|
||
Normalizes the display name so callers don't have to remember.
|
||
"""
|
||
normalized = normalize_display_name(fandom_name.strip())
|
||
fandom_tag = Tag.query.filter_by(kind='fandom', name=normalized).first()
|
||
if not fandom_tag:
|
||
fandom_tag = Tag(kind='fandom', name=normalized)
|
||
db.session.add(fandom_tag)
|
||
db.session.flush()
|
||
return fandom_tag
|
||
```
|
||
|
||
- [ ] **Step 2: Verify via Flask shell**
|
||
|
||
```bash
|
||
docker compose exec web flask shell -c "
|
||
from app.main import _ensure_fandom_tag
|
||
from app import db
|
||
t = _ensure_fandom_tag('Test Fandom Ephemeral')
|
||
print(f'name={t.name!r} kind={t.kind!r}')
|
||
assert t.name == 'Test Fandom Ephemeral', f'expected bare name, got {t.name!r}'
|
||
assert t.kind == 'fandom'
|
||
# Clean up so we don't pollute the DB
|
||
db.session.delete(t)
|
||
db.session.commit()
|
||
print('OK')
|
||
"
|
||
```
|
||
|
||
Expected: `name='Test Fandom Ephemeral' kind='fandom'` then `OK`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add app/main.py
|
||
git commit -m "refactor(main): _ensure_fandom_tag stores bare names
|
||
|
||
Post-migration tag.name no longer embeds 'fandom:' prefix. Helper now
|
||
creates/finds by (kind='fandom', name=bare).
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Rewrite `add_tag` endpoint to use `parse_kind_prefix` and bare storage
|
||
|
||
**Why:** this is the main user-facing entry point for new tags. It must accept the user-facing `character:Saber` shortcut, parse kind out at the boundary, and store bare. Character path gains explicit `fandom_id` handling.
|
||
|
||
**Files:**
|
||
- Modify: `app/main.py` (`add_tag` around line 862-931)
|
||
|
||
- [ ] **Step 1: Replace the `add_tag` function body**
|
||
|
||
Find the existing `add_tag` (decorator `@main.post("/image/<int:image_id>/tags/add")` around line 862) and replace the whole function with:
|
||
|
||
```python
|
||
@main.post("/image/<int:image_id>/tags/add")
|
||
def add_tag(image_id):
|
||
"""
|
||
JSON endpoint to add a tag to an image.
|
||
|
||
Accepts the user-facing 'kind:name' shortcut in the 'name' field
|
||
(e.g. 'character:Saber'). Bare strings without a recognized prefix are
|
||
treated as user tags.
|
||
|
||
For character tags, optional 'fandom_id' (preferred) or 'fandom_name'
|
||
attaches the character to a fandom. Both omitted = null fandom (allowed).
|
||
"""
|
||
from app.utils.tag_prefix import parse_kind_prefix
|
||
from app.utils.tag_names import underscores_to_spaces
|
||
|
||
raw = (request.form.get("name") or "").strip()
|
||
if not raw:
|
||
abort(400)
|
||
|
||
kind, name = parse_kind_prefix(raw)
|
||
if kind is None:
|
||
# No recognized prefix — treat as a user-typed general tag.
|
||
kind = "user"
|
||
name = underscores_to_spaces(name)
|
||
elif kind in ("character", "fandom"):
|
||
name = normalize_display_name(name)
|
||
|
||
img = ImageRecord.query.get_or_404(image_id)
|
||
|
||
fandom_tag = None
|
||
if kind == "character":
|
||
fandom_id = request.form.get("fandom_id", type=int)
|
||
fandom_name = (request.form.get("fandom_name") or "").strip() or None
|
||
|
||
if fandom_id:
|
||
fandom_tag = Tag.query.filter_by(id=fandom_id, kind="fandom").first()
|
||
if fandom_tag is None:
|
||
return jsonify(ok=False, error="fandom_not_found"), 400
|
||
elif fandom_name:
|
||
fandom_tag = _ensure_fandom_tag(fandom_name)
|
||
|
||
tag = Tag.query.filter_by(
|
||
kind="character",
|
||
name=name,
|
||
fandom_id=fandom_tag.id if fandom_tag else None,
|
||
).first()
|
||
if tag is None:
|
||
tag = Tag(
|
||
kind="character",
|
||
name=name,
|
||
fandom_id=fandom_tag.id if fandom_tag else None,
|
||
)
|
||
db.session.add(tag)
|
||
db.session.flush()
|
||
else:
|
||
tag = Tag.query.filter_by(kind=kind, name=name).first()
|
||
if tag is None:
|
||
tag = Tag(kind=kind, name=name)
|
||
db.session.add(tag)
|
||
db.session.flush()
|
||
|
||
if tag not in img.tags:
|
||
img.tags.append(tag)
|
||
|
||
# Auto-apply fandom tag to the image (preserves today's UX feature).
|
||
if fandom_tag and fandom_tag not in img.tags:
|
||
img.tags.append(fandom_tag)
|
||
|
||
db.session.commit()
|
||
|
||
result = {
|
||
"id": tag.id,
|
||
"name": tag.name,
|
||
"display_name": tag.display_name,
|
||
"kind": tag.kind,
|
||
}
|
||
if fandom_tag:
|
||
result["fandom_tag"] = {
|
||
"id": fandom_tag.id,
|
||
"name": fandom_tag.name,
|
||
"display_name": fandom_tag.display_name,
|
||
"kind": fandom_tag.kind,
|
||
}
|
||
return jsonify(ok=True, tag=result)
|
||
```
|
||
|
||
- [ ] **Step 2: Exercise the endpoint against an existing image**
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "SELECT id FROM image_record LIMIT 1;"
|
||
```
|
||
|
||
Copy the returned id (call it `$IMG`). Then:
|
||
|
||
```bash
|
||
IMG=<paste_the_id>
|
||
# General tag (no prefix)
|
||
curl -sX POST "http://localhost:5000/image/$IMG/tags/add" \
|
||
-d "name=test_general_ephemeral" | python -m json.tool
|
||
|
||
# Character with explicit fandom_name
|
||
curl -sX POST "http://localhost:5000/image/$IMG/tags/add" \
|
||
-d "name=character:Test Char Ephemeral&fandom_name=Test Fandom Ephemeral" \
|
||
| python -m json.tool
|
||
```
|
||
|
||
Expected for the first: `{"ok": true, "tag": {"id": ..., "name": "test general ephemeral", "display_name": "test general ephemeral", "kind": "user"}}` (no fandom_tag key).
|
||
|
||
Expected for the second: `ok=true`, `tag.name="Test Char Ephemeral"`, `tag.kind="character"`, `tag.display_name="Test Char Ephemeral (Test Fandom Ephemeral)"`, and a sibling `fandom_tag` block.
|
||
|
||
- [ ] **Step 3: Clean up the ephemeral test tags**
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "
|
||
DELETE FROM image_tags WHERE tag_id IN (
|
||
SELECT id FROM tag WHERE name IN (
|
||
'test general ephemeral', 'Test Char Ephemeral', 'Test Fandom Ephemeral'
|
||
)
|
||
);
|
||
DELETE FROM tag WHERE name IN (
|
||
'test general ephemeral', 'Test Char Ephemeral', 'Test Fandom Ephemeral'
|
||
);
|
||
"
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add app/main.py
|
||
git commit -m "refactor(main): add_tag parses prefix at boundary; bare storage
|
||
|
||
Accepts 'character:Saber' user shortcut, parses via parse_kind_prefix,
|
||
stores tag.name bare. Character path accepts fandom_id (preferred) or
|
||
fandom_name for fandom association. Returns display_name in the
|
||
response so the client can render pills without a second round-trip.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Rewrite `bulk_add_tag` to match the new `add_tag` shape
|
||
|
||
**Why:** the bulk endpoint applies one tag to many images. It has the same prefix-parsing logic — extract it to use the shared `parse_kind_prefix` helper and match the refactored storage shape.
|
||
|
||
**Files:**
|
||
- Modify: `app/main.py` (`bulk_add_tag`, around line 2320-2403)
|
||
|
||
- [ ] **Step 1: Read the current bulk_add_tag**
|
||
|
||
```bash
|
||
docker compose exec web python -c "
|
||
import re
|
||
with open('/app/app/main.py') as f:
|
||
txt = f.read()
|
||
# find def bulk_add_tag(
|
||
start = txt.index('def bulk_add_tag(')
|
||
end = txt.index('\n@main.', start)
|
||
print(txt[start:end])
|
||
" 2>/dev/null || sed -n '2320,2405p' app/main.py
|
||
```
|
||
|
||
- [ ] **Step 2: Replace bulk_add_tag with the refactored version**
|
||
|
||
Open `app/main.py` and find `def bulk_add_tag(` (around line 2321). Replace the entire function body (from the `def` line through to the next blank line before the next `@main.` decorator) with:
|
||
|
||
```python
|
||
@main.route('/api/images/bulk-add-tag', methods=['POST'])
|
||
def bulk_add_tag():
|
||
"""
|
||
Bulk-apply one tag to many images in a single transaction.
|
||
|
||
Accepts the same 'kind:name' shortcut as /image/<id>/tags/add. For
|
||
character tags, accepts optional fandom_id / fandom_name just like
|
||
the single-image endpoint.
|
||
"""
|
||
from app.utils.tag_prefix import parse_kind_prefix
|
||
from app.utils.tag_names import underscores_to_spaces
|
||
|
||
data = request.get_json() or {}
|
||
image_ids = data.get("image_ids") or []
|
||
raw = (data.get("tag_name") or "").strip()
|
||
if not image_ids or not raw:
|
||
return jsonify(ok=False, error="image_ids and tag_name required"), 400
|
||
|
||
kind, name = parse_kind_prefix(raw)
|
||
if kind is None:
|
||
kind = "user"
|
||
name = underscores_to_spaces(name)
|
||
elif kind in ("character", "fandom"):
|
||
name = normalize_display_name(name)
|
||
|
||
fandom_tag = None
|
||
if kind == "character":
|
||
fandom_id = data.get("fandom_id")
|
||
fandom_name = (data.get("fandom_name") or "").strip() or None
|
||
if fandom_id:
|
||
fandom_tag = Tag.query.filter_by(id=fandom_id, kind="fandom").first()
|
||
if fandom_tag is None:
|
||
return jsonify(ok=False, error="fandom_not_found"), 400
|
||
elif fandom_name:
|
||
fandom_tag = _ensure_fandom_tag(fandom_name)
|
||
|
||
tag = Tag.query.filter_by(
|
||
kind="character",
|
||
name=name,
|
||
fandom_id=fandom_tag.id if fandom_tag else None,
|
||
).first()
|
||
if tag is None:
|
||
tag = Tag(
|
||
kind="character",
|
||
name=name,
|
||
fandom_id=fandom_tag.id if fandom_tag else None,
|
||
)
|
||
db.session.add(tag)
|
||
db.session.flush()
|
||
else:
|
||
tag = Tag.query.filter_by(kind=kind, name=name).first()
|
||
if tag is None:
|
||
tag = Tag(kind=kind, name=name)
|
||
db.session.add(tag)
|
||
db.session.flush()
|
||
|
||
added_count = 0
|
||
for image_id in image_ids:
|
||
img = ImageRecord.query.get(image_id)
|
||
if img is None:
|
||
continue
|
||
if tag not in img.tags:
|
||
img.tags.append(tag)
|
||
added_count += 1
|
||
if fandom_tag and fandom_tag not in img.tags:
|
||
img.tags.append(fandom_tag)
|
||
|
||
db.session.commit()
|
||
return jsonify(
|
||
ok=True,
|
||
added_count=added_count,
|
||
total=len(image_ids),
|
||
tag={
|
||
"id": tag.id,
|
||
"name": tag.name,
|
||
"display_name": tag.display_name,
|
||
"kind": tag.kind,
|
||
},
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 3: Verify with two dummy images**
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "SELECT id FROM image_record LIMIT 2;"
|
||
```
|
||
|
||
Take the two ids (call them IMG1, IMG2):
|
||
|
||
```bash
|
||
IMG1=<first_id>
|
||
IMG2=<second_id>
|
||
|
||
curl -sX POST "http://localhost:5000/api/images/bulk-add-tag" \
|
||
-H "Content-Type: application/json" \
|
||
-d "{\"image_ids\": [$IMG1, $IMG2], \"tag_name\": \"bulk_test_ephemeral\"}" \
|
||
| python -m json.tool
|
||
```
|
||
|
||
Expected: `ok=true`, `added_count=2` (or less if the images already had that tag), `tag.name="bulk test ephemeral"` (underscore stripped), `tag.kind="user"`.
|
||
|
||
Clean up:
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "
|
||
DELETE FROM image_tags WHERE tag_id IN (SELECT id FROM tag WHERE name = 'bulk test ephemeral');
|
||
DELETE FROM tag WHERE name = 'bulk test ephemeral';
|
||
"
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add app/main.py
|
||
git commit -m "refactor(main): bulk_add_tag uses parse_kind_prefix; bare storage
|
||
|
||
Mirrors add_tag's new shape. Removes inline prefix-parsing, returns
|
||
display_name in the response.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Rewrite `accept_image_suggestion` for 0/1/N candidate resolution
|
||
|
||
**Why:** WD14 emits bare names. Post-migration, `Tag.name` is also bare. A bare WD14 character suggestion ("Ash") may match zero, one, or multiple existing characters. This task implements the 409-conflict branch + `explicit_tag_id` disambiguation path.
|
||
|
||
**Files:**
|
||
- Modify: `app/main.py` (`accept_image_suggestion`, currently around line 767-860)
|
||
|
||
- [ ] **Step 1: Find the current accept_image_suggestion route**
|
||
|
||
```bash
|
||
grep -n "def accept_image_suggestion\|accept-suggestion\|/suggestions/accept" app/main.py
|
||
```
|
||
|
||
Locate the route and note its line range.
|
||
|
||
- [ ] **Step 2: Replace the function body**
|
||
|
||
Replace the entire function with:
|
||
|
||
```python
|
||
@main.post("/image/<int:image_id>/suggestions/accept")
|
||
def accept_image_suggestion(image_id):
|
||
"""
|
||
Accept a single tag suggestion for an image.
|
||
|
||
Post-refactor resolution:
|
||
- If `tag_id` is provided, attach that exact tag (used by the
|
||
frontend after the user resolves an ambiguous suggestion).
|
||
- Else for character category: search by (kind='character', name=bare).
|
||
0 matches -> create null-fandom tag. 1 match -> attach. N matches ->
|
||
return HTTP 409 with candidate list for the frontend to disambiguate.
|
||
- Else for fandom/general: find-or-create by (kind, name).
|
||
"""
|
||
from app.services.tag_suggestions import _WD14_CATEGORY_TO_KIND
|
||
|
||
data = request.get_json() or {}
|
||
category = data.get("category")
|
||
name = (data.get("name") or "").strip()
|
||
explicit_tag_id = data.get("tag_id")
|
||
|
||
img = ImageRecord.query.get_or_404(image_id)
|
||
kind = _WD14_CATEGORY_TO_KIND.get(category)
|
||
|
||
if explicit_tag_id:
|
||
tag = Tag.query.get(explicit_tag_id)
|
||
if tag is None:
|
||
return jsonify(ok=False, error="tag_not_found"), 404
|
||
elif kind == "character":
|
||
candidates = Tag.query.filter_by(kind="character", name=name).all()
|
||
if len(candidates) == 1:
|
||
tag = candidates[0]
|
||
elif len(candidates) == 0:
|
||
tag = Tag(kind="character", name=name, fandom_id=None)
|
||
db.session.add(tag)
|
||
db.session.flush()
|
||
else:
|
||
return jsonify(
|
||
ok=False,
|
||
error="ambiguous",
|
||
candidates=[
|
||
{
|
||
"id": c.id,
|
||
"display_name": c.display_name,
|
||
"fandom_id": c.fandom_id,
|
||
"fandom_name": c.fandom.name if c.fandom else None,
|
||
}
|
||
for c in candidates
|
||
],
|
||
), 409
|
||
else:
|
||
effective_kind = kind if kind else "user"
|
||
tag = Tag.query.filter_by(kind=effective_kind, name=name).first()
|
||
if tag is None:
|
||
tag = Tag(kind=effective_kind, name=name)
|
||
db.session.add(tag)
|
||
db.session.flush()
|
||
|
||
# Auto-apply the character's fandom if set.
|
||
fandom_tag = None
|
||
if tag.kind == "character" and tag.fandom_id:
|
||
fandom_tag = Tag.query.get(tag.fandom_id)
|
||
|
||
if tag not in img.tags:
|
||
img.tags.append(tag)
|
||
if fandom_tag and fandom_tag not in img.tags:
|
||
img.tags.append(fandom_tag)
|
||
|
||
db.session.commit()
|
||
return jsonify(
|
||
ok=True,
|
||
tag={
|
||
"id": tag.id,
|
||
"name": tag.name,
|
||
"display_name": tag.display_name,
|
||
"kind": tag.kind,
|
||
},
|
||
fandom_tag=(
|
||
{
|
||
"id": fandom_tag.id,
|
||
"name": fandom_tag.name,
|
||
"display_name": fandom_tag.display_name,
|
||
"kind": fandom_tag.kind,
|
||
}
|
||
if fandom_tag else None
|
||
),
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 3: Smoke-test the 0-candidate branch**
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "SELECT id FROM image_record LIMIT 1;"
|
||
IMG=<paste_id>
|
||
|
||
curl -sX POST "http://localhost:5000/image/$IMG/suggestions/accept" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"category": "character", "name": "Nonexistent Test Character"}' \
|
||
| python -m json.tool
|
||
```
|
||
|
||
Expected: `ok=true`, `tag.kind="character"`, `tag.name="Nonexistent Test Character"`, `tag.display_name` matches `tag.name` (fandom is null).
|
||
|
||
- [ ] **Step 4: Smoke-test the N-candidate (ambiguous) branch**
|
||
|
||
First create two same-name characters with different fandoms:
|
||
|
||
```bash
|
||
docker compose exec web flask shell -c "
|
||
from app.models import Tag
|
||
from app import db
|
||
f1 = Tag(kind='fandom', name='Ambig Fandom One')
|
||
f2 = Tag(kind='fandom', name='Ambig Fandom Two')
|
||
db.session.add_all([f1, f2]); db.session.flush()
|
||
c1 = Tag(kind='character', name='Ambig Test Char', fandom_id=f1.id)
|
||
c2 = Tag(kind='character', name='Ambig Test Char', fandom_id=f2.id)
|
||
db.session.add_all([c1, c2])
|
||
db.session.commit()
|
||
print(f'created chars {c1.id}, {c2.id} in fandoms {f1.id}, {f2.id}')
|
||
"
|
||
```
|
||
|
||
Then try to accept the ambiguous suggestion:
|
||
|
||
```bash
|
||
curl -sX POST "http://localhost:5000/image/$IMG/suggestions/accept" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"category": "character", "name": "Ambig Test Char"}' \
|
||
-w "\nHTTP: %{http_code}\n" \
|
||
| head -20
|
||
```
|
||
|
||
Expected: HTTP 409, JSON body with `error="ambiguous"` and a `candidates` array of two entries (one for each fandom).
|
||
|
||
Clean up:
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "
|
||
DELETE FROM image_tags WHERE tag_id IN (SELECT id FROM tag WHERE name LIKE '%Ambig%' OR name = 'Nonexistent Test Character');
|
||
DELETE FROM tag WHERE name LIKE '%Ambig%' OR name = 'Nonexistent Test Character';
|
||
"
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/main.py
|
||
git commit -m "refactor(main): accept_image_suggestion 0/1/N candidate resolution
|
||
|
||
Removes the ilike-suffix fallback. Bare-name character matches are
|
||
explicit: 0 -> create null-fandom, 1 -> attach, N -> 409 with candidate
|
||
list for frontend disambiguation. Accepts explicit tag_id to complete
|
||
the disambiguation round-trip.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Rewrite `/api/tags/search` to JOIN fandom and match on CONCAT display expression
|
||
|
||
**Why:** autocomplete typing "ruby rwby" should match the character "Ruby Rose (RWBY)". Post-refactor `tag.name` is bare, so SQL needs to JOIN the fandom tag and match against the concatenated display form.
|
||
|
||
**Files:**
|
||
- Modify: `app/main.py` (`search_tags`, around line 636-691)
|
||
|
||
- [ ] **Step 1: Replace the search_tags function**
|
||
|
||
Find `def search_tags():` and replace the body with:
|
||
|
||
```python
|
||
@main.get("/api/tags/search")
|
||
def search_tags():
|
||
"""
|
||
Search existing tags for autocomplete.
|
||
Query params:
|
||
- q: search query (matches against display_name, case-insensitive)
|
||
- limit: max results (default 10)
|
||
- exclude_kind: comma-separated list of tag kinds to exclude
|
||
- kind: filter to only include tags of this kind
|
||
"""
|
||
from sqlalchemy import case
|
||
|
||
query = request.args.get("q", "").strip().lower()
|
||
limit = request.args.get("limit", 10, type=int)
|
||
exclude_kind = request.args.get("exclude_kind", "").strip()
|
||
exclude_kinds = [k.strip() for k in exclude_kind.split(",") if k.strip()]
|
||
include_kind = request.args.get("kind", "").strip()
|
||
|
||
f = aliased(Tag)
|
||
display_expr = case(
|
||
(
|
||
and_(Tag.kind == "character", Tag.fandom_id.isnot(None)),
|
||
func.concat(Tag.name, " (", f.name, ")"),
|
||
),
|
||
else_=Tag.name,
|
||
)
|
||
|
||
base_query = (
|
||
db.session.query(Tag)
|
||
.outerjoin(f, f.id == Tag.fandom_id)
|
||
.options(joinedload(Tag.fandom))
|
||
)
|
||
if exclude_kinds:
|
||
base_query = base_query.filter(~Tag.kind.in_(exclude_kinds))
|
||
if include_kind:
|
||
base_query = base_query.filter(Tag.kind == include_kind)
|
||
|
||
if not query:
|
||
# No query: most-used tags.
|
||
base_query = base_query.join(image_tags, image_tags.c.tag_id == Tag.id).group_by(Tag.id, f.id)
|
||
tags = (
|
||
base_query
|
||
.order_by(func.count(image_tags.c.image_id).desc())
|
||
.limit(limit)
|
||
.all()
|
||
)
|
||
else:
|
||
base_query = base_query.filter(display_expr.ilike(f"%{query}%"))
|
||
tags = (
|
||
base_query
|
||
.order_by(display_expr)
|
||
.limit(limit)
|
||
.all()
|
||
)
|
||
|
||
return jsonify(tags=[
|
||
{
|
||
"id": t.id,
|
||
"name": t.name,
|
||
"display_name": t.display_name,
|
||
"kind": t.kind,
|
||
"fandom_id": t.fandom_id if t.kind == "character" else None,
|
||
"fandom_name": (t.fandom.name if t.fandom else None) if t.kind == "character" else None,
|
||
}
|
||
for t in tags
|
||
])
|
||
```
|
||
|
||
- [ ] **Step 2: Verify plain search works**
|
||
|
||
```bash
|
||
curl -s "http://localhost:5000/api/tags/search?q=rose" | python -m json.tool | head -30
|
||
```
|
||
|
||
Expected: JSON with a `tags` array. Any character named "Ruby Rose" should come back with `display_name="Ruby Rose (RWBY)"` (the fandom suffix present) and `name="Ruby Rose"` (bare).
|
||
|
||
- [ ] **Step 3: Verify fandom-scoped search works (used by the modal fandom picker)**
|
||
|
||
```bash
|
||
curl -s "http://localhost:5000/api/tags/search?q=&kind=fandom&limit=5" | python -m json.tool
|
||
```
|
||
|
||
Expected: JSON array of tags, each `kind="fandom"`. Names are bare (no `fandom:` prefix).
|
||
|
||
- [ ] **Step 4: Verify concat match finds characters by fandom suffix**
|
||
|
||
```bash
|
||
curl -s "http://localhost:5000/api/tags/search?q=rwby" | python -m json.tool | head -30
|
||
```
|
||
|
||
Expected: the query matches via the `CONCAT(name, ' (', f.name, ')')` expression — characters whose fandom is "RWBY" appear even though their bare `name` doesn't contain "rwby".
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/main.py
|
||
git commit -m "refactor(main): search_tags JOINs fandom; matches on display CONCAT
|
||
|
||
Post-refactor tag.name is bare. Autocomplete needs to match the
|
||
display form (name + ' (' + fandom.name + ')') so users can still
|
||
find 'Ruby Rose (RWBY)' by typing 'rwby'.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Gallery routes — switch from `?tag=<name>` to `?tag_id=<int>`
|
||
|
||
**Why:** URL routing is the last layer still keyed on the name string. Clean-break switch to tag_id removes every name-parsing branch from route handlers.
|
||
|
||
**Files:**
|
||
- Modify: `app/main.py` around line 112-175 (main gallery route) and line 267-285 (secondary gallery route)
|
||
|
||
- [ ] **Step 1: Read the current gallery route**
|
||
|
||
```bash
|
||
sed -n '100,180p' app/main.py
|
||
```
|
||
|
||
Note the existing variable names (`tag_name`, `active_tag_obj`, `q = ImageRecord.query...`).
|
||
|
||
- [ ] **Step 2: Replace the gallery route's filter logic**
|
||
|
||
Find the block starting near line 112 (`tag_name = request.args.get('tag')`). Replace the tag-filter section (from `tag_name = ...` through the series-tag if-block around line 170) with:
|
||
|
||
```python
|
||
tag_id = request.args.get('tag_id', type=int)
|
||
active_tag_obj = (
|
||
Tag.query.options(joinedload(Tag.fandom)).get(tag_id)
|
||
if tag_id else None
|
||
)
|
||
if active_tag_obj:
|
||
q = q.join(ImageRecord.tags).filter(Tag.id == active_tag_obj.id)
|
||
|
||
# Fetch post metadata if filtering by a post tag
|
||
post_metadata = None
|
||
if active_tag_obj and active_tag_obj.kind == 'post':
|
||
from app.models import PostMetadata
|
||
pm = PostMetadata.query.filter_by(tag_id=active_tag_obj.id).first()
|
||
if pm:
|
||
post_metadata = {
|
||
'platform': pm.platform,
|
||
'post_id': pm.post_id,
|
||
'artist': pm.artist,
|
||
'title': pm.title,
|
||
'description': pm.description,
|
||
'source_url': pm.source_url,
|
||
'attachment_count': pm.attachment_count,
|
||
'published_at': pm.published_at.isoformat() if pm.published_at else None,
|
||
}
|
||
|
||
# Fetch series info if filtering by a series tag
|
||
series_info = None
|
||
if active_tag_obj and active_tag_obj.kind == 'series':
|
||
page_count = SeriesPage.query.filter_by(series_tag_id=active_tag_obj.id).count()
|
||
series_info = {
|
||
'tag_id': active_tag_obj.id,
|
||
'display_name': active_tag_obj.display_name,
|
||
'page_count': page_count,
|
||
}
|
||
```
|
||
|
||
And in the template context (at the bottom of the route), make sure `active_tag_obj` is passed through (it likely is already) and that any `name` / `tag_name` local variable references have been removed.
|
||
|
||
- [ ] **Step 3: Also update the secondary route at line ~268**
|
||
|
||
```bash
|
||
sed -n '260,290p' app/main.py
|
||
```
|
||
|
||
Find the other `tag_name = request.args.get('tag')` occurrence and apply the same refactor:
|
||
|
||
```python
|
||
tag_id = request.args.get('tag_id', type=int)
|
||
active_tag_obj = (
|
||
Tag.query.options(joinedload(Tag.fandom)).get(tag_id)
|
||
if tag_id else None
|
||
)
|
||
if active_tag_obj:
|
||
q = q.join(ImageRecord.tags).filter(Tag.id == active_tag_obj.id)
|
||
```
|
||
|
||
Remove any further downstream code that used the old `tag_name` variable; replace references with `active_tag_obj.name` or `active_tag_obj.display_name` as appropriate.
|
||
|
||
- [ ] **Step 4: Verify gallery renders with `?tag_id=...`**
|
||
|
||
```bash
|
||
# Pick any existing tag
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "SELECT id, name, kind FROM tag WHERE kind = 'character' LIMIT 1;"
|
||
TAG_ID=<paste_id>
|
||
|
||
curl -s -o /dev/null -w "%{http_code}" "http://localhost:5000/?tag_id=$TAG_ID"
|
||
```
|
||
|
||
Expected: `200`.
|
||
|
||
- [ ] **Step 5: Verify old `?tag=<name>` URLs return no filter (silently ignored)**
|
||
|
||
```bash
|
||
curl -s -o /dev/null -w "%{http_code}" "http://localhost:5000/?tag=anything"
|
||
```
|
||
|
||
Expected: `200` — the old param is simply not consumed, so the gallery renders without any filter.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add app/main.py
|
||
git commit -m "refactor(main): gallery routes use ?tag_id=int
|
||
|
||
Drops the ?tag=<name> lookup. URLs become unambiguous (distinct
|
||
same-name characters each have their own id-keyed URL). Eager-loads
|
||
Tag.fandom for the active-tag header rendering.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Refactor `set_tag_fandom` to pure fandom_id update
|
||
|
||
**Why:** the function currently parses the name, renames the tag, and runs a retroactive backfill. Post-refactor none of that is needed — the display_name @property reflects fandom_id changes instantly.
|
||
|
||
**Files:**
|
||
- Modify: `app/main.py` (`set_tag_fandom`, around line 969-1057)
|
||
|
||
- [ ] **Step 1: Replace `set_tag_fandom` with the minimal version**
|
||
|
||
```python
|
||
@main.post("/api/tag/<int:tag_id>/set-fandom")
|
||
def set_tag_fandom(tag_id):
|
||
"""
|
||
Set or clear a character tag's fandom association.
|
||
|
||
Form params:
|
||
fandom_id: ID of an existing fandom tag (preferred).
|
||
fandom_name: Name to find/create a fandom tag (used if fandom_id
|
||
not given). If both omitted, the fandom is cleared.
|
||
"""
|
||
tag = Tag.query.get_or_404(tag_id)
|
||
if tag.kind != "character":
|
||
return jsonify(ok=False, error="Only character tags can have fandoms"), 400
|
||
|
||
fandom_id = request.form.get("fandom_id", type=int)
|
||
fandom_name = (request.form.get("fandom_name") or "").strip()
|
||
|
||
if fandom_id:
|
||
fandom_tag = Tag.query.filter_by(id=fandom_id, kind="fandom").first()
|
||
if fandom_tag is None:
|
||
return jsonify(ok=False, error="Invalid fandom tag"), 400
|
||
tag.fandom_id = fandom_tag.id
|
||
elif fandom_name:
|
||
fandom_tag = _ensure_fandom_tag(fandom_name)
|
||
tag.fandom_id = fandom_tag.id
|
||
else:
|
||
tag.fandom_id = None
|
||
fandom_tag = None
|
||
|
||
db.session.commit()
|
||
|
||
# Auto-apply the fandom tag to images already tagged with this character.
|
||
if fandom_tag is not None:
|
||
try:
|
||
db.session.execute(
|
||
text("""
|
||
INSERT INTO image_tags (image_id, tag_id)
|
||
SELECT it.image_id, :fandom_id
|
||
FROM image_tags it
|
||
WHERE it.tag_id = :char_id
|
||
ON CONFLICT DO NOTHING
|
||
"""),
|
||
{"char_id": tag.id, "fandom_id": fandom_tag.id},
|
||
)
|
||
db.session.commit()
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
current_app.logger.warning(
|
||
"set_tag_fandom auto-apply failed for tag %s -> fandom %s: %s",
|
||
tag.id, fandom_tag.id, e,
|
||
)
|
||
|
||
return jsonify(ok=True, tag={
|
||
"id": tag.id,
|
||
"name": tag.name,
|
||
"display_name": tag.display_name,
|
||
"kind": tag.kind,
|
||
"fandom_id": tag.fandom_id,
|
||
"fandom_name": fandom_tag.name if fandom_tag else None,
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Verify via curl**
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "
|
||
SELECT t.id, t.name, t.fandom_id
|
||
FROM tag t
|
||
WHERE t.kind = 'character'
|
||
ORDER BY t.id
|
||
LIMIT 1;
|
||
"
|
||
TAG_ID=<paste_character_id>
|
||
|
||
# Set fandom by name
|
||
curl -sX POST "http://localhost:5000/api/tag/$TAG_ID/set-fandom" \
|
||
-d "fandom_name=Test Fandom For Set" | python -m json.tool
|
||
```
|
||
|
||
Expected: JSON with `ok=true`, `display_name` showing `"(Test Fandom For Set)"` suffix, `fandom_id` populated.
|
||
|
||
Clear it:
|
||
|
||
```bash
|
||
curl -sX POST "http://localhost:5000/api/tag/$TAG_ID/set-fandom" \
|
||
-d "" | python -m json.tool
|
||
```
|
||
|
||
Expected: `ok=true`, `fandom_id: null`, `display_name` matches `name` (no suffix). Then clean up the ephemeral fandom:
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "
|
||
DELETE FROM tag WHERE kind='fandom' AND name='Test Fandom For Set';
|
||
"
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add app/main.py
|
||
git commit -m "refactor(main): set_tag_fandom is pure fandom_id update
|
||
|
||
No rename, no retroactive-backfill-in-second-txn. display_name @property
|
||
reflects the change instantly. The auto-apply side effect (attach fandom
|
||
tag to images with the character) is preserved but simplified.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Refactor `update_tag`, `get_tag`, `list_tags`, and remove `_parse_character_fandom`
|
||
|
||
**Why:** these three routes each contain `name.split(':', 1)` branches plus `_parse_character_fandom` calls. After the migration those branches are dead. Consolidating them removes the helper in one sweep.
|
||
|
||
**Files:**
|
||
- Modify: `app/main.py`:
|
||
- `_parse_character_fandom` at line 20-25 (delete)
|
||
- `get_tag` around line 952-966
|
||
- `list_tags` around line 695-760
|
||
- `update_tag` around line 1127-1234
|
||
- `delete_tag` around line 1235-1250 (inspect, likely unchanged)
|
||
|
||
- [ ] **Step 1: Delete `_parse_character_fandom`**
|
||
|
||
Remove lines 20-25 of `app/main.py`:
|
||
|
||
```python
|
||
def _parse_character_fandom(name_after_prefix):
|
||
"""Parse 'Name (Fandom)' -> ('Name', 'Fandom') or ('Name', None)."""
|
||
m = re.match(r'^(.+?)\s*\((.+)\)$', name_after_prefix)
|
||
if m:
|
||
return m.group(1).strip(), m.group(2).strip()
|
||
return name_after_prefix, None
|
||
```
|
||
|
||
Leave the `import re` at line 14 — it's used elsewhere. Check:
|
||
|
||
```bash
|
||
grep -n "re\." app/main.py | head -5
|
||
```
|
||
|
||
If `re.` appears elsewhere, keep the import. Otherwise remove it.
|
||
|
||
- [ ] **Step 2: Rewrite `get_tag`**
|
||
|
||
Find `def get_tag(tag_id):` (around line 953). Replace the function body:
|
||
|
||
```python
|
||
@main.get("/api/tag/<int:tag_id>")
|
||
def get_tag(tag_id):
|
||
"""Get a single tag's details."""
|
||
tag = Tag.query.options(joinedload(Tag.fandom)).get_or_404(tag_id)
|
||
result = {
|
||
"id": tag.id,
|
||
"name": tag.name,
|
||
"display_name": tag.display_name,
|
||
"kind": tag.kind,
|
||
"fandom_id": tag.fandom_id,
|
||
"fandom_name": tag.fandom.name if tag.fandom else None,
|
||
}
|
||
return jsonify(ok=True, tag=result)
|
||
```
|
||
|
||
- [ ] **Step 3: Rewrite `list_tags` (image tags)**
|
||
|
||
Find `def list_tags(image_id):` (around line 695). Replace:
|
||
|
||
```python
|
||
@main.get("/image/<int:image_id>/tags")
|
||
def list_tags(image_id):
|
||
img = ImageRecord.query.get_or_404(image_id)
|
||
tags_with_fandom = (
|
||
db.session.query(Tag)
|
||
.join(image_tags, image_tags.c.tag_id == Tag.id)
|
||
.filter(image_tags.c.image_id == image_id)
|
||
.options(joinedload(Tag.fandom))
|
||
.order_by(Tag.kind.asc(), Tag.name.asc())
|
||
.all()
|
||
)
|
||
return jsonify(tags=[
|
||
{
|
||
"id": t.id,
|
||
"name": t.name,
|
||
"display_name": t.display_name,
|
||
"kind": t.kind,
|
||
"fandom_id": t.fandom_id if t.kind == "character" else None,
|
||
"fandom_name": (t.fandom.name if t.fandom else None) if t.kind == "character" else None,
|
||
}
|
||
for t in tags_with_fandom
|
||
])
|
||
```
|
||
|
||
- [ ] **Step 4: Rewrite `update_tag`**
|
||
|
||
Find `def update_tag(tag_id):` (around line 1128). Replace the full function:
|
||
|
||
```python
|
||
@main.post("/api/tag/<int:tag_id>/update")
|
||
def update_tag(tag_id):
|
||
"""
|
||
Update a tag's name and/or kind.
|
||
|
||
For character tags: rename = update Tag.name (bare). Fandom association
|
||
is NOT changed here — use /api/tag/<id>/set-fandom for that.
|
||
|
||
If the new (name, kind [, fandom_id]) combination collides with an
|
||
existing tag, offer a merge (force_merge=true to commit).
|
||
"""
|
||
tag = Tag.query.get_or_404(tag_id)
|
||
|
||
new_name = (request.form.get("name") or "").strip()
|
||
new_kind = (request.form.get("kind") or "user").strip()
|
||
force_merge = request.form.get("merge") == "true"
|
||
|
||
if not new_name:
|
||
return jsonify(ok=False, error="Name is required"), 400
|
||
|
||
if new_kind in ("character", "fandom"):
|
||
new_name = normalize_display_name(new_name)
|
||
|
||
# Check for duplicates using the new uniqueness shape
|
||
if new_kind == "character":
|
||
existing_q = Tag.query.filter(
|
||
Tag.kind == "character",
|
||
Tag.name == new_name,
|
||
Tag.fandom_id.is_(tag.fandom_id) if tag.fandom_id is None
|
||
else Tag.fandom_id == tag.fandom_id,
|
||
Tag.id != tag_id,
|
||
)
|
||
else:
|
||
existing_q = Tag.query.filter(
|
||
Tag.kind == new_kind,
|
||
Tag.name == new_name,
|
||
Tag.id != tag_id,
|
||
)
|
||
existing = existing_q.first()
|
||
|
||
if existing:
|
||
if not force_merge:
|
||
return jsonify(
|
||
ok=False,
|
||
error="A tag with this name already exists",
|
||
can_merge=True,
|
||
target_tag={
|
||
"id": existing.id,
|
||
"name": existing.name,
|
||
"display_name": existing.display_name,
|
||
"kind": existing.kind,
|
||
},
|
||
), 409
|
||
|
||
# Merge: move all images from source tag to target tag
|
||
source_images = tag.images[:]
|
||
merged_count = 0
|
||
for img in source_images:
|
||
if existing not in img.tags:
|
||
img.tags.append(existing)
|
||
merged_count += 1
|
||
img.tags.remove(tag)
|
||
db.session.delete(tag)
|
||
db.session.commit()
|
||
return jsonify(ok=True, merged=True, merged_count=merged_count, tag={
|
||
"id": existing.id,
|
||
"name": existing.name,
|
||
"display_name": existing.display_name,
|
||
"kind": existing.kind,
|
||
})
|
||
|
||
# Plain rename
|
||
tag.name = new_name
|
||
tag.kind = new_kind
|
||
db.session.commit()
|
||
return jsonify(ok=True, tag={
|
||
"id": tag.id,
|
||
"name": tag.name,
|
||
"display_name": tag.display_name,
|
||
"kind": tag.kind,
|
||
"fandom_id": tag.fandom_id,
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 5: Verify via curl**
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "
|
||
SELECT id, name, kind FROM tag WHERE kind = 'user' LIMIT 1;
|
||
"
|
||
TAG_ID=<paste_id>
|
||
|
||
curl -s "http://localhost:5000/api/tag/$TAG_ID" | python -m json.tool
|
||
```
|
||
|
||
Expected: `ok=true`, `tag.display_name` = `tag.name` (user kind has no fandom), `tag.kind="user"`.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add app/main.py
|
||
git commit -m "refactor(main): simplify get_tag/list_tags/update_tag
|
||
|
||
Removes _parse_character_fandom (no longer needed — names are bare).
|
||
update_tag respects the new (name, kind[, fandom_id]) uniqueness shape.
|
||
get_tag and list_tags return display_name so clients render without
|
||
re-parsing.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Sweep remaining `name.split(':', 1)` sites in `app/main.py`
|
||
|
||
**Why:** individual call sites scattered around: at lines 173 (gallery header display), 487 (`_get_tags_with_previews`), 1204, 1214-1215, 1220, 2470 (series page rendering), and ~901-904 if any remain from earlier tasks.
|
||
|
||
**Files:**
|
||
- Modify: `app/main.py` — every remaining `name.split(':', 1)` occurrence
|
||
|
||
- [ ] **Step 1: Find the remaining occurrences**
|
||
|
||
```bash
|
||
grep -n "\.split(['\"]:['\"]" app/main.py
|
||
```
|
||
|
||
Skip any occurrence inside `app/utils/tag_prefix.py` (that's the parser's legitimate use). Every other occurrence must be replaced.
|
||
|
||
- [ ] **Step 2: Replace each occurrence**
|
||
|
||
For each match, apply one of these patterns:
|
||
|
||
| Context | Replace `t.name.split(':', 1)[1] if ':' in t.name else t.name` with |
|
||
|---|---|
|
||
| Rendering to a user (gallery header, template name, tag pill) | `t.display_name` |
|
||
| Extracting the "post-prefix" part when the full name still had a prefix | `t.name` (post-migration the prefix is gone) |
|
||
| Series page names (e.g. `app/main.py:2470`) | `t.name` |
|
||
| Fandom extraction in `search_tags` / `get_tag` / `list_tags` | Already done in prior tasks — delete leftover duplicate code if any. |
|
||
|
||
Concrete examples:
|
||
|
||
Line 173 (gallery route, filter header display):
|
||
```python
|
||
# BEFORE
|
||
'name': active_tag_obj.name.split(':', 1)[1] if ':' in active_tag_obj.name else active_tag_obj.name,
|
||
# AFTER
|
||
'name': active_tag_obj.display_name,
|
||
```
|
||
|
||
Line 487 (`_get_tags_with_previews`):
|
||
```python
|
||
# BEFORE
|
||
label = t.name.split(":", 1)[1] if (":" in t.name) else t.name
|
||
# AFTER
|
||
label = t.display_name
|
||
```
|
||
|
||
Line 2470 (reader series name):
|
||
```python
|
||
# BEFORE
|
||
series_name = tag.name.split(':', 1)[1] if ':' in tag.name else tag.name
|
||
# AFTER
|
||
series_name = tag.name # series tags are now bare
|
||
```
|
||
|
||
- [ ] **Step 3: Verify no straggler splits remain**
|
||
|
||
```bash
|
||
grep -n "\.split(['\"]:['\"]" app/main.py
|
||
```
|
||
|
||
Expected: only occurrences inside `add_tag` / `bulk_add_tag` bodies that call `parse_kind_prefix` (which itself uses `split(':', 1)` legitimately). Zero standalone `name.split(':', 1)[1]` expressions.
|
||
|
||
- [ ] **Step 4: Smoke-test the gallery by filter**
|
||
|
||
```bash
|
||
# Start web, visit http://localhost:5000/?tag_id=<any_character_id> in a browser
|
||
# Verify the header shows "Ruby Rose (RWBY)" style display text, not bare name.
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/main.py
|
||
git commit -m "refactor(main): sweep name.split(':', 1) -> display_name
|
||
|
||
Every standalone prefix-strip expression in app/main.py replaced with
|
||
tag.display_name (user-facing) or tag.name (identifier contexts). The
|
||
only remaining colon-splits are inside parse_kind_prefix and tag
|
||
suggestions canonicalization.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Rewrite artist/archive tag construction in `app/utils/image_importer.py`
|
||
|
||
**Why:** two sites build `f"archive:{artist}:{archive_name}"` names. Post-refactor those become `Tag(kind='archive', name=f"{artist}:{archive_name}")` with the inner `:` kept.
|
||
|
||
**Files:**
|
||
- Modify: `app/utils/image_importer.py` around line 961 (archive tag creation) and line 986 (prefix-scan)
|
||
|
||
- [ ] **Step 1: Read the current state**
|
||
|
||
```bash
|
||
sed -n '955,995p' app/utils/image_importer.py
|
||
```
|
||
|
||
- [ ] **Step 2: Update the archive tag construction**
|
||
|
||
Around line 961, replace the tag build:
|
||
|
||
```python
|
||
# BEFORE
|
||
base_tag = f"archive:{artist}:{archive_name}"
|
||
existing = Tag.query.filter_by(name=base_tag, kind='archive').first()
|
||
|
||
# AFTER
|
||
base_name = f"{artist}:{archive_name}"
|
||
existing = Tag.query.filter_by(kind='archive', name=base_name).first()
|
||
```
|
||
|
||
Update any subsequent references to `base_tag` in the surrounding block to use `base_name`.
|
||
|
||
Around line 972, replace the dedup loop:
|
||
|
||
```python
|
||
# BEFORE
|
||
if not Tag.query.filter_by(name=candidate, kind='archive').first():
|
||
|
||
# AFTER
|
||
if not Tag.query.filter_by(kind='archive', name=candidate).first():
|
||
```
|
||
|
||
Where `candidate` is built, strip the `archive:` prefix:
|
||
|
||
```bash
|
||
sed -n '965,985p' app/utils/image_importer.py
|
||
```
|
||
|
||
Find whatever builds `candidate` and remove the `archive:` prefix (candidate becomes `f"{artist}:{archive_name} ({i})"` or similar).
|
||
|
||
- [ ] **Step 3: Update the prefix-scan at line 986**
|
||
|
||
```python
|
||
# BEFORE
|
||
prefix = f"archive:{artist}/"
|
||
# ... Tag.query ... Tag.name.ilike(f"{prefix}%")
|
||
|
||
# AFTER (the ILIKE stays, just the prefix string changes)
|
||
prefix = f"{artist}/"
|
||
# ... Tag.query.filter(Tag.kind == 'archive', Tag.name.ilike(f"{prefix}%"))
|
||
```
|
||
|
||
- [ ] **Step 4: Verify by importing a dummy archive (optional — skip if no archive test data available)**
|
||
|
||
This path only runs during archive imports. If you have a ZIP/CBZ in your import queue, run:
|
||
|
||
```bash
|
||
docker compose logs -f celery-worker-import &
|
||
# Move a test archive into the import directory and wait for processing
|
||
```
|
||
|
||
Expected: any archive tag created in logs shows as `Tag(kind='archive', name='{artist}:{archive_name}')` — no `archive:` prefix in name.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/utils/image_importer.py
|
||
git commit -m "refactor(importer): archive tags drop kind prefix
|
||
|
||
Archive identifier string (artist:archive_name) stays as the tag's
|
||
opaque name — the archive kind lives in the kind column.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Update post tag construction in `app/utils/metadata_enrichment.py`
|
||
|
||
**Why:** the one site that builds `f"post:{platform}:{safe_artist}:{post_id}"`. Drop the outer `post:` prefix.
|
||
|
||
**Files:**
|
||
- Modify: `app/utils/metadata_enrichment.py` around line 472
|
||
|
||
- [ ] **Step 1: Read surrounding context**
|
||
|
||
```bash
|
||
sed -n '465,490p' app/utils/metadata_enrichment.py
|
||
```
|
||
|
||
- [ ] **Step 2: Strip the outer `post:` prefix**
|
||
|
||
Replace:
|
||
|
||
```python
|
||
"name": f"post:{platform}:{safe_artist}:{post_id}",
|
||
```
|
||
|
||
with:
|
||
|
||
```python
|
||
"name": f"{platform}:{safe_artist}:{post_id}",
|
||
```
|
||
|
||
Also verify that `kind: 'post'` is set elsewhere in this data structure (grep for `'kind':` / `"kind":` in the same function). If not, add it.
|
||
|
||
- [ ] **Step 3: Verify no other post tag construction is still prefix-based**
|
||
|
||
```bash
|
||
grep -n "post:" app/utils/metadata_enrichment.py
|
||
```
|
||
|
||
Expected: only references inside docstrings/comments. Any remaining `f"post:..."` is a bug.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add app/utils/metadata_enrichment.py
|
||
git commit -m "refactor(enrichment): post tag name omits post: prefix
|
||
|
||
The inner platform:artist:id identifier remains as opaque tag.name.
|
||
Kind column carries 'post'.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: Update artist tag construction in `app/tasks/import_file.py`
|
||
|
||
**Why:** two sites (lines ~508, ~517, ~813) build `f"artist:{artist}"` or `f"archive:..."` names.
|
||
|
||
**Files:**
|
||
- Modify: `app/tasks/import_file.py`
|
||
|
||
- [ ] **Step 1: Grep for `f"artist:"`, `f"archive:"`, or any `f"{kind}:"` pattern**
|
||
|
||
```bash
|
||
grep -n "f\"artist:\|f\"archive:\|f\"fandom:\|f\"series:\|f\"post:\|f\"character:" app/tasks/import_file.py
|
||
```
|
||
|
||
- [ ] **Step 2: Strip prefixes at each match**
|
||
|
||
For each occurrence, replace `f"{kind}:{value}"` with the bare value, and ensure the `Tag(kind=..., name=...)` construction is passing `kind` explicitly.
|
||
|
||
Example (line ~813):
|
||
|
||
```python
|
||
# BEFORE
|
||
tag_name = f"artist:{artist}"
|
||
...
|
||
tag = Tag(name=tag_name, kind='artist')
|
||
|
||
# AFTER
|
||
tag_name = artist
|
||
tag = Tag(name=tag_name, kind='artist')
|
||
```
|
||
|
||
Also update any `Tag.query.filter_by(name=...)` lookups in the same block to filter by `(kind, name)`:
|
||
|
||
```python
|
||
# BEFORE
|
||
tag = Tag.query.filter_by(name=tag_name).first()
|
||
|
||
# AFTER
|
||
tag = Tag.query.filter_by(kind='artist', name=tag_name).first()
|
||
```
|
||
|
||
- [ ] **Step 3: Verify via a dry-run import (skip if no test image)**
|
||
|
||
```bash
|
||
# Check that the current imports are still creating tags correctly:
|
||
docker compose logs -f celery-worker-import
|
||
# Drop a test image into the import directory and tail logs for any crashes
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add app/tasks/import_file.py
|
||
git commit -m "refactor(import): drop artist:/archive: prefix from constructed tag names
|
||
|
||
Tag.name holds the bare identifier; Tag.kind already carried the role.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: Clean up `app/services/tag_suggestions.py`
|
||
|
||
**Why:** `_FANDOM_SUFFIX_RE`, `_fandom_suffix_prefixes`, and the `applied_fandom_prefixes` branch in `_wd14_suggestions` are all band-aids for the old naming kludge. Post-refactor they're dead.
|
||
|
||
**Files:**
|
||
- Modify: `app/services/tag_suggestions.py`
|
||
|
||
- [ ] **Step 1: Remove `_FANDOM_SUFFIX_RE` and `_fandom_suffix_prefixes`**
|
||
|
||
Find and delete:
|
||
|
||
```python
|
||
# Matches `Name (Fandom)` — used to recognize that an applied character/copyright
|
||
# tag covers a bare WD14 suggestion like `Name`.
|
||
_FANDOM_SUFFIX_RE = re.compile(r'^(.+?) \([^()]+\)$')
|
||
|
||
|
||
def _fandom_suffix_prefixes(names: Iterable[str]) -> set[str]:
|
||
"""Extract the `Name` portion from any `Name (Fandom)` entries in `names`."""
|
||
out: set[str] = set()
|
||
for n in names:
|
||
m = _FANDOM_SUFFIX_RE.match(n)
|
||
if m:
|
||
out.add(m.group(1))
|
||
return out
|
||
```
|
||
|
||
- [ ] **Step 2: Simplify `_wd14_suggestions`**
|
||
|
||
Find `_wd14_suggestions(image_id, cfg, already)`. Remove the `applied_fandom_prefixes` computation and the related skip branch:
|
||
|
||
```python
|
||
# REMOVE these lines (around lines 133-136 and 145-146 of the pre-refactor file):
|
||
applied_fandom_prefixes = _fandom_suffix_prefixes(already)
|
||
...
|
||
if p.tag_category in ('character', 'copyright') and canonical in applied_fandom_prefixes:
|
||
continue
|
||
```
|
||
|
||
The rest of `_wd14_suggestions` stays as-is.
|
||
|
||
- [ ] **Step 3: Simplify `_canonicalize_wd14_name`**
|
||
|
||
Currently (lines 41-54) it has kind-aware branching for prefix preservation. Post-refactor the function just normalizes names for character/fandom and strips underscores elsewhere:
|
||
|
||
```python
|
||
def _canonicalize_wd14_name(raw: str, category: str) -> str:
|
||
"""Rewrite a raw WD14 tag name to the canonical form ImageRepo persists.
|
||
|
||
- character / copyright: title-case each word.
|
||
- everything else: underscore-to-space, preserving user casing.
|
||
"""
|
||
from app.utils.tag_names import normalize_display_name, underscores_to_spaces
|
||
kind = _WD14_CATEGORY_TO_KIND.get(category)
|
||
if kind in ('character', 'fandom'):
|
||
return normalize_display_name(raw)
|
||
return underscores_to_spaces(raw)
|
||
```
|
||
|
||
Notice: the old code checked for `:` in the raw string and preserved the `kind:` prefix. Post-refactor WD14 emits bare names and `Tag.name` stores them bare, so there's no prefix to preserve.
|
||
|
||
- [ ] **Step 4: Remove the `import re` if no other module-level regex remains**
|
||
|
||
```bash
|
||
grep -n "^import re\|^from re" app/services/tag_suggestions.py
|
||
grep -n "\bre\." app/services/tag_suggestions.py
|
||
```
|
||
|
||
If `re.` is not called anywhere in the file after removing `_FANDOM_SUFFIX_RE`, remove the `import re` statement at the top.
|
||
|
||
- [ ] **Step 5: Smoke-test suggestions for any image**
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "
|
||
SELECT image_id, COUNT(*)
|
||
FROM image_tag_prediction
|
||
GROUP BY image_id
|
||
ORDER BY COUNT(*) DESC
|
||
LIMIT 1;
|
||
"
|
||
IMG=<paste_image_id>
|
||
|
||
curl -s "http://localhost:5000/image/$IMG/suggestions" | python -m json.tool | head -30
|
||
```
|
||
|
||
Expected: a JSON with `character`/`copyright`/`general` lists, no exceptions. Each item has bare `name` (no prefix).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add app/services/tag_suggestions.py
|
||
git commit -m "refactor(suggestions): remove name-parsing band-aids
|
||
|
||
_FANDOM_SUFFIX_RE, _fandom_suffix_prefixes, and the prefix-suppression
|
||
branch in _wd14_suggestions existed to work around the string-embedded
|
||
(Fandom) kludge. The bare-name refactor makes them all dead code.
|
||
_canonicalize_wd14_name simplifies to a single normalize-or-underscore
|
||
swap since no prefix preservation is needed.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: Delete the `sync_character_fandoms` Celery task and its settings button
|
||
|
||
**Why:** the task was a periodic cleanup for drift between characters and their fandoms. Post-refactor, `set_tag_fandom` + `add_tag` both auto-apply the fandom tag inline, and the migration's report surfaces any remaining inconsistencies. Delete the task, its route, and the settings UI that triggered it.
|
||
|
||
**Files:**
|
||
- Modify: `app/tasks/maintenance.py` (empty the body, keep the file for git history)
|
||
- Modify: `app/celery_app.py` (remove the task routing entry)
|
||
- Modify: `app/main.py` (delete `trigger_sync_character_fandoms` route at line 625-629)
|
||
- Modify: `app/templates/settings.html` (remove the maintenance button/form at line 464)
|
||
|
||
- [ ] **Step 1: Empty `app/tasks/maintenance.py`**
|
||
|
||
Replace the file contents with a stub:
|
||
|
||
```python
|
||
"""Maintenance-queue Celery tasks. Currently empty — the
|
||
sync_character_fandoms task was removed on 2026-04-21 as part of the
|
||
bare-name refactor (tag.fandom_id is now authoritative; no drift to sync).
|
||
"""
|
||
```
|
||
|
||
- [ ] **Step 2: Remove the task routing entry**
|
||
|
||
```bash
|
||
grep -n "sync_character_fandoms" app/celery_app.py
|
||
```
|
||
|
||
Find and delete the line `'app.tasks.maintenance.sync_character_fandoms': {'queue': 'maintenance'},` at line 74.
|
||
|
||
- [ ] **Step 3: Delete the `trigger_sync_character_fandoms` route**
|
||
|
||
In `app/main.py`, delete the block at around line 625-629:
|
||
|
||
```python
|
||
@main.post('/settings/maintenance/sync-character-fandoms')
|
||
def trigger_sync_character_fandoms():
|
||
from app.tasks.maintenance import sync_character_fandoms
|
||
sync_character_fandoms.apply_async(queue='maintenance')
|
||
return redirect(url_for('main.settings', tab='maintenance'))
|
||
```
|
||
|
||
- [ ] **Step 4: Remove the settings button**
|
||
|
||
```bash
|
||
grep -n "sync-character-fandoms\|sync_character_fandoms" app/templates/settings.html
|
||
```
|
||
|
||
Delete the `<form>` block (around line 464) that posts to `trigger_sync_character_fandoms`. Keep any surrounding `<h3>` or section wrapper only if it has other maintenance actions; otherwise remove the whole section.
|
||
|
||
- [ ] **Step 5: Verify the app still boots**
|
||
|
||
```bash
|
||
docker compose restart web celery-worker-maintenance
|
||
docker compose logs web --tail 30
|
||
docker compose logs celery-worker-maintenance --tail 30
|
||
```
|
||
|
||
Expected: no ImportError, no NameError. Visit `http://localhost:5000/settings` — the page loads and the maintenance tab doesn't crash.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add app/tasks/maintenance.py app/celery_app.py app/main.py app/templates/settings.html
|
||
git commit -m "chore: remove sync_character_fandoms maintenance task
|
||
|
||
tag.fandom_id is now authoritative. set_tag_fandom auto-applies the
|
||
fandom tag inline; add_tag already did. There's no drift to sync on
|
||
a schedule anymore.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: Template sweep — `_gallery_item.html` emoji dict + display_name
|
||
|
||
**Why:** the six-branch prefix-strip expression is a DRY violation that duplicates `tag.name.split(':', 1)[1]` six times. Replace with a single KIND_EMOJI dict + `t.display_name`.
|
||
|
||
**Files:**
|
||
- Modify: `app/templates/_gallery_item.html`
|
||
|
||
- [ ] **Step 1: Read the current template**
|
||
|
||
```bash
|
||
cat app/templates/_gallery_item.html
|
||
```
|
||
|
||
- [ ] **Step 2: Replace the tag rendering loop**
|
||
|
||
Find the block containing the six emoji/split branches (lines 16-32 ish). Replace the loop body with:
|
||
|
||
```jinja
|
||
{% set KIND_EMOJI = {
|
||
'artist': '🎨',
|
||
'character': '👤',
|
||
'series': '📺',
|
||
'fandom': '🎭',
|
||
'meta': '⚠️',
|
||
'post': '📌',
|
||
'archive': '📦',
|
||
'user': '🏷️',
|
||
} %}
|
||
{% for t in image.tags %}
|
||
<a href="{{ url_for('main.gallery', tag_id=t.id) }}"
|
||
class="tag-chip tag-chip-{{ t.kind or 'user' }}">
|
||
{{ KIND_EMOJI.get(t.kind, '') }} {{ t.display_name }}
|
||
</a>
|
||
{% endfor %}
|
||
```
|
||
|
||
(Preserve any wrapping `<div>` or other class names that were already present — you're only swapping the inner loop body.)
|
||
|
||
- [ ] **Step 3: Verify gallery thumbnails render correctly**
|
||
|
||
```bash
|
||
# In browser
|
||
open http://localhost:5000/
|
||
```
|
||
|
||
Expected: thumbnails show tag chips with the right emoji per kind and the right display text. Character tags that have a fandom show as "Ruby Rose (RWBY)"; others bare.
|
||
|
||
- [ ] **Step 4: Verify anchor links use tag_id**
|
||
|
||
View page source, search for `href="/?tag_id=`. Expected: all tag chip anchors use that form, not `?tag=`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/templates/_gallery_item.html
|
||
git commit -m "refactor(templates): _gallery_item.html KIND_EMOJI dict + display_name
|
||
|
||
Collapses the six-branch prefix-strip expression into a KIND_EMOJI
|
||
jinja dict. Anchor hrefs use ?tag_id=.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 20: Template sweep — `_tag_cards.html`, `reader.html`
|
||
|
||
**Why:** remaining templates that render tag names in anchors or labels.
|
||
|
||
**Files:**
|
||
- Modify: `app/templates/_tag_cards.html`
|
||
- Modify: `app/templates/reader.html`
|
||
|
||
- [ ] **Step 1: Read and update `_tag_cards.html`**
|
||
|
||
```bash
|
||
cat app/templates/_tag_cards.html
|
||
```
|
||
|
||
Find any `{{ tag.name }}` rendering for user display — replace with `{{ tag.display_name }}`. Find `url_for('main.gallery', tag=tag.tag)` → change to `url_for('main.gallery', tag_id=tag.id)`.
|
||
|
||
Since `_tag_cards.html` consumes `tag_data` dicts from `_get_tags_with_previews`, also ensure those dicts include `display_name` and `id`. Verify by checking `app/main.py:488-494`:
|
||
|
||
```python
|
||
tag_map[t.id] = {
|
||
"id": t.id,
|
||
"name": label, # from t.display_name now (Task 13)
|
||
"tag": t.name, # keep as the bare storage name
|
||
"images": [],
|
||
"kind": t.kind,
|
||
"count": count
|
||
}
|
||
```
|
||
|
||
If `label` is already set to `t.display_name` from Task 13, then `tag_data[i]['name']` in `_tag_cards.html` is already the display form. The template change is:
|
||
|
||
```jinja
|
||
<a href="{{ url_for('main.gallery', tag_id=tag.id) }}" class="tag-card-link">
|
||
```
|
||
|
||
(instead of `tag=tag.tag`).
|
||
|
||
- [ ] **Step 2: Read and update `reader.html`**
|
||
|
||
```bash
|
||
grep -n "url_for.*main\.gallery\|tag\.name\|t\.name" app/templates/reader.html
|
||
```
|
||
|
||
Replace each:
|
||
|
||
- `url_for('main.gallery', tag=tag.name)` → `url_for('main.gallery', tag_id=tag.id)`
|
||
- `{{ tag.name }}` rendering for user display → `{{ tag.display_name }}`
|
||
|
||
- [ ] **Step 3: Verify reader page still loads and links work**
|
||
|
||
```bash
|
||
# Pick a series tag
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "
|
||
SELECT id, name FROM tag WHERE kind = 'series' LIMIT 1;
|
||
"
|
||
SERIES_ID=<paste_id>
|
||
|
||
curl -s -o /dev/null -w "%{http_code}" "http://localhost:5000/read/$SERIES_ID"
|
||
```
|
||
|
||
Expected: `200`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add app/templates/_tag_cards.html app/templates/reader.html
|
||
git commit -m "refactor(templates): _tag_cards.html and reader.html use display_name + tag_id
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 21: Template sweep — `tags_list.html` (+ fandom-less character nudges)
|
||
|
||
**Why:** combines two things: the name-display sweep + the new `kind='character' AND fandom_id IS NULL` nudges (chip + header counter).
|
||
|
||
**Files:**
|
||
- Modify: `app/main.py` (`tag_list` and `api_tags_list` routes — compute `null_fandom_character_count`)
|
||
- Modify: `app/templates/tags_list.html`
|
||
|
||
- [ ] **Step 1: Update `_get_tags_with_previews` to include `fandom_id` in the returned dict**
|
||
|
||
```bash
|
||
sed -n '483,495p' app/main.py
|
||
```
|
||
|
||
In the `tag_map` dict (line ~488-495), add `fandom_id`:
|
||
|
||
```python
|
||
tag_map[t.id] = {
|
||
"id": t.id,
|
||
"name": t.display_name,
|
||
"tag": t.name,
|
||
"images": [],
|
||
"kind": t.kind,
|
||
"fandom_id": t.fandom_id,
|
||
"count": count
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Compute the fandom-less character counter in `tag_list`**
|
||
|
||
Find `def tag_list():` (around line 533). Before `return render_template(...)`, compute:
|
||
|
||
```python
|
||
null_fandom_character_count = (
|
||
db.session.query(func.count(Tag.id))
|
||
.filter(Tag.kind == 'character', Tag.fandom_id.is_(None))
|
||
.scalar() or 0
|
||
)
|
||
```
|
||
|
||
Pass it to the template context:
|
||
|
||
```python
|
||
return render_template('tags_list.html',
|
||
tag_data=tag_data,
|
||
images_per_tag=images_per_tag,
|
||
active_kind=kind,
|
||
search_query=search_query,
|
||
total_count=total_count,
|
||
has_more=has_more,
|
||
page_size=limit,
|
||
null_fandom_character_count=null_fandom_character_count,
|
||
show_null_fandom_filter=request.args.get('null_fandom') == '1')
|
||
```
|
||
|
||
Also accept a new `?null_fandom=1` filter at the top of `tag_list`:
|
||
|
||
```python
|
||
null_fandom_only = request.args.get('null_fandom') == '1'
|
||
```
|
||
|
||
And extend `_get_tags_with_previews` to accept a `null_fandom_only: bool = False` param that adds `.filter(Tag.kind == 'character', Tag.fandom_id.is_(None))` when True.
|
||
|
||
Update `_get_tags_with_previews` signature:
|
||
|
||
```python
|
||
def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3, search=None, null_fandom_only=False):
|
||
...
|
||
count_q = db.session.query(func.count(Tag.id))
|
||
if kind:
|
||
count_q = count_q.filter(Tag.kind == kind)
|
||
if search:
|
||
count_q = count_q.filter(Tag.name.ilike(f'%{search}%'))
|
||
if null_fandom_only:
|
||
count_q = count_q.filter(Tag.kind == 'character', Tag.fandom_id.is_(None))
|
||
total_count = count_q.scalar()
|
||
...
|
||
q = db.session.query(Tag, func.count(image_tags.c.image_id).label('img_count')).outerjoin(image_tags).group_by(Tag.id)
|
||
if kind:
|
||
q = q.filter(Tag.kind == kind)
|
||
if search:
|
||
q = q.filter(Tag.name.ilike(f'%{search}%'))
|
||
if null_fandom_only:
|
||
q = q.filter(Tag.kind == 'character', Tag.fandom_id.is_(None))
|
||
...
|
||
```
|
||
|
||
Pass the `null_fandom_only` flag from `tag_list` and `api_tags_list` through to the helper.
|
||
|
||
- [ ] **Step 3: Update `tags_list.html` — header counter + chip**
|
||
|
||
Open `app/templates/tags_list.html`. Near the top of the page (after the kind-filter tabs), add a header link when the counter is > 0 and the filter isn't already active:
|
||
|
||
```jinja
|
||
{% if null_fandom_character_count > 0 and not show_null_fandom_filter %}
|
||
<a href="{{ url_for('main.tag_list', kind='character', null_fandom=1) }}"
|
||
class="null-fandom-counter-link">
|
||
⚠ {{ null_fandom_character_count }} character{{ 's' if null_fandom_character_count != 1 }} need a fandom
|
||
</a>
|
||
{% endif %}
|
||
|
||
{% if show_null_fandom_filter %}
|
||
<div class="null-fandom-filter-active">
|
||
Showing characters without a fandom.
|
||
<a href="{{ url_for('main.tag_list', kind='character') }}">Clear filter</a>
|
||
</div>
|
||
{% endif %}
|
||
```
|
||
|
||
And in the tag card rendering loop, add a chip for character tags with null fandom_id (only if not already in the `null_fandom_only` mode — chip is redundant there):
|
||
|
||
```jinja
|
||
{% if tag.kind == 'character' and tag.fandom_id is none and not show_null_fandom_filter %}
|
||
<span class="character-no-fandom-chip" title="This character has no fandom — click to assign">
|
||
⚠ No fandom
|
||
</span>
|
||
{% endif %}
|
||
```
|
||
|
||
(Place this inside the existing tag card markup, next to the tag name.)
|
||
|
||
- [ ] **Step 4: Add CSS for the new elements**
|
||
|
||
Append to `app/static/style.css`:
|
||
|
||
```css
|
||
.null-fandom-counter-link {
|
||
display: inline-block;
|
||
padding: 0.35rem 0.75rem;
|
||
margin: 0.5rem 0;
|
||
background: rgba(255, 180, 0, 0.12);
|
||
border: 1px solid rgba(255, 180, 0, 0.3);
|
||
border-radius: 0.25rem;
|
||
color: #ffb400;
|
||
text-decoration: none;
|
||
font-size: 0.9rem;
|
||
}
|
||
.null-fandom-counter-link:hover {
|
||
background: rgba(255, 180, 0, 0.2);
|
||
}
|
||
|
||
.null-fandom-filter-active {
|
||
padding: 0.5rem 0.75rem;
|
||
margin: 0.5rem 0;
|
||
background: rgba(255, 180, 0, 0.08);
|
||
border-left: 3px solid #ffb400;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.character-no-fandom-chip {
|
||
display: inline-block;
|
||
padding: 0.1rem 0.4rem;
|
||
margin-left: 0.5rem;
|
||
font-size: 0.75rem;
|
||
background: rgba(255, 180, 0, 0.15);
|
||
color: #ffb400;
|
||
border-radius: 0.2rem;
|
||
cursor: help;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Verify**
|
||
|
||
```bash
|
||
curl -s "http://localhost:5000/tags?kind=character" | grep -E "No fandom|need a fandom" | head -5
|
||
```
|
||
|
||
If there are any characters without fandoms in the DB, expected: one or more matches. Then visit the page in a browser to confirm chip placement and styling.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add app/main.py app/templates/tags_list.html app/static/style.css
|
||
git commit -m "feat(tags): fandom-less character nudges
|
||
|
||
Adds a header counter (⚠ N characters need a fandom) that filters to
|
||
?null_fandom=1, plus an inline chip on each qualifying character card.
|
||
Backend accepts null_fandom_only in the list query.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 22: Fandom picker — modal HTML + CSS
|
||
|
||
**Why:** the inline reveal area below the modal's add-tag input, for assigning a fandom during character creation.
|
||
|
||
**Files:**
|
||
- Modify: `app/templates/_gallery_modal.html`
|
||
- Modify: `app/static/style.css`
|
||
|
||
- [ ] **Step 1: Add the HTML block to `_gallery_modal.html`**
|
||
|
||
Open `app/templates/_gallery_modal.html` and locate the `<form id="modalTagForm">` block inside `.modal-tags-section`. Directly after the closing `</form>` and before `<div id="tagAutocomplete">`, insert:
|
||
|
||
```html
|
||
<div id="fandomPickerWrapper" class="fandom-picker-wrapper" aria-hidden="true">
|
||
<label class="fandom-picker-label" for="fandomPickerInput">Fandom</label>
|
||
<div class="fandom-picker-input-row">
|
||
<input type="text" id="fandomPickerInput" class="fandom-picker-input"
|
||
placeholder="Pick or create fandom (optional)" autocomplete="off">
|
||
<button type="button" id="fandomPickerClear" class="btn-small"
|
||
aria-label="Clear fandom" title="Clear fandom">×</button>
|
||
</div>
|
||
<div id="fandomPickerAutocomplete"
|
||
class="tag-autocomplete tag-autocomplete-inline" aria-live="polite"></div>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 2: Append CSS to `app/static/style.css`**
|
||
|
||
```css
|
||
.fandom-picker-wrapper {
|
||
max-height: 0;
|
||
overflow: hidden;
|
||
opacity: 0;
|
||
transition: max-height 180ms ease-out, opacity 180ms ease-out;
|
||
margin-top: 0;
|
||
}
|
||
.fandom-picker-wrapper.visible {
|
||
max-height: 16rem;
|
||
opacity: 1;
|
||
margin-top: 0.5rem;
|
||
}
|
||
.fandom-picker-label {
|
||
display: block;
|
||
font-size: 0.85rem;
|
||
color: var(--muted, #888);
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
.fandom-picker-input-row {
|
||
display: flex;
|
||
gap: 0.25rem;
|
||
align-items: center;
|
||
}
|
||
.fandom-picker-input {
|
||
flex: 1;
|
||
padding: 0.4rem 0.6rem;
|
||
background: var(--input-bg, #111);
|
||
border: 1px solid var(--input-border, #333);
|
||
border-radius: 0.25rem;
|
||
color: var(--fg, #eee);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Visual smoke-test in the browser**
|
||
|
||
```bash
|
||
open http://localhost:5000/
|
||
```
|
||
|
||
Open any image in the modal. At this stage, the picker wrapper is present in the DOM but never becomes visible (no JS yet). Inspect with devtools:
|
||
|
||
```js
|
||
// In browser console
|
||
document.getElementById('fandomPickerWrapper')
|
||
```
|
||
|
||
Expected: the element exists and has `aria-hidden="true"`. No visual effect yet.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add app/templates/_gallery_modal.html app/static/style.css
|
||
git commit -m "feat(modal): fandom picker markup + styles
|
||
|
||
HTML/CSS for the inline fandom picker that slides in below the add-tag
|
||
input. JS wiring in the next commit.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 23: Fandom picker — JavaScript wiring
|
||
|
||
**Why:** make the picker reveal, autocomplete, and submit work.
|
||
|
||
**Files:**
|
||
- Modify: `app/static/js/view-modal.js`
|
||
|
||
- [ ] **Step 1: Locate the modal init block**
|
||
|
||
```bash
|
||
grep -n "tagInput\|tagForm\|modalTagForm" app/static/js/view-modal.js | head -20
|
||
```
|
||
|
||
Find where `tagInput` is acquired from the DOM (via `document.getElementById('tagInput')` or similar).
|
||
|
||
- [ ] **Step 2: Add element references and state near the top of the module**
|
||
|
||
Near the other `document.getElementById(...)` calls at the top of `view-modal.js`, add:
|
||
|
||
```js
|
||
const fandomPickerWrapper = document.getElementById('fandomPickerWrapper');
|
||
const fandomPickerInput = document.getElementById('fandomPickerInput');
|
||
const fandomPickerAutocomplete = document.getElementById('fandomPickerAutocomplete');
|
||
const fandomPickerClear = document.getElementById('fandomPickerClear');
|
||
```
|
||
|
||
- [ ] **Step 3: Wire up the reveal + clear handlers**
|
||
|
||
Inside the modal init function (wherever `tagInput` gets its other listeners wired), add:
|
||
|
||
```js
|
||
function clearFandomPicker() {
|
||
if (!fandomPickerInput) return;
|
||
fandomPickerInput.value = '';
|
||
delete fandomPickerInput.dataset.fandomId;
|
||
clearFandomAutocomplete();
|
||
}
|
||
|
||
function clearFandomAutocomplete() {
|
||
if (fandomPickerAutocomplete) fandomPickerAutocomplete.innerHTML = '';
|
||
}
|
||
|
||
function renderFandomAutocomplete(rows) {
|
||
if (!fandomPickerAutocomplete) return;
|
||
if (!rows || rows.length === 0) {
|
||
fandomPickerAutocomplete.innerHTML = '';
|
||
return;
|
||
}
|
||
fandomPickerAutocomplete.innerHTML = rows.map(r => `
|
||
<div class="tag-autocomplete-item" data-fandom-id="${r.id}" data-fandom-name="${r.name}">
|
||
${r.display_name}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
tagInput.addEventListener('input', () => {
|
||
const lower = (tagInput.value || '').toLowerCase();
|
||
const isCharacter = lower.startsWith('character:');
|
||
if (fandomPickerWrapper) {
|
||
fandomPickerWrapper.classList.toggle('visible', isCharacter);
|
||
fandomPickerWrapper.setAttribute('aria-hidden', String(!isCharacter));
|
||
}
|
||
if (!isCharacter) clearFandomPicker();
|
||
});
|
||
|
||
if (fandomPickerInput) {
|
||
let fandomDebounce = null;
|
||
fandomPickerInput.addEventListener('input', () => {
|
||
clearTimeout(fandomDebounce);
|
||
const term = fandomPickerInput.value.trim();
|
||
delete fandomPickerInput.dataset.fandomId; // any typing invalidates the stashed id
|
||
if (!term) {
|
||
clearFandomAutocomplete();
|
||
return;
|
||
}
|
||
fandomDebounce = setTimeout(() => {
|
||
fetch(`/api/tags/search?kind=fandom&q=${encodeURIComponent(term)}&limit=8`)
|
||
.then(r => r.json())
|
||
.then(body => renderFandomAutocomplete(body.tags || []));
|
||
}, 150);
|
||
});
|
||
}
|
||
|
||
if (fandomPickerAutocomplete) {
|
||
fandomPickerAutocomplete.addEventListener('click', e => {
|
||
const row = e.target.closest('[data-fandom-id]');
|
||
if (!row) return;
|
||
fandomPickerInput.value = row.dataset.fandomName;
|
||
fandomPickerInput.dataset.fandomId = row.dataset.fandomId;
|
||
clearFandomAutocomplete();
|
||
});
|
||
}
|
||
|
||
if (fandomPickerClear) {
|
||
fandomPickerClear.addEventListener('click', clearFandomPicker);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Update the tag-submit handler to pass fandom fields**
|
||
|
||
Find the existing submit handler for `modalTagForm`. It currently posts `FormData` with just `name=...`. Extend it to include `fandom_id` and `fandom_name` when the picker has been used:
|
||
|
||
```js
|
||
// Inside the existing submit handler (pseudocode — adapt to the real shape):
|
||
const formData = new FormData();
|
||
formData.append('name', tagInput.value.trim());
|
||
if (fandomPickerInput && fandomPickerInput.dataset.fandomId) {
|
||
formData.append('fandom_id', fandomPickerInput.dataset.fandomId);
|
||
} else if (fandomPickerInput && fandomPickerInput.value.trim()) {
|
||
formData.append('fandom_name', fandomPickerInput.value.trim());
|
||
}
|
||
|
||
fetch(`/image/${currentImageId}/tags/add`, {
|
||
method: 'POST',
|
||
body: formData,
|
||
}).then(r => r.json()).then(body => {
|
||
if (!body.ok) {
|
||
showError(body.error || 'Tag add failed');
|
||
return;
|
||
}
|
||
tagInput.value = '';
|
||
clearFandomPicker();
|
||
fandomPickerWrapper?.classList.remove('visible');
|
||
// re-render tag pills using body.tag.display_name
|
||
...
|
||
});
|
||
```
|
||
|
||
(Locate the real submit handler in the file and adapt this pattern to its shape.)
|
||
|
||
- [ ] **Step 5: Hook clearFandomPicker into modal close/image-switch**
|
||
|
||
Find the existing `updateImage(imageId)` function in `view-modal.js`. At the top of it, call `clearFandomPicker()`.
|
||
|
||
Find the modal close handler (probably tied to `modalClose` or ESC). Also call `clearFandomPicker()`.
|
||
|
||
- [ ] **Step 6: Visual smoke-test**
|
||
|
||
```bash
|
||
open http://localhost:5000/
|
||
```
|
||
|
||
Open any image in the modal. In the tag input, type `character:` — the fandom picker area should slide in smoothly below. Type a few letters in the fandom input — autocomplete rows should appear. Click one — input fills, autocomplete clears. Click `×` — picker clears. Clear the tag input or close the modal — picker collapses.
|
||
|
||
- [ ] **Step 7: Full submit test**
|
||
|
||
In the modal, type `character:Test Char A` in tag input. Type `Test Fandom A` in fandom picker (don't click autocomplete — let it be free-form). Submit.
|
||
|
||
Check DB:
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "
|
||
SELECT t.id, t.name, t.kind, t.fandom_id, f.name AS fandom_name
|
||
FROM tag t LEFT JOIN tag f ON t.fandom_id = f.id
|
||
WHERE t.name = 'Test Char A';
|
||
"
|
||
```
|
||
|
||
Expected: one row. `kind='character'`, `name='Test Char A'`, `fandom_name='Test Fandom A'`.
|
||
|
||
Clean up:
|
||
|
||
```bash
|
||
docker compose exec -T postgres psql -U imagerepo imagerepo -c "
|
||
DELETE FROM image_tags WHERE tag_id IN (SELECT id FROM tag WHERE name IN ('Test Char A', 'Test Fandom A'));
|
||
DELETE FROM tag WHERE name IN ('Test Char A', 'Test Fandom A');
|
||
"
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add app/static/js/view-modal.js
|
||
git commit -m "feat(modal): fandom picker JS — reveal, autocomplete, submit
|
||
|
||
Picker reveals on 'character:' prefix detection, runs its own autocomplete
|
||
against /api/tags/search?kind=fandom, stashes fandom_id when a row is
|
||
clicked, submits alongside the main add-tag request.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 24: Ambiguous-candidate picker UI in the modal
|
||
|
||
**Why:** when `accept_image_suggestion` returns HTTP 409 with a `candidates` array (same-name characters across fandoms), the frontend needs to let the user pick which one to attach.
|
||
|
||
**Files:**
|
||
- Modify: `app/static/js/view-modal.js` (accept-suggestion handler)
|
||
- Modify: `app/static/style.css`
|
||
|
||
- [ ] **Step 1: Find the existing accept handler**
|
||
|
||
```bash
|
||
grep -n "suggestions/accept\|acceptSuggestion" app/static/js/view-modal.js
|
||
```
|
||
|
||
Locate the function that handles clicking an "Accept" button on a suggestion row.
|
||
|
||
- [ ] **Step 2: Add 409 handling to the accept handler**
|
||
|
||
Update the accept call to handle the 409 branch:
|
||
|
||
```js
|
||
async function acceptSuggestion(suggestionEl) {
|
||
const name = suggestionEl.dataset.name;
|
||
const category = suggestionEl.dataset.category;
|
||
const res = await fetch(`/image/${currentImageId}/suggestions/accept`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({name, category}),
|
||
});
|
||
|
||
if (res.status === 409) {
|
||
const body = await res.json();
|
||
renderAmbiguousPicker(suggestionEl, body.candidates, name, category);
|
||
return;
|
||
}
|
||
if (!res.ok) {
|
||
showError('Accept failed');
|
||
return;
|
||
}
|
||
const body = await res.json();
|
||
// normal path: remove suggestion row, add tag pill via body.tag.display_name
|
||
suggestionEl.remove();
|
||
renderTagPill(body.tag);
|
||
}
|
||
|
||
function renderAmbiguousPicker(suggestionEl, candidates, name, category) {
|
||
const picker = document.createElement('div');
|
||
picker.className = 'suggestion-ambiguous-picker';
|
||
picker.innerHTML = `
|
||
<div class="ambiguous-label">Which one?</div>
|
||
${candidates.map(c => `
|
||
<button type="button" class="ambiguous-candidate"
|
||
data-tag-id="${c.id}">
|
||
${c.display_name}
|
||
</button>
|
||
`).join('')}
|
||
<button type="button" class="ambiguous-cancel">Cancel</button>
|
||
`;
|
||
picker.querySelectorAll('.ambiguous-candidate').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const res = await fetch(`/image/${currentImageId}/suggestions/accept`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({name, category, tag_id: parseInt(btn.dataset.tagId, 10)}),
|
||
});
|
||
const body = await res.json();
|
||
if (body.ok) {
|
||
suggestionEl.remove();
|
||
renderTagPill(body.tag);
|
||
} else {
|
||
showError(body.error || 'Accept failed');
|
||
}
|
||
});
|
||
});
|
||
picker.querySelector('.ambiguous-cancel').addEventListener('click', () => {
|
||
picker.remove();
|
||
// restore the original accept button visibility if needed
|
||
});
|
||
suggestionEl.appendChild(picker);
|
||
}
|
||
```
|
||
|
||
(Adapt `currentImageId`, `showError`, `renderTagPill` to their real names in the file.)
|
||
|
||
- [ ] **Step 3: Add CSS for the picker**
|
||
|
||
Append to `app/static/style.css`:
|
||
|
||
```css
|
||
.suggestion-ambiguous-picker {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.3rem;
|
||
padding: 0.5rem;
|
||
margin-top: 0.3rem;
|
||
background: rgba(255, 180, 0, 0.06);
|
||
border: 1px solid rgba(255, 180, 0, 0.25);
|
||
border-radius: 0.25rem;
|
||
font-size: 0.85rem;
|
||
}
|
||
.suggestion-ambiguous-picker .ambiguous-label {
|
||
width: 100%;
|
||
font-weight: 600;
|
||
color: #ffb400;
|
||
margin-bottom: 0.2rem;
|
||
}
|
||
.suggestion-ambiguous-picker .ambiguous-candidate {
|
||
padding: 0.25rem 0.5rem;
|
||
background: var(--button-bg, #222);
|
||
border: 1px solid var(--button-border, #444);
|
||
border-radius: 0.25rem;
|
||
color: var(--fg, #eee);
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
}
|
||
.suggestion-ambiguous-picker .ambiguous-candidate:hover {
|
||
background: var(--button-hover-bg, #333);
|
||
}
|
||
.suggestion-ambiguous-picker .ambiguous-cancel {
|
||
padding: 0.25rem 0.5rem;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--muted, #888);
|
||
cursor: pointer;
|
||
margin-left: auto;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Smoke-test the flow**
|
||
|
||
Recreate the ambiguous scenario from Task 8 Step 4 (two same-name characters). Open an image in the modal, find a suggestion matching that name (or fake one by running the suggestion ingest for a seed image), click Accept. Expected: the ambiguous picker appears inline with two candidate buttons.
|
||
|
||
Click a candidate. Expected: the picker disappears, the tag pill appears in the sidebar with the correct fandom-qualified `display_name`.
|
||
|
||
Clean up test data as before.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/static/js/view-modal.js app/static/style.css
|
||
git commit -m "feat(modal): ambiguous-candidate picker for 409 accept responses
|
||
|
||
When two same-name characters exist across different fandoms, the accept
|
||
handler now renders an inline picker with each candidate's display_name.
|
||
User clicks one, which re-POSTs with explicit tag_id.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 25: Remaining JS cleanup and display_name plumbing
|
||
|
||
**Why:** tag pills, autocomplete rows in bulk-select and tag-editor, and the `view-modal.js:388` split-colon branch all need to use `display_name` from the server-rendered JSON instead of splitting strings client-side.
|
||
|
||
**Files:**
|
||
- Modify: `app/static/js/view-modal.js` (line ~388 split expression)
|
||
- Modify: `app/static/js/bulk-select.js`
|
||
- Modify: `app/static/js/tag-editor.js`
|
||
|
||
- [ ] **Step 1: Remove the name.split branch at view-modal.js:388**
|
||
|
||
```bash
|
||
grep -n "tag\.name\.split\|tag\.name\.includes" app/static/js/view-modal.js
|
||
```
|
||
|
||
Find the line (reported earlier at `app/static/js/view-modal.js:388`):
|
||
|
||
```js
|
||
opt.textContent = tag.name.includes(':') ? tag.name.split(':', 2)[1] : tag.name;
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```js
|
||
opt.textContent = tag.display_name;
|
||
```
|
||
|
||
Verify the server sends `display_name` in the API response consumed here. Trace back: this block iterates over tags fetched from some endpoint; if the endpoint is `/api/tags/search` or `/image/<id>/tags`, both now include `display_name` (from Tasks 9 and 12).
|
||
|
||
- [ ] **Step 2: Update bulk-select.js**
|
||
|
||
```bash
|
||
grep -n "\.name\b\|tag\.name" app/static/js/bulk-select.js | head -20
|
||
```
|
||
|
||
For every place that displays a tag's `.name` to the user, switch to `.display_name`. Keep `.name` only when constructing a form submit value (where the bare name is what the server stores).
|
||
|
||
- [ ] **Step 3: Update tag-editor.js**
|
||
|
||
Same treatment:
|
||
|
||
```bash
|
||
grep -n "\.name\b" app/static/js/tag-editor.js | head -20
|
||
```
|
||
|
||
For user-facing rendering, swap to `.display_name`.
|
||
|
||
- [ ] **Step 4: Smoke-test bulk-select and tag-editor**
|
||
|
||
Open gallery, enter bulk-select mode, open the bulk-add-tag dropdown. Autocomplete rows should show display form ("Ruby Rose (RWBY)") not bare.
|
||
|
||
Open `/tags/<id>` for any character. The fandom picker autocomplete should show fandom `display_name`s.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add app/static/js/view-modal.js app/static/js/bulk-select.js app/static/js/tag-editor.js
|
||
git commit -m "refactor(js): use display_name from API instead of client-side split
|
||
|
||
Every user-facing tag rendering now reads tag.display_name from the
|
||
server JSON. The client-side split-on-colon fallback is removed.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 26: Dead-code sweep — final verification
|
||
|
||
**Why:** last line of defense. Grep for any remaining prefix-construction, prefix-parse, or old-format URL builder. Anything found is a bug from an earlier task that slipped through.
|
||
|
||
**Files:**
|
||
- Read-only verification across the whole project.
|
||
|
||
- [ ] **Step 1: Prefix construction sites should all be gone from production code**
|
||
|
||
```bash
|
||
grep -n 'f"character:\|f"fandom:\|f"artist:\|f"series:\|f"post:\|f"archive:\|f"meta:' \
|
||
app/ --include='*.py' -r
|
||
```
|
||
|
||
Expected: zero matches. Any match in `app/` is a bug.
|
||
|
||
Matches inside `docs/` or `migrations/versions/` (historical migrations) are expected and fine.
|
||
|
||
- [ ] **Step 2: No standalone prefix-strip expressions**
|
||
|
||
```bash
|
||
grep -rn "\.name\.split(['\"]:['\"]" app/ --include='*.py' --include='*.html' --include='*.js'
|
||
```
|
||
|
||
Expected matches only inside `app/utils/tag_prefix.py` (the parser). Any other match is a bug.
|
||
|
||
- [ ] **Step 3: No `url_for('main.gallery', tag=...)` left**
|
||
|
||
```bash
|
||
grep -rn "url_for.*main\.gallery.*tag=" app/templates/
|
||
```
|
||
|
||
Expected: zero matches. All anchor/link builders should use `tag_id=`.
|
||
|
||
- [ ] **Step 4: `_parse_character_fandom` is gone**
|
||
|
||
```bash
|
||
grep -rn "_parse_character_fandom\|parse_character_fandom" app/
|
||
```
|
||
|
||
Expected: zero matches.
|
||
|
||
- [ ] **Step 5: `sync_character_fandoms` references are gone from production code**
|
||
|
||
```bash
|
||
grep -rn "sync_character_fandoms\|sync-character-fandoms" app/
|
||
```
|
||
|
||
Expected: zero matches in `app/`. (Historical references in `docs/` are fine.)
|
||
|
||
- [ ] **Step 6: `_FANDOM_SUFFIX_RE` is gone from production code**
|
||
|
||
```bash
|
||
grep -rn "_FANDOM_SUFFIX_RE\|_fandom_suffix_prefixes" app/
|
||
```
|
||
|
||
Expected: zero matches.
|
||
|
||
- [ ] **Step 7: Run a full-site smoke test**
|
||
|
||
Visit each of these pages and confirm each loads, renders correctly, and has no console errors:
|
||
|
||
```
|
||
http://localhost:5000/ # showcase
|
||
http://localhost:5000/gallery # gallery (no filter)
|
||
http://localhost:5000/?tag_id=<any_tag_id> # gallery with filter
|
||
http://localhost:5000/tags # all tags
|
||
http://localhost:5000/tags?kind=character # character tags
|
||
http://localhost:5000/tags?kind=character&null_fandom=1 # null-fandom filter
|
||
http://localhost:5000/tags?kind=artist # artist tags
|
||
http://localhost:5000/tags?kind=fandom # fandom tags
|
||
http://localhost:5000/tag/<any_character_id> # tag detail (set fandom)
|
||
http://localhost:5000/read/<any_series_id> # reader
|
||
http://localhost:5000/settings # settings (sync-character-fandoms button gone)
|
||
```
|
||
|
||
Open the modal on an image that has character tags. Verify:
|
||
- Tag pills show `display_name` (with fandom suffix when applicable).
|
||
- Typing `character:NewName` reveals the fandom picker.
|
||
- Autocomplete in both main input and fandom picker works.
|
||
- Clicking a suggestion works; if ambiguous, the candidate picker appears.
|
||
- Provenance / suggestions / series sections render without errors.
|
||
|
||
- [ ] **Step 8: Final commit (if any small fixes emerged)**
|
||
|
||
If any issues were found and fixed in Step 7, commit them:
|
||
|
||
```bash
|
||
git add <fixed_files>
|
||
git commit -m "chore: final fixes from post-refactor smoke test
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||
```
|
||
|
||
Otherwise, no commit — this task is pure verification.
|
||
|
||
---
|
||
|
||
## Self-Review Checklist (Plan Author)
|
||
|
||
**Spec coverage:** Every section of the spec is covered.
|
||
|
||
- Data model → Task 3 (migration creates indices), Task 4 (model gets `__table_args__`)
|
||
- Migration (phases 1-3) → Task 3
|
||
- Display rendering (`@property`) → Task 1
|
||
- Display rendering (templates + N+1 joinedload) → Tasks 19, 20, 21, 12, 13
|
||
- URL routing (tag_id) → Tasks 10, 19, 20
|
||
- Search/autocomplete (JOIN + CONCAT) → Task 9
|
||
- `parse_kind_prefix` helper → Task 2
|
||
- `_ensure_fandom_tag` simplified → Task 5
|
||
- `add_tag` refactor → Task 6
|
||
- `bulk_add_tag` refactor → Task 7
|
||
- `accept_image_suggestion` 0/1/N resolution → Task 8
|
||
- Ambiguous-candidate picker UI → Task 24
|
||
- Fandom picker (HTML/CSS) → Task 22
|
||
- Fandom picker (JS) → Task 23
|
||
- Fandom-less character nudges → Task 21
|
||
- `set_tag_fandom` simplified → Task 11
|
||
- Code removals (`_parse_character_fandom`, `_FANDOM_SUFFIX_RE`, etc.) → Tasks 12, 17, 18, 26
|
||
- Archive/post/artist construction sweep → Tasks 14, 15, 16
|
||
- `sync_character_fandoms` deletion → Task 18
|
||
- Misc JS plumbing (`display_name` everywhere) → Task 25
|
||
|
||
**Placeholder scan:** grep through the plan for TBD/TODO/fill-in patterns.
|
||
|
||
```bash
|
||
grep -n "TBD\|TODO\|implement later\|fill in\|appropriate error handling\|Similar to Task" \
|
||
docs/superpowers/plans/2026-04-21-character-fandom-association-refactor.md
|
||
```
|
||
|
||
If any matches remain, fix them before handoff.
|
||
|
||
**Type consistency:**
|
||
|
||
- `parse_kind_prefix` is imported as `from app.utils.tag_prefix import parse_kind_prefix` in Tasks 6 and 7 — matches the function defined in Task 2.
|
||
- `KNOWN_KINDS` is the constant in Task 2 and referenced by documentation in Tasks 2 and 18 — matches.
|
||
- `display_name` property is added in Task 1 and used in Tasks 6, 7, 8, 9, 11, 12, 19, 20, 21 — matches.
|
||
- `_ensure_fandom_tag(name)` takes a bare string after Task 5; all subsequent callers pass bare names — matches.
|
||
- `tag_id` as query param throughout — matches.
|
||
- Table index names (`tag_character_with_fandom_uniq`, `tag_character_null_fandom_uniq`, `tag_other_kinds_uniq`, `tag_kind_idx`) referenced identically in migration (Task 3) and model (Task 4) — matches.
|
||
- `null_fandom_only` flag in `_get_tags_with_previews` referenced consistently in Task 21 — matches.
|