/** * Per-kind Activity descriptors (#249 System S5, D111): one table that says, for each job kind, what Activity shows. * * `kindOf(kind)` → `{pillVerb, startToast(job), finishToast(job), line(job), kept(job), scope(job), tiles, outcomes, * openable, log, cancelLabel}`: * - `pillVerb`: the pill's verb ("Scanning"); null shows the job's title. * - `startToast` / `finishToast`: what a toast says when this tab starts the job and when it ends. * - `line`: the counters as one line ("N added · M errors"); `kept`: what a cancelled job kept ("N added"); `scope`: * what the job covers ("2 output folders"). * - `tiles` (`[group, label, tone]`), `outcomes` (`[group, label]`) and `openable` (rows that open the viewer, D57). * - `log` and `cancelLabel` (`cancel` is the same label under the job view's older name). * * `scan` is exactly the D56 view and `image-tags` the D89 one. `thumbnails` counts Generated (green), Skipped and * Errors (red). System S7: `maintenance` (a reviewed plan's apply) counts Changed, Protected and Errors and, when it * failed, ends with the server's reason; `duplicates` (the search) counts Groups found; `duplicate_cleanup` counts * Removed, Kept and Errors; S10: `lora_import` counts Imported, Linked to existing, Skipped, Images downloaded, Download * errors and Errors and, when it failed, ends with the server's reason (the previous imports were kept); S9: `restore` * ends with the server's line (what was restored, or why nothing changed). Any other kind (the health check, a held legacy-page operation, a kind this page does not know) gets * the generic descriptor: title-based copy and no tiles or outcomes, so it never shows another kind's zeros (S1 review * M6, S2 review M3). `viewOf(job)` keeps only the groups the job itself counts, so a hold (no groups) has none either. * * Progress is indeterminate while a job runs without a known total (`indeterminate`): the `` has no value * and the pill shows no percentage, never a made-up 0%. A job shows Cancel only while it runs and the server can stop * it (`canCancel`). */ import { noun, countOf } from './plural.mjs'; // Grouped as the server writes its status lines ("8,240 of 19,622"), whatever the browser's locale. const number = value => value.toLocaleString('en-US'); const counted = (job, group, word) => `${number(job.counters?.[group] ?? 0)} ${word}`; const errorsOf = job => `${number(job.counters?.errors ?? 0)} ${noun(job.counters?.errors ?? 0, 'error')}`; const whole = value => Number.isSafeInteger(value) && value >= 0; const tiles = rows => Object.freeze(rows.map(Object.freeze)); const groups = (...names) => Object.freeze(new Set(names)); /** "1,420 added". */ export const addedOf = job => counted(job, 'added', 'added'); /** "12 tagged" (D89). */ export const taggedOf = job => counted(job, 'tagged', 'tagged'); /** "3 generated" (D111). */ export const generatedOf = job => counted(job, 'generated', 'generated'); /** "3 changed" (System S7: a maintenance apply). */ export const changedOf = job => counted(job, 'changed', 'changed'); /** "12 groups found" (System S7: a duplicate search). */ export const groupsOf = job => { const groups = job.counters?.groups_found ?? 0; return `${number(groups)} ${noun(groups, 'group')} found`; }; /** "3 removed" (System S7: a duplicate cleanup). */ export const removedOf = job => counted(job, 'removed', 'removed'); /** "12 imported" (System S10: the LoRA reference import). */ export const importedOf = job => counted(job, 'imported', 'imported'); /** "2 output folders": what a scan or a thumbnail job walks. */ function foldersOf(job) { const folders = job.scope?.output_folders; return whole(folders) ? countOf(folders, 'output folder') : ''; } /** "4 images" (D89), grouped as the server writes its lines. */ function imagesOf(job) { const images = job.scope?.images; return whole(images) ? `${number(images)} ${noun(images, 'image')}` : ''; } const titleStarted = job => `${job.title} started.`; function titleFinished(job) { if (job.state === 'cancelled') return `${job.title} was cancelled.`; if (job.state === 'failed') return `${job.title} failed.`; return `${job.title} finished.`; } /** " finished · ", " cancelled · ", " failed · ". */ const namedFinish = (name, kept, line) => job => { if (job.state === 'cancelled') return `${name(job)} cancelled · ${kept(job)}`; return `${name(job)} ${job.state === 'failed' ? 'failed' : 'finished'} · ${line(job)}`; }; const nothing = () => ''; const GENERIC = Object.freeze({ pillVerb: null, startToast: titleStarted, finishToast: titleFinished, line: nothing, kept: nothing, scope: foldersOf, tiles: tiles([]), outcomes: tiles([]), openable: groups(), log: 'Job log', cancelLabel: 'Cancel job', cancel: 'Cancel job', }); const scanLine = job => `${addedOf(job)} · ${errorsOf(job)}`; const tagLine = job => `${taggedOf(job)} · ${errorsOf(job)}`; const thumbnailLine = job => `${generatedOf(job)} · ${errorsOf(job)}`; const changedLine = job => `${changedOf(job)} · ${errorsOf(job)}`; const cleanupLine = job => `${removedOf(job)} · ${errorsOf(job)}`; const importLine = job => `${importedOf(job)} · ${errorsOf(job)}`; /** A job that ends with the server's reason when it failed (the LoRA import keeps the old collection and says so). */ const reasonFinish = (name, kept, line) => job => (job.state === 'failed' && job.status_line ? `${name} failed · ${job.status_line}` : namedFinish(() => name, kept, line)(job)); /** A maintenance apply ends with its counters, or, when it failed, the server's reason (plan-changed: review again). */ function maintenanceFinish(job) { if (job.state === 'cancelled') return `${job.title} cancelled · ${changedOf(job)}`; if (job.state === 'failed') return `${job.title} failed · ${job.status_line || errorsOf(job)}`; return `${job.title} finished · ${changedOf(job)}`; } // 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.' }); /** D89: the image tagging job's view, shared with Tag output images (#328). */ const IMAGE_TAGS = Object.freeze({ ...GENERIC, pillVerb: 'Tagging', startToast: job => IMAGE_TAG_START_TOASTS[job.scope?.mode] ?? titleStarted(job), finishToast: namedFinish(job => job.title, taggedOf, tagLine), line: tagLine, kept: taggedOf, scope: imagesOf, tiles: tiles([['tagged', 'Tagged', 'added'], ['unchanged', 'Unchanged', ''], ['skipped', 'Skipped', 'warn'], ['errors', 'Errors', 'bad']]), outcomes: tiles([['tagged', 'Tagged'], ['errors', 'Errors'], ['skipped', 'Skipped'], ['unchanged', 'Unchanged']]), openable: groups('tagged', 'unchanged'), log: 'Tagging log', cancelLabel: 'Cancel tagging', cancel: 'Cancel tagging', }); /** Every kind with a descriptor of its own. */ export const KINDS = Object.freeze({ __proto__: null, // D56: the scan's tiles and outcome list in the mockup's orders; what got added and what went wrong first. scan: Object.freeze({ ...GENERIC, pillVerb: 'Scanning', startToast: () => 'Scan started.', finishToast: namedFinish(() => 'Scan', addedOf, scanLine), line: scanLine, kept: addedOf, tiles: tiles([['added', 'Added to Library', 'added'], ['known', 'Already known', ''], ['review', 'Needs review', 'warn'], ['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad']]), outcomes: tiles([['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'], ['no_metadata', 'No usable metadata'], ['known', 'Already known']]), openable: groups('added', 'known'), log: 'Scan log', cancelLabel: 'Cancel scan', cancel: 'Cancel scan', }), // D89: Tagged (green), Unchanged, Skipped (warning) and Errors (red); Tagged and Unchanged rows open the viewer. 'image-tags': IMAGE_TAGS, // #328 (extends D111): Tag output images, the prompt-tag batch, counts its images the same way. Its legacy-page // hold counts nothing and is not cancellable, so `viewOf` gives it the generic held view. autotag: IMAGE_TAGS, // D111: Generated (green), Skipped (already fresh) and Errors (red); a row with a preview opens the viewer. thumbnails: Object.freeze({ ...GENERIC, pillVerb: 'Generating thumbnails', finishToast: namedFinish(() => 'Thumbnails', generatedOf, thumbnailLine), line: thumbnailLine, kept: generatedOf, tiles: tiles([['generated', 'Generated', 'added'], ['skipped', 'Skipped', ''], ['errors', 'Errors', 'bad']]), outcomes: tiles([['generated', 'Generated'], ['errors', 'Errors'], ['skipped', 'Skipped']]), openable: groups('generated', 'skipped'), }), // System S7: a reviewed maintenance plan's apply, titled after its operation; it runs without a known total. maintenance: Object.freeze({ ...GENERIC, finishToast: maintenanceFinish, line: changedLine, kept: changedOf, tiles: tiles([['changed', 'Changed', 'added'], ['protected', 'Protected', ''], ['errors', 'Errors', 'bad']]), outcomes: tiles([['changed', 'Changed'], ['errors', 'Errors'], ['protected', 'Protected']]), log: 'Maintenance log', }), // System S7: the duplicate image search; each found group's row opens its first copy. duplicates: Object.freeze({ ...GENERIC, pillVerb: 'Finding duplicates', startToast: () => 'Duplicate search started.', finishToast: namedFinish(() => 'Duplicate search', groupsOf, groupsOf), line: groupsOf, kept: groupsOf, tiles: tiles([['groups_found', 'Groups found', '']]), outcomes: tiles([['groups_found', 'Groups found']]), openable: groups('groups_found'), log: 'Search log', cancelLabel: 'Cancel search', cancel: 'Cancel search', }), // System S7: the reviewed duplicate cleanup; the kept copies open the viewer, the removed ones are gone. duplicate_cleanup: Object.freeze({ ...GENERIC, pillVerb: 'Removing duplicate files', // A failed cleanup says the server's reason (sources changed, no safety backup), as a maintenance apply does. finishToast: job => (job.state === 'failed' && job.status_line ? `Duplicate cleanup failed · ${job.status_line}` : namedFinish(() => 'Duplicate cleanup', removedOf, cleanupLine)(job)), line: cleanupLine, kept: removedOf, tiles: tiles([['removed', 'Removed', 'added'], ['kept', 'Kept', ''], ['errors', 'Errors', 'bad']]), outcomes: tiles([['removed', 'Removed'], ['errors', 'Errors'], ['kept', 'Kept']]), openable: groups('kept'), log: 'Cleanup log', }), // System S9: the reviewed database restore (exclusive, never cancellable, indeterminate); it ends with the server's // line: what was restored, or why nothing was changed. restore: Object.freeze({ ...GENERIC, pillVerb: 'Restoring the database', finishToast: job => (job.status_line && job.state !== 'cancelled' ? `${job.title} ${job.state === 'failed' ? 'failed' : 'finished'} · ${job.status_line}` : titleFinished(job)), line: job => (job.state === 'running' ? '' : job.status_line || ''), log: 'Restore log', }), // System S10: the reviewed LoRA reference import; cancellable only while it downloads (the server says when). lora_import: Object.freeze({ ...GENERIC, pillVerb: 'Importing LoRAs', finishToast: reasonFinish('LoRA import', importedOf, importLine), line: importLine, kept: importedOf, tiles: tiles([['imported', 'Imported', 'added'], ['linked', 'Linked to existing', ''], ['skipped', 'Skipped', 'warn'], ['downloaded', 'Images downloaded', ''], ['download_errors', 'Download errors', 'warn'], ['errors', 'Errors', 'bad']]), outcomes: tiles([['imported', 'Imported'], ['errors', 'Errors'], ['skipped', 'Skipped'], ['linked', 'Linked to existing']]), log: 'Import log', cancelLabel: 'Cancel import', cancel: 'Cancel import', }), }); /** The descriptor of a job `kind`; any kind without one gets the generic descriptor. */ export function kindOf(kind) { return (typeof kind === 'string' && KINDS[kind]) || GENERIC; } const countsOf = job => job.counters && typeof job.counters === 'object' ? job.counters : {}; /** * A legacy-page operation held on the registry (D112): not cancellable and counting no outcome groups. Whatever its * kind (the gallery's thumbnail clear is a `thumbnails` hold), it reads as the generic job under its own title. */ export const held = job => job.cancellable === false && Object.keys(countsOf(job)).length === 0; /** * The descriptor for one job: its kind's, keeping only the outcome groups the job counts; a held job's is the generic * one. */ export function viewOf(job) { if (held(job)) return GENERIC; const view = kindOf(job.kind); const counts = countsOf(job); const has = ([group]) => Object.hasOwn(counts, group); if (view.tiles.every(has) && view.outcomes.every(has)) return view; return Object.freeze({ ...view, tiles: tiles(view.tiles.filter(has)), outcomes: tiles(view.outcomes.filter(has)), openable: groups(...[...view.openable].filter(group => Object.hasOwn(counts, group))) }); } /** A running job whose total the server has not reported (yet) has no percentage. */ export function indeterminate(job) { return job.state === 'running' && !(job.total > 0); } /** Set a `` to the job's: its value and "N%", or no value at all while indeterminate. */ export function showProgress(node, job) { if (indeterminate(job)) { node.removeAttribute('value'); node.textContent = ''; return; } node.value = job.progress; node.textContent = `${job.progress}%`; } /** Truth rule: Cancel only while the job runs and the server can stop it (never on a held legacy job). */ export function canCancel(job) { return job.state === 'running' && job.cancellable !== false; } /** The pill's text: "Scanning · 42% · 8,240 / 19,622"; the file counts once the total is known, no "%" before. */ export function pillParts(job) { const verb = viewOf(job).pillVerb ?? job.title; const label = indeterminate(job) ? verb : `${verb} · ${job.progress}%`; const files = job.total > 0 ? ` · ${number(job.processed)} / ${number(job.total)}` : ''; return { label, files, text: label + files }; } /** The toast after this tab starts a job. */ export const startToast = job => viewOf(job).startToast(job); /** The toast when a job ends. */ export const finishToast = job => viewOf(job).finishToast(job); /** The counters as one line; '' for a kind without one. */ export const outcomeLine = job => viewOf(job).line(job);