From 8784a38e40950bae7de8c4c3f38fb6da2d3ef924 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 20 Oct 2026 14:27:46 -0700 Subject: [PATCH] feat(images): set the category of the selected images' linked prompts The selection bar's Set category opens the concept's simpleInput dialog ("Categorize selected images", one Category field, Cancel / Apply) and posts the selection's distinct linked positive ids to /prompt_manager/bulk/category. Images have no category of their own; the listing's category filter matches the linked positive's, so that is what changes. With no linked image checked nothing is posted and a toast says so; on success the toast counts the prompts and any skipped images, then the categories and the listing reload (clearing the selection); a failure keeps the selection. The button ignores a second press while it runs. The bulk category route now rejects anything but a non-empty list of at most 1000 positive integer ids (deduplicated in order) and validates the category with validate_category; bad JSON or a non-object body is a 400. The response shape and the legacy admin request are unchanged. --- TESTING.md | 10 ++- py/api/prompts.py | 44 +++++++++-- tests/browser/workspace-images.spec.mjs | 90 +++++++++++++++++++++- tests/js/workspace-images-category.test.js | 39 ++++++++++ tests/test_api_prompts.py | 90 ++++++++++++++++++++++ tests/test_api_workspace_images.py | 21 +++++ web/js/workspace/images-category.mjs | 34 ++++++++ web/js/workspace/images.mjs | 50 +++++++++++- web/js/workspace/workspace-dialogs.mjs | 28 +++++++ 9 files changed, 395 insertions(+), 11 deletions(-) create mode 100644 tests/js/workspace-images-category.test.js create mode 100644 web/js/workspace/images-category.mjs diff --git a/TESTING.md b/TESTING.md index ac05923..e35e371 100644 --- a/TESTING.md +++ b/TESTING.md @@ -512,7 +512,15 @@ Recorded deviations on this page (spec §8): `prompt-manager-images-metadata.json` (`format`, `version: 1`, `exported_at`, and the inspector's own "Download metadata JSON" envelopes in listing order); the button is `aria-disabled` and ignores a second press while it runs, the selection is kept, and - the toast names the unread images (none read: no download). `images selection bar matches + the toast names the unread images (none read: no download). Set category opens the + concept's `simpleInput` dialog ("Categorize selected images", `input#action-input` + labelled Category, Cancel / Apply) and posts the selection's distinct linked positive + ids to `/prompt_manager/bulk/category` (images have no category; the filter matches + the linked positive's). With no linked image checked it posts nothing and opens no + dialog; an empty value keeps the dialog open; success reloads the categories and the + listing (clearing the selection, focus to the heading); failure keeps the selection. + The route rejects anything but 1-1000 positive integer ids (deduplicated) and a + category that fails `validate_category`. `images selection bar matches the concept` compares the bar and the checked card with one card checked; the omitted actions flow again from the concept's own boxes, checked against the concept page with them removed at runtime. diff --git a/py/api/prompts.py b/py/api/prompts.py index 122d073..2914616 100644 --- a/py/api/prompts.py +++ b/py/api/prompts.py @@ -22,6 +22,25 @@ MAX_PAGE_OFFSET = 10_000_000 # for one giant result set (and silently truncating large libraries). EXPORT_PAGE_SIZE = 1000 +# The most prompts one bulk category request may change. +BULK_CATEGORY_MAX_PROMPTS = 1000 + + +def parse_bulk_prompt_ids(value, cap=BULK_CATEGORY_MAX_PROMPTS): + """A bulk request's ``prompt_ids``: distinct positive ints, first-seen order. + + Raises ``ValueError`` unless ``value`` is a non-empty list of at most + ``cap`` distinct positive integers (booleans are not integers here). + """ + if not isinstance(value, list) or not value: + raise ValueError("prompt_ids must be a non-empty list") + if any(type(pid) is not int or pid < 1 for pid in value): + raise ValueError("prompt_ids must be positive integers") + ids = list(dict.fromkeys(value)) + if len(ids) > cap: + raise ValueError(f"At most {cap} prompts can be changed at once") + return ids + def parse_page_params(query, default_limit=50, max_limit=MAX_PAGE_LIMIT): """Return ``(limit, offset)`` from a query mapping, clamped to safe bounds. @@ -1145,14 +1164,29 @@ class PromptRoutesMixin: async def bulk_set_category(self, request): """Bulk set category for prompts.""" try: - data = await request.json() - prompt_ids = data.get("prompt_ids", []) - category = (data.get("category") or "").strip() - - if not prompt_ids: + try: + data = await request.json() + except ValueError: + return web.json_response( + {"success": False, "error": "Invalid JSON body"}, status=400 + ) + if not isinstance(data, dict): + return web.json_response( + {"success": False, "error": "Expected a JSON object"}, status=400 + ) + if not data.get("prompt_ids"): return web.json_response( {"success": False, "error": "No prompt IDs provided"}, status=400 ) + category = data.get("category") + try: + prompt_ids = parse_bulk_prompt_ids(data["prompt_ids"]) + validate_category(category) + except ValueError as ve: + return web.json_response( + {"success": False, "error": str(ve)}, status=400 + ) + category = (category or "").strip() updated_count = await self._run_in_executor( self.db.bulk_set_category, prompt_ids, category diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 2538323..7b94702 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -1599,8 +1599,9 @@ test('card checkboxes select images into the concept bar and Clear empties it', await checkbox(second, 'same.png').check(); await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected'); await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveAttribute('data-kept', 'yes'); - // Set category is wired by a later task; until then it does nothing. + // Set category opens its dialog; Cancel keeps the selection. await bulkbar(page).getByRole('button', { name: 'Set category', exact: true }).click(); + await page.getByRole('dialog', { name: 'Categorize selected images', exact: true }).getByRole('button', { name: 'Cancel', exact: true }).click(); await expect(page.locator('dialog[open]')).toHaveCount(0); await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected'); await checkbox(second, 'same.png').uncheck(); @@ -1777,3 +1778,90 @@ test('Export metadata downloads nothing when no image can be read', async ({ pag expect(downloads).toHaveLength(0); await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected'); }); + +// #249 D8 follow-up (image selection, Task 3): Set category sets the linked positives' category (app.js simpleInput). +const categoryButton = page => bulkbar(page).getByRole('button', { name: 'Set category', exact: true }); +const categoryDialog = page => page.getByRole('dialog', { name: 'Categorize selected images', exact: true }); +const categoryRoute = '**/prompt_manager/bulk/category'; +/** Unlink the input root's card by deleting its prompt; returns the output card's positive id. */ +async function unlinkInputCard(request) { + const { images } = await (await request.get('/prompt_manager/images/output?mode=workspace')).json(); + const byKey = Object.fromEntries(images.map(image => [`${image.root_index}:${image.path}`, image])); + const [output, input] = [byKey['0:shared/same.png'], byKey['1:shared/same.png']]; + expect([output.linked.status, input.linked.status]).toEqual(['linked', 'linked']); + await expect(await request.delete(`/prompt_manager/delete/${input.linked.positive_id}`)).toBeOK(); + return output.linked.positive_id; +} + +test('Set category applies the category to the linked positives and the category filter then matches', async ({ page, request }) => { + const outputPositive = await unlinkInputCard(request); + await page.goto(workspace); + await checkBoth(page); + const opener = categoryButton(page); + await opener.click(); + const dialog = categoryDialog(page); + await expect(dialog).toBeVisible(); + // The concept's simpleInput: one field and Cancel / Apply (primary). + await expect(dialog.locator('#modal-body > .field > label[for="action-input"]')).toHaveText('Category'); + await expect(dialog.locator('#modal-body > .field > input#action-input')).toHaveValue(''); + await expect(dialog.locator('#modal-body > .dialog-actions > button')).toHaveText(['Cancel', 'Apply']); + await expect(dialog.getByRole('button', { name: 'Apply', exact: true })).toHaveClass(/ca-primary/); + await expect(dialog.getByLabel('Category', { exact: true })).toBeFocused(); + // An empty value applies nothing and keeps the dialog open. + const posts = []; + page.on('request', item => { if (item.method() === 'POST' && item.url().endsWith('/prompt_manager/bulk/category')) posts.push(item.postDataJSON()); }); + await dialog.getByRole('button', { name: 'Apply', exact: true }).click(); + await expect(dialog).toBeVisible(); + await dialog.getByLabel('Category', { exact: true }).fill(' Landscapes '); + const listings = []; + page.on('request', item => { if (imagePages(new URL(item.url()))) listings.push(item.url()); }); + await dialog.getByRole('button', { name: 'Apply', exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect(toast(page)).toHaveText('Category set on 1 prompt. 1 selected image had no linked prompt and was skipped.'); + expect(posts).toEqual([{ prompt_ids: [outputPositive], category: 'Landscapes' }]); + // The listing and the category options reload; the reload clears the selection. + await expect.poll(() => listings.length).toBeGreaterThan(0); + await expect(bulkbar(page)).toBeEmpty(); + await expect(page.locator('#workspace-main h1')).toBeFocused(); + await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0); + const filter = page.getByRole('combobox', { name: 'Filter by category', exact: true }); + await expect(filter.locator('option')).toContainText(['Landscapes']); + await filter.selectOption('Landscapes'); + await expect(cards(page)).toHaveCount(1); + await expect(cards(page)).toHaveAttribute('data-image-key', '0:shared/same.png'); +}); + +test('Set category without a linked image posts nothing and opens no dialog', async ({ page, request }) => { + await unlinkInputCard(request); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + let posted = 0; + await page.route(categoryRoute, route => { posted += 1; return route.continue(); }); + await checkbox(cards(page).last(), 'same.png').check(); + await categoryButton(page).click(); + await expect(toast(page)).toHaveText('None of the selected images has a linked prompt.'); + await expect(page.locator('dialog[open]')).toHaveCount(0); + expect(posted).toBe(0); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected'); +}); + +test('Set category reports a failure, keeps the selection and ignores a second press while it runs', async ({ page }) => { + await page.goto(workspace); + await checkBoth(page); + const held = []; + await page.route(categoryRoute, route => { held.push(route); }); + await categoryButton(page).click(); + await categoryDialog(page).getByLabel('Category', { exact: true }).fill('Landscapes'); + await categoryDialog(page).getByRole('button', { name: 'Apply', exact: true }).click(); + await expect.poll(() => held.length).toBe(1); + await expect(categoryButton(page)).toHaveAttribute('aria-disabled', 'true'); + // Playwright waits on an aria-disabled control, so the second press is a dispatched click. + await categoryButton(page).dispatchEvent('click'); + await expect(page.locator('dialog[open]')).toHaveCount(0); + await held[0].fulfill({ status: 500, json: { success: false, error: 'boom' } }); + await expect(toast(page)).toHaveText('Category could not be set. Try again.'); + await expect(categoryButton(page)).not.toHaveAttribute('aria-disabled', 'true'); + expect(held).toHaveLength(1); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected'); + await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(2); +}); diff --git a/tests/js/workspace-images-category.test.js b/tests/js/workspace-images-category.test.js new file mode 100644 index 0000000..ba7f3aa --- /dev/null +++ b/tests/js/workspace-images-category.test.js @@ -0,0 +1,39 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = import('../../web/js/workspace/images-category.mjs'); + +const linked = (id, extra = {}) => ({path:`p${id}.png`, linked:{status:'linked', positive_id:id, ...extra}}); + +test('targets are the distinct linked positives, in selection order, and the rest count as skipped', async () => { + const {categoryTargets} = await source; + const images = [ + linked(7), linked(3), linked(7), + {path:'u.png', linked:{status:'unlinked', positive_id:null}}, + {path:'a.png', linked:{status:'ambiguous', positive_id:4}}, + {path:'v.png', linked:{status:'unavailable', positive_id:null}}, + {path:'none.png'}, + {path:'bad.png', linked:{status:'linked', positive_id:'9'}}, + {path:'zero.png', linked:{status:'linked', positive_id:0}}, + {path:'frac.png', linked:{status:'linked', positive_id:1.5}}, + null, + ]; + assert.deepEqual(categoryTargets(images), {promptIds:[7, 3], skipped:8}); + assert.deepEqual(categoryTargets([]), {promptIds:[], skipped:0}); + assert.deepEqual(categoryTargets([linked(2)]), {promptIds:[2], skipped:0}); +}); + +test('the outcome toast counts the prompts and the skipped images', async () => { + const {categoryOutcome} = await source; + assert.equal(categoryOutcome(3, 0), 'Category set on 3 prompts.'); + assert.equal(categoryOutcome(1, 0), 'Category set on 1 prompt.'); + assert.equal(categoryOutcome(2, 4), 'Category set on 2 prompts. 4 selected images had no linked prompt and were skipped.'); + assert.equal(categoryOutcome(1, 1), 'Category set on 1 prompt. 1 selected image had no linked prompt and was skipped.'); +}); + +test('the dialog and failure copy is the plan\'s', async () => { + const {CATEGORY_DIALOG, CATEGORY_NONE_LINKED, CATEGORY_FAILED} = await source; + assert.deepEqual(CATEGORY_DIALOG, {title:'Categorize selected images', label:'Category', action:'Apply'}); + assert.equal(CATEGORY_NONE_LINKED, 'None of the selected images has a linked prompt.'); + assert.equal(CATEGORY_FAILED, 'Category could not be set. Try again.'); + assert.ok(Object.isFrozen(CATEGORY_DIALOG)); +}); diff --git a/tests/test_api_prompts.py b/tests/test_api_prompts.py index 21bb3ca..1265183 100644 --- a/tests/test_api_prompts.py +++ b/tests/test_api_prompts.py @@ -26,6 +26,7 @@ from database.operations import PromptDatabase # noqa: E402 from py.api import PromptManagerAPI # noqa: E402 import py.api.prompts as prompts_module # noqa: E402 from py.api.prompts import ( # noqa: E402 + BULK_CATEGORY_MAX_PROMPTS, MAX_PAGE_LIMIT, MAX_PAGE_OFFSET, safe_error_message, @@ -909,6 +910,95 @@ class TestBulkRoutes(RouteCoverageCase): self.assertEqual(ok_data["updated_count"], 2) self.assertEqual(self.api.db.get_prompt_by_id(ids[0])["category"], "c") + async def _set_category(self, body): + return await self._json("POST", "/prompt_manager/bulk/category", json=body) + + async def test_bulk_set_category_rejects_malformed_prompt_ids(self): + pid = self._save_prompt("bulk cat strict") + cases = ( + None, + "1", + {"id": pid}, + [], + [pid, "2"], + [pid, 0], + [pid, -3], + [pid, 1.5], + [True], + [pid, None], + list(range(1, BULK_CATEGORY_MAX_PROMPTS + 2)), + ) + for prompt_ids in cases: + with self.subTest(prompt_ids=str(prompt_ids)[:40]): + status, data = await self._set_category( + {"prompt_ids": prompt_ids, "category": "c"} + ) + self.assertEqual(status, 400, data) + self.assertFalse(data["success"]) + self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"]) + + async def test_bulk_set_category_validates_the_category(self): + pid = self._save_prompt("bulk cat validate") + for category in ("x" * 101, "bad\x00name", 7, ["c"]): + with self.subTest(category=str(category)[:20]): + status, data = await self._set_category( + {"prompt_ids": [pid], "category": category} + ) + self.assertEqual(status, 400, data) + self.assertFalse(data["success"]) + for body in ([pid], "text"): + with self.subTest(body=body): + status, data = await self._set_category(body) + self.assertEqual(status, 400, data) + resp = await self.client.post( + "/prompt_manager/bulk/category", + data="{not json", + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(resp.status, 400) + self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"]) + + async def test_bulk_set_category_dedupes_and_accepts_the_cap(self): + ids = [self._save_prompt(f"bulk cat dedupe {i}") for i in range(2)] + seen = [] + original = self.api.db.bulk_set_category + + def spy(prompt_ids, category): + seen.append((list(prompt_ids), category)) + return original(prompt_ids, category) + + self.api.db.bulk_set_category = spy + status, data = await self._set_category( + {"prompt_ids": [ids[1], ids[0], ids[1], ids[0]], "category": " Land "} + ) + self.assertEqual(status, 200, data) + self.assertEqual(data["updated_count"], 2) + self.assertEqual(seen, [([ids[1], ids[0]], "Land")]) + # The cap itself is accepted; unknown ids simply do not count. + capped = ids + list(range(10**6, 10**6 + BULK_CATEGORY_MAX_PROMPTS - 2)) + status, data = await self._set_category( + {"prompt_ids": capped, "category": "Sea"} + ) + self.assertEqual(status, 200, data) + self.assertEqual(data["updated_count"], 2) + self.assertEqual(self.api.db.get_prompt_by_id(ids[0])["category"], "Sea") + + async def test_bulk_set_category_keeps_the_legacy_admin_request(self): + # web/js/admin.js posts parseInt ids and a trimmed, non-empty category. + ids = [self._save_prompt(f"bulk cat legacy {i}") for i in range(3)] + status, data = await self._set_category( + {"prompt_ids": ids, "category": "Portraits"} + ) + self.assertEqual(status, 200, data) + self.assertEqual( + set(data), {"success", "message", "updated_count"}, "response shape" + ) + self.assertTrue(data["success"]) + self.assertEqual(data["updated_count"], 3) + self.assertEqual(data["message"], "Set category for 3 prompts") + for pid in ids: + self.assertEqual(self.api.db.get_prompt_by_id(pid)["category"], "Portraits") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_api_workspace_images.py b/tests/test_api_workspace_images.py index 4d27826..1eaeda2 100644 --- a/tests/test_api_workspace_images.py +++ b/tests/test_api_workspace_images.py @@ -1092,6 +1092,27 @@ class TestWorkspaceImages(ImageAPITestCase): db.link_image_to_prompt(owner, str(files["ambiguous"])) return files + async def test_bulk_category_change_shows_on_the_next_listing(self): + files = self._filter_fixtures() + db = self.api.db + night_a = db.get_prompt_id_for_image(str(files["night_a"])) + # Warm every listing cache (scan, orders, card facts, link reads). + for params in ({}, {"category": "Landscapes"}, {"category": "Night"}): + response, data = await self.get(**params) + self.assertEqual(response.status, 200, data) + self.assertEqual(data["total"], 3) + response = await self.client.post( + "/prompt_manager/bulk/category", + json={"prompt_ids": [night_a], "category": "Landscapes"}, + ) + self.assertEqual(response.status, 200, await response.json()) + _, moved = await self.get(category="Landscapes") + self.assertEqual(self._names(moved), ["night_a.png"]) + self.assertEqual(moved["images"][0]["linked"]["category"], "Landscapes") + _, night = await self.get(category="Night") + self.assertEqual(night["total"], 2) + self.assertNotIn("night_a.png", self._names(night)) + async def test_positive_count_counts_distinct_linked_positives(self): files = self._filter_fixtures() db = self.api.db diff --git a/web/js/workspace/images-category.mjs b/web/js/workspace/images-category.mjs new file mode 100644 index 0000000..6bcba8c --- /dev/null +++ b/web/js/workspace/images-category.mjs @@ -0,0 +1,34 @@ +import { countOf, noun } from './plural.mjs'; + +/** + * The Images selection bar's "Set category" (#249 D8). Images have no category of their own: the listing's category + * filter matches the linked positive prompt's, so the action sets the category on those prompts. + */ +export const CATEGORY_DIALOG = Object.freeze({title:'Categorize selected images', label:'Category', action:'Apply'}); +export const CATEGORY_NONE_LINKED = 'None of the selected images has a linked prompt.'; +export const CATEGORY_FAILED = 'Category could not be set. Try again.'; + +function linkedPositive(image) { + const link = image?.linked; + if (link?.status !== 'linked') return null; + return Number.isSafeInteger(link.positive_id) && link.positive_id > 0 ? link.positive_id : null; +} + +/** The distinct linked positives of `images`, first-seen order; `skipped` counts the images without one. */ +export function categoryTargets(images) { + const ids = new Set(); + let skipped = 0; + for (const image of images) { + const id = linkedPositive(image); + if (id === null) skipped += 1; + else ids.add(id); + } + return {promptIds:[...ids], skipped}; +} + +/** The success toast: the prompts changed, then any selected images that had no linked prompt. */ +export function categoryOutcome(prompts, skipped) { + const done = `Category set on ${countOf(prompts, 'prompt')}.`; + if (!skipped) return done; + return `${done} ${countOf(skipped, 'selected image')} had no linked prompt and ${noun(skipped, 'was', 'were')} skipped.`; +} diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index a6a4182..885a7d4 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -11,7 +11,8 @@ import { createMediaViewer } from './media-viewer.mjs'; import { noun } from './plural.mjs'; import { pageHeading } from './heading.mjs'; import { mountToolbar } from './toolbar.mjs'; -import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs'; +import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_NONE_LINKED, categoryOutcome, categoryTargets } from './images-category.mjs'; +import { openImportDialog, openInputDialog, openRefineDialog } from './workspace-dialogs.mjs'; import { showWorkspaceToast } from './toast.mjs'; function el(tag, text, className) { @@ -185,13 +186,14 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (bulkbar) { bulkbar.id = 'bulkbar'; bulk = el('div', undefined, 'bulkbar'); bulkCount = el('strong'); - // Set category is wired by the next selection task; until then it does nothing. + const setCategory = createButton('Set category'); + scope.listen(setCategory, 'click', () => openCategory(setCategory)); const exportMetadata = createButton('Export metadata'); scope.listen(exportMetadata, 'click', () => exportChecked(exportMetadata)); const clear = createButton('Clear'); // Clear hides the bar it sits in, so focus moves to the page heading instead of falling to the page. scope.listen(clear, 'click', () => { clearChecked(); focusFallback.focus({preventScroll:true}); }); - bulk.append(bulkCount, createButton('Set category'), exportMetadata, clear); + bulk.append(bulkCount, setCategory, exportMetadata, clear); main.append(bulkbar); } const body = el('div', undefined, 'gallery-layout image-workspace'); @@ -367,7 +369,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (exporting) return; exporting = true; button.setAttribute('aria-disabled', 'true'); - const images = state.items.filter(image => checked.has(imageKey(image))); + const images = checkedImages(); const {envelopes, failed} = await collectImagesMetadata(images, async image => toInspectionRecord(await api.get(inspectPath(image), {signal:scope.signal}), image)); if (scope.signal.aborted) return; @@ -376,6 +378,46 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (envelopes.length) downloadJson(BUNDLE_FILENAME, metadataBundle(envelopes, new Date().toISOString())); showWorkspaceToast(exportOutcome(envelopes.length, envelopes.length + failed)); } + /** + * Set category (app.js bulk-category → simpleInput): images have no category, so Apply sets it on the selection's + * distinct linked positives, then reloads the categories and the listing, which clears the selection. With no + * linked image there is nothing to change: a toast says so and no dialog opens. A failure keeps the selection. + */ + let categorizing = false; + function openCategory(button) { + if (categorizing) return; + if (!categoryTargets(checkedImages()).promptIds.length) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; } + openInputDialog({scope, trigger:button, title:CATEGORY_DIALOG.title, label:CATEGORY_DIALOG.label, + action:CATEGORY_DIALOG.action, onApply:category => applyCategory(button, category)}); + } + async function applyCategory(button, category) { + if (categorizing) return; + const {promptIds, skipped} = categoryTargets(checkedImages()); + if (!promptIds.length) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; } + categorizing = true; + button.setAttribute('aria-disabled', 'true'); + let result = null; + try { + result = await api.post('/prompt_manager/bulk/category', {prompt_ids:promptIds, category}, {signal:scope.signal}); + } catch { + // Reported below; the selection stays as it was. + } + if (scope.signal.aborted) return; + categorizing = false; + button.removeAttribute('aria-disabled'); + if (!result) { showWorkspaceToast(CATEGORY_FAILED); return; } + const updated = Number.isSafeInteger(result.updated_count) && result.updated_count >= 0 ? result.updated_count : promptIds.length; + showWorkspaceToast(categoryOutcome(updated, skipped)); + await loadCategories(); + if (scope.signal.aborted) return; + const focused = document.activeElement === button; + reset(); + // The reload hides the bar with the focused action in it, as Clear does: focus moves to the page heading. + if (focused) focusFallback.focus({preventScroll:true}); + } + function checkedImages() { + return state.items.filter(image => checked.has(imageKey(image))); + } /** A Blob, an anchor and a deferred revoke, as the concept's exportSample does. */ function downloadJson(filename, value) { const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], {type:'application/json'})); diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index c238678..11228fb 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -10,6 +10,8 @@ * - "Import & inspect" (a1483–1491): only the real operations, Inspect a local image and Scan an output folder → * Rescan; no "Import portable recipes" (D15). Rescan shows its running state (aria-disabled "Rescanning…"). * - "Copy prompt" (a1223–1241): the select-to-copy fallback when the clipboard is unavailable (D22). + * - One-value input (a1138 `simpleInput`): a "Category"-style field (`input#action-input`) and Cancel / a primary + * action; used by the Images selection bar's "Set category" (#249 D8). * * Every opener takes the page `scope`: disposing it closes and removes the shell, so a dialog never outlives its page. */ @@ -257,3 +259,29 @@ export function openCopyFallback(text, { scope, trigger = null } = {}) { ], { scope, trigger, focusTarget: area }); area.select(); } + +/** + * The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary + * `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field. + */ +export function openInputDialog({ scope, title, label, action, value = '', onApply, trigger = null }) { + requireScope(scope); + requireCallbacks({ onApply }); + for (const [name, text] of Object.entries({ title, label, action })) { + if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`); + } + const input = el('input'); + input.value = typeof value === 'string' ? value : ''; + const cancel = createButton('Cancel'); + const apply = createButton(action, 'ca-primary'); + cancel.addEventListener('click', () => finish()); + apply.addEventListener('click', () => { + const entered = input.value.trim(); + if (!entered) { input.focus(); return; } + finish(); + onApply(entered); + }); + const actions = el('div', undefined, 'dialog-actions'); + actions.append(cancel, apply); + show(title, [field(label, input, 'action-input'), actions], { scope, trigger, focusTarget: input }); +}