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

28 KiB
Raw Permalink Blame History

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

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

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

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

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

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:

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

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

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

# 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

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

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

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

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

{
  "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_suggestionsdeleted.
  • _existing_tag_names_for_image keeps returning set[str] of bare names (what tag.name now is natively).

app/main.py:accept_image_suggestion

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:

@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:388tag.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.