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>