diff --git a/TESTING.md b/TESTING.md index 1e6c4b1..477d4b5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -440,8 +440,8 @@ own Load more has loaded all 24 fixture images (D37: it loads 12 per batch, prod zoom, the 2nd card opens the viewer titled " · <filename>", Escape returns focus to it (production only: the concept re-renders its grid and drops focus), Info on the 3rd card moves `.selected`, searches for "alpine" and for one image, and Load more with -its toast. `mapImagesConcept` applies D3, D8 and D16 (follow-up PRs: no checkbox, no -Save view), D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories +its toast. `mapImagesConcept` applies D3, D8 (the selection bar's real actions only), +D16 (follow-up PR: no Save view), D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories sorted alphabetically). Removed toolbar and filter-row items flow again from the concept's own boxes (`reflow`), checked against the concept page with those items removed at runtime. Masonry tops and heights are compared unrounded, within one layout @@ -500,9 +500,38 @@ Recorded deviations on this page (spec §8): - **D5**, **D29**: no Activity button and no prototype copy (shell). - **D7**: the star is a real persisted favorite with "Added to favorites." / "Removed from favorites." and a rollback toast on failure. -- **D8 (follow-up)**: no select checkbox or bulk bar in PR C; a follow-up PR adds them - with real actions and removes the interim `.image-overlays > .star-button:only-child` - rule that keeps the star top-right. +- **D8**: every Images card has the concept's select checkbox before the star; the + `#bulkbar` shows "N selected" and only the real actions in the concept's order (Set + category, Export metadata, Clear). Add tags and Add to collection wait for image tags + and collections; "Remove links…" is dropped. The selection survives Load more and + bounded re-mounting and is cleared whenever the collection is replaced (search, + category, sort, Filters, source change, rescan, reload). A return to Images restores + the saved collection unreplaced and keeps its selection; a full page reload starts + empty. The Library-embedded gallery has no checkbox and no bar. Export metadata reads + each checked image's inspection (at most 4 at a time) and downloads one + `prompt-manager-images-metadata.json` (`format`, `version: 1`, `exported_at`, and the + inspector's own "Download metadata JSON" envelopes in listing order); the button is + `aria-disabled` and ignores a second press while it runs, the selection is kept, and + the toast names the unread images (none read: no download); leaving Images aborts the + reads with no download and no toast. Set category opens the + concept's `simpleInput` dialog ("Categorize selected images", `input#action-input` + labelled Category, Cancel / Apply) and posts the selection's distinct linked positive + ids to `/prompt_manager/bulk/category` (images have no category; the filter matches + the linked positive's). With no linked image checked it posts nothing and opens no + dialog; an empty value keeps the dialog open; the field stops at the server's 100 + characters; success reloads the categories and the listing together (clearing the + selection, focus to the heading, and, as any replaced collection does, the inspector + moves to the first listed image); failure keeps the selection. The write carries no + page signal: leaving Images while it runs lets it finish without a toast, and the return + loads the collection afresh instead of the saved visit. Three deviations from the + concept's `simpleInput`: the field starts empty (the concept pre-fills its demo + "Landscapes"), the action reads "Apply" (not "Apply in prototype"), and the + nothing-linked check runs on the Set category press, so no dialog opens. The route + rejects anything but 1-1000 positive integer ids within SQLite's INTEGER range + (deduplicated) and a category that fails `validate_category`. `images selection bar matches + the concept` compares the bar and the checked card with one card checked; the omitted + actions flow again from the concept's own boxes, checked against the concept page with + them removed at runtime. - **D9**: no Tag button until the image-annotations slice. - **D10**: the card title is the linked positive's display title, else the filename; the `h3` keeps the relative path in `title`. @@ -967,8 +996,8 @@ page lifecycle recovery retain their separate behavioral tests. Initial production acceptance requires maintainer inspection of paired PNGs and geometry; never update snapshots automatically or mask content to bless a gap. Data-dependent text/images/counts and the clearly labeled saved/frozen/current -distinctions are documented in the comparison helper. The Images card select -checkbox (#249 D8 follow-up) and tag button (#249 D9) and shared role-block +distinctions are documented in the comparison helper. The Images card tag +button (#249 D9) and shared role-block spacing/padding/glyph polish remain outside #209. The responsive production Viewer keeps its toolbar in the media panel and prompt details readable rather than reproducing the concept's mobile toolbar collision. These are explicit remaining visual differences, not diff --git a/py/api/prompts.py b/py/api/prompts.py index 122d073..22b2b3d 100644 --- a/py/api/prompts.py +++ b/py/api/prompts.py @@ -22,6 +22,29 @@ MAX_PAGE_OFFSET = 10_000_000 # for one giant result set (and silently truncating large libraries). EXPORT_PAGE_SIZE = 1000 +# The most prompts one bulk category request may change. +BULK_CATEGORY_MAX_PROMPTS = 1000 + +# The largest value SQLite's INTEGER can bind; a larger id raises OverflowError. +MAX_SQLITE_INT = 2**63 - 1 + + +def parse_bulk_prompt_ids(value, cap=BULK_CATEGORY_MAX_PROMPTS): + """A bulk request's ``prompt_ids``: distinct positive ints, first-seen order. + + Raises ``ValueError`` unless ``value`` is a non-empty list of at most + ``cap`` distinct positive integers no larger than SQLite's INTEGER + (booleans are not integers here). + """ + if not isinstance(value, list) or not value: + raise ValueError("prompt_ids must be a non-empty list") + if any(type(pid) is not int or not 1 <= pid <= MAX_SQLITE_INT for pid in value): + raise ValueError("prompt_ids must be positive integers") + ids = list(dict.fromkeys(value)) + if len(ids) > cap: + raise ValueError(f"At most {cap} prompts can be changed at once") + return ids + def parse_page_params(query, default_limit=50, max_limit=MAX_PAGE_LIMIT): """Return ``(limit, offset)`` from a query mapping, clamped to safe bounds. @@ -1145,14 +1168,29 @@ class PromptRoutesMixin: async def bulk_set_category(self, request): """Bulk set category for prompts.""" try: - data = await request.json() - prompt_ids = data.get("prompt_ids", []) - category = (data.get("category") or "").strip() - - if not prompt_ids: + try: + data = await request.json() + except ValueError: + return web.json_response( + {"success": False, "error": "Invalid JSON body"}, status=400 + ) + if not isinstance(data, dict): + return web.json_response( + {"success": False, "error": "Expected a JSON object"}, status=400 + ) + if not data.get("prompt_ids"): return web.json_response( {"success": False, "error": "No prompt IDs provided"}, status=400 ) + category = data.get("category") + try: + prompt_ids = parse_bulk_prompt_ids(data["prompt_ids"]) + validate_category(category) + except ValueError as ve: + return web.json_response( + {"success": False, "error": str(ve)}, status=400 + ) + category = (category or "").strip() updated_count = await self._run_in_executor( self.db.bulk_set_category, prompt_ids, category diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 7e0b560..09597e0 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -92,9 +92,10 @@ export async function mutate(page,{css=null,remove=[]}={}) { } /** - * Inventory sections: {chrome, heading, toolbar, filterRow, caption, grid, cards, continuation, panel, library, history, - * noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7) measure - * the concept's Images markup; `cards` holds the first six. Library and history stay null until PRs E and F. + * Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library, + * history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7) + * measure the concept's Images markup; `cards` holds the first six; `bulkbar` is the selection bar's place and, while + * anything is checked, its `.bulkbar` row. Library and history stay null until PRs E and F. */ export function inventoryOf(page,scope='main') { return page.evaluate(measureInventory,scope); @@ -329,9 +330,13 @@ export function measureInventory(scope) { box:when(panel,()=>{const box=relative(panel,gallery);return {left:box.left,top:box.top,width:box.width,...css(panel,'position','top')};})}; } const continuation=body?.querySelector(':scope > .infinite-continuation'); + function bulkbarOf(bar) { + return {id:bar.id||null,...relative(bar,main),children:bar.children.length,bar:when(bar.querySelector(':scope > .bulkbar'),rowOf)}; + } return {chrome:chromeOf(),heading:headingOf(), toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf), filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf), + bulkbar:when(main?.querySelector(':scope > #bulkbar'),bulkbarOf), caption:when(body?.querySelector(':scope > .section-caption'),captionOf), grid:when(grid,gridOf), cards:grid?[...grid.querySelectorAll(':scope > .media-card')].slice(0,6).map(cardOf):null, diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs index 8475cd4..0777b0c 100644 --- a/tests/browser/images-concept.spec.mjs +++ b/tests/browser/images-concept.spec.mjs @@ -86,22 +86,32 @@ function withoutItems(row,isRemoved) { return reflow(row,row.items.filter(item=>!isRemoved(item))); } +/** D8: the selection bar's real actions only; Add tags, Add to collection and Remove links… are left out. */ +const OMITTED_BULK=Object.freeze(['Add tags','Add to collection','Remove links…']); +const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-tags','bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`)); + /** - * D8 (follow-up PR): the overlay row without its checkbox label. The concept stretches the star to the label's line box - * (`align-items: normal`); alone, the row and the star are the star's own height: its min-height, or its line plus - * padding and border when taller. Its place stays the concept's top-right (`rightInset`). + * `withoutItems` for a row with padding and a border (the `.bulkbar`): its items flow again inside the content box, + * measured from the border box as the inventory records them. The concept's border is one width on every side + * (`border: 1px solid`), so the right border stands for the left one. */ -function withoutCheckbox(overlays) { - // A list row hides its overlays (concept `display: none`). - if(!overlays.shown)return overlays; - const items=overlays.items.filter(item=>item.tag!=='label'); - const own=item=>Math.max(parseFloat(item.minHeight)||0,parseFloat(item.lineHeight)+['paddingTop','paddingBottom','borderTopWidth','borderBottomWidth'] - .reduce((sum,key)=>sum+parseFloat(item[key]),0)); - const height=Math.max(...items.filter(item=>item.shown).map(own)); - // D8 (interim): alone, the star is pushed to the right by `margin-left: auto`, whose used value is the overlay's - // free width; the checkbox PR removes that rule and this line. - const pushed=item=>items.length===1?{marginLeft:`${overlays.width-item.width}px`}:{}; - return {...overlays,height,items:items.map(item=>item.shown?{...item,height,...pushed(item)}:item)}; +function withoutPaddedItems(row,isRemoved) { + const [left,right,top,bottom]=[['paddingLeft','borderRightWidth'],['paddingRight','borderRightWidth'], + ['paddingTop','borderTopWidth'],['paddingBottom','borderBottomWidth']].map(keys=>keys.reduce((sum,key)=>sum+parseFloat(row[key]),0)); + const shift=(item,x,y)=>item.left===undefined?item:{...item,left:item.left+x,top:item.top+y, + exact:item.exact&&{...item.exact,left:item.exact.left+x}}; + const inner={...row,width:row.width-left-right,exact:{width:row.exact.width-left-right},items:row.items.map(item=>shift(item,-left,-top))}; + const flowed=withoutItems(inner,isRemoved); + return {...flowed,width:row.width,exact:row.exact,height:flowed.height+top+bottom,items:flowed.items.map(item=>shift(item,left,top))}; +} + +/** D8: the bar without the omitted actions, laid out again from the concept's boxes; what follows moves up by as much. */ +function mapBulkbar(bulkbar,rise) { + if(!bulkbar)return {bulkbar,shrink:0}; + if(!bulkbar.bar)return {bulkbar:{...bulkbar,top:bulkbar.top-rise},shrink:0}; + const bar=withoutPaddedItems(bulkbar.bar,item=>OMITTED_BULK.includes(item.text)); + const shrink=bulkbar.bar.height-bar.height; + return {bulkbar:{...bulkbar,top:bulkbar.top-rise,height:bulkbar.height-shrink,bar:{...bar,top:bar.top-rise}},shrink}; } /** @@ -165,8 +175,6 @@ export function mapImagesConcept(reference,{manifest,now=new Date()}) { return {...card, // D29: the concept's alt adds its demo-only " · sample illustration N" to the card title. img:{...card.img,alt:card.img.alt.replace(/ · sample illustration \d+$/,'')}, - // D8 (follow-up PR): no selection checkbox yet; the star keeps the concept's top-right place. - overlays:withoutCheckbox(card.overlays), info:{...card.info,...listRow.info,copy:{...card.info.copy,...listRow.copy},p:{...card.info.p,...listRow.p}, // D10: the title keeps the file's relative path (the fixture manifest's) in `title`. h3:{...card.info.h3,title:paths.get(filename)??null}, @@ -178,7 +186,8 @@ export function mapImagesConcept(reference,{manifest,now=new Date()}) { const caption={...reference.caption,spans:reference.caption.spans.map((span,index)=>index===1 ?{...span,text:captionDay(manifest.newest_modified,now)}:{...span,text:countCopy(span.text)})}; const continuation=mapContinuation(reference.continuation); - return {...reference,toolbar,filterRow,cards,caption,continuation,panel:{...reference.panel,top:reference.panel.top-rise}}; + const {bulkbar,shrink}=mapBulkbar(reference.bulkbar,rise); + return {...reference,toolbar,filterRow,bulkbar,cards,caption,continuation,panel:{...reference.panel,top:reference.panel.top-rise-shrink}}; } /** D32: the continuation's text and Load more may wrap at extreme zoom; D35: its count uses the singular for one. */ @@ -204,7 +213,7 @@ function mapImportConcept(reference) { ?{...row,description:'Find files added or removed in the output folders.',buttons:['Rescan']}:row)}; } -const SECTIONS=['heading','toolbar','filterRow','caption','grid','cards','continuation','panel','noPageScroll']; +const SECTIONS=['heading','toolbar','filterRow','bulkbar','caption','grid','cards','continuation','panel','noPageScroll']; const pick=inventory=>stripExact(inventory); // The concept's own sample categories, in its first-appearance order (app.js 348): what D36 reorders. @@ -471,6 +480,43 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge } }); +// D8 follow-up (image selection, Task 1): with the first card checked, the concept's bar (app.js 430-434) and the +// checked card, against production's bar of real actions. The concept page itself, without the omitted actions, is the +// oracle for the re-laid-out bar. +for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selection bar matches the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{ + await page.setViewportSize({width,height}); + const manifest=testInfo.manifest; + const check=async()=>{ + await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check(); + await expect(page.locator('#bulkbar > .bulkbar > strong')).toHaveText('1 selected'); + await page.evaluate(()=>document.activeElement?.blur());await page.mouse.move(0,0);await settle(page); + }; + await openConceptLoaded(page,theme,'no-preference'); + const empty=await inventoryOf(page,'#main'); + await check(); + const reference=await inventoryOf(page,'#main'); + const restore=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]}); + const removed=(await inventoryOf(page,'#main')).bulkbar; + await restore(); + await captureCanonicalState(page,testInfo,`concept-images-selected-${width}-${theme}`); + await openProductionLoaded(page,theme,'no-preference'); + const emptyActual=await inventoryOf(page,'#workspace-main'); + await check(); + const actual=await inventoryOf(page,'#workspace-main'); + await captureCanonicalState(page,testInfo,`production-images-selected-${width}-${theme}`); + // The concept itself: an empty bar, then "1 selected" and its six actions. + expect.soft(empty.bulkbar,'concept empty bar').toMatchObject({id:'bulkbar',height:0,children:0,bar:null}); + expect.soft(reference.bulkbar.bar.items.map(item=>item.text),'concept bar').toEqual(['1 selected','Add tags','Set category', + 'Add to collection','Export metadata','Remove links…','Clear']); + expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]); + expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Set category','Export metadata','Clear']); + expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]); + expect.soft(compare(reference,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]); + // The bar comparison notices a change to the production bar. + await mutate(page,{css:'.bulkbar { padding: 4px }'}); + expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop'); +}); + // The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the // `.media-info` padding) and one DOM mutation (the Inspect buttons removed), each applied after an exact match. test('images comparison detects mutations',async({page},testInfo)=>{ diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index ef955ba..8969889 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -1014,10 +1014,11 @@ async function cardInventory(page, concept) { const walk = new Function(`return (${walkSource})()`)(); return nodes.map(card => { const copy = card.cloneNode(true); - // D8: no selection checkbox; D9: no tag button until image annotations. The D7 star is compared. - if (concept) for (const node of copy.querySelectorAll('.image-overlays > label, [aria-label^="Tag or auto-tag "]')) node.remove(); + // D9: no tag button until image annotations. The D8 checkbox and the D7 star are compared. + if (concept) for (const node of copy.querySelectorAll('[aria-label^="Tag or auto-tag "]')) node.remove(); const open = card.querySelector('.image-open'), img = open.querySelector('img'); const star = card.querySelector('.image-overlays > button.star-button'); + const box = card.querySelector('.image-overlays > label > input'); return { filename: open.getAttribute('aria-label').replace(/^Open /, ''), // The concept's alt is the card title plus its demo-only " · sample illustration N" (m5). alt: img.alt.replace(/ · sample illustration \d+$/, ''), @@ -1026,6 +1027,7 @@ async function cardInventory(page, concept) { ratio: card.style.getPropertyValue('--media-ratio').replace(/\s/g, ''), inspect: card.querySelector('.image-card-actions > button').getAttribute('aria-label'), star: star && { text: star.textContent, pressed: star.getAttribute('aria-pressed'), label: star.getAttribute('aria-label') }, + select: box && { type: box.type, checked: box.checked, label: box.getAttribute('aria-label') }, paths: walk(copy, `article.${[...card.classList].filter(value => value !== 'selected').sort().join('.')}`, []) }; }); }, { walkSource: CARD_PATHS.toString(), concept }); @@ -1187,7 +1189,9 @@ async function zoomedGallery(page, request, { cssZoom }) { }); await page.keyboard.press('ArrowDown'); await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-open`)).toBeFocused(); - // The concept's card order: image, the D7 star, then Inspect. + // The concept's card order: image, the D8 checkbox, the D7 star, then Inspect. + await page.keyboard.press('Tab'); + await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-overlays input[type="checkbox"]`)).toBeFocused(); await page.keyboard.press('Tab'); await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-overlays .star-button`)).toBeFocused(); await page.keyboard.press('Tab'); @@ -1564,3 +1568,377 @@ test('toolbar categories are alphabetical regardless of server order', async ({ const category = page.getByRole('combobox', { name: 'Filter by category', exact: true }); await expect(category.locator('option')).toHaveText(['All categories', 'Alpha', 'beta', 'zeta']); }); + +// #249 D8 follow-up (image selection, Task 1): the concept's card checkbox and selection bar (app.js 398, 426-434). +const bulkbar = page => page.locator('#workspace-main > #bulkbar'); +const checkbox = (card, filename) => card.getByRole('checkbox', { name: `Select ${filename}`, exact: true }); +const barButtons = page => bulkbar(page).locator('.bulkbar > button'); + +test('card checkboxes select images into the concept bar and Clear empties it', async ({ page }) => { + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + // The concept's place: an empty bar between the filter row and the gallery layout. + await expect(bulkbar(page)).toBeEmpty(); + expect(await bulkbar(page).evaluate(bar => [bar.previousElementSibling.className, bar.nextElementSibling.classList.contains('gallery-layout')])) + .toEqual(['filter-row', true]); + // Each overlay starts with the checkbox label, then the star. + expect(await page.locator('.image-overlays').evaluateAll(rows => rows.map(row => [...row.children].map(child => + `${child.tagName.toLowerCase()}${child.querySelector(':scope > input') ? `>${child.firstElementChild.type}` : ''}`)))) + .toEqual([['label>checkbox', 'button'], ['label>checkbox', 'button']]); + const [first, second] = [cards(page).first(), cards(page).last()]; + const selectedBefore = await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected'))); + await checkbox(first, 'same.png').check(); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected'); + await expect(barButtons(page)).toHaveText(['Set category', 'Export metadata', 'Clear']); + // Toggling neither opens the viewer, nor inspects, nor moves focus. + await expect(page.locator('dialog[open]')).toHaveCount(0); + await expect(checkbox(first, 'same.png')).toBeFocused(); + expect(await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected')))).toEqual(selectedBefore); + // The count updates in place: the same bar and count nodes. + await bulkbar(page).locator('.bulkbar > strong').evaluate(node => { node.dataset.kept = 'yes'; }); + await checkbox(second, 'same.png').check(); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected'); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveAttribute('data-kept', 'yes'); + // Set category opens its dialog; Cancel keeps the selection. + await bulkbar(page).getByRole('button', { name: 'Set category', exact: true }).click(); + await page.getByRole('dialog', { name: 'Categorize selected images', exact: true }).getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.locator('dialog[open]')).toHaveCount(0); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected'); + await checkbox(second, 'same.png').uncheck(); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected'); + await checkbox(second, 'same.png').check(); + await bulkbar(page).getByRole('button', { name: 'Clear', exact: true }).click(); + await expect(bulkbar(page)).toBeEmpty(); + // The pressed control hid with its bar: focus moves to the page heading, not to the page. + await expect(page.locator('#workspace-main h1')).toBeFocused(); + await expect(checkbox(first, 'same.png')).not.toBeChecked(); + await expect(checkbox(second, 'same.png')).not.toBeChecked(); +}); + +test('the card checkbox keeps its own Space and Tab', async ({ page }) => { + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + const first = cards(page).first(); + await first.getByRole('button', { name: 'Open same.png', exact: true }).focus(); + await page.keyboard.press('Tab'); + await expect(checkbox(first, 'same.png')).toBeFocused(); + await page.keyboard.press('Space'); + await expect(checkbox(first, 'same.png')).toBeChecked(); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected'); + await expect(page.locator('dialog[open]')).toHaveCount(0); + await page.keyboard.press('Tab'); + await expect(star(first, 'same.png')).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(checkbox(first, 'same.png')).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(first.getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused(); + // An arrow key on the checkbox still moves between cards, as from the card's other controls. + await checkbox(first, 'same.png').focus(); + await page.keyboard.press('ArrowRight'); + await expect(cards(page).last().getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused(); + await expect(checkbox(first, 'same.png')).toBeChecked(); +}); + +test('the selection survives scrolling, re-mounting and appended batches, and a search clears it', async ({ page, request }) => { + await page.addInitScript(() => { window.IntersectionObserver = undefined; }); + await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK(); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(40); + const firstKey = await cards(page).first().getAttribute('data-image-key'); + await cards(page).first().getByRole('checkbox', { name: /^Select / }).check(); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected'); + const more = page.getByRole('button', { name: 'Load more images', exact: true }); + for (const loaded of [80, 120, 160, 200, 202]) { + await more.click(); + await expect(caption(page)).toContainText(`${loaded} of 202 images`); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected'); + } + // A card from a later batch joins the kept selection. + const late = page.locator('article[data-image-key][data-index="200"]'); + await late.getByRole('checkbox', { name: /^Select / }).check(); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected'); + // Bounded rendering unmounted the first card; scrolled back, it is mounted again with its box checked. + await expect(page.locator(`article[data-image-key="${firstKey}"]`)).toHaveCount(0); + await page.evaluate(() => window.scrollTo(0, 0)); + const again = page.locator(`article[data-image-key="${firstKey}"]`); + await expect(again).toBeVisible(); + await expect(again.getByRole('checkbox', { name: /^Select / })).toBeChecked(); + await expect(cards(page).nth(1).getByRole('checkbox', { name: /^Select / })).not.toBeChecked(); + // Returning to Images keeps the restored collection and its selection. + await page.evaluate(() => { window.location.hash = '/overview'; }); + await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + await page.evaluate(() => { window.location.hash = '/images'; }); + await expect(page.locator('#workspace-main h1')).toHaveText('Images'); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected'); + // A search replaces the collection, and with it the selection. + await search(page).fill('0001'); + await expect(caption(page)).not.toContainText('202 images'); + await expect(bulkbar(page)).toBeEmpty(); + await search(page).fill(''); + await expect(caption(page)).toContainText('of 202 images'); + await expect(bulkbar(page)).toBeEmpty(); + await expect(page.locator(`article[data-image-key="${firstKey}"]`).getByRole('checkbox', { name: /^Select / })).not.toBeChecked(); + // A sort change replaces the collection too. + await cards(page).first().getByRole('checkbox', { name: /^Select / }).check(); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected'); + await sortBy(page).selectOption('oldest'); + await expect(bulkbar(page)).toBeEmpty(); + await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0); +}); + +test('the card checkbox does not open the viewer', async ({ page }) => { + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + const first = cards(page).first(); + const selectedBefore = await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected'))); + const inspectorBefore = await details(page).innerText(); + // A press on the label around the box toggles it, as does a press on the box itself. + await first.locator('.image-overlays > label').click({ position: { x: 1, y: 1 } }); + await expect(checkbox(first, 'same.png')).toBeChecked(); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected'); + await checkbox(first, 'same.png').click(); + await expect(checkbox(first, 'same.png')).not.toBeChecked(); + await expect(page.locator('dialog[open]')).toHaveCount(0); + await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0); + // Neither press selected the card or inspected it. + expect(await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected')))).toEqual(selectedBefore); + expect(await details(page).innerText()).toBe(inspectorBefore); +}); + +// #249 D8 follow-up (image selection, Task 2): Export metadata downloads one bundle of the inspector's envelopes. +const exportButton = page => bulkbar(page).getByRole('button', { name: 'Export metadata', exact: true }); +const inspectRoute = '**/prompt_manager/images/inspect/**'; +const atRoot = (route, root) => new URL(route.request().url()).searchParams.get('root') === root; +async function checkBoth(page) { + await expect(cards(page)).toHaveCount(2); + await checkbox(cards(page).first(), 'same.png').check(); + await checkbox(cards(page).last(), 'same.png').check(); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected'); +} +async function bundleOf(download) { + const fs = await import('node:fs/promises'); + return JSON.parse(await fs.readFile(await download.path(), 'utf8')); +} + +test('Export metadata downloads one bundle of the checked images in listing order and keeps the selection', async ({ page }) => { + // Record the Blob URLs the page creates and revokes, to prove the download's URL is released. + await page.addInitScript(() => { + const blobs = window.__blobs = { created: [], revoked: [] }; + const create = URL.createObjectURL.bind(URL), revoke = URL.revokeObjectURL.bind(URL); + URL.createObjectURL = value => { const url = create(value); blobs.created.push(url); return url; }; + URL.revokeObjectURL = url => { blobs.revoked.push(url); revoke(url); }; + }); + await page.goto(workspace); + await checkBoth(page); + const keys = await cards(page).evaluateAll(nodes => nodes.map(node => node.dataset.imageKey)); + // Hold both reads: the button is busy and a second press starts nothing more. + const held = [], reads = []; + await page.route(inspectRoute, route => { reads.push(route.request().url()); held.push(route); }); + const downloads = []; + page.on('download', download => downloads.push(download)); + await exportButton(page).click(); + await expect.poll(() => held.length).toBe(2); + await expect(exportButton(page)).toHaveAttribute('aria-disabled', 'true'); + // Playwright waits on an aria-disabled control, so the second press is a dispatched click. + await exportButton(page).dispatchEvent('click'); + await page.evaluate(() => new Promise(requestAnimationFrame)); + const saving = page.waitForEvent('download'); + for (const route of held) await route.continue(); + const download = await saving; + expect(download.suggestedFilename()).toBe('prompt-manager-images-metadata.json'); + const bundle = await bundleOf(download); + expect([bundle.format, bundle.version]).toEqual(['prompt-manager-images-metadata', 1]); + expect(Number.isNaN(Date.parse(bundle.exported_at))).toBe(false); + expect(bundle.images.map(image => image.identity.key)).toEqual(keys); + expect(bundle.images.map(image => image.identity.path.split('/').at(-1))).toEqual(['same.png', 'same.png']); + expect(bundle.images.every(image => image.inspection_version === 1 && image.metadata.prompt_graph === undefined)).toBe(true); + await expect(toast(page)).toHaveText('Exported metadata for 2 images.'); + await expect(exportButton(page)).not.toHaveAttribute('aria-disabled', 'true'); + expect(reads).toHaveLength(2); + expect(downloads).toHaveLength(1); + // The bundle's one Blob URL is revoked once the download started. + await expect.poll(() => page.evaluate(() => window.__blobs)).toEqual({ created: [expect.stringMatching(/^blob:/)], revoked: [expect.stringMatching(/^blob:/)] }); + const blobs = await page.evaluate(() => window.__blobs); + expect(blobs.revoked).toEqual(blobs.created); + // The selection is kept after an export. + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected'); + await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(2); +}); + +test('Export metadata reports images that could not be read and exports the rest', async ({ page }) => { + await page.goto(workspace); + await checkBoth(page); + await page.route(inspectRoute, route => atRoot(route, '1') ? + route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }) : route.continue()); + const [download] = await Promise.all([page.waitForEvent('download'), exportButton(page).click()]); + const bundle = await bundleOf(download); + expect(bundle.images.map(image => image.identity.key)).toEqual(['0:shared/same.png']); + await expect(toast(page)).toHaveText('Exported metadata for 1 of 2 images. 1 could not be read.'); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected'); +}); + +test('Export metadata downloads nothing when no image can be read', async ({ page }) => { + await page.goto(workspace); + await checkBoth(page); + await page.route(inspectRoute, route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } })); + const downloads = []; + page.on('download', download => downloads.push(download)); + await exportButton(page).click(); + await expect(toast(page)).toHaveText('Metadata could not be read. Try again.'); + await expect(exportButton(page)).not.toHaveAttribute('aria-disabled', 'true'); + expect(downloads).toHaveLength(0); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected'); +}); + +// #249 D8 follow-up (image selection, Task 3): Set category sets the linked positives' category (app.js simpleInput). +const categoryButton = page => bulkbar(page).getByRole('button', { name: 'Set category', exact: true }); +const categoryDialog = page => page.getByRole('dialog', { name: 'Categorize selected images', exact: true }); +const categoryRoute = '**/prompt_manager/bulk/category'; +/** Unlink the input root's card by deleting its prompt; returns the output card's positive id. */ +async function unlinkInputCard(request) { + const { images } = await (await request.get('/prompt_manager/images/output?mode=workspace')).json(); + const byKey = Object.fromEntries(images.map(image => [`${image.root_index}:${image.path}`, image])); + const [output, input] = [byKey['0:shared/same.png'], byKey['1:shared/same.png']]; + expect([output.linked.status, input.linked.status]).toEqual(['linked', 'linked']); + await expect(await request.delete(`/prompt_manager/delete/${input.linked.positive_id}`)).toBeOK(); + return output.linked.positive_id; +} + +test('Set category applies the category to the linked positives and the category filter then matches', async ({ page, request }) => { + const outputPositive = await unlinkInputCard(request); + await page.goto(workspace); + await checkBoth(page); + const opener = categoryButton(page); + await opener.click(); + const dialog = categoryDialog(page); + await expect(dialog).toBeVisible(); + // The concept's simpleInput: one field and Cancel / Apply (primary). + await expect(dialog.locator('#modal-body > .field > label[for="action-input"]')).toHaveText('Category'); + await expect(dialog.locator('#modal-body > .field > input#action-input')).toHaveValue(''); + await expect(dialog.locator('#modal-body > .dialog-actions > button')).toHaveText(['Cancel', 'Apply']); + await expect(dialog.getByRole('button', { name: 'Apply', exact: true })).toHaveClass(/ca-primary/); + await expect(dialog.getByLabel('Category', { exact: true })).toBeFocused(); + // An empty value applies nothing and keeps the dialog open. + const posts = []; + page.on('request', item => { if (item.method() === 'POST' && item.url().endsWith('/prompt_manager/bulk/category')) posts.push(item.postDataJSON()); }); + await dialog.getByRole('button', { name: 'Apply', exact: true }).click(); + await expect(dialog).toBeVisible(); + await dialog.getByLabel('Category', { exact: true }).fill(' Landscapes '); + const listings = []; + page.on('request', item => { if (imagePages(new URL(item.url()))) listings.push(item.url()); }); + await dialog.getByRole('button', { name: 'Apply', exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect(toast(page)).toHaveText('Category set on 1 prompt. 1 selected image had no linked prompt and was skipped.'); + expect(posts).toEqual([{ prompt_ids: [outputPositive], category: 'Landscapes' }]); + // The listing and the category options reload; the reload clears the selection. + await expect.poll(() => listings.length).toBeGreaterThan(0); + await expect(bulkbar(page)).toBeEmpty(); + await expect(page.locator('#workspace-main h1')).toBeFocused(); + await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0); + const filter = page.getByRole('combobox', { name: 'Filter by category', exact: true }); + await expect(filter.locator('option')).toContainText(['Landscapes']); + await filter.selectOption('Landscapes'); + await expect(cards(page)).toHaveCount(1); + await expect(cards(page)).toHaveAttribute('data-image-key', '0:shared/same.png'); +}); + +test('Set category without a linked image posts nothing and opens no dialog', async ({ page, request }) => { + await unlinkInputCard(request); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + let posted = 0; + await page.route(categoryRoute, route => { posted += 1; return route.continue(); }); + await checkbox(cards(page).last(), 'same.png').check(); + await categoryButton(page).click(); + await expect(toast(page)).toHaveText('None of the selected images has a linked prompt.'); + await expect(page.locator('dialog[open]')).toHaveCount(0); + expect(posted).toBe(0); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected'); +}); + +test('Set category reports a failure, keeps the selection and ignores a second press while it runs', async ({ page }) => { + await page.goto(workspace); + await checkBoth(page); + const held = []; + await page.route(categoryRoute, route => { held.push(route); }); + await categoryButton(page).click(); + await categoryDialog(page).getByLabel('Category', { exact: true }).fill('Landscapes'); + await categoryDialog(page).getByRole('button', { name: 'Apply', exact: true }).click(); + await expect.poll(() => held.length).toBe(1); + await expect(categoryButton(page)).toHaveAttribute('aria-disabled', 'true'); + // Playwright waits on an aria-disabled control, so the second press is a dispatched click. + await categoryButton(page).dispatchEvent('click'); + await expect(page.locator('dialog[open]')).toHaveCount(0); + await held[0].fulfill({ status: 500, json: { success: false, error: 'boom' } }); + await expect(toast(page)).toHaveText('Category could not be set. Try again.'); + await expect(categoryButton(page)).not.toHaveAttribute('aria-disabled', 'true'); + expect(held).toHaveLength(1); + await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected'); + await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(2); +}); + +test('leaving Images during an export downloads nothing and shows no toast', async ({ page }) => { + await page.goto(workspace); + await checkBoth(page); + const held = []; + await page.route(inspectRoute, route => { held.push(route); }); + const downloads = []; + page.on('download', download => downloads.push(download)); + await exportButton(page).click(); + await expect.poll(() => held.length).toBe(2); + await page.evaluate(() => { window.location.hash = '/overview'; }); + await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + // The reads were aborted with the page; answering them now changes nothing. + for (const route of held) await route.continue().catch(() => {}); + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); + expect(downloads).toHaveLength(0); + await expect(toast(page)).not.toContainText('etadata'); +}); + +test('leaving Images during Set category finishes the write, and the return reloads the collection', async ({ page }) => { + await page.goto(workspace); + await checkBoth(page); + const held = []; + await page.route(categoryRoute, route => { held.push(route); }); + await categoryButton(page).click(); + await categoryDialog(page).getByLabel('Category', { exact: true }).fill('Landscapes'); + await categoryDialog(page).getByRole('button', { name: 'Apply', exact: true }).click(); + await expect.poll(() => held.length).toBe(1); + await page.evaluate(() => { window.location.hash = '/overview'; }); + await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + // The write is not aborted with the page: the server still receives and answers it. + const answered = page.waitForResponse(response => response.url().endsWith('/prompt_manager/bulk/category')); + await held[0].continue(); + expect((await answered).ok()).toBe(true); + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); + await expect(toast(page)).not.toContainText('Category'); + // The saved visit predates the write, so the return loads the collection afresh: no stale selection or categories. + await page.evaluate(() => { window.location.hash = '/images'; }); + await expect(page.locator('#workspace-main h1')).toHaveText('Images'); + await expect(cards(page)).toHaveCount(2); + await expect(bulkbar(page)).toBeEmpty(); + await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0); + await expect(page.getByRole('combobox', { name: 'Filter by category', exact: true }).locator('option')).toContainText(['Landscapes']); +}); + +test('Set category limits the field to the server length, shows the server count, and a rejection keeps the selection', async ({ page }) => { + await page.goto(workspace); + await checkBoth(page); + const answers = [ + { status: 400, json: { success: false, error: 'Category cannot exceed 100 characters' } }, + { status: 200, json: { success: true, updated_count: 0 } }, + ]; + await page.route(categoryRoute, route => route.fulfill(answers.shift())); + for (const expected of ['Category could not be set. Try again.', 'Category set on 0 prompts.']) { + await categoryButton(page).click(); + const field = categoryDialog(page).getByLabel('Category', { exact: true }); + await expect(field).toHaveAttribute('maxlength', '100'); + await field.fill('Landscapes'); + await categoryDialog(page).getByRole('button', { name: 'Apply', exact: true }).click(); + await expect(toast(page)).toHaveText(expected); + if (answers.length) await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected'); + } + // The success reload clears the selection. + await expect(bulkbar(page)).toBeEmpty(); + expect(answers).toHaveLength(0); +}); diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs index 50dda65..0f2e5f0 100644 --- a/tests/browser/workspace-library-editor.spec.mjs +++ b/tests/browser/workspace-library-editor.spec.mjs @@ -680,6 +680,9 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected await lane.locator('.library-variant-dot').click();await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true'); await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative); await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1); + // #249 D8 ruling (a): the Library-embedded gallery has no select checkbox and no selection bar. + await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('.image-overlays input[type="checkbox"]')).toHaveCount(0); + await expect(page.locator('#bulkbar, .bulkbar')).toHaveCount(0); await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).getByText('1 loaded · 1 linked image',{exact:true})).toBeVisible(); expect(galleryReads.at(-1).get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.pair_id)); await expect(headingActions(page).getByRole('button',{name:/^Send prompt pair to ComfyUI/})).toBeEnabled(); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index c933206..1be1cbb 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -352,13 +352,18 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [ await expect(page.locator('dialog#viewer-dialog')).toBeVisible(); await expect(page.locator('#viewer-title')).toHaveText(filename); await page.keyboard.press('Escape'); - // The concept's card order: image, the D7 star (hidden in List, as the concept hides its overlays), then Inspect. + // The concept's card order (app.js mediaCard): image, the D8 checkbox, the D7 star, then Inspect. List hides + // the overlays as the concept does (`.list-grid .image-overlays`), so there the image is followed by Inspect. + const box = longCard.getByRole('checkbox', { name: `Select ${filename}`, exact: true }); const star = longCard.getByRole('button', { name: `Favorite ${filename}`, exact: true }); - await longCard.locator('.image-open').focus(); await page.keyboard.press('Tab'); - if (layout !== 'List') { await expect(star).toBeFocused(); await page.keyboard.press('Tab'); } + const overlays = layout === 'List' ? [] : [box, star]; + if (layout === 'List') { await expect(box).toBeHidden(); await expect(star).toBeHidden(); } + await longCard.locator('.image-open').focus(); + for (const control of overlays) { await page.keyboard.press('Tab'); await expect(control).toBeFocused(); } + await page.keyboard.press('Tab'); await expect(longCard.getByRole('button', { name: `Inspect ${filename}`, exact: true })).toBeFocused(); + for (const control of [...overlays].reverse()) { await page.keyboard.press('Shift+Tab'); await expect(control).toBeFocused(); } await page.keyboard.press('Shift+Tab'); - if (layout !== 'List') { await expect(star).toBeFocused(); await page.keyboard.press('Shift+Tab'); } await expect(longCard.locator('.image-open')).toBeFocused(); await page.keyboard.press('Enter'); await expect(page.locator('dialog[open]')).toHaveCount(1); await page.keyboard.press('Escape'); diff --git a/tests/js/workspace-images-category.test.js b/tests/js/workspace-images-category.test.js new file mode 100644 index 0000000..641d3fa --- /dev/null +++ b/tests/js/workspace-images-category.test.js @@ -0,0 +1,53 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = import('../../web/js/workspace/images-category.mjs'); + +const linked = (id, extra = {}) => ({path:`p${id}.png`, linked:{status:'linked', positive_id:id, ...extra}}); + +test('targets are the distinct linked positives, in selection order, and the rest count as skipped', async () => { + const {categoryTargets} = await source; + const images = [ + linked(7), linked(3), linked(7), + {path:'u.png', linked:{status:'unlinked', positive_id:null}}, + {path:'a.png', linked:{status:'ambiguous', positive_id:4}}, + {path:'v.png', linked:{status:'unavailable', positive_id:null}}, + {path:'none.png'}, + {path:'bad.png', linked:{status:'linked', positive_id:'9'}}, + {path:'zero.png', linked:{status:'linked', positive_id:0}}, + {path:'frac.png', linked:{status:'linked', positive_id:1.5}}, + null, + ]; + assert.deepEqual(categoryTargets(images), {promptIds:[7, 3], skipped:8}); + assert.deepEqual(categoryTargets([]), {promptIds:[], skipped:0}); + assert.deepEqual(categoryTargets([linked(2)]), {promptIds:[2], skipped:0}); +}); + +test('the outcome toast counts the prompts and the skipped images', async () => { + const {categoryOutcome} = await source; + assert.equal(categoryOutcome(3, 0), 'Category set on 3 prompts.'); + assert.equal(categoryOutcome(1, 0), 'Category set on 1 prompt.'); + assert.equal(categoryOutcome(2, 4), 'Category set on 2 prompts. 4 selected images had no linked prompt and were skipped.'); + assert.equal(categoryOutcome(1, 1), 'Category set on 1 prompt. 1 selected image had no linked prompt and was skipped.'); +}); + +test('the dialog and failure copy is the plan\'s', async () => { + const {CATEGORY_DIALOG, CATEGORY_NONE_LINKED, CATEGORY_FAILED} = await source; + assert.deepEqual(CATEGORY_DIALOG, {title:'Categorize selected images', label:'Category', action:'Apply'}); + assert.equal(CATEGORY_NONE_LINKED, 'None of the selected images has a linked prompt.'); + assert.equal(CATEGORY_FAILED, 'Category could not be set. Try again.'); + assert.ok(Object.isFrozen(CATEGORY_DIALOG)); +}); + +test('the updated count is the server\'s when it is a count, else the number of prompts posted', async () => { + const {updatedCount} = await source; + assert.equal(updatedCount({updated_count:0}, 3), 0); + assert.equal(updatedCount({updated_count:2}, 3), 2); + for (const result of [{}, {updated_count:-1}, {updated_count:1.5}, {updated_count:'2'}, null, undefined]) { + assert.equal(updatedCount(result, 3), 3, JSON.stringify(result)); + } +}); + +test('the category field is limited to the server\'s 100 characters', async () => { + const {CATEGORY_MAX_LENGTH} = await source; + assert.equal(CATEGORY_MAX_LENGTH, 100); +}); diff --git a/tests/js/workspace-images-export.test.js b/tests/js/workspace-images-export.test.js new file mode 100644 index 0000000..f9f1e23 --- /dev/null +++ b/tests/js/workspace-images-export.test.js @@ -0,0 +1,76 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = import('../../web/js/workspace/images-export.mjs'); +const inspector = import('../../web/js/workspace/file-inspector.mjs'); + +function record(path, status = 'available') { + return {identity:{kind:'rooted-file', key:`0:${path}`, root_index:0, path, root_scope:null}, file:{format:'PNG'}, + prompts:{positive:{status:'resolved', text:path}}, generation:{}, library:{status:'unlinked'}, + download:{filename:path.split('/').at(-1), url:`/prompt_manager/images/serve/${path}?root=0`}, + metadata:{status, omitted_reason:null, raw:{prompt:'{}'}, record_formats:{prompt:'original-text'}}}; +} +const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => {resolve = a; reject = b;}); return {promise, resolve, reject}; }; + +test('the single-image download and the bundle share one envelope builder', async () => { + const {metadataEnvelope, inspectionDownloads} = await inspector; + const value = record('a/one.png'); + assert.deepEqual(JSON.parse(inspectionDownloads(value).metadata.bodyText), metadataEnvelope(value)); + assert.equal(metadataEnvelope(record('b.png', 'limited')), null); + assert.equal(metadataEnvelope({}), null); +}); + +test('the bundle holds the format, version, export time and envelopes in the given order', async () => { + const {metadataBundle, BUNDLE_FILENAME} = await source; + const envelopes = [{inspection_version:1, n:1}, {inspection_version:1, n:2}]; + const bundle = metadataBundle(envelopes, '2026-10-20T00:00:00.000Z'); + assert.deepEqual(bundle, {format:'prompt-manager-images-metadata', version:1, exported_at:'2026-10-20T00:00:00.000Z', images:envelopes}); + assert.notEqual(bundle.images, envelopes); + assert.equal(BUNDLE_FILENAME, 'prompt-manager-images-metadata.json'); +}); + +test('collection keeps listing order, runs at most four reads at once and counts failures', async () => { + const {collectImagesMetadata} = await source; + const {metadataEnvelope} = await inspector; + const images = Array.from({length:9}, (_, index) => ({path:`img-${index}.png`, root_index:0})); + const waits = new Map(images.map(image => [image.path, deferred()])); + let running = 0, peak = 0; + const done = collectImagesMetadata(images, async image => { + running += 1; peak = Math.max(peak, running); + try { return await waits.get(image.path).promise; } finally { running -= 1; } + }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(running, 4); + // Settle out of order: later images finish first, one read fails and one file's metadata is limited. + for (const image of [...images].reverse()) { + const wait = waits.get(image.path); + if (image.path === 'img-3.png') wait.reject(new Error('unreadable')); + else wait.resolve(record(image.path, image.path === 'img-5.png' ? 'limited' : 'available')); + await new Promise(resolve => setImmediate(resolve)); + } + const {envelopes, failed} = await done; + assert.equal(peak, 4); + assert.equal(failed, 2); + assert.deepEqual(envelopes.map(envelope => envelope.identity.path), + ['img-0.png', 'img-1.png', 'img-2.png', 'img-4.png', 'img-6.png', 'img-7.png', 'img-8.png']); + assert.deepEqual(envelopes[0], metadataEnvelope(record('img-0.png'))); +}); + +test('a read that throws synchronously counts as a failure', async () => { + const {collectImagesMetadata} = await source; + const result = await collectImagesMetadata([{path:'a.png'}, {path:'b.png'}], image => { + if (image.path === 'a.png') throw new TypeError('Invalid inspection identity'); + return record(image.path); + }); + assert.equal(result.failed, 1); + assert.deepEqual(result.envelopes.map(envelope => envelope.identity.path), ['b.png']); + assert.deepEqual(await collectImagesMetadata([], () => assert.fail('no reads')), {envelopes:[], failed:0}); +}); + +test('the outcome toasts name the exported and unread counts', async () => { + const {exportOutcome} = await source; + assert.equal(exportOutcome(1, 1), 'Exported metadata for 1 image.'); + assert.equal(exportOutcome(2, 2), 'Exported metadata for 2 images.'); + assert.equal(exportOutcome(1, 3), 'Exported metadata for 1 of 3 images. 2 could not be read.'); + assert.equal(exportOutcome(2, 3), 'Exported metadata for 2 of 3 images. 1 could not be read.'); + assert.equal(exportOutcome(0, 2), 'Metadata could not be read. Try again.'); +}); diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js index efe3309..b64f4b1 100644 --- a/tests/js/workspace-images-state.test.js +++ b/tests/js/workspace-images-state.test.js @@ -313,3 +313,22 @@ test('withItem patches a saved browsing state without touching the other items', assert.equal(state.items[1].favorite, false); assert.equal(withItem(state, '1:b', {favorite:true}), state); }); + +test('toggleSelection returns a new frozen set and leaves the given one untouched', async () => { + const { toggleSelection } = await source; + const empty = Object.freeze(new Set()); + const one = toggleSelection(empty, '0:a', true); + assert.ok(Object.isFrozen(one)); assert.deepEqual([...one], ['0:a']); assert.equal(empty.size, 0); + const two = toggleSelection(one, '1:a', true); + assert.deepEqual([...two], ['0:a', '1:a']); assert.deepEqual([...one], ['0:a']); + assert.equal(toggleSelection(two, '0:a', true), two, 'an already selected key keeps the same set'); + assert.equal(toggleSelection(one, '9:z', false), one, 'an unselected key keeps the same set'); + assert.deepEqual([...toggleSelection(two, '0:a', false)], ['1:a']); +}); + +test('selectionLabel counts the selected images as the concept bar does', async () => { + const { selectionLabel } = await source; + assert.equal(selectionLabel(1), '1 selected'); + assert.equal(selectionLabel(2), '2 selected'); + assert.equal(selectionLabel(1234), '1,234 selected'); +}); diff --git a/tests/test_api_prompts.py b/tests/test_api_prompts.py index 21bb3ca..2cfb7a0 100644 --- a/tests/test_api_prompts.py +++ b/tests/test_api_prompts.py @@ -26,6 +26,7 @@ from database.operations import PromptDatabase # noqa: E402 from py.api import PromptManagerAPI # noqa: E402 import py.api.prompts as prompts_module # noqa: E402 from py.api.prompts import ( # noqa: E402 + BULK_CATEGORY_MAX_PROMPTS, MAX_PAGE_LIMIT, MAX_PAGE_OFFSET, safe_error_message, @@ -909,6 +910,98 @@ class TestBulkRoutes(RouteCoverageCase): self.assertEqual(ok_data["updated_count"], 2) self.assertEqual(self.api.db.get_prompt_by_id(ids[0])["category"], "c") + async def _set_category(self, body): + return await self._json("POST", "/prompt_manager/bulk/category", json=body) + + async def test_bulk_set_category_rejects_malformed_prompt_ids(self): + pid = self._save_prompt("bulk cat strict") + cases = ( + None, + "1", + {"id": pid}, + [], + [pid, "2"], + [pid, 0], + [pid, -3], + [pid, 1.5], + [True], + [pid, None], + # Beyond SQLite's INTEGER range: binding it would raise OverflowError. + [pid, 2**63], + [2**64], + list(range(1, BULK_CATEGORY_MAX_PROMPTS + 2)), + ) + for prompt_ids in cases: + with self.subTest(prompt_ids=str(prompt_ids)[:40]): + status, data = await self._set_category( + {"prompt_ids": prompt_ids, "category": "c"} + ) + self.assertEqual(status, 400, data) + self.assertFalse(data["success"]) + self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"]) + + async def test_bulk_set_category_validates_the_category(self): + pid = self._save_prompt("bulk cat validate") + for category in ("x" * 101, "bad\x00name", 7, ["c"]): + with self.subTest(category=str(category)[:20]): + status, data = await self._set_category( + {"prompt_ids": [pid], "category": category} + ) + self.assertEqual(status, 400, data) + self.assertFalse(data["success"]) + for body in ([pid], "text"): + with self.subTest(body=body): + status, data = await self._set_category(body) + self.assertEqual(status, 400, data) + resp = await self.client.post( + "/prompt_manager/bulk/category", + data="{not json", + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(resp.status, 400) + self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"]) + + async def test_bulk_set_category_dedupes_and_accepts_the_cap(self): + ids = [self._save_prompt(f"bulk cat dedupe {i}") for i in range(2)] + seen = [] + original = self.api.db.bulk_set_category + + def spy(prompt_ids, category): + seen.append((list(prompt_ids), category)) + return original(prompt_ids, category) + + self.api.db.bulk_set_category = spy + status, data = await self._set_category( + {"prompt_ids": [ids[1], ids[0], ids[1], ids[0]], "category": " Land "} + ) + self.assertEqual(status, 200, data) + self.assertEqual(data["updated_count"], 2) + self.assertEqual(seen, [([ids[1], ids[0]], "Land")]) + # The cap itself is accepted; unknown ids simply do not count. + capped = ids + list(range(10**6, 10**6 + BULK_CATEGORY_MAX_PROMPTS - 2)) + status, data = await self._set_category( + {"prompt_ids": capped, "category": "Sea"} + ) + self.assertEqual(status, 200, data) + self.assertEqual(data["updated_count"], 2) + self.assertEqual(self.api.db.get_prompt_by_id(ids[0])["category"], "Sea") + + async def test_bulk_set_category_keeps_the_legacy_admin_request(self): + # web/js/admin.js posts parseInt ids and a trimmed, non-empty category. + ids = [self._save_prompt(f"bulk cat legacy {i}") for i in range(3)] + status, data = await self._set_category( + {"prompt_ids": ids, "category": "Portraits"} + ) + self.assertEqual(status, 200, data) + self.assertEqual( + set(data), {"success", "message", "updated_count"}, "response shape" + ) + self.assertTrue(data["success"]) + self.assertEqual(data["updated_count"], 3) + self.assertEqual(data["message"], "Set category for 3 prompts") + for pid in ids: + self.assertEqual(self.api.db.get_prompt_by_id(pid)["category"], "Portraits") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_api_workspace_images.py b/tests/test_api_workspace_images.py index 4d27826..1eaeda2 100644 --- a/tests/test_api_workspace_images.py +++ b/tests/test_api_workspace_images.py @@ -1092,6 +1092,27 @@ class TestWorkspaceImages(ImageAPITestCase): db.link_image_to_prompt(owner, str(files["ambiguous"])) return files + async def test_bulk_category_change_shows_on_the_next_listing(self): + files = self._filter_fixtures() + db = self.api.db + night_a = db.get_prompt_id_for_image(str(files["night_a"])) + # Warm every listing cache (scan, orders, card facts, link reads). + for params in ({}, {"category": "Landscapes"}, {"category": "Night"}): + response, data = await self.get(**params) + self.assertEqual(response.status, 200, data) + self.assertEqual(data["total"], 3) + response = await self.client.post( + "/prompt_manager/bulk/category", + json={"prompt_ids": [night_a], "category": "Landscapes"}, + ) + self.assertEqual(response.status, 200, await response.json()) + _, moved = await self.get(category="Landscapes") + self.assertEqual(self._names(moved), ["night_a.png"]) + self.assertEqual(moved["images"][0]["linked"]["category"], "Landscapes") + _, night = await self.get(category="Night") + self.assertEqual(night["total"], 2) + self.assertNotIn("night_a.png", self._names(night)) + async def test_positive_count_counts_distinct_linked_positives(self): files = self._filter_fixtures() db = self.api.db diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 4ca07c9..b7208fb 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -197,6 +197,24 @@ "functions": 98, "statements": 98 }, + "workspace/images-category.mjs": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + }, + "workspace/images-export.mjs": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + }, + "workspace/images-selection.mjs": { + "lines": 98, + "branches": 92, + "functions": 98, + "statements": 98 + }, "workspace/images-state.mjs": { "lines": 98, "branches": 98, diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 3987ced..a16d9d0 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -22,15 +22,13 @@ function safeFilename(value) { return String(value || 'file').replace(/[\\/\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^\.+|[. ]+$/g, '') || 'file'; } -/** Export only the bounded public evidence, never normalized graphs or container carriers. */ -export function inspectionDownloads(record) { - const name = safeFilename(record?.download?.filename || record?.identity?.path?.split('/').at(-1)); - const stem = name.replace(/\.[^.]+$/, '') || 'file'; - const unavailable = (suffix, reason) => ({available:false, reason, filename:`${stem}.${suffix}.json`, bodyText:null, mime:'application/json'}); +/** + * One file's metadata envelope: only the bounded public evidence, never normalized graphs or container carriers. + * Null when the inspection holds no full metadata. Shared by "Download metadata JSON" and the Images bulk export. + */ +export function metadataEnvelope(record) { const metadata = record?.metadata; - const reason = metadata?.status === 'limited' ? RAW_LIMIT : 'Full metadata is unavailable for this inspection response.'; - const result = {metadata:unavailable('metadata', reason), workflow:unavailable('workflow', reason)}; - if (!metadata || metadata.status === 'limited') return result; + if (!metadata || metadata.status === 'limited') return null; const raw = {}, record_formats = {}, representations = {}; for (const key of RAW_KEYS) { if (Object.hasOwn(REPRESENTATIONS, metadata.record_formats?.[key])) { @@ -39,10 +37,23 @@ export function inspectionDownloads(record) { if (!TEXTLESS.has(record_formats[key]) && typeof metadata.raw?.[key] === 'string') raw[key] = metadata.raw[key]; } } - const envelope = {inspection_version:1, identity:record.identity, file:record.file, prompts:record.prompts, + return {inspection_version:1, identity:record.identity, file:record.file, prompts:record.prompts, generation:record.generation, library:record.library, metadata:{status:metadata.status, omitted_reason:metadata.omitted_reason, raw, record_formats, representations, scope:'Supported embedded records only; one selected container carrier for video. Other metadata and private carrier text are excluded.'}}; +} + +/** The inspector's metadata and workflow downloads, each with its filename or the reason it is unavailable. */ +export function inspectionDownloads(record) { + const name = safeFilename(record?.download?.filename || record?.identity?.path?.split('/').at(-1)); + const stem = name.replace(/\.[^.]+$/, '') || 'file'; + const unavailable = (suffix, reason) => ({available:false, reason, filename:`${stem}.${suffix}.json`, bodyText:null, mime:'application/json'}); + const metadata = record?.metadata; + const reason = metadata?.status === 'limited' ? RAW_LIMIT : 'Full metadata is unavailable for this inspection response.'; + const result = {metadata:unavailable('metadata', reason), workflow:unavailable('workflow', reason)}; + const envelope = metadataEnvelope(record); + if (!envelope) return result; + const {raw, record_formats} = envelope.metadata; result.metadata = {...result.metadata, available:true, reason:null, bodyText:JSON.stringify(envelope, null, 2)}; if (record_formats.workflow === 'reserialized-json') result.workflow.reason = CONTAINER_WORKFLOW; else if (record_formats.workflow === 'limited') result.workflow.reason = LIMITED_WORKFLOW; diff --git a/web/js/workspace/images-category.mjs b/web/js/workspace/images-category.mjs new file mode 100644 index 0000000..c305c68 --- /dev/null +++ b/web/js/workspace/images-category.mjs @@ -0,0 +1,42 @@ +import { countOf, noun } from './plural.mjs'; + +/** + * The Images selection bar's "Set category" (#249 D8). Images have no category of their own: the listing's category + * filter matches the linked positive prompt's, so the action sets the category on those prompts. + */ +export const CATEGORY_DIALOG = Object.freeze({title:'Categorize selected images', label:'Category', action:'Apply'}); +export const CATEGORY_NONE_LINKED = 'None of the selected images has a linked prompt.'; +export const CATEGORY_FAILED = 'Category could not be set. Try again.'; +// The server's limit (`validate_category`): the field stops there instead of failing on Apply. +export const CATEGORY_MAX_LENGTH = 100; + +function linkedPositive(image) { + const link = image?.linked; + if (link?.status !== 'linked') return null; + return Number.isSafeInteger(link.positive_id) && link.positive_id > 0 ? link.positive_id : null; +} + +/** The distinct linked positives of `images`, first-seen order; `skipped` counts the images without one. */ +export function categoryTargets(images) { + const ids = new Set(); + let skipped = 0; + for (const image of images) { + const id = linkedPositive(image); + if (id === null) skipped += 1; + else ids.add(id); + } + return {promptIds:[...ids], skipped}; +} + +/** The success toast: the prompts changed, then any selected images that had no linked prompt. */ +export function categoryOutcome(prompts, skipped) { + const done = `Category set on ${countOf(prompts, 'prompt')}.`; + if (!skipped) return done; + return `${done} ${countOf(skipped, 'selected image')} had no linked prompt and ${noun(skipped, 'was', 'were')} skipped.`; +} + +/** The prompts the server reports changed; without a count, the number of prompts posted. */ +export function updatedCount(result, posted) { + const count = result?.updated_count; + return Number.isSafeInteger(count) && count >= 0 ? count : posted; +} diff --git a/web/js/workspace/images-export.mjs b/web/js/workspace/images-export.mjs new file mode 100644 index 0000000..d9ebb75 --- /dev/null +++ b/web/js/workspace/images-export.mjs @@ -0,0 +1,40 @@ +import { metadataEnvelope } from './file-inspector.mjs'; +import { countOf } from './plural.mjs'; + +/** The Images selection bar's "Export metadata" (#249 D8): one JSON bundle of the inspector's envelopes. */ +export const BUNDLE_FILENAME = 'prompt-manager-images-metadata.json'; +const BUNDLE_FORMAT = 'prompt-manager-images-metadata'; +const EXPORT_CONCURRENCY = 4; + +export function metadataBundle(envelopes, exportedAt) { + return {format:BUNDLE_FORMAT, version:1, exported_at:exportedAt, images:[...envelopes]}; +} + +/** + * Read every image's inspection record, at most four at a time, and keep the envelopes in the images' order. + * A read that fails, or a record without full metadata, counts as one image that could not be read. + */ +export async function collectImagesMetadata(images, read) { + const envelopes = new Array(images.length).fill(null); + let next = 0; + async function worker() { + while (next < images.length) { + const index = next++; + try { + envelopes[index] = metadataEnvelope(await read(images[index])); + } catch { + // Counted below with the records that carry no full metadata. + } + } + } + await Promise.all(Array.from({length:Math.min(EXPORT_CONCURRENCY, images.length)}, worker)); + const found = envelopes.filter(envelope => envelope !== null); + return {envelopes:found, failed:images.length - found.length}; +} + +/** The export's toast; with nothing exported there is no download. */ +export function exportOutcome(exported, total) { + if (!exported) return 'Metadata could not be read. Try again.'; + if (exported === total) return `Exported metadata for ${countOf(exported, 'image')}.`; + return `Exported metadata for ${exported} of ${countOf(total, 'image')}. ${total - exported} could not be read.`; +} diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs new file mode 100644 index 0000000..ec985c6 --- /dev/null +++ b/web/js/workspace/images-selection.mjs @@ -0,0 +1,148 @@ +import { createButton } from '../shared/toolkit/core.js'; +import { imageKey, toggleSelection, selectionLabel } from './images-state.mjs'; +import { inspectPath, toInspectionRecord } from './inspection-state.mjs'; +import { BUNDLE_FILENAME, metadataBundle, collectImagesMetadata, exportOutcome } from './images-export.mjs'; +import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_MAX_LENGTH, CATEGORY_NONE_LINKED, categoryOutcome, categoryTargets, updatedCount } from './images-category.mjs'; +import { openInputDialog } from './workspace-dialogs.mjs'; +import { showWorkspaceToast } from './toast.mjs'; + +const CHECKBOX = '.image-overlays input[type="checkbox"]'; +const CATEGORY_URL = '/prompt_manager/bulk/category'; + +function el(tag, text, className) { + const element = document.createElement(tag); + if (text !== undefined) element.textContent = text; + if (className) element.className = className; + return element; +} + +/** + * The Images page's checked cards and the concept's selection bar (#249 D8; app.js renderImages, updateBulk and the + * bulk handlers). `element` is the `#bulkbar` the page places between its filter row and gallery layout: empty while + * nothing is checked, else "N selected" and the real actions in the concept's order. The page renders each card's + * checkbox from `isChecked`; this module follows the boxes through the grid's `change` events. + * + * - `getItems()` is the listing in its order; `checked` restores a saved visit's selection. + * - `onCategorySet()` reloads the categories and the listing after a successful Set category (the reload clears the + * selection through `clear`); focus then moves to `focusFallback` if it was on the hidden action. + */ +export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, checked: initial = Object.freeze(new Set()) }) { + let checked = initial; + let exporting = false, categorizing = false; + const element = el('div'); + element.id = 'bulkbar'; + const bulk = el('div', undefined, 'bulkbar'), count = el('strong'); + const setCategory = createButton('Set category'); + scope.listen(setCategory, 'click', () => openCategory(setCategory)); + const exportMetadata = createButton('Export metadata'); + scope.listen(exportMetadata, 'click', () => exportChecked(exportMetadata)); + const clearButton = createButton('Clear'); + // Clear hides the bar it sits in, so focus moves to the page heading instead of falling to the page. + scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); }); + bulk.append(count, setCategory, exportMetadata, clearButton); + + /** The count updates in place: the same bar and `strong`, its text written only when it changes. */ + function render() { + if (!checked.size) { bulk.remove(); return; } + const label = selectionLabel(checked.size); + if (count.textContent !== label) count.textContent = label; + if (bulk.parentNode !== element) element.append(bulk); + } + function clear() { + if (!checked.size) return; + checked = Object.freeze(new Set()); + for (const box of grid.querySelectorAll(CHECKBOX)) box.checked = false; + render(); + } + function checkedImages() { + return getItems().filter(image => checked.has(imageKey(image))); + } + // Every card carries its image key; its only form control is the checkbox, so a toggle never opens or inspects. + scope.listen(grid, 'change', event => { + const box = event.target; + if (!box.matches(CHECKBOX)) return; + checked = toggleSelection(checked, box.closest('article[data-image-key]').dataset.imageKey, box.checked); + render(); + }); + + /** Busy until it settles: `aria-disabled` now, released in a `finally` so a throw cannot leave it stuck. */ + function busy(button) { + button.setAttribute('aria-disabled', 'true'); + return () => button.removeAttribute('aria-disabled'); + } + /** + * Export metadata: the checked images' inspector envelopes in listing order, as one JSON download. A second press + * while it runs is ignored and the selection is kept. Leaving the page aborts the reads: no download, no toast. + */ + async function exportChecked(button) { + if (exporting) return; + exporting = true; + const release = busy(button); + try { + const { envelopes, failed } = await collectImagesMetadata(checkedImages(), + async image => toInspectionRecord(await api.get(inspectPath(image), { signal: scope.signal }), image)); + if (scope.signal.aborted) return; + if (envelopes.length) downloadJson(BUNDLE_FILENAME, metadataBundle(envelopes, new Date().toISOString())); + showWorkspaceToast(exportOutcome(envelopes.length, envelopes.length + failed)); + } finally { + exporting = false; + release(); + } + } + /** A Blob, an anchor and a deferred revoke, as the concept's exportSample does. */ + function downloadJson(filename, value) { + const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' })); + let timer, released = false; + const release = () => { if (released) return; released = true; clearTimeout(timer); URL.revokeObjectURL(url); }; + scope.cleanup(release); + const anchor = el('a'); anchor.href = url; anchor.download = filename; + element.append(anchor); anchor.click(); anchor.remove(); + timer = setTimeout(release, 1000); + } + /** + * Set category (app.js bulk-category → simpleInput): images have no category, so Apply sets it on the selection's + * distinct linked positives, taken when the (modal) dialog opens. With no linked image there is nothing to change: + * a toast says so and no dialog opens. + */ + function openCategory(button) { + if (categorizing) return; + const targets = categoryTargets(checkedImages()); + if (!targets.promptIds.length) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; } + openInputDialog({ scope, trigger: button, title: CATEGORY_DIALOG.title, label: CATEGORY_DIALOG.label, + action: CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, onApply: category => applyCategory(button, targets, category) }); + } + /** + * The write carries no page signal: once sent it finishes even if the page is left (`writing` then tells the page + * not to save a visit that predates it). A failure keeps the selection; a success reloads through `onCategorySet`. + */ + async function applyCategory(button, { promptIds, skipped }, category) { + categorizing = true; + const release = busy(button); + let result = null; + try { + result = await api.post(CATEGORY_URL, { prompt_ids: promptIds, category }); + } catch { + // Reported below; the selection stays as it was. + } finally { + categorizing = false; + release(); + } + if (scope.signal.aborted) return; + if (!result) { showWorkspaceToast(CATEGORY_FAILED); return; } + showWorkspaceToast(categoryOutcome(updatedCount(result, promptIds.length), skipped)); + const focused = document.activeElement === button; + onCategorySet(); + // The reload hides the bar with the focused action in it, as Clear does: focus moves to the page heading. + if (focused) focusFallback.focus({ preventScroll: true }); + } + + render(); + return { + element, + isChecked: key => checked.has(key), + checked: () => checked, + clear, + /** Whether a Set category write is still in flight. */ + writing: () => categorizing, + }; +} diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs index 071ba63..150c819 100644 --- a/web/js/workspace/images-state.mjs +++ b/web/js/workspace/images-state.mjs @@ -230,3 +230,16 @@ export function cardSubtitle(image) { export function mediaRatio(image) { return hasSize(image) ? `${image.width}/${image.height}` : '4/3'; } + +/** The card checkboxes' selection (#249 D8): a new frozen set with `key` added or removed; unchanged, the same set. */ +export function toggleSelection(selected, key, on) { + if (selected.has(key) === on) return selected; + const next = new Set(selected); + if (on) next.add(key); else next.delete(key); + return Object.freeze(next); +} + +/** The concept selection bar's count (app.js updateBulk), grouped like the page's other counts. */ +export function selectionLabel(count) { + return `${count.toLocaleString('en-US')} selected`; +} diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index ba9af95..fe8afca 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -3,6 +3,7 @@ import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLab import { layoutImages, visibleImages, gridMetrics, MINIMUM_CARD_WIDTH } from './image-layout.mjs'; import { mountFileInspector } from './file-inspector.mjs'; import { createInspectionController } from './inspection-state.mjs'; +import { mountSelectionBar } from './images-selection.mjs'; import { createPromptSender } from './prompt-sender.mjs'; import { promptRoles } from './prompt-blocks.mjs'; import { createWorkspaceIcon } from './icons.mjs'; @@ -108,7 +109,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters }; filters.tags = [...filters.tags]; scope.cleanup(() => { - if (!embedded) lastVisit = state ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, scrollY: window.scrollY } : null; + // A Set category write still in flight may change what the saved collection shows: the return reloads instead. + if (!embedded) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null; cancelAnimationFrame(restoreFrame); }); main.classList.add('images-main'); @@ -209,6 +211,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage details.setAttribute('aria-label', 'Image details'); details.append(el('h2', 'Image details'), el('p', 'Select an image to inspect it.')); body.append(collection, details); + // The checked cards and the concept's selection bar (#249 D8), between the filter row and the gallery layout. The + // selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none. + const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state.items, focusFallback, + checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }}); + if (bar) main.append(bar.element); main.append(body); const mediaViewer = createMediaViewer({container:main,parentScope:scope,inspectionController:inspection,sender, onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);}, @@ -263,8 +270,16 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const open = createButton('', 'image-open'); open.setAttribute('aria-label', `Open ${image.filename}`); open.append(mediaElement(image, true)); - // D7: the star, wherever the listing reports favorites (not on Library-embedded media); D8: no checkbox yet. + // D8: the selection checkbox, then D7: the star, wherever the listing reports favorites (neither on + // Library-embedded media, which has no selection bar). const overlays = el('div', undefined, 'image-overlays'); + if (bar) { + const label = el('label'), box = el('input'); + box.type = 'checkbox'; box.checked = bar.isChecked(key); + box.setAttribute('aria-label', `Select ${image.filename}`); + label.append(box); + overlays.append(label); + } if (typeof image.favorite === 'boolean') { const star = createButton('', 'star-button'); star.setAttribute('aria-label', `Favorite ${image.filename}`); @@ -551,6 +566,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage } selection = null; focusAfterLoad = null; + // The ruled selection lifetime: a replaced collection may no longer hold the checked files. + bar?.clear(); for (const video of details.querySelectorAll('video')) releaseVideo(video); updateScope(); stream.reset(filters, {load:!recovering}); diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index c238678..606e9e2 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -10,6 +10,8 @@ * - "Import & inspect" (a1483–1491): only the real operations, Inspect a local image and Scan an output folder → * Rescan; no "Import portable recipes" (D15). Rescan shows its running state (aria-disabled "Rescanning…"). * - "Copy prompt" (a1223–1241): the select-to-copy fallback when the clipboard is unavailable (D22). + * - One-value input (a1138 `simpleInput`): a "Category"-style field (`input#action-input`) and Cancel / a primary + * action; used by the Images selection bar's "Set category" (#249 D8). * * Every opener takes the page `scope`: disposing it closes and removes the shell, so a dialog never outlives its page. */ @@ -257,3 +259,31 @@ export function openCopyFallback(text, { scope, trigger = null } = {}) { ], { scope, trigger, focusTarget: area }); area.select(); } + +/** + * The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary + * `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field. + * A positive `maxLength` limits the field. + */ +export function openInputDialog({ scope, title, label, action, value = '', maxLength, onApply, trigger = null }) { + requireScope(scope); + requireCallbacks({ onApply }); + for (const [name, text] of Object.entries({ title, label, action })) { + if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`); + } + const input = el('input'); + input.value = typeof value === 'string' ? value : ''; + if (Number.isSafeInteger(maxLength) && maxLength > 0) input.maxLength = maxLength; + const cancel = createButton('Cancel'); + const apply = createButton(action, 'ca-primary'); + cancel.addEventListener('click', () => finish()); + apply.addEventListener('click', () => { + const entered = input.value.trim(); + if (!entered) { input.focus(); return; } + finish(); + onApply(entered); + }); + const actions = el('div', undefined, 'dialog-actions'); + actions.append(cancel, apply); + show(title, [field(label, input, 'action-input'), actions], { scope, trigger, focusTarget: input }); +} diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 432ce51..790a620 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -68,9 +68,6 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } /* #249 D18: the concept zooms a hovered card image (1.03 over 0.2s; its reduced-motion rule makes that instant). A touch device has no real hover, so a tap must not leave the zoom stuck. */ @media (hover: none) { .image-open:hover img { transform: none; /* D18 */ } } -/* #249 D8 (interim until the checkbox follow-up PR): without the concept's selection checkbox the star is the overlay's - only child; this keeps it at the concept's top-right. The follow-up that ships the checkbox deletes this rule. */ -.image-overlays > .star-button:only-child { margin-left: auto; /* D8 */ } /* Images cards (#249 C3). #249 D3: bounded rendering mounts only the visible cards and places each one itself, in every layout (the concept places only masonry cards), so the grid reserves the collection's height and every card is