* feat(setups): publish success feedback + published-setup discoverability (#227) Post-publish feedback (issue #227 Part 1): - POST /koolook/api/setups now returns the on-disk storagePath - new POST /koolook/api/setups/reveal opens the published-setups folder (shared _open_dir_in_file_manager helper; the snapshot reveal route was refactored onto it, behaviour-identical) - the publish modal renders a success card reusing the snapshot Save dialog's "Saved to ... Open folder" language (Open folder / Copy path / Close) instead of closing silently; a draft publish still gets a confirmation Published discoverability (reuses the tag system, no new schema): - publishing auto-tags the source workflow `published` -> a "published" badge in the Workflows tree + the Tags-section "published" pool - a new "P" toggle in the sidebar Tools row prunes the Workflows tree to published-only, folders force-expanded, so setups show in their origin structure; toggling off restores the full tree - workflowHasTag() shared helper (unit-tested) Deferred, still open on #227: separate destination/library setting (Part 2), relocating the storage folder beside the snapshot library, and clearing the filter when entering Compare mode. Tests: +server (storagePath, reveal route happy + 404), +client (reveal client, buildPublishSuccessView view-model), +sidebar (workflowHasTag). Full suite: 610 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(setups): store published setups beside the snapshot library (#227) - the published-setup registry path now derives from the configured snapshot library (`_configured_dir().parent / koolook-published-setups/setups.json`) instead of a fixed user-dir folder, so it follows libraryPath / KFORGELABS_PRESETS. The default config resolves to the same path as before (target == legacy), so existing default installs are unchanged. - non-destructive one-time migration: copy a legacy user-dir registry to the new location when the new one is absent, leaving the original in place. - sidebar: clear the "P" published filter when entering Compare mode so it doesn't render both compare columns filtered with no toggle to undo it. Tests: +tests/server/test_published_setups_relocation.py (path beside library; migration copy / no-op-when-present / no-op-when-no-legacy). Full suite: 614 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(tests): remove unused pathlib.Path import (ruff F401) The relocation test only uses the `tmp_path` fixture, not `Path` directly, so `from pathlib import Path` was unused and failed the Lint & Smoke (ruff) gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(setups): address PR #239 review — migration masking + tag persist - HIGH: a failed legacy->new migration no longer masks real setups with the bundled sample. The default registry keeps the legacy file as its primary storage when migration could not produce the new file, so stranded data stays visible. (+2 tests: keeps-legacy-on-copy-failure / uses-new-on-success) - MEDIUM/LOW: the publish flow now awaits the `published`-tag persist so the success card reflects a committed tag (persist failures already surface via persistMutation's critical toast), and warns when the source workflow could not be tagged (vanished mid-publish) rather than on a harmless republish. Full suite: 616 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(setups): harden migration against partial/corrupt relocation target Addresses the remaining HIGH from the PR #239 consolidated re-review: a zero-byte/partial/corrupt setups.json at the relocated path could shadow the intact legacy registry (existence was treated as proof of migration). - migration now copies via temp file + atomic os.replace (the same pattern FileSetupStorage.save_setups uses), so an interrupted copy can never leave a partial setups.json at the target; failed-copy temp files are cleaned up. - primary selection is corruption-aware: when the relocated file exists but does not parse as a setups document while the legacy file is intact, the registry keeps serving the legacy file and logs a loud diagnostic. The unreadable file is left untouched (never overwritten) as evidence. A valid relocated file stays authoritative; if both are corrupt, the relocated path stays primary so storage diagnostics point at the current location. Tests (+4): corrupt-target-prefers-intact-legacy (file left untouched), valid-target-stays-authoritative, both-corrupt-keeps-target, and interrupted-copy-leaves-no-partial-target-and-no-tmp-litter. Full suite: 620 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1210 lines
53 KiB
JavaScript
1210 lines
53 KiB
JavaScript
// SPDX-FileCopyrightText: 2025-2026 Kforge Labs <https://github.com/malkuthro/ComfyUI-Koolook>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
// =============================================================================
|
|
// 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,
|
|
MODULE_TAG,
|
|
compareNames,
|
|
noStoreUrl,
|
|
toast,
|
|
criticalToast,
|
|
} from "./constants.js";
|
|
import { formatLocalStamp } from "./format_time.js";
|
|
|
|
let workflowsCache = { directories: {} };
|
|
|
|
// Reject the three keys that can poison Object.prototype when an untrusted
|
|
// directory/workflow name is used as an object key. Guards the keyed-mutation
|
|
// sinks below (CodeQL js/prototype-polluting-assignment — e.g. archiveWorkflow's
|
|
// `dir.workflows[wfName].archived = true`, where a "__proto__" name resolves to
|
|
// Object.prototype and slips past the truthy existence check). Reads are
|
|
// harmless; only assignment/delete/mutation by name needs the guard.
|
|
function isSafeObjectKey(name) {
|
|
return name !== "__proto__" && name !== "constructor" && name !== "prototype";
|
|
}
|
|
|
|
function notifyWorkflowsChanged() {
|
|
window.dispatchEvent(new CustomEvent(WORKFLOWS_CHANGED_EVENT));
|
|
}
|
|
|
|
// =============================================================================
|
|
// Normalization (with back-compat migration for the pre-v0.3 flat shape)
|
|
// =============================================================================
|
|
//
|
|
// Exported so callers outside this module (e.g. ``detectBootDrift`` in
|
|
// ``snapshot.js``) can canonicalize a freshly-read JSON blob with the
|
|
// exact same transforms the live ``workflowsCache`` was built from —
|
|
// otherwise a fingerprint comparison of "live cache vs disk file" gets
|
|
// confused by ``archived: "false"`` (string) on disk vs the cache's
|
|
// ``archived: false`` (bool), missing ``directories: {}`` fields on
|
|
// pre-v0.3 nodes, or unsorted/duplicate tags. The transforms are
|
|
// idempotent: passing already-normalized data returns an equivalent
|
|
// (deep-cloned) shape, so calling this on a happy-path read is safe.
|
|
export 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);
|
|
}
|
|
}
|
|
const module = wf.module === true || tags.includes(MODULE_TAG);
|
|
cleanedWfs[wfName] = { ...wf, archived: wf.archived === true, module, 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 };
|
|
}
|
|
|
|
function cloneJson(value) {
|
|
return JSON.parse(JSON.stringify(value));
|
|
}
|
|
|
|
// Recursive deep-clone with object keys sorted case-insensitively at every
|
|
// level. Exported for reuse by two callers that both need the same key-order
|
|
// canonicalization: snapshot_diff.js (issue #181, Compare view) deciding
|
|
// whether two workflow graphs differ, and ``detectBootDrift`` in
|
|
// ``snapshot.js`` (issues #161/#162) comparing the live cache against the
|
|
// on-disk snapshot — without it, insertion-order-sensitive ``JSON.stringify``
|
|
// makes equivalent content produce different fingerprints (false-positive
|
|
// drift). Kept defined here next to its `compareNames` dependency rather than
|
|
// moved, so the store's own callers are untouched.
|
|
export function sortJsonValue(value) {
|
|
if (Array.isArray(value)) return value.map(sortJsonValue);
|
|
if (!value || typeof value !== "object") return value;
|
|
const out = {};
|
|
for (const key of Object.keys(value).sort(compareNames)) {
|
|
out[key] = sortJsonValue(value[key]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function normalizedStoresEqual(a, b) {
|
|
return JSON.stringify(sortJsonValue(a)) === JSON.stringify(sortJsonValue(b));
|
|
}
|
|
|
|
function workflowSavedAtMs(wf) {
|
|
if (!wf || typeof wf !== "object" || typeof wf.savedAt !== "string") return 0;
|
|
const ms = Date.parse(wf.savedAt);
|
|
return Number.isFinite(ms) ? ms : 0;
|
|
}
|
|
|
|
const ARCHIVE_NAME_RE = /^(.*) \(archived (\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})\)(?: #\d+)?$/;
|
|
|
|
function archiveTimestampFromName(wfName) {
|
|
if (typeof wfName !== "string") return 0;
|
|
const m = wfName.match(ARCHIVE_NAME_RE);
|
|
if (!m) return 0;
|
|
// Archive keys are generated from `toISOString()` with "T" replaced by
|
|
// a space, so parse them back as UTC. Parsing the bare string directly
|
|
// would use the browser's local timezone and skew retention windows.
|
|
const ms = Date.parse(`${m[2]}T${m[3]}.000Z`);
|
|
return Number.isFinite(ms) ? ms : 0;
|
|
}
|
|
|
|
function archiveBaseName(wfName) {
|
|
if (typeof wfName !== "string") return "";
|
|
const m = wfName.match(ARCHIVE_NAME_RE);
|
|
return m ? m[1] : wfName;
|
|
}
|
|
|
|
function workflowArchivedAtMs(wfName, wf) {
|
|
if (wf && typeof wf === "object" && typeof wf.archivedAt === "string") {
|
|
const ms = Date.parse(wf.archivedAt);
|
|
if (Number.isFinite(ms)) return ms;
|
|
}
|
|
const nameMs = archiveTimestampFromName(wfName);
|
|
if (nameMs) return nameMs;
|
|
return workflowSavedAtMs(wf);
|
|
}
|
|
|
|
function archiveStampForName(d) {
|
|
return d.toISOString().slice(0, 19).replace("T", " ");
|
|
}
|
|
|
|
function mergeNewerFallbackDir(serverDir, fallbackDir) {
|
|
let changed = false;
|
|
if (!serverDir.workflows || typeof serverDir.workflows !== "object") serverDir.workflows = {};
|
|
if (!serverDir.directories || typeof serverDir.directories !== "object") serverDir.directories = {};
|
|
|
|
const fallbackWorkflows =
|
|
fallbackDir && fallbackDir.workflows && typeof fallbackDir.workflows === "object"
|
|
? fallbackDir.workflows
|
|
: {};
|
|
for (const [wfName, fallbackWorkflow] of Object.entries(fallbackWorkflows)) {
|
|
const serverWorkflow = serverDir.workflows[wfName];
|
|
if (!serverWorkflow || workflowSavedAtMs(fallbackWorkflow) > workflowSavedAtMs(serverWorkflow)) {
|
|
serverDir.workflows[wfName] = cloneJson(fallbackWorkflow);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
const fallbackDirs =
|
|
fallbackDir && fallbackDir.directories && typeof fallbackDir.directories === "object"
|
|
? fallbackDir.directories
|
|
: {};
|
|
for (const [dirName, fallbackSubdir] of Object.entries(fallbackDirs)) {
|
|
if (!serverDir.directories[dirName]) {
|
|
serverDir.directories[dirName] = cloneJson(fallbackSubdir);
|
|
changed = true;
|
|
} else if (mergeNewerFallbackDir(serverDir.directories[dirName], fallbackSubdir)) {
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function mergeNewerFallbackStore(serverStore, fallbackStore) {
|
|
const merged = cloneJson(serverStore || { directories: {} });
|
|
if (!merged.directories || typeof merged.directories !== "object") merged.directories = {};
|
|
const fallbackDirs =
|
|
fallbackStore && fallbackStore.directories && typeof fallbackStore.directories === "object"
|
|
? fallbackStore.directories
|
|
: {};
|
|
let changed = false;
|
|
for (const [dirName, fallbackDir] of Object.entries(fallbackDirs)) {
|
|
if (!merged.directories[dirName]) {
|
|
merged.directories[dirName] = cloneJson(fallbackDir);
|
|
changed = true;
|
|
} else if (mergeNewerFallbackDir(merged.directories[dirName], fallbackDir)) {
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed ? merged : null;
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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(noStoreUrl(`/userdata/${WORKFLOWS_USERDATA_PATH}`), {
|
|
cache: "no-store",
|
|
});
|
|
} 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) {
|
|
// Corrupt /userdata file is a recovery situation — the user almost
|
|
// certainly thinks their data is gone. Sticky toast forces explicit
|
|
// acknowledgment and tells them where to look.
|
|
criticalToast(
|
|
"Workflow file on /userdata is unreadable (parse error). The " +
|
|
"file is preserved on disk — refusing to auto-recover so you " +
|
|
"can manually inspect / repair it before any save overwrites " +
|
|
"the bad blob. Check the browser console for the parse error " +
|
|
"and the file at /userdata/" + WORKFLOWS_USERDATA_PATH + "."
|
|
);
|
|
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 surface: /userdata loaded fine but a localStorage
|
|
// fallback from an earlier outage may still exist. Missing or newer entries are
|
|
// merged below, then persisted back to /userdata when possible.
|
|
// Older/ambiguous fallback data still uses the manual recovery surface
|
|
// described here.
|
|
// Server-only entries are preserved during automatic recovery.
|
|
// Same-name conflicts only auto-recover when the fallback timestamp wins.
|
|
// We don't fire the recovery toast from here either. The toast is
|
|
// wired by the entry point (`koolook_sidebar.js`) where the
|
|
// recovery handlers — Restore-as-snapshot and Discard — have access to
|
|
// `writePreset` / `loadUserPicks` / `showConfirmModal` without creating
|
|
// a circular import. We just return the blob so the caller can decide.
|
|
const fallbackBlob = localStorage.getItem(WORKFLOWS_FALLBACK_KEY);
|
|
if (fallbackBlob) {
|
|
let fallbackStore = null;
|
|
try {
|
|
fallbackStore = normalizeWorkflowsStore(JSON.parse(fallbackBlob));
|
|
} catch (e) {
|
|
console.warn("[Koolook] failed to parse localStorage workflows fallback for reconciliation:", e);
|
|
}
|
|
const mergedStore = fallbackStore ? mergeNewerFallbackStore(workflowsCache, fallbackStore) : null;
|
|
if (mergedStore) {
|
|
workflowsCache = mergedStore;
|
|
const reconcileResult = await persistWorkflowsToServer(workflowsCache);
|
|
if (reconcileResult === "server") {
|
|
localStorage.removeItem(WORKFLOWS_FALLBACK_KEY);
|
|
notifyWorkflowsChanged();
|
|
console.warn(
|
|
"[Koolook] merged browser-local workflow fallback and wrote it back to /userdata."
|
|
);
|
|
return { corrupt: false, fallbackRecovered: true };
|
|
}
|
|
console.warn(
|
|
`[Koolook] merged browser-local workflow fallback is live, but re-persist landed in ` +
|
|
`${reconcileResult || "neither"}; keeping recovery banner available.`
|
|
);
|
|
return { corrupt: false, fallbackBlob };
|
|
}
|
|
if (fallbackStore && normalizedStoresEqual(fallbackStore, workflowsCache)) {
|
|
try {
|
|
localStorage.removeItem(WORKFLOWS_FALLBACK_KEY);
|
|
console.warn("[Koolook] cleared redundant browser-local workflow fallback already present in /userdata.");
|
|
} catch (e) {
|
|
console.warn("[Koolook] failed to clear redundant localStorage workflows fallback:", e);
|
|
return { corrupt: false, fallbackBlob };
|
|
}
|
|
return { corrupt: false };
|
|
}
|
|
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.`
|
|
);
|
|
}
|
|
return { corrupt: false, fallbackBlob: fallbackBlob || null };
|
|
}
|
|
|
|
// Removes the localStorage fallback blob written during an earlier
|
|
// `/userdata` outage. Called by the recovery toast's Restore / Discard
|
|
// actions once the blob has been either persisted as a snapshot or
|
|
// explicitly discarded — clears the underlying condition so the toast
|
|
// stops re-firing on subsequent page loads. Best-effort: a quota-disabled
|
|
// browser would have failed the write anyway.
|
|
export function clearOfflineFallback() {
|
|
try {
|
|
localStorage.removeItem(WORKFLOWS_FALLBACK_KEY);
|
|
} catch (e) {
|
|
console.warn("[Koolook] failed to clear offline fallback:", e);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Transaction layer (commit + rollback + persistMutation)
|
|
// =============================================================================
|
|
// Track whether we've already raised the "fallback-only" sticky toast for
|
|
// the current outage. Without this, every mutation during a /userdata outage
|
|
// would stack another red banner on screen — quickly unusable. We re-arm
|
|
// the warning only after a successful "server" write proves /userdata is
|
|
// back, so a SECOND outage in the same session still alerts the user.
|
|
let _fallbackWarnedThisOutage = false;
|
|
|
|
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 === "fallback" && !_fallbackWarnedThisOutage) {
|
|
_fallbackWarnedThisOutage = true;
|
|
criticalToast(
|
|
"Workflow saved to browser-local fallback only — /userdata server " +
|
|
"unreachable. Data persists per-browser until the server is back. " +
|
|
"DO NOT clear browser data until you've confirmed a server save."
|
|
);
|
|
} else if (result === "server" && _fallbackWarnedThisOutage) {
|
|
// Server is back — re-arm the warning so a future outage will surface.
|
|
_fallbackWarnedThisOutage = false;
|
|
}
|
|
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;
|
|
}
|
|
// Capture the unsaved state BEFORE rollback so a recovery copy ends up
|
|
// in the user's clipboard via the critical toast — full backend failure
|
|
// is rare but devastating, and the brief moment between "user clicked
|
|
// save" and "rollback applied" is the only chance to surface what was
|
|
// lost. Pretty-printed for human readability since the user might paste
|
|
// it into a snapshot file or text editor.
|
|
const unsavedJson = JSON.stringify(workflowsCache, null, 2);
|
|
restore();
|
|
criticalToast(
|
|
persistFailedMessage ||
|
|
"Workflow save failed — both /userdata server AND browser " +
|
|
"localStorage rejected the write. Your last change has been " +
|
|
"reverted. Click Copy details to save a recovery JSON to your " +
|
|
"clipboard before retrying.",
|
|
{ copyText: unsavedJson }
|
|
);
|
|
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";
|
|
|
|
// Read-only render-source override (issue #181, Compare mode). When set,
|
|
// everything that resolves through `dirOf` — `listDirectoryNames`,
|
|
// `getWorkflowGraph`, `getWorkflowTags`, `isWorkflowModule`, and the tree
|
|
// gather in tree.js — reads this store instead of the live `workflowsCache`,
|
|
// letting the Compare view render a second sidebar from a loaded snapshot
|
|
// without mutating live state. Mutators also resolve paths through `dirOf`, so
|
|
// the override is scoped to one synchronous render pass and always cleared
|
|
// afterwards (see `withSnapshotSource` in tree.js); it must never straddle an
|
|
// await or a user interaction.
|
|
let renderSourceOverride = null;
|
|
|
|
function activeStore() {
|
|
return renderSourceOverride || workflowsCache;
|
|
}
|
|
|
|
export function setWorkflowsRenderSource(store) {
|
|
renderSourceOverride =
|
|
store && typeof store === "object" && store.directories && typeof store.directories === "object"
|
|
? store
|
|
: { directories: {} };
|
|
}
|
|
|
|
export function clearWorkflowsRenderSource() {
|
|
renderSourceOverride = null;
|
|
}
|
|
|
|
// Resolves to the DirNode at `path`, or `undefined` if any segment is missing.
|
|
// `[]` returns a synthetic wrapper around the root (with `directories`
|
|
// pointing at the active store's directories) so callers don't need a special
|
|
// case for root vs. nested.
|
|
export function dirOf(path) {
|
|
if (!Array.isArray(path)) return undefined;
|
|
const store = activeStore();
|
|
if (path.length === 0) {
|
|
return { workflows: {}, directories: store.directories || {} };
|
|
}
|
|
let node = { directories: store.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);
|
|
}
|
|
|
|
// 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;
|
|
if (!isSafeObjectKey(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 (!isSafeObjectKey(oldName) || !isSafeObjectKey(newName)) 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) {
|
|
if (!isSafeObjectKey(name)) return false;
|
|
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, options = {}) {
|
|
if (!isSafeObjectKey(wfName)) return false;
|
|
const dir = ensureDirectoryAtPath(path);
|
|
if (!dir) return false;
|
|
let archivedAs = null;
|
|
const existing = dir.workflows[wfName];
|
|
const now = options.now instanceof Date && !isNaN(options.now.getTime())
|
|
? options.now
|
|
: new Date();
|
|
if (existing) {
|
|
// Same-name save: move the existing version into the directory's
|
|
// Archive (timestamp-suffixed) before overwriting.
|
|
const ts = archiveStampForName(now);
|
|
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, archivedAt: now.toISOString() };
|
|
archivedAs = archiveName;
|
|
}
|
|
dir.workflows[wfName] = {
|
|
savedAt: now.toISOString(),
|
|
graph: graphData,
|
|
module: options.module === true,
|
|
};
|
|
return { archivedAs };
|
|
}
|
|
|
|
// Path-preserving copy (#197). Copy a workflow into `store` (any `{directories}`
|
|
// object) at `dirSegs` — the SAME folder path it lives at on the source side —
|
|
// creating any missing directories along the way and merging into existing
|
|
// ones (never duplicating a folder that's already there). Deep-clones the graph
|
|
// so the copy never aliases the source object. Collision policy at the
|
|
// destination path + name:
|
|
// - no active entry there -> add at `wfName` (status "added")
|
|
// - identical graph -> skip; it's already present (status "skipped")
|
|
// - graph differs -> keep both at "wfName (from <sourceLabel>)"
|
|
// (with a #n counter on further collision)
|
|
// (status "kept-both")
|
|
// Used for BOTH the live store (via copyWorkflowIntoLiveStore, inside a
|
|
// persistMutation) and a loaded snapshot's workflows (Compare A->B write-back).
|
|
// Returns { status, finalName }.
|
|
export function copyWorkflowIntoStore(store, dirSegs, wfName, graph, { tags = [], module = false, sourceLabel = "source" } = {}) {
|
|
if (!store || typeof store !== "object" || !Array.isArray(dirSegs) || typeof wfName !== "string" || !wfName) {
|
|
return { status: "failed", finalName: wfName };
|
|
}
|
|
// Prototype-pollution guard for the destination keys, matching the other
|
|
// name-keyed store mutators (#203): a "__proto__"/"constructor"/"prototype"
|
|
// workflow name or directory segment (from a crafted snapshot) would
|
|
// otherwise poison Object.prototype on the assignments below.
|
|
if (!isSafeObjectKey(wfName)) return { status: "failed", finalName: wfName };
|
|
if (!store.directories) store.directories = {};
|
|
let node = store;
|
|
for (const seg of dirSegs) {
|
|
if (typeof seg !== "string" || !seg || !isSafeObjectKey(seg)) return { status: "failed", finalName: wfName };
|
|
if (!node.directories) node.directories = {};
|
|
if (!node.directories[seg]) node.directories[seg] = { workflows: {}, directories: {} };
|
|
node = node.directories[seg];
|
|
}
|
|
if (!node.workflows) node.workflows = {};
|
|
const existing = node.workflows[wfName];
|
|
const collides = existing && existing.archived !== true;
|
|
if (collides && normalizedStoresEqual(existing.graph, graph)) {
|
|
return { status: "skipped", finalName: wfName };
|
|
}
|
|
let finalName = wfName;
|
|
if (collides) {
|
|
finalName = `${wfName} (from ${sourceLabel})`;
|
|
let n = 1;
|
|
while (node.workflows[finalName]) {
|
|
n += 1;
|
|
finalName = `${wfName} (from ${sourceLabel}) #${n}`;
|
|
}
|
|
}
|
|
node.workflows[finalName] = {
|
|
savedAt: new Date().toISOString(),
|
|
graph: JSON.parse(JSON.stringify(graph)),
|
|
module: module === true,
|
|
tags: Array.isArray(tags) ? [...tags] : [],
|
|
};
|
|
return { status: collides ? "kept-both" : "added", finalName };
|
|
}
|
|
|
|
// Live-store wrapper for the copy engine — mutates the working cache so the
|
|
// result rides the caller's persistMutation commit (and the live panel
|
|
// re-renders off WORKFLOWS_CHANGED_EVENT). Same return shape.
|
|
export function copyWorkflowIntoLiveStore(dirSegs, wfName, graph, opts) {
|
|
return copyWorkflowIntoStore(workflowsCache, dirSegs, wfName, graph, opts);
|
|
}
|
|
|
|
// Collect every ACTIVE workflow under `dirSegs` in `store` (recursively), each
|
|
// with its full path segments. Archived versions are skipped — a bulk merge
|
|
// moves live setups, not old versions. Returns [{ segs, wfName, entry }].
|
|
function collectWorkflowsUnderPath(store, dirSegs) {
|
|
let node = store && typeof store === "object" ? store : { directories: {} };
|
|
for (const seg of dirSegs) {
|
|
if (!node.directories || !node.directories[seg]) return [];
|
|
node = node.directories[seg];
|
|
}
|
|
const out = [];
|
|
const walk = (n, segs) => {
|
|
const wfs = n && typeof n.workflows === "object" && n.workflows ? n.workflows : {};
|
|
for (const wfName of Object.keys(wfs)) {
|
|
if (wfs[wfName] && wfs[wfName].archived === true) continue;
|
|
out.push({ segs, wfName, entry: wfs[wfName] });
|
|
}
|
|
const dirs = n && typeof n.directories === "object" && n.directories ? n.directories : {};
|
|
for (const dn of Object.keys(dirs)) walk(dirs[dn], [...segs, dn]);
|
|
};
|
|
walk(node, [...dirSegs]);
|
|
return out;
|
|
}
|
|
|
|
// Bulk path-preserving copy (#197): copy every active workflow under `dirSegs`
|
|
// in `sourceStore` into `targetStore` at the same path (folders auto-created /
|
|
// merged, same skip-identical / keep-both collision policy as the single copy).
|
|
// Powers "Copy folder to the other side" — the merge case for two different
|
|
// snapshots. `dirSegs` = [] copies the entire workflow tree. Returns a summary
|
|
// { total, added, skipped, keptBoth }.
|
|
export function copyFolderIntoStore(targetStore, sourceStore, dirSegs, { sourceLabel = "source" } = {}) {
|
|
const items = collectWorkflowsUnderPath(sourceStore, Array.isArray(dirSegs) ? dirSegs : []);
|
|
const summary = { total: items.length, added: 0, skipped: 0, keptBoth: 0 };
|
|
for (const it of items) {
|
|
const res = copyWorkflowIntoStore(targetStore, it.segs, it.wfName, it.entry.graph, {
|
|
tags: it.entry.tags, module: it.entry.module === true, sourceLabel,
|
|
});
|
|
if (res.status === "added") summary.added += 1;
|
|
else if (res.status === "skipped") summary.skipped += 1;
|
|
else if (res.status === "kept-both") summary.keptBoth += 1;
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
export function copyFolderIntoLiveStore(sourceStore, dirSegs, opts) {
|
|
return copyFolderIntoStore(workflowsCache, sourceStore, dirSegs, opts);
|
|
}
|
|
|
|
export function archiveWorkflow(path, wfName, options = {}) {
|
|
if (!isSafeObjectKey(wfName)) return false;
|
|
const dir = dirOf(path);
|
|
if (!dir || !dir.workflows[wfName]) return false;
|
|
dir.workflows[wfName].archived = true;
|
|
const now = options.now instanceof Date && !isNaN(options.now.getTime())
|
|
? options.now
|
|
: new Date();
|
|
dir.workflows[wfName].archivedAt = now.toISOString();
|
|
return true;
|
|
}
|
|
|
|
export function unarchiveWorkflow(path, wfName) {
|
|
if (!isSafeObjectKey(wfName)) return false;
|
|
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;
|
|
if (!isSafeObjectKey(oldWfName) || !isSafeObjectKey(newWfName)) 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) {
|
|
if (!isSafeObjectKey(wfName)) return false;
|
|
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 (!isSafeObjectKey(wfName)) return false;
|
|
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;
|
|
}
|
|
|
|
export function isWorkflowModule(path, wfName) {
|
|
const dir = dirOf(path);
|
|
if (!dir || !dir.workflows[wfName]) return false;
|
|
const wf = dir.workflows[wfName];
|
|
const tags = Array.isArray(wf.tags) ? wf.tags : [];
|
|
return wf.module === true || tags.includes(MODULE_TAG);
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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.
|
|
// =============================================================================
|
|
|
|
// Tag auto-applied to a workflow when it is published as a setup. Drives the
|
|
// Workflows-tree badge and the Tools "Published" filter, and lands the
|
|
// workflow in the Tags section's "published" pool for free. A normal tag, so
|
|
// it stays user-manageable (and may drift if a setup is later unpublished).
|
|
export const PUBLISHED_TAG = "published";
|
|
|
|
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] : [];
|
|
}
|
|
|
|
// Case-sensitive membership test, shared by the published-discovery badge and
|
|
// the Tools "Published" filter so both agree on what counts as tagged. Missing
|
|
// workflow or absent tags -> false (never throws).
|
|
export function workflowHasTag(path, wfName, tag) {
|
|
const tags = getWorkflowTags(path, wfName);
|
|
return Array.isArray(tags) && tags.includes(tag);
|
|
}
|
|
|
|
export function addTag(path, wfName, tag) {
|
|
if (!isSafeObjectKey(wfName)) return false;
|
|
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);
|
|
if (tag === MODULE_TAG) wf.module = true;
|
|
return true;
|
|
}
|
|
|
|
export function removeTag(path, wfName, tag) {
|
|
if (!isSafeObjectKey(wfName)) return false;
|
|
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);
|
|
if (tag === MODULE_TAG) wf.module = false;
|
|
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 };
|
|
}
|
|
|
|
function sortedArchivedEntries(dir) {
|
|
return Object.entries(dir.workflows || {})
|
|
.filter(([, wf]) => wf && wf.archived === true)
|
|
.map(([name, wf]) => ({
|
|
name,
|
|
wf,
|
|
baseName: archiveBaseName(name),
|
|
timestampMs: workflowArchivedAtMs(name, wf),
|
|
}))
|
|
.sort((a, b) => {
|
|
const byTime = b.timestampMs - a.timestampMs;
|
|
return byTime || compareNames(a.name, b.name);
|
|
});
|
|
}
|
|
|
|
export function getArchiveDisplayInfo(path, wfName, now = new Date()) {
|
|
const dir = dirOf(path);
|
|
const wf = dir && dir.workflows ? dir.workflows[wfName] : null;
|
|
const timestampMs = workflowArchivedAtMs(wfName, wf);
|
|
const label = archiveBaseName(wfName);
|
|
const safeNow = now instanceof Date && !isNaN(now.getTime()) ? now : new Date();
|
|
const stamp = timestampMs ? formatLocalStamp(new Date(timestampMs), safeNow) : "";
|
|
return {
|
|
label,
|
|
timestampMs,
|
|
meta: stamp ? `archived ${stamp}` : "archived",
|
|
};
|
|
}
|
|
|
|
export function getArchiveCleanupPlan(path, now = new Date()) {
|
|
const dir = dirOf(path);
|
|
if (!dir || !dir.workflows) {
|
|
return { keepNames: [], deleteNames: [], keepCount: 0, deleteCount: 0, groups: 0 };
|
|
}
|
|
const nowMs = now instanceof Date && !isNaN(now.getTime()) ? now.getTime() : Date.now();
|
|
const windows = [
|
|
5 * 60 * 1000,
|
|
60 * 60 * 1000,
|
|
24 * 60 * 60 * 1000,
|
|
];
|
|
const archivedEntries = sortedArchivedEntries(dir);
|
|
const byBase = new Map();
|
|
for (const entry of archivedEntries) {
|
|
if (!byBase.has(entry.baseName)) byBase.set(entry.baseName, []);
|
|
byBase.get(entry.baseName).push(entry);
|
|
}
|
|
|
|
const keep = new Set();
|
|
for (const entries of byBase.values()) {
|
|
const groupKeep = new Set();
|
|
for (const windowMs of windows) {
|
|
const representative = entries.find((entry) => {
|
|
if (!entry.timestampMs) return false;
|
|
const ageMs = Math.max(0, nowMs - entry.timestampMs);
|
|
return ageMs <= windowMs && !groupKeep.has(entry.name);
|
|
});
|
|
if (representative) groupKeep.add(representative.name);
|
|
}
|
|
if (groupKeep.size === 0 && entries.length > 0) {
|
|
groupKeep.add(entries[0].name);
|
|
}
|
|
for (const name of groupKeep) keep.add(name);
|
|
}
|
|
|
|
const allNames = archivedEntries.map((entry) => entry.name);
|
|
const deleteNames = allNames.filter((name) => !keep.has(name));
|
|
const keepNames = allNames.filter((name) => keep.has(name));
|
|
return {
|
|
keepNames,
|
|
deleteNames,
|
|
keepCount: keepNames.length,
|
|
deleteCount: deleteNames.length,
|
|
groups: byBase.size,
|
|
};
|
|
}
|
|
|
|
export function cleanUpArchive(path, planOrNow = new Date()) {
|
|
const dir = dirOf(path);
|
|
if (!dir || !dir.workflows) return false;
|
|
const plan =
|
|
planOrNow && typeof planOrNow === "object" && Array.isArray(planOrNow.deleteNames)
|
|
? planOrNow
|
|
: getArchiveCleanupPlan(path, planOrNow);
|
|
if (!Array.isArray(plan.deleteNames) || plan.deleteNames.length === 0) return false;
|
|
const deletedNames = [];
|
|
for (const name of plan.deleteNames) {
|
|
if (!isSafeObjectKey(name)) continue;
|
|
const wf = dir.workflows[name];
|
|
if (!wf || wf.archived !== true) continue;
|
|
delete dir.workflows[name];
|
|
deletedNames.push(name);
|
|
}
|
|
if (deletedNames.length === 0) return false;
|
|
return {
|
|
...plan,
|
|
deleteNames: deletedNames,
|
|
deleteCount: deletedNames.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 (!isSafeObjectKey(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).
|