* 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.
* 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.
* 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.
* 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.
* 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.
* 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.
77 lines
4.4 KiB
JavaScript
77 lines
4.4 KiB
JavaScript
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.');
|
|
});
|