e66987f092
Extracts gallery.js's hand-rolled inflightId pattern into a new useInflightToken composable; adopts in every store that previously had no guard against late-response overwrites or wrong-image URL interpolation. Two operator-impacting bugs the audit (workflow wf_bbe3fdb1-e62) flagged: - modal.removeTag rolled back the chip rail unconditionally even when only the secondary dismiss POST had failed — UI lied until refresh. And all tag-mutation URLs interpolated currentImageId AFTER an await, so a fast prev/next could route DELETE/POST to the wrong image. Both fixed: split try/catch (dismiss failure surfaces a warning, doesn't roll back the delete); imageId captured at call-time and used in URLs throughout. - suggestions.accept dereferenced currentImageId after the awaited POST /api/tags, so the subsequent /suggestions/accept could apply A's chosen tag to image B AND push it to B's allowlist. Fixed by capturing imageId at click-time + inflight guard on load(). Same shape across artist / downloads / artistDirectory / tagDirectory / posts stores: rapid filter/nav changes used to interleave responses (last-writer-wins). Now the late response is discarded and the most-recent request wins. Filter-change-during- search no longer drops the second fetch because the loading flag was still true from the first. gallery.js's inflightId removed in favor of the shared composable so the pattern stays consistent.
121 lines
4.4 KiB
JavaScript
121 lines
4.4 KiB
JavaScript
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
|
|
}
|
|
})
|