* fix(library): close recipe editor follow-ups from the native editor slice - After restored storage, Create text revision and Archive are disabled so they cannot re-read an old numeric ID; only Reload Library remains. - The storage-changed exit in Edit, revision and Add negative states that Reload Library closes the dialog and discards the entries (the concept has no discard confirmation, so the copy stays inline). - When Save becomes disabled after an unconfirmed write or a conflict, focus moves to the recovery action instead of falling to the body; the archive confirmation does the same. - A reuse toast only says the entered parent metadata was not applied when the controller found it differs from the stored metadata. - Prompt history opens on its first negative variant, as the concept does, so the heading Send starts enabled. - "1 linked image" is singular on cards, variant lanes and the embedded gallery count. * fix(library): treat a blank category as equal to a stored uncategorized one A reuse that left the category blank sent "Uncategorized" and compared it with the stored NULL, so the toast claimed the entered metadata was not applied. Both sides now normalize blank and "Uncategorized" the same way before comparing; a genuinely different category still discloses it. * test(coverage): raise frontend coverage floors after editor follow-ups * fix(library): singular counts for one item and honest discard copy - One shared countOf/noun helper (web/js/workspace/plural.mjs) now words linked images, saved negative variants (card chip and history count), distinct recorded runs and the Images toolbar count in the singular for exactly one; every other count keeps its existing wording. - The restored-storage status says "Leaving this dialog discards your entries." because Cancel, the close button and Escape discard the draft too, not only Reload Library. - The new helper gets its coverage floor; no other floor changes. * fix(library): open every prompt history visit on its first negative The concept resets to the first negative variant on every navigation to a prompt (navigate: state.variant = 0) and selects another pair only right after a save names it. The history no longer remembers a clicked variant or an explicit All images choice across visits: the per-visit memory is replaced by a one-shot saved selection that the next history mount (or the same visit's reconciliation) consumes, and a verified pair outside the loaded page is kept only for that saved selection. Within a visit, clicks and All images behave as before.
397 lines
21 KiB
JavaScript
397 lines
21 KiB
JavaScript
/**
|
|
* Native Library editor state: fresh initialization, guarded saves and honest recovery.
|
|
*
|
|
* UI-agnostic. A dialog renders `controller.state` and calls its methods. Every
|
|
* controller owns its reads: a newer read, a retry or dispose() supersedes older
|
|
* ones, so a late response can never enable Save or supply an epoch. Writes are
|
|
* sent once, without an abort signal (aborting a request cannot undo a write),
|
|
* and are never replayed automatically.
|
|
*/
|
|
const MODES = new Set(['new', 'metadata', 'revision', 'negative']);
|
|
const KINDS = new Set(['metadata', 'create', 'add-negative', 'archive-pair', 'restore-pair']);
|
|
const FIELDS = ['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes', 'rating'];
|
|
const RECOVERY = {'metadata-conflict': 'conflict', 'storage-changed': 'storage-changed'};
|
|
// A proxy timeout or bad gateway cannot tell whether the server committed the write.
|
|
const UNCONFIRMED_STATUS = new Set([502, 503, 504]);
|
|
const SUBMITTABLE = new Set(['ready', 'write-error']);
|
|
const RELOADABLE = new Set(['ready', 'write-error', 'conflict', 'storage-changed', 'unknown-outcome', 'initialization-error']);
|
|
const UNCATEGORIZED = 'Uncategorized';
|
|
const VARIANT_LIMIT = 40;
|
|
const OPTIONS_URL = '/prompt_manager/library/options';
|
|
|
|
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
|
|
const token = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
|
|
const id = value => Number.isSafeInteger(value) && value > 0;
|
|
const count = value => Number.isSafeInteger(value) && value >= 0;
|
|
const nullableString = value => value === null || typeof value === 'string';
|
|
const strings = value => Array.isArray(value) && value.every(item => typeof item === 'string');
|
|
const rating = value => value === null || (Number.isInteger(value) && value >= 1 && value <= 5);
|
|
|
|
function requireValue(valid, message = 'Invalid Library response') {if (!valid) throw new TypeError(message);}
|
|
function freeze(value) {
|
|
if (value && typeof value === 'object' && !Object.isFrozen(value)) {Object.values(value).forEach(freeze); Object.freeze(value);}
|
|
return value;
|
|
}
|
|
function storageChanged() {
|
|
return Object.assign(new Error('Prompt storage was restored; refresh the library'), {code: 'storage-changed'});
|
|
}
|
|
// Duck-typed by name: this ES module cannot import the UMD api-client.js, and a
|
|
// browser page may load a different ApiError class instance than the Node tests.
|
|
function isApiError(error) {return error instanceof Error && error.name === 'ApiError' && Number.isInteger(error.status);}
|
|
|
|
function validateParent(value, positiveId) {
|
|
requireValue(object(value) && id(value.id) && typeof value.text === 'string' && nullableString(value.title) &&
|
|
nullableString(value.category) && strings(value.tags) && rating(value.rating) && nullableString(value.notes) &&
|
|
token(value.edit_token) && (positiveId === undefined || value.id === positiveId), 'Invalid prompt owner');
|
|
}
|
|
function validatePair(value, owner) {
|
|
requireValue(object(value) && id(value.id) && id(value.positive_prompt_id) && typeof value.positive_text === 'string' &&
|
|
typeof value.negative_text === 'string' && typeof value.archived === 'boolean' && count(value.image_count) &&
|
|
count(value.run_count), 'Invalid saved negative variant');
|
|
requireValue(value.positive_prompt_id === owner.id && value.positive_text === owner.text,
|
|
'Saved negative variant belongs to another prompt');
|
|
}
|
|
|
|
const fresh = (api, url, signal) => api.get(url, {signal, cache: 'no-store'});
|
|
async function readOptions(api, signal) {
|
|
const value = await fresh(api, OPTIONS_URL, signal);
|
|
requireValue(object(value) && value.success === true && uuid(value.library_epoch) && strings(value.categories), 'Invalid Library options');
|
|
return value;
|
|
}
|
|
async function readDetail(api, positiveId, signal) {
|
|
const value = await fresh(api, `/prompt_manager/library/${positiveId}?variant_limit=${VARIANT_LIMIT}`, signal);
|
|
requireValue(object(value) && value.success === true && uuid(value.library_epoch) && Array.isArray(value.variants), 'Invalid prompt history');
|
|
validateParent(value.positive, positiveId);
|
|
return value;
|
|
}
|
|
/** The exact pair by ID; ownership is checked against the parent read by the caller. */
|
|
async function readPair(api, pairId, signal) {
|
|
const value = await fresh(api, `/prompt_manager/pairs/${pairId}`, signal);
|
|
requireValue(object(value) && value.success === true && object(value.pair) && value.pair.id === pairId, 'Invalid saved negative variant');
|
|
return value.pair;
|
|
}
|
|
|
|
/** Detail, options and pair read together; snapshots from different epochs are rejected. */
|
|
async function readOwner(api, {positiveId, pairId, withOptions}, signal) {
|
|
const [detail, options, pair] = await Promise.all([readDetail(api, positiveId, signal),
|
|
withOptions ? readOptions(api, signal) : null, pairId === null ? null : readPair(api, pairId, signal)]);
|
|
if (options && options.library_epoch !== detail.library_epoch) throw storageChanged();
|
|
if (pair) validatePair(pair, detail.positive);
|
|
return {epoch: detail.library_epoch, owner: detail.positive, pair, categories: options ? options.categories : [], detail, options};
|
|
}
|
|
|
|
/** New prompt has no owner: only a fresh options epoch makes it ready. */
|
|
async function readNewSnapshot(api, signal) {
|
|
const options = await readOptions(api, signal);
|
|
return {epoch: options.library_epoch, owner: null, pair: null, categories: options.categories};
|
|
}
|
|
|
|
/**
|
|
* Re-read the saved owner after a confirmed receipt. Reads only: detail, options
|
|
* and (when the commit names one) the exact pair, kept apart from detail.variants.
|
|
*/
|
|
export async function reconcileLibraryReceipt(api, commit, {signal} = {}) {
|
|
requireValue(object(commit) && KINDS.has(commit.kind) && object(commit.receipt), 'Invalid editor commit');
|
|
const {receipt} = commit;
|
|
const positiveId = receipt.positive ? receipt.positive.id : receipt.pair?.positive_prompt_id;
|
|
requireValue(id(positiveId), 'Invalid editor commit');
|
|
const pairId = receipt.pair ? receipt.pair.id : null;
|
|
const {detail, options, pair} = await readOwner(api, {positiveId, pairId, withOptions: true}, signal);
|
|
if (receipt.library_epoch !== detail.library_epoch) throw storageChanged();
|
|
return freeze({commit, detail, pair, options});
|
|
}
|
|
|
|
export function fallbackTitle(text) {return text.split('\n')[0].trim();}
|
|
export function parseTags(value) {return [...new Set(value.split(',').map(tag => tag.trim()).filter(Boolean))];}
|
|
const sameTags = (a, b) => a.length === b.length && new Set([...a, ...b]).size === a.length;
|
|
const stored = value => (value ?? '').trim();
|
|
function normalizeRating(value) {
|
|
if (value === null || value === '') return null;
|
|
const number = typeof value === 'string' && /^[1-5]$/.test(value) ? Number(value) : value;
|
|
if (!rating(number)) throw new TypeError('Rating must be 1 to 5 or null');
|
|
return number;
|
|
}
|
|
|
|
const blankDraft = () => ({title: '', positive_text: '', negative_text: '', category: '', tags: '', notes: '', rating: null});
|
|
function seedDraft(mode, {owner, pair}) {
|
|
if (!owner) return blankDraft();
|
|
if (mode === 'negative') return {...blankDraft(), positive_text: owner.text};
|
|
return {title: stored(owner.title) ? owner.title : fallbackTitle(owner.text), positive_text: owner.text,
|
|
negative_text: pair ? pair.negative_text : '', category: owner.category ?? '', tags: owner.tags.join(', '),
|
|
notes: owner.notes ?? '', rating: owner.rating};
|
|
}
|
|
/** Keep fields the user changed from the previous seed; the rest follow the new seed. */
|
|
function overlay(next, draft, previous) {
|
|
return Object.fromEntries(FIELDS.map(name => [name, draft[name] !== previous[name] ? draft[name] : next[name]]));
|
|
}
|
|
function baselineOf(owner) {
|
|
return owner && {title: owner.title, category: owner.category, tags: [...owner.tags], rating: owner.rating, notes: owner.notes};
|
|
}
|
|
|
|
/** Only changed fields; an untouched fallback title is not a title edit. */
|
|
function metadataChanges(draft, seed, baseline) {
|
|
const changes = {};
|
|
const title = draft.title.trim(), category = draft.category.trim(), tags = parseTags(draft.tags);
|
|
if (draft.title !== seed.title && title !== stored(baseline.title)) changes.title = title;
|
|
if (category !== stored(baseline.category)) changes.category = category;
|
|
if (!sameTags(tags, baseline.tags)) changes.tags = tags;
|
|
if (draft.rating !== baseline.rating) changes.rating = draft.rating;
|
|
if (draft.notes !== (baseline.notes ?? '')) changes.notes = draft.notes;
|
|
return changes;
|
|
}
|
|
function createMetadata(draft) {
|
|
return {title: draft.title.trim(), category: draft.category.trim() || UNCATEGORIZED, tags: parseTags(draft.tags),
|
|
rating: draft.rating, notes: draft.notes};
|
|
}
|
|
// A blank category is stored as NULL; "Uncategorized" is only the UI's label for it.
|
|
const categoryKey = value => (stored(value) === UNCATEGORIZED ? '' : stored(value));
|
|
function sameMetadata(sent, owner) {
|
|
return sent.title === stored(owner.title) && categoryKey(sent.category) === categoryKey(owner.category) && sameTags(sent.tags, owner.tags) &&
|
|
sent.rating === owner.rating && sent.notes === (owner.notes ?? '');
|
|
}
|
|
function draftProblem(mode, draft) {
|
|
if (mode !== 'negative' && !draft.title.trim()) return Object.assign(new Error('Title is required'), {code: 'invalid-draft'});
|
|
if ((mode === 'new' || mode === 'revision') && !draft.positive_text.trim()) {
|
|
return Object.assign(new Error('Positive prompt is required'), {code: 'invalid-draft'});
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Phase for a failed write: only an exact 409 code drives recovery. */
|
|
function failedPhase(error) {
|
|
if (!isApiError(error)) return 'unknown-outcome';
|
|
if (error.status === 409 && Object.hasOwn(RECOVERY, error.code)) return RECOVERY[error.code];
|
|
if ((error.status >= 200 && error.status < 300) || UNCONFIRMED_STATUS.has(error.status)) return 'unknown-outcome';
|
|
return 'write-error';
|
|
}
|
|
|
|
function validateReceipt(command, value, {positiveId, owner, pairId}) {
|
|
requireValue(object(value) && value.success === true, 'Invalid save receipt');
|
|
if (command.kind === 'archive-pair' || command.kind === 'restore-pair') {
|
|
validatePair(value.pair, owner);
|
|
requireValue(value.pair.id === pairId && value.pair.archived === (command.kind === 'archive-pair'), 'Invalid save receipt');
|
|
return {success: true, library_epoch: command.epoch, pair: value.pair};
|
|
}
|
|
requireValue(value.library_epoch === command.body.expected_epoch, 'Invalid save receipt');
|
|
validateParent(value.positive, command.kind === 'create' ? undefined : positiveId);
|
|
if (command.kind === 'metadata') return value;
|
|
validatePair(value.pair, value.positive);
|
|
requireValue(typeof value.positive_created === 'boolean' && typeof value.pair_created === 'boolean' &&
|
|
value.parent_metadata_applied === value.positive_created && (value.pair_created || !value.positive_created) &&
|
|
(command.kind === 'create' || !value.positive_created), 'Invalid save receipt');
|
|
return value;
|
|
}
|
|
function outcomeOf(command, receipt) {
|
|
const recipe = command.kind === 'create' || command.kind === 'add-negative';
|
|
return {kind: command.kind, positiveCreated: recipe && receipt.positive_created, pairCreated: recipe && receipt.pair_created,
|
|
metadataNotApplied: command.kind === 'create' && !receipt.parent_metadata_applied &&
|
|
!sameMetadata(command.body.parent_metadata, receipt.positive),
|
|
pairArchived: receipt.pair ? receipt.pair.archived : null};
|
|
}
|
|
|
|
export function createLibraryEditorController({api, mode, positiveId = null, pairId = null, onChange = () => {},
|
|
onCommitted = () => {}, onReconciled = () => {}} = {}) {
|
|
requireValue(MODES.has(mode), 'Unknown editor mode');
|
|
const existing = mode !== 'new';
|
|
requireValue(existing ? id(positiveId) : positiveId === null && pairId === null, 'Invalid editor owner');
|
|
requireValue(pairId === null || id(pairId), 'Invalid editor pair');
|
|
const withOptions = mode !== 'negative';
|
|
let disposed = false, generation = 0, reads = null, started = null, review = false;
|
|
let seed = blankDraft(), pending = null, commit = null;
|
|
let state = freeze({phase: 'initializing', draft: blankDraft(), baseline: null, receipt: null, error: null,
|
|
reviewRequired: false, epoch: null, owner: null, pair: null, pairRead: 'none', pairReadError: null, categories: [], outcome: null,
|
|
reconciled: null, unchanged: false});
|
|
const emit = patch => {if (disposed) return; state = freeze({...state, ...patch}); onChange(state);};
|
|
|
|
/** Start a read that supersedes every older one; current() is false once stale. */
|
|
function beginRead() {
|
|
reads?.abort();
|
|
const controller = reads = new AbortController(), attempt = ++generation;
|
|
return {signal: controller.signal, current() {
|
|
if (disposed || attempt !== generation) return false;
|
|
reads = null; return true;
|
|
}};
|
|
}
|
|
|
|
async function load(keepDraft) {
|
|
const read = beginRead();
|
|
emit({phase: 'initializing', error: null});
|
|
try {
|
|
const snapshot = existing ? await readOwner(api, {positiveId, pairId, withOptions}, read.signal)
|
|
: await readNewSnapshot(api, read.signal);
|
|
if (!read.current()) return;
|
|
const next = seedDraft(mode, snapshot);
|
|
const draft = keepDraft ? overlay(next, state.draft, seed) : next;
|
|
seed = next;
|
|
emit({phase: 'ready', draft, baseline: baselineOf(snapshot.owner), epoch: snapshot.epoch, owner: snapshot.owner,
|
|
pair: snapshot.pair, pairRead: snapshot.pair ? 'fresh' : 'none', pairReadError: null, categories: snapshot.categories,
|
|
error: null, reviewRequired: review && keepDraft});
|
|
} catch (error) {
|
|
if (read.current()) emit({phase: 'initialization-error', error});
|
|
}
|
|
}
|
|
|
|
function command() {
|
|
const {draft, epoch, owner} = state;
|
|
if (mode === 'metadata') {
|
|
const metadata = metadataChanges(draft, seed, state.baseline);
|
|
return Object.keys(metadata).length ? {kind: 'metadata', method: 'patch', url: `/prompt_manager/library/${positiveId}`,
|
|
body: {expected_epoch: epoch, if_match: owner.edit_token, metadata}} : null;
|
|
}
|
|
if (mode === 'negative') {
|
|
return {kind: 'add-negative', method: 'post', url: `/prompt_manager/library/${positiveId}/variants`,
|
|
body: {expected_epoch: epoch, if_match: owner.edit_token, negative_text: draft.negative_text}};
|
|
}
|
|
return {kind: 'create', method: 'post', url: '/prompt_manager/library', body: {expected_epoch: epoch,
|
|
positive_text: draft.positive_text, negative_text: draft.negative_text, parent_metadata: createMetadata(draft)}};
|
|
}
|
|
|
|
async function send(next) {
|
|
pending = next;
|
|
emit({phase: 'saving', error: null});
|
|
let value;
|
|
try {
|
|
value = await api[next.method](next.url, next.body);
|
|
} catch (error) {
|
|
if (disposed) return;
|
|
const phase = failedPhase(error);
|
|
if (phase !== 'unknown-outcome') pending = null;
|
|
emit({phase, error});
|
|
return;
|
|
}
|
|
let receipt;
|
|
try {
|
|
receipt = freeze(structuredClone(validateReceipt(next, value, {positiveId, owner: state.owner, pairId: state.pair?.id})));
|
|
} catch (error) {
|
|
emit({phase: 'unknown-outcome', error});
|
|
return;
|
|
}
|
|
pending = null;
|
|
commit = freeze({kind: next.kind, receipt});
|
|
// A confirmed write still invalidates cached Library views after the dialog went away; nothing else runs then.
|
|
if (disposed) {onCommitted(commit); return;}
|
|
emit({receipt, outcome: outcomeOf(next, receipt)});
|
|
onCommitted(commit);
|
|
await reconcile();
|
|
}
|
|
|
|
async function reconcile() {
|
|
const read = beginRead();
|
|
try {
|
|
const result = await reconcileLibraryReceipt(api, commit, {signal: read.signal});
|
|
if (!read.current()) return;
|
|
emit({phase: 'complete', reconciled: result, error: null});
|
|
onReconciled(result);
|
|
} catch (error) {
|
|
if (read.current()) emit({phase: 'saved-refresh-error', error});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The pair route has no epoch guard, so every pair write, first or retried,
|
|
* first confirms with a fresh read that storage was not restored since
|
|
* `next.epoch`. A failed check read keeps `failurePhase` and writes nothing.
|
|
*/
|
|
async function sendFencedPair(next, failurePhase) {
|
|
emit({phase: 'saving', error: null});
|
|
const read = beginRead();
|
|
let current;
|
|
try {
|
|
current = await readOptions(api, read.signal);
|
|
} catch (error) {
|
|
if (read.current()) emit({phase: failurePhase, error});
|
|
return;
|
|
}
|
|
if (!read.current()) return;
|
|
if (current.library_epoch !== next.epoch) {
|
|
pending = null;
|
|
emit({phase: 'storage-changed', error: storageChanged()});
|
|
return;
|
|
}
|
|
await send(next);
|
|
}
|
|
|
|
async function setArchive(value) {
|
|
if (typeof value !== 'boolean') throw new TypeError('Archive state must be a boolean');
|
|
if (disposed || !existing || !SUBMITTABLE.has(state.phase)) return;
|
|
if (!state.pair) {emit({error: new Error('Choose a saved negative variant first.')}); return;}
|
|
await sendFencedPair(freeze({kind: value ? 'archive-pair' : 'restore-pair', method: 'patch',
|
|
url: `/prompt_manager/pairs/${state.pair.id}`, body: {archived: value}, epoch: state.epoch}), 'write-error');
|
|
}
|
|
|
|
return {
|
|
get state() {return state;},
|
|
initialize() {
|
|
if (!started) started = disposed ? Promise.resolve() : load(true);
|
|
return started;
|
|
},
|
|
setField(name, value) {
|
|
if (!FIELDS.includes(name)) throw new TypeError(`Unknown editor field: ${name}`);
|
|
const next = name === 'rating' ? normalizeRating(value) : value;
|
|
if (name !== 'rating' && typeof next !== 'string') throw new TypeError(`${name} must be a string`);
|
|
emit({draft: {...state.draft, [name]: next}});
|
|
},
|
|
async submit() {
|
|
if (disposed || !SUBMITTABLE.has(state.phase)) return;
|
|
const problem = draftProblem(mode, state.draft);
|
|
if (problem) {emit({error: problem}); return;}
|
|
const next = command();
|
|
if (!next) {emit({phase: 'complete', error: null, unchanged: true}); return;}
|
|
await send(freeze(next));
|
|
},
|
|
setArchive,
|
|
/**
|
|
* Re-read the selected pair (for example its linked counts) through the same
|
|
* exact-ID read and owner check as initialization. A failure keeps the
|
|
* previous pair and is reported as `pairRead: 'error'` with `pairReadError`.
|
|
*/
|
|
async refreshPair() {
|
|
if (disposed || !existing || !state.pair || !SUBMITTABLE.has(state.phase)) return;
|
|
const read = beginRead();
|
|
emit({pairRead: 'reading', pairReadError: null});
|
|
try {
|
|
const pair = await readPair(api, state.pair.id, read.signal);
|
|
validatePair(pair, state.owner);
|
|
if (read.current()) emit({pair, pairRead: 'fresh'});
|
|
} catch (error) {
|
|
if (read.current()) emit({pairRead: 'error', pairReadError: error});
|
|
}
|
|
},
|
|
async reloadOwner({keepDraft} = {}) {
|
|
if (typeof keepDraft !== 'boolean') throw new TypeError('reloadOwner needs keepDraft');
|
|
if (disposed || !RELOADABLE.has(state.phase)) return;
|
|
pending = null;
|
|
review = keepDraft;
|
|
await load(keepDraft);
|
|
},
|
|
async retryInitialization() {
|
|
if (!disposed && state.phase === 'initialization-error') await load(true);
|
|
},
|
|
async retryReconciliation() {
|
|
if (!disposed && state.phase === 'saved-refresh-error') await reconcile();
|
|
},
|
|
/** Explicit user action only: resend the identical unconfirmed payload once. */
|
|
async retryUnconfirmed() {
|
|
if (disposed || state.phase !== 'unknown-outcome' || !pending) return;
|
|
if (pending.kind === 'archive-pair' || pending.kind === 'restore-pair') {
|
|
// The outcome stays unknown if the fence read itself fails.
|
|
await sendFencedPair(pending, 'unknown-outcome');
|
|
return;
|
|
}
|
|
if (pending.kind !== 'metadata') {await send(pending); return;}
|
|
// A metadata retry needs the current owner reviewed, never a replayed token.
|
|
pending = null;
|
|
review = true;
|
|
await load(true);
|
|
},
|
|
dispose() {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
reads?.abort();
|
|
reads = null;
|
|
state = freeze({...state, phase: 'disposed'});
|
|
},
|
|
};
|
|
}
|