diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index fd3c5ce..e6011b3 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -206,3 +206,22 @@ async def tags_prune_unused(): ) ) return jsonify(result) + + +@admin_bp.route("/tags/purge-retired-kinds", methods=["POST"]) +async def tags_purge_retired_kinds(): + """Tier-A: delete tags whose kind is a system/legacy kind FC no + longer creates (archive / post / artist — the IR-migration leftovers + like `BlenderKnight:Hannah_BJ_Loops`). dry-run preview returns + per-kind counts + a sample so the UI can show exactly what'll go + before the operator confirms with dry_run=false.""" + from ..services.cleanup_service import purge_tags_by_kind + + 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: purge_tags_by_kind(sync_sess, dry_run=dry_run) + ) + return jsonify(result) diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index 7025b8b..4470d3f 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -369,6 +369,48 @@ def prune_unused_tags(session: Session, *, dry_run: bool = False) -> dict: return {"deleted": len(ids), "sample_names": sample} +# System/legacy tag kinds that FC no longer creates: the tag input only +# makes character/fandom/series/general; provenance (post grouping) and +# archive membership are their own systems now, and artists are +# first-class Artist/Source rows. These linger only on IR-migrated rows. +# meta/rating were hard-deleted by alembic 0023, so they're not listed. +PURGEABLE_TAG_KINDS = ("archive", "post", "artist") + + +def purge_tags_by_kind( + session: Session, *, kinds: tuple[str, ...] = PURGEABLE_TAG_KINDS, + dry_run: bool = False, +) -> dict: + """Count (dry_run) or delete all tags whose kind is in `kinds`. + + CASCADE on image_tag / tag_alias / tag_allowlist / + tag_reference_embedding / tag_suggestion_rejection / series_page + clears the related rows on the parent DELETE. + + Returns: + {"by_kind": {kind: count, ...}, "count": total, + "sample_names": [first 50], and on live runs "deleted": total} + """ + rows = session.execute( + select(Tag.id, Tag.name, Tag.kind).where(Tag.kind.in_(kinds)) + ).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 + sample = [name for _id, name, _kind in rows[:50]] + total = len(rows) + if dry_run: + return {"by_kind": by_kind, "count": total, "sample_names": sample} + if total: + session.execute(Tag.__table__.delete().where(Tag.kind.in_(kinds))) + session.commit() + return { + "by_kind": by_kind, "count": total, "deleted": total, + "sample_names": sample, + } + + # --------------------------------------------------------------------------- # FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules. # --------------------------------------------------------------------------- diff --git a/frontend/src/components/modal/ProvenancePanel.vue b/frontend/src/components/modal/ProvenancePanel.vue index 178c6af..507a9e6 100644 --- a/frontend/src/components/modal/ProvenancePanel.vue +++ b/frontend/src/components/modal/ProvenancePanel.vue @@ -37,9 +37,13 @@ View images from this post + {{ expanded[e.provenance_id] ? 'Hide description ▴' : 'Show description ▾' }}
@@ -74,7 +78,7 @@