From 2dd9b956d519887a62c8d40ab2c6997a249843ee Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Mon, 21 Sep 2026 14:32:51 -0400
Subject: [PATCH] =?UTF-8?q?feat:=20File=20placement=20card=20=E2=80=94=20s?=
=?UTF-8?q?urvey,=20plan=20per=20artist,=20apply,=20put=20back=20(4246,=20?=
=?UTF-8?q?slice=203c)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The UI half of the reconciler, in Maintenance. Survey shows how many images
sit in the wrong artist's folder and which folders they are in; each artist
gets its own Plan button; each run can be reviewed, applied, and put back.
Deliberately NOT using useMaintenanceTask. That composable stashes a task id
in localStorage so a result survives navigate-away, which is the right answer
when the only record is a Celery result. Here the runs are database rows — so
a reload, another machine, or coming back tomorrow simply shows the same
state, because the state IS the row. The card polls the runs endpoint instead.
Copy avoids the vocabulary this work has been tripping over: "folder", "put
back", "in the wrong folder" rather than artist_id, revert and canonical. The
one thing the operator most needs to know — nothing here changes who an image
belongs to — is what the blurb says first.
Three things I had assumed and checked instead: MaintenanceTile lives in
common/ not settings/; there is no generic ConfirmDialog (BackupCard uses a
purpose-built modal), so this uses a plain v-dialog; and `loadArtistNames`
did not exist — it does now, mapping id to name so a run row reads "Conto"
rather than "#47". The name deliberately is not denormalised into the run: it
belongs to the artist and would go stale on a rename.
Also extracted `stubFetch` to frontend/test/stubFetch.js. The shape ledger
flagged it as a byte-identical duplicate across five specs and this would
have been the sixth; the others keep their copies until each is next touched.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
---
.../components/settings/MaintenancePanel.vue | 2 +
.../src/components/settings/PlacementCard.vue | 312 ++++++++++++++++++
frontend/src/stores/cleanup.js | 56 ++++
frontend/test/placement.spec.js | 111 +++++++
frontend/test/stubFetch.js | 24 ++
5 files changed, 505 insertions(+)
create mode 100644 frontend/src/components/settings/PlacementCard.vue
create mode 100644 frontend/test/placement.spec.js
create mode 100644 frontend/test/stubFetch.js
diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue
index 3931384..0c44eaa 100644
--- a/frontend/src/components/settings/MaintenancePanel.vue
+++ b/frontend/src/components/settings/MaintenancePanel.vue
@@ -54,6 +54,7 @@
Self-healing and repair: missing files, thumbnails, database upkeep.
+
@@ -80,6 +81,7 @@ import MLBackfillCard from './MLBackfillCard.vue'
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
import ArchiveReextractCard from './ArchiveReextractCard.vue'
import MissingFileRepairCard from './MissingFileRepairCard.vue'
+import PlacementCard from './PlacementCard.vue'
import GpuTriageCard from './GpuTriageCard.vue'
import DbMaintenanceCard from './DbMaintenanceCard.vue'
import VideoEmbeddingCard from './VideoEmbeddingCard.vue'
diff --git a/frontend/src/components/settings/PlacementCard.vue b/frontend/src/components/settings/PlacementCard.vue
new file mode 100644
index 0000000..462c255
--- /dev/null
+++ b/frontend/src/components/settings/PlacementCard.vue
@@ -0,0 +1,312 @@
+
+
+
+ The library keeps one folder per artist, named after them. Files written
+ under older rules can sit in another artist's folder — this moves them
+ home, updating the record and the file together. Every run can be
+ reverted, so the safe way to use it is one artist at a time: run it,
+ look at the gallery, then continue or put it back.
+
+
+ {{ error }}
+
+
+
+ Check placement
+
+ {{ layout.misplaced_rows.toLocaleString() }} of
+ {{ layout.total_rows.toLocaleString() }} images are in the wrong folder
+
+ — across {{ layout.artists.length }} artists
+
+
+
+
+
+
+
+ | Artist |
+ To move |
+ Currently in |
+ Plan |
+
+
+
+
+ | {{ a.name }} |
+ {{ a.misplaced_rows.toLocaleString() }} |
+ {{ a.stray_dirs.join(', ') }} |
+
+ Plan
+ |
+
+
+
+
+
+
+
+ Runs
+
+
+
+ | When |
+ Scope |
+ Status |
+ Planned |
+ Moved |
+ Refused |
+ Actions |
+
+
+
+
+ |
+ {{ formatRelative(r.started_at) }}
+ |
+ {{ artistName(r.artist_id) }} |
+
+
+ {{ statusIcon(r.status) }}
+
+ {{ r.status }}
+ |
+ {{ r.planned_count.toLocaleString() }} |
+ {{ r.moved_count.toLocaleString() }} |
+
+
+ {{ r.refused_count.toLocaleString() }}
+
+ |
+
+
+
+
+
+
+ |
+
+
+ |
+ No runs yet. Check placement above, then plan one artist.
+ |
+
+
+
+
+
+
+
+
+ Run {{ reviewRun?.id }} — {{ reviewRun?.planned_count?.toLocaleString() }} moves
+
+
+
+ Showing the first {{ REVIEW_LIMIT }}. Each row moves the file and
+ its record together; nothing is overwritten.
+
+
+
+
+ | {{ m.from }} |
+ → {{ m.to }} |
+
+
+
+
+
Refused ({{ reviewRun.refusals.length }})
+
+ Rows the run declined to touch — the source moved, the
+ destination was taken, or the record changed since planning.
+
+
#{{ f.image_id }} — {{ f.reason }}
+
+
+
+
+ Close
+
+
+
+
+
+
+ {{ confirmTitle }}
+ {{ confirmMessage }}
+
+
+ Cancel
+ Go ahead
+
+
+
+
+
+
+
diff --git a/frontend/src/stores/cleanup.js b/frontend/src/stores/cleanup.js
index 82813a2..73b17d7 100644
--- a/frontend/src/stores/cleanup.js
+++ b/frontend/src/stores/cleanup.js
@@ -71,10 +71,66 @@ export const useCleanupStore = defineStore('cleanup', () => {
return await api.post(`/api/cleanup/audit/${id}/cancel`)
}
+ // --- placement reconciler (milestone #421) --------------------------------
+ //
+ // Runs are server-side rows, so the DATABASE is the durable state here —
+ // no localStorage resurfacing (useMaintenanceTask) is needed. Reload the
+ // page, open it on another machine, and the run and its status are simply
+ // there. That also means a plan survives being walked away from for a day.
+
+ const placementRuns = ref([])
+ const layout = ref(null)
+
+ // The survey: which rows sit outside their artist's directory. check_disk
+ // additionally stats every destination (collisions, missing sources) and
+ // costs one stat per misplaced row over NFS, so it is opt-in.
+ async function loadLayout(checkDisk = false) {
+ layout.value = await api.get('/api/cleanup/layout', {
+ params: checkDisk ? { check_disk: 1 } : {},
+ })
+ return layout.value
+ }
+
+ // id -> name, so a run row can say "Conto" instead of "#47". The runs
+ // endpoint carries artist_id alone: the name belongs to the artist, and
+ // denormalising it into every run would go stale the moment one is renamed.
+ async function loadArtistNames() {
+ const rows = await api.get('/api/artists/names')
+ return Object.fromEntries((rows || []).map(a => [a.id, a.name]))
+ }
+
+ async function loadPlacementRuns(limit = 25) {
+ const body = await api.get('/api/cleanup/placement/runs', { params: { limit } })
+ placementRuns.value = body.runs || []
+ return placementRuns.value
+ }
+
+ // Detail carries `moves` — the plan the operator reads before agreeing.
+ async function getPlacementRun(id) {
+ return await api.get(`/api/cleanup/placement/runs/${id}`)
+ }
+
+ async function planPlacement(artistId = null) {
+ return await api.post('/api/cleanup/placement/plan', {
+ body: artistId === null ? {} : { artist_id: artistId },
+ })
+ }
+
+ async function applyPlacement(id) {
+ return await api.post(`/api/cleanup/placement/runs/${id}/apply`)
+ }
+
+ async function revertPlacement(id) {
+ return await api.post(`/api/cleanup/placement/runs/${id}/revert`)
+ }
+
return {
defaults, recentRuns,
loadDefaults,
previewMinDim, deleteMinDim,
startAudit, getAudit, loadHistory, latestAuditForRule, applyAudit, cancelAudit,
+ placementRuns, layout,
+ loadLayout, loadArtistNames, loadPlacementRuns, getPlacementRun,
+ planPlacement, applyPlacement, revertPlacement,
}
})
diff --git a/frontend/test/placement.spec.js b/frontend/test/placement.spec.js
new file mode 100644
index 0000000..af5fc1a
--- /dev/null
+++ b/frontend/test/placement.spec.js
@@ -0,0 +1,111 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { setActivePinia, createPinia } from 'pinia'
+import { useCleanupStore } from '../src/stores/cleanup.js'
+import { stubFetch } from './stubFetch.js'
+
+
+describe('placement reconciler store (milestone #421)', () => {
+ beforeEach(() => setActivePinia(createPinia()))
+ afterEach(() => vi.restoreAllMocks())
+
+ it('loadLayout leaves the disk check off by default', async () => {
+ const s = useCleanupStore()
+ let seen = ''
+ stubFetch((url) => {
+ seen = url
+ return { status: 200, body: { total_rows: 10, misplaced_rows: 2, artists: [] } }
+ })
+ await s.loadLayout()
+ // One stat per misplaced row over NFS is the cost; it must be opt-in.
+ expect(seen).not.toContain('check_disk')
+ expect(s.layout.misplaced_rows).toBe(2)
+ })
+
+ it('loadLayout asks for the disk check when requested', async () => {
+ const s = useCleanupStore()
+ let seen = ''
+ stubFetch((url) => {
+ seen = url
+ return { status: 200, body: { total_rows: 0, misplaced_rows: 0, artists: [] } }
+ })
+ await s.loadLayout(true)
+ expect(seen).toContain('check_disk=1')
+ })
+
+ it('planPlacement scopes to an artist when given one', async () => {
+ const s = useCleanupStore()
+ let sent = null
+ stubFetch((url, init) => {
+ sent = JSON.parse(init.body)
+ return { status: 202, body: { status: 'dispatched' } }
+ })
+ await s.planPlacement(47)
+ expect(sent).toEqual({ artist_id: 47 })
+ })
+
+ it('planPlacement sends no scope for the whole library', async () => {
+ const s = useCleanupStore()
+ let sent = null
+ stubFetch((url, init) => {
+ sent = JSON.parse(init.body)
+ return { status: 202, body: { status: 'dispatched' } }
+ })
+ await s.planPlacement()
+ // Not `{artist_id: null}` — the endpoint rejects a non-integer, and an
+ // absent key is how "whole library" is spelled.
+ expect(sent).toEqual({})
+ })
+
+ it('loadPlacementRuns keeps the rows for the table', async () => {
+ const s = useCleanupStore()
+ stubFetch(() => ({
+ status: 200,
+ body: { runs: [{ id: 3, status: 'ready', planned_count: 12 }] },
+ }))
+ await s.loadPlacementRuns()
+ expect(s.placementRuns).toHaveLength(1)
+ expect(s.placementRuns[0].status).toBe('ready')
+ })
+
+ it('getPlacementRun carries the moves — it is the preview', async () => {
+ const s = useCleanupStore()
+ stubFetch(() => ({
+ status: 200,
+ body: {
+ id: 3, status: 'ready', planned_count: 1,
+ moves: [{ image_id: 9, from: '/images/Conto/x.png', to: '/images/conto/x.png' }],
+ },
+ }))
+ const run = await s.getPlacementRun(3)
+ expect(run.moves[0].from).toBe('/images/Conto/x.png')
+ expect(run.moves[0].to).toBe('/images/conto/x.png')
+ })
+
+ it('loadArtistNames maps id to name for the run rows', async () => {
+ const s = useCleanupStore()
+ stubFetch(() => ({
+ status: 200,
+ body: [{ id: 47, name: 'Conto', slug: 'conto' }],
+ }))
+ expect(await s.loadArtistNames()).toEqual({ 47: 'Conto' })
+ })
+
+ it('loadArtistNames survives an empty roster', async () => {
+ const s = useCleanupStore()
+ stubFetch(() => ({ status: 200, body: [] }))
+ expect(await s.loadArtistNames()).toEqual({})
+ })
+
+ it('applyPlacement and revertPlacement post to their own run', async () => {
+ const s = useCleanupStore()
+ const urls = []
+ stubFetch((url) => {
+ urls.push(url)
+ return { status: 202, body: { status: 'dispatched' } }
+ })
+ await s.applyPlacement(5)
+ await s.revertPlacement(5)
+ expect(urls[0]).toContain('/api/cleanup/placement/runs/5/apply')
+ expect(urls[1]).toContain('/api/cleanup/placement/runs/5/revert')
+ })
+})
diff --git a/frontend/test/stubFetch.js b/frontend/test/stubFetch.js
new file mode 100644
index 0000000..80dc8fd
--- /dev/null
+++ b/frontend/test/stubFetch.js
@@ -0,0 +1,24 @@
+import { vi } from 'vitest'
+
+// The canonical fetch stub for store specs.
+//
+// `handler(url, init)` returns `{ status, body }`; body is JSON-encoded, and
+// `ok` is derived from the status so a store's error path can be exercised by
+// returning 4xx/5xx. Returns the vi.fn so a caller can assert on calls.
+//
+// Extracted 2026-09-21 from six specs carrying byte-identical copies
+// (adminStore, credentials, dbMaintenance, gallery, suggestions,
+// galleryRelatedStrip). Those still hold their own; migrate each the next
+// time it is touched rather than in one sweep.
+export function stubFetch (handler) {
+ globalThis.fetch = vi.fn(async (url, init) => {
+ const { status, body } = handler(url, init)
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ statusText: String(status),
+ text: async () => (body == null ? '' : JSON.stringify(body)),
+ }
+ })
+ return globalThis.fetch
+}