3c89223dcb
Follow-up to #701: new tags are saved canonical, but the back-catalog keeps whatever casing it was created with. This adds a maintenance action that Title-Cases every existing tag (collapsing whitespace) and merges case/whitespace-variant duplicates into one. Backend: - tag_service.normalize_existing_tags(session, *, dry_run): groups all tags by (kind, coalesce(fandom_id,-1), canonical_name). Per group it picks a survivor (prefer an already-canonical member → no rename/self-alias; else the best-connected tag → fewest FK repoints; else lowest id), merges the variants INTO it via the tested TagService._do_merge (image_tag/allowlist/embedding/ aliases/series_page repoints + protective ML aliases), then renames the survivor to canonical. Losers are deleted before the rename so there's no transient unique-index clash; commits per group and isolates failures per group. Idempotent — an already-canonical lone tag is a no-op. - normalize_tags_task (maintenance queue, asyncio.run + per-task NullPool async engine, soft 1800/hard 2400) — recovery/timeout/duration covered by FC-3i. - POST /api/admin/tags/normalize: dry_run=true returns a projection inline (group/collision/rename counts + sample); dry_run=false enqueues the task. Frontend: a "Standardize tag casing" section in TagMaintenanceCard (Cleanup tab) — preview → apply (polls the activity dashboard to terminal status), behind a back-up-first warning. admin store gains normalizeTags(). Tests: tests/test_tag_normalize.py — dry-run counts, live merge + image-tag dedup/repoint, idempotency, same-name-different-fandom and -different-kind kept separate, ML-known loser keeps a protective alias. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
201 lines
5.1 KiB
JavaScript
201 lines
5.1 KiB
JavaScript
import { defineStore } from 'pinia'
|
|
import { ref } from 'vue'
|
|
|
|
import { useApi } from '../composables/useApi.js'
|
|
|
|
export const useAdminStore = defineStore('admin', () => {
|
|
const api = useApi()
|
|
const lastError = ref(null)
|
|
|
|
// --- Tier-C: artist cascade ---------------------------------------
|
|
|
|
async function projectArtistCascade(slug) {
|
|
lastError.value = null
|
|
try {
|
|
return await api.post(
|
|
`/api/admin/artists/${encodeURIComponent(slug)}/cascade-delete`,
|
|
{ body: { dry_run: true } },
|
|
)
|
|
} catch (e) {
|
|
lastError.value = e.message
|
|
throw e
|
|
}
|
|
}
|
|
|
|
async function dispatchArtistCascade(slug, confirm) {
|
|
lastError.value = null
|
|
try {
|
|
return await api.post(
|
|
`/api/admin/artists/${encodeURIComponent(slug)}/cascade-delete`,
|
|
{ body: { dry_run: false, confirm } },
|
|
)
|
|
} catch (e) {
|
|
lastError.value = e.message
|
|
throw e
|
|
}
|
|
}
|
|
|
|
// --- Tier-C: bulk image delete ------------------------------------
|
|
|
|
async function projectBulkImageDelete(imageIds) {
|
|
lastError.value = null
|
|
try {
|
|
return await api.post(
|
|
'/api/admin/images/bulk-delete',
|
|
{ body: { image_ids: imageIds, dry_run: true } },
|
|
)
|
|
} catch (e) {
|
|
lastError.value = e.message
|
|
throw e
|
|
}
|
|
}
|
|
|
|
async function dispatchBulkImageDelete(imageIds, confirm) {
|
|
lastError.value = null
|
|
try {
|
|
return await api.post(
|
|
'/api/admin/images/bulk-delete',
|
|
{ body: { image_ids: imageIds, dry_run: false, confirm } },
|
|
)
|
|
} catch (e) {
|
|
lastError.value = e.message
|
|
throw e
|
|
}
|
|
}
|
|
|
|
// --- Tier-B: tag delete + merge -----------------------------------
|
|
|
|
async function deleteTag(tagId) {
|
|
lastError.value = null
|
|
try {
|
|
return await api.delete(`/api/admin/tags/${tagId}`)
|
|
} catch (e) {
|
|
lastError.value = e.message
|
|
throw e
|
|
}
|
|
}
|
|
|
|
async function mergeTags(destId, sourceId) {
|
|
lastError.value = null
|
|
try {
|
|
return await api.post(
|
|
`/api/admin/tags/${destId}/merge`,
|
|
{ body: { source_id: sourceId } },
|
|
)
|
|
} catch (e) {
|
|
lastError.value = e.message
|
|
throw e
|
|
}
|
|
}
|
|
|
|
async function tagUsageCount(tagId) {
|
|
lastError.value = null
|
|
try {
|
|
const body = await api.get(`/api/admin/tags/${tagId}/usage-count`)
|
|
return body.count
|
|
} catch (e) {
|
|
lastError.value = e.message
|
|
throw e
|
|
}
|
|
}
|
|
|
|
// --- Tier-A: prune unused -----------------------------------------
|
|
|
|
async function pruneUnusedTags({ dryRun = true } = {}) {
|
|
lastError.value = null
|
|
try {
|
|
return await api.post(
|
|
'/api/admin/tags/prune-unused',
|
|
{ body: { dry_run: dryRun } },
|
|
)
|
|
} catch (e) {
|
|
lastError.value = e.message
|
|
throw e
|
|
}
|
|
}
|
|
|
|
async function purgeLegacyTags({ dryRun = true } = {}) {
|
|
lastError.value = null
|
|
try {
|
|
return await api.post(
|
|
'/api/admin/tags/purge-legacy',
|
|
{ body: { dry_run: dryRun } },
|
|
)
|
|
} catch (e) {
|
|
lastError.value = e.message
|
|
throw e
|
|
}
|
|
}
|
|
|
|
// Destructive: deletes ALL general + character tags so the operator can
|
|
// re-tag from scratch via auto-suggest. fandom + series preserved.
|
|
async function resetContentTagging({ dryRun = true } = {}) {
|
|
lastError.value = null
|
|
try {
|
|
return await api.post(
|
|
'/api/admin/tags/reset-content',
|
|
{ body: { dry_run: dryRun } },
|
|
)
|
|
} catch (e) {
|
|
lastError.value = e.message
|
|
throw e
|
|
}
|
|
}
|
|
|
|
// #714: Title-Case the back-catalog + merge case/whitespace-variant tags.
|
|
// dry-run returns a projection inline; live returns {task_id} (long op —
|
|
// FK repoints) the caller polls via pollTaskUntilDone.
|
|
async function normalizeTags({ dryRun = true } = {}) {
|
|
lastError.value = null
|
|
try {
|
|
return await api.post(
|
|
'/api/admin/tags/normalize',
|
|
{ body: { dry_run: dryRun } },
|
|
)
|
|
} catch (e) {
|
|
lastError.value = e.message
|
|
throw e
|
|
}
|
|
}
|
|
|
|
// --- Task progress polling (taps FC-3i activity dashboard) --------
|
|
|
|
/**
|
|
* Polls /api/system/activity/runs?queue=maintenance every 3s,
|
|
* resolves when a task_run row with the given celery task_id
|
|
* reaches a terminal status (ok / error / timeout). Returns the
|
|
* row. Times out after 30 min by default.
|
|
*/
|
|
async function pollTaskUntilDone(taskId, { timeoutMs = 1_800_000 } = {}) {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
const body = await api.get(
|
|
'/api/system/activity/runs',
|
|
{ params: { queue: 'maintenance', limit: 20 } },
|
|
)
|
|
const row = (body.runs || []).find(r => r.celery_task_id === taskId)
|
|
if (row && ['ok', 'error', 'timeout'].includes(row.status)) {
|
|
return row
|
|
}
|
|
await new Promise((r) => setTimeout(r, 3000))
|
|
}
|
|
throw new Error(`task ${taskId} did not finish within ${timeoutMs}ms`)
|
|
}
|
|
|
|
return {
|
|
lastError,
|
|
projectArtistCascade,
|
|
dispatchArtistCascade,
|
|
projectBulkImageDelete,
|
|
dispatchBulkImageDelete,
|
|
deleteTag,
|
|
mergeTags,
|
|
tagUsageCount,
|
|
pruneUnusedTags,
|
|
purgeLegacyTags,
|
|
resetContentTagging,
|
|
normalizeTags,
|
|
pollTaskUntilDone,
|
|
}
|
|
})
|