From 3d6413c6390e08b5db74f97ca7adf8cbfdf71484 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 18:57:37 -0400 Subject: [PATCH] fix(modal): autocomplete picks resolve by tag_id, not just by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/main.py | 99 +++++++++++++++++++++---------------- app/static/js/view-modal.js | 38 +++++++++----- 2 files changed, 82 insertions(+), 55 deletions(-) diff --git a/app/main.py b/app/main.py index dc4b5d0..156438e 100644 --- a/app/main.py +++ b/app/main.py @@ -932,61 +932,76 @@ def add_tag(image_id): """ JSON endpoint to add a tag to an image. - Accepts the user-facing 'kind:name' shortcut in the 'name' field - (e.g. 'character:Saber'). Bare strings without a recognized prefix are - treated as user tags. + Resolution order: + - If 'tag_id' is provided, attach that exact tag (used when the + 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' - attaches the character to a fandom. Both omitted = null fandom (allowed). + For character tags created via the parse path, optional 'fandom_id' + (preferred) or 'fandom_name' attaches the character to a fandom. Both + omitted = null fandom (allowed). """ from app.utils.tag_prefix import parse_kind_prefix from app.utils.tag_names import underscores_to_spaces - raw = (request.form.get("name") or "").strip() - if not raw: - abort(400) - - kind, name = parse_kind_prefix(raw) - if kind is None: - # No recognized prefix — treat as a user-typed general tag. - kind = "user" - name = underscores_to_spaces(name) - elif kind in ("character", "fandom"): - name = normalize_display_name(name) - img = ImageRecord.query.get_or_404(image_id) + explicit_tag_id = request.form.get("tag_id", type=int) fandom_tag = None - if kind == "character": - fandom_id = request.form.get("fandom_id", type=int) - fandom_name = (request.form.get("fandom_name") or "").strip() or None - if fandom_id: - fandom_tag = Tag.query.filter_by(id=fandom_id, kind="fandom").first() - if fandom_tag is None: - return jsonify(ok=False, error="fandom_not_found"), 400 - elif fandom_name: - fandom_tag = _ensure_fandom_tag(fandom_name) - - tag = Tag.query.filter_by( - kind="character", - name=name, - fandom_id=fandom_tag.id if fandom_tag else None, - ).first() + if explicit_tag_id: + # Autocomplete-picker path: attach the exact clicked tag. + tag = Tag.query.get(explicit_tag_id) 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", name=name, fandom_id=fandom_tag.id if fandom_tag else None, - ) - db.session.add(tag) - db.session.flush() - else: - tag = Tag.query.filter_by(kind=kind, name=name).first() - if tag is None: - tag = Tag(kind=kind, name=name) - db.session.add(tag) - db.session.flush() + ).first() + if tag is None: + tag = Tag( + kind="character", + name=name, + fandom_id=fandom_tag.id if fandom_tag else None, + ) + db.session.add(tag) + db.session.flush() + else: + tag = Tag.query.filter_by(kind=kind, name=name).first() + if tag is None: + tag = Tag(kind=kind, name=name) + db.session.add(tag) + db.session.flush() if tag not in img.tags: img.tags.append(tag) diff --git a/app/static/js/view-modal.js b/app/static/js/view-modal.js index ed4447f..4a9212c 100644 --- a/app/static/js/view-modal.js +++ b/app/static/js/view-modal.js @@ -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(); - fd.append('name', name); - if (fandomId) { - fd.append('fandom_id', String(fandomId)); - } else if (fandomName) { - fd.append('fandom_name', fandomName); + if (tagId) { + // Autocomplete-picker path: server attaches this exact tag, skipping + // parse_kind_prefix so a same-name character/user ambiguity is + // resolved by the row the user clicked. + 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 j = await r.json(); @@ -747,7 +754,7 @@ document.addEventListener('DOMContentLoaded', () => { tagAutocomplete.innerHTML = tags.map((t, i) => { const icon = getTagIcon(t.kind); const displayName = getTagDisplayName(t.name); - return `
+ return `
${icon} ${displayName} ${t.kind || 'user'}
`; @@ -778,14 +785,18 @@ document.addEventListener('DOMContentLoaded', () => { } } - // Shared function to submit a tag (avoids page refresh issues with form dispatch) - async function submitTag() { + // Shared function to submit a tag (avoids page refresh issues with form dispatch). + // `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 name = (tagInput?.value || '').trim(); - if (!id || !name) return; + if (!id || (!name && !pickedTagId)) return; const fandomId = fandomPickerInput?.dataset?.fandomId; const fandomName = (fandomPickerInput?.value || '').trim(); const ok = await addTag(id, name, { + tagId: pickedTagId || null, fandomId: fandomId ? Number(fandomId) : null, fandomName: fandomName || null, }); @@ -839,8 +850,8 @@ document.addEventListener('DOMContentLoaded', () => { e.preventDefault(); const selected = autocompleteItems[autocompleteSelectedIndex]; if (selected) { - tagInput.value = selected.name; - submitTag(); + tagInput.value = selected.display_name || selected.name; + submitTag(selected.id); } } else if (e.key === 'Escape') { selectAutocompleteItem(-1); @@ -857,9 +868,10 @@ document.addEventListener('DOMContentLoaded', () => { const item = e.target.closest('.tag-autocomplete-item'); if (!item) return; const name = item.dataset.name; + const tagId = item.dataset.tagId ? Number(item.dataset.tagId) : null; if (tagInput && name) { tagInput.value = name; - submitTag(); + submitTag(tagId); } }); }