diff --git a/TESTING.md b/TESTING.md
index 34a13795..09187454 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -2135,8 +2135,10 @@ npm run --prefix tools/vendor test:workflows -- library-concept.spec.mjs \
### 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:
+selects prompts and adds tags to them (#442) or sets their category (#437) in one step,
+the legacy admin page's bulk "Add Tags" and "Set Category", and selects every loaded
+prompt as its select-all checkbox did (#444 ruling). It is built only from the concept's
+Images parts:
- Each `article.prompt-card` leads with the Images `.image-overlays > label > input`
checkbox ("Select
"), placed by the concept's overlay CSS over the art; the card
@@ -2145,13 +2147,36 @@ Category". It is built only from the concept's Images parts:
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
+ is checked, then the concept's `.bulkbar`: "N selected", Add tags, Set category, Clear
+ (the Images bar's order). 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.
+ helpers). Escape in the bar and Clear clear it, focus to the heading.
+- Select all of the loaded shelf (no concept counterpart): Ctrl/Cmd+A in the grid (Images'
+ key check; the search box keeps its text meaning) selects every loaded prompt,
+ windowed-out cards included, and Escape in the grid clears it with focus kept on the
+ card. "Select all N loaded" follows the count while some loaded prompts are unchecked
+ and at most 1,000 are loaded; it selects them all and focus moves to Add tags; N follows
+ each loaded batch. It sits where D135's "Select all N matching" sits, but that offer
+ appears once every loaded image is checked; this one never reaches beyond the loaded
+ prompts. There is no Shift+arrow, long-press or "all matching" on the Library.
+- Add tags opens the shared `simpleInput` dialog ("Tag selected prompts", "Tags,
+ separated by commas", the tag vocabulary `GET /prompt_manager/tags` as suggestions, the
+ list the editor offers). The field refuses no tag, more than 20, a tag over 50
+ characters or with a control character, each with its sentence. Apply posts `{prompt_ids,
+ tags, expected_epoch}` to `POST /prompt_manager/bulk/tags`: prompt tags only, never
+ image tags, auto-tags or pair tags. Names are normalized as image tags are (trim, no
+ commas, casefold repeats folded), registry rows are found and inserted by their exact
+ name and only when some prompt gets them, and a prompt gets only names it lacks by
+ casefold. A prompt whose tags would pass 20 is skipped (`over_limit`), so the editor can
+ still save it. The answer is `{library_epoch, tags, updated, unchanged, not_found,
+ over_limit}`; toasts "Added “a, b” to N prompts." plus already-had, skipped and not-found
+ sentences. Storage restored: "… No tags were added."; unconfirmed: "Adding the tags could
+ not be confirmed. The Library was reloaded."; refused: "Tags could not be added. Try
+ again." The legacy `{prompt_ids, tags}` body is unchanged; both forms refuse a body
+ that is not a JSON object with 400.
- 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
@@ -2173,19 +2198,22 @@ Category". It is built only from the concept's Images parts:
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.
+ selection kept. Every bar action and the select-all offer are `aria-disabled` while a
+ 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);
+fence after a return visit, an unseen restore, refused and unconfirmed writes, Add tags
+and its field and restore refusal, Ctrl/Cmd+A and Select all N loaded);
`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.
+`tests/test_bulk_library_category.py`, `tests/test_bulk_library_tags.py`. A Python
+change: ComfyUI needs a restart.
### Prompt history page and recorded deviations (#249 PR F-a)
@@ -6029,7 +6057,8 @@ 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 delete and tag stay on the legacy admin page. A
+multi-tag AND/OR browsing; bulk prompt delete stays on the legacy admin page (bulk tags:
+the Prompt library's selection bar, D148). 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`);
diff --git a/database/library_commands.py b/database/library_commands.py
index 878a7e98..d328416f 100644
--- a/database/library_commands.py
+++ b/database/library_commands.py
@@ -23,6 +23,8 @@ _TOKEN = re.compile(r"[0-9a-f]{64}")
_PROMPT_COLUMNS = ("category", "rating", "notes")
# Ids per IN (...) read: well under SQLite's oldest bound-parameter limit (999).
_BULK_CHUNK = 500
+# The tags one prompt may carry: what the editor's save accepts (``validate_tags``).
+MAX_LIBRARY_TAGS = 20
class LibraryCommandError(Exception):
@@ -363,3 +365,88 @@ class LibraryCommandOperations:
"unchanged": len(current) - len(changed),
"not_found": len(prompt_ids) - len(current),
}
+
+ def bulk_add_library_tags(
+ self, prompt_ids: list[int], tags: list[str], *, expected_epoch: str
+ ) -> dict:
+ """Add tags to many Library parents, all or nothing (#442).
+
+ Names are normalized once (trim, the vocabulary name rules, casefold
+ repeats folded, at most 20). A parent is given only the names it lacks,
+ compared with ``casefold`` against its own tags; registry rows are
+ found and inserted by their exact name, and only for names some parent
+ gets. A parent whose tags would pass 20 is skipped (``over_limit``) so
+ the editor can still save it. Pair and image tags are not touched.
+ """
+ from .annotations import ImageTagLimitError, normalize_image_tags
+
+ _validate_epoch(expected_epoch)
+ _validate_bulk_ids(prompt_ids)
+ prompt_ids = list(dict.fromkeys(prompt_ids))
+ too_many = f"At most {MAX_LIBRARY_TAGS} tags can be added at once"
+ try:
+ tags = normalize_image_tags(tags)
+ except ImageTagLimitError:
+ # Its message names images; this is a prompt route.
+ raise ValueError(too_many) from None
+ if not tags:
+ raise ValueError("tags must name at least one tag")
+ if len(tags) > MAX_LIBRARY_TAGS:
+ raise ValueError(too_many)
+ with self._pair_transaction() as conn:
+ epoch = self._library_epoch(conn, expected_epoch)
+ held = self._library_parent_tags(conn, prompt_ids)
+ additions, over_limit = {}, 0
+ for pid, names in held.items():
+ present = {name.casefold() for name in names}
+ missing = [name for name in tags if name.casefold() not in present]
+ if not missing:
+ continue
+ if len(names) + len(missing) > MAX_LIBRARY_TAGS:
+ over_limit += 1
+ continue
+ additions[pid] = missing
+ wanted = list(
+ dict.fromkeys(n for names in additions.values() for n in names)
+ )
+ tag_ids = self._ensure_tags(conn, wanted)
+ now = utc_now_iso()
+ for pid, names in additions.items():
+ conn.executemany(
+ "INSERT OR IGNORE INTO prompt_tags (prompt_id, tag_id) "
+ "VALUES (?, ?)",
+ [(pid, tag_ids[name]) for name in names],
+ )
+ self._ensure_library_parent(conn, pid)
+ conn.execute("UPDATE prompts SET updated_at=? WHERE id=?", (now, pid))
+ return {
+ "library_epoch": epoch,
+ "tags": tags,
+ "updated": len(additions),
+ "unchanged": len(held) - len(additions) - over_limit,
+ "not_found": len(prompt_ids) - len(held),
+ "over_limit": over_limit,
+ }
+
+ def _library_parent_tags(
+ self, conn: sqlite3.Connection, prompt_ids: list[int]
+ ) -> dict[int, list[str]]:
+ """The Library parents among ``prompt_ids`` and their own tag names."""
+ held: dict[int, list[str]] = {}
+ for start in range(0, len(prompt_ids), _BULK_CHUNK):
+ chunk = prompt_ids[start : start + _BULK_CHUNK]
+ marks = ",".join("?" for _ in chunk)
+ for (pid,) in conn.execute(
+ f"SELECT p.id FROM prompts p WHERE p.id IN ({marks}) " # nosec B608
+ f"AND {_ELIGIBLE}",
+ chunk,
+ ):
+ held[pid] = []
+ for pid, name in conn.execute(
+ "SELECT pt.prompt_id,t.name FROM prompt_tags pt " # nosec B608
+ f"JOIN tags t ON t.id=pt.tag_id WHERE pt.prompt_id IN ({marks})",
+ chunk,
+ ):
+ if pid in held:
+ held[pid].append(name)
+ return held
diff --git a/py/api/prompts.py b/py/api/prompts.py
index 09001dcd..de2c6716 100644
--- a/py/api/prompts.py
+++ b/py/api/prompts.py
@@ -1130,7 +1130,14 @@ class PromptRoutesMixin:
async def bulk_add_tags(self, request):
"""Bulk add tags to prompts."""
try:
- data = await request.json()
+ try:
+ data = await request.json()
+ except ValueError:
+ return bad_request("Invalid JSON body")
+ if not isinstance(data, dict):
+ return bad_request("Expected a JSON object")
+ if "expected_epoch" in data:
+ return await self._bulk_library_tags(data)
prompt_ids = data.get("prompt_ids", [])
new_tags = data.get("tags", [])
@@ -1220,14 +1227,20 @@ class PromptRoutesMixin:
status=500,
)
- async def _bulk_library_category(self, data: dict) -> web.Response:
- """The workspace Library's fenced form (#437).
+ async def _bulk_library_tags(self, data: dict) -> web.Response:
+ """The workspace Library's fenced form (#442).
- ``{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.
+ ``{prompt_ids, tags, expected_epoch}``: prompt tags only, never image tags.
"""
- fields = {"prompt_ids", "category", "expected_epoch"}
+ return await self._fenced_library_write(
+ data, "tags", self.db.bulk_add_library_tags
+ )
+
+ async def _fenced_library_write(
+ self, data: dict, field: str, write
+ ) -> web.Response:
+ """One fenced Library bulk write: exact keys, checked ids, 409 on a restore."""
+ fields = {"prompt_ids", field, "expected_epoch"}
if set(data) != fields:
return bad_request(
"Request must contain exactly " + ", ".join(sorted(fields))
@@ -1235,10 +1248,7 @@ class PromptRoutesMixin:
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"],
+ write, prompt_ids, data[field], expected_epoch=data["expected_epoch"]
)
except LibraryCommandError as exc:
return web.json_response(
@@ -1254,6 +1264,17 @@ class PromptRoutesMixin:
return bad_request(str(exc))
return web.json_response({"success": True, **result})
+ 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.
+ """
+ return await self._fenced_library_write(
+ data, "category", self.db.bulk_set_library_category
+ )
+
async def _bulk_category_by_filter(self, data: dict) -> web.Response:
"""Set the category on every linked positive a listing filter admits.
diff --git a/tests/browser/library-concept.spec.mjs b/tests/browser/library-concept.spec.mjs
index 33f6bbff..14de15c5 100644
--- a/tests/browser/library-concept.spec.mjs
+++ b/tests/browser/library-concept.spec.mjs
@@ -317,7 +317,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
/**
* 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).
+ * and the bar's box, tokens and the actions both bars have (Add tags, Set category, Clear; #442).
*/
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]]));};
@@ -327,7 +327,7 @@ const measureSelection=(page,{card,main})=>page.evaluate(({card,main})=>{
'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'];
+ const shared=['Add tags','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')},
@@ -357,7 +357,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`the Library se
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(reference.bar.buttons.map(button=>button.text),'the concept bar has every Library action').toEqual(['Add tags','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 }'});
diff --git a/tests/browser/workspace-library-selection.spec.mjs b/tests/browser/workspace-library-selection.spec.mjs
index 48335b0d..82d9f4d2 100644
--- a/tests/browser/workspace-library-selection.spec.mjs
+++ b/tests/browser/workspace-library-selection.spec.mjs
@@ -11,6 +11,14 @@ 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');
+const tagsDialog=page=>page.getByRole('dialog',{name:'Tag selected prompts',exact:true});
+const chips=(page,id)=>card(page,id).locator('.tags > .chip');
+async function addTags(page,value) {
+ await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
+ await expect(tagsDialog(page)).toBeVisible();
+ await tagsDialog(page).locator('#action-input').fill(value);
+ await tagsDialog(page).getByRole('button',{name:'Apply',exact:true}).click();
+}
async function shelf(request) {
const body=await(await request.get('/prompt_manager/library?limit=100')).json();
@@ -53,7 +61,8 @@ test('Set category writes the selected prompts on the server, reloads the shelf
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']);
+ // Two of three loaded are checked, so Select all N loaded follows the count (D148, #444 ruling).
+ await expect(bar(page).getByRole('button')).toHaveText([`Select all ${items.length} loaded`,'Add tags','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);
@@ -175,3 +184,111 @@ test('an answer that may follow a committed write says so and reloads; a refused
await expect(page.locator('#library-bulkbar')).toBeEmpty();
await expect(card(page,items[0].id).locator('.card-title .chip')).toHaveText('Unconfirmed');
});
+
+// #442: Add tags writes prompt tags (never image tags) on the selected prompts.
+function recordTagWrites(page) {
+ const writes=[];
+ page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname==='/prompt_manager/bulk/tags')writes.push(item.postDataJSON());});
+ return writes;
+}
+async function promptTags(request,id) {
+ return (await shelf(request)).find(item=>item.id===id).tags;
+}
+
+test('Add tags adds prompt tags to the selected prompts, suggests the vocabulary and reloads the shelf',async({page,request})=>{
+ const items=await shelf(request);const [first,second,third]=items;const writes=recordTagWrites(page);
+ const vocabulary=(await(await request.get('/prompt_manager/tags')).json()).tags;
+ await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
+ await box(page,first.id).check();await box(page,second.id).check();
+ await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
+ await expect(tagsDialog(page).locator('datalist option')).toHaveCount(vocabulary.length);
+ await tagsDialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(tagsDialog(page)).toHaveCount(0);
+ await addTags(page,' studio-lit , Night ,studio-lit');
+ await expect(toast(page)).toHaveText('Added “studio-lit, Night” to 2 prompts.');
+ await expect(page.locator('#library-bulkbar')).toBeEmpty();
+ for(const item of [first,second]) {
+ await expect(chips(page,item.id).filter({hasText:'studio-lit'})).toHaveCount(1);
+ expect(await promptTags(request,item.id)).toEqual(expect.arrayContaining(['studio-lit','Night']));
+ }
+ expect(await promptTags(request,third.id)).not.toContain('studio-lit');
+ const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
+ expect(writes).toEqual([{prompt_ids:[first.id,second.id],tags:['studio-lit','Night'],expected_epoch:epoch}]);
+ // A second add of the same names changes nothing and says so.
+ await box(page,first.id).check();await addTags(page,'Studio-Lit');
+ await expect(toast(page)).toHaveText('1 prompt already had “Studio-Lit”.');
+});
+
+test('the Add tags field states what it refuses, and a restore the page did not see adds nothing',async({page,request})=>{
+ await armUnseenRestores(page);
+ const items=await shelf(request);const writes=recordTagWrites(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:'Add tags',exact:true}).click();
+ const field=tagsDialog(page).locator('#action-input');
+ for(const [value,message] of [[' , ','Enter at least one tag.'],['x'.repeat(51),'A tag can have at most 50 characters.'],
+ [Array.from({length:21},(_,i)=>`t${i}`).join(','),'Enter at most 20 tags.']]) {
+ await field.fill(value);await tagsDialog(page).getByRole('button',{name:'Apply',exact:true}).click();
+ await expect(tagsDialog(page)).toBeVisible();
+ expect(await field.evaluate(node=>node.validationMessage)).toBe(message);
+ }
+ await tagsDialog(page).getByRole('button',{name:'Cancel',exact:true}).click();
+ expect(writes).toHaveLength(0);
+ 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();
+ await addTags(page,'after-restore');
+ await expect(toast(page)).toHaveText('Prompt storage was restored since this page loaded. No tags were added.');
+ await expect(page.locator('#library-bulkbar')).toBeEmpty();
+ expect((await(await request.get('/prompt_manager/tags')).json()).tags).not.toContain('after-restore');
+});
+
+// #444 ruling (D148): select every loaded prompt, as the legacy page's select-all checkbox did for its page.
+test('Ctrl/Cmd+A and Select all N loaded select every loaded prompt, windowed ones included',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=recordTagWrites(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 loaded=160;expect(await cards(page).count()).toBeLessThan(loaded);
+ // In the search box Ctrl/Cmd+A keeps its text meaning: it selects the box's text, no prompt.
+ await page.locator('#search').focus();await page.keyboard.press('ControlOrMeta+a');
+ await expect(page.locator('#library-bulkbar')).toBeEmpty();
+ // One checked card offers the rest; the offer selects every loaded prompt and focus moves to the first action.
+ const firstId=Number(await cards(page).first().getAttribute('data-prompt-id'));
+ await box(page,firstId).check();
+ const offer=bar(page).getByRole('button',{name:`Select all ${loaded} loaded`,exact:true});
+ await expect(bar(page).locator(':scope > *').nth(1)).toHaveText(`Select all ${loaded} loaded`);
+ await offer.click();
+ await expect(bar(page).locator('strong')).toHaveText(`${loaded} selected`);
+ await expect(offer).toHaveCount(0);
+ await expect(bar(page).getByRole('button',{name:'Add tags',exact:true})).toBeFocused();
+ // Another batch arrives unchecked: the offer comes back with the new N.
+ {
+ 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(bar(page).getByRole('button',{name:`Select all ${loaded+40} loaded`,exact:true})).toBeVisible();
+ await expect(bar(page).locator('strong')).toHaveText(`${loaded} selected`);
+ // Escape in the grid clears; Ctrl/Cmd+A in the grid selects them all again.
+ const title=cards(page).first().locator('.card-title > button');
+ await title.focus();await page.keyboard.press('Escape');
+ await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(title).toBeFocused();
+ await page.keyboard.press('ControlOrMeta+a');
+ const all=loaded+40;
+ await expect(bar(page).locator('strong')).toHaveText(`${all} selected`);
+ // While the write runs, every bar action and the offer are aria-disabled.
+ await box(page,firstId).uncheck();
+ let release;const held=new Promise(resolve=>{release=resolve;});
+ await page.route('**/prompt_manager/bulk/tags',async route=>{await held;await route.continue();},{times:1});
+ await addTags(page,'everyone');
+ for(const name of ['Add tags','Set category',`Select all ${all} loaded`]) await expect(bar(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
+ release();
+ await expect(toast(page)).toHaveText(`Added “everyone” to ${all-1} prompts.`);
+ expect(writes).toHaveLength(1);expect(new Set(writes[0].prompt_ids).size).toBe(all-1);
+});
diff --git a/tests/js/workspace-library-selection.test.js b/tests/js/workspace-library-selection.test.js
index ad771f9a..7f2388a9 100644
--- a/tests/js/workspace-library-selection.test.js
+++ b/tests/js/workspace-library-selection.test.js
@@ -42,3 +42,55 @@ test('the field refuses control characters as validate_category does', async ()
assert.equal(categoryProblem('nul\u0000'), CONTROL_CHARACTERS);
assert.equal(categoryProblem('unicode ✓ é'), null);
});
+
+test('Add tags uses the Images dialog words for prompts, the tag routes and the editor limits (#442)', async () => {
+ const {PROMPT_TAGS_DIALOG, TAGS_URL, TAG_OPTIONS_URL, MAX_PROMPT_TAGS} = await source;
+ assert.deepEqual({...PROMPT_TAGS_DIALOG}, {title:'Tag selected prompts', label:'Tags, separated by commas', action:'Apply'});
+ assert.equal(TAGS_URL, '/prompt_manager/bulk/tags');
+ assert.equal(TAG_OPTIONS_URL, '/prompt_manager/tags');
+ assert.equal(MAX_PROMPT_TAGS, 20);
+});
+
+test('the Add tags toast says what was added, already present, skipped and missing', async () => {
+ const {tagsOutcome, writeOutcome, NONE_FOUND} = await source;
+ assert.equal(tagsOutcome({tags:['a', 'b'], updated:2, unchanged:0, over_limit:0, not_found:0}), 'Added “a, b” to 2 prompts.');
+ assert.equal(tagsOutcome({tags:['a'], updated:1, unchanged:2, over_limit:1, not_found:3}),
+ 'Added “a” to 1 prompt. 2 prompts already had them. 1 prompt would pass 20 tags and was skipped. 3 could not be found.');
+ assert.equal(tagsOutcome({tags:['a'], updated:0, unchanged:2, over_limit:0, not_found:0}), '2 prompts already had “a”.');
+ // Every prompt skipped: found, so the skip sentence, never "none found"; nothing committed.
+ assert.deepEqual(writeOutcome({success:true, tags:['a'], updated:0, unchanged:0, over_limit:2, not_found:0}, null, 'tags'),
+ {toast:'2 prompts would pass 20 tags and were skipped.', committed:false, reload:true});
+ assert.deepEqual(writeOutcome({success:true, tags:['a'], updated:0, unchanged:0, over_limit:0, not_found:2}, null, 'tags'),
+ {toast:NONE_FOUND, committed:false, reload:true});
+});
+
+test('Add tags failures name tags, not the category', async () => {
+ const {writeOutcome, TAGS_STORAGE_CHANGED, TAGS_UNCONFIRMED, TAGS_FAILED} = await source;
+ assert.equal(writeOutcome(null, {status:409, code:'storage-changed'}, 'tags').toast, TAGS_STORAGE_CHANGED);
+ assert.equal(writeOutcome(null, {status:503}, 'tags').toast, TAGS_UNCONFIRMED);
+ assert.equal(writeOutcome(null, {status:400}, 'tags').toast, TAGS_FAILED);
+ assert.equal(TAGS_STORAGE_CHANGED, 'Prompt storage was restored since this page loaded. No tags were added.');
+});
+
+test('the Add tags field refuses what validate_tags refuses', async () => {
+ const {tagsProblem, TAG_PROBLEMS} = await source;
+ assert.equal(tagsProblem('red, blue'), null);
+ assert.equal(tagsProblem(' , ,'), TAG_PROBLEMS.none);
+ assert.equal(tagsProblem(Array.from({length:21}, (_, i) => `t${i}`).join(',')), TAG_PROBLEMS.many);
+ assert.equal(tagsProblem(Array.from({length:21}, () => 'same').join(',')), null);
+ assert.equal(tagsProblem('x'.repeat(51)), TAG_PROBLEMS.long);
+ assert.equal(tagsProblem('bad\ttag'), TAG_PROBLEMS.control);
+});
+
+test('Select all N loaded is offered while some loaded prompts are unchecked (#444 ruling)', async () => {
+ const {loadedOffer, offersLoaded} = await source;
+ assert.equal(loadedOffer(3), 'Select all 3 loaded');
+ assert.equal(loadedOffer(1203), 'Select all 1,203 loaded');
+ assert.equal(offersLoaded(new Set([1]), [1, 2, 3]), true);
+ assert.equal(offersLoaded(new Set([1, 2, 3]), [1, 2, 3]), false);
+ assert.equal(offersLoaded(new Set(), [1, 2]), false);
+ assert.equal(offersLoaded(new Set([9]), [9]), false);
+ // More loaded than any action accepts: never offered (Ctrl/Cmd+A still selects them).
+ assert.equal(offersLoaded(new Set([1]), Array.from({length:1001}, (_, i) => i + 1)), false);
+ assert.equal(offersLoaded(new Set([1]), Array.from({length:1000}, (_, i) => i + 1)), true);
+});
diff --git a/tests/test_bulk_library_tags.py b/tests/test_bulk_library_tags.py
new file mode 100644
index 00000000..be8fb9a4
--- /dev/null
+++ b/tests/test_bulk_library_tags.py
@@ -0,0 +1,286 @@
+"""#442: the workspace Library's fenced bulk "Add tags" (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/tags"
+
+
+def save(db, text, **metadata):
+ return db.save_prompt(text, prompt_hash=generate_prompt_hash(text), **metadata)
+
+
+def tags_of(db, prompt_id):
+ with db.model.get_connection() as conn:
+ rows = conn.execute(
+ "SELECT t.name FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
+ "WHERE pt.prompt_id=? ORDER BY t.name",
+ (prompt_id,),
+ ).fetchall()
+ return [row[0] for row in rows]
+
+
+def updated_at(db, prompt_id):
+ with db.model.get_connection() as conn:
+ return conn.execute(
+ "SELECT updated_at FROM prompts WHERE id=?", (prompt_id,)
+ ).fetchone()[0]
+
+
+def tag_names(db):
+ with db.model.get_connection() as conn:
+ return sorted(row[0] for row in conn.execute("SELECT name FROM tags"))
+
+
+class TestBulkLibraryTags(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.db = PromptDatabase(str(Path(self.temp.name) / "tags.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, tags, epoch=None):
+ return self.db.bulk_add_library_tags(
+ ids, tags, expected_epoch=epoch or self.epoch
+ )
+
+ def test_adds_only_missing_tags_and_counts_each_outcome(self):
+ a = save(self.db, "Alpha", tags=["red"])
+ b = save(self.db, "Beta", tags=["red", "blue"])
+ c = save(self.db, "Gamma")
+ before_b = updated_at(self.db, b)
+ result = self.run_bulk([a, b, c, 99999], [" red ", "blue", "Blue"])
+ self.assertEqual(
+ result,
+ {
+ "library_epoch": self.epoch,
+ "tags": ["red", "blue"],
+ "updated": 2,
+ "unchanged": 1,
+ "not_found": 1,
+ "over_limit": 0,
+ },
+ )
+ self.assertEqual(tags_of(self.db, a), ["blue", "red"])
+ self.assertEqual(tags_of(self.db, c), ["blue", "red"])
+ self.assertEqual(updated_at(self.db, b), before_b)
+ self.assertTrue(updated_at(self.db, a).endswith("+00:00"))
+
+ def test_a_case_variant_of_a_held_tag_is_not_added_and_names_stay_exact(self):
+ a = save(self.db, "Alpha", tags=["Portrait"])
+ b = save(self.db, "Beta")
+ result = self.run_bulk([a, b], ["portrait"])
+ # Alpha already holds it (casefold); Beta gets the name exactly as typed.
+ self.assertEqual((result["updated"], result["unchanged"]), (1, 1))
+ self.assertEqual(tags_of(self.db, a), ["Portrait"])
+ self.assertEqual(tags_of(self.db, b), ["portrait"])
+ self.assertEqual(tag_names(self.db), ["Portrait", "portrait"])
+ c = save(self.db, "Gamma", tags=["STRASSE"])
+ self.assertEqual(self.run_bulk([c], ["straße"])["unchanged"], 1)
+
+ def test_names_nothing_receives_are_never_inserted(self):
+ full = save(self.db, "Full", tags=[f"t{i}" for i in range(20)])
+ self.run_bulk([full, 99999], ["unused"])
+ self.assertNotIn("unused", tag_names(self.db))
+
+ def test_a_prompt_already_over_twenty_that_gains_nothing_is_unchanged(self):
+ over = save(self.db, "Over", tags=[f"t{i}" for i in range(22)])
+ result = self.run_bulk([over], ["t1"])
+ self.assertEqual((result["unchanged"], result["over_limit"]), (1, 0))
+
+ def test_image_and_pair_tags_are_untouched(self):
+ pair = self.db.save_prompt_pair("Cat", "Blur", tags=["pairtag"])
+ positive = self.db.get_prompt_pair(pair)["positive_prompt_id"]
+ with self.db.model.get_connection() as conn:
+ before = conn.execute("SELECT COUNT(*) FROM prompt_pair_tags").fetchone()[0]
+ self.run_bulk([positive], ["pairtag", "own"])
+ self.assertEqual(tags_of(self.db, positive), ["own", "pairtag"])
+ with self.db.model.get_connection() as conn:
+ after = conn.execute("SELECT COUNT(*) FROM prompt_pair_tags").fetchone()[0]
+ self.assertEqual(after, before)
+
+ def test_a_prompt_that_would_pass_twenty_tags_is_skipped_untouched(self):
+ full = save(self.db, "Full", tags=[f"t{i}" for i in range(19)])
+ other = save(self.db, "Other")
+ before = updated_at(self.db, full)
+ result = self.run_bulk([full, other], ["x", "y"])
+ self.assertEqual((result["updated"], result["over_limit"]), (1, 1))
+ self.assertEqual(len(tags_of(self.db, full)), 19)
+ self.assertEqual(updated_at(self.db, full), before)
+ self.assertEqual(tags_of(self.db, other), ["x", "y"])
+ # Exactly twenty is allowed.
+ self.assertEqual(self.run_bulk([full], ["x"])["updated"], 1)
+
+ def test_changed_prompts_become_registered_library_parents(self):
+ a = save(self.db, "Alpha")
+ self.run_bulk([a], ["new"])
+ with self.db.model.get_connection() as conn:
+ self.assertIsNotNone(
+ conn.execute(
+ "SELECT 1 FROM prompt_library_state WHERE prompt_id=?", (a,)
+ ).fetchone()
+ )
+
+ def test_negative_only_prompts_are_not_found(self):
+ pair = self.db.save_prompt_pair("Cat", "Blur")
+ negative = self.db.get_prompt_pair(pair)["negative_prompt_id"]
+ result = self.run_bulk([negative], ["x"])
+ self.assertEqual((result["updated"], result["not_found"]), (0, 1))
+ self.assertEqual(tags_of(self.db, negative), [])
+
+ def test_restored_storage_writes_nothing_not_even_a_tag_name(self):
+ a = save(self.db, "Alpha")
+ rotate_history_epoch(self.db.model.get_connection())
+ with self.assertRaises(LibraryCommandError) as caught:
+ self.run_bulk([a], ["fresh"])
+ self.assertEqual(caught.exception.code, "storage-changed")
+ self.assertEqual(tags_of(self.db, a), [])
+ self.assertNotIn("fresh", tag_names(self.db))
+
+ def test_a_failure_mid_write_leaves_every_prompt_as_it_was(self):
+ ids = [save(self.db, f"P{i}") for i in range(3)]
+ with self.db.model.get_connection() as conn:
+ conn.execute(
+ "CREATE TRIGGER fail_third BEFORE INSERT ON prompt_tags "
+ f"WHEN NEW.prompt_id={ids[2]} BEGIN SELECT RAISE(ABORT, 'boom'); END"
+ )
+ with self.assertRaises(sqlite3.DatabaseError):
+ self.run_bulk(ids, ["x"])
+ self.assertEqual([tags_of(self.db, pid) for pid in ids], [[], [], []])
+ self.assertNotIn("x", tag_names(self.db))
+
+ def test_entries_are_counted_after_dedupe_and_the_limit_names_prompts(self):
+ a = save(self.db, "Alpha")
+ self.assertEqual(self.run_bulk([a], ["dup"] * 30 + ["Dup"])["updated"], 1)
+ with self.assertRaises(ValueError) as caught:
+ self.run_bulk([a], [f"t{i}" for i in range(101)])
+ self.assertEqual(str(caught.exception), "At most 20 tags can be added at once")
+
+ def test_many_ids_are_read_in_chunks(self):
+ ids = [save(self.db, f"Many {i}") for i in range(1000)]
+ self.assertEqual(self.run_bulk(ids, ["bulk"])["updated"], 1000)
+
+ def test_rejects_invalid_arguments(self):
+ a = save(self.db, "Alpha")
+ for ids, tags, epoch in (
+ ([a], [], self.epoch),
+ ([a], [" "], self.epoch),
+ ([a], "red", self.epoch),
+ ([a], ["x" * 51], self.epoch),
+ ([a], ["bad\x01"], self.epoch),
+ ([a], ["a,b"], self.epoch),
+ ([a], [f"t{i}" for i in range(21)], self.epoch),
+ ([a], ["ok"], "not-a-uuid"),
+ ([], ["ok"], self.epoch),
+ ):
+ with self.subTest(tags=tags, epoch=epoch, ids=ids):
+ with self.assertRaises(ValueError):
+ self.run_bulk(ids, tags, epoch)
+
+
+class TestBulkLibraryTagsRoute(RouteCoverageCase):
+ def epoch(self):
+ return self.api.db.get_library_options()["library_epoch"]
+
+ async def test_fenced_form_adds_tags_and_reports_counts(self):
+ ids = [self._save_prompt(f"route {i}") for i in range(2)]
+ status, body = await self._json(
+ "POST",
+ URL,
+ json={"prompt_ids": ids, "tags": ["Fresh"], "expected_epoch": self.epoch()},
+ )
+ self.assertEqual(status, 200)
+ self.assertEqual(
+ body,
+ {
+ "success": True,
+ "library_epoch": self.epoch(),
+ "tags": ["Fresh"],
+ "updated": 2,
+ "unchanged": 0,
+ "not_found": 0,
+ "over_limit": 0,
+ },
+ )
+
+ async def test_restored_storage_answers_409_storage_changed(self):
+ pid = self._save_prompt("route restore")
+ 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], "tags": ["x"], "expected_epoch": stale},
+ )
+ self.assertEqual((status, body["code"]), (409, "storage-changed"))
+ self.assertEqual(body["library_epoch"], new_epoch)
+
+ async def test_fenced_form_validates_its_body(self):
+ pid = self._save_prompt("route validate")
+ epoch = self.epoch()
+ for body in (
+ {"prompt_ids": [pid], "tags": [], "expected_epoch": epoch},
+ {"prompt_ids": [pid], "tags": "x", "expected_epoch": epoch},
+ {"prompt_ids": [pid], "tags": ["x" * 51], "expected_epoch": epoch},
+ {"prompt_ids": [pid], "tags": ["ok"], "expected_epoch": "nope"},
+ {"prompt_ids": [True], "tags": ["ok"], "expected_epoch": epoch},
+ {
+ "prompt_ids": list(range(1, 1002)),
+ "tags": ["ok"],
+ "expected_epoch": epoch,
+ },
+ {"prompt_ids": [pid], "expected_epoch": epoch},
+ {"prompt_ids": [pid], "tags": ["ok"], "expected_epoch": epoch, "x": 1},
+ ):
+ with self.subTest(body=body):
+ status, answer = await self._json("POST", URL, json=body)
+ self.assertEqual(status, 400)
+ self.assertFalse(answer["success"])
+ self.assertEqual(tags_of(self.api.db, pid), [])
+
+ async def test_fenced_form_reports_an_unexpected_failure(self):
+ pid = self._save_prompt("route failure")
+ self._break("bulk_add_library_tags")
+ status, body = await self._json(
+ "POST",
+ URL,
+ json={"prompt_ids": [pid], "tags": ["ok"], "expected_epoch": self.epoch()},
+ )
+ self.assertEqual(status, 500)
+ self.assertFalse(body["success"])
+
+ async def test_legacy_form_refuses_a_body_that_is_not_an_object(self):
+ for raw in ("[1, 2]", "not json"):
+ with self.subTest(raw=raw):
+ status, body = await self._json(
+ "POST", URL, data=raw, headers={"Content-Type": "application/json"}
+ )
+ self.assertEqual(status, 400)
+ self.assertFalse(body["success"])
+
+ async def test_legacy_form_is_unchanged(self):
+ pid = self._save_prompt("route legacy")
+ status, body = await self._json(
+ "POST", URL, json={"prompt_ids": [pid], "tags": ["Legacy"]}
+ )
+ self.assertEqual((status, body["updated_count"]), (200, 1))
+ self.assertNotIn("library_epoch", body)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/web/js/workspace/library-selection.mjs b/web/js/workspace/library-selection.mjs
index e487cf7c..9535e450 100644
--- a/web/js/workspace/library-selection.mjs
+++ b/web/js/workspace/library-selection.mjs
@@ -2,19 +2,23 @@ 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 { NO_TAGS, parseBulkTags } from './images-tagging-bulk.mjs';
import { openInputDialog } from './workspace-dialogs.mjs';
+import { countOf } from './plural.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
+ * The Prompt library's selection and its bulk actions (D148, beyond the concept): "Add tags" (#442) and "Set
+ * category" (#437), the legacy admin page's bulk Add Tags and 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 actions in the
+ * Images bar's order, 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.
+ * Apply posts `{prompt_ids, category|tags, expected_epoch}` to `POST /prompt_manager/bulk/category` or `/bulk/tags`:
+ * the epoch the shelf was read under, so a restored storage answers 409 `storage-changed` and nothing is written.
+ * Add tags writes prompt tags only, never image tags or auto-tags.
*/
export const CATEGORY_URL = '/prompt_manager/bulk/category';
export const PROMPT_CATEGORY_DIALOG = Object.freeze({ ...CATEGORY_DIALOG, title: 'Categorize selected prompts' });
@@ -26,6 +30,17 @@ export const UNCONFIRMED = 'The category change could not be confirmed. The Libr
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 };
+export const TAGS_URL = '/prompt_manager/bulk/tags';
+export const TAG_OPTIONS_URL = '/prompt_manager/tags';
+export const PROMPT_TAGS_DIALOG = Object.freeze({ title: 'Tag selected prompts', label: 'Tags, separated by commas', action: 'Apply' });
+// What the editor's save accepts for one prompt (`validate_tags`): the route refuses more.
+export const MAX_PROMPT_TAGS = 20;
+export const MAX_TAG_LENGTH = 50;
+export const TAG_PROBLEMS = Object.freeze({ none: NO_TAGS, many: 'Enter at most 20 tags.',
+ long: 'A tag can have at most 50 characters.', control: 'A tag cannot contain control characters.' });
+export const TAGS_FAILED = 'Tags could not be added. Try again.';
+export const TAGS_STORAGE_CHANGED = 'Prompt storage was restored since this page loaded. No tags were added.';
+export const TAGS_UNCONFIRMED = 'Adding the tags could not be confirmed. The Library was reloaded.';
// 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]);
@@ -43,16 +58,56 @@ function el(tag, text, className) {
* 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) {
+export function writeOutcome(result, failure, kind = 'category') {
+ const copy = WRITES[kind];
if (failure === null && result?.success) {
- const found = (Number(result.updated) || 0) + (Number(result.unchanged) || 0);
+ const found = ['updated', 'unchanged', 'over_limit'].reduce((sum, key) => sum + (Number(result[key]) || 0), 0);
if (!found) return { toast: NONE_FOUND, committed: false, reload: true };
- return { toast: setOutcome(result, result.category, 'prompt'), committed: true, reload: true };
+ return { toast: copy.success(result), committed: (Number(result.updated) || 0) > 0, reload: true };
}
- if (failure?.code === 'storage-changed') return { toast: STORAGE_CHANGED, committed: false, reload: true };
+ if (failure?.code === 'storage-changed') return { toast: copy.storage, 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 };
+ if (!Number.isInteger(status) || UNCONFIRMED_STATUSES.has(status)) return { toast: copy.unconfirmed, committed: true, reload: true };
+ return { toast: copy.failed, committed: false, reload: false };
+}
+
+/** The Add tags toast (#442): what was added, then what already had them, was skipped or could not be found. */
+export function tagsOutcome(result) {
+ const updated = Number(result?.updated) || 0, unchanged = Number(result?.unchanged) || 0;
+ const skipped = Number(result?.over_limit) || 0, missing = Number(result?.not_found) || 0;
+ const names = Array.isArray(result?.tags) ? result.tags.join(', ') : '';
+ const parts = [];
+ if (updated) parts.push(`Added “${names}” to ${countOf(updated, 'prompt')}.`);
+ if (unchanged) parts.push(`${countOf(unchanged, 'prompt')} already had ${updated ? 'them' : `“${names}”`}.`);
+ if (skipped) parts.push(`${countOf(skipped, 'prompt')} would pass ${MAX_PROMPT_TAGS} tags and ${skipped === 1 ? 'was' : 'were'} skipped.`);
+ if (missing) parts.push(`${missing.toLocaleString('en-US')} could not be found.`);
+ return parts.join(' ');
+}
+
+const WRITES = Object.freeze({
+ category: { success: result => setOutcome(result, result.category, 'prompt'), storage: STORAGE_CHANGED,
+ unconfirmed: UNCONFIRMED, failed: CATEGORY_FAILED },
+ tags: { success: tagsOutcome, storage: TAGS_STORAGE_CHANGED, unconfirmed: TAGS_UNCONFIRMED, failed: TAGS_FAILED },
+});
+
+/**
+ * "Select all N loaded" (#444 ruling, D148; the legacy page's select-all checkbox): offered while some loaded prompts
+ * are unchecked and no more are loaded than an action accepts. It sits where D135's "Select all N matching" sits on
+ * Images, but that offer appears once every loaded image is checked; this one reaches only the loaded prompts.
+ */
+export function offersLoaded(selected, keys) {
+ return selected.size > 0 && keys.length <= MAX_CATEGORY_PROMPTS && keys.some(key => !selected.has(key));
+}
+export const loadedOffer = count => `Select all ${count.toLocaleString('en-US')} loaded`;
+
+/** The Add tags field's check before Apply: the parsed names, as `validate_tags` would judge them. */
+export function tagsProblem(text) {
+ const tags = parseBulkTags(text);
+ if (!tags.length) return TAG_PROBLEMS.none;
+ if (tags.length > MAX_PROMPT_TAGS) return TAG_PROBLEMS.many;
+ if (tags.some(tag => tag.length > MAX_TAG_LENGTH)) return TAG_PROBLEMS.long;
+ if (tags.some(tag => /[\x00-\x1f]/.test(tag))) return TAG_PROBLEMS.control;
+ return null;
}
/** The field's check before Apply: what `validate_category` refuses beyond the length the field already limits. */
@@ -63,7 +118,7 @@ export function categoryProblem(value) {
/**
* 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
+ * `onCommitted(kind, receipt)` (`category` or `tags`) 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.
*/
@@ -73,9 +128,19 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
const element = el('div');
element.id = 'library-bulkbar';
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
+ const addTags = createButton('Add tags');
const setCategory = createButton('Set category');
const clearButton = createButton('Clear');
- bulk.append(count, setCategory, clearButton);
+ const selectLoaded = createButton('');
+ const actions = [addTags, setCategory, selectLoaded];
+ bulk.append(count, addTags, setCategory, clearButton);
+ scope.listen(selectLoaded, 'click', () => {
+ if (writing) return;
+ selectAll();
+ // The pressed offer left the bar: focus moves to its first action, as after D135's offer.
+ addTags.focus({ preventScroll: true });
+ });
+ scope.listen(addTags, 'click', () => openTags());
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.
@@ -91,6 +156,12 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
if (!checked.size) { bulk.remove(); return; }
const label = selectionLabel(checked.size);
if (count.textContent !== label) count.textContent = label;
+ const keys = ids();
+ if (offersLoaded(checked, keys)) {
+ const offer = loadedOffer(keys.length);
+ if (selectLoaded.textContent !== offer) selectLoaded.textContent = offer;
+ if (count.nextElementSibling !== selectLoaded) count.after(selectLoaded);
+ } else selectLoaded.remove();
if (bulk.parentNode !== element) element.append(bulk);
}
function paint() {
@@ -104,6 +175,10 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
anchor = null;
if (checked.size) update(Object.freeze(new Set()));
}
+ /** Every loaded prompt, windowed-out cards included; the anchor stays. */
+ function selectAll() {
+ update(applyKeys(checked, ids(), true));
+ }
// 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');
@@ -134,32 +209,53 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
return overlays;
}
- function openCategory() {
- if (writing) return;
+ /** The selected loaded prompts and the shelf epoch, or null after a toast says why nothing can be sent. */
+ function target(failed) {
+ if (writing) return null;
const loaded = new Set(ids()), selected = [...checked].filter(id => loaded.has(id));
- if (selected.length > MAX_CATEGORY_PROMPTS) { showWorkspaceToast(TOO_MANY_PROMPTS); return; }
+ if (selected.length > MAX_CATEGORY_PROMPTS) { showWorkspaceToast(TOO_MANY_PROMPTS); return null; }
const epoch = getEpoch();
- if (!selected.length || typeof epoch !== 'string') { showWorkspaceToast(CATEGORY_FAILED); return; }
+ if (!selected.length || typeof epoch !== 'string') { showWorkspaceToast(failed); return null; }
+ return { prompt_ids: selected, expected_epoch: epoch };
+ }
+ function openCategory() {
+ const chosen = target(CATEGORY_FAILED);
+ if (!chosen) 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 }) });
+ validate: categoryProblem, onApply: category => write(CATEGORY_URL, { ...chosen, category }, 'category') });
+ }
+ /** Add tags (#442): the tag vocabulary as suggestions, the editor's list; a failed read opens without them. */
+ async function openTags() {
+ const chosen = target(TAGS_FAILED);
+ if (!chosen) return;
+ let suggestions = null;
+ try {
+ const result = await api.get(TAG_OPTIONS_URL, { signal: scope.signal, cache: 'no-store' });
+ if (Array.isArray(result?.tags)) suggestions = result.tags.filter(tag => typeof tag === 'string');
+ } catch {
+ // Suggestions are auxiliary; the field works without them.
+ }
+ if (scope.signal.aborted) return;
+ openInputDialog({ scope, trigger: addTags, ...PROMPT_TAGS_DIALOG, suggestions, validate: tagsProblem,
+ onApply: text => write(TAGS_URL, { ...chosen, tags: parseBulkTags(text) }, 'tags') });
}
/** 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) {
+ async function write(url, body, kind) {
writing = true;
- setCategory.setAttribute('aria-disabled', 'true');
+ for (const button of actions) button.setAttribute('aria-disabled', 'true');
let result = null, failure = null;
try {
- result = await api.post(CATEGORY_URL, body);
+ result = await api.post(url, body);
} catch (error) {
failure = error;
} finally {
writing = false;
- setCategory.removeAttribute('aria-disabled');
+ for (const button of actions) button.removeAttribute('aria-disabled');
}
- const outcome = writeOutcome(result, failure);
+ const outcome = writeOutcome(result, failure, kind);
// 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 (outcome.committed) onCommitted(kind, result?.success ? result : { phase: 'unconfirmed' });
if (scope.signal.aborted) return;
const focused = element.contains(document.activeElement);
showWorkspaceToast(outcome.toast);
@@ -173,7 +269,11 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
element,
overlayFor,
isChecked: id => checked.has(id),
+ hasSelection: () => checked.size > 0,
clear,
+ selectAll,
+ /** A loaded batch: the count and the offer follow it. */
+ listingChanged: () => { if (checked.size) render(); },
/** Whether a Set category write is still in flight. */
writing: () => writing,
};
diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs
index 907c34f0..0a307ae7 100644
--- a/web/js/workspace/library.mjs
+++ b/web/js/workspace/library.mjs
@@ -178,7 +178,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
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();}});
+ onCommitted:(kind,receipt)=>invalidateLibraryAfterWrite({kind:`bulk-${kind}`,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;
@@ -364,6 +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);
+ // D148 (#444 ruling): Ctrl/Cmd+A selects every loaded prompt and Escape clears, as on Images (focus stays).
+ if((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase()==='a') {event.preventDefault();selection.selectAll();return;}
+ if(event.key==='Escape' && selection.hasSelection()) {event.preventDefault();selection.clear();return;}
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(CARD_CONTROLS);const next=index+(event.shiftKey?-1:1);
@@ -372,7 +375,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
}
});
function render(next) {
- if(items!==next.items) {
+ const batch=items!==next.items;
+ if(batch) {
pendingAnchor ||= captureAnchor();items=next.items;indexesById=new Map(items.map((item,index)=>[item.id,index]));dirty=true;
if(!items.length) {
anchor=null;pendingAnchor=null;heights.clear();layout={rows:[],height:0};
@@ -381,6 +385,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
} else if(retainedAnchor) {pendingAnchor=retainedAnchor;retainedAnchor=null;}
}
state=next;
+ // A loaded batch: the selection bar's count and Select all N loaded follow it (D148).
+ if(batch) selection.listingChanged();
if(next.variantTotal!==null) {
const count=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
if(captionCount.textContent!==count) captionCount.textContent=count;caption.hidden=false;