import { defineStore } from 'pinia' import { toast } from '../utils/toast.js' import { ref } from 'vue' import { useApi } from '../composables/useApi.js' import { useAsyncAction } from '../composables/useAsyncAction.js' import { useInflightToken } from '../composables/useInflightToken.js' // Category display order: people first, general last. // 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired — only // character and general surface as suggestion categories now. export const CATEGORY_ORDER = ['character', 'general'] export const CATEGORY_LABELS = { character: 'Character', general: 'General' } export const useSuggestionsStore = defineStore('suggestions', () => { const api = useApi() const byCategory = ref({}) // { category: [suggestion, ...] } const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) let currentImageId = null // Audit 2026-06-02: this store had no inflight guard — a late // /suggestions response from a prior image could overwrite // byCategory while currentImageId pointed at a new one, and // accept() dereferenced currentImageId AFTER an awaited POST so // the subsequent /suggestions/accept could apply A's chosen tag // to image B (and push it to the allowlist). Both fixed below // by capturing imageId at call-time and gating writes on the token. const inflight = useInflightToken() async function load(imageId) { // Cancel any in-flight load from the previous image so its late // response can't overwrite this image's byCategory. inflight.cancel() currentImageId = imageId byCategory.value = {} // cleared upfront so it stays empty on error const t = inflight.claim() await run(async () => { const body = await api.get(`/api/images/${imageId}/suggestions`) if (!t.isCurrent()) return byCategory.value = body.by_category || {} }) } function _drop(category, predicate) { const list = byCategory.value[category] if (!list) return byCategory.value[category] = list.filter(s => !predicate(s)) } async function accept(suggestion) { // Capture imageId so a mid-flight prev/next can't reroute the // accept POST to a different image AND push the tag to that // image's allowlist. const imageId = currentImageId if (imageId == null) return // Raw tags (creates_new_tag) have no canonical_tag_id; the backend's // accept endpoint needs a tag_id, so for raw tags we create the tag // first via the existing /api/tags endpoint, then accept by id. let tagId = suggestion.canonical_tag_id if (tagId == null) { const created = await api.post('/api/tags', { body: { name: suggestion.display_name, kind: suggestion.category } }) tagId = created.id } await api.post(`/api/images/${imageId}/suggestions/accept`, { body: { tag_id: tagId } }) // Only drop from THIS image's category list — if the user navigated, // the new image has its own suggestions and this drop would corrupt them. if (currentImageId === imageId) { _drop(suggestion.category, s => s === suggestion) } toast({ text: `Tagged: ${suggestion.display_name}`, type: 'success' }) } async function aliasAccept(suggestion, canonicalTagId) { const imageId = currentImageId if (imageId == null) return await api.post(`/api/images/${imageId}/suggestions/alias`, { body: { alias_string: suggestion.display_name, alias_category: suggestion.category, canonical_tag_id: canonicalTagId } }) if (currentImageId === imageId) { _drop(suggestion.category, s => s === suggestion) } toast({ text: `Aliased & tagged: ${suggestion.display_name}`, type: 'success' }) } async function dismiss(suggestion) { const imageId = currentImageId if (imageId == null) return // Dismiss needs a tag_id; raw tags have none, so dismissing a raw // suggestion just hides it client-side (nothing to persist a rejection // against until the tag exists). if (suggestion.canonical_tag_id != null) { await api.post(`/api/images/${imageId}/suggestions/dismiss`, { body: { tag_id: suggestion.canonical_tag_id } }) } if (currentImageId === imageId) { _drop(suggestion.category, s => s === suggestion) } } return { byCategory, loading, error, load, accept, aliasAccept, dismiss } })