System > Appearance is the concept's Browsing & display surface on real values: the browser rows edit this browser's display defaults through the shared gallerySettings object, and Open from ComfyUI and the node search auto-hide save to the server settings with their revision. A new Images visit starts from the default layout and size (the current view and saved views keep theirs); metadata off waits for Load metadata; cache off re-reads the shown file; paths off hides the Path row; the viewer reads the video defaults. D97 adds card details, collapsed viewer details and Reset display defaults; storage refused says Saved for this tab only.
226 lines
9.8 KiB
JavaScript
226 lines
9.8 KiB
JavaScript
/**
|
||
* The System draft store (#249 System S2 Task 5): every System section edits drafts behind the one Save row (the
|
||
* concept's `.save-row`, app.js renderSystem 705), in three domains:
|
||
* - `browser`: preferences kept in this browser (Appearance);
|
||
* - `host`: the server configuration, saved with the `revision` its read returned (`expected_revision`, #220);
|
||
* - `runtime`: values applied to the running server only (the log level, Q10b).
|
||
*
|
||
* `save()` writes browser, then host, then runtime, and only the domains with changes. A domain that fails keeps its
|
||
* drafts dirty and its keys `failed()`, so the section highlights them; the others are saved. A host save refused
|
||
* because the configuration changed elsewhere (`settings-changed`) is a `conflict` and keeps the draft. A host save before
|
||
* `load('host')` returned a revision fails without a request: a blind write would merge over a configuration changed
|
||
* elsewhere (S1 reads a null `expected_revision` as "merge into what is on disk"). `reloadHost()` reads the host values
|
||
* again (System calls it on mount); a host conflict reloads them itself, so the next save carries the current revision.
|
||
* Drafts live for
|
||
* the page session (Q10a): a section left and opened again shows them, and a reload of its values keeps them.
|
||
*
|
||
* Adapters: `readBrowser()` and `readRuntime()` return the values; `readHost()` returns `{values, revision}`.
|
||
* `writeBrowser(changes)` returns `{persisted}` (false: kept for this tab only), `writeHost(changes,
|
||
* {expected_revision})` the settings response (`{success, persisted, revision}`), `writeRuntime(changes)` anything;
|
||
* a write that throws failed.
|
||
*/
|
||
export { SYSTEM_SECTIONS, isSystemSection } from './state.mjs';
|
||
|
||
/** D92: the Save row's status copy; never the concept's prototype text. */
|
||
export const SETTINGS_STATUS = Object.freeze({
|
||
clean: 'No unsaved changes.',
|
||
dirty: 'Unsaved changes.',
|
||
browser: 'Saved in this browser.',
|
||
host: 'Saved to the server configuration.',
|
||
runtime: 'Applied until the server restarts.',
|
||
partial: 'Some changes were not saved. Review the highlighted settings.',
|
||
tabOnly: 'Saved for this tab only; browser storage is unavailable.',
|
||
conflict: 'Settings changed elsewhere. Reload this section to see them.',
|
||
});
|
||
|
||
export const DRAFT_DOMAINS = Object.freeze(['browser', 'host', 'runtime']);
|
||
const CONFLICT_CODE = 'settings-changed';
|
||
const ADAPTERS = ['readBrowser', 'readHost', 'readRuntime', 'writeBrowser', 'writeHost', 'writeRuntime'];
|
||
|
||
/** The status copy of one `save()` outcome (`{browser, host, runtime}`, each clean/saved/tab-only/applied/failed/conflict). */
|
||
export function statusText(outcome) {
|
||
const results = DRAFT_DOMAINS.map(domain => outcome?.[domain]);
|
||
if (results.includes('conflict')) return SETTINGS_STATUS.conflict;
|
||
if (results.includes('failed')) return SETTINGS_STATUS.partial;
|
||
const saved = [];
|
||
if (outcome?.browser === 'saved') saved.push(SETTINGS_STATUS.browser);
|
||
if (outcome?.browser === 'tab-only') saved.push(SETTINGS_STATUS.tabOnly);
|
||
if (outcome?.host === 'saved') saved.push(SETTINGS_STATUS.host);
|
||
if (outcome?.runtime === 'applied') saved.push(SETTINGS_STATUS.runtime);
|
||
return saved.join(' ') || SETTINGS_STATUS.clean;
|
||
}
|
||
|
||
function requireDomain(domain) {
|
||
if (!DRAFT_DOMAINS.includes(domain)) throw new TypeError(`Unknown settings domain: ${domain}`);
|
||
}
|
||
|
||
/** Create the store; see the module comment for the adapters. */
|
||
export function createSystemDrafts(adapters) {
|
||
for (const name of ADAPTERS) {
|
||
if (typeof adapters?.[name] !== 'function') throw new TypeError(`System drafts need ${name}`);
|
||
}
|
||
const baseline = { browser: {}, host: {}, runtime: {} };
|
||
const drafts = { browser: new Map(), host: new Map(), runtime: new Map() };
|
||
const failedKeys = { browser: new Set(), host: new Set(), runtime: new Set() };
|
||
const listeners = new Set();
|
||
let revision = null;
|
||
let outcome = null;
|
||
let saving = null;
|
||
|
||
const notify = () => { for (const listener of [...listeners]) listener(); };
|
||
const changes = domain => Object.fromEntries(drafts[domain]);
|
||
|
||
/** Read a domain's saved values; drafts are kept, and a draft equal to the new value is clean. */
|
||
async function load(domain) {
|
||
requireDomain(domain);
|
||
let values;
|
||
if (domain === 'host') {
|
||
const read = await adapters.readHost();
|
||
values = read?.values ?? {};
|
||
revision = read?.revision ?? null;
|
||
} else {
|
||
values = await (domain === 'browser' ? adapters.readBrowser() : adapters.readRuntime());
|
||
}
|
||
baseline[domain] = { ...(values ?? {}) };
|
||
for (const [key, value] of drafts[domain]) if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key);
|
||
notify();
|
||
return { ...baseline[domain] };
|
||
}
|
||
|
||
function get(domain, key) {
|
||
requireDomain(domain);
|
||
return drafts[domain].has(key) ? drafts[domain].get(key) : baseline[domain][key];
|
||
}
|
||
|
||
function set(domain, key, value) {
|
||
requireDomain(domain);
|
||
if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key);
|
||
else drafts[domain].set(key, value);
|
||
failedKeys[domain].delete(key);
|
||
outcome = null;
|
||
notify();
|
||
}
|
||
|
||
function dirty(domain) {
|
||
if (domain !== undefined) {
|
||
requireDomain(domain);
|
||
return drafts[domain].size > 0;
|
||
}
|
||
return DRAFT_DOMAINS.some(name => drafts[name].size > 0);
|
||
}
|
||
|
||
function discard(domain) {
|
||
for (const name of domain === undefined ? DRAFT_DOMAINS : [domain]) {
|
||
requireDomain(name);
|
||
drafts[name].clear();
|
||
failedKeys[name].clear();
|
||
}
|
||
outcome = null;
|
||
notify();
|
||
}
|
||
|
||
/** The values of one domain as written: they become its saved values, and drafts still equal to them are clean. */
|
||
function commit(domain, written) {
|
||
for (const [key, value] of written) {
|
||
baseline[domain][key] = value;
|
||
if (drafts[domain].has(key) && Object.is(drafts[domain].get(key), value)) drafts[domain].delete(key);
|
||
}
|
||
}
|
||
|
||
function fail(domain, written) {
|
||
for (const [key] of written) failedKeys[domain].add(key);
|
||
}
|
||
|
||
async function saveDomain(domain) {
|
||
const written = new Map(drafts[domain]);
|
||
if (written.size === 0) return 'clean';
|
||
const values = Object.fromEntries(written);
|
||
try {
|
||
if (domain === 'browser') {
|
||
const result = await adapters.writeBrowser(values);
|
||
commit(domain, written);
|
||
return result?.persisted === false ? 'tab-only' : 'saved';
|
||
}
|
||
if (domain === 'host') {
|
||
if (typeof revision !== 'string') { fail(domain, written); return 'failed'; }
|
||
const result = await adapters.writeHost(values, { expected_revision: revision });
|
||
if (result?.code === CONFLICT_CODE) { fail(domain, written); return 'conflict'; }
|
||
if (!result?.success || result.persisted !== true) { fail(domain, written); return 'failed'; }
|
||
if (typeof result.revision === 'string') revision = result.revision;
|
||
commit(domain, written);
|
||
return 'saved';
|
||
}
|
||
await adapters.writeRuntime(values);
|
||
commit(domain, written);
|
||
return 'applied';
|
||
} catch (error) {
|
||
fail(domain, written);
|
||
return error?.code === CONFLICT_CODE ? 'conflict' : 'failed';
|
||
}
|
||
}
|
||
|
||
/** Read the host values again; a failed read leaves host saves refused until one succeeds. */
|
||
const reloadHost = () => load('host').then(() => true, () => false);
|
||
|
||
/** Save the dirty domains in order; a Save pressed while one runs joins it. */
|
||
function save() {
|
||
saving ??= (async () => {
|
||
for (const domain of DRAFT_DOMAINS) failedKeys[domain].clear();
|
||
const result = {};
|
||
for (const domain of DRAFT_DOMAINS) result[domain] = await saveDomain(domain);
|
||
// Settings changed elsewhere: read them (and their revision), keeping the drafts (Q10).
|
||
if (result.host === 'conflict') await reloadHost();
|
||
return result;
|
||
})().then(result => {
|
||
saving = null;
|
||
outcome = result;
|
||
notify();
|
||
return result;
|
||
});
|
||
return saving;
|
||
}
|
||
|
||
/** A value the control could not take (System S3: a timeout outside 0–300): no draft, and the control is highlighted. */
|
||
function reject(domain, key) {
|
||
requireDomain(domain);
|
||
drafts[domain].delete(key);
|
||
failedKeys[domain].add(key);
|
||
notify();
|
||
}
|
||
|
||
/**
|
||
* Another page changed saved values this tab has drafts for (a browser `storage` event, System S3): the drafts stay
|
||
* and are highlighted, and the status says the settings changed elsewhere.
|
||
*/
|
||
function conflict(domain, keys) {
|
||
requireDomain(domain);
|
||
if (!keys.length) return;
|
||
for (const key of keys) failedKeys[domain].add(key);
|
||
outcome = { [domain]: 'conflict' };
|
||
notify();
|
||
}
|
||
|
||
return {
|
||
load,
|
||
reloadHost,
|
||
reject,
|
||
conflict,
|
||
/** The saved values of a domain (its last read or save), not the drafts. */
|
||
saved: domain => { requireDomain(domain); return { ...baseline[domain] }; },
|
||
get,
|
||
set,
|
||
dirty,
|
||
discard,
|
||
save,
|
||
changes,
|
||
saving: () => saving !== null,
|
||
failed: domain => { requireDomain(domain); return new Set(failedKeys[domain]); },
|
||
/** The Save row's text: the last save's outcome until the next edit, else unsaved or clean. */
|
||
statusText: () => (outcome ? statusText(outcome) : dirty() ? SETTINGS_STATUS.dirty : SETTINGS_STATUS.clean),
|
||
subscribe(listener) {
|
||
listeners.add(listener);
|
||
return () => listeners.delete(listener);
|
||
},
|
||
};
|
||
}
|