feat(tags): legacy-tag purge also catches source:* general tags

Operator-confirmed 2026-05-28: the BlenderKnight:* tags are `archive`
kind (caught by the kind purge), but the `source:patreon`-style tags
are IR's old `source` kind that fell back to `general` during migration
(FC's enum has no `source` kind) — so they can't be matched by kind.

Broadened the purge to a two-rule match and renamed it for accuracy
(all dev-only, unreleased):
- cleanup_service.purge_tags_by_kind → purge_legacy_tags. Predicate is
  now `kind IN (archive, post, artist) OR name LIKE 'source:%'`
  (LEGACY_NAME_PREFIXES). Preview classifies each row into by_kind OR
  by_prefix (source:* counts once under the prefix bucket regardless of
  its general kind).
- endpoint /tags/purge-retired-kinds → /tags/purge-legacy. dry-run
  returns by_kind + by_prefix + count + sample.
- store purgeRetiredKindTags → purgeLegacyTags.
- Tag Maintenance card copy + breakdown updated to show both buckets;
  button reads "Preview/Delete legacy tags".

Tests updated + extended: dry-run reports by_kind {archive,post,artist}
AND by_prefix {source:*}, plain general/character tags survive; commit
deletes both the kind-matched and source:*-matched rows and leaves the
rest.
This commit is contained in:
2026-05-28 01:20:41 -04:00
parent e1fc65bd1b
commit bf8eb4468f
5 changed files with 104 additions and 66 deletions
+11 -9
View File
@@ -6,6 +6,7 @@ Five action surfaces:
DELETE /api/admin/tags/<int:tag_id> (Tier B)
POST /api/admin/tags/<int:dest_id>/merge (Tier B)
POST /api/admin/tags/prune-unused (Tier A)
POST /api/admin/tags/purge-legacy (Tier A)
GET /api/admin/tags/<int:tag_id>/usage-count (helper)
Tier-C ops take a dry_run body flag (returns projection inline,
@@ -208,20 +209,21 @@ 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
@admin_bp.route("/tags/purge-legacy", methods=["POST"])
async def tags_purge_legacy():
"""Tier-A: delete legacy IR-migration tags — archive/post/artist
kinds (e.g. `BlenderKnight:Hannah_BJ_Loops`) PLUS general tags with
a legacy name prefix (`source:*`, from IR's source kind that fell
back to general). dry-run preview returns per-kind + per-prefix
counts + a sample so the UI shows exactly what'll go before the
operator confirms with dry_run=false."""
from ..services.cleanup_service import purge_legacy_tags
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)
lambda sync_sess: purge_legacy_tags(sync_sess, dry_run=dry_run)
)
return jsonify(result)
+49 -25
View File
@@ -16,7 +16,7 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy import func, or_, select, update
from sqlalchemy.orm import Session
from ..models import Artist, ImageRecord, LibraryAuditRun, Tag
@@ -369,46 +369,70 @@ 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.
# Legacy tags FC no longer uses, in two shapes:
# (1) kinds the tag input never produces — archive/post/artist.
# provenance (post grouping) + archive membership are their own
# systems now, and artists are first-class Artist/Source rows.
# meta/rating were already hard-deleted by alembic 0023.
# (2) name prefixes from IR kinds FC never adopted — `source:*`.
# ImageRepo had a `source` kind; FC's enum doesn't, so ir_ingest
# fell those back to `general` (kind=general, name="source:patreon"
# etc.). They can't be caught by kind, so we match the name prefix.
PURGEABLE_TAG_KINDS = ("archive", "post", "artist")
LEGACY_NAME_PREFIXES = ("source:",)
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`.
def _legacy_tag_predicate():
name_clauses = [Tag.name.like(f"{p}%") for p in LEGACY_NAME_PREFIXES]
return or_(Tag.kind.in_(PURGEABLE_TAG_KINDS), *name_clauses)
def purge_legacy_tags(session: Session, *, dry_run: bool = False) -> dict:
"""Count (dry_run) or delete legacy IR-migration tags: archive/post/
artist-kind tags PLUS general tags whose name matches a legacy
prefix (source:*).
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}
{"by_kind": {kind: count, ...}, # kind-matched rows
"by_prefix": {"source:*": count}, # name-prefix-matched rows
"count": total, "sample_names": [first 50],
and on live runs "deleted": total}
"""
predicate = _legacy_tag_predicate()
rows = session.execute(
select(Tag.id, Tag.name, Tag.kind).where(Tag.kind.in_(kinds))
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
by_prefix: dict[str, int] = {}
for _id, name, kind in rows:
# Classify by name-prefix first so a source:* row counts once,
# under the prefix bucket, regardless of its (general) kind.
matched_prefix = next(
(p for p in LEGACY_NAME_PREFIXES if name.startswith(p)), None,
)
if matched_prefix is not None:
label = f"{matched_prefix}*"
by_prefix[label] = by_prefix.get(label, 0) + 1
else:
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,
result = {
"by_kind": by_kind, "by_prefix": by_prefix,
"count": total, "sample_names": sample,
}
if dry_run:
return result
if total:
session.execute(Tag.__table__.delete().where(predicate))
session.commit()
result["deleted"] = total
return result
# ---------------------------------------------------------------------------