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:
@@ -245,6 +245,32 @@ async def tags_reset_content():
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@admin_bp.route("/tags/normalize", methods=["POST"])
|
||||
async def tags_normalize():
|
||||
"""#714: retro-normalize existing tags to the #701 canonical form (Title
|
||||
Case + collapsed whitespace) and merge case/whitespace-variant duplicates.
|
||||
|
||||
dry_run=true (default) returns a projection inline — group/collision/rename
|
||||
counts + a sample of the changes — so the UI shows exactly what'll happen.
|
||||
dry_run=false dispatches the long-running maintenance task (the merge FK
|
||||
repoints can touch many tags); the UI tails the activity dashboard for the
|
||||
summary. Idempotent; back up first (the merges are irreversible)."""
|
||||
from ..services.tag_service import normalize_existing_tags
|
||||
|
||||
body = await request.get_json(silent=True) or {}
|
||||
dry_run = bool(body.get("dry_run", True))
|
||||
|
||||
if dry_run:
|
||||
async with get_session() as session:
|
||||
result = await normalize_existing_tags(session, dry_run=True)
|
||||
return jsonify(result)
|
||||
|
||||
from ..tasks.admin import normalize_tags_task
|
||||
|
||||
async_result = normalize_tags_task.delay()
|
||||
return jsonify({"task_id": async_result.id, "status": "queued"}), 202
|
||||
|
||||
|
||||
@admin_bp.route("/maintenance/db-stats", methods=["GET"])
|
||||
async def db_stats():
|
||||
"""Per-table bloat readout (pg_stat_user_tables) for the high-churn tables
|
||||
|
||||
Reference in New Issue
Block a user