diff --git a/TESTING.md b/TESTING.md index 3875439..f44b451 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2442,13 +2442,15 @@ 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", Set category, Clear (D151 + adds Add tags before Set category, in 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. + Shift+arrow, long-press or "Select all N matching" on the Library (D151 adds + Ctrl/Cmd+A and "Select all N loaded"). - 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 @@ -2470,7 +2472,8 @@ 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 D151's select-all offer) is `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. @@ -2484,6 +2487,43 @@ 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. +### Adding tags to selected prompts and selecting all (#442) + +**D151** (beyond the concept, on D148's bar): the Prompt library adds tags to the selected +prompts in one step, the legacy admin page's bulk "Add Tags", and selects every loaded +prompt, as its select-all checkbox did (#444 ruling). The concept has neither. + +- The bar reads "N selected", Add tags, Set category, Clear: the Images bar's order. +- Select all of the loaded shelf: 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. No "all matching". +- 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; a prompt gets only the 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}`; the toast "Added “a, b” to N prompts." adds already-had, + skipped and not-found sentences. Storage restored: "Prompt storage was restored since + this page loaded. 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. + +Tests: `tests/browser/workspace-library-selection.spec.mjs` (Add tags with its suggestions, +field refusals and restore refusal; Ctrl/Cmd+A and Select all N loaded across batches and +during a write); `tests/browser/library-concept.spec.mjs` (the bar comparison now includes +Add tags); units `tests/js/workspace-library-selection.test.js`; Python +`tests/test_bulk_library_tags.py`. A Python change: ComfyUI needs a restart. + ### Prompt history page and recorded deviations (#249 PR F-a) The history page (`#/library/`) takes the concept's heading, summary, negative @@ -6330,7 +6370,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, D151). 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 878a7e9..d328416 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 09001dc..de2c671 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 33f6bbf..14de15c 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 48335b0..d92eeb7 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 (D151, #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 (D151): 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 ad771f9..7f2388a 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 0000000..be8fb9a --- /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 e487cf7..27f2b88 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, D151, beyond the concept): "Add tags" (#442, D151) 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, D151; 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 907c34f..3baa22b 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); + // D151 (#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 (D151). + 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;