This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
imagerepo/docs/superpowers/specs/2026-04-21-character-fandom-association-design.md
T
bvandeusen 191a1329c3 docs: character-fandom association refactor spec (wide scope)
Replaces the string-embedded `character:Name (Fandom)` naming scheme with
structural storage: bare name in tag.name, kind in kind column only,
fandom association via fandom_id. Extends to all tag kinds — the kind
prefix (`artist:`, `fandom:`, etc.) is stripped from tag.name repo-wide,
making tag.name authoritative display text and tag.kind the sole source
of truth for kind.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:51:17 -04:00

632 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CharacterFandom Association Refactor (+ Tag Name Normalization)
**Status:** Design approved 2026-04-21
**Supersedes:** `2026-04-19-character-tag-integrity-design.md`
## Problem
Two intertwined issues in tag storage:
### 1. Characterfandom association is encoded in the name string
Character tags today store the fandom inside the `name` column: `Tag(name="character:Ruby Rose (RWBY)", kind="character", fandom_id=NULL-or-set)`. The `fandom_id` FK exists (migration `f26021101`) but is not authoritative — URL routing, search, autocomplete, and accept paths all re-parse `(Fandom)` out of the name. Consequences:
- Two distinct same-name characters across different fandoms are awkward. The name string must carry the disambiguator, and WD14 (which emits bare names like `"Ruby Rose"`) mismatches the curated form.
- Parser glue proliferates: `_parse_character_fandom`, `_FANDOM_SUFFIX_RE` in two files, `sync_character_fandoms` Celery task, an ilike-wildcard fallback in `accept_image_suggestion`.
- Renaming a fandom requires a retroactive-backfill task rewriting every character tag's name.
- WD14 + curated mismatches surface as duplicate tags (the "Ruby Rose becomes a second fandomless tag" bug).
### 2. Kind is duplicated — once in the `kind` column, again as a prefix in the `name` string
Current tag storage:
```
Tag(name="character:Ruby Rose (RWBY)", kind="character") ← prefix + suffix
Tag(name="fandom:RWBY", kind="fandom") ← prefix
Tag(name="artist:Eric Canete", kind="artist") ← prefix
Tag(name="post:patreon:eric:12345", kind="post") ← prefix (+ inner ':' separators)
Tag(name="archive:eric:volume_1", kind="archive") ← prefix (+ inner ':' separators)
Tag(name="sunset", kind="user") ← NO prefix (inconsistent)
```
The `kind:` prefix duplicates the `kind` column. It's also applied inconsistently — user-typed general tags are stored bare. This makes changing a tag's kind require renaming the tag too, and forces ~30 `name.split(':', 1)` parsing sites across the app to extract the display form. Templates even re-implement the strip themselves (`_gallery_item.html`).
The structural fix, in one refactor: store `kind` only in the `kind` column; store the fandom association only in `fandom_id`; `name` becomes the bare, opaque display string.
## Goals
- **Bare name storage** — `Tag.name` never encodes kind or fandom. `Tag.name = "Ruby Rose"`, `kind='character'`, `fandom_id=42`. Same shape for every kind: `Tag.name = "Eric Canete", kind='artist'`; `Tag.name = "RWBY", kind='fandom'`; `Tag.name = "sunset", kind='user'`.
- Multiple same-name characters across distinct fandoms are first-class.
- Display form `"Ruby Rose (RWBY)"` computed at render time via `@property`.
- Null-fandom character tags are allowed (`fandom_id IS NULL`) — WD14 imports land clean, users assign fandoms later.
- Net fewer helpers, one fewer Celery task, dozens fewer parsing sites.
## Non-Goals
- Backwards-compat for old `?tag=<name>` gallery URLs. Clean break — URLs become `?tag_id=<int>`.
- Changes to how `Tag.kind` values are spelled. The set of kinds (`character`, `fandom`, `artist`, `series`, `post`, `archive`, `meta`, `user`) is unchanged.
- Changing the `:` separator inside post/archive identifiers. Once `kind` moves out of the name, the inner `:` in `"patreon:eric:12345"` is just opaque identifier text — no parser cares.
- Bulk UX for collapsing bare + suffixed character duplicates found by migration. Surfaced as a report; user resolves via the tag-list UI.
## Data Model
```
Tag
├── id PK
├── name text (bare — no kind prefix, no fandom suffix)
├── kind text (character | fandom | general/NULL | meta | artist | series | post | archive | user)
├── fandom_id FK → tag.id (nullable; only meaningful when kind='character')
└── …existing columns…
```
Drop the existing `UNIQUE(name)` constraint. Replace with four partial unique indices:
```sql
-- Characters with a fandom: unique on (name, fandom_id)
CREATE UNIQUE INDEX tag_character_with_fandom_uniq
ON tag (name, fandom_id)
WHERE kind = 'character' AND fandom_id IS NOT NULL;
-- Characters without a fandom: unique on name alone
CREATE UNIQUE INDEX tag_character_null_fandom_uniq
ON tag (name)
WHERE kind = 'character' AND fandom_id IS NULL;
-- Everything else: unique on (name, kind)
-- An artist "John" and a user tag "John" can coexist; two artist "John"s cannot.
CREATE UNIQUE INDEX tag_other_kinds_uniq
ON tag (name, kind)
WHERE kind != 'character';
```
Two partial indices for `kind='character'` model "NULL is a distinct group of size one" — two "Ash" tags with different `fandom_id` are allowed; two "Ash" tags with NULL fandom are not. Everything else is straightforward `(name, kind)` composite.
Add a supporting btree index on `kind` so `tag_list` filters stay fast:
```sql
CREATE INDEX tag_kind_idx ON tag (kind);
```
## Migration (One-Shot Alembic)
Runs as a single migration with two logical phases. Both phases commit together; if anything fails, the whole migration rolls back.
### Phase 1 — Strip `kind:` prefix from `name` for every kind
```python
# Same kinds as the app-level KNOWN_KINDS; user is intentionally excluded
# because user tags have never been prefix-stored.
KNOWN_PREFIXES = ('character:', 'fandom:', 'artist:', 'series:',
'post:', 'archive:', 'meta:')
for tag in all_tags:
for prefix in KNOWN_PREFIXES:
if tag.name.startswith(prefix):
tag.name = tag.name[len(prefix):]
break
```
**Collision handling:** after prefix strip, the old `UNIQUE(name)` is dropped *before* the new partial indices are created, so interim duplicates are fine. But same-name different-kind duplicates should already be rare (the old unique prevented them except for user tags, which never had prefixes). For belt-and-suspenders: if phase 1 produces two rows with the same `(name, kind)` (should be impossible — same name would have been the same tag under old unique), abort with report.
User tags (`kind='user'`, never prefixed) are left alone by this phase.
### Phase 2 — Character fandom extraction
After phase 1, every `kind='character'` tag's name looks like either `"Ruby Rose (RWBY)"` or `"Ruby Rose"` (the `character:` is gone).
```python
FANDOM_SUFFIX_RE = re.compile(r'^(.+?) \(([^()]+)\)$')
for tag in Tag.query.filter_by(kind='character').all():
m = FANDOM_SUFFIX_RE.match(tag.name)
if not m:
continue # already bare — nothing to do
bare_name, fandom_name = m.group(1), m.group(2)
# Ensure fandom tag exists (phase 1 has already stripped "fandom:" prefix)
fandom = Tag.query.filter_by(kind='fandom', name=fandom_name).first()
if not fandom:
fandom = Tag(kind='fandom', name=fandom_name)
db.session.add(fandom)
db.session.flush()
# Collision check: existing (name=bare, fandom_id=fandom.id, kind='character')?
collision = Tag.query.filter(
Tag.id != tag.id,
Tag.kind == 'character',
Tag.name == bare_name,
Tag.fandom_id == fandom.id,
).first()
if collision:
reassign_image_tags(from_id=tag.id, to_id=collision.id)
db.session.delete(tag)
report_merged.append((tag.id, collision.id, tag.name))
continue
tag.name = bare_name
tag.fandom_id = fandom.id
```
### Phase 3 — Index swap
```python
op.drop_constraint('tag_name_key', 'tag', type_='unique') # old UNIQUE(name)
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 at end of migration:
```
prefixes stripped: N (by kind: character=X, fandom=Y, artist=Z, series=…)
character→bare + fandom_id set: N
characters auto-merged (true duplicates): N -- with (from_id, to_id, original_name) each
bare-name characters coexisting with suffixed siblings: N -- review in /tags
malformed names (skipped, e.g. "Name ()" ): N
```
### Downgrade
Best-effort: re-prepend `kind:` to every non-user tag's name, re-embed `(Fandom)` into character names where `fandom_id` is set, drop the four partial indices, restore `UNIQUE(name)`. Will fail if phase-2 auto-merges happened (the deleted duplicate can't be recreated). Acceptable — solo-dev personal repo, downgrade is a last resort.
## Display Rendering
Python-side property is the single source of truth:
```python
# app/models.py, on Tag
@property
def display_name(self) -> str:
if self.kind == 'character' and self.fandom is not None:
return f"{self.name} ({self.fandom.name})"
return self.name
```
Note: `display_name` returns bare or suffixed — **never** with `kind:` prefix. Templates that want an emoji or kind indicator render that separately, keyed off `tag.kind` (see `_gallery_item.html` refactor below).
### Template changes
**`_gallery_item.html` (currently lines 20-30):** six near-identical branches, each doing `t.name.split(':', 1)[1]` with a kind-specific emoji. Collapse to:
```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) }}">
{{ KIND_EMOJI.get(t.kind, '') }} {{ t.display_name }}
</a>
{% endfor %}
```
**Other templates:** every `{{ tag.name }}``{{ tag.display_name }}` where a tag is being rendered for humans. Every `url_for('main.gallery', tag=...)``url_for('main.gallery', tag_id=tag.id)`. Affected files: `_gallery_item.html`, `_gallery_modal.html` (rendered via `/api/image/<id>/tags` JSON), `_tag_cards.html`, `tags_list.html`, `reader.html`, `showcase.html`.
### N+1 avoidance
List-rendering routes that render `display_name` for potentially-character tags add `joinedload(Tag.fandom)` to the query. Concretely: `_get_tags_with_previews`, `/api/tags/list`, gallery's active-tag fetch, and the image-tags JSON payload.
## URL Routing
### Gallery filter
```python
@main.route('/')
def gallery():
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
)
q = ImageRecord.query
if active_tag_obj:
q = q.join(ImageRecord.tags).filter(Tag.id == tag_id)
...
```
Old `?tag=<name>` querystring is silently ignored. Same change at the other gallery-like route around `app/main.py:268`.
### Search & autocomplete (the one SQL-level display concern)
```python
# app/main.py search_tags
from sqlalchemy import case, and_, func
from sqlalchemy.orm import aliased, contains_eager
f = aliased(Tag)
display_expr = case(
(and_(Tag.kind == 'character', Tag.fandom_id.isnot(None)),
func.concat(Tag.name, ' (', f.name, ')')),
else_=Tag.name,
)
q = (
db.session.query(Tag)
.outerjoin(f, f.id == Tag.fandom_id)
.options(contains_eager(Tag.fandom, alias=f))
.filter(display_expr.ilike(f'%{term}%'))
.order_by(display_expr)
.limit(limit)
)
```
Response includes `display_name` for every row. A new optional `?kind=` param scopes results to one kind (used by the modal's fandom picker — `kind=fandom`).
## Add-Tag Input (User-Facing Prefix Shortcut)
**Important:** under wide scope, `name` doesn't store the prefix — but the **user-facing input field** keeps accepting `character:Saber`, `artist:Name`, etc., as a shortcut for specifying kind. The prefix is *parsed once* at the boundary (in `add_tag`, `bulk_add_tag`) and never hits storage.
This preserves the typing habit you already have. The parser lives in one place.
### Server — `app/main.py:add_tag`
```python
# Module-level constant; shared with the Alembic migration and bulk_add_tag.
KNOWN_KINDS = frozenset({
'character', 'fandom', 'artist', 'series',
'post', 'archive', 'meta', 'user',
})
def _parse_kind_prefix(raw: str) -> tuple[str | None, str]:
"""('character:Saber', …) → ('character', 'Saber').
('sunset', …) → (None, 'sunset').
('http://example', …) → (None, 'http://example').
Only recognized kinds are treated as prefixes; anything else keeps
the colon as literal text."""
if ':' in raw:
prefix, rest = raw.split(':', 1)
if prefix in KNOWN_KINDS:
return prefix, rest.strip()
return None, raw.strip()
@main.post("/image/<int:image_id>/tags/add")
def add_tag(image_id):
data = request.get_json() or request.form
raw = (data.get("name") or "").strip()
kind, name = _parse_kind_prefix(raw)
if kind is None:
kind = 'user'
if kind == 'character':
fandom_id = data.get('fandom_id', type=int)
fandom_name = (data.get('fandom_name') or '').strip() or None
if fandom_id:
fandom = Tag.query.filter_by(id=fandom_id, kind='fandom').first()
if fandom is None:
return jsonify({'error': 'fandom_not_found'}), 400
elif fandom_name:
fandom = _ensure_fandom_tag(fandom_name) # kind='fandom', no prefix
else:
fandom = None
name = normalize_display_name(name)
tag = Tag.query.filter_by(
kind='character', name=name,
fandom_id=fandom.id if fandom else None,
).first()
if tag is None:
tag = Tag(kind='character', name=name,
fandom_id=fandom.id if fandom else None)
db.session.add(tag)
db.session.flush()
else:
if kind in ('fandom', 'character'):
name = normalize_display_name(name)
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()
attach_tag_to_image(tag, image_id)
db.session.commit()
return jsonify({
'ok': True,
'tag': {'id': tag.id, 'name': tag.name, 'kind': tag.kind,
'display_name': tag.display_name},
})
```
The same parser lives in `bulk_add_tag` (`app/main.py:2340` area) — one helper, both call sites.
### `_ensure_fandom_tag` simplified
Before: creates `Tag(name=f"fandom:{normalized}", kind='fandom')`.
After: creates `Tag(name=normalized, kind='fandom')`. (Still handles find-or-create.)
## Add-Tag UX (Modal — inline fandom picker)
### HTML
Inserted into `app/templates/_gallery_modal.html` inside `.modal-tags-section`, after `#modalTagForm` and before `#tagAutocomplete`:
```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">×</button>
</div>
<div id="fandomPickerAutocomplete"
class="tag-autocomplete tag-autocomplete-inline" aria-live="polite"></div>
</div>
```
### CSS (smooth reveal)
```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; /* label + input + 5 autocomplete rows */
opacity: 1;
margin-top: 0.5rem;
}
.fandom-picker-input-row { display: flex; gap: 0.25rem; }
.fandom-picker-input { flex: 1; }
.fandom-picker-label { display: block; font-size: 0.85rem; color: var(--muted); }
```
### JS (`app/static/js/view-modal.js`)
```js
tagInput.addEventListener('input', () => {
const isCharacter = tagInput.value.toLowerCase().startsWith('character:');
fandomPickerWrapper.classList.toggle('visible', isCharacter);
fandomPickerWrapper.setAttribute('aria-hidden', String(!isCharacter));
if (!isCharacter) clearFandomPicker();
});
fandomPickerInput.addEventListener('input', () => {
const term = fandomPickerInput.value.trim();
if (!term) return clearFandomAutocomplete();
fetch(`/api/tags/search?kind=fandom&q=${encodeURIComponent(term)}`)
.then(r => r.json())
.then(rows => renderFandomAutocomplete(rows));
});
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();
});
function clearFandomPicker() {
fandomPickerInput.value = '';
delete fandomPickerInput.dataset.fandomId;
clearFandomAutocomplete();
}
```
On modal close and on image switch, `clearFandomPicker()` runs so state doesn't bleed between images.
### Submit payload
```json
{
"name": "character:Bellatrix Lestrange",
"fandom_name": "Harry Potter",
"fandom_id": 247
}
```
`fandom_id` preferred when the user clicked a picker row; `fandom_name` used for free-form typed text (server creates the fandom if missing). Both omitted = null fandom, allowed.
## WD14 Suggestion & Accept Path
### `app/services/tag_suggestions.py` simplifications
- `_canonicalize_wd14_name` collapses to: `normalize_display_name(raw)` for character/copyright, `underscores_to_spaces(raw)` otherwise. No fandom-suffix awareness, no `character:` prefix concern.
- `_FANDOM_SUFFIX_RE`, `_fandom_suffix_prefixes`, the `applied_fandom_prefixes` branch in `_wd14_suggestions`**deleted**.
- `_existing_tag_names_for_image` keeps returning `set[str]` of bare names (what `tag.name` now is natively).
### `app/main.py:accept_image_suggestion`
```python
def accept_image_suggestion(image_id):
data = request.get_json()
name = data['name'] # bare (WD14 emits bare)
category = data['category']
explicit_tag_id = data.get('tag_id')
kind = _WD14_CATEGORY_TO_KIND.get(category) # 'character' | 'fandom' | None
if explicit_tag_id:
tag = Tag.query.get(explicit_tag_id)
if tag is None:
return jsonify({'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({
'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()
attach_tag_to_image(tag, image_id)
db.session.commit()
return jsonify({
'success': True,
'tag': {'id': tag.id, 'name': tag.name,
'kind': tag.kind, 'display_name': tag.display_name},
})
```
### Frontend disambiguation
On HTTP 409 `ambiguous`, view-modal.js swaps the Accept button for an inline candidate picker (small row of clickable chips, one per candidate showing `display_name`). Clicking a chip re-POSTs with `tag_id = chip.dataset.id`, taking the explicit-id branch above.
### Bulk accept
Per-image accept loop catches 409 and adds the image/name pair to a `needs_disambiguation` list in the response. UI shows summary; user resolves per-image in the modal.
## Fandom-less Character Nudges
Visible in three places; never in the modal (modal stays smooth).
- **Tags list (`/tags?kind=character`):** character rows with `fandom_id IS NULL` get a `⚠ No fandom` chip and an "Assign" button.
- **Tag detail (`/tags/<id>`):** banner at top if character + null fandom; fandom picker expanded by default.
- **Tags list header counter:** `⚠ N characters need a fandom` link that filters to `kind=character&null_fandom=1`.
`set_tag_fandom` shrinks to one column update — no rename, no retroactive backfill:
```python
@main.post("/api/tag/<int:tag_id>/set-fandom")
def set_tag_fandom(tag_id):
tag = Tag.query.get_or_404(tag_id)
if tag.kind != 'character':
return jsonify({'error': 'not_a_character'}), 400
fandom_id = request.get_json().get('fandom_id')
if fandom_id is None:
tag.fandom_id = None
else:
fandom = Tag.query.filter_by(id=fandom_id, kind='fandom').first()
if fandom is None:
return jsonify({'error': 'fandom_not_found'}), 400
tag.fandom_id = fandom.id
db.session.commit()
return jsonify({'success': True, 'display_name': tag.display_name})
```
## Call Sites — The Mechanical Sweep
Approximately 53 call sites across production code. All follow predictable patterns.
### Construction sites (~16) — drop the `kind:` prefix
| Before | After |
|---|---|
| `Tag(name=f"fandom:{normalized}", kind="fandom")` (`app/main.py:34, 37`) | `Tag(name=normalized, kind="fandom")` |
| `url_for('main.gallery', tag=f"artist:{name}")` (`app/main.py:599`) | `url_for('main.gallery', tag_id=tag.id)` |
| `tag_name = f"character:{norm_char} ({fandom_name})"` + `Tag(name=tag_name, ...)` (`app/main.py:899, 904, 2359, 2364`) | `Tag(name=norm_char, kind='character', fandom_id=fandom.id)` |
| `tag.name = f"character:{char_base}"` (`app/main.py:1004`) | `tag.name = char_base; tag.kind = 'character'` |
| `new_name = f"character:{char_base} ({fandom_display})"` (`app/main.py:1015, 1222`) | Replace with `fandom_id` update — no rename (see `set_tag_fandom`). |
| `f"artist:{artist_name}"` (`app/main.py:2016`, `app/tasks/import_file.py:813`) | `Tag(name=artist_name, kind='artist')` |
| `f"archive:{artist}:{archive_name}"` (`app/utils/image_importer.py:961`) | `Tag(name=f"{artist}:{archive_name}", kind='archive')` — inner `:` kept (opaque identifier) |
| `prefix = f"archive:{artist}/"` + `ILIKE prefix%` (`app/utils/image_importer.py:986`) | `Tag.query.filter(kind='archive', name.like(f'{artist}/%'))` |
| `f"post:{platform}:{artist}:{post_id}"` (`app/utils/metadata_enrichment.py:472`) | `Tag(name=f"{platform}:{artist}:{post_id}", kind='post')` |
### Parsing sites (~30) — replace `name.split(':', 1)[1]` with `tag.name` or `tag.display_name`
All ~30 `name.split(':', 1)` sites (enumerated in the grep results) become one of:
- Templates rendering human text → `{{ t.display_name }}`.
- Backend computing display → `tag.display_name`.
- Backend extracting "the part after `:`" (e.g. `app/main.py:2470` series page rendering) → `tag.name` directly, since the prefix is gone.
- Backend parsing kind from user input (`app/main.py:877, 2340`) → `_parse_kind_prefix` helper.
### Template & JS sites
- `app/templates/_gallery_item.html:20-30` — six-branch split collapses into the KIND_EMOJI dict render (see Display section).
- `app/static/js/view-modal.js:388``tag.name.includes(':') ? …` branch deleted; use `tag.display_name` from the JSON response.
## Code Removals
Net-negative LOC:
| File | Remove |
|---|---|
| `app/main.py` | `_parse_character_fandom` helper. |
| `app/main.py` | Ilike `Name (%)` fallback branch in `accept_image_suggestion`. |
| `app/main.py` | `set_tag_fandom` retroactive-rename logic (replaced by one-line `fandom_id` update). |
| `app/main.py` | Old `?tag=<name>` resolution branches in gallery routes. |
| `app/main.py` | Most `split(':', 1)` prefix-strip calls (~25 of the 30 sites). |
| `app/tasks/maintenance.py` | `sync_character_fandoms` Celery task + its schedule entry. |
| `app/services/tag_suggestions.py` | `_FANDOM_SUFFIX_RE`, `_fandom_suffix_prefixes`, `applied_fandom_prefixes` branch. |
| `app/templates/_gallery_item.html` | Six-branch name-split; replaced by KIND_EMOJI dict. |
### Kept (still useful)
- `_ensure_fandom_tag` — simplified to no-prefix creation.
- `normalize_display_name`, `underscores_to_spaces` — unchanged.
- `_parse_kind_prefix` (formalized from the inline `name.split(':', 1)[0]` used today) — lives in one place for add-tag's user-facing shortcut.
- Migrations `h26041901`, `i26041901` — historical, never touched.
## Summary Matrix
| Layer | Before | After |
|---|---|---|
| Name storage | `"character:Ruby Rose (RWBY)"`, `"artist:Eric Canete"`, `"sunset"` (mixed) | Bare everywhere: `"Ruby Rose"`, `"Eric Canete"`, `"sunset"` |
| Kind source of truth | Prefix in name + `kind` column (duplicated) | `kind` column only |
| Fandom link | Parsed from name suffix | `fandom_id` FK |
| Same-name distinct characters | Impossible without name collision | Natural via `(name, fandom_id)` |
| Uniqueness | `UNIQUE(name)` | 3 partial indices (character split by fandom null-ness; everything else by `(name, kind)`) |
| Display | `tag.name.split(':', 1)[1] if ':' in tag.name else tag.name` in ~30 places | `tag.display_name` (one property) |
| Emoji in gallery chip | Six-branch template expression | Dict lookup keyed on `tag.kind` |
| Gallery URL | `?tag=character:Ruby%20Rose%20(RWBY)` | `?tag_id=42` |
| Search | ILIKE `tag.name` including prefix | JOIN fandom, ILIKE on CONCAT display expression |
| Character add-tag fandom | `character:Name (Fandom)` suffix string | Inline fandom picker beside input |
| Null-fandom character | Parser-accident | First-class; tag list nudges review |
| WD14 bare-name handling | Band-aid prefix-suppression + rename-on-accept | 0/1/N candidate resolution |
| Rename a fandom | Walk + rewrite every character name | Update one row; display auto-updates |
| Changing a tag's kind | Rewrite name too (prefix) | `UPDATE tag SET kind = …` |
| Celery maintenance | `sync_character_fandoms` ran on schedule | Deleted |
## Open Questions
None. All clarifying questions resolved:
- Uniqueness: null fandoms distinct (two partial indices for characters).
- URL strategy: switch to `tag_id`, clean break.
- Display: `@property` + SQL CONCAT for search.
- Migration collision handling: auto-merge true duplicates, report orphans.
- Dead code: delete, no deprecation window.
- Add-tag UX: inline fandom picker with CSS height transition; null fandom allowed; nudges live in tag list/detail.
- Scope: wide — kind prefix stripped from `name` for all kinds, not just characters.
- Post/archive nested `:` in identifiers: kept as-is (opaque identifier text once `kind` moves out of `name`).
## Implementation Notes
- Plan order: migration first (its own commit), then model + display property, then route handlers, then templates + JS, then test fixtures.
- TDD focus points: migration collision scenarios (phase 1 same-name-different-kind interim, phase 2 character merges), `display_name` property (character with/without fandom, each other kind), ambiguous-accept 409 contract, `_parse_kind_prefix` boundary cases.
- Test fixtures creating tags today via `name="character:X (Y)"` need rewriting to the factory shape `Tag(kind='character', name='X', fandom_id=y_id)`. Search the test directory and rewrite systematically before any production code changes compile-break them.
- One commit per logical chunk keeps review tractable. Rough chunking: (a) Alembic migration, (b) `Tag.display_name` + URL routing + models, (c) `add_tag` / `bulk_add_tag` + `_parse_kind_prefix`, (d) `accept_image_suggestion` + tag_suggestions cleanup, (e) template sweep, (f) `_gallery_modal.html` fandom picker HTML/CSS/JS, (g) removal of `sync_character_fandoms` + dead helpers, (h) tag-list fandom-less nudges.