From e3855a5ae071a731a03addc6f735c380dfe44dac Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 26 Jun 2026 11:47:48 -0400 Subject: [PATCH 1/3] chore(tags): remove orphaned cluster tag-gaps route + service method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cluster tag-gap feature's only UI (Explore's TagGapPanel) was removed in the 3-pane rework, leaving the backend that fed it with no caller. Surgical removal: - drop the POST /api/images/cluster/tag-gaps route (cluster_tag_gaps) - drop BulkTagService.tag_gaps (+ the now-unused `import math`) - drop the tag_gaps tests (test_bulk_tag_service, test_api_bulk_tags) BulkTagService's common_tags / bulk_add / bulk_remove stay — they still back the gallery bulk editor. Pure deletion, no behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/api/tags.py | 21 ------ backend/app/services/bulk_tag_service.py | 81 ------------------------ tests/test_api_bulk_tags.py | 39 ------------ tests/test_bulk_tag_service.py | 56 ---------------- 4 files changed, 197 deletions(-) diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index 31aee4e..0556ed9 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -320,27 +320,6 @@ async def common_tags(): return jsonify({"tags": tags}) -@tags_bp.route("/images/cluster/tag-gaps", methods=["POST"]) -async def cluster_tag_gaps(): - """Cluster-consensus tag gaps for a visual neighbour set (#94 Explore): - tags on >= threshold of the images but not all. Each gap carries the - laggard image ids (minus rejections) for apply-to-cluster.""" - body = await request.get_json() - ids, err = _parse_bulk_ids(body) - if err: - return err - try: - threshold = float(body.get("threshold", 0.6)) - except (TypeError, ValueError): - threshold = 0.6 - threshold = min(1.0, max(0.0, threshold)) - async with get_session() as session: - gaps = await BulkTagService(session).tag_gaps(ids, threshold) - return jsonify( - {"gaps": gaps, "total": len(set(ids)), "threshold": threshold} - ) - - @tags_bp.route("/images/bulk/tags", methods=["POST"]) async def bulk_add_tag(): body = await request.get_json() diff --git a/backend/app/services/bulk_tag_service.py b/backend/app/services/bulk_tag_service.py index 57d673c..b573ef9 100644 --- a/backend/app/services/bulk_tag_service.py +++ b/backend/app/services/bulk_tag_service.py @@ -1,7 +1,5 @@ """Bulk tag operations over a set of images (Core, set-based, atomic).""" -import math - from sqlalchemy import and_, func, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession @@ -15,85 +13,6 @@ class BulkTagService: def __init__(self, session: AsyncSession): self.session = session - async def tag_gaps( - self, image_ids: list[int], threshold: float - ) -> list[dict]: - """Cluster-consensus tag gaps (#94 Explore): tags applied to at least - `threshold` fraction of the image set but NOT all of it — the - "7 of these 10 share Hatsune Miku; these 3 don't" signal. - - For each gap, `missing_image_ids` are the set members that LACK the tag - and have NOT rejected it (TagSuggestionRejection) — so apply-to-cluster - never re-proposes a tag a neighbor explicitly dismissed. A tag on every - image is "common", not a gap; a tag on fewer than 2 isn't consensus. - """ - ids = list({int(i) for i in image_ids}) - if len(ids) < 2: - return [] - n = len(ids) - min_present = max(2, math.ceil(threshold * n)) - if min_present >= n: - return [] # threshold so high only a 100%-common tag could qualify - - cnt = func.count(func.distinct(image_tag.c.image_record_id)) - gap_rows = ( - await self.session.execute( - select(Tag.id, Tag.name, Tag.kind) - .join(image_tag, image_tag.c.tag_id == Tag.id) - .where(image_tag.c.image_record_id.in_(ids)) - .group_by(Tag.id, Tag.name, Tag.kind) - .having(and_(cnt >= min_present, cnt < n)) - .order_by(cnt.desc(), Tag.name.asc()) - ) - ).all() - if not gap_rows: - return [] - gap_ids = [r.id for r in gap_rows] - - present: dict[int, set[int]] = {} - for tid, iid in ( - await self.session.execute( - select(image_tag.c.tag_id, image_tag.c.image_record_id).where( - image_tag.c.tag_id.in_(gap_ids), - image_tag.c.image_record_id.in_(ids), - ) - ) - ).all(): - present.setdefault(tid, set()).add(iid) - - rejected: dict[int, set[int]] = {} - for tid, iid in ( - await self.session.execute( - select( - TagSuggestionRejection.tag_id, - TagSuggestionRejection.image_record_id, - ).where( - TagSuggestionRejection.tag_id.in_(gap_ids), - TagSuggestionRejection.image_record_id.in_(ids), - ) - ) - ).all(): - rejected.setdefault(tid, set()).add(iid) - - id_set = set(ids) - gaps = [] - for r in gap_rows: - have = present.get(r.id, set()) - missing = id_set - have - rejected.get(r.id, set()) - if not missing: - continue # every laggard rejected it — nothing to apply - gaps.append( - { - "tag_id": r.id, - "name": r.name, - "kind": r.kind.value if hasattr(r.kind, "value") else str(r.kind), - "present_count": len(have), - "total": n, - "missing_image_ids": sorted(missing), - } - ) - return gaps - async def common_tags(self, image_ids: list[int]) -> list[dict]: """Tags present on EVERY image in image_ids.""" if not image_ids: diff --git a/tests/test_api_bulk_tags.py b/tests/test_api_bulk_tags.py index 7f6e0db..5c3c6c7 100644 --- a/tests/test_api_bulk_tags.py +++ b/tests/test_api_bulk_tags.py @@ -87,42 +87,3 @@ async def test_bulk_remove_route_requires_ids(client): "/api/images/bulk/tags/remove", json={"tag_id": 1} ) assert resp.status_code == 400 - - -@pytest.mark.asyncio -async def test_cluster_tag_gaps_requires_list(client): - resp = await client.post("/api/images/cluster/tag-gaps", json={}) - assert resp.status_code == 400 - - -@pytest.mark.asyncio -async def test_cluster_tag_gaps_route_reports_gap(client, db): - from backend.app.models import ImageRecord, TagKind - from backend.app.services.tag_service import TagService - - tags = TagService(db) - gap = await tags.find_or_create("GapRoute", TagKind.general) - imgs = [] - for i in range(4): - rec = ImageRecord( - path=f"/tmp/gaproute_{i}.png", sha256=f"g{i:063d}", - size_bytes=1, mime="image/png", origin="uploaded", - ) - db.add(rec) - await db.flush() - imgs.append(rec.id) - for i in imgs[:3]: - await tags.add_to_image(i, gap.id) # on 3/4 → a gap at 0.6 - await db.commit() - - resp = await client.post( - "/api/images/cluster/tag-gaps", - json={"image_ids": imgs, "threshold": 0.6}, - ) - assert resp.status_code == 200 - body = await resp.get_json() - assert body["total"] == 4 - assert body["threshold"] == 0.6 - g = next(x for x in body["gaps"] if x["tag_id"] == gap.id) - assert g["present_count"] == 3 - assert g["missing_image_ids"] == [imgs[3]] diff --git a/tests/test_bulk_tag_service.py b/tests/test_bulk_tag_service.py index 4cd9024..cb8d1c5 100644 --- a/tests/test_bulk_tag_service.py +++ b/tests/test_bulk_tag_service.py @@ -120,59 +120,3 @@ async def test_bulk_remove_rejection_is_idempotent(db): # Second remove: nothing to delete, rejection already present, no error. removed = await svc.bulk_remove([i1], tag.id) assert removed == 0 - - -@pytest.mark.asyncio -async def test_tag_gaps_finds_consensus_excludes_common(db): - tags = TagService(db) - gap = await tags.find_or_create("Miku", TagKind.character) - common = await tags.find_or_create("Solo", TagKind.general) - imgs = [await _img(db) for _ in range(5)] - for i in imgs: - await tags.add_to_image(i, common.id) # on all 5 → not a gap - for i in imgs[:4]: - await tags.add_to_image(i, gap.id) # on 4/5 → a gap - svc = BulkTagService(db) - gaps = await svc.tag_gaps(imgs, threshold=0.6) - assert [g["tag_id"] for g in gaps] == [gap.id] # the common tag is excluded - g = gaps[0] - assert g["present_count"] == 4 - assert g["total"] == 5 - assert g["missing_image_ids"] == [imgs[4]] - - -@pytest.mark.asyncio -async def test_tag_gaps_excludes_rejected_laggard(db): - tags = TagService(db) - gap = await tags.find_or_create("Rin", TagKind.character) - imgs = [await _img(db) for _ in range(6)] - for i in imgs[:4]: - await tags.add_to_image(i, gap.id) # on 4/6 (min_present=ceil(3.6)=4) - # imgs[4], imgs[5] lack it; imgs[4] explicitly rejected it. - db.add(TagSuggestionRejection(image_record_id=imgs[4], tag_id=gap.id)) - await db.flush() - svc = BulkTagService(db) - gaps = await svc.tag_gaps(imgs, threshold=0.6) - g = next(x for x in gaps if x["tag_id"] == gap.id) - assert g["missing_image_ids"] == [imgs[5]] # rejected laggard excluded - - -@pytest.mark.asyncio -async def test_tag_gaps_drops_tag_when_all_laggards_rejected(db): - tags = TagService(db) - gap = await tags.find_or_create("Len", TagKind.character) - imgs = [await _img(db) for _ in range(5)] - for i in imgs[:4]: - await tags.add_to_image(i, gap.id) # on 4/5, sole laggard imgs[4] - db.add(TagSuggestionRejection(image_record_id=imgs[4], tag_id=gap.id)) - await db.flush() - svc = BulkTagService(db) - gaps = await svc.tag_gaps(imgs, threshold=0.6) - assert all(g["tag_id"] != gap.id for g in gaps) # nothing to apply → dropped - - -@pytest.mark.asyncio -async def test_tag_gaps_needs_at_least_two_images(db): - svc = BulkTagService(db) - assert await svc.tag_gaps([], 0.6) == [] - assert await svc.tag_gaps([await _img(db)], 0.6) == [] -- 2.52.0 From c8a8e2305042a49b2cd88c5b70af3f3f9139aa4a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 26 Jun 2026 21:25:51 -0400 Subject: [PATCH 2/3] feat(explore/tags): return focus to the tag input after every action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explore is a rapid walk-and-tag surface, so focus must keep returning to the tag input with no extra click (operator-asked 2026-06-26). Two gaps closed: - Navigation hardening: refocus on every focused-image change (neighbour click, breadcrumb, Random image, seed) now runs nextTick → requestAnimationFrame, so it lands AFTER the post-navigation re-render/paint instead of being stolen back by the neighbour-grid re-render. - All tag actions refocus, in both Explore and the modal: tag add (existing/new) and remove now hand focus back like accept-suggestion already did; and the rename + fandom-assignment dialogs refocus on @after-leave (fires after Vuetify's own focus-return to the activator, so ours wins). TagAutocomplete's mobile guard is preserved throughout (no soft-keyboard pop on touch). Modal behaviour gains the same stickier focus — consistent, low-risk. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/components/modal/TagPanel.vue | 20 ++++++++++++++++---- frontend/src/views/ExploreView.vue | 20 ++++++++++++-------- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/modal/TagPanel.vue b/frontend/src/components/modal/TagPanel.vue index 4f0d335..0e76142 100644 --- a/frontend/src/components/modal/TagPanel.vue +++ b/frontend/src/components/modal/TagPanel.vue @@ -30,7 +30,15 @@ @accepted="focusTagInput" /> - + + store.currentImageId, - (id) => { if (id != null) nextTick(() => tagPanelRef.value?.focusTagInput?.()) }, -) +// Auto-focus the tag input after any action so tagging needs no extra click — +// the whole point of the workspace (operator-asked 2026-06-26). nextTick waits +// for the post-navigation re-render, then rAF lands the focus AFTER paint so a +// neighbour/breadcrumb click that re-renders the grid can't steal it back. +// Reuses TagPanel/TagAutocomplete's focus, which keeps its mobile guard (no +// soft-keyboard pop on touch). +function refocusTag () { + nextTick(() => requestAnimationFrame(() => tagPanelRef.value?.focusTagInput?.())) +} +// Every focused-image change (seed + every walk: neighbour click, breadcrumb, +// Random image). +watch(() => store.currentImageId, (id) => { if (id != null) refocusTag() }) // The route is the source of truth. With an anchor, walk it; without one (the // bare /explore nav entry) seed a RANDOM image so the tab kick-starts a rabbit -- 2.52.0 From e34f79fc56d26b5c41842c165c8e76fb9b4b1384 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 26 Jun 2026 21:31:09 -0400 Subject: [PATCH 3/3] feat(explore): show Provenance in the tag rail (post often names the character) The post title/description frequently names the character, so surface it while tagging in Explore (operator-asked 2026-06-26). ProvenancePanel gains optional imageId/image props (default = modal store, so the modal is unchanged) since provenance is its own system loaded by id; ExploreView renders it above TagPanel in the right rail, hosted on the anchor. Self-collapses when the image has no provenance. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/modal/ProvenancePanel.vue | 18 ++++++++++++++---- frontend/src/views/ExploreView.vue | 14 ++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/modal/ProvenancePanel.vue b/frontend/src/components/modal/ProvenancePanel.vue index 95d0630..2bd8309 100644 --- a/frontend/src/components/modal/ProvenancePanel.vue +++ b/frontend/src/components/modal/ProvenancePanel.vue @@ -97,9 +97,19 @@ import { useProvenanceStore } from '../../stores/provenance.js' import { formatPostDate } from '../../utils/date.js' import { toPlainText } from '../../utils/htmlSanitize.js' +// `imageId`/`image` let a non-modal surface (the Explore workspace) render +// provenance for its anchor. Default to the modal store's current image so the +// image modal is unchanged. Provenance is its own system (loaded by id via the +// provenance store), so it only needs the right id + the artist fallback. +const props = defineProps({ + imageId: { type: Number, default: null }, + image: { type: Object, default: null }, +}) const modal = useModalStore() const prov = useProvenanceStore() const router = useRouter() +const effectiveId = computed(() => props.imageId ?? modal.currentImageId) +const effectiveImage = computed(() => props.image ?? modal.current) // Per-post description collapse state (keyed by provenance_id). Default // collapsed so multiple posts don't each eat ~180px of the panel — the @@ -107,21 +117,21 @@ const router = useRouter() // 2026-05-28. Reset when the viewed image changes. const expanded = reactive({}) function toggleDesc(id) { expanded[id] = !expanded[id] } -watch(() => modal.currentImageId, () => { +watch(() => effectiveId.value, () => { for (const k of Object.keys(expanded)) delete expanded[k] }) watch( - () => modal.currentImageId, + () => effectiveId.value, (id) => { if (id != null) prov.loadForImage(id) }, { immediate: true } ) const state = computed(() => - modal.currentImageId == null ? null : prov.imageProv(modal.currentImageId) + effectiveId.value == null ? null : prov.imageProv(effectiveId.value) ) -const fallbackArtist = computed(() => modal.current?.artist || null) +const fallbackArtist = computed(() => effectiveImage.value?.artist || null) const showArtistFallback = computed(() => { const st = state.value diff --git a/frontend/src/views/ExploreView.vue b/frontend/src/views/ExploreView.vue index e7be32f..4b60970 100644 --- a/frontend/src/views/ExploreView.vue +++ b/frontend/src/views/ExploreView.vue @@ -96,12 +96,13 @@ - -