* 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.
290 lines
12 KiB
JavaScript
290 lines
12 KiB
JavaScript
/**
|
||
* The concept's shared page dialogs in its one modal shell (docs/concepts/workspace/index.html `dialog#modal`:
|
||
* `.dialog-header` > `h2#modal-title` + `×` "Close dialog", then `#modal-body`). Opening a dialog while one is open
|
||
* swaps its content, as the concept's `dialog()` does. Closing, by ×, Escape or an action, returns focus to the
|
||
* control that opened it.
|
||
*
|
||
* - "Refine this view" (a1348–1364): Source / Folder / Media / Tags, each optional per page; "Apply filters" and no
|
||
* mock-status note because every field is a real filter, and no "Prompt evidence" field because evidence is only
|
||
* known per file (D14). Clear also clears the toolbar category, as the concept's does; the page applies it.
|
||
* - "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.
|
||
*/
|
||
import { createButton } from '../shared/toolkit/core.js';
|
||
|
||
const FIELD_LABELS = Object.freeze({ source: 'Source', folder: 'Folder', media: 'Media', tags: 'Tags' });
|
||
const FIELD_ORDER = Object.keys(FIELD_LABELS);
|
||
|
||
let shell = null;
|
||
let session = null;
|
||
|
||
function el(tag, text, className) {
|
||
const node = document.createElement(tag);
|
||
if (text !== undefined) node.textContent = text;
|
||
if (className) node.className = className;
|
||
return node;
|
||
}
|
||
|
||
function field(label, control, id) {
|
||
const wrapper = el('div', undefined, 'field');
|
||
const caption = el('label', label);
|
||
caption.htmlFor = id;
|
||
control.id = id;
|
||
wrapper.append(caption, control);
|
||
return wrapper;
|
||
}
|
||
|
||
function createShell() {
|
||
const dialog = el('dialog');
|
||
dialog.id = 'modal';
|
||
dialog.setAttribute('aria-labelledby', 'modal-title');
|
||
const header = el('div', undefined, 'dialog-header');
|
||
const title = el('h2');
|
||
title.id = 'modal-title';
|
||
const close = createButton('×');
|
||
close.id = 'modal-close';
|
||
close.setAttribute('aria-label', 'Close dialog');
|
||
const body = el('div');
|
||
body.id = 'modal-body';
|
||
header.append(title, close);
|
||
dialog.append(header, body);
|
||
close.addEventListener('click', () => finish());
|
||
dialog.addEventListener('cancel', event => {
|
||
event.preventDefault();
|
||
finish();
|
||
});
|
||
// A browser may still close a modal on its own; that is a dismissal too. `close` arrives as a later task, so it
|
||
// is ignored once this shell was reopened or replaced.
|
||
dialog.addEventListener('close', () => {
|
||
if (shell?.dialog === dialog && !dialog.open) finish();
|
||
});
|
||
return { dialog, title, body, close };
|
||
}
|
||
|
||
/** Close the open dialog, if any, and focus its trigger while that is still on the page. */
|
||
function finish() {
|
||
if (!session) return;
|
||
const { trigger } = session;
|
||
session = null;
|
||
if (shell?.dialog.open) shell.dialog.close();
|
||
if (trigger?.isConnected) trigger.focus();
|
||
}
|
||
|
||
const boundScopes = new WeakSet();
|
||
|
||
function requireScope(scope) {
|
||
if (typeof scope?.cleanup !== 'function') throw new TypeError('Workspace dialogs need the page scope');
|
||
}
|
||
|
||
function requireCallbacks(callbacks) {
|
||
for (const [name, callback] of Object.entries(callbacks)) {
|
||
if (typeof callback !== 'function') throw new TypeError(`The dialog needs ${name}`);
|
||
}
|
||
}
|
||
|
||
/** Disposing a page scope closes the shell unless a newer page's dialog is open in it. */
|
||
function bindScope(scope) {
|
||
if (boundScopes.has(scope)) return;
|
||
boundScopes.add(scope);
|
||
scope.cleanup(() => {
|
||
boundScopes.delete(scope);
|
||
if (!session || session.scope === scope) closeWorkspaceDialog();
|
||
});
|
||
}
|
||
|
||
function show(heading, nodes, { scope, trigger, focusTarget }) {
|
||
shell ??= createShell();
|
||
if (!shell.dialog.isConnected) document.body.append(shell.dialog);
|
||
bindScope(scope);
|
||
session = { scope, trigger: trigger ?? session?.trigger ?? null };
|
||
shell.title.textContent = heading;
|
||
shell.body.replaceChildren(...nodes);
|
||
if (!shell.dialog.open) shell.dialog.showModal();
|
||
(focusTarget ?? shell.close).focus();
|
||
}
|
||
|
||
/** Close any open workspace dialog and remove the shell; a page calls this when it unmounts. */
|
||
export function closeWorkspaceDialog() {
|
||
finish();
|
||
shell?.dialog.remove();
|
||
shell = null;
|
||
}
|
||
|
||
function fillSelect(select, key, options) {
|
||
if (!Array.isArray(options) || options.length === 0 ||
|
||
options.some(pair => !Array.isArray(pair) || typeof pair[0] !== 'string' || typeof pair[1] !== 'string')) {
|
||
throw new TypeError(`The ${key} filter needs [value, label] options`);
|
||
}
|
||
select.replaceChildren(...options.map(([value, label]) => {
|
||
const node = el('option', label);
|
||
node.value = value;
|
||
return node;
|
||
}));
|
||
}
|
||
|
||
function selectControl(key, options) {
|
||
const select = el('select');
|
||
fillSelect(select, key, options);
|
||
return select;
|
||
}
|
||
|
||
/** Show `value` when the select offers it, else its first (cleared) option. */
|
||
function choose(select, value) {
|
||
const known = [...select.options].some(option => option.value === value);
|
||
select.value = known ? value : select.options[0].value;
|
||
}
|
||
|
||
/**
|
||
* "Refine this view". `fields` lists the page's filters in any subset of Source, Folder, Media and Tags, as
|
||
* `{key, options}` with `[value, label]` options for the selects (the first is the cleared value) and no options for
|
||
* Tags; Folder may add `optionsFor(source)`, the options of one Source, so its list follows the chosen Source and a
|
||
* folder that Source lacks returns to the first option. `values` holds the current value per key. Apply reports the edited values; Clear resets every field and
|
||
* reports the cleared values plus `category: ''`, so the page clears its toolbar category too. Both close first.
|
||
*/
|
||
export function openRefineDialog({ scope, fields, values = {}, onApply, onClear, trigger = null }) {
|
||
requireScope(scope);
|
||
requireCallbacks({ onApply, onClear });
|
||
if (!Array.isArray(fields) || fields.some(item => !FIELD_ORDER.includes(item?.key))) {
|
||
throw new TypeError('Refine fields are source, folder, media and tags');
|
||
}
|
||
const controls = FIELD_ORDER.flatMap(key => {
|
||
const item = fields.find(candidate => candidate.key === key);
|
||
if (!item) return [];
|
||
if (key === 'tags') {
|
||
const input = el('input');
|
||
input.placeholder = 'e.g. cinematic';
|
||
input.value = typeof values.tags === 'string' ? values.tags : '';
|
||
return [{ key, control: input, cleared: '' }];
|
||
}
|
||
const select = selectControl(key, item.options);
|
||
choose(select, values[key]);
|
||
return [{ key, control: select, cleared: item.options[0][0], optionsFor: item.optionsFor }];
|
||
});
|
||
const source = controls.find(item => item.key === 'source');
|
||
const folder = controls.find(item => item.key === 'folder' && typeof item.optionsFor === 'function');
|
||
const followSource = () => {
|
||
const chosen = folder.control.value;
|
||
fillSelect(folder.control, 'folder', folder.optionsFor(source.control.value));
|
||
choose(folder.control, chosen);
|
||
};
|
||
if (source && folder) {
|
||
followSource();
|
||
source.control.addEventListener('change', followSource);
|
||
}
|
||
const read = () => Object.fromEntries(controls.map(({ key, control }) =>
|
||
[key, key === 'tags' ? control.value.trim() : control.value]));
|
||
|
||
const clear = createButton('Clear filters');
|
||
const apply = createButton('Apply filters', 'ca-primary');
|
||
clear.addEventListener('click', () => {
|
||
// The cleared Source's folders first, so the cleared Folder value is one of them.
|
||
if (source && folder) { source.control.value = source.cleared; followSource(); }
|
||
for (const { control, cleared } of controls) control.value = cleared;
|
||
const reset = read();
|
||
finish();
|
||
onClear({ ...reset, category: '' });
|
||
});
|
||
apply.addEventListener('click', () => {
|
||
const applied = read();
|
||
finish();
|
||
onApply(applied);
|
||
});
|
||
const actions = el('div', undefined, 'dialog-actions');
|
||
actions.append(clear, apply);
|
||
show('Refine this view', [
|
||
...controls.map(({ key, control }) => field(FIELD_LABELS[key], control, `filter-${key}`)),
|
||
actions,
|
||
], { scope, trigger });
|
||
}
|
||
|
||
/** One operation row; a press on its busy (aria-disabled) action is ignored and keeps the dialog open. */
|
||
function operation(title, description, label, onAction) {
|
||
const row = el('div', undefined, 'operation');
|
||
const text = el('div');
|
||
text.append(el('h3', title), el('p', description));
|
||
const actions = el('div', undefined, 'row-actions');
|
||
const action = createButton(label);
|
||
action.addEventListener('click', () => {
|
||
if (action.getAttribute('aria-disabled') === 'true') return;
|
||
finish();
|
||
onAction();
|
||
});
|
||
actions.append(action);
|
||
row.append(text, actions);
|
||
return { row, action };
|
||
}
|
||
|
||
const RESCAN_LABEL = 'Rescan';
|
||
const RESCAN_BUSY_LABEL = 'Rescanning…';
|
||
|
||
/**
|
||
* "Import & inspect" with the two real operations (D15). While `rescanBusy`, Rescan reads "Rescanning…" and is
|
||
* aria-disabled, so a second press is visibly ignored. Returns `{setRescanBusy}` for the page to end that state.
|
||
*/
|
||
export function openImportDialog({ scope, onInspect, onRescan, trigger = null, rescanBusy = false }) {
|
||
requireScope(scope);
|
||
requireCallbacks({ onInspect, onRescan });
|
||
const list = el('div', undefined, 'operation-list');
|
||
const inspect = operation('Inspect a local image', 'Read embedded metadata before deciding to add it.', 'Open inspector', onInspect);
|
||
const rescan = operation('Scan an output folder', 'Find files added or removed in the output folders.', RESCAN_LABEL, onRescan);
|
||
list.append(inspect.row, rescan.row);
|
||
function setRescanBusy(busy) {
|
||
rescan.action.textContent = busy ? RESCAN_BUSY_LABEL : RESCAN_LABEL;
|
||
if (busy) rescan.action.setAttribute('aria-disabled', 'true');
|
||
else rescan.action.removeAttribute('aria-disabled');
|
||
}
|
||
setRescanBusy(rescanBusy);
|
||
show('Import & inspect', [
|
||
el('p', 'Image inspection and output scanning have different effects. Choose the workflow you want.', 'small'),
|
||
list,
|
||
], { scope, trigger });
|
||
return { setRescanBusy };
|
||
}
|
||
|
||
/** "Copy prompt": the text in a readonly, pre-selected textarea for a manual copy. */
|
||
export function openCopyFallback(text, { scope, trigger = null } = {}) {
|
||
requireScope(scope);
|
||
if (typeof text !== 'string') throw new TypeError('Copy text must be a string');
|
||
const area = el('textarea');
|
||
area.readOnly = true;
|
||
area.value = text;
|
||
show('Copy prompt', [
|
||
field('Select and copy this text', area, 'copy-text'),
|
||
el('p', 'Automatic clipboard access requires a secure browser context.', 'small'),
|
||
], { 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 });
|
||
}
|