Compare commits

...
Author SHA1 Message Date
Vito Sansevero 963c671747 refactor(images): move the selection bar into images-selection.mjs and harden its actions
- images.mjs is back under 800 lines; the bar, its checked set, Export metadata and Set category live in images-selection.mjs.
- Busy flags release in finally; the dead guards are gone and the category targets are taken when the modal dialog opens.
- The category write no longer carries the page signal; leaving Images mid-write drops the saved visit so the return reloads.
- The categories and the listing reload together after Set category.
- The Category field stops at the server's 100 characters; the server's updated_count is a tested pure helper.
- New browser tests: leaving mid-export and mid-write, Blob URL revoke, updated_count 0 and a 400 rejection.
- Additive coverage baseline entries for images-category, images-export and images-selection.
- TESTING.md D8 records the dialog deviations and these rulings.
2026-10-20 15:37:51 -07:00
Vito Sansevero 4a5dade10d fix(api): answer 400 for bulk category ids beyond SQLite's INTEGER range
An id of 2**63 or more passed the parser and made sqlite3 raise OverflowError, which the route turned into a 500.
2026-10-20 15:37:50 -07:00
Vito Sansevero 92da267172 test(images): zoomed card keyboard order follows the concept's checkbox before the star
The card's Tab order is image, Select checkbox, star, Inspect (concept app.js:398), both ways; List hides the overlays as the concept does, so there the image is followed by Inspect.
2026-10-20 15:37:50 -07:00
Vito Sansevero 8784a38e40 feat(images): set the category of the selected images' linked prompts
The selection bar's Set category opens the concept's simpleInput dialog
("Categorize selected images", one Category field, Cancel / Apply) and
posts the selection's distinct linked positive ids to
/prompt_manager/bulk/category. Images have no category of their own; the
listing's category filter matches the linked positive's, so that is what
changes. With no linked image checked nothing is posted and a toast says
so; on success the toast counts the prompts and any skipped images, then
the categories and the listing reload (clearing the selection); a failure
keeps the selection. The button ignores a second press while it runs.

The bulk category route now rejects anything but a non-empty list of at
most 1000 positive integer ids (deduplicated in order) and validates the
category with validate_category; bad JSON or a non-object body is a 400.
The response shape and the legacy admin request are unchanged.
2026-10-20 14:27:46 -07:00
Vito Sansevero 5b3896bf26 feat(images): export the selected images' metadata as one JSON bundle
The selection bar's Export metadata reads each checked image's inspection,
at most four at a time, and downloads prompt-manager-images-metadata.json:
format, version 1, exported_at and, in listing order, the same envelope the
inspector's "Download metadata JSON" saves (one shared builder,
metadataEnvelope). The button is aria-disabled and ignores a second press
while it runs, the selection is kept, and the toast reports the exported
count, the images that could not be read, or that none could (no download).

Also: the bar and its count are built only on the Images page, the
Library-embedded gallery is asserted to have no checkbox or bar, a sort
change is asserted to clear the selection, the checkbox label test asserts
the toggle and an unchanged selection and inspector, and TESTING.md records
that a return to Images keeps the selection while a reload starts empty.
2026-10-20 14:11:47 -07:00
Vito Sansevero d7f1b9987f feat(images): card selection checkbox and the concept selection bar
Every Images card's overlay starts with the concept's "Select <filename>"
checkbox before the star. Checked image keys form the page's selection,
kept across Load more, infinite scroll and bounded re-mounting and cleared
whenever the collection is replaced. The #bulkbar between the filter row
and the gallery shows "N selected" with Set category, Export metadata and
Clear (the first two are wired by the next tasks); Clear empties it.

The interim D8 star rule is removed, and the concept comparison now
measures the overlay with its checkbox and the bar with one card checked.
2026-10-20 13:54:09 -07:00
21 changed files with 1136 additions and 54 deletions
+36 -7
View File
@@ -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 "<title> · <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
+43 -5
View File
@@ -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
+8 -3
View File
@@ -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,
+64 -18
View File
@@ -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)=>{
+381 -3
View File
@@ -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);
});
@@ -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();
+9 -4
View File
@@ -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');
@@ -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);
});
+76
View File
@@ -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.');
});
+19
View File
@@ -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');
});
+93
View File
@@ -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()
+21
View File
@@ -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
+18
View File
@@ -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,
+20 -9
View File
@@ -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;
+42
View File
@@ -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;
}
+40
View File
@@ -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.`;
}
+148
View File
@@ -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,
};
}
+13
View File
@@ -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`;
}
+19 -2
View File
@@ -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});
+30
View File
@@ -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 });
}
-3
View File
@@ -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