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>
This commit is contained in:
@@ -0,0 +1,631 @@
|
||||
# Character–Fandom 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. Character–fandom 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.
|
||||
Reference in New Issue
Block a user