* feat(sidebar): snapshot library — named presets for picks + workflows Implements #46 item 1 (custom save location + cross-machine portability) as a preset library: each preset is one named JSON file under /userdata/koolook-presets/, capturing the full Kforge Labs state. Use cases addressed ------------------- - **Personal portability:** copy `koolook-presets/` between machines, or sync ComfyUI's userdata folder via Dropbox/iCloud/Drive — your kit follows. - **Team / facility shared library:** multiple workstations on the same ComfyUI server share `/userdata/koolook-presets/` natively. One person publishes "facility kit", everyone else loads it. - **One-off transfer (different servers):** Manage tab's Download button saves a snapshot to local disk; Upload imports it into another machine's library. UI -- New top-level **Snapshot** action row above the search field, mirroring the Nodes/Workflows action-row pattern. Three icon buttons (Load / Save / Manage), each opens the same modal at a different starting tab. - Load tab: lists every preset with metadata (workflow count · pick count · export date). Click a row → "Replace current state?" confirm → restores both picks and workflows. - Save tab: input field defaults to `preset YYYY-MM-DD`, fully editable. Existing-name save prompts to overwrite. - Manage tab: per-row Download / Delete buttons + a top-of-tab Upload action (file picker imports a JSON snapshot from disk into the library). Schema ------ ```json { "kind": "koolook-snapshot", "version": 1, "name": "Wan video kit", "exportedAt": "2026-05-05T08:42:13.000Z", "picks": [...], "workflows": { "directories": {...} } } ``` `kind` + `version` for future schema migrations. Anything without `kind === "koolook-snapshot"` is rejected on import. Module shape ------------ - `web/sidebar/snapshot.js` (new) — server I/O on the preset directory, schema validation, gather/apply, download/upload helpers. UI-free. - `web/sidebar/modals.js` — new `showSnapshotModal({initialTab, ...})` that takes capability functions instead of importing snapshot.js directly, so the UI module stays UI-only. - `web/sidebar/workflows_store.js` — new `replaceAllWorkflows(rawStore)` for bulk-replace (bypasses persistMutation since the entire cache is being swapped) + `getAllWorkflowsForExport()` for the gather flow. - `web/sidebar/picks_store.js` — new `setAllPicks(picks)` exported helper. - `web/sidebar/tree.js` — new top action row + 3 toolbar buttons + one wiring helper. - `web/sidebar/constants.js` — CSS for tab strip, list rows, manage buttons. Deliberate scope cuts (per the design discussion) ------------------------------------------------- - No continuous sync. Each save is a deliberate act. - No conflict resolution. Same preset name written from two machines on the same server = last-write-wins. Documented as a known limit; revisit when it bites. - No selective import (preset = full state). Merge can be a v2. - No custom userdata path config. The `koolook-presets/` location is fixed; portability comes from the snapshot files themselves, not from rerouting storage. Verification ------------ - `node --input-type=module --check` on all modified + new JS files — clean. - `python tools/preflight_release.py --skip manager-meta -v` PASS. - Synced to live install on Mac for browser smoke-test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sidebar): snapshot library now uses configurable filesystem path Reworks PR #67 (which served snapshots from /userdata/koolook-presets/) to expose the library through Koolook's own server-side endpoints, making the storage location configurable via an env var. The userdata namespace is per-install — fine for personal use, useless for the "facility shared library" use case the user explicitly called out. Architectural change -------------------- - The client no longer talks to ComfyUI's `/userdata/*` API for snapshot files. New JS-side endpoints: `/koolook/presets/{info,list,file}`. - Server side: new `koolook_routes.py` registers aiohttp routes on ComfyUI's PromptServer at custom-node load time. Path-traversal protection at the route boundary via a strict filename whitelist regex (`^[A-Za-z0-9 _.()\-]+\.json$`). - Storage location is read from the `KFORGELABS_PRESETS` env var on every request (so re-launching ComfyUI with a new value picks it up without any cache fuss). Default fallback: `<comfyui-userdata>/koolook-presets/`. - Auto-creates the configured directory on first save (parent must already exist — for NFS/SMB mounts the mount must be present). Use cases this enables vs. PR #67 --------------------------------- | Use case | PR #67 | This PR | |-------------------------|--------|---------| | Personal, single-machine | ✓ | ✓ | | Personal cross-machine | symlink-or-sync userdata | point env var at Dropbox folder | | NFS/SMB facility share | ✗ (locked to userdata) | ✓ point env var at mount | | Multiple parallel libs | ✗ | per-instance env var | | Read-only distribution | ✗ | ✓ (server detects, save fails cleanly) | UI difference vs. PR #67 ------------------------ - The Manage tab gains a small `i` icon at the right edge of the upload row. Hover tooltip shows the current library path, source (default vs `KFORGELABS_PRESETS` override), and writability status. No header line, no extra UI weight. Files added/changed ------------------- - `koolook_routes.py` (new, ~180 lines) — aiohttp routes + env-var config + filename validation. Loaded by `__init__.py` at startup; failure is non-fatal (the rest of Koolook still works, just the snapshot feature is unavailable). - `__init__.py` — registers `koolook_routes` import-time side-effect. - `scripts/sync_to_dev.py` — `koolook_routes.py` added to `RUNTIME_PATHS` so dev-sync includes it. - `web/sidebar/snapshot.js` — server I/O section rewritten to call `/koolook/presets/*` instead of `/userdata/koolook-presets/*`. Drops the URL-encoding workaround (we own the route now, so the `name=<file>` query param is clean and the server validates). - `web/sidebar/modals.js` — `showSnapshotModal` accepts a `getLibraryInfo` capability function; the Manage tab calls it and populates the info-icon tooltip. - `web/sidebar/tree.js` — passes `getLibraryInfo` from `snapshot.js` to the modal. - `.env.example` — new `KFORGELABS_PRESETS` block with macOS / Linux / Windows examples for personal-sync, NFS facility share, and SMB facility share patterns. - `CHANGELOG.md` — Added entry rewritten for the env-var design; Internal entry on the new server-side module. Why a separate v2 PR (instead of patching #67) ---------------------------------------------- The userdata-namespace design in #67 was the wrong architecture for what the user actually wanted. Patching it to the env-var design would have meant a near-total rewrite of #67's server I/O paths mid-review. Cleaner: branch fresh from main, cherry-pick #67's UI + stores work as a base, layer the new endpoint design on top. PR #67 will be closed as superseded once this lands. Verification ------------ - `node --input-type=module --check` on every modified JS file — clean. - `python -c "import ast; ast.parse(...)"` on `koolook_routes.py` + `__init__.py` — clean. - `python tools/preflight_release.py --skip manager-meta -v` PASS. - Synced to live install on Mac. User to smoke-test against a real ComfyUI session (the previous URL-encoding 405 should be gone since the new route handlers own the URL space). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sidebar): redesign snapshot UX — single-click Save, real Settings panel User feedback on #68's first cut: tabbed modal is too complicated, can't see existing presets in the Save flow, the env var alone isn't a real settings UI, the default-path text was stale (still claimed `/userdata/koolook-presets/` after the configurable-path landed). UX changes ---------- - **Save button → one click.** If a preset is currently loaded (tracked in localStorage as `koolook.snapshot.currentPresetName.v1`, survives reloads), prompts "Save over '<name>'?" with three options: Save (overwrite) / Save as new… (rename) / Cancel. If nothing is loaded yet, prompts for a name with `preset YYYY-MM-DD` default, fully editable. - **Load → single dialog.** Preset list with per-row × Delete button. Header line shows the current library path so the user can see where they're loading from. No tabs. - **Cog → real Settings panel.** Single field for the library path (text input, absolute path). Save / Reset to default / Close. Read- only line shows the currently-resolved path + source label (settings panel / env var / built-in default). The "Manage" tab and its Download / Upload sub-actions are gone — the user can already reach the file on disk after a save, no in-app file ops needed. Server side ----------- - New `GET /koolook/presets/settings` returns the saved library path + resolved path + source. - New `POST /koolook/presets/settings` writes the saved library path to `<comfyui-userdata>/koolook-settings.json`. Empty value clears the override. - Resolution chain in `_configured_dir()`: settings file > KFORGELABS_PRESETS env var > built-in default Reads each on every call; no module-load caching. - `info` endpoint gains a `source` field ("settings" | "env" | "default") the UI uses for the readout line. Client side ----------- - `showSnapshotModal` (tabbed) replaced by three exports: showSaveSnapshotDialog, showLoadSnapshotDialog, showSnapshotSettingsDialog. Each is self-contained, no shared state. - `snapshot.js` adds `getSettings`/`saveSettings` for the Settings dialog and `getCurrentPresetName`/`setCurrentPresetName` for the one-click Save tracking. Settings ride on the new server endpoints; current-preset tracking is browser-local. - `applySnapshot` now also updates the current-preset tracker (via the dialog wrapper, not the function itself) on successful load. - `tree.js` toolbar wires three direct callbacks instead of one `openSnapshotModal(initialTab)` helper. Stale text fix -------------- - The Save dialog's hint text in #68's first cut hardcoded "/userdata/koolook-presets/<name>.json" — stale from the userdata- era PR #67. The redesigned Save flow doesn't show that text at all (the path is no longer relevant to the user-typed name); the Load dialog's header line and the Settings dialog's read-only field are the canonical surfaces for "where my presets actually live." Deliberately out of scope ------------------------- - No per-save path picker. The library is one location at a time, set in advance via Settings or env var. Saving to ad-hoc locations would fragment the library and break the "facility shared library" use case. - No in-app Download / Upload (was in #68's first cut). Files are on disk at the configured path; the OS file manager handles transfer. Verification ------------ - `node --input-type=module --check` on every modified JS file — clean. - `python -c "import ast; ast.parse(...)"` on `koolook_routes.py` — clean. - `python tools/preflight_release.py --skip manager-meta -v` PASS. - Synced to live install on Mac. User to smoke-test against a real ComfyUI session. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sidebar): address PR #68 review — atomicity, security, error UX Multi-agent review surfaced 2 BLOCKING + 5 HIGH + 4 MEDIUM + 3 LOW. Validated the BLOCKINGs against the actual code (both real) and applied the bundle of fixes that hold up. Modal-size HIGH (#7 file size) deferred to a follow-up — that's a wider refactor. BLOCKING fixes -------------- - **`applySnapshot` partial-failure tracker gating** (modals.js Load dialog). `setCurrentPresetName(...)` was called unconditionally before checking `{picksOk, workflowsOk}` from the apply. Partial apply (one of picks/workflows persisted, the other didn't) left the tracker pointing at the preset, so the next Save would offer "Save over '<name>'?" and silently overwrite the on-disk preset with the corrupted half-state. Now: tracker is set only on `picksOk && workflowsOk`; partial failure clears it (and the toast tells the user "Reload to recover prior state. Tracker cleared."). - **`replaceAllWorkflows` non-atomic rebind** (workflows_store.js). Rebinds `workflowsCache` before awaiting persist, with no rollback. Now: snapshots the cache via the existing `snapshotCache()` primitive that `persistMutation` uses; on persist failure, restore the prior cache and return false. Load is fully atomic. HIGH fixes ---------- - **Workflows-store invariant block updated.** Lists `replaceAllWorkflows` as the fourth legitimate rebind site (with rollback semantics). "Fifth rebind" warning bumped to "sixth." - **Explicit HEAD route on `/koolook/presets/file`** that just does `is_file()` + status, no body work. The auto-synthesis path was reading the full file from disk just to discard it — wasteful on multi-MB snapshots over Dropbox/iCloud/NFS. - **Client `sanitizeName` mirrors server whitelist regex** (`^[A-Za-z0-9 _.()\-]+$`). Characters outside the whitelist (`+`, `&`, `'`, accented letters, emoji, etc.) collapse to `_` rather than triggering an opaque HTTP 400 at save time. - **Error-reason surfacing via `await resp.text()`.** RFC 7540 strips HTTP/2 reason phrases; behind any HTTP/2-terminating proxy `resp.statusText` is empty. Reading the body always carries aiohttp's `reason=` text. Used by writePreset, deletePreset, saveSettings, readPreset. MEDIUM fixes ------------ - **Symlink follow-through hardening.** `_resolve_within_base` helper computes `(base / name).resolve()` and asserts it's still under `base.resolve()`. Defends against a symlink planted in a facility- shared library dir redirecting reads/writes off-share. - **Atomic settings write.** `_write_settings` writes to `<tmp>` then `os.replace`, so an interrupted process can't truncate the file and silently revert the user's saved library path on next load. - **`__init__.py` import wrapped in try/except.** The "non-fatal" comment promised that an import failure wouldn't break node registration; previously only `install()` returning False was guarded. Now any module-load exception is caught and logged with the `[Koolook]` prefix, and the rest of the plugin loads. LOW housekeeping ---------------- - Dropped dead exports `downloadSnapshotAsFile` + `readSnapshotFile` (left over from an earlier iteration that had Download/Upload UI). - Dropped 5 dead CSS rules (`.koolook-snapshot-tabs*`, `.koolook-snapshot-tab-pane`) from when the modal was tabbed. - `currentPresetName` cleared when the user deletes the currently- loaded preset (Load dialog's promptDelete now checks via the new `getCurrentPresetName` capability). - `loadPreview` warns to console on `kind` mismatch — non-snapshot JSON files in the library directory are now visible in DevTools rather than silently filtered. - `presetExists` is tri-state (`true` / `false` / `null`) — the Save flow refuses to write when the library is unreachable rather than silently skipping the overwrite-confirm prompt. Deferred -------- - modals.js size (1080 lines) — extracting the snapshot dialogs into their own file is a follow-up refactor. Not blocking merge. - Doc drift on maintainer docs / README env-var convention — separate documentation sweep. Verification ------------ - `python -c "import ast; ast.parse(...)"` on all .py files — clean. - `node --input-type=module --check` on every modified JS file — clean. - `python tools/preflight_release.py --skip manager-meta -v` PASS. - `grep -rn "downloadSnapshotAsFile\\|readSnapshotFile\\|koolook-snapshot-tab" web/` — zero hits (dead code gone). - Synced to live install for browser smoke-test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: nudge — re-trigger CI after webhook miss on d6d2bf8 The CI workflow didn't fire on the prior push (Socket Security ran but GitHub Actions Lint/Smoke/Pre-flight/Security Gate didn't). Empty commit to refire the pull_request webhook. * fix(routes): drop unnecessary f-string prefixes flagged by ruff (F541) Two adjacent string literals in the symlink-escape error message had an `f` prefix despite containing no placeholders. CI's ruff check fails on F541 — strip the prefixes so the lint gate passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
699 lines
30 KiB
JavaScript
699 lines
30 KiB
JavaScript
// =============================================================================
|
|
// Workflows storage (ComfyUI /userdata API with localStorage fallback) +
|
|
// transaction layer + in-memory mutation operations.
|
|
//
|
|
// Schema (recursive, since v0.3 — back-compatible with the v0.2 flat shape):
|
|
// workflowsCache = { directories: { [name]: DirNode } }
|
|
// DirNode = { workflows: { [wfName]: WorkflowEntry }, directories: { [name]: DirNode } }
|
|
//
|
|
// Every directory can host workflows AND nested subdirectories at any depth.
|
|
// The root has only `directories` — workflows live exclusively inside named
|
|
// directories. Old flat data without the nested `directories` field still
|
|
// loads fine; `normalizeWorkflowsStore` adds an empty `directories: {}` per
|
|
// node during migration, so a user's existing /userdata file is preserved.
|
|
//
|
|
// All mutator/lookup ops take a `path: string[]` of segment names. The empty
|
|
// path `[]` is the root. Path segments must be non-empty strings.
|
|
//
|
|
// `workflowsCache` is module-private. Outside callers go through the exported
|
|
// helpers — direct access would otherwise hold a stale reference after the
|
|
// cache rebinds during seed/recovery.
|
|
// =============================================================================
|
|
import {
|
|
WORKFLOWS_USERDATA_PATH,
|
|
WORKFLOWS_FALLBACK_KEY,
|
|
WORKFLOWS_SEEDED_KEY,
|
|
WORKFLOWS_CHANGED_EVENT,
|
|
WORKFLOWS_DEFAULTS_URL,
|
|
compareNames,
|
|
toast,
|
|
} from "./constants.js";
|
|
|
|
let workflowsCache = { directories: {} };
|
|
|
|
function notifyWorkflowsChanged() {
|
|
window.dispatchEvent(new CustomEvent(WORKFLOWS_CHANGED_EVENT));
|
|
}
|
|
|
|
// =============================================================================
|
|
// Normalization (with back-compat migration for the pre-v0.3 flat shape)
|
|
// =============================================================================
|
|
function normalizeWorkflowsStore(data) {
|
|
if (!data || typeof data !== "object") return { directories: {} };
|
|
const dirs = data.directories;
|
|
if (!dirs || typeof dirs !== "object") return { directories: {} };
|
|
const stats = { dropped: 0 };
|
|
const out = {};
|
|
for (const [name, dir] of Object.entries(dirs)) {
|
|
const cleaned = normalizeDirNode(dir, stats);
|
|
if (cleaned) out[name] = cleaned;
|
|
}
|
|
if (stats.dropped > 0) {
|
|
console.warn(`[Koolook] dropped ${stats.dropped} malformed workflow entr(y/ies) during normalize`);
|
|
}
|
|
return { directories: out };
|
|
}
|
|
|
|
function normalizeDirNode(node, stats) {
|
|
if (!node || typeof node !== "object") return null;
|
|
const wfs = node.workflows && typeof node.workflows === "object" ? node.workflows : {};
|
|
const cleanedWfs = {};
|
|
for (const [wfName, wf] of Object.entries(wfs)) {
|
|
// Drop entries that can't be loaded (missing/non-object graph).
|
|
// Coerce `archived` to a strict boolean so a stray "false" string
|
|
// can't accidentally flag an entry as archived. Coerce `tags` to a
|
|
// clean string[] (trim, drop empties, dedupe) so old entries without
|
|
// a tags field load with `tags: []` and the rest of the code can
|
|
// assume the field always exists.
|
|
if (!wf || typeof wf !== "object" || !wf.graph || typeof wf.graph !== "object") {
|
|
stats.dropped += 1;
|
|
continue;
|
|
}
|
|
const tags = [];
|
|
if (Array.isArray(wf.tags)) {
|
|
const seen = new Set();
|
|
for (const raw of wf.tags) {
|
|
// Reject non-strings outright instead of coercing — `String(null)`
|
|
// becomes the literal string `"null"`, which would silently
|
|
// surface as a garbage tag in the Tags section. The schema
|
|
// contract is `string[]`; anything else is corruption from a
|
|
// hand-edited /userdata file or a bug elsewhere.
|
|
if (typeof raw !== "string") {
|
|
stats.dropped += 1;
|
|
continue;
|
|
}
|
|
const t = raw.trim();
|
|
if (!t || seen.has(t)) continue;
|
|
seen.add(t);
|
|
tags.push(t);
|
|
}
|
|
}
|
|
cleanedWfs[wfName] = { ...wf, archived: wf.archived === true, tags };
|
|
}
|
|
// Recurse into subdirectories. Pre-v0.3 nodes don't have a `directories`
|
|
// field — give them an empty one so the rest of the code can assume it
|
|
// always exists.
|
|
const subs = node.directories && typeof node.directories === "object" ? node.directories : {};
|
|
const cleanedSubs = {};
|
|
for (const [subName, subDir] of Object.entries(subs)) {
|
|
const cleaned = normalizeDirNode(subDir, stats);
|
|
if (cleaned) cleanedSubs[subName] = cleaned;
|
|
}
|
|
return { workflows: cleanedWfs, directories: cleanedSubs };
|
|
}
|
|
|
|
// =============================================================================
|
|
// Server I/O
|
|
// =============================================================================
|
|
|
|
// Sentinel for "server reachable but file content is unparseable".
|
|
// Distinct from `undefined` (server unreachable) and `null` (file missing)
|
|
// so callers can refuse to auto-seed on top of a corrupt-but-existing file.
|
|
const SERVER_FILE_CORRUPT = Symbol("workflows-server-corrupt");
|
|
|
|
async function fetchWorkflowsFromServer() {
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`/userdata/${WORKFLOWS_USERDATA_PATH}`);
|
|
} catch (e) {
|
|
console.warn("[Koolook] /userdata read failed (network):", e);
|
|
return undefined;
|
|
}
|
|
if (resp.status === 404) return null;
|
|
if (!resp.ok) {
|
|
console.warn(`[Koolook] /userdata read returned HTTP ${resp.status}`);
|
|
return undefined;
|
|
}
|
|
const text = await resp.text();
|
|
if (!text || !text.trim()) return null;
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (e) {
|
|
console.error("[Koolook] /userdata workflow file is unreadable; refusing to auto-recover:", e);
|
|
return SERVER_FILE_CORRUPT;
|
|
}
|
|
}
|
|
|
|
// ComfyUI's /userdata endpoint requires `?overwrite=true` to allow POST over
|
|
// an existing file; without it the server 409s on every save after the
|
|
// first. The flag is part of the endpoint contract — pinned here as a named
|
|
// constant so the contract is visible at a glance.
|
|
const USERDATA_OVERWRITE_QUERY = "?overwrite=true";
|
|
|
|
// Returns "server" | "fallback" | false. Callers that care about durability
|
|
// (specifically the seeders) should only treat "server" as a success — a
|
|
// "fallback"-only write is per-browser and won't reach future page loads if
|
|
// /userdata becomes reachable again.
|
|
async function persistWorkflowsToServer(store) {
|
|
const json = JSON.stringify(store, null, 2);
|
|
try {
|
|
const resp = await fetch(`/userdata/${WORKFLOWS_USERDATA_PATH}${USERDATA_OVERWRITE_QUERY}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: json,
|
|
});
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
return "server";
|
|
} catch (e) {
|
|
console.warn("[Koolook] /userdata write failed, using localStorage fallback:", e);
|
|
try {
|
|
localStorage.setItem(WORKFLOWS_FALLBACK_KEY, json);
|
|
return "fallback";
|
|
} catch (e2) {
|
|
console.error("[Koolook] both /userdata and localStorage write failed:", e2);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Returns { corrupt: false } on normal load (cache populated), or
|
|
// { corrupt: true } when /userdata reachable but file content is unparseable —
|
|
// the caller (setup) must skip seeding so we don't clobber the corrupt-but-
|
|
// recoverable file with stock defaults.
|
|
export async function loadWorkflowsStore() {
|
|
const fromServer = await fetchWorkflowsFromServer();
|
|
if (fromServer === SERVER_FILE_CORRUPT) {
|
|
toast("Workflow file on /userdata is unreadable. Refusing to auto-recover; check console.");
|
|
workflowsCache = { directories: {} };
|
|
return { corrupt: true };
|
|
}
|
|
if (fromServer === null) {
|
|
// Server reachable, file just doesn't exist yet — return empty.
|
|
workflowsCache = { directories: {} };
|
|
return { corrupt: false };
|
|
}
|
|
if (fromServer === undefined) {
|
|
// Server unreachable — try localStorage fallback.
|
|
try {
|
|
const raw = localStorage.getItem(WORKFLOWS_FALLBACK_KEY);
|
|
if (raw) {
|
|
workflowsCache = normalizeWorkflowsStore(JSON.parse(raw));
|
|
return { corrupt: false };
|
|
}
|
|
} catch (e) {
|
|
console.warn("[Koolook] failed to parse localStorage workflows fallback:", e);
|
|
}
|
|
workflowsCache = { directories: {} };
|
|
return { corrupt: false };
|
|
}
|
|
workflowsCache = normalizeWorkflowsStore(fromServer);
|
|
// Reconciliation: /userdata loaded successfully but a stale fallback blob
|
|
// from an earlier outage still exists. We don't auto-merge (risk of
|
|
// clobbering) but we surface it visibly so the user knows where to recover.
|
|
if (localStorage.getItem(WORKFLOWS_FALLBACK_KEY)) {
|
|
console.warn(
|
|
`[Koolook] /userdata loaded, but a stale localStorage fallback exists ` +
|
|
`at "${WORKFLOWS_FALLBACK_KEY}". If workflows you saved during a previous ` +
|
|
`outage are missing, recover from there before clearing.`
|
|
);
|
|
toast(`Old offline workflow data found in localStorage["${WORKFLOWS_FALLBACK_KEY}"]. See console to recover.`, 4500);
|
|
}
|
|
return { corrupt: false };
|
|
}
|
|
|
|
// =============================================================================
|
|
// Transaction layer (commit + rollback + persistMutation)
|
|
// =============================================================================
|
|
async function commit() {
|
|
// Both "server" and "fallback" are user-visible successes — the panel
|
|
// shows the change either way. Only `false` (both backends rejected)
|
|
// indicates a real loss requiring rollback at the call site.
|
|
const result = await persistWorkflowsToServer(workflowsCache);
|
|
if (result) notifyWorkflowsChanged();
|
|
return result !== false;
|
|
}
|
|
|
|
// Snapshot the cache so callers can roll back a mutation when commit() fails.
|
|
// Pairs with a `restore()` function that puts the cache back and re-renders.
|
|
function snapshotCache() {
|
|
const snap = JSON.stringify(workflowsCache);
|
|
return () => {
|
|
try {
|
|
workflowsCache = JSON.parse(snap);
|
|
notifyWorkflowsChanged();
|
|
} catch (e) {
|
|
console.error("[Koolook] cache rollback failed:", e);
|
|
}
|
|
};
|
|
}
|
|
|
|
// Mutate-then-commit with automatic rollback on persist failure. Mutation
|
|
// returning `false` is treated as a no-op (e.g. name collision) and bypasses
|
|
// commit. `onSuccess(result)` and `onNoOp()` callbacks run their own toasts.
|
|
export async function persistMutation({ mutate, onSuccess, onNoOp, persistFailedMessage }) {
|
|
const restore = snapshotCache();
|
|
const result = mutate();
|
|
if (result === false) {
|
|
if (onNoOp) onNoOp();
|
|
return false;
|
|
}
|
|
if (await commit()) {
|
|
if (onSuccess) onSuccess(result);
|
|
return true;
|
|
}
|
|
restore();
|
|
toast(persistFailedMessage || "Save failed — change reverted. See console.");
|
|
return false;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Seeding
|
|
// =============================================================================
|
|
export async function seedWorkflowDefaultsIfNeeded() {
|
|
if (localStorage.getItem(WORKFLOWS_SEEDED_KEY)) return;
|
|
// If existing data is non-empty, respect it and mark seeded.
|
|
const dirNames = Object.keys(workflowsCache.directories || {});
|
|
if (dirNames.length > 0) {
|
|
localStorage.setItem(WORKFLOWS_SEEDED_KEY, "1");
|
|
return;
|
|
}
|
|
try {
|
|
const resp = await fetch(WORKFLOWS_DEFAULTS_URL);
|
|
if (!resp.ok) {
|
|
localStorage.setItem(WORKFLOWS_SEEDED_KEY, "1");
|
|
return;
|
|
}
|
|
const data = await resp.json();
|
|
const normalized = normalizeWorkflowsStore(data);
|
|
const seedDirCount = Object.keys(normalized.directories).length;
|
|
if (seedDirCount > 0) {
|
|
workflowsCache = normalized;
|
|
// Only mark seeded if the persist *reached the server* — a
|
|
// "fallback"-only write is per-browser, and once /userdata
|
|
// becomes reachable on a later load the empty server file
|
|
// would otherwise trump the locally-seeded data.
|
|
const persistResult = await persistWorkflowsToServer(workflowsCache);
|
|
if (persistResult !== "server") {
|
|
console.warn(
|
|
`[Koolook] seed persist landed in ${persistResult || "neither"}; ` +
|
|
`not marking seeded so we retry against /userdata next load.`
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
localStorage.setItem(WORKFLOWS_SEEDED_KEY, "1");
|
|
console.log(`[Koolook] seeded ${seedDirCount} default workflow director(y/ies)`);
|
|
} catch (e) {
|
|
console.warn("[Koolook] failed to load workflow_defaults.json:", e);
|
|
localStorage.setItem(WORKFLOWS_SEEDED_KEY, "1");
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Path-based directory operations (the public mutator API)
|
|
//
|
|
// `path` is a `string[]` of segment names. `[]` is the root (which has no
|
|
// workflows; only directories live there). Mutators that take a `parentPath`
|
|
// position the operation against that parent directory; e.g.
|
|
// `addDirectory(["UP-scale"], "Type-A")` creates `UP-scale/Type-A`.
|
|
//
|
|
// Subdirectories cannot be named "Archive" (case-insensitive) — that name is
|
|
// reserved by the synthetic Archive folder rendered for archived workflows.
|
|
//
|
|
// === Mutator invariants — read this before adding a new mutator ===
|
|
//
|
|
// 1. **Mutate-then-commit, never one without the other.** Every mutator
|
|
// below mutates `workflowsCache` in place and returns immediately. The
|
|
// cache change is invisible on disk until a caller pairs the mutator
|
|
// with `await commit()` (or wraps both in `persistMutation`, which is
|
|
// the strongly-preferred path because it also handles snapshot/rollback
|
|
// and the "no-op" return-false convention). A mutator that "succeeds"
|
|
// without a paired commit silently drops the change on the next
|
|
// reload — a class of bug we've already shipped + reverted once.
|
|
//
|
|
// 2. **Return `false` for no-op (collisions, missing source, validation
|
|
// failures), truthy for success.** `persistMutation` treats a `false`
|
|
// return as a no-op and skips commit. Truthy returns are passed to
|
|
// `onSuccess(result)` as-is, which is why `saveWorkflowEntry` returns
|
|
// `{archivedAs}` — the success callback wants to surface that detail.
|
|
//
|
|
// 3. **Mutate in place; don't return new structures.** Callers (and the
|
|
// transaction layer's snapshot) expect `workflowsCache` to be the
|
|
// single source of truth. Returning a new structure would silently
|
|
// detach future reads from the change.
|
|
//
|
|
// 4. **Never replace `workflowsCache` itself except in seed / load /
|
|
// rollback / snapshot-apply paths.** The four legitimate rebind sites
|
|
// are:
|
|
// - module init (this file's top-level `let workflowsCache = …`)
|
|
// - `loadWorkflowsStore` and `seedWorkflowDefaultsIfNeeded`
|
|
// (re-binding the cache to a freshly-normalized server payload
|
|
// or a default seed)
|
|
// - the closure returned by `snapshotCache()` running rollback
|
|
// inside `persistMutation` — this rebind is what makes commit
|
|
// failure recoverable
|
|
// - `replaceAllWorkflows` (snapshot-apply) — bulk-replaces the
|
|
// entire cache from a deserialized snapshot file, with the same
|
|
// `snapshotCache()` rollback wrapper as `persistMutation` so the
|
|
// atomic "swap on persist success, restore on failure" contract
|
|
// is preserved
|
|
// Outside callers hold no reference to the cache (it's
|
|
// module-private), so external rebinds are impossible. The constraint
|
|
// is internal: a fifth rebind site added by a future contributor for
|
|
// any other reason would break the snapshot/restore semantics that
|
|
// `persistMutation` and `replaceAllWorkflows` depend on.
|
|
// =============================================================================
|
|
|
|
const ARCHIVE_RESERVED_NAME = "archive";
|
|
|
|
// Resolves to the DirNode at `path`, or `undefined` if any segment is missing.
|
|
// `[]` returns a synthetic wrapper around the root (with `directories`
|
|
// pointing at `workflowsCache.directories`) so callers don't need a special
|
|
// case for root vs. nested.
|
|
export function dirOf(path) {
|
|
if (!Array.isArray(path)) return undefined;
|
|
if (path.length === 0) {
|
|
return { workflows: {}, directories: workflowsCache.directories || {} };
|
|
}
|
|
let node = { directories: workflowsCache.directories || {} };
|
|
for (const seg of path) {
|
|
if (typeof seg !== "string" || !seg) return undefined;
|
|
if (!node.directories || !node.directories[seg]) return undefined;
|
|
node = node.directories[seg];
|
|
}
|
|
return node;
|
|
}
|
|
|
|
// Direct child directory names at `parentPath`, sorted A→Z.
|
|
export function listDirectoryNames(parentPath = []) {
|
|
const node = dirOf(parentPath);
|
|
if (!node || !node.directories) return [];
|
|
return Object.keys(node.directories).sort(compareNames);
|
|
}
|
|
|
|
// All directory paths in DFS, sorted A→Z at each level. Used by the save
|
|
// modal to populate the directory dropdown as a flat path picker.
|
|
export function listAllDirectoryPaths() {
|
|
const out = [];
|
|
const walk = (parentPath) => {
|
|
for (const name of listDirectoryNames(parentPath)) {
|
|
const next = [...parentPath, name];
|
|
out.push(next);
|
|
walk(next);
|
|
}
|
|
};
|
|
walk([]);
|
|
return out;
|
|
}
|
|
|
|
// Internal: walk to `parentPath` and create intermediate directories along
|
|
// the way. Used only by `saveWorkflowEntry` so a save into a freshly-typed
|
|
// new top-level directory always succeeds. Subdirectory creation is explicit
|
|
// (right-click → Create subdirectory…) so we don't auto-create nested paths.
|
|
function ensureDirectoryAtPath(path) {
|
|
if (!Array.isArray(path) || path.length === 0) return null;
|
|
let node = { directories: workflowsCache.directories };
|
|
for (let i = 0; i < path.length; i += 1) {
|
|
const seg = path[i];
|
|
if (typeof seg !== "string" || !seg) return null;
|
|
if (!node.directories) node.directories = {};
|
|
if (!node.directories[seg]) {
|
|
node.directories[seg] = { workflows: {}, directories: {} };
|
|
}
|
|
node = node.directories[seg];
|
|
}
|
|
return node;
|
|
}
|
|
|
|
// Add a directory at `parentPath` with `name`. Returns false on:
|
|
// - empty trimmed name
|
|
// - reserved name "Archive" (case-insensitive) when nested under another
|
|
// directory (collides with the synthetic Archive folder rendering)
|
|
// - parent path doesn't exist
|
|
// - sibling with the same name already exists
|
|
export function addDirectory(parentPath, name) {
|
|
name = (name || "").trim();
|
|
if (!name) return false;
|
|
// Type guard mirroring `moveDirectory` — without this, a future caller
|
|
// passing `undefined` would crash on `parentPath.length`.
|
|
if (!Array.isArray(parentPath)) return false;
|
|
// Reserved-name check: "Archive" at root is fine (no synthetic Archive
|
|
// collides at root because root has no archived workflows of its own),
|
|
// but inside a directory it would shadow the archived-workflows folder.
|
|
if (parentPath.length > 0 && name.toLowerCase() === ARCHIVE_RESERVED_NAME) return false;
|
|
const parent = dirOf(parentPath);
|
|
if (!parent) return false;
|
|
if (!parent.directories) parent.directories = {};
|
|
if (parent.directories[name]) return false;
|
|
parent.directories[name] = { workflows: {}, directories: {} };
|
|
return true;
|
|
}
|
|
|
|
export function renameDirectory(parentPath, oldName, newName) {
|
|
newName = (newName || "").trim();
|
|
if (!newName || newName === oldName) return false;
|
|
if (parentPath.length > 0 && newName.toLowerCase() === ARCHIVE_RESERVED_NAME) return false;
|
|
const parent = dirOf(parentPath);
|
|
if (!parent || !parent.directories || !parent.directories[oldName]) return false;
|
|
if (parent.directories[newName]) return false;
|
|
parent.directories[newName] = parent.directories[oldName];
|
|
delete parent.directories[oldName];
|
|
return true;
|
|
}
|
|
|
|
export function deleteDirectory(parentPath, name) {
|
|
const parent = dirOf(parentPath);
|
|
if (!parent || !parent.directories || !parent.directories[name]) return false;
|
|
delete parent.directories[name];
|
|
return true;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Workflow operations (path-addressed)
|
|
// =============================================================================
|
|
export function saveWorkflowEntry(path, wfName, graphData) {
|
|
const dir = ensureDirectoryAtPath(path);
|
|
if (!dir) return false;
|
|
let archivedAs = null;
|
|
const existing = dir.workflows[wfName];
|
|
if (existing) {
|
|
// Same-name save: move the existing version into the directory's
|
|
// Archive (timestamp-suffixed) before overwriting.
|
|
const ts = new Date().toISOString().slice(0, 19).replace("T", " ");
|
|
let archiveName = `${wfName} (archived ${ts})`;
|
|
let n = 1;
|
|
while (dir.workflows[archiveName]) {
|
|
n += 1;
|
|
archiveName = `${wfName} (archived ${ts}) #${n}`;
|
|
}
|
|
dir.workflows[archiveName] = { ...existing, archived: true };
|
|
archivedAs = archiveName;
|
|
}
|
|
dir.workflows[wfName] = {
|
|
savedAt: new Date().toISOString(),
|
|
graph: graphData,
|
|
};
|
|
return { archivedAs };
|
|
}
|
|
|
|
export function archiveWorkflow(path, wfName) {
|
|
const dir = dirOf(path);
|
|
if (!dir || !dir.workflows[wfName]) return false;
|
|
dir.workflows[wfName].archived = true;
|
|
return true;
|
|
}
|
|
|
|
export function unarchiveWorkflow(path, wfName) {
|
|
const dir = dirOf(path);
|
|
if (!dir || !dir.workflows[wfName]) return false;
|
|
delete dir.workflows[wfName].archived;
|
|
return true;
|
|
}
|
|
|
|
export function renameWorkflow(path, oldWfName, newWfName) {
|
|
newWfName = (newWfName || "").trim();
|
|
if (!newWfName || newWfName === oldWfName) return false;
|
|
const dir = dirOf(path);
|
|
if (!dir || !dir.workflows[oldWfName]) return false;
|
|
if (dir.workflows[newWfName]) return false;
|
|
dir.workflows[newWfName] = dir.workflows[oldWfName];
|
|
delete dir.workflows[oldWfName];
|
|
return true;
|
|
}
|
|
|
|
export function deleteWorkflow(path, wfName) {
|
|
const dir = dirOf(path);
|
|
if (!dir || !dir.workflows[wfName]) return false;
|
|
delete dir.workflows[wfName];
|
|
return true;
|
|
}
|
|
|
|
// `srcPath` and `dstPath` must both reference existing directories. Returns
|
|
// false on identical paths, missing source workflow, missing destination,
|
|
// or a name collision in the destination.
|
|
export function moveWorkflow(srcPath, wfName, dstPath) {
|
|
if (pathsEqual(srcPath, dstPath)) return false;
|
|
const src = dirOf(srcPath);
|
|
if (!src || !src.workflows[wfName]) return false;
|
|
const dst = dirOf(dstPath);
|
|
if (!dst) return false;
|
|
if (dst.workflows[wfName]) return false;
|
|
dst.workflows[wfName] = src.workflows[wfName];
|
|
delete src.workflows[wfName];
|
|
return true;
|
|
}
|
|
|
|
export function getWorkflowGraph(path, wfName) {
|
|
const dir = dirOf(path);
|
|
if (!dir || !dir.workflows[wfName]) return null;
|
|
return dir.workflows[wfName].graph || null;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Per-workflow tag operations. Tags are insertion-ordered string[] on each
|
|
// workflow entry. Comparison is case-sensitive: "AI" and "ai" are distinct.
|
|
// `normalizeDirNode` guarantees `tags` is always an array on cached entries,
|
|
// but the mutators still defensively coerce so a freshly-saved workflow that
|
|
// hasn't gone through normalize still gets sane behavior.
|
|
// =============================================================================
|
|
export function getWorkflowTags(path, wfName) {
|
|
const dir = dirOf(path);
|
|
if (!dir || !dir.workflows[wfName]) return null;
|
|
const tags = dir.workflows[wfName].tags;
|
|
return Array.isArray(tags) ? [...tags] : [];
|
|
}
|
|
|
|
export function addTag(path, wfName, tag) {
|
|
tag = (tag || "").trim();
|
|
if (!tag) return false;
|
|
const dir = dirOf(path);
|
|
if (!dir || !dir.workflows[wfName]) return false;
|
|
const wf = dir.workflows[wfName];
|
|
if (!Array.isArray(wf.tags)) wf.tags = [];
|
|
if (wf.tags.includes(tag)) return false;
|
|
wf.tags.push(tag);
|
|
return true;
|
|
}
|
|
|
|
export function removeTag(path, wfName, tag) {
|
|
const dir = dirOf(path);
|
|
if (!dir || !dir.workflows[wfName]) return false;
|
|
const wf = dir.workflows[wfName];
|
|
if (!Array.isArray(wf.tags)) return false;
|
|
const idx = wf.tags.indexOf(tag);
|
|
if (idx < 0) return false;
|
|
wf.tags.splice(idx, 1);
|
|
return true;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Snapshot / preset support — bulk replace + read-only export of the cache.
|
|
// Used by the snapshot library flow (#46 item 1).
|
|
// =============================================================================
|
|
|
|
// Replace the entire `workflowsCache` with a freshly-normalized version of
|
|
// `rawStore` and persist the result atomically. Returns `true` on
|
|
// successful persist (server or localStorage fallback), `false` on full
|
|
// persist rejection — in which case the cache is rolled back to its
|
|
// pre-call state so the in-memory state stays consistent with disk.
|
|
//
|
|
// Atomicity matters because a snapshot apply replaces both pieces of
|
|
// user state (picks + workflows) and the caller's UI presents the
|
|
// "Replace current state?" confirm as a single act. A partially-applied
|
|
// load (cache rebound but persist rejected) would diverge in-memory
|
|
// from disk; the next reload would revert silently and the user would
|
|
// have no signal that the load didn't stick.
|
|
//
|
|
// Bypasses `persistMutation` because the entire cache is being replaced
|
|
// (rather than mutated through a single mutator). Uses `snapshotCache`
|
|
// directly for the same atomic semantics — it's the same primitive
|
|
// `persistMutation` uses internally.
|
|
export async function replaceAllWorkflows(rawStore) {
|
|
const restore = snapshotCache();
|
|
workflowsCache = normalizeWorkflowsStore(rawStore);
|
|
const result = await persistWorkflowsToServer(workflowsCache);
|
|
if (result === false) {
|
|
restore();
|
|
return false;
|
|
}
|
|
notifyWorkflowsChanged();
|
|
return true;
|
|
}
|
|
|
|
// Deep-cloned read-only view of `workflowsCache` for the snapshot export
|
|
// flow. Returning a clone (rather than the live ref) keeps callers from
|
|
// accidentally mutating cache state through the export pipeline.
|
|
export function getAllWorkflowsForExport() {
|
|
return JSON.parse(JSON.stringify(workflowsCache));
|
|
}
|
|
|
|
// Delete every archived workflow under the directory at `path`. Returns
|
|
// `{ count }` (number of entries removed) on success, `false` if the
|
|
// directory is missing or has no archived entries. Active (non-archived)
|
|
// workflows in the same directory are untouched. Used by the Archive
|
|
// folder's right-click "Delete archive" action.
|
|
export function clearArchive(path) {
|
|
const dir = dirOf(path);
|
|
if (!dir || !dir.workflows) return false;
|
|
const archivedNames = Object.entries(dir.workflows)
|
|
.filter(([, wf]) => wf && wf.archived === true)
|
|
.map(([n]) => n);
|
|
if (archivedNames.length === 0) return false;
|
|
for (const name of archivedNames) delete dir.workflows[name];
|
|
return { count: archivedNames.length };
|
|
}
|
|
|
|
// Move the directory at `srcParentPath/name` to live under `dstParentPath`
|
|
// (its name is preserved). Returns false when:
|
|
// - the source doesn't exist
|
|
// - the destination parent doesn't exist
|
|
// - the destination already has a sibling with the same name
|
|
// - the move would create a cycle (dst is the source itself or any
|
|
// descendant of the source — you can't drop a folder into itself)
|
|
// - the source and destination parent are identical (no-op)
|
|
// - the new location at root level would use the reserved name "Archive"
|
|
// in a non-root parent (already enforced by addDirectory's check would
|
|
// not apply here; we re-check explicitly)
|
|
export function moveDirectory(srcParentPath, name, dstParentPath) {
|
|
name = (name || "").trim();
|
|
if (!name) return false;
|
|
if (!Array.isArray(srcParentPath) || !Array.isArray(dstParentPath)) return false;
|
|
// Same parent → no-op (identical location).
|
|
if (pathsEqual(srcParentPath, dstParentPath)) return false;
|
|
// Reserved-name check at the new (non-root) parent.
|
|
if (dstParentPath.length > 0 && name.toLowerCase() === ARCHIVE_RESERVED_NAME) return false;
|
|
const src = dirOf(srcParentPath);
|
|
if (!src || !src.directories || !src.directories[name]) return false;
|
|
const dst = dirOf(dstParentPath);
|
|
if (!dst) return false;
|
|
if (!dst.directories) dst.directories = {};
|
|
if (dst.directories[name]) return false; // collision in destination
|
|
// Cycle prevention: dstParentPath must not be the source itself or a
|
|
// descendant. Source path is srcParentPath + [name]; reject any dst
|
|
// path that begins with that prefix.
|
|
const srcFullPath = [...srcParentPath, name];
|
|
if (isPathDescendantOrSame(dstParentPath, srcFullPath)) return false;
|
|
dst.directories[name] = src.directories[name];
|
|
delete src.directories[name];
|
|
return true;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Path utilities
|
|
// =============================================================================
|
|
export function pathsEqual(a, b) {
|
|
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
|
return true;
|
|
}
|
|
|
|
// True when `testPath` equals or is a descendant of `ancestorPath`.
|
|
// e.g. isPathDescendantOrSame(["A","B"], ["A"]) → true (B is under A)
|
|
// isPathDescendantOrSame(["A"], ["A","B"]) → false (A is above)
|
|
// isPathDescendantOrSame(["A","B"], ["A","B"]) → true (same)
|
|
function isPathDescendantOrSame(testPath, ancestorPath) {
|
|
if (!Array.isArray(testPath) || !Array.isArray(ancestorPath)) return false;
|
|
if (testPath.length < ancestorPath.length) return false;
|
|
for (let i = 0; i < ancestorPath.length; i += 1) {
|
|
if (testPath[i] !== ancestorPath[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// `isArchiveReservedName` was previously exported but never imported — the
|
|
// reserved-name check is enforced inline by every mutator that creates or
|
|
// renames a directory (`addDirectory`, `renameDirectory`, `moveDirectory`).
|
|
// Removed during a dead-export sweep; revive as an export only if a future
|
|
// caller needs to gate UI before submit (e.g. live-validate the new-dir
|
|
// input in the save modal).
|