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:
+57
-42
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user