Merge remote-tracking branch 'origin/main' into feat/249-inspect-local-roles
This commit is contained in:
@@ -11,6 +11,9 @@
|
||||
* filter, and the selected outcome's listed rows (at most 200 per outcome, so "Showing N of M"). Rows under Added and
|
||||
* Already known open the file in the existing viewer through its listing identity (D57); the others are plain text.
|
||||
* - Log: `details.term` "Scan log", collapsed, holding the raw timestamped lines in `pre[tabindex=0]`.
|
||||
* - D89: the tiles, the outcome list, the openable rows and the log and cancel labels are the job kind's (`jobView`):
|
||||
* an image tagging job shows Tagged, Unchanged, Skipped and Errors, opens Tagged and Unchanged rows, and reads
|
||||
* "Tagging log" and "Cancel tagging"; another kind keeps the scan's tiles as "Job log" and "Cancel job".
|
||||
*
|
||||
* The summary follows the Activity store (one SSE stream per running job); the listed rows and the log are read from
|
||||
* `GET /prompt_manager/jobs/<id>` at most once a second while the job runs, and once more when it ends.
|
||||
@@ -19,7 +22,7 @@ import { createButton } from '../shared/toolkit/core.js';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { noun } from './plural.mjs';
|
||||
import { parseJob, JOBS_URL, LOST_DETAIL } from './activity-store.mjs';
|
||||
import { parseJob, jobView, JOB_VIEWS, JOBS_URL, LOST_DETAIL } from './activity-store.mjs';
|
||||
import { jobDetail, CANCEL_FAILED_TOAST } from './activity.mjs';
|
||||
import { downloadJobReport } from './activity-scan.mjs';
|
||||
import { createMediaViewer } from './media-viewer.mjs';
|
||||
@@ -36,18 +39,6 @@ export const FILE_GONE = 'This file is no longer in Images. Rescan from Import a
|
||||
export const OPEN_FAILED = 'The file could not be opened. Try again.';
|
||||
const LISTING_LIMIT = 5;
|
||||
|
||||
/** The counter tiles, in the spec's order and the mockup's tones. */
|
||||
export const TILES = Object.freeze([
|
||||
['added', 'Added to Library', 'added'], ['known', 'Already known', ''], ['review', 'Needs review', 'warn'],
|
||||
['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad'],
|
||||
]);
|
||||
/** The outcome list, in the mockup's order: what got added and what went wrong first. */
|
||||
export const OUTCOMES = Object.freeze([
|
||||
['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'],
|
||||
['no_metadata', 'No usable metadata'], ['known', 'Already known'],
|
||||
]);
|
||||
const OPENABLE = new Set(['added', 'known']);
|
||||
|
||||
function element(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
@@ -103,9 +94,9 @@ export function filterRows(rows, filter) {
|
||||
return rows.filter(row => `${row.root}/${row.path}`.toLocaleLowerCase().includes(wanted));
|
||||
}
|
||||
|
||||
/** The first outcome with files, so a view opens on what happened; Added when nothing has yet. */
|
||||
export function defaultOutcome(counters) {
|
||||
return OUTCOMES.find(([group]) => (counters[group] ?? 0) > 0)?.[0] ?? 'added';
|
||||
/** The first outcome with files, so a view opens on what happened; the first outcome when nothing has yet. */
|
||||
export function defaultOutcome(counters, outcomes = JOB_VIEWS.scan.outcomes) {
|
||||
return (outcomes.find(([group]) => (counters[group] ?? 0) > 0) ?? outcomes[0])[0];
|
||||
}
|
||||
|
||||
function section(title, id) {
|
||||
@@ -132,13 +123,6 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
bar.max = 100;
|
||||
const stats = element('dl', undefined, 'stat-row');
|
||||
const tileValues = new Map();
|
||||
for (const [group, label, tone] of TILES) {
|
||||
const tile = element('div', undefined, tone ? `stat ${tone}` : 'stat');
|
||||
const value = element('dd', '0');
|
||||
tile.append(element('dt', label), value);
|
||||
tileValues.set(group, value);
|
||||
stats.append(tile);
|
||||
}
|
||||
progressPart.node.append(bar, stats);
|
||||
|
||||
const resultsPart = section('Results', 'job-results-title');
|
||||
@@ -155,15 +139,6 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
list.setAttribute('aria-label', 'Outcomes');
|
||||
const files = element('div', undefined, 'outcome-files');
|
||||
const buttons = new Map();
|
||||
for (const [group, label] of OUTCOMES) {
|
||||
const button = createButton('');
|
||||
const count = element('span', '0');
|
||||
button.append(element('span', label), count);
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
button.count = count;
|
||||
buttons.set(group, button);
|
||||
list.append(button);
|
||||
}
|
||||
outcomes.append(list, files);
|
||||
resultsPart.node.append(outcomes);
|
||||
|
||||
@@ -181,6 +156,8 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
missing.append(element('p', NOT_FOUND, 'empty-state'), back);
|
||||
|
||||
let detail = null;
|
||||
// The job's kind decides its tiles, outcomes and labels (D89); it is known once the job is.
|
||||
let view = null;
|
||||
let selected = null;
|
||||
let mounted = false;
|
||||
let lastRead = 0;
|
||||
@@ -200,13 +177,35 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: inspection,
|
||||
sender: createPromptSender({ scope }), onSelect: image => inspection.select(image) });
|
||||
|
||||
/** The tiles and the outcome list of the job's kind, built once. */
|
||||
function layout(kind) {
|
||||
view = jobView(kind); // D89
|
||||
for (const [group, label, tone] of view.tiles) {
|
||||
const tile = element('div', undefined, tone ? `stat ${tone}` : 'stat');
|
||||
const value = element('dd', '0');
|
||||
tile.append(element('dt', label), value);
|
||||
tileValues.set(group, value);
|
||||
stats.append(tile);
|
||||
}
|
||||
for (const [group, label] of view.outcomes) {
|
||||
const button = createButton('');
|
||||
const count = element('span', '0');
|
||||
button.append(element('span', label), count);
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
button.count = count;
|
||||
buttons.set(group, button);
|
||||
list.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
function showBody(job) {
|
||||
if (mounted) return;
|
||||
mounted = true;
|
||||
if (!view) layout(job.kind);
|
||||
missing.remove();
|
||||
parts.actions.append(download, cancel);
|
||||
main.append(progressPart.node, resultsPart.node, log);
|
||||
selected ??= defaultOutcome(job.counters);
|
||||
selected ??= defaultOutcome(job.counters, view.outcomes);
|
||||
}
|
||||
|
||||
/** The job is not (or no longer) in this session: say so instead of an empty view. */
|
||||
@@ -242,7 +241,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
|
||||
function renderRows(job) {
|
||||
const rows = detail?.outcomes[selected] ?? [];
|
||||
const openable = OPENABLE.has(selected);
|
||||
const openable = view.openable.has(selected);
|
||||
const wanted = filterRows(rows.map((row, index) => ({ ...row, index })), input.value);
|
||||
const kept = new Map(wanted.map(row => rowNode(row, row.index, openable)));
|
||||
rowNodes = kept;
|
||||
@@ -270,7 +269,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
parts.h1.textContent = job.title;
|
||||
document.title = `${job.title} · Prompt Manager`;
|
||||
parts.description.textContent = jobDetail(job);
|
||||
cancel.textContent = scan ? 'Cancel scan' : 'Cancel job';
|
||||
cancel.textContent = view.cancel;
|
||||
// A job lost with the server has no report there (review M9).
|
||||
const lost = job.state === 'lost';
|
||||
if (lost && document.activeElement === download) parts.h1.focus();
|
||||
@@ -291,7 +290,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
|
||||
button.count.textContent = number(job.counters[group] ?? 0);
|
||||
button.setAttribute('aria-pressed', String(group === selected));
|
||||
}
|
||||
logLabel.textContent = `● ${scan ? 'Scan' : 'Job'} log`;
|
||||
logLabel.textContent = `● ${view.log}`;
|
||||
const text = detail?.log.length ? detail.log.join('\n') : 'No log lines yet.';
|
||||
if (pre.textContent !== text) {
|
||||
// Replacing the text clamps the scroll position, so keep it: pinned readers follow the end, others stay.
|
||||
|
||||
@@ -23,10 +23,52 @@ export const LOST_DETAIL = 'This job ended when ComfyUI restarted.';
|
||||
const SERVER_STATES = new Set(['running', 'finished', 'cancelled', 'failed']);
|
||||
const FINAL_STATES = new Set(['finished', 'cancelled', 'failed']);
|
||||
const JOB_ID = /^[0-9a-f]{32}$/;
|
||||
// The pill's verb for each job kind; a kind without one shows its title.
|
||||
const PILL_VERBS = Object.freeze({ scan: 'Scanning' });
|
||||
// The pill's verb for each job kind; a kind without one shows its title (D89: image tagging jobs read "Tagging").
|
||||
const PILL_VERBS = Object.freeze({ scan: 'Scanning', 'image-tags': 'Tagging' });
|
||||
const START_TOASTS = Object.freeze({ scan: 'Scan started.' });
|
||||
|
||||
/** The scan's job view (D56): its counter tiles `[group, label, tone]` and outcome list in the mockup's orders. */
|
||||
const SCAN_VIEW = Object.freeze({
|
||||
tiles: Object.freeze([
|
||||
['added', 'Added to Library', 'added'], ['known', 'Already known', ''], ['review', 'Needs review', 'warn'],
|
||||
['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad'],
|
||||
].map(Object.freeze)),
|
||||
// What got added and what went wrong first.
|
||||
outcomes: Object.freeze([
|
||||
['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'],
|
||||
['no_metadata', 'No usable metadata'], ['known', 'Already known'],
|
||||
].map(Object.freeze)),
|
||||
// Rows that open the file in the viewer (D57); the others are plain text.
|
||||
openable: Object.freeze(new Set(['added', 'known'])),
|
||||
log: 'Scan log',
|
||||
cancel: 'Cancel scan',
|
||||
});
|
||||
|
||||
/**
|
||||
* Each job kind's view: its tiles, outcome list, openable rows, log label and cancel label. D89: an image tagging job
|
||||
* (`image-tags`) counts Tagged (green), Unchanged, Skipped (warning) and Errors (red), lists what got tagged and what
|
||||
* went wrong first, and opens Tagged and Unchanged rows in the viewer like the scan's (D57).
|
||||
*/
|
||||
export const JOB_VIEWS = Object.freeze({
|
||||
scan: SCAN_VIEW,
|
||||
'image-tags': Object.freeze({
|
||||
tiles: Object.freeze([['tagged', 'Tagged', 'added'], ['unchanged', 'Unchanged', ''], ['skipped', 'Skipped', 'warn'],
|
||||
['errors', 'Errors', 'bad']].map(Object.freeze)),
|
||||
outcomes: Object.freeze([['tagged', 'Tagged'], ['errors', 'Errors'], ['skipped', 'Skipped'],
|
||||
['unchanged', 'Unchanged']].map(Object.freeze)),
|
||||
openable: Object.freeze(new Set(['tagged', 'unchanged'])),
|
||||
log: 'Tagging log',
|
||||
cancel: 'Cancel tagging',
|
||||
}),
|
||||
});
|
||||
// Another kind keeps the scan's tiles and outcomes under the generic labels.
|
||||
const GENERIC_VIEW = Object.freeze({ ...SCAN_VIEW, log: 'Job log', cancel: 'Cancel job' });
|
||||
|
||||
/** The view of a job `kind`; an unknown kind gets the generic one. */
|
||||
export function jobView(kind) {
|
||||
return Object.hasOwn(JOB_VIEWS, kind) ? JOB_VIEWS[kind] : GENERIC_VIEW;
|
||||
}
|
||||
|
||||
/** The wait before the `attempt`-th re-read (0-based): 1 s, 2 s, 4 s … never more than 30 s. */
|
||||
export function backoffDelay(attempt) {
|
||||
const step = Number.isSafeInteger(attempt) && attempt > 0 ? Math.min(attempt, 16) : 0;
|
||||
@@ -70,20 +112,31 @@ const errorsOf = job => `${number(job.counters.errors ?? 0)} ${noun(job.counters
|
||||
/** "1,420 added". */
|
||||
export const addedOf = job => `${number(job.counters.added ?? 0)} added`;
|
||||
|
||||
/** The toast when a job ends: "Scan finished · N added · M errors", "Scan cancelled · N added". */
|
||||
/**
|
||||
* The toast when a job ends: "Scan finished · N added · M errors", "Scan cancelled · N added"; an image tagging job
|
||||
* the same way with its own title and count (D89): "Auto-tag images finished · N tagged · M errors".
|
||||
*/
|
||||
export function finishToast(job) {
|
||||
if (job.kind === 'scan') {
|
||||
if (job.state === 'cancelled') return `Scan cancelled · ${addedOf(job)}`;
|
||||
if (job.state === 'failed') return `Scan failed · ${addedOf(job)} · ${errorsOf(job)}`;
|
||||
return `Scan finished · ${addedOf(job)} · ${errorsOf(job)}`;
|
||||
}
|
||||
if (job.kind === 'image-tags') {
|
||||
if (job.state === 'cancelled') return `${job.title} cancelled · ${taggedOf(job)}`;
|
||||
return `${job.title} ${job.state === 'failed' ? 'failed' : 'finished'} · ${outcomeLine(job)}`;
|
||||
}
|
||||
if (job.state === 'cancelled') return `${job.title} was cancelled.`;
|
||||
if (job.state === 'failed') return `${job.title} failed.`;
|
||||
return `${job.title} finished.`;
|
||||
}
|
||||
|
||||
// D85: an image tagging job says which of its two modes started.
|
||||
const IMAGE_TAG_START_TOASTS = Object.freeze({ add: 'Tagging started.', auto: 'Auto-tagging started.' });
|
||||
|
||||
/** The toast after this tab starts a job. */
|
||||
export function startToast(job) {
|
||||
if (job.kind === 'image-tags' && IMAGE_TAG_START_TOASTS[job.scope.mode]) return IMAGE_TAG_START_TOASTS[job.scope.mode];
|
||||
return START_TOASTS[job.kind] ?? `${job.title} started.`;
|
||||
}
|
||||
|
||||
@@ -94,9 +147,14 @@ export function pillParts(job) {
|
||||
return { label, files, text: label + files };
|
||||
}
|
||||
|
||||
/** The scan's counters as one line: "N added · M errors"; other kinds have none yet. */
|
||||
/** "12 tagged" (D89). */
|
||||
export const taggedOf = job => `${number(job.counters.tagged ?? 0)} tagged`;
|
||||
|
||||
/** The counters as one line: "N added · M errors" for a scan, "N tagged · M errors" for image tagging (D89). */
|
||||
export function outcomeLine(job) {
|
||||
return job.kind === 'scan' ? `${addedOf(job)} · ${errorsOf(job)}` : '';
|
||||
if (job.kind === 'scan') return `${addedOf(job)} · ${errorsOf(job)}`;
|
||||
if (job.kind === 'image-tags') return `${taggedOf(job)} · ${errorsOf(job)}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
function defaultConnect(url) {
|
||||
|
||||
@@ -11,13 +11,14 @@
|
||||
* - D54: each `.activity-item` is a real job. Its chip names the state (green "Completed", or blue "Running",
|
||||
* "Cancelled", "Failed", "Ended"), its line says when and what, and a running job shows its `<progress>` and
|
||||
* "Cancel job" (no "Simulate completion").
|
||||
* - D89: an image tagging job's line names its images and, once ended, "N tagged · M errors".
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { navigateTo } from './state.mjs';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { countOf } from './plural.mjs';
|
||||
import { pillParts, outcomeLine, addedOf, LOST_DETAIL } from './activity-store.mjs';
|
||||
import { countOf, noun } from './plural.mjs';
|
||||
import { pillParts, outcomeLine, addedOf, taggedOf, LOST_DETAIL } from './activity-store.mjs';
|
||||
|
||||
export const ACTIVITY_DESCRIPTION = 'Scans, tagging, downloads and maintenance share one history.';
|
||||
export const EMPTY_ACTIVITY = 'No jobs have run since ComfyUI started. Scans and other long operations appear here.';
|
||||
@@ -44,12 +45,21 @@ export function clockTime(seconds) {
|
||||
|
||||
const join = parts => parts.filter(Boolean).join(' · ');
|
||||
|
||||
/** What a job covers: "2 output folders" for a scan. */
|
||||
const whole = value => Number.isSafeInteger(value) && value >= 0;
|
||||
|
||||
/** What a job covers: "2 output folders" for a scan, "4 images" for image tagging (D89). */
|
||||
function scopeLine(job) {
|
||||
// D89: grouped as the server writes its lines ("19,622 images").
|
||||
const images = job.scope?.images;
|
||||
if (job.kind === 'image-tags') return whole(images) ? `${images.toLocaleString('en-US')} ${noun(images, 'image')}` : '';
|
||||
const folders = job.scope?.output_folders;
|
||||
return Number.isSafeInteger(folders) && folders >= 0 ? countOf(folders, 'output folder') : '';
|
||||
return whole(folders) ? countOf(folders, 'output folder') : '';
|
||||
}
|
||||
|
||||
/** What a cancelled job kept: "N added" for a scan, "N tagged" for image tagging (D89). */
|
||||
const KEPT = Object.freeze({ __proto__: null, scan: addedOf, 'image-tags': taggedOf }); // D89
|
||||
const keptOf = job => KEPT[job.kind]?.(job) ?? '';
|
||||
|
||||
/** The item's line under its title, by state. */
|
||||
export function jobDetail(job) {
|
||||
const at = clockTime(job.finished_at);
|
||||
@@ -58,8 +68,7 @@ export function jobDetail(job) {
|
||||
}
|
||||
if (job.state === 'lost') return LOST_DETAIL;
|
||||
if (job.state === 'cancelled') {
|
||||
return join([`Cancelled ${at} at a file boundary`, job.kind === 'scan' ? addedOf(job) : '',
|
||||
'completed work is kept']);
|
||||
return join([`Cancelled ${at} at a file boundary`, keptOf(job), 'completed work is kept']);
|
||||
}
|
||||
if (job.state === 'failed') return join([`Stopped on an error at ${at}`, outcomeLine(job)]);
|
||||
return join([`Finished ${at}`, outcomeLine(job)]);
|
||||
|
||||
@@ -128,7 +128,8 @@ export function optionError(model, options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function requestOptions(model, options) {
|
||||
/** The options a request sends for `model`: numbers parsed, only the model's own keys. */
|
||||
export function requestOptions(model, options) {
|
||||
const body = {suggestion_limit: numberOf(options.suggestion_limit)};
|
||||
if (modelOf(model).classifier) {
|
||||
return {general_threshold: numberOf(options.general_threshold), character_threshold: numberOf(options.character_threshold), ...body};
|
||||
@@ -177,28 +178,48 @@ function validateReceipt(value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Thresholds, instructions and suggestion count from the server, else the documented defaults. */
|
||||
/**
|
||||
* Thresholds, instructions and suggestion count from the server, else the documented defaults, and the models'
|
||||
* download status (`models`, null when unknown).
|
||||
*/
|
||||
async function readDefaults(api, signal) {
|
||||
try {
|
||||
const value = await api.get(MODELS_URL, {signal, cache: 'no-store'});
|
||||
if (!object(value)) return DEFAULTS;
|
||||
if (!object(value)) return {defaults: DEFAULTS, models: null};
|
||||
return {
|
||||
general_threshold: unit(value.wd14_general_threshold) ? value.wd14_general_threshold : DEFAULTS.general_threshold,
|
||||
character_threshold: unit(value.wd14_character_threshold) ? value.wd14_character_threshold : DEFAULTS.character_threshold,
|
||||
instructions: typeof value.default_prompt === 'string' ? value.default_prompt : DEFAULTS.instructions,
|
||||
suggestion_limit: limit(value.suggestion_limit) ? value.suggestion_limit : DEFAULTS.suggestion_limit,
|
||||
defaults: {
|
||||
general_threshold: unit(value.wd14_general_threshold) ? value.wd14_general_threshold : DEFAULTS.general_threshold,
|
||||
character_threshold: unit(value.wd14_character_threshold) ? value.wd14_character_threshold : DEFAULTS.character_threshold,
|
||||
instructions: typeof value.default_prompt === 'string' ? value.default_prompt : DEFAULTS.instructions,
|
||||
suggestion_limit: limit(value.suggestion_limit) ? value.suggestion_limit : DEFAULTS.suggestion_limit,
|
||||
},
|
||||
models: object(value.models) ? value.models : null,
|
||||
};
|
||||
} catch {
|
||||
// The defaults keep the dialog usable; Generate reports a real failure itself.
|
||||
return DEFAULTS;
|
||||
return {defaults: DEFAULTS, models: null};
|
||||
}
|
||||
}
|
||||
|
||||
function initialState(source) {
|
||||
const UNAVAILABLE_HINT = 'Download it from the legacy Auto-tag page.';
|
||||
/**
|
||||
* Why `model` cannot run, from the `/autotag/models` status (the server's own `model-unavailable` sentence), else ''
|
||||
* (downloaded, or the status is unknown: the job then reports a missing model itself).
|
||||
*/
|
||||
export function modelUnavailable(models, model) {
|
||||
const option = modelOf(model);
|
||||
requireValue(option, 'Unknown tagging model');
|
||||
const status = object(models) && object(models[model]) ? models[model] : null;
|
||||
if (!status || status.downloaded !== false) return '';
|
||||
const name = typeof status.name === 'string' && status.name.trim() ? status.name : option.label.split(' · ')[0];
|
||||
return `${name} is not downloaded. ${UNAVAILABLE_HINT}`;
|
||||
}
|
||||
|
||||
function initialState(source, preset) {
|
||||
return {
|
||||
phase: 'observing', closed: false, source, showScopeNote: source === 'review',
|
||||
image: null, savedTags: [], tagRevision: 0, currentTags: null, manual: '',
|
||||
model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, generated: false, suggestions: [],
|
||||
model: preset?.model ?? MODEL_OPTIONS[0].id, options: {...DEFAULTS, ...preset?.options}, generated: false, suggestions: [],
|
||||
suggestionsText: COPY.modelChanged, statusText: '', generateSupported: false, generateReason: null, toast: null,
|
||||
// Bumped whenever the controller itself rewrites the option fields or the manual draft, never while typing.
|
||||
optionsRevision: 0, draftRevision: 0,
|
||||
@@ -219,13 +240,31 @@ function derive(state) {
|
||||
};
|
||||
}
|
||||
|
||||
export function createImageTaggingController({api, target, source = 'images', onLateSave = () => {}} = {}) {
|
||||
/** A `{model, options}` preset (the bulk review's choice) when valid, else a TypeError. */
|
||||
function validatePreset(preset) {
|
||||
if (preset === null || preset === undefined) return null;
|
||||
requireValue(object(preset) && modelOf(preset.model) && object(preset.options), 'Invalid tagging preset');
|
||||
const options = {};
|
||||
for (const [key, value] of Object.entries(preset.options)) {
|
||||
requireValue(OPTION_KEYS.has(key), 'Unknown tagging option');
|
||||
options[key] = value;
|
||||
}
|
||||
requireValue(!optionError(preset.model, {...DEFAULTS, ...options}), 'Invalid tagging preset');
|
||||
return Object.freeze({model: preset.model, options: Object.freeze(options)});
|
||||
}
|
||||
|
||||
/**
|
||||
* `preset` ({model, options}, D90's review session) starts the dialog on that model, and every observation applies
|
||||
* those options over the server's defaults.
|
||||
*/
|
||||
export function createImageTaggingController({api, target, source = 'images', onLateSave = () => {}, preset = null} = {}) {
|
||||
requireValue(object(api) && ['get', 'post', 'put', 'del'].every(name => typeof api[name] === 'function'), 'An api client is required');
|
||||
const imageTarget = validateTarget(target);
|
||||
requireValue(SOURCES.has(source), 'Unknown tagging source');
|
||||
requireValue(typeof onLateSave === 'function', 'onLateSave must be a function');
|
||||
const chosen = validatePreset(preset);
|
||||
|
||||
let state = freeze(derive(initialState(source)));
|
||||
let state = freeze(derive(initialState(source, chosen)));
|
||||
const listeners = new Set();
|
||||
let closed = false;
|
||||
let capture = null;
|
||||
@@ -297,7 +336,7 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
}
|
||||
capture = {token: value.capture.token, sha256: value.capture.sha256};
|
||||
epoch = value.library_epoch;
|
||||
defaults = await defaultsRead;
|
||||
({defaults} = await defaultsRead);
|
||||
if (closed || serial !== openSerial) return;
|
||||
observedOnce = true;
|
||||
const {width, height, media_type: mediaType, preview_url: previewUrl, generate} = value.capture;
|
||||
@@ -311,7 +350,7 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
savedTags: value.tags.slice(), tagRevision: value.tag_revision,
|
||||
manual: draft === null ? value.tags.join(', ') : changed ? mergeCurrent(value.tags, draft) : draft,
|
||||
draftRevision: state.draftRevision + 1,
|
||||
options: {...defaults}, optionsRevision: state.optionsRevision + 1,
|
||||
options: {...defaults, ...chosen?.options}, optionsRevision: state.optionsRevision + 1,
|
||||
generateSupported: generate.supported, generateReason: generate.reason,
|
||||
statusText: changed ? `${COPY.changedElsewhere} ${savedNow(value.tags)}${reason ? ` ${reason}` : ''}` : reason,
|
||||
});
|
||||
@@ -482,3 +521,66 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, cancel, dispose,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The model and options of the bulk "Auto-tag selected images" dialog (D85): the same fields as the Tag image dialog's
|
||||
* model panel, without an image. open() reads the server's defaults and the models' download status once; until it
|
||||
* lands Start waits (`canStart` false). `unavailable` is why the chosen model cannot run ('' when it can), so the
|
||||
* dialog refuses to start a job that would fail at once.
|
||||
*/
|
||||
export function createTaggingOptionsController({api} = {}) {
|
||||
requireValue(object(api) && typeof api.get === 'function', 'An api client is required');
|
||||
const listeners = new Set();
|
||||
let closed = false;
|
||||
let defaults = DEFAULTS;
|
||||
let models = null;
|
||||
let abort = null;
|
||||
const deriveOptions = value => ({...value, canGenerate: false, unavailable: modelUnavailable(models, value.model),
|
||||
canStart: value.phase === 'ready' && !modelUnavailable(models, value.model)});
|
||||
let state = freeze(deriveOptions({phase: 'loading', model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, optionsRevision: 0}));
|
||||
|
||||
function set(patch) {
|
||||
if (closed) return;
|
||||
state = freeze(deriveOptions({...state, ...patch}));
|
||||
for (const listener of [...listeners]) listener(state);
|
||||
}
|
||||
async function open() {
|
||||
if (closed || abort) return;
|
||||
abort = new AbortController();
|
||||
const read = await readDefaults(api, abort.signal);
|
||||
if (closed) return;
|
||||
({defaults, models} = read);
|
||||
set({phase: 'ready', options: {...defaults}, optionsRevision: state.optionsRevision + 1});
|
||||
}
|
||||
function setModel(id) {
|
||||
requireValue(modelOf(id), 'Unknown tagging model');
|
||||
if (closed || id === state.model) return;
|
||||
// As in the Tag image dialog: the model's fields start from the defaults again; the count is kept (D91).
|
||||
set({model: id, optionsRevision: state.optionsRevision + 1,
|
||||
options: {...state.options, general_threshold: defaults.general_threshold,
|
||||
character_threshold: defaults.character_threshold, instructions: defaults.instructions}});
|
||||
}
|
||||
function setOption(key, value) {
|
||||
requireValue(OPTION_KEYS.has(key), 'Unknown tagging option');
|
||||
set({options: {...state.options, [key]: value}});
|
||||
}
|
||||
/** `{model_type, options}` for a job or a review session, or null while the options are invalid. */
|
||||
function request() {
|
||||
if (optionError(state.model, state.options)) return null;
|
||||
return {model_type: state.model, options: requestOptions(state.model, state.options)};
|
||||
}
|
||||
function dispose() {
|
||||
closed = true;
|
||||
listeners.clear();
|
||||
abort?.abort();
|
||||
}
|
||||
function subscribe(listener) {
|
||||
requireValue(typeof listener === 'function', 'A listener function is required');
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
return {
|
||||
get state() {return state;},
|
||||
subscribe, open, setModel, setOption, request, dispose,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,10 +101,12 @@ function numberInput(value, {min, max, step}) {
|
||||
|
||||
/**
|
||||
* The concept's `.tag-model-panel` (a1049): `heading`, the Tagging model select, the model's options and Generate.
|
||||
* It follows `controller` until `scope` is disposed (reused by the bulk Auto-tag dialog). Returns the `element` and
|
||||
* `requestGenerate()`, Generate's own path (field validity first), for a Retry after a failed Generate.
|
||||
* It follows `controller` until `scope` is disposed (reused by the bulk Auto-tag dialog, D85, without Generate:
|
||||
* `generate: false`). Returns the `element`, `validate()` (the fields' native validity, then what the controller would
|
||||
* refuse; true when valid, else the first invalid field reports) and `requestGenerate()`, Generate's own path (field
|
||||
* validity first), for a Retry after a failed Generate.
|
||||
*/
|
||||
export function renderModelPanel({scope, controller, heading = 'Auto-tag this image'}) {
|
||||
export function renderModelPanel({scope, controller, heading = 'Auto-tag this image', generate: withGenerate = true}) {
|
||||
const panel = el('div', undefined, 'tag-model-panel');
|
||||
const select = el('select');
|
||||
for (const option of MODEL_OPTIONS) {
|
||||
@@ -116,7 +118,8 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im
|
||||
options.id = 'image-tag-model-options';
|
||||
const generate = createButton('', 'ca-primary');
|
||||
generate.append(createWorkspaceIcon('tagImage'), ' Generate suggestions');
|
||||
panel.append(el('h3', heading), field('Tagging model', select, 'image-tag-model'), options, generate);
|
||||
panel.append(el('h3', heading), field('Tagging model', select, 'image-tag-model'), options);
|
||||
if (withGenerate) panel.append(generate);
|
||||
const inputs = new Map();
|
||||
let shownModel = null, shownOptions = null;
|
||||
|
||||
@@ -161,26 +164,28 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im
|
||||
setDisabled(generate, !state.canGenerate);
|
||||
}
|
||||
select.addEventListener('change', () => controller.setModel(select.value));
|
||||
function requestGenerate() {
|
||||
function validate() {
|
||||
const {state} = controller;
|
||||
if (!state.canGenerate) return;
|
||||
// The concept's native validity first (a1104–1114), then what the controller would refuse (a blank field).
|
||||
for (const input of inputs.values()) input.setCustomValidity('');
|
||||
const invalid = [...inputs.values()].find(input => !input.checkValidity());
|
||||
if (invalid) { invalid.reportValidity(); return; }
|
||||
if (invalid) { invalid.reportValidity(); return false; }
|
||||
const error = optionError(state.model, state.options);
|
||||
if (error) {
|
||||
const input = inputs.get(error.field);
|
||||
input.setCustomValidity(error.message);
|
||||
input.reportValidity();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
controller.generate();
|
||||
return true;
|
||||
}
|
||||
function requestGenerate() {
|
||||
if (controller.state.canGenerate && validate()) controller.generate();
|
||||
}
|
||||
generate.addEventListener('click', () => { if (!disabled(generate)) requestGenerate(); });
|
||||
scope.cleanup(controller.subscribe(update));
|
||||
update(controller.state);
|
||||
return {element: panel, requestGenerate};
|
||||
return {element: panel, requestGenerate, validate};
|
||||
}
|
||||
|
||||
function previewOf(image, state) {
|
||||
@@ -192,20 +197,33 @@ function previewOf(image, state) {
|
||||
|
||||
/**
|
||||
* Open "Tag image" for one listed `image` (its tagging target). `source` is the page (`images`, `library`, `review`,
|
||||
* `tags`); `onSaved(event)` runs after a confirmed save, as every `onImageTagsSaved` listener does.
|
||||
* `tags`, `bulk`); `onSaved(event)` runs after a confirmed save, as every `onImageTagsSaved` listener does.
|
||||
*
|
||||
* D90 (the bulk review session, `images-tagging-review.mjs`): `actions: 'review'` with `position: {index, total}`
|
||||
* titles the dialog "Tag image · Image <index> of <total>" and makes its actions Stop reviewing, Skip and Approve and
|
||||
* next (Approve and finish on the last image). `preset` ({model, options}) applies the session's model and options,
|
||||
* and `autoGenerate` runs Generate as soon as the file is observed. Approve saves as Save does, without the per-image
|
||||
* toast (the session toasts its count), and calls `onSaved`; Skip writes nothing and calls `onSkip`; Stop reviewing,
|
||||
* Escape and × write nothing and call `onStop` once. Swapping the next image's dialog in is not a stop.
|
||||
*/
|
||||
export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null}) {
|
||||
export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null, preset = null,
|
||||
autoGenerate = false, actions: mode = 'tag', position = null, onSkip = null, onStop = null}) {
|
||||
const target = imageTarget(image);
|
||||
if (!target) throw new TypeError('The image has no tagging target');
|
||||
const review = mode === 'review';
|
||||
if (review && !(Number.isSafeInteger(position?.index) && Number.isSafeInteger(position?.total) &&
|
||||
position.index >= 1 && position.index <= position.total)) throw new TypeError('A review needs its position');
|
||||
// Set once this dialog hands over to the session (Approve, Skip): its close is then not a Stop.
|
||||
let advancing = false;
|
||||
const saved = ({filename: name, tags, toast}) => {
|
||||
const event = {target, filename: name, tags, source};
|
||||
announceSaved(event);
|
||||
if (toast) showWorkspaceToast(toast);
|
||||
onSaved?.(event);
|
||||
showWorkspaceToast(toast);
|
||||
};
|
||||
// A save that settles after the dialog closed is still reported: its toast, and the pages' refresh (I5).
|
||||
const onLateSave = outcome => (outcome.saved ? saved(outcome) : showWorkspaceToast(outcome.message));
|
||||
const controller = createImageTaggingController({api, target, source, onLateSave});
|
||||
const controller = createImageTaggingController({api, target, source, onLateSave, preset});
|
||||
const local = createScope();
|
||||
let open = true;
|
||||
let completed = false;
|
||||
@@ -253,9 +271,15 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
|
||||
// 7. The actions; Retry leads them only while the controller allows it.
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
const retry = createButton('Retry');
|
||||
const cancel = createButton('Cancel');
|
||||
const cancel = createButton(review ? 'Stop reviewing' : 'Cancel'); // D90
|
||||
const skip = createButton('Skip');
|
||||
const primary = createButton('Save manual tags', 'ca-primary');
|
||||
actions.append(cancel, primary);
|
||||
const last = review && position.index === position.total;
|
||||
// D90: Approve with suggestions on screen; after a model change cleared them (M2) Save keeps only the manual field.
|
||||
const reviewLabel = state => (state.generated ? (last ? 'Approve and finish' : 'Approve and next')
|
||||
: (last ? 'Save and finish' : 'Save and next'));
|
||||
if (review) actions.append(cancel, skip, primary);
|
||||
else actions.append(cancel, primary);
|
||||
|
||||
const nodes = [heading];
|
||||
if (controller.state.showScopeNote) nodes.push(el('p', SCOPE_NOTE, 'tag-scope-note small'));
|
||||
@@ -301,6 +325,12 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
|
||||
}
|
||||
function complete(state) {
|
||||
completed = true;
|
||||
if (review) {
|
||||
// D90: the session swaps the next image in (or ends), so this dialog is not closed here.
|
||||
advancing = true;
|
||||
saved({filename: state.image.filename, tags: state.savedTags.slice(), toast: null});
|
||||
return;
|
||||
}
|
||||
closeWorkspaceDialog();
|
||||
saved({filename: state.image.filename, tags: state.savedTags.slice(), toast: state.toast});
|
||||
}
|
||||
@@ -315,8 +345,10 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
|
||||
if (state.suggestions !== shownRows || state.suggestionsText !== shownText) renderSuggestions(state);
|
||||
setText(count, state.countText);
|
||||
setText(status, state.statusText);
|
||||
setText(primary, state.primaryLabel);
|
||||
setText(primary, review ? reviewLabel(state) : state.primaryLabel);
|
||||
setDisabled(primary, !state.canSave);
|
||||
// D90: while Approve's save is in flight the session cannot be left, so its count includes that save.
|
||||
if (review) for (const button of [skip, cancel]) setDisabled(button, state.phase === 'saving');
|
||||
if (state.canRetry !== retry.isConnected) {
|
||||
if (state.canRetry) actions.prepend(retry); // D116
|
||||
else {
|
||||
@@ -327,7 +359,12 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
|
||||
if (state.phase === 'saved') complete(state);
|
||||
}
|
||||
|
||||
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
||||
cancel.addEventListener('click', () => { if (!disabled(cancel)) closeWorkspaceDialog(); });
|
||||
skip.addEventListener('click', () => {
|
||||
if (!open || advancing || disabled(skip)) return;
|
||||
advancing = true;
|
||||
onSkip?.();
|
||||
});
|
||||
// Retry after a failed Generate takes Generate's own path, so an invalid field reports its validity (M4).
|
||||
retry.addEventListener('click', () => (controller.state.phase === 'generate-error' ? requestGenerate() : controller.retry()));
|
||||
primary.addEventListener('click', () => {
|
||||
@@ -345,12 +382,17 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
|
||||
document.getElementById('modal')?.classList.remove('tagging-modal');
|
||||
local.dispose();
|
||||
controller.dispose();
|
||||
if (review && !advancing) onStop?.();
|
||||
}
|
||||
openContentDialog({scope, title: 'Tag image', nodes, trigger, onClose: release});
|
||||
const dialogTitle = review ? `Tag image · Image ${position.index} of ${position.total}` : 'Tag image'; // D90
|
||||
openContentDialog({scope, title: dialogTitle, nodes, trigger, onClose: release,
|
||||
canClose: review ? () => controller.state.phase !== 'saving' : null});
|
||||
document.getElementById('modal')?.classList.add('tagging-modal');
|
||||
local.cleanup(controller.subscribe(render));
|
||||
render(controller.state);
|
||||
controller.open();
|
||||
controller.open().then(() => {
|
||||
if (open && autoGenerate && controller.state.canGenerate) controller.generate();
|
||||
});
|
||||
return {
|
||||
close() { if (open) closeWorkspaceDialog(); },
|
||||
get controller() { return controller; },
|
||||
|
||||
@@ -4,6 +4,7 @@ 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"]';
|
||||
@@ -26,8 +27,11 @@ function el(tag, text, className) {
|
||||
* - `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, checked: initial = Object.freeze(new Set()) }) {
|
||||
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');
|
||||
@@ -40,7 +44,15 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
|
||||
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);
|
||||
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() {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* The Images selection bar's tagging actions (#249 PR D, Mock inventory M3):
|
||||
*
|
||||
* - **Add tags** (the concept's `bulk-tags` simpleInput, app.js a1378–1383): "Tag selected images", "Tags, separated
|
||||
* by commas", Apply. The names are parsed as the Tag image dialog's manual field parses them, then the `image-tags`
|
||||
* Activity job adds them to every selected image.
|
||||
* - **Auto-tag selected** (D85, ruling L1, beyond the concept): "Auto-tag selected images" with the Tag image dialog's
|
||||
* model panel (its fields show the server's defaults, D91's Suggestions included) and no Generate, then **How to
|
||||
* apply** (D90, ruling T3-bulk): **Apply automatically** starts the job, **Review each image** starts the browser
|
||||
* review session (images-tagging-review.mjs). A model that is not downloaded keeps Start disabled and says why.
|
||||
*
|
||||
* The toast says the job started (the Activity store's "Tagging started." / "Auto-tagging started."), or why it did
|
||||
* not: a running record-changing job (named, with its link), a held model, a refused body. Activity shows progress.
|
||||
* The selection stays either way. When a started job ends having tagged files, `onTagged(keys)` re-reads them.
|
||||
*/
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { createTaggingOptionsController, parseManualTags } from './image-tagging-state.mjs';
|
||||
import { imageTarget, renderModelPanel } from './image-tagging.mjs';
|
||||
import { startReviewSession } from './images-tagging-review.mjs';
|
||||
import { closeWorkspaceDialog, openContentDialog, openInputDialog } from './workspace-dialogs.mjs';
|
||||
import { busyToast } from './activity-scan.mjs';
|
||||
import { imageKey } from './images-state.mjs';
|
||||
import { countOf } from './plural.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
export const IMAGE_TAG_JOB = 'image-tags';
|
||||
export const MAX_BULK_IMAGES = 500;
|
||||
export const TOO_MANY = 'Select at most 500 images.';
|
||||
export const NO_TAGS = 'Enter at least one tag.'; // D118
|
||||
export const ADD_DIALOG = Object.freeze({title: 'Tag selected images', label: 'Tags, separated by commas', action: 'Apply'});
|
||||
export const AUTO_TITLE = 'Auto-tag selected images';
|
||||
export const APPLY_CHOICES = Object.freeze([
|
||||
Object.freeze({value: 'auto', label: 'Apply automatically', action: 'Start auto-tagging'}),
|
||||
Object.freeze({value: 'review', label: 'Review each image', action: 'Start review'}),
|
||||
]);
|
||||
// D118: the concept has no copy for these; they follow D55's "The scan could not start. Try again.".
|
||||
const START_FAILED = Object.freeze({add: 'Tagging could not start. Try again.', auto: 'Auto-tagging could not start. Try again.'});
|
||||
const FINAL = new Set(['finished', 'cancelled', 'failed']);
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* The job targets of the checked `images` in listing order (their reveal identity), or null when more than 500 are
|
||||
* checked. A file without a tagging identity is left out.
|
||||
*/
|
||||
export function bulkTargets(images) {
|
||||
if (images.length > MAX_BULK_IMAGES) return null;
|
||||
return images.map(imageTarget).filter(Boolean);
|
||||
}
|
||||
|
||||
/** The Add tags field's names, deduplicated as the Tag image dialog's manual field is. */
|
||||
export const parseBulkTags = parseManualTags;
|
||||
|
||||
// Duck-typed: this ES module cannot import the UMD api-client.js.
|
||||
const apiMessage = error => (error?.name === 'ApiError' && error.code && error.message ? error.message : null);
|
||||
|
||||
/**
|
||||
* Start one `image-tags` job with `body` through `store.start`. Returns the job, or null after toasting why not: the
|
||||
* busy answer naming the running job, a coded refusal's own sentence (`model-busy`, a bad body), else a fixed one.
|
||||
*/
|
||||
export async function startTagJob({store, scope, body, notify = showWorkspaceToast}) {
|
||||
try {
|
||||
const result = await store.start(IMAGE_TAG_JOB, body);
|
||||
if (scope.signal.aborted) return null;
|
||||
if (result.busy !== undefined) {
|
||||
notify(busyToast(result.busy));
|
||||
return null;
|
||||
}
|
||||
return result.job;
|
||||
} catch (error) {
|
||||
if (!scope.signal.aborted) notify(apiMessage(error) ?? START_FAILED[body.mode]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Call `onEnd(job)` once the job `id` has ended in `store`; returns the unsubscribe. */
|
||||
export function followJobEnd({store, scope, id, onEnd}) {
|
||||
let done = false;
|
||||
const check = () => {
|
||||
const job = store.find(id);
|
||||
if (done || !job || !FINAL.has(job.state)) return;
|
||||
done = true;
|
||||
stop();
|
||||
onEnd(job);
|
||||
};
|
||||
const stop = store.subscribe(check);
|
||||
scope.cleanup(stop);
|
||||
check();
|
||||
return stop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire one started job: when it ends having tagged files, `onTagged(keys)` gets the image keys it was started for.
|
||||
*/
|
||||
function followTagged({store, scope, job, images, onTagged}) {
|
||||
const keys = new Set(images.map(imageKey));
|
||||
followJobEnd({store, scope, id: job.id, onEnd: ended => { if ((ended.counters.tagged ?? 0) > 0) onTagged(keys); }});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tags: the concept's simpleInput, then the job over `images` (the checked files, taken now). A field that parses
|
||||
* to no names keeps the dialog and its text, and reports NO_TAGS on the field (D118). `openInput` is the dialog seam.
|
||||
*/
|
||||
export function openAddTags({scope, store, images, trigger, onTagged = () => {}, notify = showWorkspaceToast,
|
||||
openInput = openInputDialog}) {
|
||||
const targets = bulkTargets(images);
|
||||
if (!targets) { notify(TOO_MANY); return; }
|
||||
openInput({scope, trigger, ...ADD_DIALOG, validate: text => (parseBulkTags(text).length ? null : NO_TAGS),
|
||||
onApply: async text => {
|
||||
const tags = parseBulkTags(text);
|
||||
const job = await startTagJob({store, scope, body: {mode: 'add', targets, tags}, notify});
|
||||
if (job) followTagged({store, scope, job, images, onTagged});
|
||||
}});
|
||||
}
|
||||
|
||||
/** The D90 How to apply radio group: Apply automatically (checked) or Review each image. */
|
||||
function applyChoice(onChange) {
|
||||
const group = el('div', undefined, 'field');
|
||||
group.setAttribute('role', 'radiogroup');
|
||||
const caption = el('span', 'How to apply');
|
||||
caption.id = 'auto-tag-apply-label';
|
||||
group.setAttribute('aria-labelledby', caption.id);
|
||||
group.append(caption);
|
||||
for (const choice of APPLY_CHOICES) {
|
||||
const label = el('label');
|
||||
const input = el('input');
|
||||
Object.assign(input, {type: 'radio', name: 'auto-tag-apply', value: choice.value, checked: choice.value === 'auto'});
|
||||
input.addEventListener('change', () => { if (input.checked) onChange(choice); });
|
||||
label.append(input, ` ${choice.label}`);
|
||||
group.append(label);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-tag selected (D85): the model panel, How to apply (D90), Cancel and Start. Start validates the fields as
|
||||
* Generate does, then starts the job or the review session over `images` (the checked files, taken now).
|
||||
*/
|
||||
export function openAutoTag({api, scope, store, images, trigger, onTagged = () => {}, notify = showWorkspaceToast}) {
|
||||
const targets = bulkTargets(images);
|
||||
if (!targets) { notify(TOO_MANY); return; }
|
||||
const listed = images.filter(imageTarget);
|
||||
const local = createScope();
|
||||
const options = createTaggingOptionsController({api});
|
||||
// D85: the Tag image dialog's model panel without Generate; its Suggestions field is the job's count (D91).
|
||||
const {element: panel, validate} = renderModelPanel({scope: local, controller: options,
|
||||
heading: `Auto-tag ${countOf(listed.length, 'image')}`, generate: false}); // D118
|
||||
let choice = APPLY_CHOICES[0];
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
const cancel = createButton('Cancel');
|
||||
const start = createButton(choice.action, 'ca-primary');
|
||||
actions.append(cancel, start);
|
||||
// A model that is not downloaded: the reason, as the Tag image dialog's status line words it.
|
||||
const status = el('div', undefined, 'small');
|
||||
status.id = 'tag-generation-status';
|
||||
status.setAttribute('role', 'status');
|
||||
const choices = applyChoice(next => { choice = next; start.textContent = next.action; }); // D90
|
||||
const render = state => {
|
||||
if (status.textContent !== state.unavailable) status.textContent = state.unavailable;
|
||||
if (state.canStart) start.removeAttribute('aria-disabled');
|
||||
else start.setAttribute('aria-disabled', 'true');
|
||||
};
|
||||
local.cleanup(options.subscribe(render));
|
||||
render(options.state);
|
||||
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
||||
start.addEventListener('click', () => {
|
||||
if (start.getAttribute('aria-disabled') === 'true' || !options.state.canStart || !validate()) return;
|
||||
const request = options.request();
|
||||
if (!request) return;
|
||||
if (choice.value === 'review') {
|
||||
// The first image's dialog replaces this one in the shell (D90).
|
||||
startReviewSession({api, scope, images: listed, ...request, trigger});
|
||||
return;
|
||||
}
|
||||
closeWorkspaceDialog();
|
||||
startTagJob({store, scope, body: {mode: 'auto', targets, ...request}, notify}).then(job => {
|
||||
if (job) followTagged({store, scope, job, images: listed, onTagged});
|
||||
});
|
||||
});
|
||||
openContentDialog({scope, title: AUTO_TITLE, nodes: [panel, choices, status, actions], trigger, onClose: () => {
|
||||
document.getElementById('modal')?.classList.remove('tagging-modal');
|
||||
local.dispose();
|
||||
options.dispose();
|
||||
}});
|
||||
document.getElementById('modal')?.classList.add('tagging-modal');
|
||||
options.open();
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* D90 (ruling T3-bulk): "Review each image", the bulk Auto-tag session in the browser. The selected images are walked
|
||||
* one Tag image dialog at a time (image-tagging.mjs `actions: 'review'`), each opened with the session's model and
|
||||
* options and Generate already running. Approve saves that image as the dialog's Save does and advances; Skip
|
||||
* advances without a write; Stop reviewing, Escape and × end the session. Each image is one gated `suggest` call, so
|
||||
* the session holds no model between images; a refused one (`model-busy`) stays on its image with the dialog's own
|
||||
* status and Retry until the user retries or skips. Ending toasts "<n> of <M> images tagged."; the selection stays.
|
||||
*/
|
||||
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
|
||||
import { openImageTagging } from './image-tagging.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
/** The closing toast: approved saves of the session's images (the plan's copy verbatim). */
|
||||
export function reviewOutcome(tagged, total) {
|
||||
return `${tagged} of ${total} images tagged.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk `images` (listing items with a tagging target, in selection order) with `model_type` and `options`.
|
||||
* `onSaved(event)` follows every approved save. Returns `{stop()}`, which ends the session (its open dialog closes and
|
||||
* writes nothing more). `open`, `close` and `notify` are the dialog seams (the defaults in the page).
|
||||
*/
|
||||
export function startReviewSession({api, scope, images, model_type: model, options, trigger = null, onSaved = null,
|
||||
open = openImageTagging, close = closeWorkspaceDialog, notify = showWorkspaceToast}) {
|
||||
if (!Array.isArray(images) || !images.length) throw new TypeError('A review needs its images');
|
||||
const total = images.length;
|
||||
let index = 0, tagged = 0, ended = false, handle = null;
|
||||
|
||||
function end() {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
handle = null;
|
||||
notify(reviewOutcome(tagged, total));
|
||||
}
|
||||
function advance() {
|
||||
if (ended) return;
|
||||
index += 1;
|
||||
if (index >= total) {
|
||||
// The last dialog handed over (advancing), so closing it is not a Stop.
|
||||
end();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
show();
|
||||
}
|
||||
function show() {
|
||||
let current = true;
|
||||
const settled = () => {
|
||||
const was = current;
|
||||
current = false;
|
||||
return was;
|
||||
};
|
||||
handle = open({api, scope, image: images[index], source: 'bulk', trigger, preset: {model, options},
|
||||
autoGenerate: true, actions: 'review', position: {index: index + 1, total},
|
||||
onSaved: event => {
|
||||
// A save that settles after its dialog was left (Skip or Stop while it ran) still counts.
|
||||
tagged += 1;
|
||||
onSaved?.(event);
|
||||
if (settled()) advance();
|
||||
},
|
||||
onSkip: () => { if (settled()) advance(); },
|
||||
onStop: () => { if (settled()) end(); },
|
||||
});
|
||||
}
|
||||
|
||||
show();
|
||||
return {
|
||||
stop() {
|
||||
if (ended) return;
|
||||
const open = handle;
|
||||
end();
|
||||
open?.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -23,10 +23,13 @@ export function openCardTagging({api, scope, image, action, embedded}) {
|
||||
/**
|
||||
* Follow confirmed tag saves until `scope` ends. `filtered()` tells whether a Tags filter or a search is active,
|
||||
* `selection()` is the selected image key, and `onRefreshing(busy)` marks Load more busy while the re-read runs.
|
||||
* Returns `refresh(keys)`, the same re-read for the files a bulk tagging job tagged (once per job, D85).
|
||||
*/
|
||||
export function followImageTagSaves({scope, stream, inspection, filtered, selection, onRefreshing}) {
|
||||
scope.cleanup(onImageTagsSaved(async ({target}) => {
|
||||
const key = `${target.root_index}:${target.path}`;
|
||||
scope.cleanup(onImageTagsSaved(({target}) => refresh(new Set([`${target.root_index}:${target.path}`]))));
|
||||
return refresh;
|
||||
|
||||
async function refresh(keys) {
|
||||
if (filtered()) {
|
||||
onRefreshing(true);
|
||||
try {
|
||||
@@ -37,6 +40,6 @@ export function followImageTagSaves({scope, stream, inspection, filtered, select
|
||||
if (!scope.signal.aborted) onRefreshing(false);
|
||||
}
|
||||
}
|
||||
if (!scope.signal.aborted && selection() === key) inspection.refresh();
|
||||
}));
|
||||
if (!scope.signal.aborted && keys.has(selection())) inspection.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// 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(); }});
|
||||
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys)});
|
||||
if (bar) main.append(bar.element);
|
||||
main.append(body);
|
||||
const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender,
|
||||
@@ -529,7 +529,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
scheduleLoad();
|
||||
}
|
||||
const stream = createImageStream({ api, fetchPage, onChange: renderState, restore: visit ? {filters, state:visit.state} : undefined });
|
||||
followImageTagSaves({scope, stream, inspection, selection:() => selection, filtered:() => !embedded && !recovering && Boolean(filters.tags.length || filters.q),
|
||||
const refreshTagged = followImageTagSaves({scope, stream, inspection, selection:() => selection, filtered:() => !embedded && !recovering && Boolean(filters.tags.length || filters.q),
|
||||
onRefreshing:busy => more.setAttribute('aria-disabled', String(busy || Boolean(state?.loading) || recovering))});
|
||||
scope.cleanup(() => {
|
||||
stream.dispose();
|
||||
|
||||
@@ -59,10 +59,10 @@ function createShell() {
|
||||
body.id = 'modal-body';
|
||||
header.append(title, close);
|
||||
dialog.append(header, body);
|
||||
close.addEventListener('click', () => finish());
|
||||
close.addEventListener('click', () => dismiss());
|
||||
dialog.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
finish();
|
||||
dismiss();
|
||||
});
|
||||
// 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.
|
||||
@@ -82,6 +82,12 @@ function finish() {
|
||||
if (trigger?.isConnected) trigger.focus();
|
||||
}
|
||||
|
||||
/** × or Escape: close, unless the open dialog's `canClose()` holds it (a save in flight). */
|
||||
function dismiss() {
|
||||
if (session?.canClose && !session.canClose()) return;
|
||||
finish();
|
||||
}
|
||||
|
||||
const boundScopes = new WeakSet();
|
||||
|
||||
function requireScope(scope) {
|
||||
@@ -104,13 +110,13 @@ function bindScope(scope) {
|
||||
});
|
||||
}
|
||||
|
||||
function show(heading, nodes, { scope, trigger, focusTarget, onClose = null }) {
|
||||
function show(heading, nodes, { scope, trigger, focusTarget, onClose = null, canClose = null }) {
|
||||
shell ??= createShell();
|
||||
if (!shell.dialog.isConnected) document.body.append(shell.dialog);
|
||||
bindScope(scope);
|
||||
// A swapped-out body is gone from the shell, so its owner releases it.
|
||||
const replaced = session?.onClose;
|
||||
session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose };
|
||||
session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose, canClose };
|
||||
replaced?.();
|
||||
shell.title.textContent = heading;
|
||||
shell.body.replaceChildren(...nodes);
|
||||
@@ -301,10 +307,14 @@ export function openCopyFallback(text, { scope, trigger = null, title = 'Copy pr
|
||||
* 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, or
|
||||
* reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field.
|
||||
* `validate(value)` may refuse a value with a message: the dialog then stays open with the typed text and the field
|
||||
* reports the message (its native validity) until it is edited.
|
||||
*/
|
||||
export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null }) {
|
||||
export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null,
|
||||
validate = null }) {
|
||||
requireScope(scope);
|
||||
requireCallbacks({ onApply });
|
||||
if (validate !== null) requireCallbacks({ validate });
|
||||
for (const [name, text] of Object.entries({ title, label, action })) {
|
||||
if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`);
|
||||
}
|
||||
@@ -317,9 +327,17 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe
|
||||
const cancel = createButton('Cancel');
|
||||
const apply = createButton(action, 'ca-primary');
|
||||
cancel.addEventListener('click', () => finish());
|
||||
input.addEventListener('input', () => input.setCustomValidity(''));
|
||||
apply.addEventListener('click', () => {
|
||||
const entered = input.value.trim() || fallback;
|
||||
if (!entered) { input.focus(); return; }
|
||||
const problem = validate?.(entered);
|
||||
if (problem) {
|
||||
input.setCustomValidity(problem);
|
||||
input.reportValidity();
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
onApply(entered);
|
||||
});
|
||||
@@ -330,14 +348,16 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe
|
||||
|
||||
/**
|
||||
* The concept's plain `dialog(title, body)` with page-built `nodes`. `onClose` runs once when the dialog closes or
|
||||
* another dialog replaces its body, so the page can dispose what it mounted there.
|
||||
* another dialog replaces its body, so the page can dispose what it mounted there. While `canClose()` answers false,
|
||||
* × and Escape leave the dialog open (the page, a route change or a replacing dialog still close it).
|
||||
*/
|
||||
export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null }) {
|
||||
export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null }) {
|
||||
requireScope(scope);
|
||||
if (typeof title !== 'string' || !title) throw new TypeError('The dialog needs a title');
|
||||
if (!Array.isArray(nodes) || nodes.some(node => !node || typeof node !== 'object')) throw new TypeError('The dialog needs its nodes');
|
||||
if (onClose !== null && typeof onClose !== 'function') throw new TypeError('The dialog onClose must be a function');
|
||||
show(title, nodes, { scope, trigger, onClose });
|
||||
if (canClose !== null && typeof canClose !== 'function') throw new TypeError('The dialog canClose must be a function');
|
||||
show(title, nodes, { scope, trigger, onClose, canClose });
|
||||
}
|
||||
|
||||
export const SCAN_REVIEW_SENTENCE = 'Enrich positive / negative associations, reuse canonical text and report unchanged files. ' +
|
||||
|
||||
@@ -378,3 +378,9 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
/* D83: the chip's × keeps the concept's 40px coarse target. */
|
||||
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
|
||||
}
|
||||
|
||||
/* Auto-tag selected images (#249 PR D, D85/D90): the Tag image dialog's model panel, then How to apply, a radio group
|
||||
in the concept's `.field` row whose radios keep their own size (the concept's `.field input` is a full-width text
|
||||
field) beside their label, as the concept's own radio rows (s1160 `.compare-item label`). */
|
||||
.field[role="radiogroup"] > label { display: flex; align-items: center; gap: 8px; /* D90 */ }
|
||||
.field[role="radiogroup"] input[type="radio"] { width: auto; margin: 0; /* D90 */ }
|
||||
|
||||
Reference in New Issue
Block a user