diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index e6011b3..29820e8 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -6,6 +6,7 @@ Five action surfaces: DELETE /api/admin/tags/ (Tier B) POST /api/admin/tags//merge (Tier B) POST /api/admin/tags/prune-unused (Tier A) + POST /api/admin/tags/purge-legacy (Tier A) GET /api/admin/tags//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) diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index 4470d3f..dba9323 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -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 # --------------------------------------------------------------------------- diff --git a/frontend/src/components/settings/TagMaintenanceCard.vue b/frontend/src/components/settings/TagMaintenanceCard.vue index c19fef7..6ea69c8 100644 --- a/frontend/src/components/settings/TagMaintenanceCard.vue +++ b/frontend/src/components/settings/TagMaintenanceCard.vue @@ -48,12 +48,14 @@

- Purge tags with retired/system kinds FC no longer uses - (archive, post, artist) — - IR-migration leftovers like - BlenderKnight:Hannah_BJ_Loops. Provenance and - artists are their own systems now, so these tag rows are pure - noise. Removes them from every image. + Purge legacy IR-migration tags FC no longer uses: retired/system + kinds (archive, post, + artist — e.g. + BlenderKnight:Hannah_BJ_Loops) plus + source:* tags (ImageRepo's old source + kind, which migrated to general). Provenance and + artists are their own systems now, so these are pure noise. + Removes them from every image.

Preview retired-kind tags + >Preview legacy tags

- {{ kindPreview.count }} tag(s) of retired kinds. + {{ kindPreview.count }} legacy tag(s). {{ k }}: {{ n }}   + + {{ p }}: {{ n }}   +

@@ -82,7 +87,7 @@ :disabled="!kindPreview.count" :loading="kindCommitting" @click="onKindCommit" - >Delete {{ kindPreview.count }} retired-kind tag(s) + >Delete {{ kindPreview.count }} legacy tag(s)
@@ -123,7 +128,7 @@ async function onCommit() { async function onKindPreview() { loadingKindPreview.value = true try { - kindPreview.value = await store.purgeRetiredKindTags({ dryRun: true }) + kindPreview.value = await store.purgeLegacyTags({ dryRun: true }) } finally { loadingKindPreview.value = false } @@ -132,8 +137,8 @@ async function onKindPreview() { async function onKindCommit() { kindCommitting.value = true try { - await store.purgeRetiredKindTags({ dryRun: false }) - kindPreview.value = { count: 0, by_kind: {}, sample_names: [] } + await store.purgeLegacyTags({ dryRun: false }) + kindPreview.value = { count: 0, by_kind: {}, by_prefix: {}, sample_names: [] } } finally { kindCommitting.value = false } diff --git a/frontend/src/stores/admin.js b/frontend/src/stores/admin.js index 2e915b3..eb51ec9 100644 --- a/frontend/src/stores/admin.js +++ b/frontend/src/stores/admin.js @@ -114,11 +114,11 @@ export const useAdminStore = defineStore('admin', () => { } } - async function purgeRetiredKindTags({ dryRun = true } = {}) { + async function purgeLegacyTags({ dryRun = true } = {}) { lastError.value = null try { return await api.post( - '/api/admin/tags/purge-retired-kinds', + '/api/admin/tags/purge-legacy', { body: { dry_run: dryRun } }, ) } catch (e) { @@ -161,7 +161,7 @@ export const useAdminStore = defineStore('admin', () => { mergeTags, tagUsageCount, pruneUnusedTags, - purgeRetiredKindTags, + purgeLegacyTags, pollTaskUntilDone, } }) diff --git a/tests/test_api_admin.py b/tests/test_api_admin.py index dd74436..d778007 100644 --- a/tests/test_api_admin.py +++ b/tests/test_api_admin.py @@ -367,50 +367,57 @@ async def test_prune_unused_commit_deletes_and_returns_count(client, db): @pytest.mark.asyncio -async def test_purge_retired_kinds_dry_run_counts_by_kind(client, db): +async def test_purge_legacy_dry_run_counts_by_kind_and_prefix(client, db): db.add_all([ Tag(name="BlenderKnight:Hannah_BJ_Loops", kind=TagKind.archive), Tag(name="BlenderKnight:May Animation", kind=TagKind.post), Tag(name="SomeArtist", kind=TagKind.artist), + # IR `source` kind fell back to general during migration — + # caught by the source:* name prefix, not by kind. + Tag(name="source:patreon", kind=TagKind.general), Tag(name="blonde hair", kind=TagKind.general), # must survive ]) await db.commit() resp = await client.post( - "/api/admin/tags/purge-retired-kinds", json={"dry_run": True}, + "/api/admin/tags/purge-legacy", json={"dry_run": True}, ) body = await resp.get_json() - assert body["count"] == 3 + assert body["count"] == 4 assert body["by_kind"] == {"archive": 1, "post": 1, "artist": 1} + assert body["by_prefix"] == {"source:*": 1} assert "blonde hair" not in body["sample_names"] + assert "source:patreon" in body["sample_names"] @pytest.mark.asyncio -async def test_purge_retired_kinds_commit_deletes_only_retired(client, db): +async def test_purge_legacy_commit_deletes_only_legacy(client, db): from sqlalchemy import func, select db.add_all([ Tag(name="arch1", kind=TagKind.archive), - Tag(name="post1", kind=TagKind.post), + Tag(name="source:fanbox", kind=TagKind.general), Tag(name="keepme", kind=TagKind.general), Tag(name="char1", kind=TagKind.character), ]) await db.commit() resp = await client.post( - "/api/admin/tags/purge-retired-kinds", json={"dry_run": False}, + "/api/admin/tags/purge-legacy", json={"dry_run": False}, ) body = await resp.get_json() - assert body["deleted"] == 2 + assert body["deleted"] == 2 # arch1 + source:fanbox - # general + character tags survive. - surviving = (await db.execute( - select(func.count()).select_from(Tag) - .where(Tag.kind.in_([TagKind.general, TagKind.character])) + # The plain general + character tags survive. + keep = (await db.execute( + select(Tag.name).where(Tag.name.in_(["keepme", "char1"])) + )).scalars().all() + assert set(keep) == {"keepme", "char1"} + # No archive/post/artist or source:* left. + gone = (await db.execute( + select(func.count()).select_from(Tag).where( + (Tag.kind.in_([TagKind.archive, TagKind.post, TagKind.artist])) + | (Tag.name.like("source:%")) + ) )).scalar_one() - assert surviving >= 2 - retired_left = (await db.execute( - select(func.count()).select_from(Tag) - .where(Tag.kind.in_([TagKind.archive, TagKind.post, TagKind.artist])) - )).scalar_one() - assert retired_left == 0 + assert gone == 0