From 26e47a86cb2b3b21a69f82faf906a2161806f7f5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 16:24:52 -0400 Subject: [PATCH 1/3] fix(gallery): render similar-mode results (flat list when no date groups) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'See all similar' takeover fetched /api/gallery/similar fine (200, ~100 results) but the grid showed nothing: GalleryGrid renders ONLY by iterating store.dateGroups, and similar-mode returns date_groups=[] (results are ranked by cosine distance, not chronological). Zero groups → zero tiles despite store.images being full. Add a flat fallback: when there are no date groups but images exist, render them as one ungrouped list in ranked order (no date headers). The modal Related strip was unaffected (it renders its images directly). Test locks both the flat and grouped paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/gallery/GalleryGrid.vue | 13 +++++ frontend/test/components/galleryGrid.spec.js | 47 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 frontend/test/components/galleryGrid.spec.js diff --git a/frontend/src/components/gallery/GalleryGrid.vue b/frontend/src/components/gallery/GalleryGrid.vue index 29db64f..27ac70c 100644 --- a/frontend/src/components/gallery/GalleryGrid.vue +++ b/frontend/src/components/gallery/GalleryGrid.vue @@ -15,6 +15,19 @@ + + + diff --git a/frontend/test/components/galleryGrid.spec.js b/frontend/test/components/galleryGrid.spec.js new file mode 100644 index 0000000..5710a7a --- /dev/null +++ b/frontend/test/components/galleryGrid.spec.js @@ -0,0 +1,47 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' + +import GalleryGrid from '../../src/components/gallery/GalleryGrid.vue' +import { useGalleryStore } from '../../src/stores/gallery.js' +import { freshPinia } from '../support/mountComponent.js' + +const GIStub = { name: 'GalleryItem', props: ['image'], template: '
' } + +function mountGrid(pinia) { + return mount(GalleryGrid, { + global: { + plugins: [pinia], + stubs: { GalleryItem: GIStub, RouterLink: { template: '' } }, + }, + }) +} + +describe('GalleryGrid', () => { + it('renders a flat ranked list when there are no date groups (similar mode)', () => { + const pinia = freshPinia() + const store = useGalleryStore() + // similar-mode shape: images present, date_groups empty, no cursor. + store.images = [ + { id: 1, thumbnail_url: '/a' }, + { id: 2, thumbnail_url: '/b' }, + { id: 3, thumbnail_url: '/c' }, + ] + store.dateGroups = [] + const w = mountGrid(pinia) + expect(w.findAll('.gi').length).toBe(3) + }) + + it('renders grouped by date when date groups are present', () => { + const pinia = freshPinia() + const store = useGalleryStore() + store.images = [ + { id: 1, thumbnail_url: '/a' }, + { id: 2, thumbnail_url: '/b' }, + ] + store.dateGroups = [{ year: 2026, month: 6, image_ids: [1, 2] }] + const w = mountGrid(pinia) + expect(w.find('.fc-gallery-grid__date-header').exists()).toBe(true) + expect(w.findAll('.gi').length).toBe(2) + }) +}) From 91b0145bc89a9cf603048bc465b7e7a1a2fff0e6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 16:47:36 -0400 Subject: [PATCH 2/3] feat(tags): 'Reset content tagging' admin action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wipe every general + character tag so the operator can re-tag from scratch via the Camie auto-suggest, while PRESERVING fandoms, series (+ series_page order), and each image's stored tagger_predictions (so suggestions repopulate immediately). One set-based DELETE FROM tag WHERE kind IN ('general','character') — the five tag-referencing tables all cascade, so applications + aliases + allowlist + rejections + centroids clear automatically; series tags aren't deleted so series survive; Tag.fandom_id is SET NULL so fandoms are untouched. Reuses the established dry-run-preview -> confirm pattern: cleanup_service. reset_content_tagging() + POST /api/admin/tags/reset-content + TagMaintenanceCard section with a backup-first warning and a red confirm showing exact counts (tags by kind + image applications). Irreversible except via DB backup restore; the wipe only fires when the operator confirms. Tests: service dry-run counts + live delete preserves fandom/series/series_page while content tags + their image_tag cascade away; API dry-run wiring. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/api/admin.py | 21 ++++++ backend/app/services/cleanup_service.py | 56 +++++++++++++++ .../settings/TagMaintenanceCard.vue | 69 +++++++++++++++++++ frontend/src/stores/admin.js | 16 +++++ tests/test_api_admin.py | 23 +++++++ tests/test_cleanup_service.py | 60 ++++++++++++++++ 6 files changed, 245 insertions(+) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 9785cd7..9e4b2b2 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -224,6 +224,27 @@ async def tags_purge_legacy(): return jsonify(result) +@admin_bp.route("/tags/reset-content", methods=["POST"]) +async def tags_reset_content(): + """Tier-A: delete ALL general + character tags (the Camie-suggestable + content vocabulary) so the operator can re-tag from scratch via + auto-suggest. fandom + series tags + series_page ordering are preserved, + and image tagger_predictions are untouched so suggestions repopulate. + dry-run preview returns per-kind counts + applications + a sample so the + UI shows exactly what'll go before the operator confirms (dry_run=false). + Irreversible except via DB backup restore.""" + from ..services.cleanup_service import reset_content_tagging + + 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: reset_content_tagging(sync_sess, dry_run=dry_run) + ) + return jsonify(result) + + @admin_bp.route("/maintenance/db-stats", methods=["GET"]) async def db_stats(): """Per-table bloat readout (pg_stat_user_tables) for the high-churn tables diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index 58aab98..f4ee687 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -455,6 +455,62 @@ def purge_legacy_tags(session: Session, *, dry_run: bool = False) -> dict: return result +# The Camie-suggestable CONTENT vocabulary. "Reset content tagging" wipes +# these so the operator can re-tag from scratch via auto-suggest. fandom + +# series (and series_page ordering) are deliberately NOT here — they're kept. +RESETTABLE_TAG_KINDS = ("general", "character") + + +def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict: + """Count (dry_run) or DELETE every general + character tag so the operator + can re-tag from scratch via the Camie auto-suggest. + + PRESERVED: fandom + series tags and their series_page ordering, plus every + image's image_record.tagger_predictions (untouched) so suggestions + repopulate immediately. CASCADE on image_tag / tag_alias / tag_allowlist / + tag_reference_embedding / tag_suggestion_rejection clears each deleted + tag's applications + metadata. Tag.fandom_id is SET NULL, so deleting + character tags never touches the fandom rows. Irreversible except via DB + backup restore. + + Returns: + {"by_kind": {"general": N, "character": M}, + "count": total tags, + "applications": image_tag rows that will be / were removed, + "sample_names": [first 50], + and on live runs "deleted": total} + """ + predicate = Tag.kind.in_(RESETTABLE_TAG_KINDS) + rows = session.execute( + 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 + # Headline impact: applications (image_tag rows) that vanish via cascade. + applications = session.execute( + select(func.count()) + .select_from(image_tag) + .where(image_tag.c.tag_id.in_(select(Tag.id).where(predicate))) + ).scalar_one() + sample = [name for _id, name, _kind in rows[:50]] + total = len(rows) + result = { + "by_kind": by_kind, + "count": total, + "applications": applications, + "sample_names": sample, + } + if dry_run: + return result + if total: + session.execute(Tag.__table__.delete().where(predicate)) + session.commit() + result["deleted"] = total + return result + + # --------------------------------------------------------------------------- # FC-Cleanup additions (2026-05-26): retroactive audit of import-filter rules. # --------------------------------------------------------------------------- diff --git a/frontend/src/components/settings/TagMaintenanceCard.vue b/frontend/src/components/settings/TagMaintenanceCard.vue index 6ea69c8..db8fc73 100644 --- a/frontend/src/components/settings/TagMaintenanceCard.vue +++ b/frontend/src/components/settings/TagMaintenanceCard.vue @@ -89,6 +89,53 @@ @click="onKindCommit" >Delete {{ kindPreview.count }} legacy tag(s)
+ + + +

+ Reset content tagging. + Deletes every general and character tag and + removes them from every image, so you can re-tag from scratch with the + auto-suggest. Fandoms and series (with their page order) are + kept, and each image's saved predictions are untouched — open + an image and its suggestions reappear. +

+ + Irreversible — there's no undo except restoring a DB backup. + Back one up first (Settings → Maintenance → Backup). + + + Preview content-tag reset + +
+

+ {{ resetPreview.count }} content tag(s) + + ({{ k }}: {{ n }})  + + across {{ resetPreview.applications }} image + application(s). +

+
+ + {{ n }} + +
+ Delete {{ resetPreview.count }} content tag(s) + + {{ resetPreview.applications }} application(s) +
@@ -105,6 +152,9 @@ const committing = ref(false) const kindPreview = ref(null) const loadingKindPreview = ref(false) const kindCommitting = ref(false) +const resetPreview = ref(null) +const loadingResetPreview = ref(false) +const resetCommitting = ref(false) async function onPreview() { loadingPreview.value = true @@ -143,6 +193,25 @@ async function onKindCommit() { kindCommitting.value = false } } + +async function onResetPreview() { + loadingResetPreview.value = true + try { + resetPreview.value = await store.resetContentTagging({ dryRun: true }) + } finally { + loadingResetPreview.value = false + } +} + +async function onResetCommit() { + resetCommitting.value = true + try { + await store.resetContentTagging({ dryRun: false }) + resetPreview.value = { count: 0, by_kind: {}, applications: 0, sample_names: [] } + } finally { + resetCommitting.value = false + } +}