From e1fc65bd1bdbcb84fdb48716097cc1048bdf81ff Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 01:12:20 -0400 Subject: [PATCH] feat(provenance+tags): collapse provenance descriptions; purge retired-kind tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-flagged 2026-05-28, two asks. **1. Provenance posts ate the panel.** Each post card rendered its full description (180px scroll box) inline, so a few posts pushed everything else off-screen. ProvenancePanel now collapses the description by default behind a per-post "Show description ▾ / Hide ▴" toggle (state keyed by provenance_id, reset when the viewed image changes). Cards stay compact — platform/date/title/meta/actions — and the operator expands only the descriptions they want. **2. Purge tags of retired/system kinds.** The IR migration left `archive`/`post`/`artist`-kind tags (e.g. `BlenderKnight:Hannah_BJ_Loops`) that FC no longer creates — the tag input only makes character/fandom/series/general, and provenance + artists are their own systems now. (meta/rating were already hard-deleted by alembic 0023.) - cleanup_service.purge_tags_by_kind(kinds=PURGEABLE_TAG_KINDS) — counts (dry_run) or deletes tags whose kind ∈ (archive, post, artist). CASCADE clears the image_tag / alias / allowlist / etc. rows. - POST /api/admin/tags/purge-retired-kinds (Tier-A, dry-run preview returns per-kind counts + sample names — the preview IS the verification of exactly what'll be deleted before committing). - Tag Maintenance card gets a second section: "Preview retired-kind tags" → per-kind breakdown + sample → "Delete N retired-kind tag(s)". Tests: dry-run counts by kind (general survives), commit deletes only the retired kinds (general + character survive, retired count → 0). NOTE: a dry-run preview will show exactly which kinds/counts are present. If the operator's noisy tags turn out to be `general` (e.g. an IR `source:patreon` that fell back to general during migration), they won't be caught by the kind purge — the preview makes that visible so we can decide on a name-based pass separately. --- backend/app/api/admin.py | 19 ++++++ backend/app/services/cleanup_service.py | 42 +++++++++++++ .../src/components/modal/ProvenancePanel.vue | 18 +++++- .../settings/TagMaintenanceCard.vue | 62 +++++++++++++++++++ frontend/src/stores/admin.js | 14 +++++ tests/test_api_admin.py | 50 +++++++++++++++ 6 files changed, 203 insertions(+), 2 deletions(-) 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 @@