| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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: {} }; |
|
|
| |
| |
| |
| |
| |
| |
| function isSafeObjectKey(name) { |
| return name !== "__proto__" && name !== "constructor" && name !== "prototype"; |
| } |
|
|
| function notifyWorkflowsChanged() { |
| window.dispatchEvent(new CustomEvent(WORKFLOWS_CHANGED_EVENT)); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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)) { |
| |
| |
| |
| |
| |
| |
| 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) { |
| |
| |
| |
| |
| |
| 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 }; |
| } |
| |
| |
| |
| 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)); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| |
| const USERDATA_OVERWRITE_QUERY = "?overwrite=true"; |
|
|
| |
| |
| |
| |
| 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; |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| export async function loadWorkflowsStore() { |
| const fromServer = await fetchWorkflowsFromServer(); |
| if (fromServer === SERVER_FILE_CORRUPT) { |
| |
| |
| |
| 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) { |
| |
| workflowsCache = { directories: {} }; |
| return { corrupt: false }; |
| } |
| if (fromServer === undefined) { |
| |
| 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); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function clearOfflineFallback() { |
| try { |
| localStorage.removeItem(WORKFLOWS_FALLBACK_KEY); |
| } catch (e) { |
| console.warn("[Koolook] failed to clear offline fallback:", e); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| let _fallbackWarnedThisOutage = false; |
|
|
| async function commit() { |
| |
| |
| |
| 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) { |
| |
| _fallbackWarnedThisOutage = false; |
| } |
| if (result) notifyWorkflowsChanged(); |
| return result !== false; |
| } |
|
|
| |
| |
| function snapshotCache() { |
| const snap = JSON.stringify(workflowsCache); |
| return () => { |
| try { |
| workflowsCache = JSON.parse(snap); |
| notifyWorkflowsChanged(); |
| } catch (e) { |
| console.error("[Koolook] cache rollback failed:", e); |
| } |
| }; |
| } |
|
|
| |
| |
| |
| 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; |
| } |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| export async function seedWorkflowDefaultsIfNeeded() { |
| if (localStorage.getItem(WORKFLOWS_SEEDED_KEY)) return; |
| |
| 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; |
| |
| |
| |
| |
| 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"); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const ARCHIVE_RESERVED_NAME = "archive"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| export function listDirectoryNames(parentPath = []) { |
| const node = dirOf(parentPath); |
| if (!node || !node.directories) return []; |
| return Object.keys(node.directories).sort(compareNames); |
| } |
|
|
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function addDirectory(parentPath, name) { |
| name = (name || "").trim(); |
| if (!name) return false; |
| if (!isSafeObjectKey(name)) return false; |
| |
| |
| if (!Array.isArray(parentPath)) return false; |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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) { |
| |
| |
| 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 }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 }; |
| } |
| |
| |
| |
| |
| 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 }; |
| } |
|
|
| |
| |
| |
| export function copyWorkflowIntoLiveStore(dirSegs, wfName, graph, opts) { |
| return copyWorkflowIntoStore(workflowsCache, dirSegs, wfName, graph, opts); |
| } |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| 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] : []; |
| } |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| export function getAllWorkflowsForExport() { |
| return JSON.parse(JSON.stringify(workflowsCache)); |
| } |
|
|
| |
| |
| |
| |
| |
| 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, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| |
| if (pathsEqual(srcParentPath, dstParentPath)) return false; |
| |
| 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; |
| |
| |
| |
| const srcFullPath = [...srcParentPath, name]; |
| if (isPathDescendantOrSame(dstParentPath, srcFullPath)) return false; |
| dst.directories[name] = src.directories[name]; |
| delete src.directories[name]; |
| return true; |
| } |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
|
|