* feat(activity): image tagging jobs for bulk Add tags and Auto-tag
POST /prompt_manager/jobs/image-tags starts one record-changing
"image-tags" job over 1..500 listed images (202, or the same 409 busy
envelope as /jobs/scan). Add tags hashes each current file and unions a
fixed set into that byte record; Auto-tag captures each still image, asks
one legacy engine for its top N suggestions on the capture (count from
the options or autotag.defaults.suggestion_limit), re-hashes the file and
unions only onto the same bytes. Outcomes are grouped as tagged,
unchanged, skipped (video, too large, file changed, file not found, not a
still image) and errors; cancel stops at a file boundary.
Auto-tag holds the shared model gate for the whole job (409 model-busy at
start when it is held), loads the model once and never downloads one: an
unavailable model fails the job before the first file. The registry gains
blocker() so a start answers busy before taking the gate.
* feat(annotations): Add tags and Auto-tag selected in the selection bar
The Images selection bar gains the concept's Add tags and, beside it,
Auto-tag selected (D85). Add tags starts the image-tags Activity job with
the dialog's comma-separated names; Auto-tag selected reuses the Tag image
model panel (server defaults, Suggestions count) without Generate, refuses
a model that is not downloaded, and either starts the job (Apply
automatically) or walks the selection one Tag image dialog at a time
(Review each image, D90). Busy, model-busy and refused starts toast why;
the selection stays; a finished job's files are read again.
* feat(activity): show image tagging jobs with their own outcomes
Image tagging jobs get their own Activity view (D89): the pill reads
"Tagging", the item and heading line name the images and end with
"N tagged · M errors", and the job view shows Tagged, Unchanged, Skipped
and Errors tiles and outcomes, opens Tagged and Unchanged rows in the
viewer, and reads "Tagging log" and "Cancel tagging". The per-kind table
lives in activity-store.mjs; the scan's view is unchanged.
The selection bar's copy without a concept line is recorded as D117.
* fix(annotations): final review round for bulk image tagging
- Rename the D117 copy deviation to D118 (D117 belongs to another PR).
- The tagging job's progress line gives the scan's "about X min left"
estimate, with each finished file as one batch.
- The start body cap fits 500 targets of the longest legal path, even
escaped; one byte over answers 413.
- A capture store at capacity is retried with a short backoff, then the
file is skipped with a rerun sentence instead of a Tag-dialog error.
- Output roots and their labels are resolved once per job in the
executor, not twice per file on the event loop.
- Review each image holds Stop reviewing, Escape and x while a save is in
flight, and its primary reads "Save and next" when only the manual
field would be saved.
- An Add tags field that parses to no names keeps the dialog and its
text and reports the message on the field.
- Image tagging finish toasts carry the counts ("... finished · N tagged
· M errors").
- Specs: the bulk spec waits for the answered Start before checking that
the invalid press sent nothing; the Activity spec uses a real error row.
170 lines
8.8 KiB
JavaScript
170 lines
8.8 KiB
JavaScript
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 { openAddTags, openAutoTag } from './images-tagging-bulk.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.
|
|
* - `retain(kept)` drops the checked files a refreshed listing no longer holds (a tag save under a filter, I7).
|
|
* - With the Activity store `activity`, Add tags and Auto-tag selected (D85) follow the count, as the concept's Add tags
|
|
* leads its actions; `onTagged(keys)` re-reads the files a finished tagging job tagged. The selection stays.
|
|
*/
|
|
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged,
|
|
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);
|
|
if (activity) {
|
|
const addTags = createButton('Add tags');
|
|
scope.listen(addTags, 'click', () => openAddTags({ scope, store: activity, images: checkedImages(), trigger: addTags, onTagged }));
|
|
const autoTag = createButton('Auto-tag selected'); // D85
|
|
scope.listen(autoTag, 'click', () => openAutoTag({ api, scope, store: activity, images: checkedImages(), trigger: autoTag, onTagged }));
|
|
bulk.append(addTags, autoTag);
|
|
}
|
|
bulk.append(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();
|
|
}
|
|
/** Keep only the checked files `kept(key)` still lists (a refreshed result), so the count is what the actions apply to. */
|
|
function retain(kept) {
|
|
const next = [...checked].filter(kept);
|
|
if (next.length === checked.size) return;
|
|
checked = Object.freeze(new Set(next));
|
|
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,
|
|
retain,
|
|
/** Whether a Set category write is still in flight. */
|
|
writing: () => categorizing,
|
|
};
|
|
}
|