Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
963c671747 | ||
|
|
4a5dade10d | ||
|
|
92da267172 | ||
|
|
8784a38e40 | ||
|
|
5b3896bf26 | ||
|
|
d7f1b9987f |
+36
-7
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)=>{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user