feat(provenance+tags): collapse provenance descriptions; purge retired-kind tags
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user