feat(tags): retro-normalize existing tags to Title Case + merge case-collisions (plan #714)
CI / lint (push) Failing after 2s
CI / frontend-build (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m2s

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:
2026-06-06 16:28:34 -04:00
parent 23f452021f
commit 3c89223dcb
6 changed files with 526 additions and 0 deletions
@@ -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&nbsp;&nbsp;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>
+17
View File
@@ -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,
}
})