fix(modal): autocomplete picks resolve by tag_id, not just by name

Clicking an autocomplete row (or pressing Enter on a highlighted row)
now sends tag_id in the add-tag POST. Server attaches that exact tag,
skipping parse_kind_prefix. Same-name ambiguity — e.g. a character
tag 'Yidhari' and a user tag 'Yidhari' both matching the autocomplete
query 'yid' — now resolves by whichever row the user picked, instead
of silently routing to the user-kind fallback.

Bare free-text input (no click) still goes through parse_kind_prefix
with its kind: shortcut and kind='user' default. The fandom picker
path is untouched.

The click handler also stashes tag_id on the dataset so the keyboard
Enter path on a highlighted row picks up the same resolution.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 18:57:37 -04:00
parent fc70d56afb
commit 3d6413c639
2 changed files with 82 additions and 55 deletions
+57 -42
View File
@@ -932,61 +932,76 @@ def add_tag(image_id):
""" """
JSON endpoint to add a tag to an image. JSON endpoint to add a tag to an image.
Accepts the user-facing 'kind:name' shortcut in the 'name' field Resolution order:
(e.g. 'character:Saber'). Bare strings without a recognized prefix are - If 'tag_id' is provided, attach that exact tag (used when the
treated as user tags. autocomplete picker resolves a specific row). Auto-applies the
character's fandom if set. Skips parse_kind_prefix entirely.
- Else parse 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' For character tags created via the parse path, optional 'fandom_id'
attaches the character to a fandom. Both omitted = null fandom (allowed). (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_prefix import parse_kind_prefix
from app.utils.tag_names import underscores_to_spaces 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) img = ImageRecord.query.get_or_404(image_id)
explicit_tag_id = request.form.get("tag_id", type=int)
fandom_tag = None 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: if explicit_tag_id:
fandom_tag = Tag.query.filter_by(id=fandom_id, kind="fandom").first() # Autocomplete-picker path: attach the exact clicked tag.
if fandom_tag is None: tag = Tag.query.get(explicit_tag_id)
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: if tag is None:
tag = Tag( return jsonify(ok=False, error="tag_not_found"), 404
if tag.kind == "character" and tag.fandom_id:
fandom_tag = Tag.query.get(tag.fandom_id)
else:
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)
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", kind="character",
name=name, name=name,
fandom_id=fandom_tag.id if fandom_tag else None, fandom_id=fandom_tag.id if fandom_tag else None,
) ).first()
db.session.add(tag) if tag is None:
db.session.flush() tag = Tag(
else: kind="character",
tag = Tag.query.filter_by(kind=kind, name=name).first() name=name,
if tag is None: fandom_id=fandom_tag.id if fandom_tag else None,
tag = Tag(kind=kind, name=name) )
db.session.add(tag) db.session.add(tag)
db.session.flush() 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: if tag not in img.tags:
img.tags.append(tag) img.tags.append(tag)
+25 -13
View File
@@ -658,13 +658,20 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
async function addTag(imageId, name, { fandomId, fandomName } = {}) { async function addTag(imageId, name, { tagId, fandomId, fandomName } = {}) {
const fd = new FormData(); const fd = new FormData();
fd.append('name', name); if (tagId) {
if (fandomId) { // Autocomplete-picker path: server attaches this exact tag, skipping
fd.append('fandom_id', String(fandomId)); // parse_kind_prefix so a same-name character/user ambiguity is
} else if (fandomName) { // resolved by the row the user clicked.
fd.append('fandom_name', fandomName); fd.append('tag_id', String(tagId));
} else {
fd.append('name', name);
if (fandomId) {
fd.append('fandom_id', String(fandomId));
} else if (fandomName) {
fd.append('fandom_name', fandomName);
}
} }
const r = await fetch(`/image/${imageId}/tags/add`, { method: 'POST', body: fd }); const r = await fetch(`/image/${imageId}/tags/add`, { method: 'POST', body: fd });
const j = await r.json(); const j = await r.json();
@@ -747,7 +754,7 @@ document.addEventListener('DOMContentLoaded', () => {
tagAutocomplete.innerHTML = tags.map((t, i) => { tagAutocomplete.innerHTML = tags.map((t, i) => {
const icon = getTagIcon(t.kind); const icon = getTagIcon(t.kind);
const displayName = getTagDisplayName(t.name); const displayName = getTagDisplayName(t.name);
return `<div class="tag-autocomplete-item" data-index="${i}" data-name="${t.name}"> return `<div class="tag-autocomplete-item" data-index="${i}" data-tag-id="${t.id}" data-name="${t.name}" data-kind="${t.kind || ''}">
<span>${icon} ${displayName}</span> <span>${icon} ${displayName}</span>
<span class="tag-kind">${t.kind || 'user'}</span> <span class="tag-kind">${t.kind || 'user'}</span>
</div>`; </div>`;
@@ -778,14 +785,18 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
// Shared function to submit a tag (avoids page refresh issues with form dispatch) // Shared function to submit a tag (avoids page refresh issues with form dispatch).
async function submitTag() { // `pickedTagId` is set when the user clicks an autocomplete row or presses
// Enter on a highlighted row — in that case, skip parsing and attach that
// exact tag so same-name character/user ambiguity is resolved by the pick.
async function submitTag(pickedTagId = null) {
const id = getEditorImageId(); const id = getEditorImageId();
const name = (tagInput?.value || '').trim(); const name = (tagInput?.value || '').trim();
if (!id || !name) return; if (!id || (!name && !pickedTagId)) return;
const fandomId = fandomPickerInput?.dataset?.fandomId; const fandomId = fandomPickerInput?.dataset?.fandomId;
const fandomName = (fandomPickerInput?.value || '').trim(); const fandomName = (fandomPickerInput?.value || '').trim();
const ok = await addTag(id, name, { const ok = await addTag(id, name, {
tagId: pickedTagId || null,
fandomId: fandomId ? Number(fandomId) : null, fandomId: fandomId ? Number(fandomId) : null,
fandomName: fandomName || null, fandomName: fandomName || null,
}); });
@@ -839,8 +850,8 @@ document.addEventListener('DOMContentLoaded', () => {
e.preventDefault(); e.preventDefault();
const selected = autocompleteItems[autocompleteSelectedIndex]; const selected = autocompleteItems[autocompleteSelectedIndex];
if (selected) { if (selected) {
tagInput.value = selected.name; tagInput.value = selected.display_name || selected.name;
submitTag(); submitTag(selected.id);
} }
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
selectAutocompleteItem(-1); selectAutocompleteItem(-1);
@@ -857,9 +868,10 @@ document.addEventListener('DOMContentLoaded', () => {
const item = e.target.closest('.tag-autocomplete-item'); const item = e.target.closest('.tag-autocomplete-item');
if (!item) return; if (!item) return;
const name = item.dataset.name; const name = item.dataset.name;
const tagId = item.dataset.tagId ? Number(item.dataset.tagId) : null;
if (tagInput && name) { if (tagInput && name) {
tagInput.value = name; tagInput.value = name;
submitTag(); submitTag(tagId);
} }
}); });
} }