Compare commits

...
Author SHA1 Message Date
Vito Sansevero ae890717d1 Merge remote-tracking branch 'origin/main' into feat/437-bulk-prompt-category 2026-10-28 16:47:42 -07:00
Vito Sansevero 72df65113a feat(library): set the category on many selected prompts (#437)
The Prompt library shelf gets the concept's Images selection checkbox on each
card and its .bulkbar (N selected, Set category, Clear), recorded as D148.
Set category opens the shared one-value dialog and posts
{prompt_ids, category, expected_epoch} to POST /prompt_manager/bulk/category:
the server checks the storage epoch the shelf was read under, then sets the
category on the Library parents in one BEGIN IMMEDIATE transaction, writing
only rows that change. A restored storage answers 409 storage-changed and
writes nothing. The legacy admin and images_filter bodies are unchanged.

This replaces the legacy admin page's bulk Set Category, a cutover
prerequisite (#334).

Closes #437
2026-10-28 16:17:23 -07:00
16 changed files with 929 additions and 26 deletions
+65 -8
View File
@@ -1446,9 +1446,9 @@ Recorded deviations on this page (spec §8):
checked images' own categories: it opens the concept's `simpleInput` dialog
("Categorize selected images", `input#action-input` labelled Category) and writes
`PUT`/`DELETE /prompt_manager/images/category`, never a prompt (see D140). Prompt
categories are changed in the Prompt library editor and on the legacy admin page's
"Set Category"; `POST /prompt_manager/bulk/category` is unchanged and serves that
legacy page only. The write carries no page signal: leaving Images while it runs lets
categories are changed in the Prompt library editor, the Prompt library's selection
bar (D148, #437) and on the legacy admin page's "Set Category"; both bulk ones write
`POST /prompt_manager/bulk/category`. The write carries no page signal: leaving Images while it runs lets
it finish without a toast, and the return loads the collection afresh instead of the
saved visit. Its success reloads the categories and the listing together (clearing
the selection, focus to the heading, and, as any replaced collection does, the
@@ -2196,6 +2196,61 @@ npm run --prefix tools/vendor test:workflows -- library-concept.spec.mjs \
workspace-library.spec.mjs --project chromium --project touch --project firefox --project webkit
```
### Selecting prompts and Set category (#437)
**D148** (beyond the concept, whose Library cards have no checkbox): the Prompt library
selects prompts and sets their category in one step, the legacy admin page's bulk "Set
Category". It is built only from the concept's Images parts:
- Each `article.prompt-card` leads with the Images `.image-overlays > label > input`
checkbox ("Select <title>"), placed by the concept's overlay CSS over the art; the card
carries `position: relative` (the concept puts it on `.media-art`). The checkbox comes
before the title in tab order (on Images it follows the image button). Tab and
Shift+Tab across windowed cards reach it (the card's controls are `button,
input[type=checkbox]`); Home, End and the arrow keys still move between titles.
- `div#library-bulkbar` sits between the toolbar and the caption, empty until something
is checked, then the concept's `.bulkbar`: "N selected", Set category, Clear. The
selection is a set of prompt ids over the loaded shelf: windowed-out cards keep their
state, Load more keeps it, and any shelf reload (search, category, sort, tags, a
write) and leaving the page clear it. Clicking a checkbox toggles it and sets the
anchor; Shift+click applies the anchor's range with the box's new state (the D135
helpers). Escape in the bar and Clear clear it, focus to the heading. There is no
Ctrl/Cmd+A, Shift+arrow, long-press or "Select all N matching" on the Library.
- Set category opens the shared `simpleInput` dialog ("Categorize selected prompts",
`input#action-input` labelled Category, at most 100 characters, the Library's
categories as suggestions). A blank value keeps it open; a control character is
refused on the field ("A category cannot contain control characters."). More than
1,000 selected toasts "Select at most 1,000 prompts." and opens nothing.
- Apply posts `{prompt_ids, category, expected_epoch}` to `POST
/prompt_manager/bulk/category`, the epoch being the shelf's first page's
`library_epoch` (kept with a returning visit; a later page from another epoch is a
shelf error). The server checks the epoch, then sets the category on the Library
parents among the ids in one `BEGIN IMMEDIATE` transaction, writing (and registering as
Library parents, as the editor's PATCH does) only rows whose category differs, and
answers `{library_epoch, category, updated, unchanged, not_found}`. A restored storage
answers 409 `storage-changed` and writes nothing. The legacy `{prompt_ids, category}`
and `{images_filter, category}` bodies are unchanged.
- Outcomes: "Category “X” set on N prompts." (plus "M could not be found."), then the
shelf and its categories reload and cached Library views and the sidebar counts are
invalidated, also when the page was left during the write. None found: "None of the
selected prompts could be found. The Library was reloaded." Storage restored: "Prompt
storage was restored since this page loaded. No category was changed." and a reload.
No answer, 502, 503 or 504: "The category change could not be confirmed. The Library
was reloaded." Any other refusal: "Category could not be set. Try again.", the
selection kept. Set category is `aria-disabled` while the write runs.
- The bar's DOM and write loop live in `library-selection.mjs`; the toast text
(`setOutcome` with the noun "prompt"), the range helpers, the count and the dialog are
the Images bar's. One bar core shared with `images-selection.mjs` is a follow-up.
Tests: `tests/browser/workspace-library-selection.spec.mjs` (set, blank and control
characters, Clear/Escape/filter, Shift+click range and Tab across the window edge, the
fence after a return visit, an unseen restore, refused and unconfirmed writes);
`tests/browser/library-concept.spec.mjs` ("the Library selection checkbox and bar match
the concept's Images ones", 1600x1100 and 390x844, both themes, with a moved-checkbox
mutation); units `tests/js/workspace-library-selection.test.js`,
`tests/js/workspace-library-state.test.js` (the epoch); Python
`tests/test_bulk_library_category.py`. A Python change: ComfyUI needs a restart.
### Prompt history page and recorded deviations (#249 PR F-a)
The history page (`#/library/<id>`) takes the concept's heading, summary, negative
@@ -4916,7 +4971,7 @@ category; without one it shows the category its link supplies.
category can still be removed. An unexpected failure is a logged 500 ("The
category could not be changed." / "Image categories could not be read.") without
the exception text. The routes never read or write `prompts`, `prompt_pairs` or
`generated_images`; `POST /prompt_manager/bulk/category` is unchanged.
`generated_images`; `POST /prompt_manager/bulk/category` (prompts only) is not one of them.
- Duplicate cleanup (System › Maintenance): the kept copy's own category wins. A
kept copy without one receives the removed copy's (`COPY_SQL`, part of the
cleanup's query-plan check). The removed copy's row is never deleted, so a
@@ -4934,7 +4989,8 @@ category; without one it shows the category its link supplies.
the side panel, the viewer and History's media inspector (D145); Tags & categories
counts images on each category row and lists image-only values (D143), and Manage
category previews and renames image rows too (D144). Prompt categories are changed in
the Prompt library editor and on the legacy admin page's "Set Category". Concept
the Prompt library editor, the Prompt library's selection bar (D148, #437) and on the
legacy admin page's "Set Category". Concept
comparisons, at 1600x1100 and 390x844 in both themes: `images-concept.spec.mjs` (the
Set category dialog and the options), `inspector-concept.spec.mjs` (the chip),
`history-concept.spec.mjs` (History's chip) and `vocabulary-visual.spec.mjs` (rows and
@@ -6037,9 +6093,10 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
longer hands off to the legacy tag manager. That page stays reachable from Overview's
"Open existing tag manager" card for tag search and sort, the untagged entry and
multi-tag AND/OR browsing; bulk prompt actions (delete, tag, category) stay on the
legacy admin page. A prompt's category is changed in the Prompt library editor or with
the legacy admin page's "Set Category" (`POST /prompt_manager/bulk/category`, unchanged);
multi-tag AND/OR browsing; bulk prompt delete and tag stay on the legacy admin page. A
prompt's category is changed in the Prompt library editor, on many prompts at once with
the Prompt library's selection bar (D148, #437), or with the legacy admin page's "Set
Category" (both `POST /prompt_manager/bulk/category`);
the Images selection bar's Set category sets images' own categories only (D140, #332),
and Manage category renames a value on prompts, recipes and images together (D144). The screen consumes only the `/prompt_manager/vocabulary/*` routes
above and adds no route, so PR B needs no ComfyUI restart: a browser refresh is enough.
+55
View File
@@ -21,6 +21,8 @@ _UUID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
_TOKEN = re.compile(r"[0-9a-f]{64}")
# Parent metadata stored on the prompt row; title lives in prompt_library_state.
_PROMPT_COLUMNS = ("category", "rating", "notes")
# Ids per IN (...) read: well under SQLite's oldest bound-parameter limit (999).
_BULK_CHUNK = 500
class LibraryCommandError(Exception):
@@ -85,6 +87,13 @@ def _validate_epoch(expected_epoch: str) -> None:
raise ValueError("expected_epoch must be a canonical UUID")
def _validate_bulk_ids(prompt_ids: list[int]) -> None:
if not isinstance(prompt_ids, list) or not prompt_ids:
raise ValueError("prompt_ids must be a non-empty list")
if any(type(pid) is not int or not 1 <= pid <= 2**63 - 1 for pid in prompt_ids):
raise ValueError("prompt_ids must be positive integers")
def _validate_guards(positive_id: int, expected_epoch: str, if_match: str) -> None:
if type(positive_id) is not int or not 1 <= positive_id <= 2**63 - 1:
raise ValueError("Invalid positive prompt ID")
@@ -308,3 +317,49 @@ class LibraryCommandOperations:
positive_created=False,
pair_created=pair_created,
)
def bulk_set_library_category(
self, prompt_ids: list[int], category: str, *, expected_epoch: str
) -> dict:
"""Set one category on many Library parents, all or nothing (#437).
Guarded by the storage epoch the page read. Only rows whose category
differs are written (their ``updated_at`` and edit token change) and
registered as Library parents, as the editor's PATCH does; ids
that are missing or are not Library parents count as not found.
"""
_validate_epoch(expected_epoch)
_validate_bulk_ids(prompt_ids)
prompt_ids = list(dict.fromkeys(prompt_ids))
if not isinstance(category, str) or not category.strip():
raise ValueError("Category is required")
validate_category(category)
category = category.strip()
with self._pair_transaction() as conn:
epoch = self._library_epoch(conn, expected_epoch)
current = {}
for start in range(0, len(prompt_ids), _BULK_CHUNK):
chunk = prompt_ids[start : start + _BULK_CHUNK]
marks = ",".join("?" for _ in chunk)
current.update(
conn.execute(
f"SELECT p.id,p.category FROM prompts p " # nosec B608
f"WHERE p.id IN ({marks}) AND {_ELIGIBLE}",
chunk,
).fetchall()
)
changed = [pid for pid, value in current.items() if value != category]
for pid in changed:
self._ensure_library_parent(conn, pid)
now = utc_now_iso()
conn.executemany(
"UPDATE prompts SET category=?,updated_at=? WHERE id=?",
[(category, now, pid) for pid in changed],
)
return {
"library_epoch": epoch,
"category": category,
"updated": len(changed),
"unchanged": len(current) - len(changed),
"not_found": len(prompt_ids) - len(current),
}
+38
View File
@@ -8,8 +8,10 @@ import sqlite3
from aiohttp import web
try:
from ...database.library_commands import LibraryCommandError
from ...database.pairs import PairDependencyError
except ImportError:
from database.library_commands import LibraryCommandError
from database.pairs import PairDependencyError
from urllib.parse import quote
@@ -1180,6 +1182,8 @@ class PromptRoutesMixin:
)
if "images_filter" in data:
return await self._bulk_category_by_filter(data)
if "expected_epoch" in data:
return await self._bulk_library_category(data)
if not data.get("prompt_ids"):
return web.json_response(
{"success": False, "error": "No prompt IDs provided"}, status=400
@@ -1216,6 +1220,40 @@ class PromptRoutesMixin:
status=500,
)
async def _bulk_library_category(self, data: dict) -> web.Response:
"""The workspace Library's fenced form (#437).
``{prompt_ids, category, expected_epoch}``: a non-blank category on
the selected Library parents, refused with 409 ``storage-changed``
when prompt storage was restored since the page read its epoch.
"""
fields = {"prompt_ids", "category", "expected_epoch"}
if set(data) != fields:
return bad_request(
"Request must contain exactly " + ", ".join(sorted(fields))
)
try:
prompt_ids = parse_bulk_prompt_ids(data["prompt_ids"])
result = await self._run_in_executor(
self.db.bulk_set_library_category,
prompt_ids,
data["category"],
expected_epoch=data["expected_epoch"],
)
except LibraryCommandError as exc:
return web.json_response(
{
"success": False,
"code": exc.code,
"library_epoch": exc.epoch,
"error": "Prompt storage was restored; refresh the library",
},
status=409,
)
except ValueError as exc:
return bad_request(str(exc))
return web.json_response({"success": True, **result})
async def _bulk_category_by_filter(self, data: dict) -> web.Response:
"""Set the category on every linked positive a listing filter admits.
+53
View File
@@ -30,6 +30,9 @@ export function mapLibraryConcept(reference) {
// A shorter toolbar moves what follows it up by as much.
const rise=library.toolbar.height-toolbar.height;
const cards=library.cards.map((card,index)=>({...card,
// D148 (#437): each card leads with the concept's Images `.image-overlays` selection checkbox, placed absolutely
// over the art, so nothing else moves; it is compared with the concept's own overlay below.
children:['image-overlays',...card.children],
// D23: "− N negative variants" uses the singular for one; that row is the concept's own, measured so (`oracle`).
meta:card.meta.items[0]?.text===PLURAL_ONE?reference.oracle.meta[index]:card.meta,
// D23: a long title wraps inside the title row, so the title button may shrink below its text (`min-width: 0`).
@@ -311,6 +314,56 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
});
/**
* D148 (#437): the selection checkbox overlay of `card` and the `.bulkbar` row inside `main`, with one item checked,
* measured alike in the concept's Images page and production's Library: the overlay's inset in its card, the checkbox,
* and the bar's box, tokens and the two actions both bars have (Set category, Clear).
*/
const measureSelection=(page,{card,main})=>page.evaluate(({card,main})=>{
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
const rect=node=>node.getBoundingClientRect(),px=value=>Math.round(value*100)/100;
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','color');
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginBottom','borderTopWidth','borderTopStyle',
'borderTopColor','borderTopLeftRadius','backgroundColor');
const host=document.querySelector(card),overlay=host.querySelector('.image-overlays'),label=overlay.querySelector(':scope > label');
const input=label.querySelector('input'),root=document.querySelector(main),bar=root.querySelector('.bulkbar');
const shared=['Set category','Clear'];
return {
overlay:{left:px(rect(overlay).left-rect(host).left),top:px(rect(overlay).top-rect(host).top),right:px(rect(host).right-rect(overlay).right),
...css(overlay,'position','display','justifyContent','pointerEvents')},
label:css(label,'pointerEvents'),checkbox:{width:px(rect(input).width),height:px(rect(input).height),checked:input.checked,...css(input,'accentColor')},
bar:{left:px(rect(bar).left-rect(root).left),...css(bar,'display','alignItems','columnGap','rowGap','flexWrap'),...edges(bar),...font(bar),
count:{text:bar.querySelector(':scope > strong').textContent,...font(bar.querySelector(':scope > strong'))},
buttons:[...bar.querySelectorAll(':scope > button')].filter(button=>shared.includes(button.textContent.trim()))
.map(button=>({text:button.textContent.trim(),height:px(rect(button).height),...font(button),...edges(button)}))},
};
},{card,main});
async function conceptSelection(page,theme) {
await openConcept(page,'images',theme);
await page.locator('#image-grid .media-card').first().locator('input[data-select]').check();
await expect(page.locator('#bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0,0);await settle(page);
return measureSelection(page,{card:'#image-grid .media-card',main:'#main'});
}
async function productionSelection(page,theme) {
await openProductionLibrary(page,theme,'no-preference');
await page.locator(CARDS).first().locator('.image-overlays input').check();
await expect(page.locator('#library-bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0,0);await settle(page);
return measureSelection(page,{card:CARDS,main:'#workspace-main'});
}
for(const [width,height] of SIZES)for(const theme of THEMES)test(`the Library selection checkbox and bar match the concept's Images ones ${width}x${height} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const reference=await conceptSelection(page,theme);
await captureCanonicalState(page,testInfo,`concept-images-selection-${width}-${theme}`);
const actual=await productionSelection(page,theme);
await captureCanonicalState(page,testInfo,`production-library-selection-${width}-${theme}`);
await testInfo.attach('library-selection',{body:JSON.stringify({reference,actual},null,2),contentType:'application/json'});
expect(reference.bar.buttons.map(button=>button.text),'the concept bar has both actions').toEqual(['Set category','Clear']);
expect(actual).toEqual(reference);
// The comparison notices a moved checkbox (spec §7 mutation check).
await mutate(page,{css:'.prompt-card .image-overlays { inset: 20px 9px auto }'});
expect((await measureSelection(page,{card:CARDS,main:'#workspace-main'})).overlay.top).not.toBe(reference.overlay.top);
});
// The grid and cards at the concept's three-column breakpoint (style.css 1274–1276).
for(const theme of THEMES)test(`library grid and cards match the concept 1800x1100 ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width:1800,height:1100});
@@ -0,0 +1,177 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
// #437 D148: select prompts on the Library shelf and set their category in one step (the legacy admin's Set Category).
const route='/prompt_manager/workspace#/library';
const cards=page=>page.locator('article[data-prompt-id]');
const card=(page,id)=>page.locator(`article[data-prompt-id="${id}"]`);
const box=(page,id)=>card(page,id).locator('.image-overlays input[type="checkbox"]');
const bar=page=>page.locator('#library-bulkbar > .bulkbar');
const dialog=page=>page.getByRole('dialog',{name:'Categorize selected prompts',exact:true});
const toast=page=>page.locator('.toast');
const heading=page=>page.locator('#workspace-main h1');
async function shelf(request) {
const body=await(await request.get('/prompt_manager/library?limit=100')).json();
return body.items;
}
/** Every POST to the bulk category route, with its body. */
function recordWrites(page) {
const writes=[];
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname==='/prompt_manager/bulk/category')writes.push(item.postDataJSON());});
return writes;
}
/** The first mounted card's index once two frames in a row agree (the shelf's window follows scrolling a frame later). */
async function stableFirstIndex(page) {
return page.evaluate(()=>new Promise(resolve=>{
const first=()=>document.querySelector('article[data-prompt-id]')?.dataset.index;
let previous=first();
const check=()=>requestAnimationFrame(()=>requestAnimationFrame(()=>{const now=first();if(now===previous)resolve(Number(now));else{previous=now;check();}}));
check();
}));
}
async function setCategory(page,value) {
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
await expect(dialog(page)).toBeVisible();
await dialog(page).locator('#action-input').fill(value);
await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
}
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true}})).toBeOK();
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
test('Set category writes the selected prompts on the server, reloads the shelf and clears the selection',async({page,request})=>{
const items=await shelf(request);const [first,second,third]=items;
const writes=recordWrites(page);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
// The checkbox is named after the card's title, as the Images overlay names its file.
await expect(box(page,first.id)).toHaveAccessibleName(new RegExp(`^Select `));
await box(page,first.id).check();await expect(bar(page).locator('strong')).toHaveText('1 selected');
await box(page,second.id).check();await expect(bar(page).locator('strong')).toHaveText('2 selected');
await expect(bar(page).getByRole('button')).toHaveText(['Set category','Clear']);
await setCategory(page,' Studio set ');
await expect(toast(page)).toHaveText('Category “Studio set” set on 2 prompts.');
await expect(dialog(page)).toHaveCount(0);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
for(const item of [first,second]) await expect(card(page,item.id).locator('.card-title .chip')).toHaveText('Studio set');
await expect(card(page,third.id).locator('.card-title .chip')).toHaveText(third.category || 'Uncategorized');
await expect(box(page,first.id)).not.toBeChecked();
const after=await shelf(request);
expect(after.filter(item=>item.category==='Studio set').map(item=>item.id).sort()).toEqual([first.id,second.id].sort());
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
expect(writes).toEqual([{prompt_ids:[first.id,second.id],category:'Studio set',expected_epoch:epoch}]);
// The toolbar's category list now offers the new value, and so does the next dialog.
await expect(page.locator('#category-filter option',{hasText:'Studio set'})).toHaveCount(1);
await box(page,third.id).check();
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
await expect(dialog(page).locator('datalist option[value="Studio set"]')).toHaveCount(1);
await dialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
expect(writes).toHaveLength(1);
});
test('a blank or control-character category keeps the dialog open; Clear and Escape clear the selection',async({page,request})=>{
const items=await shelf(request);const writes=recordWrites(page);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
const field=dialog(page).locator('#action-input');
await field.fill(' ');await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
await expect(dialog(page)).toBeVisible();await expect(field).toBeFocused();
await field.fill('bad\tvalue');await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
await expect(dialog(page)).toBeVisible();
expect(await field.evaluate(node=>node.validationMessage)).toBe('A category cannot contain control characters.');
await dialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
expect(writes).toHaveLength(0);
await bar(page).getByRole('button',{name:'Clear',exact:true}).click();
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(heading(page)).toBeFocused();
await expect(box(page,items[0].id)).not.toBeChecked();
await box(page,items[1].id).check();
await bar(page).getByRole('button',{name:'Set category',exact:true}).focus();await page.keyboard.press('Escape');
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(heading(page)).toBeFocused();
// A filter change replaces the shelf and clears the selection too.
await box(page,items[1].id).check();await expect(bar(page)).toBeVisible();
await page.locator('#search').fill('portrait');
await expect(page.locator('#library-bulkbar')).toBeEmpty();
});
test('Shift+click selects a range across windowed cards, Tab reaches the checkbox at the window edge, and a return keeps the fence',async({page,request,isMobile})=>{
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}})).toBeOK();
if(!isMobile)await page.setViewportSize({width:1600,height:1100});
const writes=recordWrites(page);
await page.goto(route);await expect(cards(page).first()).toBeVisible();
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
for(let index=0;index<3;index++) {
await expect(more).toHaveAttribute('aria-disabled','false');
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
await more.evaluate(node=>node.click());await response;
}
await expect(more).toHaveAttribute('aria-disabled','false');
const firstId=Number(await cards(page).first().getAttribute('data-prompt-id'));
await box(page,firstId).check();
// Windowed: fewer cards are mounted than are loaded.
expect(await cards(page).count()).toBeLessThan(160);
// Tab from the last mounted card's last control lands on the next (unmounted) card's checkbox.
const last=cards(page).last();const lastIndex=Number(await last.getAttribute('data-index'));
await last.locator('button').last().focus();await page.keyboard.press('Tab');
const next=page.locator(`article[data-index="${lastIndex+1}"]`);
await expect(next.locator('.image-overlays input')).toBeFocused();
// Shift+click that card's checkbox: every card from the first to it is selected, mounted or not.
await next.locator('.image-overlays > label').click({modifiers:['Shift']});
await expect(bar(page).locator('strong')).toHaveText(`${lastIndex+2} selected`);
// Shift+Tab from a checkbox whose previous card is unmounted lands on that card's last control.
// Halfway down the loaded cards (short of the continuation, so no page loads), once the window stops moving.
await page.evaluate(()=>{const grid=document.querySelector('.library-grid');scrollTo(0,grid.getBoundingClientRect().top+scrollY+grid.offsetHeight/2);});
await expect.poll(()=>page.locator('article[data-index="0"]').count()).toBe(0);
const topIndex=await stableFirstIndex(page);
await page.locator(`article[data-index="${topIndex}"] .image-overlays input`).focus();await page.keyboard.press('Shift+Tab');
await expect(page.locator(`article[data-index="${topIndex-1}"] .card-footer .row-actions > button`).last()).toBeFocused();
// Leaving for a prompt's history and coming back restores the shelf without a read; its epoch still fences.
await page.locator(`article[data-index="${topIndex}"]`).getByRole('button',{name:'Explore history',exact:true}).click();
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(cards(page).first()).toBeVisible();await expect(page.locator('#library-bulkbar')).toBeEmpty();
const visible=Number(await cards(page).first().getAttribute('data-prompt-id'));
await box(page,visible).check();await setCategory(page,'Returned');
await expect(toast(page)).toHaveText('Category “Returned” set on 1 prompt.');
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
expect(writes).toEqual([{prompt_ids:[visible],category:'Returned',expected_epoch:epoch}]);
});
test('a restore the page did not see refuses the write, changes nothing and reloads the shelf',async({page,request})=>{
await armUnseenRestores(page);
const items=await shelf(request);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
await expect(restore).toBeOK();
const reload=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&!url.searchParams.has('cursor');});
await setCategory(page,'After restore');
await expect(toast(page)).toHaveText('Prompt storage was restored since this page loaded. No category was changed.');
await reload;await expect(page.locator('#library-bulkbar')).toBeEmpty();
expect((await shelf(request)).filter(item=>item.category==='After restore')).toEqual([]);
// The reloaded shelf reads the new epoch: the next write goes through.
await box(page,items[0].id).check();await setCategory(page,'After reload');
await expect(toast(page)).toHaveText('Category “After reload” set on 1 prompt.');
});
test('an answer that may follow a committed write says so and reloads; a refused write keeps the selection',async({page,request})=>{
const items=await shelf(request);
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
await box(page,items[0].id).check();
await page.route('**/prompt_manager/bulk/category',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'}),{times:1});
await setCategory(page,'Refused');
await expect(toast(page)).toHaveText('Category could not be set. Try again.');
await expect(bar(page).locator('strong')).toHaveText('1 selected');
let sent=false;
await page.route('**/prompt_manager/bulk/category',async route=>{sent=true;await route.fetch();await route.abort('failed');},{times:1});
await setCategory(page,'Unconfirmed');
await expect(toast(page)).toHaveText('The category change could not be confirmed. The Library was reloaded.');
expect(sent).toBe(true);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
await expect(card(page,items[0].id).locator('.card-title .chip')).toHaveText('Unconfirmed');
});
+2 -1
View File
@@ -618,7 +618,8 @@ test('card matches the concept and never wraps the category mid-word',async({pag
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
// app.js renderLibrary a586: article.prompt-card > .card-art, .card-content, .card-footer.
await expect(card).toHaveClass('prompt-card');
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-art','card-content','card-footer']);
// D148 (#437): the concept's Images selection overlay leads the card.
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['image-overlays','card-art','card-content','card-footer']);
expect(await card.locator('.card-art').evaluate(node=>[...node.children].map(child=>child.tagName))).toEqual(['IMG','IMG']);
expect(await card.locator('.card-content').evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-title','prompt-inspector','card-meta','tags']);
expect(await card.locator('.card-title').evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['BUTTON.','SPAN.chip']);
@@ -59,3 +59,9 @@ test('the toolbar list merges prompt and image categories once each', async () =
assert.deepEqual(mergeCategories(['A', '', ' ', 7], null), ['A']);
assert.deepEqual(mergeCategories(undefined, ['landscapes', 'Landscapes']), ['landscapes', 'Landscapes']);
});
test('the outcome names what was set: images by default, prompts for the Library bar (#437)', async () => {
const {setOutcome} = await source;
assert.equal(setOutcome({updated:1, unchanged:0, not_found:0}, 'A', 'prompt'), 'Category “A” set on 1 prompt.');
assert.equal(setOutcome({updated:2, unchanged:0, not_found:0}, 'A', 'prompt'), 'Category “A” set on 2 prompts.');
});
@@ -0,0 +1,44 @@
const test = require('node:test');
const assert = require('node:assert/strict');
// #437 D148: the Prompt library's selection bar sets one category on the selected prompts, fenced by the storage epoch.
const source = import('../../web/js/workspace/library-selection.mjs');
test('the dialog is the shared simpleInput with the prompt title, and the route and cap are the legacy ones', async () => {
const {PROMPT_CATEGORY_DIALOG, CATEGORY_URL, MAX_CATEGORY_PROMPTS, TOO_MANY_PROMPTS} = await source;
assert.deepEqual({...PROMPT_CATEGORY_DIALOG}, {title:'Categorize selected prompts', label:'Category', action:'Apply'});
assert.equal(CATEGORY_URL, '/prompt_manager/bulk/category');
assert.equal(MAX_CATEGORY_PROMPTS, 1000);
assert.equal(TOO_MANY_PROMPTS, 'Select at most 1,000 prompts.');
});
test('a confirmed write toasts the shared outcome with "prompt", invalidates and reloads', async () => {
const {writeOutcome} = await source;
assert.deepEqual(writeOutcome({success:true, category:'Studio', updated:2, unchanged:1, not_found:0}, null),
{toast:'Category “Studio” set on 3 prompts.', committed:true, reload:true});
assert.deepEqual(writeOutcome({success:true, category:'Studio', updated:1, unchanged:0, not_found:2}, null),
{toast:'Category “Studio” set on 1 prompt. 2 could not be found.', committed:true, reload:true});
});
test('none found, a restored storage, an unconfirmed answer and a refused write each say what happened', async () => {
const {writeOutcome, NONE_FOUND, STORAGE_CHANGED, UNCONFIRMED, CATEGORY_FAILED} = await source;
assert.deepEqual(writeOutcome({success:true, category:'X', updated:0, unchanged:0, not_found:3}, null),
{toast:NONE_FOUND, committed:false, reload:true});
assert.deepEqual(writeOutcome(null, {status:409, code:'storage-changed'}), {toast:STORAGE_CHANGED, committed:false, reload:true});
for (const failure of [new TypeError('Failed to fetch'), {status:502}, {status:503}, {status:504}]) {
assert.deepEqual(writeOutcome(null, failure), {toast:UNCONFIRMED, committed:true, reload:true});
}
assert.deepEqual(writeOutcome(null, null), {toast:UNCONFIRMED, committed:true, reload:true});
for (const status of [400, 500]) {
assert.deepEqual(writeOutcome(null, {status}), {toast:CATEGORY_FAILED, committed:false, reload:false});
}
assert.equal(STORAGE_CHANGED, 'Prompt storage was restored since this page loaded. No category was changed.');
});
test('the field refuses control characters as validate_category does', async () => {
const {categoryProblem, CONTROL_CHARACTERS} = await source;
assert.equal(categoryProblem('Studio portraits'), null);
assert.equal(categoryProblem('tab\there'), CONTROL_CHARACTERS);
assert.equal(categoryProblem('nul\u0000'), CONTROL_CHARACTERS);
assert.equal(categoryProblem('unicode ✓ é'), null);
});
+10
View File
@@ -104,3 +104,13 @@ test('archived variant reads use their own scope and cursor without changing the
const active=[];const plain=createLibraryDetail({api:{get:async url=>{active.push(url);return {positive:item(1),variants:[]};}},onChange:()=>{}});await plain.load(1);assert.equal(new URL(active[0],'https://x').searchParams.has('archive'),false);
assert.throws(()=>createLibraryDetail({api:{},onChange:()=>{},archive:'parents'}));
});
test('the stream keeps the first page\'s storage epoch and refuses a later page from another one (#437)',async()=>{
const {createLibraryStream}=await source;const A='11111111-1111-4111-8111-111111111111',B='22222222-2222-4222-8222-222222222222';
const pending=[{...page([item(1)],'next'),library_epoch:A},{...page([item(2)],'more'),library_epoch:A},{...page([item(3)]),library_epoch:B}];
const s=createLibraryStream({api:{get:async()=>pending.shift()}});assert.equal(s.state.epoch,null);
await s.reset();assert.equal(s.state.epoch,A);await s.loadMore();assert.equal(s.state.epoch,A);assert.equal(s.state.items.length,2);
await s.loadMore();assert.ok(s.state.error);assert.equal(s.state.items.length,2);assert.equal(s.state.epoch,A);
const restored=createLibraryStream({api:{},restore:{filters:{},state:s.state}});assert.equal(restored.state.epoch,A);restored.dispose();
const fresh=[{...page([item(4)]),library_epoch:B}];const reset=s.reset({text:'x'});assert.equal(s.state.epoch,null);
s.dispose();const next=createLibraryStream({api:{get:async()=>fresh.shift()}});await next.reset();assert.equal(next.state.epoch,B);next.dispose();await reset;
});
+255
View File
@@ -0,0 +1,255 @@
"""#437: the workspace Library's fenced bulk "Set category" (DB command and route)."""
import sqlite3
import tempfile
import unittest
from pathlib import Path
from database.execution_history_schema import rotate_history_epoch
from database.library_commands import LibraryCommandError
from database.operations import PromptDatabase
from utils.hashing import generate_prompt_hash
try:
from test_api_prompts import RouteCoverageCase
except ImportError:
from tests.test_api_prompts import RouteCoverageCase
URL = "/prompt_manager/bulk/category"
def save(db, text, **metadata):
return db.save_prompt(text, prompt_hash=generate_prompt_hash(text), **metadata)
def row(db, prompt_id):
with db.model.get_connection() as conn:
found = conn.execute(
"SELECT category, updated_at FROM prompts WHERE id=?", (prompt_id,)
).fetchone()
return tuple(found) if found else None
class TestBulkLibraryCategory(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db = PromptDatabase(str(Path(self.temp.name) / "bulk.db"))
self.epoch = self.db.get_library_options()["library_epoch"]
def tearDown(self):
self.db.close_all()
self.temp.cleanup()
def run_bulk(self, ids, category, epoch=None):
return self.db.bulk_set_library_category(
ids, category, expected_epoch=epoch or self.epoch
)
def test_sets_changed_rows_and_counts_unchanged_and_missing(self):
a = save(self.db, "Alpha", category="Old")
b = save(self.db, "Beta", category="New")
c = save(self.db, "Gamma")
before_b = row(self.db, b)
result = self.run_bulk([a, b, c, 99999], " New ")
self.assertEqual(
result,
{
"library_epoch": self.epoch,
"category": "New",
"updated": 2,
"unchanged": 1,
"not_found": 1,
},
)
self.assertEqual(row(self.db, a)[0], "New")
self.assertEqual(row(self.db, c)[0], "New")
# An unchanged row keeps its timestamp (and so its edit token).
self.assertEqual(row(self.db, b), before_b)
def test_changed_category_rotates_the_edit_token_only_for_changed_rows(self):
a = save(self.db, "Alpha", category="Old")
b = save(self.db, "Beta", category="New")
tokens = {
pid: self.db.get_library_positive(pid)["positive"]["edit_token"]
for pid in (a, b)
}
self.run_bulk([a, b], "New")
self.assertNotEqual(
self.db.get_library_positive(a)["positive"]["edit_token"], tokens[a]
)
self.assertEqual(
self.db.get_library_positive(b)["positive"]["edit_token"], tokens[b]
)
def test_changed_rows_become_registered_library_parents_like_the_editor(self):
a = save(self.db, "Alpha", category="Old")
b = save(self.db, "Beta", category="Same")
def registered(pid):
with self.db.model.get_connection() as conn:
return bool(
conn.execute(
"SELECT 1 FROM prompt_library_state WHERE prompt_id=?", (pid,)
).fetchone()
)
self.assertEqual((registered(a), registered(b)), (False, False))
self.run_bulk([a, b], "Same")
self.assertEqual((registered(a), registered(b)), (True, False))
def test_updated_at_is_utc_iso_text(self):
a = save(self.db, "Alpha")
self.run_bulk([a], "Utc")
self.assertTrue(row(self.db, a)[1].endswith("+00:00"))
def test_negative_only_prompts_are_not_library_parents(self):
pair = self.db.save_prompt_pair("Cat", "Blur")
negative = self.db.get_prompt_pair(pair)["negative_prompt_id"]
positive = self.db.get_prompt_pair(pair)["positive_prompt_id"]
result = self.run_bulk([positive, negative], "Animals")
self.assertEqual((result["updated"], result["not_found"]), (1, 1))
self.assertIsNone(row(self.db, negative)[0])
def test_restored_storage_writes_nothing(self):
a = save(self.db, "Alpha", category="Old")
new_epoch = rotate_history_epoch(self.db.model.get_connection())
with self.assertRaises(LibraryCommandError) as caught:
self.run_bulk([a], "New")
self.assertEqual(caught.exception.code, "storage-changed")
self.assertEqual(caught.exception.epoch, new_epoch)
self.assertEqual(row(self.db, a)[0], "Old")
def test_a_failure_mid_write_leaves_every_row_as_it_was(self):
ids = [save(self.db, f"P{i}", category="Old") for i in range(3)]
with self.db.model.get_connection() as conn:
conn.execute(
"CREATE TRIGGER fail_third BEFORE UPDATE OF category ON prompts "
f"WHEN OLD.id={ids[2]} BEGIN SELECT RAISE(ABORT, 'boom'); END"
)
with self.assertRaises(sqlite3.DatabaseError):
self.run_bulk(ids, "New")
self.assertEqual([row(self.db, pid)[0] for pid in ids], ["Old"] * 3)
def test_many_ids_are_read_in_chunks(self):
ids = [save(self.db, f"Many {i}") for i in range(1000)]
result = self.run_bulk(ids, "Bulk")
self.assertEqual(result["updated"], 1000)
def test_rejects_invalid_arguments(self):
a = save(self.db, "Alpha")
for ids, category, epoch in (
([a], "", self.epoch),
([a], " ", self.epoch),
([a], None, self.epoch),
([a], "x" * 101, self.epoch),
([a], "bad\x01", self.epoch),
([a], "ok", "not-a-uuid"),
([], "ok", self.epoch),
):
with self.subTest(category=category, epoch=epoch, ids=ids):
with self.assertRaises(ValueError):
self.run_bulk(ids, category, epoch)
class TestBulkLibraryCategoryRoute(RouteCoverageCase):
def epoch(self):
return self.api.db.get_library_options()["library_epoch"]
async def test_fenced_form_sets_categories_and_reports_counts(self):
ids = [self._save_prompt(f"route {i}", category="Old") for i in range(2)]
status, body = await self._json(
"POST",
URL,
json={
"prompt_ids": ids,
"category": "Fresh",
"expected_epoch": self.epoch(),
},
)
self.assertEqual(status, 200)
self.assertEqual(
body,
{
"success": True,
"library_epoch": self.epoch(),
"category": "Fresh",
"updated": 2,
"unchanged": 0,
"not_found": 0,
},
)
self.assertEqual(self.api.db.get_prompt_by_id(ids[1])["category"], "Fresh")
async def test_restored_storage_answers_409_storage_changed(self):
pid = self._save_prompt("route restore", category="Old")
stale = self.epoch()
new_epoch = rotate_history_epoch(self.api.db.model.get_connection())
status, body = await self._json(
"POST",
URL,
json={"prompt_ids": [pid], "category": "Fresh", "expected_epoch": stale},
)
self.assertEqual((status, body["code"]), (409, "storage-changed"))
self.assertFalse(body["success"])
self.assertEqual(body["library_epoch"], new_epoch)
self.assertIsInstance(body["error"], str)
self.assertEqual(self.api.db.get_prompt_by_id(pid)["category"], "Old")
async def test_fenced_form_validates_its_body(self):
pid = self._save_prompt("route validate")
epoch = self.epoch()
for body in (
{"prompt_ids": [pid], "category": "", "expected_epoch": epoch},
{"prompt_ids": [pid], "category": " ", "expected_epoch": epoch},
{"prompt_ids": [pid], "category": 5, "expected_epoch": epoch},
{"prompt_ids": [pid], "category": "x" * 101, "expected_epoch": epoch},
{"prompt_ids": [pid], "category": "ok", "expected_epoch": "nope"},
{"prompt_ids": [pid], "category": "ok", "expected_epoch": 7},
{"prompt_ids": [], "category": "ok", "expected_epoch": epoch},
{"prompt_ids": [True], "category": "ok", "expected_epoch": epoch},
{
"prompt_ids": list(range(1, 1002)),
"category": "ok",
"expected_epoch": epoch,
},
{"prompt_ids": [pid], "expected_epoch": epoch},
{
"prompt_ids": [pid],
"category": "ok",
"expected_epoch": epoch,
"extra": 1,
},
):
with self.subTest(body=body):
status, answer = await self._json("POST", URL, json=body)
self.assertEqual(status, 400)
self.assertFalse(answer["success"])
self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"])
async def test_fenced_form_reports_an_unexpected_failure(self):
pid = self._save_prompt("route failure")
self._break("bulk_set_library_category")
status, body = await self._json(
"POST",
URL,
json={
"prompt_ids": [pid],
"category": "ok",
"expected_epoch": self.epoch(),
},
)
self.assertEqual(status, 500)
self.assertFalse(body["success"])
async def test_legacy_form_is_unchanged(self):
pid = self._save_prompt("route legacy", category="Old")
status, body = await self._json(
"POST", URL, json={"prompt_ids": [pid], "category": "Legacy"}
)
self.assertEqual((status, body["updated_count"]), (200, 1))
self.assertNotIn("library_epoch", body)
self.assertEqual(self.api.db.get_prompt_by_id(pid)["category"], "Legacy")
if __name__ == "__main__":
unittest.main()
+6
View File
@@ -377,6 +377,12 @@
"functions": 93,
"statements": 94
},
"workspace/library-selection.mjs": {
"lines": 98,
"branches": 86,
"functions": 84,
"statements": 98
},
"workspace/library-state.mjs": {
"lines": 98,
"branches": 95,
+8 -4
View File
@@ -4,7 +4,8 @@ import { countOf } from './plural.mjs';
* The Images selection bar's "Set category" (#332, D140; the concept's bulk-category → simpleInput). Apply sets each
* selected image's own category (`PUT /prompt_manager/images/category`); "Use prompt category" removes it after a
* confirmation (`DELETE`), so the image shows its prompt's category again. It never writes a prompt: prompt
* categories are set in the Prompt library editor and on the legacy admin page's "Set Category".
* categories are set in the Prompt library editor, the Prompt library's selection bar (#437, library-selection.mjs)
* and on the legacy admin page's "Set Category".
*/
export const CATEGORY_URL = '/prompt_manager/images/category';
export const IMAGE_CATEGORIES_URL = '/prompt_manager/images/categories';
@@ -20,11 +21,14 @@ export const CATEGORY_MAX_LENGTH = 100;
// One request names at most 500 images (DC2), as "Add tags" does.
export const MAX_CATEGORY_IMAGES = 500;
/** The success toast of a set: unchanged files count as set; files no source lists are named after. */
export function setOutcome(result, category) {
/**
* The success toast of a set: unchanged items count as set; items that could not be found are named after. `noun` is
* what was set (the Prompt library's selection bar passes "prompt", #437).
*/
export function setOutcome(result, category, noun = 'image') {
const updated = Number(result?.updated) || 0, unchanged = Number(result?.unchanged) || 0;
const missing = Number(result?.not_found) || 0;
const done = `Category “${category}” set on ${countOf(updated + unchanged, 'image')}.`;
const done = `Category “${category}” set on ${countOf(updated + unchanged, noun)}.`;
return missing ? `${done} ${missing.toLocaleString()} could not be found.` : done;
}
+180
View File
@@ -0,0 +1,180 @@
import { createButton } from '../shared/toolkit/core.js';
import { selectionLabel, toggleSelection } from './images-state.mjs';
import { applyKeys, rangeKeys } from './images-multiselect.mjs';
import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_MAX_LENGTH, setOutcome } from './images-category.mjs';
import { openInputDialog } from './workspace-dialogs.mjs';
import { showWorkspaceToast } from './toast.mjs';
/**
* The Prompt library's selection and its one bulk action, "Set category" (#437, D148, beyond the concept): the
* legacy admin page's "Set Category" in the workspace. Built from the concept's Images parts only: each card's
* `.image-overlays` checkbox and the `.bulkbar` row ("N selected", the action, Clear). The selection is a set of
* prompt ids over the shelf's loaded items, so windowed-out cards keep their state; Shift+click on a checkbox applies
* the anchor's range (the Images helpers, D135). The dialog, the range helpers, the count and the toast text are the
* Images bar's own; one bar core shared with images-selection.mjs is a follow-up (D148).
*
* Apply posts `{prompt_ids, category, expected_epoch}` to `POST /prompt_manager/bulk/category`: the epoch the shelf
* was read under, so a restored storage answers 409 `storage-changed` and nothing is written.
*/
export const CATEGORY_URL = '/prompt_manager/bulk/category';
export const PROMPT_CATEGORY_DIALOG = Object.freeze({ ...CATEGORY_DIALOG, title: 'Categorize selected prompts' });
// The route's cap (`BULK_CATEGORY_MAX_PROMPTS`): more opens nothing.
export const MAX_CATEGORY_PROMPTS = 1000;
export const TOO_MANY_PROMPTS = 'Select at most 1,000 prompts.';
export const STORAGE_CHANGED = 'Prompt storage was restored since this page loaded. No category was changed.';
export const UNCONFIRMED = 'The category change could not be confirmed. The Library was reloaded.';
export const NONE_FOUND = 'None of the selected prompts could be found. The Library was reloaded.';
export const CONTROL_CHARACTERS = 'A category cannot contain control characters.';
export { CATEGORY_FAILED };
// Answers that may follow a committed write (the Library editor's unknown outcome): no status, or a gateway's.
const UNCONFIRMED_STATUSES = new Set([502, 503, 504]);
const CHECKBOX = '.image-overlays input[type="checkbox"]';
const CARD = 'article[data-prompt-id]';
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/**
* What a settled write means for the page: `toast` to show, whether the write may have `committed` (the page then
* invalidates its cached Library views) and whether the shelf must `reload` (which clears the selection).
*/
export function writeOutcome(result, failure) {
if (failure === null && result?.success) {
const found = (Number(result.updated) || 0) + (Number(result.unchanged) || 0);
if (!found) return { toast: NONE_FOUND, committed: false, reload: true };
return { toast: setOutcome(result, result.category, 'prompt'), committed: true, reload: true };
}
if (failure?.code === 'storage-changed') return { toast: STORAGE_CHANGED, committed: false, reload: true };
const status = failure?.status;
if (!Number.isInteger(status) || UNCONFIRMED_STATUSES.has(status)) return { toast: UNCONFIRMED, committed: true, reload: true };
return { toast: CATEGORY_FAILED, committed: false, reload: false };
}
/** The field's check before Apply: what `validate_category` refuses beyond the length the field already limits. */
export function categoryProblem(value) {
return /[\x00-\x1f]/.test(value) ? CONTROL_CHARACTERS : null;
}
/**
* Mount the selection on the shelf `grid`. `getItems()` is the shelf's loaded items in order, `getEpoch()` the
* storage epoch they were read under, `getCategories()` the Library's category list (the field's suggestions).
* `onCommitted(receipt)` runs after a write that may have changed prompts, even once the page is gone (the page
* invalidates its cached Library views); `onReload()` then reloads the mounted shelf (which calls `clear`). Focus moves
* to `focusFallback` when it was on the bar the reload hides.
*/
export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, getCategories, focusFallback, onCommitted,
onReload }) {
let checked = Object.freeze(new Set()), anchor = null, shiftBox = null, writing = false;
const element = el('div');
element.id = 'library-bulkbar';
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
const setCategory = createButton('Set category');
const clearButton = createButton('Clear');
bulk.append(count, setCategory, clearButton);
scope.listen(setCategory, 'click', () => openCategory());
scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); });
// Escape in the bar clears the selection; the bar hides, so focus moves to the page heading as after Clear.
scope.listen(element, 'keydown', event => {
if (event.key !== 'Escape' || !checked.size) return;
event.preventDefault();
clear();
focusFallback.focus({ preventScroll: true });
});
const ids = () => getItems().map(item => item.id);
function render() {
if (!checked.size) { bulk.remove(); return; }
const label = selectionLabel(checked.size);
if (count.textContent !== label) count.textContent = label;
if (bulk.parentNode !== element) element.append(bulk);
}
function paint() {
for (const box of grid.querySelectorAll(CHECKBOX)) {
const on = checked.has(Number(box.closest(CARD).dataset.promptId));
if (box.checked !== on) box.checked = on;
}
}
function update(next) { checked = next; paint(); render(); }
function clear() {
anchor = null;
if (checked.size) update(Object.freeze(new Set()));
}
// The checkbox toggles natively; a Shift+click applies the anchor's range with the box's new state.
scope.listen(grid, 'click', event => {
const box = event.target.closest('.image-overlays > label');
if (box && event.shiftKey) shiftBox = Number(box.closest(CARD).dataset.promptId);
});
scope.listen(grid, 'change', event => {
if (!event.target.matches(CHECKBOX)) return;
const id = Number(event.target.closest(CARD).dataset.promptId), on = event.target.checked;
if (shiftBox === id) {
const keys = rangeKeys(ids(), anchor, id);
if (anchor === null || keys.length === 1) anchor = id;
update(applyKeys(checked, keys, on));
} else {
anchor = id;
update(toggleSelection(checked, id, on));
}
shiftBox = null;
});
/** The card's overlay: the concept's `.image-overlays > label > input`, checked from the selection. */
function overlayFor(item, name) {
const overlays = el('div', undefined, 'image-overlays'), label = el('label'), box = el('input');
box.type = 'checkbox';
box.checked = checked.has(item.id);
box.setAttribute('aria-label', `Select ${name}`);
label.append(box);
overlays.append(label);
return overlays;
}
function openCategory() {
if (writing) return;
const loaded = new Set(ids()), selected = [...checked].filter(id => loaded.has(id));
if (selected.length > MAX_CATEGORY_PROMPTS) { showWorkspaceToast(TOO_MANY_PROMPTS); return; }
const epoch = getEpoch();
if (!selected.length || typeof epoch !== 'string') { showWorkspaceToast(CATEGORY_FAILED); return; }
openInputDialog({ scope, trigger: setCategory, title: PROMPT_CATEGORY_DIALOG.title, label: PROMPT_CATEGORY_DIALOG.label,
action: PROMPT_CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, suggestions: getCategories(),
validate: categoryProblem, onApply: category => write({ prompt_ids: selected, category, expected_epoch: epoch }) });
}
/** The write carries no page signal: once sent it completes even if the page is left. A failure keeps the selection. */
async function write(body) {
writing = true;
setCategory.setAttribute('aria-disabled', 'true');
let result = null, failure = null;
try {
result = await api.post(CATEGORY_URL, body);
} catch (error) {
failure = error;
} finally {
writing = false;
setCategory.removeAttribute('aria-disabled');
}
const outcome = writeOutcome(result, failure);
// A left page still invalidates what it cached, so a return never shows the old categories.
if (outcome.committed) onCommitted(result?.success ? result : { phase: 'unconfirmed' });
if (scope.signal.aborted) return;
const focused = element.contains(document.activeElement);
showWorkspaceToast(outcome.toast);
if (!outcome.reload) return;
onReload();
if (focused) focusFallback.focus({ preventScroll: true });
}
render();
return {
element,
overlayFor,
isChecked: id => checked.has(id),
clear,
/** Whether a Set category write is still in flight. */
writing: () => writing,
};
}
+10 -5
View File
@@ -19,7 +19,7 @@ function validatePage(page,cursor) {
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
}
export function createLibraryStream({api,onChange=()=>{},restore}) {
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});
let query=parameters({},40),request,disposed=false,seen=new Set();
if(restore) {
query=parameters(restore.filters,40);const saved=restore.state;
@@ -27,7 +27,7 @@ export function createLibraryStream({api,onChange=()=>{},restore}) {
const firstPagePending=saved?.variantTotal===null && Array.isArray(saved.items) && !saved.items.length && saved.total===0 && saved.cursor===null && saved.hasMore===true;
if(!firstPagePending) validatePage({items:saved.items,total:saved.total,variant_total:saved.variantTotal,next_cursor:saved.cursor,has_more:saved.cursor!==null},null);
if(new Set(saved.items.map(item=>item.id)).size!==saved.items.length) throw new TypeError('Duplicate restored parent');
state=Object.freeze({...saved,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id));
state=Object.freeze({...saved,epoch:saved.epoch ?? null,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id));
}
const emit=patch=>{state=Object.freeze({...state,...patch});onChange(state);};
async function loadMore() {
@@ -37,14 +37,19 @@ export function createLibraryStream({api,onChange=()=>{},restore}) {
try {
const page=await api.get(`/prompt_manager/library?${params}`,{signal:current.signal});
if(disposed || current!==request) return 0;
validatePage(page,cursor);const incoming=[];const added=new Set();
validatePage(page,cursor);
// #437: the storage epoch the shelf's items were read under, which a bulk write sends as its fence; a later
// page from a restored storage is refused (its items would belong to another database).
const epoch=typeof page.library_epoch==='string'?page.library_epoch:null;
if(state.epoch!==null && epoch!==state.epoch) throw new TypeError('Prompt storage changed while the library was loading');
const incoming=[];const added=new Set();
for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));}
for(const value of added) seen.add(value);
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,epoch:state.epoch ?? epoch,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
} catch(error) {if(!disposed && current===request) {request=null;emit({loading:false,error});}return 0;}
}
return {get state(){return state;},loadMore,async reset(filters={}) {
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});return loadMore();
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});return loadMore();
},dispose(){disposed=true;request?.abort();request=null;}};
}
/**
+17 -8
View File
@@ -15,6 +15,7 @@ import {pageHeading} from './heading.mjs';
import {mountToolbar} from './toolbar.mjs';
import {libraryFilters,libraryViewState} from './saved-views.mjs';
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
import {mountLibrarySelection} from './library-selection.mjs';
import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs';
let shelfVisit=null;
@@ -30,6 +31,8 @@ const metadataDrafts=new Map();
const archivedVisible=new Map();
// The concept's three sorts first, then the Library's appended Oldest first (D14).
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['rating','Highest rated'],['oldest','Oldest first']]);
// A card's focusable controls in order: the D148 selection checkbox, then its buttons (#437).
const CARD_CONTROLS='button, input[type="checkbox"]';
const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.';
const ARCHIVED_MESSAGE='Archived. Find it under Show archived in Negative variants.';
function el(tag,text,className) {
@@ -171,6 +174,11 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
// The count is announced when it changes; a reload keeps the last count shown, so nothing else re-announces it.
captionCount.setAttribute('role','status');
const grid=el('div',undefined,'library-grid');grid.setAttribute('role','list');grid.setAttribute('aria-label','Positive prompts');
// #437 D148: select prompts and set their category (the legacy admin's Set Category); a write reloads the shelf.
let categoryNames=[];
const selection=mountLibrarySelection({scope,api,grid,getItems:()=>state?.items ?? [],getEpoch:()=>state?.epoch ?? null,
getCategories:()=>categoryNames,focusFallback:{focus:()=>focusHeading(main)},
onCommitted:receipt=>invalidateLibraryAfterWrite({kind:'bulk-category',receipt}),onReload:()=>{loadCategories();reset();}});
const message=el('p','','library-status');message.setAttribute('role','status');
const continuation=el('div',undefined,'library-continuation');
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
@@ -201,7 +209,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
scope.listen(exportButton,'click',exportLibrary);
headingDescription(main,section,'One positive. Reusable negatives. Every experiment stays connected.');
// Nothing follows the grid in the concept; the legacy Library stays one click away on Overview (spec §5).
continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,caption,grid,continuation);main.append(section);
continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,selection.element,caption,grid,continuation);main.append(section);
const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map();
let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1;
const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2;
@@ -246,7 +254,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
const send=createButton('','send icon-btn');send.setAttribute('aria-label',`Send positive prompt to ComfyUI: ${name}`);send.append(createWorkspaceIcon('send'));send.dataset.sendPositive='';
const explore=createButton('');explore.append(createWorkspaceIcon('branch'),' Explore history');explore.dataset.history='';
actions.append(send,explore);footer.append(facts,actions);
article.append(art,content,footer);article.dataset.index=index;return {node:article,blocks};
article.append(selection.overlayFor(item,name),art,content,footer);article.dataset.index=index;return {node:article,blocks};
}
function captureAnchor() {
// Mounted rows can already differ from estimates before their observer batch.
@@ -340,10 +348,11 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
if(changed) {pendingAnchor ||= anchor;schedule();}
}):null;
measurementObserver?.observe(grid);
function focusCard(index,last=false) {
/** Focus a card's first or `last` control (Tab across the window edge), or its title (`title`: Home/End/arrows). */
function focusCard(index,last=false,title=false) {
if(index<0 || index>=state.items.length) return;
if(dirty) {cancelAnimationFrame(frame);flush();}focusOverride=index;renderWindow();const card=cards.get(state.items[index].id).node;
const controls=card.querySelectorAll('button');(last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow();
const controls=card.querySelectorAll(CARD_CONTROLS);(title?card.querySelector('.card-title > button'):last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow();
}
// The title and Explore history open the prompt's history; the footer send sends the full positive (D23).
scope.listen(grid,'click',event=>{
@@ -355,9 +364,9 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
scope.listen(grid,'keydown',event=>{
const card=event.target.closest('article[data-prompt-id]');if(!card) return;
const index=Number(card.dataset.index);
if(event.target.matches('[data-history]') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1));}
if(event.target.matches('[data-history]') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1),false,true);}
else if(event.key==='Tab') {
const controls=card.querySelectorAll('button');const next=index+(event.shiftKey?-1:1);
const controls=card.querySelectorAll(CARD_CONTROLS);const next=index+(event.shiftKey?-1:1);
const edge=event.shiftKey?controls[0]:controls[controls.length-1];
if(event.target===edge && state.items[next] && !cards.has(state.items[next].id)) {event.preventDefault();focusCard(next,event.shiftKey);}
}
@@ -392,10 +401,10 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
optionsRequest?.abort();const request=optionsRequest=new AbortController();
try {
const data=await api.get('/prompt_manager/library/options',{signal:request.signal});if(scope.signal.aborted || optionsRequest!==request) return;
toolbar.setCategories([...data.categories,filters.category]);toolbar.setValue({category:filters.category});optionsStatus.hidden=true;
categoryNames=[...data.categories];toolbar.setCategories([...data.categories,filters.category]);toolbar.setValue({category:filters.category});optionsStatus.hidden=true;
} catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent=CATEGORIES_FAILED;optionsStatus.hidden=false;}}
}
const reset=()=>{restoreScrollY=null;toolbar.setActiveFilters(refinements());return stream.reset(filters);};
const reset=()=>{restoreScrollY=null;selection.clear();toolbar.setActiveFilters(refinements());return stream.reset(filters);};
/** A toolbar report; a debounced search that lands after a select already applied the same values reloads nothing. */
function applyToolbar(value) {
const next={text:value.q.trim(),category:value.category,sort:value.sort};
+3
View File
@@ -264,6 +264,9 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
/* #249 D23: a parent with one preview, or none, fills the art row instead of leaving the concept's second cell empty. */
.prompt-card .card-art > :only-child { grid-column: 1 / -1; /* D23 */ }
.prompt-card .card-art > .small { display: grid; place-content: center; min-width: 0; color: var(--ca-muted); /* D23 */ }
/* #437 D148: the card's selection checkbox is the concept's Images `.image-overlays`, placed over the art as on an image
card; `.media-art` carries the concept's `position: relative` there, the prompt card carries it here. */
.prompt-card { position: relative; /* D148 */ }
/* Prompt library shelf (#249 PR E). #249 D3: rows stay windowed, as the Images grid's do; the concept's `.library-grid`
tracks and breakpoints lay them out, each row spans them, and appended rows never anchor the scroll. The row's