* fix(snapshot): prevent drift corruption from duplicate installs + boot-time mismatch (#161, #162) Closes #162 and #161 as the paired fix the issues describe — both touch the same data-loss surface and verify against the same dev-sync rig. installs at import time, log critical, register nothing in the alphabetical loser; client-side `window` sentinel as belt-and-suspenders; align .env.example / dev-iteration-loop / sync_to_dev / README at the Registry-derived `koolook/` path. compare against live /userdata + picks, set a drift flag on mismatch; gate periodic + pre-load autosave routing to `_unsaved_autosave/` while drifted; promote status pill to "drifted" precedence over "saved" with a recovery tooltip; auto-clear on the next markStateSaved (Save / Load). 26 new tests (14 install guard, 4 sidebar sentinel, 8 boot drift) — 316 total passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(snapshot): apply review fixes — canonical fingerprint + early loser exit + drift timestamp Review-driven follow-ups on PR #186: 1. **Symmetrize file-side fingerprint (MEDIUM).** `detectBootDrift` now uses a new `_canonicalStateFingerprint()` that normalizes the workflows tree through `normalizeWorkflowsStore` AND deep-sorts keys via `sortJsonValue`. Without this, hand-edited / cross-machine / pre-v0.3 files would false-positive as drifted on first boot (e.g. `archived: "false"` string vs `false` bool, missing `directories: {}`, key insertion-order asymmetry from the `{...wf, archived, module, tags}` spread). `_computeFingerprint` stays unchanged so the localStorage save baseline contract holds for existing users. New regression test verifies a shape-only diff does NOT trigger drift. 2. **Gate fork imports on `_is_winning_install` (LOW).** Non-winning duplicate installs now short-circuit before the heavy torch / PIL / diffusers / aiohttp imports, exiting in ~5 LoC instead of paying full import cost just to discard the mappings. 3. **Drift timestamp uses `driftDetectedAt`, not stale `lastNamedSaveAt` (LOW).** The localStorage save baseline could be from a prior session whose live state was already corrupt — surfacing it as "Last named save" in the drift tooltip would mislead. Replaced with the session-noticed-at timestamp from `_bootDriftDiagnostics`. 352 tests passing (316 prior + 26 from PR #186 + 1 new normalize- symmetry case). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(install-guard): fail safe on unreadable / non-UTF-8 siblings (#162) Re-review follow-up. The import-time duplicate-install scan could crash the entire plugin load (fail-silent — the alphabetical winner vanished too, not just the loser) on two unguarded paths: - detect_duplicate_koolook_installs: the per-entry is_dir()/is_file() over custom_nodes/ siblings was unguarded, so a non-traversable sibling (restrictive perms, broken mount, a special dir) raised PermissionError up into __init__.py. No duplicate even required. Now caught per-entry and skipped. - read_pyproject_version: read_text(encoding="utf-8") raises UnicodeDecodeError (a ValueError, NOT OSError) on a non-UTF-8 / binary pyproject.toml, escaping the `except OSError`. Now degrades to "?" via `except (OSError, ValueError)`. - __init__.py: wrapped the guard call site as a final backstop — any unforeseen guard failure now degrades to register-normally rather than aborting the import. Both paths reproduced before the fix; the first surfaced while running the suite from a /tmp checkout whose parent held a root-owned dir. 3 regression tests added (non-traversable sibling skipped; non-UTF-8 pyproject -> "?"; report survives a non-UTF-8 sibling). Full suite: 452 passed (449 + 3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: kflame99 <261674183+kflame99@users.noreply.github.com>
1210 lines
54 KiB
JavaScript
1210 lines
54 KiB
JavaScript
// SPDX-FileCopyrightText: 2025-2026 Kforge Labs <https://github.com/malkuthro/ComfyUI-Koolook>
|
||
// SPDX-License-Identifier: GPL-3.0-only
|
||
|
||
// =============================================================================
|
||
// Snapshot / preset library (#46 item 1).
|
||
//
|
||
// A "snapshot" is a single JSON file that captures the full Kforge Labs state
|
||
// — curated picks + the entire workflows store — under a user-chosen name.
|
||
// Multiple snapshots live side-by-side as files in a configurable filesystem
|
||
// directory served by Koolook's `/koolook/presets/*` endpoints (see
|
||
// `koolook_routes.py`).
|
||
//
|
||
// Storage location:
|
||
// - Default: `<comfyui-userdata>/koolook-presets/`
|
||
// - Configurable: set the `KFORGELABS_PRESETS` env var on the ComfyUI
|
||
// server before launch to point at any absolute filesystem path
|
||
// (NFS / SMB mount for facility-shared libraries, Dropbox folder for
|
||
// cross-machine personal sync, etc.).
|
||
//
|
||
// Schema:
|
||
// {
|
||
// "kind": "koolook-snapshot",
|
||
// "version": 1,
|
||
// "name": "Wan video kit",
|
||
// "exportedAt": "2026-05-05T08:42:13.000Z",
|
||
// "picks": ["EasyResize_Koolook", ...],
|
||
// "workflows": { "directories": { ... } }
|
||
// }
|
||
//
|
||
// `kind` + `version` are dispatch fields for future migrations. Anything
|
||
// without `kind === "koolook-snapshot"` is rejected on import — keeps a
|
||
// stray unrelated JSON file from being applied as state.
|
||
//
|
||
// Server-side filename validation: filenames are restricted to a whitelist
|
||
// at the route handler. Anything outside that whitelist returns 400 from
|
||
// the server.
|
||
// =============================================================================
|
||
import { loadUserPicks, setAllPicks, notifyPicksChanged } from "./picks_store.js";
|
||
import {
|
||
replaceAllWorkflows,
|
||
getAllWorkflowsForExport,
|
||
normalizeWorkflowsStore,
|
||
sortJsonValue,
|
||
} from "./workflows_store.js";
|
||
import {
|
||
STARTER_SEEDED_KEY,
|
||
STARTER_URL,
|
||
STARTER_PRESET_FILENAME,
|
||
SNAPSHOT_STATUS_CHANGED_EVENT,
|
||
noStoreUrl,
|
||
toast,
|
||
compareNames,
|
||
} from "./constants.js";
|
||
|
||
export const SNAPSHOT_KIND = "koolook-snapshot";
|
||
export const SNAPSHOT_VERSION = 1;
|
||
|
||
// =============================================================================
|
||
// Filename hygiene
|
||
// =============================================================================
|
||
|
||
// User-typed names need to survive filesystem + URL roundtripping AND
|
||
// match the server's filename whitelist (see `_FILENAME_RE` in
|
||
// `koolook_routes.py`: `^[A-Za-z0-9 _.()\-]+\.json$`). The server
|
||
// validates at the route boundary; we mirror the same rules here so a
|
||
// reasonable-looking name doesn't sail through the form only to fail
|
||
// with an opaque HTTP 400 toast at save time.
|
||
//
|
||
// Strategy: replace anything outside the server whitelist with `_`,
|
||
// collapse whitespace runs, trim. Empty after normalization → null
|
||
// (caller should reject before submit).
|
||
export function sanitizeName(raw) {
|
||
if (typeof raw !== "string") return null;
|
||
const cleaned = raw
|
||
// Replace any character outside the server's whitelist with `_`
|
||
// — `+`, `&`, `'`, `,`, `;`, `[]`, accented letters, emoji, etc.
|
||
// all collapse to underscores rather than triggering a 400.
|
||
.replace(/[^A-Za-z0-9 _.()\-]+/g, "_")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
return cleaned || null;
|
||
}
|
||
|
||
// =============================================================================
|
||
// Capture / apply (the in-memory side)
|
||
// =============================================================================
|
||
|
||
export function gatherSnapshot(name) {
|
||
return {
|
||
kind: SNAPSHOT_KIND,
|
||
version: SNAPSHOT_VERSION,
|
||
name,
|
||
exportedAt: new Date().toISOString(),
|
||
picks: loadUserPicks(),
|
||
workflows: getAllWorkflowsForExport(),
|
||
};
|
||
}
|
||
|
||
// Validate a parsed object as a snapshot. Returns the snapshot on success,
|
||
// throws an Error with a user-readable message on failure. The error
|
||
// messages get surfaced verbatim in toasts.
|
||
export function validateSnapshot(obj) {
|
||
if (!obj || typeof obj !== "object") {
|
||
throw new Error("Not a valid snapshot file (empty or non-object).");
|
||
}
|
||
if (obj.kind !== SNAPSHOT_KIND) {
|
||
throw new Error(`Not a Kforge Labs snapshot — expected kind="${SNAPSHOT_KIND}".`);
|
||
}
|
||
if (typeof obj.version !== "number" || obj.version < 1) {
|
||
throw new Error(`Snapshot version "${obj.version}" not recognized.`);
|
||
}
|
||
if (obj.version > SNAPSHOT_VERSION) {
|
||
throw new Error(
|
||
`Snapshot version ${obj.version} is newer than this Kforge Labs ` +
|
||
`understands (max v${SNAPSHOT_VERSION}). Update the plugin to load it.`
|
||
);
|
||
}
|
||
if (!Array.isArray(obj.picks)) {
|
||
throw new Error("Snapshot is missing a `picks` array.");
|
||
}
|
||
if (!obj.workflows || typeof obj.workflows !== "object") {
|
||
throw new Error("Snapshot is missing a `workflows` object.");
|
||
}
|
||
return obj;
|
||
}
|
||
|
||
// Apply a validated snapshot to the in-memory + persisted stores. Returns
|
||
// `{picksOk, workflowsOk}` so the caller can surface a partial-failure
|
||
// toast if one of the two backends rejected the write.
|
||
export async function applySnapshot(snapshot) {
|
||
const picksOk = setAllPicks(snapshot.picks);
|
||
if (picksOk) notifyPicksChanged();
|
||
const workflowsOk = await replaceAllWorkflows(snapshot.workflows);
|
||
return { picksOk, workflowsOk };
|
||
}
|
||
|
||
// =============================================================================
|
||
// Auto-save (defensive snapshots).
|
||
//
|
||
// All autosaves live in a per-preset SUBFOLDER under the library directory,
|
||
// invisible to the user-facing Load list:
|
||
//
|
||
// <library>/
|
||
// Wan video kit.json ← user-named save (in Load list)
|
||
// Wan video kit_autosave/ ← shadow folder (HIDDEN from Load)
|
||
// periodic.json ← single rolling file, overwritten
|
||
// pre_load_<iso>.json × ≤5 ← rotated; permanent per Load
|
||
// another preset.json
|
||
// _unsaved_autosave/ ← used when no preset is tracked
|
||
// periodic.json
|
||
// pre_load_<iso>.json × ≤5
|
||
//
|
||
// The server (`koolook_routes.py`) hides subfolders + legacy `_autosave_*`
|
||
// flat files from the root-level list endpoint, so the regular Load dialog
|
||
// never enumerates these files. Recovery is via direct filesystem access.
|
||
//
|
||
// Why a subfolder per preset, not flat? The user's library was getting
|
||
// cluttered with `_autosave_*` files at root (visible in Total Commander,
|
||
// Finder, etc.). Tying autosaves to a per-preset folder makes the on-disk
|
||
// structure self-documenting: "the shadow recovery for X lives at X_autosave/".
|
||
//
|
||
// Why one rolling `periodic.json` (not timestamped)? The latest periodic
|
||
// auto-save IS the most useful periodic state. Earlier periodic snapshots
|
||
// are worse copies of approximately the same work. Keeping just one trades
|
||
// disk diversity for cleanliness — and pre-load files cover the "I want a
|
||
// recovery point from before a deliberate destructive action" case.
|
||
//
|
||
// Why timestamped pre-load files (not rolling)? Each Load is a deliberate
|
||
// destructive action and deserves a permanent recovery point. Rotation
|
||
// keeps last 5 to bound disk; rare "5 Loads back" recovery loss is the
|
||
// trade-off for not letting the folder grow unbounded.
|
||
//
|
||
// `setCurrentPresetName` is intentionally NOT called by any auto-save —
|
||
// these aren't user-initiated saves and shouldn't hijack the "Save over X"
|
||
// default the way a manual Save does.
|
||
// =============================================================================
|
||
|
||
const AUTOSAVE_PRE_LOAD_PREFIX = "pre_load_";
|
||
const AUTOSAVE_PERIODIC_FILENAME = "periodic";
|
||
const AUTOSAVE_UNSAVED_SUBDIR = "_unsaved_autosave";
|
||
const AUTOSAVE_SUBDIR_SUFFIX = "_autosave";
|
||
const AUTOSAVE_PRE_LOAD_KEEP = 5;
|
||
|
||
function isoToFsToken(iso) {
|
||
// 2026-05-06T11:45:33.123Z → 2026-05-06T11-45-33-123Z
|
||
return iso.replace(/[:.]/g, "-");
|
||
}
|
||
|
||
// Resolves the subfolder name for the current session's autosaves.
|
||
// Tied to `getCurrentPresetName()` — when the user loads or saves "Foo"
|
||
// the autosaves redirect to `Foo_autosave/`. When no preset is tracked
|
||
// (fresh session, or user explicitly cleared the tracker) autosaves go
|
||
// to `_unsaved_autosave/`. Sanitizes the preset name through the same
|
||
// filename whitelist so the dirname is server-validation-safe.
|
||
//
|
||
// Boot-time drift override (#161): when the session opened with the
|
||
// tracked snapshot's named file diverging from the live state, route
|
||
// auto-saves to `_unsaved_autosave/` regardless of whether a preset is
|
||
// tracked. This is the core data-loss-prevention move — without it the
|
||
// periodic timer captures the (potentially corrupt) live state INTO the
|
||
// named preset's recovery folder, where the Load dialog will later
|
||
// present it as a "newer recovery" choice and the user can accept it
|
||
// thinking it's a legitimate auto-save of their work.
|
||
function _autosaveSubdir() {
|
||
if (_bootDrifted) return AUTOSAVE_UNSAVED_SUBDIR;
|
||
const name = getCurrentPresetName();
|
||
if (!name) return AUTOSAVE_UNSAVED_SUBDIR;
|
||
const sanitized = sanitizeName(name);
|
||
if (!sanitized) return AUTOSAVE_UNSAVED_SUBDIR;
|
||
return sanitized + AUTOSAVE_SUBDIR_SUFFIX;
|
||
}
|
||
|
||
// =============================================================================
|
||
// Snapshot status — tracks whether the live in-memory state matches the last
|
||
// named save, the latest periodic auto-save, both, or neither. Surfaces in
|
||
// the sidebar via `getSnapshotStatus()`.
|
||
//
|
||
// Two reference fingerprints:
|
||
// • `_lastNamedSaveFingerprint` — set by markStateSaved() on Save / Load
|
||
// success. Persisted to localStorage so the baseline survives reloads
|
||
// (otherwise every fresh page-load would show "saved" briefly even
|
||
// after the user mutated the state in a previous session).
|
||
// • `_lastPeriodicFingerprint` — set when a periodic auto-save lands.
|
||
// Lives in memory only; periodic auto-saves are session-scoped recovery
|
||
// points, not user-visible save history.
|
||
//
|
||
// `_computeFingerprint()` strips fields that change every call (`exportedAt`,
|
||
// the wrapping `name`/`kind`/`version` envelope) so the comparison is
|
||
// "did the actual data change" rather than "is this a different export."
|
||
// =============================================================================
|
||
|
||
const SAVED_FINGERPRINT_KEY = "koolook.snapshot.savedFingerprint.v1";
|
||
const SAVED_AT_KEY = "koolook.snapshot.savedAt.v1";
|
||
|
||
let _lastNamedSaveFingerprint = null;
|
||
let _lastNamedSaveAt = null;
|
||
let _lastPeriodicFingerprint = null;
|
||
let _lastPeriodicAt = null;
|
||
|
||
// =============================================================================
|
||
// Boot-time drift state (#161). Set by `detectBootDrift()` when the live
|
||
// `/userdata` workflow store + picks diverge from the tracked named snapshot
|
||
// file on disk at session start. While set:
|
||
// • `_autosaveSubdir()` routes periodic auto-saves to `_unsaved_autosave/`
|
||
// so the named preset's recovery folder is never overwritten with the
|
||
// corrupted live state (the data-loss surface this whole guard exists
|
||
// to close).
|
||
// • `getSnapshotStatus()` returns `state: "drifted"` with highest
|
||
// precedence so the sidebar pill carries the warning.
|
||
// Cleared by `_clearBootDriftInternal()` whenever `markStateSaved()` runs —
|
||
// a deliberate Save or Load realigns the two stores, so the warning auto-
|
||
// extinguishes without the user having to dismiss it.
|
||
// =============================================================================
|
||
let _bootDrifted = false;
|
||
let _bootDriftDiagnostics = null;
|
||
|
||
(function _initSavedFingerprintFromStorage() {
|
||
try {
|
||
const v = localStorage.getItem(SAVED_FINGERPRINT_KEY);
|
||
if (typeof v === "string" && v) _lastNamedSaveFingerprint = v;
|
||
const at = localStorage.getItem(SAVED_AT_KEY);
|
||
if (typeof at === "string" && at) _lastNamedSaveAt = at;
|
||
} catch (e) {
|
||
// localStorage unreadable (private mode + permission edge cases).
|
||
// Status will be slightly less accurate this session — fine.
|
||
}
|
||
})();
|
||
|
||
function _persistSavedFingerprint() {
|
||
try {
|
||
if (_lastNamedSaveFingerprint) {
|
||
localStorage.setItem(SAVED_FINGERPRINT_KEY, _lastNamedSaveFingerprint);
|
||
} else {
|
||
localStorage.removeItem(SAVED_FINGERPRINT_KEY);
|
||
}
|
||
if (_lastNamedSaveAt) {
|
||
localStorage.setItem(SAVED_AT_KEY, _lastNamedSaveAt);
|
||
} else {
|
||
localStorage.removeItem(SAVED_AT_KEY);
|
||
}
|
||
} catch (e) {
|
||
// Quota / private mode — the in-memory fingerprint still works for
|
||
// this session; only cross-session accuracy degrades.
|
||
}
|
||
}
|
||
|
||
function _computeFingerprint() {
|
||
return JSON.stringify({
|
||
picks: loadUserPicks(),
|
||
workflows: getAllWorkflowsForExport(),
|
||
});
|
||
}
|
||
|
||
// Canonical state fingerprint used exclusively by the boot-time drift
|
||
// detector (#161). Sorts picks (set semantics, order irrelevant) and
|
||
// normalize-then-sorts the workflows tree so two state snapshots with
|
||
// the same content but different shapes produce equal fingerprints.
|
||
// See the long comment in ``detectBootDrift`` for why this is distinct
|
||
// from ``_computeFingerprint``.
|
||
function _canonicalStateFingerprint(picks, workflows) {
|
||
const picksList = Array.isArray(picks) ? [...picks].sort(compareNames) : [];
|
||
const workflowsTree = workflows && typeof workflows === "object"
|
||
? workflows
|
||
: { directories: {} };
|
||
const canonicalWorkflows = sortJsonValue(normalizeWorkflowsStore(workflowsTree));
|
||
return JSON.stringify({ picks: picksList, workflows: canonicalWorkflows });
|
||
}
|
||
|
||
function _emitStatusChanged() {
|
||
try {
|
||
window.dispatchEvent(new CustomEvent(SNAPSHOT_STATUS_CHANGED_EVENT));
|
||
} catch (e) {
|
||
// Some test environments don't have window — defensive only.
|
||
}
|
||
}
|
||
|
||
// Mark the current in-memory state as "saved" — call after any successful
|
||
// named save (writePreset for a user-named preset) or successful Load
|
||
// (applySnapshot baseline). Auto-saves do NOT call this; they're not
|
||
// user-initiated saves and shouldn't reset the dirty indicator.
|
||
//
|
||
// Also clears any boot-time drift flag (#161): a successful Save means
|
||
// the named file on disk now matches the live state, so the drift the
|
||
// session opened with is resolved. A successful Load is the other
|
||
// realignment path — both call `markStateSaved()`, so this is the single
|
||
// point that retires the drift warning.
|
||
export function markStateSaved() {
|
||
_lastNamedSaveFingerprint = _computeFingerprint();
|
||
_lastNamedSaveAt = new Date().toISOString();
|
||
_persistSavedFingerprint();
|
||
_clearBootDriftInternal();
|
||
_emitStatusChanged();
|
||
}
|
||
|
||
// Mark the current in-memory state as matching the latest periodic
|
||
// auto-save. Call after restoring from `<preset>_autosave/periodic.json`
|
||
// — the in-memory state IS that auto-save's content, but the named file
|
||
// on disk is still the OLDER deliberate save. Calling `markStateSaved()`
|
||
// here would mis-baseline the dirty indicator as "saved" against the
|
||
// named file, blurring the deliberate-save model the snapshot UX relies
|
||
// on. Setting `_lastPeriodicFingerprint` instead makes the indicator read
|
||
// "auto-saved" until the user explicitly Save / Quick Save's the
|
||
// restored state into the named file. Session-scoped (not persisted) —
|
||
// matches the rest of the periodic-fingerprint contract.
|
||
export function markStateAutosaved() {
|
||
_lastPeriodicFingerprint = _computeFingerprint();
|
||
_lastPeriodicAt = new Date().toISOString();
|
||
_emitStatusChanged();
|
||
}
|
||
|
||
// Returns one of:
|
||
// { name: <string|null>,
|
||
// state: "drifted" | "saved" | "autosaved" | "unsaved" | "none",
|
||
// lastNamedSaveAt: <ISO|null>, lastAutosaveAt: <ISO|null> }
|
||
//
|
||
// Status precedence (highest first):
|
||
// • "drifted" — boot-time check found the named-snapshot file on disk
|
||
// diverged from the live /userdata state; user must
|
||
// Reload or re-Save to realign (#161)
|
||
// • "saved" — current fingerprint matches the last named save
|
||
// • "autosaved" — current matches the latest periodic auto-save (only)
|
||
// • "unsaved" — there IS a tracked preset but state diverged
|
||
// • "none" — no preset tracked, no autosave match
|
||
//
|
||
// "drifted" wins over "saved" because the localStorage fingerprint can be
|
||
// stale — if a previous session baselined against already-corrupt live
|
||
// state, the in-memory fingerprint would falsely satisfy `"saved"`. The
|
||
// disk-file comparison run by `detectBootDrift()` at session start is the
|
||
// authoritative check; until the user Reloads or Saves, we keep flagging.
|
||
export function getSnapshotStatus() {
|
||
const name = getCurrentPresetName();
|
||
const fp = _computeFingerprint();
|
||
if (_bootDrifted) {
|
||
// ``lastNamedSaveAt`` is from localStorage and may be a baseline
|
||
// captured by a PRIOR session whose live state was already corrupt
|
||
// — surfacing it in the drift tooltip ("Last named save: <stale>")
|
||
// would mislead users about when the divergence actually happened.
|
||
// Replace it with ``driftDetectedAt`` (when this session noticed
|
||
// the disk-vs-live disagreement) so the tooltip's timestamp
|
||
// anchors on a meaningful event. ``lastAutosaveAt`` stays — it's
|
||
// session-scoped and the only autosaves landing while drifted go
|
||
// to ``_unsaved_autosave/`` so the timestamp is still accurate.
|
||
return {
|
||
name,
|
||
state: "drifted",
|
||
lastNamedSaveAt: null,
|
||
lastAutosaveAt: _lastPeriodicAt,
|
||
driftDetectedAt: _bootDriftDiagnostics ? _bootDriftDiagnostics.detectedAt : null,
|
||
};
|
||
}
|
||
if (_lastNamedSaveFingerprint && fp === _lastNamedSaveFingerprint) {
|
||
return { name, state: "saved", lastNamedSaveAt: _lastNamedSaveAt, lastAutosaveAt: _lastPeriodicAt };
|
||
}
|
||
if (_lastPeriodicFingerprint && fp === _lastPeriodicFingerprint) {
|
||
return { name, state: "autosaved", lastNamedSaveAt: _lastNamedSaveAt, lastAutosaveAt: _lastPeriodicAt };
|
||
}
|
||
if (name) {
|
||
return { name, state: "unsaved", lastNamedSaveAt: _lastNamedSaveAt, lastAutosaveAt: _lastPeriodicAt };
|
||
}
|
||
return { name: null, state: "none", lastNamedSaveAt: _lastNamedSaveAt, lastAutosaveAt: _lastPeriodicAt };
|
||
}
|
||
|
||
// =============================================================================
|
||
// Boot-time drift detection (#161). Invoked once from `koolook_sidebar.js`'s
|
||
// `setup()` immediately after `loadWorkflowsStore()` completes. Reads the
|
||
// tracked named snapshot file from the library, computes its fingerprint
|
||
// using the same shape as the live-state fingerprint (`picks` + `workflows`,
|
||
// dropping the wrapping `name`/`kind`/`version`/`exportedAt` envelope), and
|
||
// compares. On mismatch, sets `_bootDrifted` — see the state block above
|
||
// for what gates on the flag.
|
||
//
|
||
// Skips silently when no preset is tracked (fresh user, nothing to verify
|
||
// against) or when the named file can't be read (renamed, deleted, server
|
||
// down). Those are distinct failure modes from "the named file and live
|
||
// state disagree" and shouldn't trip the warning — the named file's
|
||
// absence already surfaces via the regular Load dialog's empty-state.
|
||
// =============================================================================
|
||
export async function detectBootDrift(trackedName) {
|
||
if (!trackedName) return null;
|
||
let snapshot;
|
||
try {
|
||
snapshot = await readPreset(trackedName);
|
||
} catch (e) {
|
||
// Named file unreadable — log so power users notice, but don't
|
||
// flag as drifted. The bug this guard catches is "file says X,
|
||
// live state is Y"; "file is gone" is a separate UX path.
|
||
console.warn(
|
||
`[Koolook] boot drift check skipped: tracked snapshot "${trackedName}" ` +
|
||
`not readable (${e.message}). Status pill will show whatever the ` +
|
||
`localStorage fingerprint baseline reports.`
|
||
);
|
||
return null;
|
||
}
|
||
// Canonicalize both sides before fingerprinting. The live cache and
|
||
// a freshly-imported snapshot file can carry the same content in
|
||
// different *shapes*:
|
||
// • ``archived: "false"`` (string, hand-edit) vs ``false`` (bool).
|
||
// • Missing ``directories: {}`` on pre-v0.3 leaf nodes vs present.
|
||
// • Picks accumulated in a different order on the source machine.
|
||
// • Object keys spread-rewrapped in a different insertion order
|
||
// by ``normalizeDirNode`` depending on whether the input already
|
||
// had ``archived`` / ``module`` / ``tags`` declared.
|
||
// None of these differences mean the user's state diverged. Without
|
||
// the canonicalization below, the first boot after a cross-machine
|
||
// snapshot import (the documented NFS/SMB/Dropbox sync workflow —
|
||
// see L13-18) would false-positive as drift, redirecting every
|
||
// autosave to ``_unsaved_autosave/`` until the user re-saved.
|
||
//
|
||
// The canonical form runs:
|
||
// 1. ``normalizeWorkflowsStore`` — same transforms the live cache
|
||
// passes through on every load/persist (idempotent on the
|
||
// happy path).
|
||
// 2. ``sortJsonValue`` — case-insensitive recursive key sort, so
|
||
// object key order is stable regardless of how the source
|
||
// shape was built.
|
||
// 3. Sort the picks array — picks-as-set semantics; order is
|
||
// irrelevant for equality.
|
||
// ``_computeFingerprint`` deliberately stays unsorted: it's used as
|
||
// the localStorage-persisted save baseline for the dirty indicator,
|
||
// and changing its shape would invalidate every existing user's
|
||
// baseline on first load post-deploy. The boot-drift comparison is
|
||
// session-local, so it can canonicalize freely.
|
||
const fileFingerprint = _canonicalStateFingerprint(
|
||
snapshot.picks,
|
||
snapshot.workflows,
|
||
);
|
||
const liveFingerprint = _canonicalStateFingerprint(
|
||
loadUserPicks(),
|
||
getAllWorkflowsForExport(),
|
||
);
|
||
if (fileFingerprint === liveFingerprint) {
|
||
_clearBootDriftInternal();
|
||
_emitStatusChanged();
|
||
return { drifted: false, trackedName };
|
||
}
|
||
_bootDrifted = true;
|
||
_bootDriftDiagnostics = {
|
||
trackedName,
|
||
fileFingerprintBytes: fileFingerprint.length,
|
||
liveFingerprintBytes: liveFingerprint.length,
|
||
detectedAt: new Date().toISOString(),
|
||
};
|
||
console.warn(
|
||
`[Koolook] DRIFT DETECTED: tracked snapshot "${trackedName}" diverges from ` +
|
||
`live state at session start.\n` +
|
||
` Named snapshot file: ${fileFingerprint.length} bytes (canonical state).\n` +
|
||
` Live /userdata + picks: ${liveFingerprint.length} bytes.\n` +
|
||
` Periodic auto-saves will redirect to _unsaved_autosave/ until you\n` +
|
||
` Reload "${trackedName}" or Save over it. This prevents the live\n` +
|
||
` state from contaminating the named snapshot's recovery folder.`
|
||
);
|
||
_emitStatusChanged();
|
||
return { drifted: true, trackedName, diagnostics: _bootDriftDiagnostics };
|
||
}
|
||
|
||
// True iff `detectBootDrift()` flagged a mismatch this session and no
|
||
// Save/Load has realigned the state since. Read by `_autosaveSubdir()` to
|
||
// gate the periodic-autosave routing, and by the sidebar pill renderer.
|
||
export function isBootDrifted() {
|
||
return _bootDrifted;
|
||
}
|
||
|
||
// Diagnostic payload captured at drift detect time — surfaced as part of
|
||
// the sidebar pill tooltip and useful for support-style "what was the
|
||
// drift size?" questions in the console.
|
||
export function getBootDriftDiagnostics() {
|
||
return _bootDriftDiagnostics;
|
||
}
|
||
|
||
// Public version of `_clearBootDriftInternal` for callers that have
|
||
// independently realigned the state (e.g. a test that wants to reset
|
||
// state without going through Save/Load). Production code clears via
|
||
// `markStateSaved()`; this is the explicit escape hatch.
|
||
export function clearBootDrift() {
|
||
if (!_bootDrifted) return;
|
||
_clearBootDriftInternal();
|
||
_emitStatusChanged();
|
||
}
|
||
|
||
function _clearBootDriftInternal() {
|
||
if (!_bootDrifted) return;
|
||
_bootDrifted = false;
|
||
_bootDriftDiagnostics = null;
|
||
}
|
||
|
||
// Write a pre-load auto-save into the current session's autosave subfolder
|
||
// and return a `<subdir>/<filename>` token on success. Throws on persist
|
||
// failure — callers should treat that as a hard abort signal: the
|
||
// destructive Load that motivated this auto-save MUST NOT proceed without
|
||
// a recovery point, otherwise the entire premise of the feature collapses.
|
||
//
|
||
// IMPORTANT: the subfolder is captured at write time, NOT load time. Pre-
|
||
// load autosaves preserve the OLD state (which is named after the OLD
|
||
// preset). If the user has "Foo" loaded and Loads "Bar", the pre-load
|
||
// recovery file lands in `Foo_autosave/` — that's where the user looks
|
||
// when they realize they wanted to undo back to the Foo state.
|
||
export async function writePreLoadAutosave(label) {
|
||
const isoNow = new Date().toISOString();
|
||
const subdir = _autosaveSubdir();
|
||
const fileName = AUTOSAVE_PRE_LOAD_PREFIX + isoToFsToken(isoNow);
|
||
const displayName = label
|
||
? `Pre-load auto-save · ${subdir} · ${label} · ${isoNow}`
|
||
: `Pre-load auto-save · ${subdir} · ${isoNow}`;
|
||
const snap = gatherSnapshot(displayName);
|
||
await writePreset(fileName, snap, { dir: subdir });
|
||
// Prune older pre-load files in the same subdir to bound disk.
|
||
await _prunePreLoadAutosaves(subdir, AUTOSAVE_PRE_LOAD_KEEP);
|
||
return `${subdir}/${fileName}`;
|
||
}
|
||
|
||
async function _prunePreLoadAutosaves(subdir, keep) {
|
||
try {
|
||
const all = await listPresets({ dir: subdir });
|
||
// Lex sort of ISO-ish filenames is chronological order. We strip
|
||
// to pre-load files only (keeping `periodic.json` untouched),
|
||
// sort newest-first, delete everything past `keep`.
|
||
const preLoads = all
|
||
.filter((p) => typeof p.fileName === "string" &&
|
||
p.fileName.startsWith(AUTOSAVE_PRE_LOAD_PREFIX))
|
||
.sort((a, b) => b.fileName.localeCompare(a.fileName));
|
||
const toDelete = preLoads.slice(keep);
|
||
for (const p of toDelete) {
|
||
try {
|
||
await deletePreset(p.fileName, { dir: subdir });
|
||
} catch (e) {
|
||
console.warn(`[Koolook] failed to prune ${subdir}/${p.fileName}:`, e);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn(`[Koolook] pre-load prune failed for ${subdir}:`, e);
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// Periodic auto-save — module-private timer + change-detection state.
|
||
// Started by `startPeriodicAutosave` (called from the entry `setup()`).
|
||
// Shares `_lastPeriodicFingerprint` with the snapshot-status block above —
|
||
// when a tick lands successfully, that's also the new "matches latest
|
||
// auto-save" reference for the status indicator. `null` initial value
|
||
// guarantees the first tick saves unconditionally, capturing a baseline
|
||
// recovery point ~30s after sidebar open.
|
||
// =============================================================================
|
||
let _periodicTimerId = null;
|
||
let _periodicFirstTickId = null;
|
||
|
||
async function _periodicTick() {
|
||
// Skip while the tab is in the background — saving while the user can't
|
||
// see toasts / errors is fine, but saving WORK they're not actively
|
||
// doing isn't valuable, and quietly hammering /userdata in a backgrounded
|
||
// tab wastes server cycles.
|
||
if (typeof document !== "undefined" && document.hidden) return;
|
||
try {
|
||
const isoNow = new Date().toISOString();
|
||
const subdir = _autosaveSubdir();
|
||
const displayName = `Periodic auto-save · ${subdir} · ${isoNow}`;
|
||
const snap = gatherSnapshot(displayName);
|
||
// Change detection — uses the same picks-and-workflows fingerprint
|
||
// as the status indicator so post-tick the "auto-saved" status is
|
||
// accurate without recomputing.
|
||
const fingerprint = _computeFingerprint();
|
||
if (fingerprint === _lastPeriodicFingerprint) return;
|
||
// Single rolling file inside the subdir — overwriting the previous
|
||
// periodic snapshot. No rotation / no list-and-delete dance; the
|
||
// latest periodic IS the most useful, and pre-load files cover the
|
||
// "deliberate destructive-action recovery" case independently.
|
||
await writePreset(AUTOSAVE_PERIODIC_FILENAME, snap, { dir: subdir });
|
||
_lastPeriodicFingerprint = fingerprint;
|
||
_lastPeriodicAt = isoNow;
|
||
// Tell the sidebar status indicator to refresh — state didn't change
|
||
// (mutation events would have covered that) but the *match* against
|
||
// the latest auto-save did, so "unsaved" can flip to "auto-saved".
|
||
_emitStatusChanged();
|
||
console.log(`[Koolook] periodic auto-save: ${subdir}/${AUTOSAVE_PERIODIC_FILENAME}.json`);
|
||
} catch (e) {
|
||
// Library unreachable, read-only mount, etc. — log and try again
|
||
// next interval. Periodic auto-save failures should NOT surface
|
||
// as toasts; they're a background defensive layer, not a primary
|
||
// action the user is waiting on.
|
||
console.warn("[Koolook] periodic auto-save failed:", e);
|
||
}
|
||
}
|
||
|
||
// Start the periodic auto-save loop. Idempotent — calling twice is a no-op
|
||
// (the second call returns without registering another timer). Default
|
||
// interval 5 minutes. The first tick fires after a short grace period
|
||
// (30s) rather than immediately so the load / seed flows have time to
|
||
// settle before we capture the first baseline.
|
||
export function startPeriodicAutosave({ intervalMs = 5 * 60 * 1000, firstTickDelayMs = 30 * 1000 } = {}) {
|
||
if (_periodicTimerId !== null) return;
|
||
const tick = () => _periodicTick();
|
||
_periodicFirstTickId = setTimeout(tick, firstTickDelayMs);
|
||
_periodicTimerId = setInterval(tick, intervalMs);
|
||
console.log(
|
||
`[Koolook] periodic auto-save started: every ${Math.round(intervalMs / 1000)}s, ` +
|
||
`first tick in ${Math.round(firstTickDelayMs / 1000)}s`
|
||
);
|
||
}
|
||
|
||
// Stop the periodic auto-save loop. Idempotent. Useful for tests and for
|
||
// hot-reload paths during development; in normal use the timer outlives
|
||
// the page anyway.
|
||
export function stopPeriodicAutosave() {
|
||
if (_periodicFirstTickId !== null) {
|
||
clearTimeout(_periodicFirstTickId);
|
||
_periodicFirstTickId = null;
|
||
}
|
||
if (_periodicTimerId !== null) {
|
||
clearInterval(_periodicTimerId);
|
||
_periodicTimerId = null;
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// Starter preset distribution — replaces the legacy curated_defaults.json
|
||
// pick-only seed with a full snapshot file shipped at web/starter_preset.json.
|
||
// On a fresh install the seeder copies it into the user's snapshot library
|
||
// (via the same /koolook/presets/file endpoint the Save dialog uses), so it
|
||
// shows up as one entry called "starter" in the Load dialog. The user gets
|
||
// one click to populate picks + workflows + tags + archive — and full agency
|
||
// to delete or edit it later, since it's just a regular preset on disk.
|
||
// =============================================================================
|
||
|
||
// Seed the bundled starter preset into the user's snapshot library iff:
|
||
// • we haven't already attempted on this browser (`STARTER_SEEDED_KEY`),
|
||
// • the user is fresh (no existing picks — existing users keep their state),
|
||
// • the library is reachable AND has no preset called "starter" yet.
|
||
//
|
||
// If the seed succeeds, toast the user so they know the Load dialog now has
|
||
// something to apply. On any reachability failure (server down, fetch error)
|
||
// we deliberately DON'T set the seeded flag — the next page load retries.
|
||
export async function seedStarterPresetIfNeeded() {
|
||
if (localStorage.getItem(STARTER_SEEDED_KEY)) return;
|
||
|
||
// Existing user with non-empty picks → already seeded by the legacy
|
||
// curated_defaults.json flow. Mark and skip; their state is theirs.
|
||
if (loadUserPicks().length > 0) {
|
||
localStorage.setItem(STARTER_SEEDED_KEY, "1");
|
||
return;
|
||
}
|
||
|
||
// Existence probe — `presetExists` returns true/false/null where null
|
||
// means the library is unreachable. We bail without setting the flag on
|
||
// null so a server hiccup doesn't permanently skip the seed.
|
||
let exists;
|
||
try {
|
||
exists = await presetExists(STARTER_PRESET_FILENAME);
|
||
} catch (e) {
|
||
console.warn("[Koolook] starter seed: presetExists threw:", e);
|
||
return;
|
||
}
|
||
if (exists === null) {
|
||
console.warn("[Koolook] starter seed: library unreachable, retrying next load");
|
||
return;
|
||
}
|
||
if (exists === true) {
|
||
// Already there — facility-shared library, or a re-install on a
|
||
// machine that previously had it. Mark and move on.
|
||
localStorage.setItem(STARTER_SEEDED_KEY, "1");
|
||
return;
|
||
}
|
||
|
||
// Fetch the bundled preset from the package's web/ folder. A 4xx/5xx or
|
||
// a parse error means the package is broken; mark seeded so we don't
|
||
// retry forever, and log so the maintainer notices.
|
||
let preset;
|
||
try {
|
||
const resp = await fetch(STARTER_URL);
|
||
if (!resp.ok) {
|
||
console.warn(`[Koolook] starter_preset.json HTTP ${resp.status}; skipping seed`);
|
||
localStorage.setItem(STARTER_SEEDED_KEY, "1");
|
||
return;
|
||
}
|
||
preset = await resp.json();
|
||
} catch (e) {
|
||
console.warn("[Koolook] failed to fetch starter_preset.json:", e);
|
||
localStorage.setItem(STARTER_SEEDED_KEY, "1");
|
||
return;
|
||
}
|
||
|
||
// Validate before writing — we don't want to seed a malformed file into
|
||
// every fresh install. validateSnapshot throws with a user-readable
|
||
// message; we just log it (no toast — fresh users shouldn't see scary
|
||
// errors about a feature they haven't discovered yet).
|
||
try {
|
||
validateSnapshot(preset);
|
||
} catch (e) {
|
||
console.warn("[Koolook] starter_preset.json failed validation:", e.message);
|
||
localStorage.setItem(STARTER_SEEDED_KEY, "1");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await writePreset(STARTER_PRESET_FILENAME, preset);
|
||
} catch (e) {
|
||
console.warn("[Koolook] starter preset write failed (retrying next load):", e.message);
|
||
// Don't set the flag — a read-only mount or network blip should
|
||
// self-heal on the user's next launch.
|
||
return;
|
||
}
|
||
|
||
localStorage.setItem(STARTER_SEEDED_KEY, "1");
|
||
console.log(`[Koolook] seeded starter preset into snapshot library as "${STARTER_PRESET_FILENAME}.json"`);
|
||
toast(`Starter preset "${STARTER_PRESET_FILENAME}" added — open Snapshot → Load to populate picks & workflows.`);
|
||
}
|
||
|
||
// Maintainer flow: capture the current full state as the bundled starter
|
||
// preset for the next release. Copies a JSON-formatted snapshot to clipboard
|
||
// (with download fallback) so the maintainer can paste it over
|
||
// `web/starter_preset.json` in the repo.
|
||
//
|
||
// Distinct from the regular Snapshot Save flow — that writes to the user's
|
||
// snapshot library directory (per-user state). This writes nothing to disk;
|
||
// it just hands the maintainer the JSON for a repo-side commit.
|
||
export async function exportStarterPreset() {
|
||
const snapshot = gatherSnapshot(STARTER_PRESET_FILENAME);
|
||
const json = JSON.stringify(snapshot, null, 2) + "\n";
|
||
const pickN = snapshot.picks.length;
|
||
const wfN = countWorkflowsInStore(snapshot.workflows);
|
||
const summary = `${pickN} pick${pickN === 1 ? "" : "s"} · ${wfN} workflow${wfN === 1 ? "" : "s"}`;
|
||
try {
|
||
await navigator.clipboard.writeText(json);
|
||
toast(`Copied starter preset to clipboard (${summary}). Paste into web/starter_preset.json.`);
|
||
return;
|
||
} catch (e) {
|
||
console.warn("[Koolook] clipboard write failed, falling back to download:", e);
|
||
}
|
||
const blob = new Blob([json], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = "starter_preset.json";
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
toast(`Downloaded starter_preset.json (${summary}). Replace the file in web/ with it.`);
|
||
}
|
||
|
||
// =============================================================================
|
||
// Current-preset tracking — remembers which preset the user last loaded or
|
||
// saved, so a Save button can default to "save over the current one" instead
|
||
// of forcing the user to retype the name every session. Persists in
|
||
// localStorage so it survives reloads.
|
||
// =============================================================================
|
||
|
||
const CURRENT_PRESET_KEY = "koolook.snapshot.currentPresetName.v1";
|
||
|
||
export function getCurrentPresetName() {
|
||
try {
|
||
const v = localStorage.getItem(CURRENT_PRESET_KEY);
|
||
return typeof v === "string" && v ? v : null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function setCurrentPresetName(name) {
|
||
try {
|
||
if (name) localStorage.setItem(CURRENT_PRESET_KEY, name);
|
||
else localStorage.removeItem(CURRENT_PRESET_KEY);
|
||
} catch (e) {
|
||
// localStorage rejected (quota, private mode) — non-fatal,
|
||
// the next save just won't pre-fill. Log so power users notice.
|
||
console.warn("[Koolook] could not persist current preset name:", e);
|
||
}
|
||
// Status display shows the tracked name — refresh on every change.
|
||
_emitStatusChanged();
|
||
}
|
||
|
||
// =============================================================================
|
||
// Server I/O — calls Koolook's `/koolook/presets/*` routes, defined in
|
||
// `koolook_routes.py`. The server reads the `KFORGELABS_PRESETS` env var
|
||
// to decide where on disk the library lives (with a default fallback to
|
||
// `<comfyui-userdata>/koolook-presets/`), so the same client code works
|
||
// for personal-only, NFS/SMB facility-shared, and Dropbox-synced setups.
|
||
//
|
||
// Routes:
|
||
// GET /koolook/presets/info → {path, isDefault, envVar, exists, writable}
|
||
// GET /koolook/presets/list → array of {name, mtime, size}
|
||
// GET /koolook/presets/file?name=… → file content
|
||
// POST /koolook/presets/file?name=… → write file body
|
||
// DELETE /koolook/presets/file?name=… → delete file
|
||
//
|
||
// `name` is always the FULL filename including `.json` extension —
|
||
// matches the server-side validation regex exactly.
|
||
// =============================================================================
|
||
|
||
const ROUTE_INFO = "/koolook/presets/info";
|
||
const ROUTE_LIST = "/koolook/presets/list";
|
||
const ROUTE_FILE = "/koolook/presets/file";
|
||
const ROUTE_SETTINGS = "/koolook/presets/settings";
|
||
const ROUTE_BROWSE = "/koolook/presets/browse";
|
||
const ROUTE_BROWSE_NEW_FOLDER = "/koolook/presets/browse/new-folder";
|
||
const ROUTE_AUTOSAVES_LIST = "/koolook/presets/autosaves/list";
|
||
const ROUTE_REVEAL = "/koolook/presets/reveal";
|
||
|
||
// Read a non-OK response's reason for a user-facing toast. Prefers the
|
||
// response body (always available, carries aiohttp's `reason=` text),
|
||
// then `resp.statusText` (HTTP/1.1 only — HTTP/2 strips reason phrases
|
||
// per RFC 7540, so behind any HTTP/2-terminating proxy this is empty),
|
||
// then `HTTP <status>` as a last resort. Async because we have to await
|
||
// the body read.
|
||
async function readErrorReason(resp) {
|
||
try {
|
||
const body = (await resp.text()).trim();
|
||
if (body) return body;
|
||
} catch (e) { /* fall through */ }
|
||
return resp.statusText || `HTTP ${resp.status}`;
|
||
}
|
||
|
||
function fileQuery(fileName, dir) {
|
||
let q = `?name=${encodeURIComponent(`${fileName}.json`)}`;
|
||
if (dir) q += `&dir=${encodeURIComponent(dir)}`;
|
||
return q;
|
||
}
|
||
|
||
// Filename → preview metadata. Returns
|
||
// `{displayName, fileName, exportedAt, workflowCount, pickCount}` on
|
||
// success, `null` if the file isn't a valid snapshot. Failed reads are
|
||
// filtered out by `listPresets` so the list shows only valid entries;
|
||
// corrupt files surface in the console for debugging.
|
||
async function loadPreview(fullName, dir, rowMeta) {
|
||
try {
|
||
const bareName = fullName.replace(/\.json$/i, "");
|
||
const dirParam = dir ? `&dir=${encodeURIComponent(dir)}` : "";
|
||
const resp = await fetch(
|
||
noStoreUrl(`${ROUTE_FILE}?name=${encodeURIComponent(fullName)}${dirParam}`),
|
||
{ cache: "no-store" }
|
||
);
|
||
if (!resp.ok) return null;
|
||
const text = await resp.text();
|
||
const obj = JSON.parse(text);
|
||
if (obj.kind !== SNAPSHOT_KIND) {
|
||
// Non-snapshot JSON in the library directory (someone dropped a
|
||
// workflow export, a settings file, etc.). Log so a "library
|
||
// appears empty" symptom is debuggable from the console.
|
||
console.warn(
|
||
`[Koolook] preset "${fullName}" skipped — kind="${obj.kind}", ` +
|
||
`expected "${SNAPSHOT_KIND}".`
|
||
);
|
||
return null;
|
||
}
|
||
return {
|
||
// The filename is the user-visible snapshot name. Users can
|
||
// rename snapshots directly on disk; if the embedded `name`
|
||
// lags behind, the Load list must still reflect the file they
|
||
// see in Finder / Explorer. Keep reading the JSON for validation
|
||
// + metadata, but use the storage key for display and actions.
|
||
displayName: bareName,
|
||
fileName: bareName,
|
||
exportedAt: typeof obj.exportedAt === "string" ? obj.exportedAt : null,
|
||
// mtime + size threaded through from the listing endpoint so
|
||
// every preview carries the same fields autosave previews do
|
||
// — lets the Load dialog render saved-time consistently
|
||
// across regular snapshot rows and recovery rows.
|
||
mtime: rowMeta && typeof rowMeta.mtime === "number" ? rowMeta.mtime : null,
|
||
size: rowMeta && typeof rowMeta.size === "number" ? rowMeta.size : null,
|
||
// Server augments root rows with the mtime of `<base>_autosave/
|
||
// periodic.json` when it exists AND is strictly newer than the
|
||
// named file. Powers the Load dialog's inline "Newer auto-save
|
||
// available — restore?" affordance. `null` for autosave subdir
|
||
// listings (the server only emits this at root) and for rows
|
||
// whose autosave is stale or absent.
|
||
latestAutosaveMtime:
|
||
rowMeta && typeof rowMeta.latestAutosaveMtime === "number"
|
||
? rowMeta.latestAutosaveMtime
|
||
: null,
|
||
workflowCount: countWorkflowsInStore(obj.workflows),
|
||
pickCount: Array.isArray(obj.picks) ? obj.picks.length : 0,
|
||
};
|
||
} catch (e) {
|
||
console.warn(`[Koolook] failed to read preset "${fullName}":`, e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Returns the library's resolved storage path + writability + source
|
||
// (settings / env / default). Returns null if the endpoint isn't
|
||
// reachable — caller falls back to a generic message.
|
||
export async function getLibraryInfo() {
|
||
try {
|
||
const resp = await fetch(noStoreUrl(ROUTE_INFO), { cache: "no-store" });
|
||
if (!resp.ok) return null;
|
||
return await resp.json();
|
||
} catch (e) {
|
||
console.warn("[Koolook] preset library info unavailable:", e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Read the saved-in-UI library path (settings file's `libraryPath` field)
|
||
// alongside the currently-resolved path and source. The Settings dialog
|
||
// uses `savedLibraryPath` as the editable field, `resolvedPath` as the
|
||
// "currently in effect" readout, and `source` to label the chain.
|
||
export async function getSettings() {
|
||
try {
|
||
const resp = await fetch(noStoreUrl(ROUTE_SETTINGS), { cache: "no-store" });
|
||
if (!resp.ok) return null;
|
||
return await resp.json();
|
||
} catch (e) {
|
||
console.warn("[Koolook] preset settings unavailable:", e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Save (or clear, if `libraryPath` is empty) the in-UI library-path
|
||
// override. Returns the server's echoed state ({savedLibraryPath,
|
||
// resolvedPath, source}) on success. Throws on server failure with the
|
||
// server-provided reason.
|
||
export async function saveSettings(libraryPath) {
|
||
const resp = await fetch(ROUTE_SETTINGS, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ libraryPath: libraryPath || "" }),
|
||
});
|
||
if (!resp.ok) {
|
||
throw new Error(`Could not save settings: ${await readErrorReason(resp)}.`);
|
||
}
|
||
return await resp.json();
|
||
}
|
||
|
||
// Directory browser used by both the legacy Settings dialog and the
|
||
// folder picker introduced for issue #137 (mockup section 6). The default
|
||
// response shape returns directories only; ``{files: true}`` also pulls
|
||
// the parent's ``*.json`` children for the picker's greyed-files-for-
|
||
// context affordance. Selected path is still persisted via
|
||
// ``saveSettings()`` — this is a listing endpoint only.
|
||
export async function browseDirectories(path, { files = false } = {}) {
|
||
const params = new URLSearchParams();
|
||
if (path) params.set("path", path);
|
||
if (files) params.set("files", "1");
|
||
const q = params.toString();
|
||
const resp = await fetch(noStoreUrl(`${ROUTE_BROWSE}${q ? `?${q}` : ""}`), {
|
||
cache: "no-store",
|
||
});
|
||
if (!resp.ok) {
|
||
throw new Error(`Could not browse directories: ${await readErrorReason(resp)}.`);
|
||
}
|
||
return await resp.json();
|
||
}
|
||
|
||
export async function createBrowseDirectory(parentPath, name) {
|
||
const resp = await fetch(ROUTE_BROWSE_NEW_FOLDER, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ parentPath, name }),
|
||
});
|
||
if (!resp.ok) {
|
||
throw new Error(`Could not create folder: ${await readErrorReason(resp)}.`);
|
||
}
|
||
return await resp.json();
|
||
}
|
||
|
||
// Returns an array of preview objects, sorted by display name
|
||
// (case-insensitive). Empty array on a missing directory or unreachable
|
||
// server — modal shows an empty state rather than throwing.
|
||
//
|
||
// Optional `dir` scopes the listing to a subfolder (used for autosave
|
||
// pruning). The user-facing Load list calls this without `dir` and gets
|
||
// only root-level user-named presets — autosave subfolders are NEVER
|
||
// enumerated as part of the regular Load flow.
|
||
export async function listPresets({ dir } = {}) {
|
||
let resp;
|
||
const url = dir ? `${ROUTE_LIST}?dir=${encodeURIComponent(dir)}` : ROUTE_LIST;
|
||
try {
|
||
resp = await fetch(noStoreUrl(url), { cache: "no-store" });
|
||
} catch (e) {
|
||
console.warn("[Koolook] preset listing failed (network):", e);
|
||
return [];
|
||
}
|
||
if (!resp.ok) {
|
||
console.warn(`[Koolook] preset listing returned HTTP ${resp.status}`);
|
||
return [];
|
||
}
|
||
let entries;
|
||
try {
|
||
entries = await resp.json();
|
||
} catch (e) {
|
||
console.warn("[Koolook] preset listing JSON parse failed:", e);
|
||
return [];
|
||
}
|
||
if (!Array.isArray(entries)) return [];
|
||
|
||
const previews = await Promise.all(entries.map((row) => loadPreview(row.name, dir, row)));
|
||
return previews
|
||
.filter(p => p !== null)
|
||
.sort((a, b) =>
|
||
a.displayName.localeCompare(b.displayName, undefined, { sensitivity: "base" })
|
||
);
|
||
}
|
||
|
||
// Read one preset by its filename (no `.json` suffix). Optional `dir`
|
||
// scopes the read to an autosave subfolder. Returns the parsed +
|
||
// validated snapshot, throws on read or parse failure.
|
||
export async function readPreset(fileName, { dir } = {}) {
|
||
const resp = await fetch(noStoreUrl(`${ROUTE_FILE}${fileQuery(fileName, dir)}`), {
|
||
cache: "no-store",
|
||
});
|
||
if (!resp.ok) {
|
||
throw new Error(`Could not read preset "${fileName}": ${await readErrorReason(resp)}.`);
|
||
}
|
||
const text = await resp.text();
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(text);
|
||
} catch (e) {
|
||
throw new Error(`Preset file "${fileName}" is not valid JSON.`);
|
||
}
|
||
return validateSnapshot(parsed);
|
||
}
|
||
|
||
// Write a snapshot under `fileName` (no `.json` suffix). Optional `dir`
|
||
// scopes the write to an autosave subfolder; the server auto-creates the
|
||
// subfolder on POST. Throws on server failure with the server-provided
|
||
// reason — surfaces "read-only mount", "parent missing", "invalid
|
||
// filename" in the user-facing toast.
|
||
export async function writePreset(fileName, snapshot, { dir } = {}) {
|
||
const resp = await fetch(`${ROUTE_FILE}${fileQuery(fileName, dir)}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(snapshot, null, 2),
|
||
});
|
||
if (!resp.ok) {
|
||
throw new Error(`Could not save preset "${fileName}": ${await readErrorReason(resp)}.`);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Returns ``true`` / ``false`` for present/absent, ``null`` when the
|
||
// existence check itself couldn't run (network down, server error).
|
||
// Caller distinguishes:
|
||
// - `false` → safe to write without the overwrite-confirm prompt
|
||
// - `null` → don't trust either branch; surface a "library
|
||
// unreachable" warning rather than silently writing
|
||
// - `true` → ask for overwrite confirmation before writing
|
||
//
|
||
// Server has a dedicated HEAD handler that does just `is_file()` + status,
|
||
// so this is a cheap probe even on multi-MB snapshots over Dropbox/NFS.
|
||
export async function presetExists(fileName, { dir } = {}) {
|
||
try {
|
||
const resp = await fetch(noStoreUrl(`${ROUTE_FILE}${fileQuery(fileName, dir)}`), {
|
||
method: "HEAD",
|
||
cache: "no-store",
|
||
});
|
||
if (resp.ok) return true;
|
||
if (resp.status === 404) return false;
|
||
// 5xx, 4xx other than 404, opaque proxy errors → unreachable.
|
||
console.warn(`[Koolook] presetExists: server returned HTTP ${resp.status}`);
|
||
return null;
|
||
} catch (e) {
|
||
console.warn("[Koolook] presetExists: network failure:", e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Delete a preset. Throws on server failure (read-only mount, missing
|
||
// file, etc.) — no silent fallback.
|
||
export async function deletePreset(fileName, { dir } = {}) {
|
||
const resp = await fetch(`${ROUTE_FILE}${fileQuery(fileName, dir)}`, { method: "DELETE" });
|
||
if (!resp.ok) {
|
||
throw new Error(`Could not delete preset "${fileName}": ${await readErrorReason(resp)}.`);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// List every recovery autosave across all `*_autosave/` subfolders. Returns
|
||
// preview objects enriched with `dir`, `mtime`, and a `kind` discriminator
|
||
// ("pre_load" | "periodic" | "other"). Empty array on a missing library or
|
||
// unreachable server. Used by the Load dialog's collapsible "Recovery
|
||
// auto-saves" section — without this endpoint the user's pre-load /
|
||
// periodic auto-saves are written but unreachable from the UI.
|
||
//
|
||
// N+1 cost is real here: one HEAD-or-similar metadata call to list, then
|
||
// one GET per file to validate + extract displayName. We accept it because
|
||
// the recovery section is collapsed by default — the cost is paid only
|
||
// when the user expands it. For very large libraries (many presets ×
|
||
// many autosaves) this can be slow; we'd add server-side preview-flatten
|
||
// before that becomes a real complaint.
|
||
export async function listAutosaves() {
|
||
let resp;
|
||
try {
|
||
resp = await fetch(noStoreUrl(ROUTE_AUTOSAVES_LIST), { cache: "no-store" });
|
||
} catch (e) {
|
||
console.warn("[Koolook] autosave listing failed (network):", e);
|
||
return [];
|
||
}
|
||
if (!resp.ok) {
|
||
console.warn(`[Koolook] autosave listing returned HTTP ${resp.status}`);
|
||
return [];
|
||
}
|
||
let entries;
|
||
try {
|
||
entries = await resp.json();
|
||
} catch (e) {
|
||
console.warn("[Koolook] autosave listing JSON parse failed:", e);
|
||
return [];
|
||
}
|
||
if (!Array.isArray(entries)) return [];
|
||
|
||
const previews = await Promise.all(entries.map(async (row) => {
|
||
try {
|
||
const bareName = row.name.replace(/\.json$/i, "");
|
||
const dirParam = `&dir=${encodeURIComponent(row.dir)}`;
|
||
const r = await fetch(
|
||
noStoreUrl(`${ROUTE_FILE}?name=${encodeURIComponent(row.name)}${dirParam}`),
|
||
{ cache: "no-store" }
|
||
);
|
||
if (!r.ok) return null;
|
||
const obj = JSON.parse(await r.text());
|
||
if (obj.kind !== SNAPSHOT_KIND) return null;
|
||
// Classify by filename: `pre_load_*.json` are timestamped
|
||
// recovery points (one per Load), `periodic.json` is the
|
||
// single rolling auto-save. Anything else (legacy / hand-
|
||
// dropped) is "other".
|
||
let kind = "other";
|
||
if (bareName === "periodic") kind = "periodic";
|
||
else if (bareName.startsWith("pre_load_")) kind = "pre_load";
|
||
return {
|
||
dir: row.dir,
|
||
fileName: bareName,
|
||
kind,
|
||
displayName: typeof obj.name === "string" && obj.name ? obj.name : bareName,
|
||
exportedAt: typeof obj.exportedAt === "string" ? obj.exportedAt : null,
|
||
mtime: row.mtime,
|
||
workflowCount: countWorkflowsInStore(obj.workflows),
|
||
pickCount: Array.isArray(obj.picks) ? obj.picks.length : 0,
|
||
};
|
||
} catch (e) {
|
||
console.warn(`[Koolook] failed to read autosave ${row.dir}/${row.name}:`, e);
|
||
return null;
|
||
}
|
||
}));
|
||
return previews.filter((p) => p !== null);
|
||
}
|
||
|
||
// Open the preset library (or an autosave subfolder) in the OS file
|
||
// manager. Returns the resolved server-side path on success — the caller
|
||
// can surface it in a toast so the user has the literal path even when
|
||
// the OS open call silently fails (rare, but the path text is enough to
|
||
// paste into Finder / Explorer manually).
|
||
export async function revealPresetFolder({ dir = "" } = {}) {
|
||
const dirParam = dir ? `?dir=${encodeURIComponent(dir)}` : "";
|
||
const r = await fetch(`${ROUTE_REVEAL}${dirParam}`, { method: "POST" });
|
||
if (!r.ok) throw new Error(await readErrorReason(r));
|
||
return r.json();
|
||
}
|
||
|
||
// =============================================================================
|
||
// Internal helpers
|
||
// =============================================================================
|
||
|
||
// Recursive workflow count over a snapshot's `workflows.directories` tree.
|
||
// Counts active + archived. Used for the preview metadata in the Load tab.
|
||
function countWorkflowsInStore(store) {
|
||
if (!store || typeof store !== "object") return 0;
|
||
let total = 0;
|
||
const walkDirs = (dirs) => {
|
||
if (!dirs || typeof dirs !== "object") return;
|
||
for (const dir of Object.values(dirs)) {
|
||
if (!dir || typeof dir !== "object") continue;
|
||
if (dir.workflows && typeof dir.workflows === "object") {
|
||
total += Object.keys(dir.workflows).length;
|
||
}
|
||
walkDirs(dir.directories);
|
||
}
|
||
};
|
||
walkDirs(store.directories);
|
||
return total;
|
||
}
|