diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 9785cd7..9e4b2b2 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -224,6 +224,27 @@ async def tags_purge_legacy(): return jsonify(result) +@admin_bp.route("/tags/reset-content", methods=["POST"]) +async def tags_reset_content(): + """Tier-A: delete ALL general + character tags (the Camie-suggestable + content vocabulary) so the operator can re-tag from scratch via + auto-suggest. fandom + series tags + series_page ordering are preserved, + and image tagger_predictions are untouched so suggestions repopulate. + dry-run preview returns per-kind counts + applications + a sample so the + UI shows exactly what'll go before the operator confirms (dry_run=false). + Irreversible except via DB backup restore.""" + from ..services.cleanup_service import reset_content_tagging + + body = await request.get_json(silent=True) or {} + dry_run = bool(body.get("dry_run", False)) + + async with get_session() as session: + result = await session.run_sync( + lambda sync_sess: reset_content_tagging(sync_sess, dry_run=dry_run) + ) + return jsonify(result) + + @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 diff --git a/backend/app/api/cleanup.py b/backend/app/api/cleanup.py index 24853fe..68e0f00 100644 --- a/backend/app/api/cleanup.py +++ b/backend/app/api/cleanup.py @@ -154,12 +154,15 @@ async def audit_history(): limit = min(int(request.args.get("limit", "20")), 100) except ValueError: return _bad("invalid_limit") + # Optional rule filter so a card can reconnect to ITS latest run on mount + # (?rule=transparency&limit=1) — the audit survives navigation; the UI + # rehydrates from this rather than losing the in-flight scan. + rule = request.args.get("rule") or None async with get_session() as session: - rows = (await session.execute( - select(LibraryAuditRun) - .order_by(LibraryAuditRun.id.desc()) - .limit(limit) - )).scalars().all() + stmt = select(LibraryAuditRun).order_by(LibraryAuditRun.id.desc()) + if rule is not None: + stmt = stmt.where(LibraryAuditRun.rule == rule) + rows = (await session.execute(stmt.limit(limit))).scalars().all() return jsonify({"runs": [_serialize_audit_run(r) for r in rows]}) diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index 58aab98..f4ee687 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -455,6 +455,62 @@ def purge_legacy_tags(session: Session, *, dry_run: bool = False) -> dict: return result +# The Camie-suggestable CONTENT vocabulary. "Reset content tagging" wipes +# these so the operator can re-tag from scratch via auto-suggest. fandom + +# series (and series_page ordering) are deliberately NOT here — they're kept. +RESETTABLE_TAG_KINDS = ("general", "character") + + +def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict: + """Count (dry_run) or DELETE every general + character tag so the operator + can re-tag from scratch via the Camie auto-suggest. + + PRESERVED: fandom + series tags and their series_page ordering, plus every + image's image_record.tagger_predictions (untouched) so suggestions + repopulate immediately. CASCADE on image_tag / tag_alias / tag_allowlist / + tag_reference_embedding / tag_suggestion_rejection clears each deleted + tag's applications + metadata. Tag.fandom_id is SET NULL, so deleting + character tags never touches the fandom rows. Irreversible except via DB + backup restore. + + Returns: + {"by_kind": {"general": N, "character": M}, + "count": total tags, + "applications": image_tag rows that will be / were removed, + "sample_names": [first 50], + and on live runs "deleted": total} + """ + predicate = Tag.kind.in_(RESETTABLE_TAG_KINDS) + rows = session.execute( + select(Tag.id, Tag.name, Tag.kind).where(predicate) + ).all() + by_kind: dict[str, int] = {} + for _id, _name, kind in rows: + key = kind.value if hasattr(kind, "value") else str(kind) + by_kind[key] = by_kind.get(key, 0) + 1 + # Headline impact: applications (image_tag rows) that vanish via cascade. + applications = session.execute( + select(func.count()) + .select_from(image_tag) + .where(image_tag.c.tag_id.in_(select(Tag.id).where(predicate))) + ).scalar_one() + sample = [name for _id, name, _kind in rows[:50]] + total = len(rows) + result = { + "by_kind": by_kind, + "count": total, + "applications": applications, + "sample_names": sample, + } + if dry_run: + return result + if total: + session.execute(Tag.__table__.delete().where(predicate)) + session.commit() + result["deleted"] = total + return result + + # --------------------------------------------------------------------------- # FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules. # --------------------------------------------------------------------------- diff --git a/frontend/src/components/cleanup/SingleColorAuditCard.vue b/frontend/src/components/cleanup/SingleColorAuditCard.vue index 08c6476..dc75e4c 100644 --- a/frontend/src/components/cleanup/SingleColorAuditCard.vue +++ b/frontend/src/components/cleanup/SingleColorAuditCard.vue @@ -112,6 +112,16 @@ onMounted(async () => { await store.loadDefaults() threshold.value = store.defaults.single_color_threshold tolerance.value = store.defaults.single_color_tolerance + // Reconnect to this rule's latest run so a scan started before navigating + // away keeps showing progress / its result on return (the scan itself runs + // backend-side regardless). + try { + const latest = await store.latestAuditForRule('single_color') + if (latest) { + audit.value = latest + if (latest.status === 'running') startPoll(latest.id) + } + } catch { /* non-fatal — card still works for a fresh scan */ } }) onUnmounted(() => stopPoll()) diff --git a/frontend/src/components/cleanup/TransparencyAuditCard.vue b/frontend/src/components/cleanup/TransparencyAuditCard.vue index 6e26f99..5fb7523 100644 --- a/frontend/src/components/cleanup/TransparencyAuditCard.vue +++ b/frontend/src/components/cleanup/TransparencyAuditCard.vue @@ -97,6 +97,16 @@ let pollTimer = null onMounted(async () => { await store.loadDefaults() threshold.value = store.defaults.transparency_threshold + // Reconnect to this rule's latest run so a scan started before navigating + // away keeps showing progress / its result on return (the scan itself runs + // backend-side regardless). + try { + const latest = await store.latestAuditForRule('transparency') + if (latest) { + audit.value = latest + if (latest.status === 'running') startPoll(latest.id) + } + } catch { /* non-fatal — card still works for a fresh scan */ } }) onUnmounted(() => stopPoll()) diff --git a/frontend/src/components/gallery/GalleryGrid.vue b/frontend/src/components/gallery/GalleryGrid.vue index 29db64f..27ac70c 100644 --- a/frontend/src/components/gallery/GalleryGrid.vue +++ b/frontend/src/components/gallery/GalleryGrid.vue @@ -15,6 +15,19 @@ + + + diff --git a/frontend/src/components/settings/TagMaintenanceCard.vue b/frontend/src/components/settings/TagMaintenanceCard.vue index 6ea69c8..db8fc73 100644 --- a/frontend/src/components/settings/TagMaintenanceCard.vue +++ b/frontend/src/components/settings/TagMaintenanceCard.vue @@ -89,6 +89,53 @@ @click="onKindCommit" >Delete {{ kindPreview.count }} legacy tag(s) + + + +

+ Reset content tagging. + Deletes every general and character tag and + removes them from every image, so you can re-tag from scratch with the + auto-suggest. Fandoms and series (with their page order) are + kept, and each image's saved predictions are untouched — open + an image and its suggestions reappear. +

+ + Irreversible — there's no undo except restoring a DB backup. + Back one up first (Settings → Maintenance → Backup). + + + Preview content-tag reset + +
+

+ {{ resetPreview.count }} content tag(s) + + ({{ k }}: {{ n }})  + + across {{ resetPreview.applications }} image + application(s). +

+
+ + {{ n }} + +
+ Delete {{ resetPreview.count }} content tag(s) + + {{ resetPreview.applications }} application(s) +
@@ -105,6 +152,9 @@ const committing = ref(false) const kindPreview = ref(null) const loadingKindPreview = ref(false) const kindCommitting = ref(false) +const resetPreview = ref(null) +const loadingResetPreview = ref(false) +const resetCommitting = ref(false) async function onPreview() { loadingPreview.value = true @@ -143,6 +193,25 @@ async function onKindCommit() { kindCommitting.value = false } } + +async function onResetPreview() { + loadingResetPreview.value = true + try { + resetPreview.value = await store.resetContentTagging({ dryRun: true }) + } finally { + loadingResetPreview.value = false + } +} + +async function onResetCommit() { + resetCommitting.value = true + try { + await store.resetContentTagging({ dryRun: false }) + resetPreview.value = { count: 0, by_kind: {}, applications: 0, sample_names: [] } + } finally { + resetCommitting.value = false + } +}