feat(tags): retro-normalize existing tags to Title Case + merge case-collisions (plan #714)
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>
This commit is contained in:
@@ -136,6 +136,64 @@
|
||||
>Delete {{ resetPreview.count }} content tag(s) +
|
||||
{{ resetPreview.applications }} application(s)</v-btn>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-5" />
|
||||
|
||||
<!-- #714: standardize existing tags to the canonical Title-Case form
|
||||
and merge case/whitespace-variant duplicates. -->
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>Standardize tag casing.</strong>
|
||||
New tags are saved Title Case, but older tags keep whatever casing they
|
||||
were created with. This renames every existing tag to
|
||||
<code>Title Case</code> (collapsing extra spaces) and
|
||||
<strong>merges</strong> tags that differ only by case or spacing
|
||||
(e.g. <code>hatsune miku</code> + <code>Hatsune Miku</code>)
|
||||
into one — repointing their images, allowlist entries and series pages.
|
||||
</p>
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||
The merges are irreversible — back up first
|
||||
(Settings → Maintenance → Backup). Safe to run more than once.
|
||||
</v-alert>
|
||||
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingNormPreview"
|
||||
class="mb-3"
|
||||
@click="onNormPreview"
|
||||
>Preview tag standardization</v-btn>
|
||||
|
||||
<div v-if="normPreview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ normPreview.total_changes }}</strong> tag group(s) to
|
||||
change — <strong>{{ normPreview.tags_to_rename }}</strong> rename(s),
|
||||
<strong>{{ normPreview.collisions }}</strong> collision(s) merging
|
||||
<strong>{{ normPreview.tags_to_merge }}</strong> tag(s) away.
|
||||
</p>
|
||||
<div v-if="normPreview.sample?.length" class="fc-name-grid mb-3">
|
||||
<span
|
||||
v-for="s in normPreview.sample" :key="s.to + s.kind"
|
||||
class="fc-name"
|
||||
>
|
||||
<template v-if="s.merge">{{ s.from.join(' + ') }} → </template>{{ s.to }}
|
||||
</span>
|
||||
</div>
|
||||
<v-btn
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-format-letter-case"
|
||||
:disabled="!normPreview.total_changes"
|
||||
:loading="normCommitting"
|
||||
@click="onNormCommit"
|
||||
>Standardize {{ normPreview.total_changes }} tag group(s)</v-btn>
|
||||
<span
|
||||
v-if="normResult"
|
||||
class="ml-3 text-caption"
|
||||
:class="normResult === 'ok' ? 'text-success' : 'text-warning'"
|
||||
>
|
||||
<template v-if="normResult === 'ok'">Standardization complete ✓</template>
|
||||
<template v-else>Finished with status: {{ normResult }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
@@ -155,6 +213,10 @@ const kindCommitting = ref(false)
|
||||
const resetPreview = ref(null)
|
||||
const loadingResetPreview = ref(false)
|
||||
const resetCommitting = ref(false)
|
||||
const normPreview = ref(null)
|
||||
const loadingNormPreview = ref(false)
|
||||
const normCommitting = ref(false)
|
||||
const normResult = ref(null)
|
||||
|
||||
async function onPreview() {
|
||||
loadingPreview.value = true
|
||||
@@ -212,6 +274,33 @@ async function onResetCommit() {
|
||||
resetCommitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onNormPreview() {
|
||||
loadingNormPreview.value = true
|
||||
normResult.value = null
|
||||
try {
|
||||
normPreview.value = await store.normalizeTags({ dryRun: true })
|
||||
} finally {
|
||||
loadingNormPreview.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onNormCommit() {
|
||||
normCommitting.value = true
|
||||
normResult.value = null
|
||||
try {
|
||||
// Long op (FK repoints): enqueue the maintenance task, then tail the
|
||||
// activity dashboard until its row reaches a terminal status. (The
|
||||
// per-run summary dict isn't exposed by /activity/runs, so we surface
|
||||
// the terminal status — the dry-run preview is the detailed view.)
|
||||
const { task_id: taskId } = await store.normalizeTags({ dryRun: false })
|
||||
const row = await store.pollTaskUntilDone(taskId)
|
||||
normResult.value = row?.status || 'ok'
|
||||
normPreview.value = { total_changes: 0, tags_to_rename: 0, collisions: 0, tags_to_merge: 0, sample: [] }
|
||||
} finally {
|
||||
normCommitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -142,6 +142,22 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// #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) --------
|
||||
|
||||
/**
|
||||
@@ -178,6 +194,7 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
pruneUnusedTags,
|
||||
purgeLegacyTags,
|
||||
resetContentTagging,
|
||||
normalizeTags,
|
||||
pollTaskUntilDone,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user