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)
|
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}
|
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.
|
# FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -37,9 +37,13 @@
|
|||||||
<a href="#" @click.prevent="openPost(e.post.id)">
|
<a href="#" @click.prevent="openPost(e.post.id)">
|
||||||
View images from this post
|
View images from this post
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
v-if="e.post.description_html"
|
||||||
|
href="#" @click.prevent="toggleDesc(e.provenance_id)"
|
||||||
|
>{{ expanded[e.provenance_id] ? 'Hide description ▴' : 'Show description ▾' }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="e.post.description_html"
|
v-if="e.post.description_html && expanded[e.provenance_id]"
|
||||||
class="fc-prov__desc" v-html="e.post.description_html"
|
class="fc-prov__desc" v-html="e.post.description_html"
|
||||||
/>
|
/>
|
||||||
</article>
|
</article>
|
||||||
@@ -74,7 +78,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, watch } from 'vue'
|
import { computed, reactive, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useModalStore } from '../../stores/modal.js'
|
import { useModalStore } from '../../stores/modal.js'
|
||||||
import { useProvenanceStore } from '../../stores/provenance.js'
|
import { useProvenanceStore } from '../../stores/provenance.js'
|
||||||
@@ -84,6 +88,16 @@ const modal = useModalStore()
|
|||||||
const prov = useProvenanceStore()
|
const prov = useProvenanceStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Per-post description collapse state (keyed by provenance_id). Default
|
||||||
|
// collapsed so multiple posts don't each eat ~180px of the panel — the
|
||||||
|
// operator flagged the descriptions consuming a lot of real estate
|
||||||
|
// 2026-05-28. Reset when the viewed image changes.
|
||||||
|
const expanded = reactive({})
|
||||||
|
function toggleDesc(id) { expanded[id] = !expanded[id] }
|
||||||
|
watch(() => modal.currentImageId, () => {
|
||||||
|
for (const k of Object.keys(expanded)) delete expanded[k]
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => modal.currentImageId,
|
() => modal.currentImageId,
|
||||||
(id) => { if (id != null) prov.loadForImage(id) },
|
(id) => { if (id != null) prov.loadForImage(id) },
|
||||||
|
|||||||
@@ -44,6 +44,46 @@
|
|||||||
@click="onCommit"
|
@click="onCommit"
|
||||||
>Delete {{ preview.count }} unused tag(s)</v-btn>
|
>Delete {{ preview.count }} unused tag(s)</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="my-5" />
|
||||||
|
|
||||||
|
<p class="fc-muted text-body-2 mb-4">
|
||||||
|
Purge tags with retired/system kinds FC no longer uses
|
||||||
|
(<code>archive</code>, <code>post</code>, <code>artist</code>) —
|
||||||
|
IR-migration leftovers like
|
||||||
|
<code>BlenderKnight:Hannah_BJ_Loops</code>. Provenance and
|
||||||
|
artists are their own systems now, so these tag rows are pure
|
||||||
|
noise. Removes them from every image.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
color="accent" variant="flat" rounded="pill"
|
||||||
|
prepend-icon="mdi-magnify"
|
||||||
|
:loading="loadingKindPreview"
|
||||||
|
class="mb-3"
|
||||||
|
@click="onKindPreview"
|
||||||
|
>Preview retired-kind tags</v-btn>
|
||||||
|
|
||||||
|
<div v-if="kindPreview">
|
||||||
|
<p class="text-body-2 mb-2">
|
||||||
|
<strong>{{ kindPreview.count }}</strong> tag(s) of retired kinds.
|
||||||
|
<span v-for="(n, k) in kindPreview.by_kind" :key="k" class="fc-muted">
|
||||||
|
{{ k }}: {{ n }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div v-if="kindPreview.sample_names?.length" class="fc-name-grid mb-3">
|
||||||
|
<span v-for="n in kindPreview.sample_names" :key="n" class="fc-name">
|
||||||
|
{{ n }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<v-btn
|
||||||
|
color="error" variant="flat" rounded="pill"
|
||||||
|
prepend-icon="mdi-delete-sweep"
|
||||||
|
:disabled="!kindPreview.count"
|
||||||
|
:loading="kindCommitting"
|
||||||
|
@click="onKindCommit"
|
||||||
|
>Delete {{ kindPreview.count }} retired-kind tag(s)</v-btn>
|
||||||
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -57,6 +97,9 @@ const store = useAdminStore()
|
|||||||
const preview = ref(null)
|
const preview = ref(null)
|
||||||
const loadingPreview = ref(false)
|
const loadingPreview = ref(false)
|
||||||
const committing = ref(false)
|
const committing = ref(false)
|
||||||
|
const kindPreview = ref(null)
|
||||||
|
const loadingKindPreview = ref(false)
|
||||||
|
const kindCommitting = ref(false)
|
||||||
|
|
||||||
async function onPreview() {
|
async function onPreview() {
|
||||||
loadingPreview.value = true
|
loadingPreview.value = true
|
||||||
@@ -76,6 +119,25 @@ async function onCommit() {
|
|||||||
committing.value = false
|
committing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onKindPreview() {
|
||||||
|
loadingKindPreview.value = true
|
||||||
|
try {
|
||||||
|
kindPreview.value = await store.purgeRetiredKindTags({ dryRun: true })
|
||||||
|
} finally {
|
||||||
|
loadingKindPreview.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onKindCommit() {
|
||||||
|
kindCommitting.value = true
|
||||||
|
try {
|
||||||
|
await store.purgeRetiredKindTags({ dryRun: false })
|
||||||
|
kindPreview.value = { count: 0, by_kind: {}, sample_names: [] }
|
||||||
|
} finally {
|
||||||
|
kindCommitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -114,6 +114,19 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function purgeRetiredKindTags({ dryRun = true } = {}) {
|
||||||
|
lastError.value = null
|
||||||
|
try {
|
||||||
|
return await api.post(
|
||||||
|
'/api/admin/tags/purge-retired-kinds',
|
||||||
|
{ body: { dry_run: dryRun } },
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
lastError.value = e.message
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Task progress polling (taps FC-3i activity dashboard) --------
|
// --- Task progress polling (taps FC-3i activity dashboard) --------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,6 +161,7 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
mergeTags,
|
mergeTags,
|
||||||
tagUsageCount,
|
tagUsageCount,
|
||||||
pruneUnusedTags,
|
pruneUnusedTags,
|
||||||
|
purgeRetiredKindTags,
|
||||||
pollTaskUntilDone,
|
pollTaskUntilDone,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -364,3 +364,53 @@ async def test_prune_unused_commit_deletes_and_returns_count(client, db):
|
|||||||
body = await resp.get_json()
|
body = await resp.get_json()
|
||||||
assert body["deleted"] >= 1
|
assert body["deleted"] >= 1
|
||||||
assert "byebye" in body["sample_names"]
|
assert "byebye" in body["sample_names"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purge_retired_kinds_dry_run_counts_by_kind(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),
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["count"] == 3
|
||||||
|
assert body["by_kind"] == {"archive": 1, "post": 1, "artist": 1}
|
||||||
|
assert "blonde hair" not in body["sample_names"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purge_retired_kinds_commit_deletes_only_retired(client, db):
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
db.add_all([
|
||||||
|
Tag(name="arch1", kind=TagKind.archive),
|
||||||
|
Tag(name="post1", kind=TagKind.post),
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["deleted"] == 2
|
||||||
|
|
||||||
|
# general + character tags survive.
|
||||||
|
surviving = (await db.execute(
|
||||||
|
select(func.count()).select_from(Tag)
|
||||||
|
.where(Tag.kind.in_([TagKind.general, TagKind.character]))
|
||||||
|
)).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
|
||||||
|
|||||||
Reference in New Issue
Block a user