| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { |
| REPOS, |
| ROOT_GROUP_LABEL, |
| WORKFLOWS_GROUP_LABEL, |
| STORAGE_KEY, |
| WORKFLOWS_FALLBACK_KEY, |
| PICKS_CHANGED_EVENT, |
| WORKFLOWS_CHANGED_EVENT, |
| SNAPSHOT_STATUS_CHANGED_EVENT, |
| GROUP_MODE_KEY, |
| GROUP_MODE_DEFAULT, |
| MODULE_TAG, |
| GUIDE_URL, |
| GITHUB_REPO_URL, |
| ensureStyle, |
| toast, |
| compareNames, |
| } from "./constants.js"; |
| import { formatLocalStamp } from "./format_time.js"; |
| import { |
| loadUserPicks, |
| removeFromMyPicks, |
| notifyPicksChanged, |
| addToMyPicks, |
| loadAutoPullHidden, |
| hideAutoPullType, |
| setPicksRenderSource, |
| clearPicksRenderSource, |
| } from "./picks_store.js"; |
| import { |
| persistMutation, |
| listDirectoryNames, |
| dirOf, |
| addDirectory, |
| renameDirectory, |
| deleteDirectory, |
| saveWorkflowEntry, |
| copyWorkflowIntoStore, |
| copyWorkflowIntoLiveStore, |
| copyFolderIntoStore, |
| copyFolderIntoLiveStore, |
| archiveWorkflow, |
| unarchiveWorkflow, |
| renameWorkflow, |
| deleteWorkflow, |
| moveWorkflow, |
| moveDirectory, |
| clearArchive, |
| cleanUpArchive, |
| getArchiveCleanupPlan, |
| getArchiveDisplayInfo, |
| pathsEqual, |
| getWorkflowGraph, |
| getWorkflowTags, |
| workflowHasTag, |
| PUBLISHED_TAG, |
| isWorkflowModule, |
| addTag, |
| removeTag, |
| getAllWorkflowsForExport, |
| setWorkflowsRenderSource, |
| clearWorkflowsRenderSource, |
| } from "./workflows_store.js"; |
| import { diffPicks, diffWorkflows, getWorkflowEntryFromStore } from "./snapshot_diff.js"; |
| import { checkForUpdate, renderUpdateFooter } from "./update_check.js"; |
| import { |
| serializeFullCanvas, |
| serializeSelection, |
| canvasIsNonEmpty, |
| captureWorkflowApiPrompt, |
| loadWorkflowOntoCanvas, |
| insertWorkflowOntoCanvas, |
| insertNode, |
| getSelectedNodeTypes, |
| getSelectedNodeCount, |
| getCanvasNodeCount, |
| dropPlaceholdersForPacks, |
| } from "./canvas_io.js"; |
| import { discoverMissingPacks } from "./installer.js"; |
| import { |
| showInputModal, |
| showConfirmModal, |
| showSaveWorkflowModal, |
| showContextMenu, |
| showTagsModal, |
| showPublishSetupModal, |
| showInstallMissingModal, |
| showSaveSnapshotDialog, |
| showLoadSnapshotDialog, |
| } from "./modals.js"; |
| import { attachHoverPreview, teardownPreview } from "./node_preview.js"; |
| import { publishSavedWorkflowSetup, revealPublishedSetupFolder } from "./published_setups.js"; |
| import { |
| sanitizeName, |
| gatherSnapshot, |
| applySnapshot, |
| listPresets, |
| readPreset, |
| writePreset, |
| presetExists, |
| deletePreset, |
| getLibraryInfo, |
| saveSettings, |
| browseDirectories, |
| createBrowseDirectory, |
| getCurrentPresetName, |
| setCurrentPresetName, |
| exportStarterPreset, |
| writePreLoadAutosave, |
| markStateSaved, |
| markStateAutosaved, |
| getSnapshotStatus, |
| listAutosaves, |
| revealPresetFolder, |
| } from "./snapshot.js"; |
| import { resolveFolderExpanded } from "./tree_expansion.js"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const SECTION_ID_NODES = "nodes"; |
| const SECTION_ID_WORKFLOWS = "workflows"; |
| const SECTION_ID_TAGS = "tags"; |
|
|
| |
| |
| |
| |
| let publishedOnly = false; |
|
|
| const SVG_NS = "http://www.w3.org/2000/svg"; |
| const TOOLBAR_ICONS = { |
| loadSnapshot: { kind: "letter", text: "L" }, |
| saveSnapshot: { kind: "letter", text: "S" }, |
| compareSnapshot: { kind: "letter", text: "A/B" }, |
| help: { kind: "letter", text: "H" }, |
| publishedFilter: { kind: "letter", text: "P" }, |
| exportStarter: { kind: "letter", text: "E" }, |
| installMissing: { kind: "letter", text: "I" }, |
| dropMissing: { |
| kind: "svg", |
| shapes: [ |
| ["path", { d: "M10 10 5 5" }], |
| ["path", { d: "M5 5v5" }], |
| ["path", { d: "M5 5h5" }], |
| ["path", { d: "m14 10 5-5" }], |
| ["path", { d: "M19 5v5" }], |
| ["path", { d: "M19 5h-5" }], |
| ["path", { d: "m10 14-5 5" }], |
| ["path", { d: "M5 19v-5" }], |
| ["path", { d: "M5 19h5" }], |
| ["path", { d: "m14 14 5 5" }], |
| ["path", { d: "M19 19v-5" }], |
| ["path", { d: "M19 19h-5" }], |
| ["circle", { cx: "12", cy: "12", r: "1.2" }], |
| ], |
| }, |
| repoMode: { kind: "stair" }, |
| categoryMode: { kind: "list" }, |
| saveWorkflow: { kind: "square" }, |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| let pathStates = new Map(); |
| const comparePathStates = new Map(); |
|
|
| |
| |
| |
| |
| const pinnedPaths = new Set(); |
|
|
| |
| |
| const SECTIONS = []; |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| function addSection(spec) { |
| SECTIONS.push(spec); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function pinExpanded(paths) { |
| for (const p of paths) pinnedPaths.add(p); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function spotlightAddedPicks(typeNames) { |
| if (!Array.isArray(typeNames) || typeNames.length === 0) return; |
| const pathsToExpand = new Set(); |
| for (const t of typeNames) { |
| const loc = findPackPathForType(t); |
| if (!loc) continue; |
| pathsToExpand.add(`${SECTION_ID_NODES}/${loc.packLabel}`); |
| pathsToExpand.add(`${SECTION_ID_NODES}/${loc.packLabel}/${loc.sub}`); |
| } |
| if (pathsToExpand.size === 0) return; |
|
|
| |
| |
| |
| |
| |
| for (const key of [...pathStates.keys()]) { |
| if (key === SECTION_ID_NODES || key.startsWith(SECTION_ID_NODES + "/")) { |
| pathStates.delete(key); |
| } |
| } |
| pinExpanded([...pathsToExpand]); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function renderTree({ treeEl, query }) { |
| |
| |
| |
| |
| |
| |
| teardownPreview(); |
| treeEl.innerHTML = ""; |
| const q = (query || "").trim().toLowerCase(); |
| const isFiltered = q.length > 0; |
| const validPaths = new Set(); |
| |
| |
| |
| |
| const renderedSectionIds = new Set(); |
|
|
| |
| const gathered = SECTIONS.map(section => ({ |
| section, |
| result: section.gather(q), |
| })); |
|
|
| |
| |
| |
| const anyVisible = gathered.some(({ result }) => result.total > 0); |
| if (!anyVisible && isFiltered) { |
| const empty = document.createElement("div"); |
| empty.className = "koolook-empty"; |
| empty.textContent = "No nodes or workflows match your search."; |
| treeEl.appendChild(empty); |
| } else { |
| let appended = 0; |
| for (const { section, result } of gathered) { |
| if (result.total === 0) { |
| |
| |
| if (!isFiltered && section.emptyMessage) { |
| if (appended > 0) appendDivider(treeEl); |
| const el = document.createElement("div"); |
| el.className = "koolook-empty"; |
| el.textContent = section.emptyMessage; |
| treeEl.appendChild(el); |
| appended += 1; |
| } |
| continue; |
| } |
|
|
| if (appended > 0) appendDivider(treeEl); |
|
|
| renderedSectionIds.add(section.id); |
| const sectionPath = section.id; |
| validPaths.add(sectionPath); |
| |
| |
| |
| |
| const sectionExpand = isFiltered |
| || (publishedOnly && section.id === SECTION_ID_WORKFLOWS); |
| const sectionFolder = buildFolder({ |
| name: section.label, |
| count: result.total, |
| iconKind: section.iconKind, |
| startExpanded: true, |
| path: sectionPath, |
| forceExpanded: sectionExpand, |
| |
| |
| |
| onContextMenu: section.rootContextMenu, |
| childrenBuilder: (children) => { |
| const ctx = makeSectionCtx(children, sectionPath, sectionExpand, validPaths); |
| ctx.data = result; |
| ctx.query = q; |
| ctx.isFiltered = isFiltered; |
| section.build(ctx); |
| }, |
| }); |
| treeEl.appendChild(sectionFolder); |
| appended += 1; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| const pinned = new Set(pinnedPaths); |
| pinnedPaths.clear(); |
| for (const key of [...pathStates.keys()]) { |
| const slashIdx = key.indexOf("/"); |
| const sectionId = slashIdx === -1 ? key : key.slice(0, slashIdx); |
| if (!renderedSectionIds.has(sectionId)) continue; |
| if (!validPaths.has(key) && !pinned.has(key)) { |
| pathStates.delete(key); |
| } |
| } |
| for (const p of pinned) pathStates.set(p, true); |
| } |
|
|
| |
| |
| |
| function makeSectionCtx(parentEl, prefix, isFiltered, validPaths) { |
| return { |
| folder({ |
| name, |
| count, |
| iconKind, |
| path, |
| startExpanded = false, |
| forceExpandedWhenFiltered = true, |
| onContextMenu, |
| build, |
| draggablePayload, |
| dropTarget, |
| }) { |
| const fullPath = prefix ? `${prefix}/${path}` : path; |
| validPaths.add(fullPath); |
| const folder = buildFolder({ |
| name, count, iconKind, startExpanded, |
| path: fullPath, |
| forceExpanded: isFiltered, |
| forceExpandedWhenFiltered, |
| onContextMenu, |
| draggablePayload, |
| dropTarget, |
| childrenBuilder: (children) => { |
| const sub = makeSectionCtx(children, fullPath, isFiltered, validPaths); |
| build(sub); |
| }, |
| }); |
| parentEl.appendChild(folder); |
| }, |
| leaf({ row }) { |
| parentEl.appendChild(row); |
| }, |
| }; |
| } |
|
|
| function appendDivider(treeEl) { |
| const d = document.createElement("div"); |
| d.className = "koolook-tree-divider"; |
| treeEl.appendChild(d); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const DND_MIME = "application/x-koolook-row"; |
|
|
| function decorateDraggable(row, payload) { |
| row.draggable = true; |
| row.addEventListener("dragstart", (e) => { |
| e.stopPropagation(); |
| e.dataTransfer.effectAllowed = "move"; |
| e.dataTransfer.setData(DND_MIME, JSON.stringify(payload)); |
| }); |
| } |
|
|
| function decorateDropTarget(row, target) { |
| row.addEventListener("dragover", (e) => { |
| if (!Array.from(e.dataTransfer.types).includes(DND_MIME)) return; |
| e.preventDefault(); |
| e.stopPropagation(); |
| e.dataTransfer.dropEffect = "move"; |
| row.classList.add("koolook-drop-target"); |
| }); |
| row.addEventListener("dragleave", () => { |
| row.classList.remove("koolook-drop-target"); |
| }); |
| row.addEventListener("drop", (e) => { |
| row.classList.remove("koolook-drop-target"); |
| if (!Array.from(e.dataTransfer.types).includes(DND_MIME)) return; |
| e.preventDefault(); |
| e.stopPropagation(); |
| const raw = e.dataTransfer.getData(DND_MIME); |
| if (!raw) return; |
| let payload; |
| try { payload = JSON.parse(raw); } |
| catch { return; } |
| handleDndDrop(payload, target); |
| }); |
| } |
|
|
| function handleDndDrop(payload, target) { |
| if (!payload || !target) return; |
| if (payload.type === "workflow") { |
| const srcPath = payload.path; |
| const wfName = payload.name; |
| if (target.kind === "dir") { |
| |
| if (pathsEqual(srcPath, target.path)) return; |
| persistMutation({ |
| mutate: () => moveWorkflow(srcPath, wfName, target.path), |
| onSuccess: () => toast(`Moved "${wfName}" to ${target.path.join(" / ")}.`), |
| onNoOp: () => toast(`Could not move (name conflict in destination?).`), |
| }); |
| return; |
| } |
| if (target.kind === "archive") { |
| |
| |
| const sameDir = pathsEqual(srcPath, target.path); |
| persistMutation({ |
| mutate: () => { |
| if (!sameDir) { |
| if (!moveWorkflow(srcPath, wfName, target.path)) return false; |
| } |
| return archiveWorkflow(target.path, wfName); |
| }, |
| onSuccess: () => { |
| const where = sameDir ? "" : ` in ${target.path.join(" / ")}`; |
| toast(`Archived "${wfName}"${where}.`); |
| }, |
| onNoOp: () => toast(`Could not archive (name conflict or workflow missing).`), |
| }); |
| return; |
| } |
| return; |
| } |
| if (payload.type === "directory") { |
| if (target.kind !== "dir") return; |
| const srcParentPath = payload.parentPath; |
| const dirName = payload.name; |
| |
| if (pathsEqual(srcParentPath, target.path)) return; |
| |
| |
| |
| |
| |
| |
| |
| if (pathsEqual([...srcParentPath, dirName], target.path)) return; |
| persistMutation({ |
| mutate: () => moveDirectory(srcParentPath, dirName, target.path), |
| onSuccess: () => { |
| const where = target.path.length === 0 ? "(root)" : target.path.join(" / "); |
| toast(`Moved directory "${dirName}" into ${where}.`); |
| }, |
| onNoOp: () => toast(`Could not move directory (cycle, name collision, or invalid target).`), |
| }); |
| } |
| } |
|
|
| |
| |
| |
| function subcategoryFor(category, categoryRoot) { |
| if (!category) return "(uncategorized)"; |
| if (category === categoryRoot) return "(root)"; |
| if (categoryRoot && category.startsWith(categoryRoot + "/")) { |
| return category.slice(categoryRoot.length + 1); |
| } |
| |
| |
| |
| |
| |
| |
| console.warn( |
| `[Koolook] subcategoryFor fallback fired: category="${category}" did not match ` + |
| `categoryRoot="${categoryRoot}". The repo config may need updating to track an ` + |
| `upstream category rename.` |
| ); |
| const parts = category.split("/"); |
| return parts.length > 1 ? parts.slice(1).join("/") : category; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function isAutoPulled(typeName) { |
| const registry = (typeof LiteGraph !== "undefined" && LiteGraph.registered_node_types) || {}; |
| const nc = registry[typeName]; |
| if (!nc) return false; |
| const cat = nc.category || ""; |
| for (const repo of REPOS) { |
| if (repo.select === "all") { |
| const root = repo.categoryRoot || ""; |
| if (root && (cat === root || cat.startsWith(root + "/"))) return true; |
| } else if (Array.isArray(repo.select) && repo.select.includes(typeName)) { |
| return true; |
| } |
| } |
| return false; |
| } |
|
|
| function findPackPathForType(typeName) { |
| const registry = (typeof LiteGraph !== "undefined" && LiteGraph.registered_node_types) || {}; |
| const nc = registry[typeName]; |
| if (!nc) return null; |
| const cat = (nc && nc.category) || ""; |
|
|
| for (const repo of REPOS) { |
| if (repo.select === "all") { |
| const root = repo.categoryRoot || ""; |
| if (root && (cat === root || cat.startsWith(root + "/"))) { |
| return { packLabel: repo.label, sub: subcategoryFor(cat, root) }; |
| } |
| } else if (Array.isArray(repo.select)) { |
| if (repo.select.includes(typeName)) { |
| return { packLabel: repo.label, sub: subcategoryFor(cat, repo.categoryRoot || "") }; |
| } |
| } |
| } |
|
|
| const packLabel = cat.split("/")[0] || "(uncategorized)"; |
| return { packLabel, sub: subcategoryFor(cat, packLabel) }; |
| } |
|
|
| function matchesQuery(display, type, q) { |
| if (!q) return true; |
| return display.toLowerCase().includes(q) || type.toLowerCase().includes(q); |
| } |
|
|
| function gatherNodesByRepo(query) { |
| const q = (query || "").trim().toLowerCase(); |
| const registry = (typeof LiteGraph !== "undefined" && LiteGraph.registered_node_types) || {}; |
| const hidden = loadAutoPullHidden(); |
| const out = []; |
|
|
| for (const repo of REPOS) { |
| let candidateIds; |
| if (repo.select === "all") { |
| const root = repo.categoryRoot || ""; |
| candidateIds = Object.entries(registry) |
| .filter(([type, nc]) => { |
| if (hidden.has(type)) return false; |
| const cat = (nc && nc.category) || ""; |
| return root && (cat === root || cat.startsWith(root + "/")); |
| }) |
| .map(([type]) => type); |
| } else if (Array.isArray(repo.select)) { |
| candidateIds = repo.select.filter(t => registry[t] !== undefined && !hidden.has(t)); |
| } else { |
| candidateIds = []; |
| } |
|
|
| const subcats = new Map(); |
| let total = 0; |
| for (const type of candidateIds) { |
| const nc = registry[type]; |
| const display = (nc && nc.title) || type; |
| if (repo.excludePatterns && repo.excludePatterns.some(re => re.test(display))) continue; |
| if (!matchesQuery(display, type, q)) continue; |
|
|
| const sub = subcategoryFor((nc && nc.category) || "", repo.categoryRoot); |
| if (!subcats.has(sub)) subcats.set(sub, []); |
| subcats.get(sub).push({ type, display }); |
| total += 1; |
| } |
|
|
| const categories = [...subcats.entries()] |
| .map(([name, nodes]) => ({ |
| name, |
| nodes: nodes.sort((a, b) => compareNames(a.display, b.display)), |
| })) |
| .sort((a, b) => compareNames(a.name, b.name)); |
|
|
| out.push({ label: repo.label, categories, total }); |
| } |
| return out; |
| } |
|
|
| function gatherUserPickPacks(query) { |
| const q = (query || "").trim().toLowerCase(); |
| const registry = (typeof LiteGraph !== "undefined" && LiteGraph.registered_node_types) || {}; |
| const picks = loadUserPicks(); |
|
|
| const autoCategoryRoots = new Set( |
| REPOS.filter(r => r.select === "all" && r.categoryRoot).map(r => r.categoryRoot) |
| ); |
|
|
| const byPack = new Map(); |
| for (const type of picks) { |
| const nc = registry[type]; |
| if (!nc) continue; |
| const cat = nc.category || ""; |
| const packLabel = cat.split("/")[0] || "(uncategorized)"; |
| if (autoCategoryRoots.has(packLabel)) continue; |
|
|
| const display = nc.title || type; |
| if (!matchesQuery(display, type, q)) continue; |
|
|
| const sub = subcategoryFor(cat, packLabel); |
| if (!byPack.has(packLabel)) byPack.set(packLabel, new Map()); |
| const subcatMap = byPack.get(packLabel); |
| if (!subcatMap.has(sub)) subcatMap.set(sub, []); |
| subcatMap.get(sub).push({ type, display }); |
| } |
|
|
| const result = []; |
| for (const [packLabel, subcatMap] of byPack.entries()) { |
| const categories = [...subcatMap.entries()] |
| .map(([name, nodes]) => ({ |
| name, |
| nodes: nodes.sort((a, b) => compareNames(a.display, b.display)), |
| })) |
| .sort((a, b) => compareNames(a.name, b.name)); |
| const total = categories.reduce((acc, c) => acc + c.nodes.length, 0); |
| result.push({ label: packLabel, categories, total, isUserPicks: true }); |
| } |
| result.sort((a, b) => compareNames(a.label, b.label)); |
| return result; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| function canonicalSegment(s) { |
| return String(s).toLowerCase().replace(/[\s_\-]+/g, ""); |
| } |
|
|
| |
| |
| |
| |
| |
| function pickDisplayLabel(displayCounts) { |
| let best = null; |
| let bestCount = -1; |
| for (const [name, count] of displayCounts) { |
| if (count > bestCount) { |
| best = name; |
| bestCount = count; |
| } |
| } |
| return best; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const THEME_CLASSIFIERS = [ |
| { |
| key: "image", |
| label: "Image", |
| patterns: [ |
| /\bimg\b/i, /image/i, /pixel/i, |
| /color/i, /\bocio\b/i, /\bexr\b/i, /\bhdr\b/i, |
| /upscal/i, /resize/i, /comparer/i, /\btile\b/i, |
| /frame/i, /thumbnail/i, |
| ], |
| }, |
| { |
| key: "video", |
| label: "Video", |
| patterns: [/video/i, /\bwan\b/i, /hunyuan/i, /cogvideo/i, /\bltx\b/i, /motion/i, /sequence/i], |
| }, |
| { |
| key: "mask", |
| label: "Mask", |
| patterns: [/\bmask/i, /\balpha\b/i, /segment/i, /matte/i, /rmbg/i], |
| }, |
| { |
| key: "audio", |
| label: "Audio", |
| patterns: [/audio/i, /\bwav\b/i, /\bsound\b/i], |
| }, |
| { |
| key: "model", |
| label: "Model", |
| patterns: [ |
| /model/i, /checkpoint/i, /\blora\b/i, /\bvae\b/i, /\bclip\b/i, |
| /controlnet/i, /ipadapter/i, /diffusion/i, /\bunet\b/i, |
| ], |
| }, |
| { |
| key: "sampler", |
| label: "Sampler", |
| patterns: [/sampler/i, /scheduler/i, /\bnoise\b/i, /\bsigma\b/i], |
| }, |
| { |
| key: "conditioning", |
| label: "Conditioning", |
| patterns: [/condition/i, /\bembed/i, /tokeniz/i], |
| }, |
| { |
| key: "text", |
| label: "Text & Prompt", |
| patterns: [/\btext\b/i, /prompt/i, /\bstring\b/i, /caption/i], |
| }, |
| { |
| key: "math", |
| label: "Math & Logic", |
| patterns: [ |
| /\bmath\b/i, /\blogic\b/i, /\bint\b/i, /\bfloat\b/i, /\bbool/i, |
| /switch/i, /\bcompare\b/i, /numeric/i, |
| ], |
| }, |
| { |
| key: "pipeline", |
| label: "Pipeline & Batch", |
| patterns: [/pipeline/i, /workflow/i, /\bbatch\b/i, /\bcache\b/i], |
| }, |
| { |
| key: "io", |
| label: "I/O", |
| patterns: [/\bload\b/i, /\bsave\b/i, /\bfile\b/i, /\bpath\b/i, /import/i, /export/i], |
| }, |
| { |
| key: "camera", |
| label: "Camera", |
| patterns: [/camera/i, /\blens\b/i, /\bpose\b/i, /\btrack/i], |
| }, |
| { |
| key: "utility", |
| label: "Utility", |
| |
| |
| |
| |
| |
| |
| patterns: [/\butil/i, /helper/i, /\btool\b/i, /\bget\b/i, /\bset\b/i, /reroute/i], |
| }, |
| ]; |
|
|
| |
| |
| |
| |
| |
| |
| function classifyTheme(corpus) { |
| for (const cls of THEME_CLASSIFIERS) { |
| for (const pat of cls.patterns) { |
| if (pat.test(corpus)) return { key: cls.key, label: cls.label }; |
| } |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function extractTheme(typeName, registry) { |
| const nc = registry[typeName]; |
| if (!nc) return { kind: "unresolved" }; |
| const cat = nc.category || ""; |
| const display = nc.title || typeName; |
| const loc = findPackPathForType(typeName); |
| const packLabel = loc?.packLabel || ""; |
|
|
| |
| |
| |
| |
| const corpus = `${packLabel} ${cat} ${display} ${typeName}`; |
| const classified = classifyTheme(corpus); |
| if (classified) { |
| return { |
| kind: "theme", |
| themeRaw: classified.label, |
| themeCanonical: classified.key, |
| }; |
| } |
|
|
| |
| |
| |
| const segs = cat.split("/").map(s => s.trim()).filter(Boolean); |
| if (segs.length === 0) return { kind: "uncategorized" }; |
| let themeIdx = 0; |
| if (loc && loc.packLabel && segs.length > 1) { |
| if (canonicalSegment(loc.packLabel) === canonicalSegment(segs[0])) { |
| themeIdx = 1; |
| } |
| } |
| const themeRaw = segs[themeIdx]; |
| if (!themeRaw) return { kind: "uncategorized" }; |
| return { |
| kind: "theme", |
| themeRaw, |
| themeCanonical: canonicalSegment(themeRaw), |
| }; |
| } |
|
|
| function gatherNodesByTheme(query) { |
| const q = (query || "").trim().toLowerCase(); |
| const registry = (typeof LiteGraph !== "undefined" && LiteGraph.registered_node_types) || {}; |
| const userPicks = loadUserPicks(); |
|
|
| |
| |
| |
| |
| |
| |
| |
| const autoCategoryRoots = new Set( |
| REPOS.filter(r => r.select === "all" && r.categoryRoot).map(r => r.categoryRoot) |
| ); |
| const hidden = loadAutoPullHidden(); |
| const seen = new Set(); |
| const seeds = []; |
|
|
| for (const repo of REPOS) { |
| if (repo.select === "all") { |
| const root = repo.categoryRoot || ""; |
| if (!root) continue; |
| for (const [type, nc] of Object.entries(registry)) { |
| if (seen.has(type) || hidden.has(type)) continue; |
| const cat = (nc && nc.category) || ""; |
| if (cat !== root && !cat.startsWith(root + "/")) continue; |
| const display = (nc && nc.title) || type; |
| if (repo.excludePatterns && repo.excludePatterns.some(re => re.test(display))) continue; |
| seen.add(type); |
| seeds.push({ type }); |
| } |
| } else if (Array.isArray(repo.select)) { |
| for (const type of repo.select) { |
| if (seen.has(type) || hidden.has(type) || registry[type] === undefined) continue; |
| seen.add(type); |
| seeds.push({ type }); |
| } |
| } |
| } |
|
|
| for (const type of userPicks) { |
| if (seen.has(type)) continue; |
| |
| |
| |
| |
| const nc = registry[type]; |
| if (nc && !hidden.has(type)) { |
| const packLabel = (nc.category || "").split("/")[0] || ""; |
| if (autoCategoryRoots.has(packLabel)) continue; |
| } |
| seen.add(type); |
| seeds.push({ type }); |
| } |
|
|
| |
| const themes = new Map(); |
| const ensureBucket = (key, seedLabel) => { |
| if (!themes.has(key)) { |
| themes.set(key, { |
| displayLabels: seedLabel ? new Map([[seedLabel, 1]]) : new Map(), |
| nodes: [], |
| }); |
| } |
| return themes.get(key); |
| }; |
|
|
| for (const { type } of seeds) { |
| const result = extractTheme(type, registry); |
|
|
| if (result.kind === "unresolved") { |
| |
| |
| |
| if (!matchesQuery(type, type, q)) continue; |
| const bucket = ensureBucket("(unresolved)", "(unresolved)"); |
| bucket.nodes.push({ type, display: type, unresolved: true }); |
| continue; |
| } |
|
|
| |
| |
| |
| const nc = registry[type]; |
| const display = nc.title || type; |
| if (!matchesQuery(display, type, q)) continue; |
|
|
| if (result.kind === "uncategorized") { |
| const bucket = ensureBucket("(uncategorized)", "(uncategorized)"); |
| bucket.nodes.push({ type, display }); |
| continue; |
| } |
|
|
| const bucket = ensureBucket(result.themeCanonical); |
| bucket.displayLabels.set( |
| result.themeRaw, |
| (bucket.displayLabels.get(result.themeRaw) || 0) + 1, |
| ); |
| bucket.nodes.push({ type, display }); |
| } |
|
|
| return themes; |
| } |
|
|
| |
| |
| function countNodesInThemes(themes) { |
| let c = 0; |
| for (const bucket of themes.values()) c += bucket.nodes.length; |
| return c; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function loadGroupMode() { |
| try { |
| const v = localStorage.getItem(GROUP_MODE_KEY); |
| return v === "category" || v === "repo" ? v : GROUP_MODE_DEFAULT; |
| } catch { |
| return GROUP_MODE_DEFAULT; |
| } |
| } |
|
|
| function saveGroupMode(mode) { |
| if (mode !== "repo" && mode !== "category") return; |
| try { localStorage.setItem(GROUP_MODE_KEY, mode); } catch { } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function gatherWorkflows(query) { |
| const q = (query || "").trim().toLowerCase(); |
| const stats = { total: 0 }; |
| const directories = gatherDirsAt([], q, stats); |
| return { directories, total: stats.total }; |
| } |
|
|
| function gatherDirsAt(parentPath, q, stats) { |
| const out = []; |
| for (const dirName of listDirectoryNames(parentPath)) { |
| const dirPath = [...parentPath, dirName]; |
| const dir = dirOf(dirPath); |
| if (!dir) continue; |
|
|
| const matches = (n) => !q || n.toLowerCase().includes(q) || dirName.toLowerCase().includes(q); |
|
|
| const allNames = Object.keys(dir.workflows || {}); |
| const active = []; |
| const archived = []; |
| for (const n of allNames) { |
| if (!matches(n)) continue; |
| |
| |
| if (publishedOnly && !workflowHasTag(dirPath, n, PUBLISHED_TAG)) continue; |
| if (dir.workflows[n] && dir.workflows[n].archived) { |
| if (publishedOnly) continue; |
| archived.push(n); |
| } else { |
| active.push(n); |
| } |
| } |
| active.sort(compareNames); |
| archived.sort((a, b) => { |
| const aInfo = getArchiveDisplayInfo(dirPath, a); |
| const bInfo = getArchiveDisplayInfo(dirPath, b); |
| const byTime = bInfo.timestampMs - aInfo.timestampMs; |
| return byTime || compareNames(a, b); |
| }); |
|
|
| const subdirs = gatherDirsAt(dirPath, q, stats); |
|
|
| |
| |
| |
| |
| |
| if ((q || publishedOnly) && active.length === 0 && archived.length === 0 && subdirs.length === 0) continue; |
|
|
| out.push({ name: dirName, path: dirPath, active, archived, subdirs }); |
| stats.total += active.length + archived.length; |
| } |
| return out; |
| } |
|
|
| |
| |
| |
| |
| function countWorkflowsInGatheredDir(dir) { |
| let count = dir.active.length + dir.archived.length; |
| for (const sub of dir.subdirs) count += countWorkflowsInGatheredDir(sub); |
| return count; |
| } |
|
|
| |
| |
| function countDescendantsOfRawDir(dir) { |
| if (!dir) return { workflows: 0, subdirs: 0 }; |
| let workflows = Object.keys(dir.workflows || {}).length; |
| let subdirs = 0; |
| for (const child of Object.values(dir.directories || {})) { |
| const sub = countDescendantsOfRawDir(child); |
| workflows += sub.workflows; |
| subdirs += 1 + sub.subdirs; |
| } |
| return { workflows, subdirs }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function gatherTags(query) { |
| const q = (query || "").trim().toLowerCase(); |
| const tagMap = new Map(); |
|
|
| const walk = (parentPath) => { |
| for (const dirName of listDirectoryNames(parentPath)) { |
| const dirPath = [...parentPath, dirName]; |
| const dir = dirOf(dirPath); |
| if (!dir) continue; |
| for (const [wfName, wf] of Object.entries(dir.workflows || {})) { |
| if (wf.archived === true) continue; |
| const tags = Array.isArray(wf.tags) ? wf.tags : []; |
| if (tags.length === 0) continue; |
| for (const tag of tags) { |
| if (q && !tag.toLowerCase().includes(q) && !wfName.toLowerCase().includes(q)) continue; |
| if (!tagMap.has(tag)) tagMap.set(tag, []); |
| tagMap.get(tag).push({ wfName, path: dirPath }); |
| } |
| } |
| walk(dirPath); |
| } |
| }; |
| walk([]); |
|
|
| const tags = [...tagMap.entries()] |
| .map(([name, entries]) => ({ |
| name, |
| entries: entries.sort((a, b) => compareNames(a.wfName, b.wfName)), |
| })) |
| .sort((a, b) => compareNames(a.name, b.name)); |
| const total = tags.reduce((acc, t) => acc + t.entries.length, 0); |
| return { tags, total }; |
| } |
|
|
| |
| |
| |
|
|
| function makeIconElement(icon) { |
| if (icon && icon.kind === "letter") { |
| const el = document.createElement("span"); |
| el.className = "koolook-letter-icon"; |
| el.textContent = icon.text; |
| el.setAttribute("aria-hidden", "true"); |
| return el; |
| } |
| if (icon && icon.kind === "square") { |
| const el = document.createElement("span"); |
| el.className = "koolook-filled-square-icon"; |
| el.setAttribute("aria-hidden", "true"); |
| return el; |
| } |
| if (icon && icon.kind === "stair") { |
| const el = document.createElement("span"); |
| el.className = "koolook-stair-icon"; |
| el.setAttribute("aria-hidden", "true"); |
| for (let i = 0; i < 3; i += 1) { |
| el.appendChild(document.createElement("span")); |
| } |
| return el; |
| } |
| if (icon && icon.kind === "list") { |
| const el = document.createElement("span"); |
| el.className = "koolook-list-icon"; |
| el.setAttribute("aria-hidden", "true"); |
| for (let i = 0; i < 3; i += 1) { |
| el.appendChild(document.createElement("span")); |
| } |
| return el; |
| } |
| if (icon && icon.kind === "svg") { |
| const svg = document.createElementNS(SVG_NS, "svg"); |
| svg.classList.add("koolook-inline-svg-icon"); |
| svg.setAttribute("viewBox", "0 0 24 24"); |
| svg.setAttribute("aria-hidden", "true"); |
| for (const [tag, attrs] of icon.shapes || []) { |
| const shape = document.createElementNS(SVG_NS, tag); |
| for (const [key, value] of Object.entries(attrs)) { |
| shape.setAttribute(key, value); |
| } |
| svg.appendChild(shape); |
| } |
| return svg; |
| } |
| const el = document.createElement("span"); |
| el.className = typeof icon === "string" ? icon : (icon?.iconClass || ""); |
| el.setAttribute("aria-hidden", "true"); |
| return el; |
| } |
|
|
| |
| |
| function makeToolbarButton({ iconClass, icon, title, onClick }) { |
| const btn = document.createElement("button"); |
| btn.className = "koolook-add-btn koolook-icon-btn"; |
| btn.appendChild(makeIconElement(icon || iconClass)); |
| btn.title = title; |
| btn.setAttribute("aria-label", title); |
| btn.addEventListener("click", onClick); |
| return btn; |
| } |
|
|
| function makeFolderRow({ name, count, iconKind, onToggle, onContextMenu, draggablePayload, dropTarget }) { |
| const row = document.createElement("div"); |
| row.className = "koolook-row"; |
|
|
| const chevron = document.createElement("span"); |
| chevron.className = "koolook-chevron"; |
| chevron.textContent = "▾"; |
| row.appendChild(chevron); |
|
|
| const icon = document.createElement("span"); |
| if (iconKind === "favorites") { |
| icon.className = "pi pi-star koolook-pin-icon"; |
| } else if (iconKind === "workflows") { |
| icon.className = "pi pi-th-large koolook-workflows-icon"; |
| } else if (iconKind === "archive") { |
| icon.className = "pi pi-box koolook-archive-icon"; |
| } else { |
| icon.className = "pi pi-folder koolook-folder-icon"; |
| } |
| row.appendChild(icon); |
|
|
| const nameEl = document.createElement("span"); |
| nameEl.className = "koolook-name"; |
| nameEl.textContent = name; |
| row.appendChild(nameEl); |
|
|
| if (count != null) { |
| const cnt = document.createElement("span"); |
| cnt.className = "koolook-count"; |
| cnt.textContent = String(count); |
| row.appendChild(cnt); |
| } |
|
|
| row.addEventListener("click", onToggle); |
| if (onContextMenu) row.addEventListener("contextmenu", onContextMenu); |
| if (draggablePayload) decorateDraggable(row, draggablePayload); |
| if (dropTarget) decorateDropTarget(row, dropTarget); |
| return { row, chevron }; |
| } |
|
|
| function makeNodeLeafRow({ display, type, removable, onClick, unresolved, breadcrumb, packBadge }) { |
| const row = document.createElement("div"); |
| row.className = "koolook-row koolook-leaf"; |
| if (unresolved) row.classList.add("koolook-leaf-unresolved"); |
| |
| if (type) row.dataset.koolookNodeType = type; |
| |
| |
| |
| row.title = breadcrumb ? `${breadcrumb} › ${type}` : type; |
|
|
| const chevron = document.createElement("span"); |
| chevron.className = "koolook-chevron"; |
| row.appendChild(chevron); |
|
|
| const dot = document.createElement("span"); |
| dot.className = "koolook-leaf-dot"; |
| row.appendChild(dot); |
|
|
| |
| |
| |
| |
| |
| |
| const nameEl = document.createElement("span"); |
| nameEl.className = "koolook-name"; |
| if (breadcrumb) { |
| const crumb = document.createElement("span"); |
| crumb.className = "koolook-leaf-crumb"; |
| crumb.textContent = `${breadcrumb} › `; |
| nameEl.appendChild(crumb); |
| nameEl.appendChild(document.createTextNode(display)); |
| } else { |
| nameEl.textContent = display; |
| } |
| row.appendChild(nameEl); |
|
|
| |
| |
| |
| if (packBadge) { |
| const badge = document.createElement("span"); |
| badge.className = "koolook-pack-badge"; |
| badge.textContent = packBadge; |
| row.appendChild(badge); |
| } |
|
|
| if (removable) { |
| const rm = document.createElement("span"); |
| rm.className = "koolook-remove"; |
| rm.textContent = "×"; |
| rm.title = "Remove from favorites"; |
| rm.addEventListener("click", (e) => { |
| e.stopPropagation(); |
| |
| |
| |
| |
| |
| removeFromMyPicks(type); |
| if (isAutoPulled(type)) hideAutoPullType(type); |
| notifyPicksChanged(); |
| }); |
| row.appendChild(rm); |
| } |
|
|
| row.addEventListener("click", onClick); |
| |
| |
| |
| attachHoverPreview(row, type); |
| return row; |
| } |
|
|
| function makeWorkflowLeafRow({ |
| name, |
| dirName, |
| wfPath, |
| onClick, |
| onContextMenu, |
| draggablePayload, |
| isModule = false, |
| isPublished = false, |
| secondaryText = "", |
| }) { |
| const row = document.createElement("div"); |
| row.className = "koolook-row koolook-leaf"; |
| |
| if (wfPath) row.dataset.koolookWfPath = wfPath; |
| |
| |
| |
| |
| const titleSuffix = secondaryText ? ` — ${secondaryText}` : ""; |
| row.title = isModule |
| ? `${dirName} / ${name}${titleSuffix} — module (left-click to insert into canvas)` |
| : `${dirName} / ${name}${titleSuffix}`; |
|
|
| const chevron = document.createElement("span"); |
| chevron.className = "koolook-chevron"; |
| row.appendChild(chevron); |
|
|
| const icon = document.createElement("span"); |
| icon.className = isModule |
| ? "pi pi-plus-circle koolook-module-icon" |
| : "pi pi-file koolook-leaf-icon"; |
| row.appendChild(icon); |
|
|
| if (secondaryText) { |
| const stack = document.createElement("span"); |
| stack.className = "koolook-name-stack"; |
| const nameEl = document.createElement("span"); |
| nameEl.className = "koolook-name"; |
| nameEl.textContent = name; |
| stack.appendChild(nameEl); |
| const metaEl = document.createElement("span"); |
| metaEl.className = "koolook-row-meta"; |
| metaEl.textContent = secondaryText; |
| stack.appendChild(metaEl); |
| row.appendChild(stack); |
| } else { |
| const nameEl = document.createElement("span"); |
| nameEl.className = "koolook-name"; |
| nameEl.textContent = name; |
| row.appendChild(nameEl); |
| } |
|
|
| |
| |
| |
| if (isPublished) { |
| const badge = document.createElement("span"); |
| badge.className = "koolook-published-badge"; |
| badge.textContent = "published"; |
| badge.title = "Published as a callable setup"; |
| row.appendChild(badge); |
| } |
|
|
| row.addEventListener("click", onClick); |
| if (onContextMenu) row.addEventListener("contextmenu", onContextMenu); |
| if (draggablePayload) decorateDraggable(row, draggablePayload); |
| return row; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function isModuleWorkflow(dirPath, wfName) { |
| return isWorkflowModule(dirPath, wfName); |
| } |
|
|
| function buildFolder({ |
| name, |
| count, |
| iconKind, |
| childrenBuilder, |
| onContextMenu, |
| startExpanded = true, |
| path, |
| forceExpanded = false, |
| forceExpandedWhenFiltered = true, |
| draggablePayload, |
| dropTarget, |
| }) { |
| |
| |
| |
| |
| |
| |
| |
| |
| if (typeof path !== "string" || !path) { |
| throw new Error("buildFolder: `path` must be a non-empty string"); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| const stateMap = pathStates; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const initiallyExpanded = resolveFolderExpanded({ |
| forceExpanded, |
| forceExpandedWhenFiltered, |
| iconKind, |
| isPinned: pinnedPaths.has(path), |
| hasStoredState: stateMap.has(path), |
| storedState: stateMap.get(path), |
| startExpanded, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| let isExpanded = initiallyExpanded; |
| const wrapper = document.createElement("div"); |
|
|
| const children = document.createElement("div"); |
| children.className = "koolook-children"; |
| if (!isExpanded) children.style.display = "none"; |
|
|
| const { row, chevron } = makeFolderRow({ |
| name, |
| count, |
| iconKind, |
| onContextMenu, |
| draggablePayload, |
| dropTarget, |
| onToggle: () => { |
| isExpanded = !isExpanded; |
| chevron.textContent = isExpanded ? "▾" : "▸"; |
| children.style.display = isExpanded ? "" : "none"; |
| stateMap.set(path, isExpanded); |
| }, |
| }); |
| if (!initiallyExpanded) chevron.textContent = "▸"; |
|
|
| |
| |
| |
| |
| row.dataset.koolookFolderPath = path; |
|
|
| wrapper.appendChild(row); |
| wrapper.appendChild(children); |
| childrenBuilder(children); |
| return wrapper; |
| } |
|
|
| |
| |
| |
| function workflowRowContextMenu(event, dirPath, wfName, isArchived = false) { |
| |
| |
| |
| |
| |
| |
| function moveToNewDirAction(parentPath, parentLabel) { |
| showInputModal({ |
| title: parentLabel |
| ? `New subdirectory under "${parentLabel}"` |
| : "New top-level directory", |
| label: "Name", |
| placeholder: "e.g. drafts", |
| confirmLabel: "Create & Move", |
| onSubmit: (name) => persistMutation({ |
| mutate: () => { |
| if (!addDirectory(parentPath, name)) return false; |
| const target = [...parentPath, name]; |
| if (!moveWorkflow(dirPath, wfName, target)) { |
| deleteDirectory(parentPath, name); |
| return false; |
| } |
| return target; |
| }, |
| onSuccess: (target) => toast(`Moved "${wfName}" to ${target.join(" / ")}.`), |
| onNoOp: () => toast(`Could not create — name in use, empty, or "Archive" reserved.`), |
| }), |
| }); |
| } |
|
|
| const newDirItem = { |
| label: "+ New directory…", |
| action: () => moveToNewDirAction([], null), |
| }; |
| const newSubdirItem = { |
| label: `+ New subdirectory under "${dirPath.join(" / ")}"…`, |
| action: () => moveToNewDirAction(dirPath, dirPath.join(" / ")), |
| }; |
|
|
| const archiveItem = isArchived |
| ? { |
| label: "Restore from archive", |
| action: () => persistMutation({ |
| mutate: () => unarchiveWorkflow(dirPath, wfName), |
| onSuccess: () => toast(`Restored "${wfName}".`), |
| }), |
| } |
| : { |
| label: "Move to archive", |
| action: () => persistMutation({ |
| mutate: () => archiveWorkflow(dirPath, wfName), |
| onSuccess: () => toast(`Archived "${wfName}".`), |
| }), |
| }; |
|
|
| const duplicateItem = { |
| label: "Duplicate…", |
| action: () => { |
| const sourceGraph = getWorkflowGraph(dirPath, wfName); |
| if (!sourceGraph) { |
| toast("Could not duplicate — workflow not found."); |
| return; |
| } |
| |
| |
| |
| const sourceTags = getWorkflowTags(dirPath, wfName) || []; |
| const sourceIsModule = isWorkflowModule(dirPath, wfName); |
| showInputModal({ |
| title: "Duplicate workflow", |
| label: "New name", |
| defaultValue: `${wfName} (copy)`, |
| confirmLabel: "Duplicate", |
| onSubmit: (newName) => persistMutation({ |
| mutate: () => { |
| |
| |
| |
| |
| |
| |
| |
| |
| if (getWorkflowGraph(dirPath, wfName) === null) return false; |
| const cloned = JSON.parse(JSON.stringify(sourceGraph)); |
| const result = saveWorkflowEntry(dirPath, newName, cloned, { module: sourceIsModule }); |
| if (!result) return false; |
| |
| |
| |
| |
| |
| |
| for (const t of sourceTags) addTag(dirPath, newName, t); |
| if (sourceIsModule) addTag(dirPath, newName, MODULE_TAG); |
| return result; |
| }, |
| onSuccess: (result) => { |
| if (result && result.archivedAs) { |
| toast(`Duplicated to "${newName}" — previous "${newName}" archived as "${result.archivedAs}".`); |
| } else { |
| toast(`Duplicated to "${newName}".`); |
| } |
| }, |
| onNoOp: () => toast(`Could not duplicate — "${wfName}" no longer exists.`), |
| }), |
| }); |
| }, |
| }; |
|
|
| const updateFromCanvasItem = { |
| label: "Update from selection or canvas", |
| action: () => { |
| if (!canvasIsNonEmpty()) { |
| toast("Canvas is empty."); |
| return; |
| } |
|
|
| const selectedCount = getSelectedNodeCount(); |
| const totalNodeCount = getCanvasNodeCount(); |
| const allNodesSelected = selectedCount > 0 && selectedCount === totalNodeCount; |
|
|
| let graph = null; |
| let sourceLabel = "canvas"; |
|
|
| if (selectedCount > 0 && !allNodesSelected) { |
| const selectionResult = serializeSelection(); |
| if (selectionResult.kind === "stale") { |
| toast("Selected node(s) no longer exist."); |
| return; |
| } |
| if (selectionResult.kind !== "ok") { |
| toast("Selection unavailable. Re-select nodes and try again."); |
| return; |
| } |
| if (!selectionResult.graph || !Array.isArray(selectionResult.graph.nodes) || selectionResult.graph.nodes.length === 0) { |
| toast("Failed to serialize selection. See console."); |
| return; |
| } |
| graph = selectionResult.graph; |
| sourceLabel = "selection"; |
| } else { |
| graph = serializeFullCanvas(); |
| if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) { |
| toast("Failed to serialize canvas. See console."); |
| return; |
| } |
| } |
|
|
| const sourceTags = getWorkflowTags(dirPath, wfName) || []; |
| const sourceIsModule = isWorkflowModule(dirPath, wfName); |
| persistMutation({ |
| mutate: () => { |
| if (getWorkflowGraph(dirPath, wfName) === null) return false; |
| const result = saveWorkflowEntry(dirPath, wfName, graph, { module: sourceIsModule }); |
| if (!result) return false; |
| for (const t of sourceTags) addTag(dirPath, wfName, t); |
| if (sourceIsModule) addTag(dirPath, wfName, MODULE_TAG); |
| return result; |
| }, |
| onSuccess: (result) => { |
| if (result && result.archivedAs) { |
| toast(`Updated "${wfName}" from ${sourceLabel} (previous version archived as "${result.archivedAs}").`); |
| } else { |
| toast(`Updated "${wfName}" from ${sourceLabel}.`); |
| } |
| }, |
| onNoOp: () => toast(`Could not update — "${wfName}" no longer exists.`), |
| }); |
| }, |
| }; |
|
|
| const tagsItem = { |
| label: "Tags…", |
| action: () => { |
| showTagsModal({ |
| wfName, |
| |
| |
| |
| |
| |
| getCurrentTags: () => getWorkflowTags(dirPath, wfName), |
| onAddTag: (tag, onDone) => persistMutation({ |
| mutate: () => addTag(dirPath, wfName, tag), |
| onSuccess: () => { onDone(); toast(`Tagged "${wfName}" with "${tag}".`); }, |
| onNoOp: () => toast(`Could not add — empty or already tagged.`), |
| }), |
| onRemoveTag: (tag, onDone) => persistMutation({ |
| mutate: () => removeTag(dirPath, wfName, tag), |
| onSuccess: () => { onDone(); toast(`Removed tag "${tag}" from "${wfName}".`); }, |
| onNoOp: () => toast(`Tag "${tag}" was not present.`), |
| }), |
| }); |
| }, |
| }; |
|
|
| const publishSetupItem = { |
| label: "Publish setup…", |
| action: () => { |
| const visualGraph = getWorkflowGraph(dirPath, wfName); |
| showPublishSetupModal({ |
| wfName, |
| dirPath, |
| currentTags: getWorkflowTags(dirPath, wfName) || [], |
| visualGraph, |
| revealPublishedSetupFolder, |
| |
| |
| |
| onPublish: async ({ metadata, inputContract, outputContract }) => { |
| const result = await publishSavedWorkflowSetup({ |
| dirPath, |
| wfName, |
| visualGraph, |
| captureApiPrompt: captureWorkflowApiPrompt, |
| metadata, |
| inputContract, |
| outputContract, |
| }); |
| |
| |
| |
| |
| |
| |
| await persistMutation({ |
| mutate: () => addTag(dirPath, wfName, PUBLISHED_TAG), |
| onSuccess: () => {}, |
| onNoOp: () => { |
| |
| |
| |
| |
| if (!workflowHasTag(dirPath, wfName, PUBLISHED_TAG)) { |
| toast(`Published, but couldn't tag "${wfName}" as published — it may have moved.`); |
| } |
| }, |
| }); |
| return result; |
| }, |
| }); |
| }, |
| }; |
|
|
| showContextMenu(event, [ |
| { |
| label: "Load", |
| action: () => loadWorkflowOntoCanvas(dirPath, wfName), |
| }, |
| { |
| |
| |
| |
| |
| label: "Insert into canvas", |
| action: () => insertWorkflowOntoCanvas(dirPath, wfName), |
| }, |
| ...(!isArchived ? [updateFromCanvasItem] : []), |
| { |
| label: "Rename…", |
| action: () => { |
| showInputModal({ |
| title: "Rename workflow", |
| label: "New name", |
| defaultValue: wfName, |
| confirmLabel: "Rename", |
| onSubmit: (newName) => persistMutation({ |
| mutate: () => renameWorkflow(dirPath, wfName, newName), |
| onSuccess: () => toast(`Renamed to "${newName}".`), |
| onNoOp: () => toast(`Rename failed (name in use?).`), |
| }), |
| }); |
| }, |
| }, |
| duplicateItem, |
| tagsItem, |
| ...(!isArchived ? [publishSetupItem] : []), |
| archiveItem, |
| |
| |
| |
| null, |
| newDirItem, |
| newSubdirItem, |
| null, |
| { |
| label: "Delete", |
| danger: true, |
| action: () => { |
| showConfirmModal({ |
| title: "Delete workflow?", |
| message: `"${wfName}" will be removed. This cannot be undone.`, |
| confirmLabel: "Delete", |
| danger: true, |
| onConfirm: () => persistMutation({ |
| mutate: () => deleteWorkflow(dirPath, wfName), |
| onSuccess: () => toast(`Deleted "${wfName}".`), |
| }), |
| }); |
| }, |
| }, |
| ]); |
| } |
|
|
| |
| |
| |
| |
| function archiveFolderContextMenu(event, dirPath, archivedCount) { |
| const dirDisplay = dirPath.join(" / "); |
| const noun = `${archivedCount} archived workflow${archivedCount === 1 ? "" : "s"}`; |
| const cleanupPlan = getArchiveCleanupPlan(dirPath); |
| showContextMenu(event, [ |
| { |
| label: "Clean up archive", |
| action: () => { |
| if (!cleanupPlan || cleanupPlan.deleteCount === 0) { |
| toast(`Archive in "${dirDisplay}" already looks tidy.`); |
| return; |
| } |
| showConfirmModal({ |
| title: "Clean up archive?", |
| message: archiveCleanupConfirmMessage(cleanupPlan), |
| confirmLabel: "Clean up archive", |
| danger: true, |
| onConfirm: () => persistMutation({ |
| mutate: () => cleanUpArchive(dirPath, cleanupPlan), |
| onSuccess: (result) => toast( |
| `Cleaned archive in "${dirDisplay}": kept ${result.keepCount}, deleted ${result.deleteCount}.` |
| ), |
| onNoOp: () => toast(`Archive in "${dirDisplay}" already looks tidy.`), |
| }), |
| }); |
| }, |
| }, |
| { |
| label: `Delete archive (${archivedCount})`, |
| danger: true, |
| action: () => { |
| showConfirmModal({ |
| title: "Delete archived workflows?", |
| message: `${noun} in "${dirDisplay}" will be permanently deleted. Active workflows in this directory are not affected.`, |
| confirmLabel: "Delete archive", |
| danger: true, |
| onConfirm: () => persistMutation({ |
| mutate: () => clearArchive(dirPath), |
| onSuccess: (result) => toast( |
| `Deleted ${result.count} archived workflow${result.count === 1 ? "" : "s"} in "${dirDisplay}".` |
| ), |
| }), |
| }); |
| }, |
| }, |
| ]); |
| } |
|
|
| function archiveCleanupConfirmMessage(cleanupPlan) { |
| const list = document.createElement("ul"); |
| list.className = "koolook-confirm-list"; |
|
|
| const items = [ |
| "Clean up this Archive folder.", |
| "Keep latest from 5 minutes, 1 hour, and 1 day.", |
| "Apply separately to each setup name.", |
| `Delete ${cleanupPlan.deleteCount} older duplicate${cleanupPlan.deleteCount === 1 ? "" : "s"}.`, |
| "Active workflows stay untouched.", |
| ]; |
|
|
| for (const text of items) { |
| const item = document.createElement("li"); |
| item.textContent = text; |
| list.appendChild(item); |
| } |
|
|
| return list; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function workflowsRootContextMenu(event) { |
| showContextMenu(event, [ |
| { |
| label: "Create directory…", |
| action: () => { |
| showInputModal({ |
| title: "Create directory", |
| label: "Directory name", |
| placeholder: "e.g. Inpainting", |
| confirmLabel: "Create", |
| onSubmit: (name) => persistMutation({ |
| |
| |
| |
| mutate: () => addDirectory([], name), |
| onSuccess: () => toast(`Directory "${name}" created.`), |
| onNoOp: () => toast(`Directory "${name}" already exists.`), |
| }), |
| }); |
| }, |
| }, |
| ]); |
| } |
|
|
| function directoryRowContextMenu(event, dirPath) { |
| const dir = dirOf(dirPath); |
| const dirName = dirPath[dirPath.length - 1]; |
| const parentPath = dirPath.slice(0, -1); |
| const displayPath = dirPath.join(" / "); |
| const counts = countDescendantsOfRawDir(dir); |
| const isEmpty = counts.workflows === 0 && counts.subdirs === 0; |
|
|
| showContextMenu(event, [ |
| { |
| label: "Create subdirectory…", |
| action: () => { |
| showInputModal({ |
| title: `Create subdirectory under "${displayPath}"`, |
| label: "Name", |
| placeholder: "e.g. Type-A", |
| confirmLabel: "Create", |
| onSubmit: (name) => persistMutation({ |
| mutate: () => addDirectory(dirPath, name), |
| onSuccess: () => toast(`Created "${name}" in ${displayPath}.`), |
| onNoOp: () => toast(`Could not create — name in use, empty, or "Archive" reserved.`), |
| }), |
| }); |
| }, |
| }, |
| { |
| label: "Rename directory…", |
| action: () => { |
| showInputModal({ |
| title: "Rename directory", |
| label: "New name", |
| defaultValue: dirName, |
| confirmLabel: "Rename", |
| onSubmit: (newName) => persistMutation({ |
| mutate: () => renameDirectory(parentPath, dirName, newName), |
| onSuccess: () => toast(`Renamed to "${newName}".`), |
| onNoOp: () => toast(`Rename failed — name in use or "Archive" reserved.`), |
| }), |
| }); |
| }, |
| }, |
| null, |
| { |
| label: "Delete directory", |
| danger: true, |
| action: () => { |
| if (isEmpty) { |
| showConfirmModal({ |
| title: "Delete empty directory?", |
| message: `"${displayPath}" will be removed.`, |
| confirmLabel: "Delete", |
| danger: true, |
| onConfirm: () => persistMutation({ |
| mutate: () => deleteDirectory(parentPath, dirName), |
| onSuccess: () => toast(`Deleted directory "${displayPath}".`), |
| }), |
| }); |
| } else { |
| const parts = []; |
| if (counts.workflows > 0) parts.push(`${counts.workflows} workflow${counts.workflows === 1 ? "" : "s"}`); |
| if (counts.subdirs > 0) parts.push(`${counts.subdirs} subdirector${counts.subdirs === 1 ? "y" : "ies"}`); |
| showConfirmModal({ |
| title: "Delete non-empty directory?", |
| message: `"${displayPath}" contains ${parts.join(" and ")}. They will be permanently deleted.`, |
| confirmLabel: "Delete all", |
| danger: true, |
| onConfirm: () => persistMutation({ |
| mutate: () => deleteDirectory(parentPath, dirName), |
| onSuccess: () => toast(`Deleted "${displayPath}" and its contents.`), |
| }), |
| }); |
| } |
| }, |
| }, |
| ]); |
| } |
|
|
| |
| |
| |
| addSection({ |
| id: SECTION_ID_NODES, |
| label: ROOT_GROUP_LABEL, |
| iconKind: "favorites", |
| emptyMessage: "No curated nodes yet. Click + above (with a canvas node selected) or right-click a node on the canvas → Add to Kforge Labs.", |
| gather(q) { |
| const mode = loadGroupMode(); |
| if (mode === "category") { |
| const themes = gatherNodesByTheme(q); |
| return { mode, themes, total: countNodesInThemes(themes) }; |
| } |
| const auto = gatherNodesByRepo(q); |
| const user = gatherUserPickPacks(q); |
| const packs = [...auto, ...user] |
| .filter(p => p.total > 0) |
| .sort((a, b) => compareNames(a.label, b.label)); |
| const total = packs.reduce((acc, p) => acc + p.total, 0); |
| return { mode: "repo", packs, total }; |
| }, |
| build(ctx) { |
| |
| |
| |
| |
| |
| |
| |
| if (ctx.isFiltered) { |
| emitNodesFlatSearchResults(ctx); |
| return; |
| } |
| if (ctx.data.mode === "category") { |
| emitThemeChildren(ctx, ctx.data.themes); |
| return; |
| } |
| for (const pack of ctx.data.packs) { |
| ctx.folder({ |
| name: pack.label, |
| count: pack.total, |
| path: pack.label, |
| build: (packCtx) => { |
| for (const cat of pack.categories) { |
| packCtx.folder({ |
| name: cat.name, |
| count: cat.nodes.length, |
| path: cat.name, |
| build: (catCtx) => { |
| for (const n of cat.nodes) { |
| catCtx.leaf({ |
| row: makeNodeLeafRow({ |
| display: n.display, |
| type: n.type, |
| removable: true, |
| onClick: () => insertNode(n.type), |
| }), |
| }); |
| } |
| }, |
| }); |
| } |
| }, |
| }); |
| } |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
|
|
| function emitNodesFlatSearchResults(ctx) { |
| const items = []; |
| if (ctx.data.mode === "category") { |
| collectFlatFromThemes(ctx.data.themes, items); |
| } else { |
| collectFlatFromRepoMode(ctx.data.packs, items); |
| } |
| items.sort((a, b) => compareNames(a.display, b.display)); |
| for (const item of items) { |
| ctx.leaf({ |
| row: makeNodeLeafRow({ |
| display: item.display, |
| type: item.type, |
| breadcrumb: item.breadcrumb, |
| unresolved: !!item.unresolved, |
| removable: true, |
| onClick: () => insertNode(item.type), |
| }), |
| }); |
| } |
| } |
|
|
| function collectFlatFromThemes(themes, out) { |
| for (const bucket of themes.values()) { |
| |
| |
| |
| |
| const label = pickDisplayLabel(bucket.displayLabels); |
| for (const n of bucket.nodes) { |
| out.push({ |
| display: n.display, |
| type: n.type, |
| unresolved: !!n.unresolved, |
| removable: !!n.isUserPick, |
| breadcrumb: label || null, |
| }); |
| } |
| } |
| } |
|
|
| function collectFlatFromRepoMode(packs, out) { |
| for (const pack of packs) { |
| for (const cat of pack.categories) { |
| |
| |
| |
| |
| |
| |
| const subRedundant = cat.name === "(root)" || cat.name === pack.label; |
| const crumbs = subRedundant ? [pack.label] : [pack.label, cat.name]; |
| const breadcrumb = crumbs.join(" › "); |
| for (const n of cat.nodes) { |
| out.push({ |
| display: n.display, |
| type: n.type, |
| removable: !!pack.isUserPicks, |
| breadcrumb, |
| }); |
| } |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| function emitThemeChildren(parentCtx, themes) { |
| const themeEntries = [...themes.entries()] |
| .map(([canonical, bucket]) => ({ |
| canonical, |
| bucket, |
| label: pickDisplayLabel(bucket.displayLabels) || canonical, |
| })) |
| .sort((a, b) => compareNames(a.label, b.label)); |
|
|
| for (const { canonical, bucket, label } of themeEntries) { |
| parentCtx.folder({ |
| name: label, |
| count: bucket.nodes.length, |
| path: canonical, |
| build: (sub) => { |
| const sortedNodes = [...bucket.nodes].sort( |
| (a, b) => compareNames(a.display, b.display), |
| ); |
| for (const n of sortedNodes) { |
| |
| |
| |
| let badge = null; |
| if (!n.unresolved) { |
| const loc = findPackPathForType(n.type); |
| if (loc && loc.packLabel && !loc.packLabel.startsWith("(")) { |
| badge = loc.packLabel; |
| } |
| } |
| sub.leaf({ |
| row: makeNodeLeafRow({ |
| display: n.display, |
| type: n.type, |
| removable: true, |
| unresolved: !!n.unresolved, |
| packBadge: badge, |
| onClick: () => insertNode(n.type), |
| }), |
| }); |
| } |
| }, |
| }); |
| } |
| } |
|
|
| addSection({ |
| id: SECTION_ID_WORKFLOWS, |
| label: WORKFLOWS_GROUP_LABEL, |
| iconKind: "workflows", |
| rootContextMenu: workflowsRootContextMenu, |
| |
| |
| |
| |
| |
| gather(q) { |
| return gatherWorkflows(q); |
| }, |
| build(ctx) { |
| for (const dir of ctx.data.directories) renderGatheredDir(ctx, dir); |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| addSection({ |
| id: SECTION_ID_TAGS, |
| label: "Tags", |
| iconKind: "folder", |
| |
| |
| gather(q) { |
| return gatherTags(q); |
| }, |
| build(ctx) { |
| for (const tag of ctx.data.tags) { |
| ctx.folder({ |
| name: tag.name, |
| count: tag.entries.length, |
| path: tag.name, |
| build: (tagCtx) => { |
| for (const entry of tag.entries) { |
| |
| |
| |
| const moduleEntry = isModuleWorkflow(entry.path, entry.wfName); |
| tagCtx.leaf({ |
| row: makeWorkflowLeafRow({ |
| name: entry.wfName, |
| dirName: entry.path.join(" / "), |
| wfPath: [...entry.path, entry.wfName].join("/"), |
| onClick: () => moduleEntry |
| ? insertWorkflowOntoCanvas(entry.path, entry.wfName) |
| : loadWorkflowOntoCanvas(entry.path, entry.wfName), |
| onContextMenu: (e) => workflowRowContextMenu(e, entry.path, entry.wfName, false), |
| isModule: moduleEntry, |
| }), |
| }); |
| } |
| }, |
| }); |
| } |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| function renderGatheredDir(parentCtx, dir) { |
| const dirPath = dir.path; |
| const parentPath = dirPath.slice(0, -1); |
| const dirDisplay = dirPath.join(" / "); |
| const totalInTree = countWorkflowsInGatheredDir(dir); |
| parentCtx.folder({ |
| name: dir.name, |
| count: totalInTree, |
| path: dir.name, |
| onContextMenu: (e) => directoryRowContextMenu(e, dirPath), |
| |
| |
| draggablePayload: { type: "directory", parentPath, name: dir.name }, |
| dropTarget: { kind: "dir", path: dirPath }, |
| build: (dirCtx) => { |
| for (const sub of dir.subdirs) renderGatheredDir(dirCtx, sub); |
| if (dir.archived.length > 0) { |
| dirCtx.folder({ |
| name: "Archive", |
| count: dir.archived.length, |
| iconKind: "archive", |
| path: "Archive", |
| onContextMenu: (e) => archiveFolderContextMenu(e, dirPath, dir.archived.length), |
| |
| |
| dropTarget: { kind: "archive", path: dirPath }, |
| build: (archCtx) => { |
| for (const wfName of dir.archived) { |
| const displayInfo = getArchiveDisplayInfo(dirPath, wfName); |
| archCtx.leaf({ |
| row: makeWorkflowLeafRow({ |
| name: displayInfo.label, |
| dirName: dirDisplay, |
| wfPath: [...dirPath, wfName].join("/"), |
| onClick: () => loadWorkflowOntoCanvas(dirPath, wfName), |
| onContextMenu: (e) => workflowRowContextMenu(e, dirPath, wfName, true), |
| draggablePayload: { type: "workflow", path: dirPath, name: wfName }, |
| secondaryText: displayInfo.meta, |
| }), |
| }); |
| } |
| }, |
| }); |
| } |
| for (const wfName of dir.active) { |
| const moduleEntry = isModuleWorkflow(dirPath, wfName); |
| dirCtx.leaf({ |
| row: makeWorkflowLeafRow({ |
| name: wfName, |
| dirName: dirDisplay, |
| wfPath: [...dirPath, wfName].join("/"), |
| |
| |
| |
| |
| onClick: () => moduleEntry |
| ? insertWorkflowOntoCanvas(dirPath, wfName) |
| : loadWorkflowOntoCanvas(dirPath, wfName), |
| onContextMenu: (e) => workflowRowContextMenu(e, dirPath, wfName, false), |
| draggablePayload: { type: "workflow", path: dirPath, name: wfName }, |
| isModule: moduleEntry, |
| isPublished: workflowHasTag(dirPath, wfName, PUBLISHED_TAG), |
| }), |
| }); |
| } |
| }, |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| let compareSnapshot = null; |
| let compareMeta = null; |
| let sidebarContainer = null; |
| let liveListeners = null; |
| |
| |
| |
| |
| |
| let activeSide = "A"; |
| |
| |
| |
| |
| let compareFilter = new Set(); |
| |
| |
| |
| |
| |
| let compareDirty = false; |
| |
| |
| let compareSaving = false; |
|
|
| |
| |
| |
| |
| function withSnapshotSource(snapshot, fn) { |
| setPicksRenderSource(snapshot && Array.isArray(snapshot.picks) ? snapshot.picks : []); |
| setWorkflowsRenderSource(snapshot && snapshot.workflows ? snapshot.workflows : { directories: {} }); |
| try { |
| return fn(); |
| } finally { |
| clearPicksRenderSource(); |
| clearWorkflowsRenderSource(); |
| } |
| } |
|
|
| |
| |
| |
| function withComparePathStates(fn) { |
| const saved = pathStates; |
| pathStates = comparePathStates; |
| try { |
| return fn(); |
| } finally { |
| pathStates = saved; |
| } |
| } |
|
|
| export function renderSidebar(container) { |
| sidebarContainer = container; |
| |
| |
| if (liveListeners) liveListeners.abort(); |
| liveListeners = new AbortController(); |
| const signal = liveListeners.signal; |
|
|
| if (!compareSnapshot) { |
| container.classList.remove("koolook-compare-host"); |
| renderPanel(container, { onToggleCompare: enterCompareMode, signal }); |
| return; |
| } |
|
|
| container.innerHTML = ""; |
| container.classList.remove("koolook-sidebar"); |
| container.classList.add("koolook-compare-host"); |
|
|
| const snapName = compareSnapName(); |
| const liveIsActive = activeSide === "A"; |
|
|
| const split = document.createElement("div"); |
| split.className = "koolook-compare-split"; |
| |
| |
| const leftCol = makeCompareColumn(); |
| const rightCol = makeCompareColumn(); |
| split.appendChild(leftCol.col); |
| split.appendChild(rightCol.col); |
| container.appendChild(split); |
|
|
| |
| |
| |
| |
| |
| const pullIn = makePullIn(); |
| renderPanel(leftCol.host, { |
| onToggleCompare: exitCompareMode, |
| signal, |
| readOnly: !liveIsActive, |
| pullIn: liveIsActive ? null : pullIn, |
| showUpdateFooter: false, |
| }); |
| renderPanel(rightCol.host, { |
| compare: true, |
| snapshot: compareSnapshot, |
| onToggleCompare: exitCompareMode, |
| signal, |
| compareName: snapName, |
| readOnly: true, |
| pullIn: liveIsActive ? pullIn : null, |
| showUpdateFooter: false, |
| }); |
| |
| |
| |
| |
| |
| const livePicks = loadUserPicks(); |
| const liveStore = getAllWorkflowsForExport(); |
| const snapPicks = Array.isArray(compareSnapshot.picks) ? compareSnapshot.picks : []; |
| const snapStore = compareSnapshot.workflows && typeof compareSnapshot.workflows === "object" |
| ? compareSnapshot.workflows |
| : { directories: {} }; |
| const sourceHost = liveIsActive ? rightCol.host : leftCol.host; |
| const sourcePicks = liveIsActive ? snapPicks : livePicks; |
| const sourceStore = liveIsActive ? snapStore : liveStore; |
| const targetPicks = liveIsActive ? livePicks : snapPicks; |
| const targetStore = liveIsActive ? liveStore : snapStore; |
| const counts = applyCompareTint(sourceHost, targetPicks, targetStore, sourcePicks, sourceStore); |
| applyCompareFilter(sourceHost); |
|
|
| |
| (liveIsActive ? leftCol : rightCol).col.classList.add("koolook-compare-active"); |
| labelColumnFoot(leftCol.foot, { sideLetter: "A", role: liveIsActive ? "target" : "source", name: "your kit" }); |
| labelColumnFoot(rightCol.foot, { sideLetter: "B", role: liveIsActive ? "source" : "target", name: snapName }); |
|
|
| |
| container.appendChild(buildSaveStripe()); |
| container.appendChild(buildCompareBar(counts)); |
| } |
|
|
| |
| |
| function makeCompareColumn() { |
| const col = document.createElement("div"); |
| col.className = "koolook-compare-col"; |
| const host = document.createElement("div"); |
| host.className = "koolook-compare-panelhost"; |
| const foot = document.createElement("div"); |
| foot.className = "koolook-compare-colfoot"; |
| col.appendChild(host); |
| col.appendChild(foot); |
| return { col, host, foot }; |
| } |
|
|
| |
| |
| function labelColumnFoot(footEl, { sideLetter, role, name }) { |
| footEl.className = `koolook-compare-colfoot koolook-foot-${role}`; |
| footEl.replaceChildren(); |
| const roleEl = document.createElement("span"); |
| roleEl.className = "koolook-foot-role"; |
| roleEl.textContent = role === "target" ? "TARGET" : "SOURCE"; |
| const nameEl = document.createElement("span"); |
| nameEl.className = "koolook-foot-name"; |
| nameEl.textContent = `${sideLetter} · ${name}`; |
| footEl.appendChild(roleEl); |
| footEl.appendChild(nameEl); |
| } |
|
|
| |
| |
| |
| function isAutosaveTarget() { |
| const dir = compareMeta && compareMeta.dir ? compareMeta.dir : ""; |
| return dir.endsWith("_autosave"); |
| } |
|
|
| |
| |
| |
| function compareSnapName() { |
| const dir = compareMeta && compareMeta.dir ? compareMeta.dir : ""; |
| if (dir === "_unsaved_autosave") return "unsaved recovery · autosave"; |
| if (dir.endsWith("_autosave")) return `${dir.slice(0, -"_autosave".length)} · autosave`; |
| return (compareMeta && compareMeta.fileName) ? compareMeta.fileName : "snapshot"; |
| } |
|
|
| |
| |
| |
| |
| |
| function buildSaveStripe() { |
| const stripe = document.createElement("div"); |
| stripe.className = "koolook-compare-savebar"; |
| const msg = document.createElement("span"); |
| msg.className = "koolook-savebar-msg"; |
| if (compareDirty) { |
| msg.classList.add("koolook-savebar-unsaved"); |
| msg.textContent = isAutosaveTarget() |
| ? "● Snapshot has unsaved edits — it's an autosave, so Save writes a named file." |
| : `● Snapshot "${compareSnapName()}" has unsaved edits — not saved to disk yet.`; |
| } else { |
| msg.textContent = "Your kit auto-saves · the snapshot is a read-only file; edits Save to a named file."; |
| } |
| stripe.appendChild(msg); |
| if (compareDirty) { |
| const btn = document.createElement("button"); |
| btn.type = "button"; |
| btn.className = "koolook-savebar-btn"; |
| btn.textContent = isAutosaveTarget() ? "Save as file…" : "Save to file"; |
| btn.title = "Save the merged snapshot to a named file"; |
| btn.addEventListener("click", saveCompareSnapshot); |
| stripe.appendChild(btn); |
| } |
| return stripe; |
| } |
|
|
| |
| |
| function buildCompareBar({ newCount, diffCount }) { |
| const bar = document.createElement("div"); |
| bar.className = "koolook-compare-status"; |
| bar.appendChild(makeFilterChip( |
| "new", "new", newCount, |
| "In the SOURCE side, not the target — copy candidates. Click to show just these.", |
| )); |
| bar.appendChild(makeFilterChip( |
| "diff", "modified", diffCount, |
| "In both, but the graph differs. Click to show just these.", |
| )); |
| const swapBtn = document.createElement("button"); |
| swapBtn.type = "button"; |
| swapBtn.className = "koolook-compare-swap"; |
| swapBtn.textContent = "⇄ Swap A↔B"; |
| swapBtn.title = "Flip source/target — which side you copy into"; |
| swapBtn.addEventListener("click", swapCompareSides); |
| bar.appendChild(swapBtn); |
| return bar; |
| } |
|
|
| |
| |
| function makeFilterChip(status, label, count, tip) { |
| const chip = document.createElement("button"); |
| chip.type = "button"; |
| chip.className = "koolook-cmp-chip"; |
| if (compareFilter.has(status)) chip.classList.add("koolook-cmp-chip-on"); |
| chip.title = tip; |
| const dot = document.createElement("span"); |
| dot.className = `koolook-cmp-dot koolook-cmp-dot-${status}`; |
| chip.appendChild(dot); |
| chip.appendChild(document.createTextNode(`${count} ${label}`)); |
| chip.addEventListener("click", () => toggleCompareFilter(status)); |
| return chip; |
| } |
|
|
| function toggleCompareFilter(status) { |
| if (compareFilter.has(status)) compareFilter.delete(status); |
| else compareFilter.add(status); |
| rerenderSidebar(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function applyCompareFilter(panelEl) { |
| if (!panelEl || compareFilter.size === 0) return; |
| const sels = []; |
| if (compareFilter.has("new")) sels.push(".koolook-cmp-new"); |
| if (compareFilter.has("diff")) sels.push(".koolook-cmp-diff"); |
| const matchSel = sels.join(","); |
| if (!matchSel) return; |
| panelEl.classList.add("koolook-cmp-filtering"); |
| for (const leaf of panelEl.querySelectorAll(".koolook-leaf")) { |
| leaf.style.display = leaf.matches(matchSel) ? "" : "none"; |
| } |
| |
| |
| for (const childrenEl of panelEl.querySelectorAll(".koolook-children")) { |
| const wrapper = childrenEl.parentElement; |
| if (childrenEl.querySelector(matchSel)) { |
| childrenEl.style.display = ""; |
| if (wrapper) wrapper.style.display = ""; |
| } else if (wrapper) { |
| wrapper.style.display = "none"; |
| } |
| } |
| } |
|
|
| function rerenderSidebar() { |
| if (sidebarContainer) renderSidebar(sidebarContainer); |
| } |
|
|
| |
| |
| |
| function enterCompareMode() { |
| showLoadSnapshotDialog({ |
| listPresets, |
| readPreset, |
| deletePreset, |
| applySnapshot, |
| setCurrentPresetName, |
| getCurrentPresetName, |
| getLibraryInfo, |
| saveSettings, |
| browseDirectories, |
| createBrowseDirectory, |
| writePreLoadAutosave, |
| markStateSaved, |
| markStateAutosaved, |
| listAutosaves, |
| revealPresetFolder, |
| onToast: toast, |
| onChoose: (snap, meta) => { |
| compareSnapshot = snap; |
| compareMeta = meta || null; |
| activeSide = "A"; |
| compareFilter.clear(); |
| |
| |
| |
| |
| publishedOnly = false; |
| comparePathStates.clear(); |
| compareDirty = false; |
| rerenderSidebar(); |
| }, |
| }); |
| } |
|
|
| function exitCompareMode() { |
| const doExit = () => { |
| compareSnapshot = null; |
| compareMeta = null; |
| activeSide = "A"; |
| compareFilter.clear(); |
| comparePathStates.clear(); |
| compareDirty = false; |
| rerenderSidebar(); |
| }; |
| |
| if (compareDirty) { |
| showConfirmModal({ |
| title: "Discard unsaved snapshot edits?", |
| message: "You copied items into the snapshot but haven't saved them to a file yet. Exit Compare and discard those edits?", |
| confirmLabel: "Discard", |
| danger: true, |
| onConfirm: doExit, |
| }); |
| return; |
| } |
| doExit(); |
| } |
|
|
| |
| |
| function swapCompareSides() { |
| activeSide = activeSide === "A" ? "B" : "A"; |
| rerenderSidebar(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function makePullIn() { |
| const targetIsLive = activeSide === "A"; |
| return { |
| destLabel: activeSide, |
| copyNode: (type) => (targetIsLive ? copyNodeToLive(type) : copyNodeToSnapshot(type)), |
| copyWorkflow: (wfPath) => (targetIsLive ? copyWorkflowToLive(wfPath) : copyWorkflowToSnapshot(wfPath)), |
| copyFolder: (dirSegs, label) => (targetIsLive ? copyFolderToLive(dirSegs, label) : copyFolderToSnapshot(dirSegs, label)), |
| }; |
| } |
|
|
| |
| function folderCopyToast(dest, label, s) { |
| if (!s || s.total === 0) return `"${label}" has no workflows to copy to ${dest}.`; |
| if (s.added === 0 && s.keptBoth === 0) { |
| return `"${label}": all ${s.skipped} already in ${dest} — nothing copied.`; |
| } |
| const parts = []; |
| if (s.added) parts.push(`${s.added} added`); |
| if (s.keptBoth) parts.push(`${s.keptBoth} kept-both`); |
| if (s.skipped) parts.push(`${s.skipped} already there`); |
| return `Copied folder "${label}" → ${dest}: ${parts.join(", ")}.`; |
| } |
|
|
| function splitWfPath(wfPath) { |
| const segs = String(wfPath).split("/"); |
| const wfName = segs.pop(); |
| return { dirSegs: segs, wfName }; |
| } |
|
|
| |
| |
| |
| function pinDestPath(dirSegs) { |
| const pinKeys = [SECTION_ID_WORKFLOWS]; |
| let cur = SECTION_ID_WORKFLOWS; |
| for (const seg of dirSegs) { |
| cur = `${cur}/${seg}`; |
| pinKeys.push(cur); |
| } |
| pinExpanded(pinKeys); |
| } |
|
|
| |
| |
| |
| |
| function openCompareDestPath(dirSegs) { |
| comparePathStates.set(SECTION_ID_WORKFLOWS, true); |
| let cur = SECTION_ID_WORKFLOWS; |
| for (const seg of dirSegs) { |
| cur = `${cur}/${seg}`; |
| comparePathStates.set(cur, true); |
| } |
| } |
|
|
| function copyToast(dest, wfName, res) { |
| if (res && res.status === "kept-both") { |
| return `Copied into ${dest} as "${res.finalName}" — kept both (a different "${wfName}" was already there).`; |
| } |
| return `Copied "${wfName}" into ${dest}.`; |
| } |
|
|
| |
| function copyNodeToLive(type) { |
| const result = addToMyPicks(type); |
| if (result === "added") { |
| notifyPicksChanged(); |
| toast(`Copied "${type}" into A's favorites.`); |
| } else if (result === "duplicate") { |
| toast(`"${type}" is already in A's favorites.`); |
| } else { |
| toast(`Could not copy "${type}" — favorites write failed.`); |
| } |
| } |
|
|
| function copyWorkflowToLive(wfPath) { |
| const entry = getWorkflowEntryFromStore( |
| compareSnapshot && compareSnapshot.workflows ? compareSnapshot.workflows : null, |
| wfPath, |
| ); |
| if (!entry || !entry.graph) { |
| toast(`Could not read "${wfPath}" from the snapshot.`); |
| return; |
| } |
| const { dirSegs, wfName } = splitWfPath(wfPath); |
| const tags = Array.isArray(entry.tags) ? entry.tags : []; |
| const sourceLabel = compareMeta && compareMeta.displayName ? compareMeta.displayName : "snapshot"; |
| let res = null; |
| persistMutation({ |
| mutate: () => { |
| res = copyWorkflowIntoLiveStore(dirSegs, wfName, entry.graph, { |
| tags, module: entry.module === true, sourceLabel, |
| }); |
| if (res.status === "skipped") return false; |
| pinDestPath(dirSegs); |
| return res; |
| }, |
| onSuccess: () => toast(copyToast("A", wfName, res)), |
| onNoOp: () => toast(`"${wfName}" is already in A at that path.`), |
| persistFailedMessage: `Copy failed — could not write "${wfName}" to A. See console.`, |
| }); |
| } |
|
|
| |
| |
| |
| |
| function copyNodeToSnapshot(type) { |
| const picks = Array.isArray(compareSnapshot.picks) ? compareSnapshot.picks : []; |
| if (picks.includes(type)) { |
| toast(`"${type}" is already in the snapshot.`); |
| return; |
| } |
| compareSnapshot.picks = [...picks, type]; |
| compareDirty = true; |
| rerenderSidebar(); |
| toast(`Added "${type}" to the snapshot — unsaved (use Save in the footer to keep it).`); |
| } |
|
|
| function copyWorkflowToSnapshot(wfPath) { |
| const entry = getWorkflowEntryFromStore(getAllWorkflowsForExport(), wfPath); |
| if (!entry || !entry.graph) { |
| toast(`Could not read "${wfPath}" from your kit.`); |
| return; |
| } |
| const { dirSegs, wfName } = splitWfPath(wfPath); |
| const tags = Array.isArray(entry.tags) ? entry.tags : []; |
| if (!compareSnapshot.workflows || typeof compareSnapshot.workflows !== "object") { |
| compareSnapshot.workflows = { directories: {} }; |
| } |
| const res = copyWorkflowIntoStore(compareSnapshot.workflows, dirSegs, wfName, entry.graph, { |
| tags, module: entry.module === true, sourceLabel: "your kit", |
| }); |
| if (res.status === "skipped") { |
| toast(`"${wfName}" is already in the snapshot at that path.`); |
| return; |
| } |
| compareDirty = true; |
| openCompareDestPath(dirSegs); |
| rerenderSidebar(); |
| toast(`${copyToast("B", wfName, res)} Unsaved — Save to keep it.`); |
| } |
|
|
| |
| |
| |
| |
| function compareDefaultSaveName() { |
| const dir = compareMeta && compareMeta.dir ? compareMeta.dir : ""; |
| if (dir === "_unsaved_autosave") return ""; |
| if (dir.endsWith("_autosave")) return dir.slice(0, -"_autosave".length); |
| return compareMeta && compareMeta.fileName ? compareMeta.fileName : ""; |
| } |
|
|
| |
| |
| |
| |
| |
| function saveCompareSnapshot() { |
| if (!compareSnapshot) return; |
| const snapshotRef = compareSnapshot; |
| showInputModal({ |
| title: "Save snapshot", |
| label: "Save merged snapshot as", |
| defaultValue: compareDefaultSaveName(), |
| placeholder: "snapshot name", |
| confirmLabel: "Save", |
| onSubmit: async (rawName) => { |
| |
| |
| const name = sanitizeName(rawName); |
| if (!name) { |
| toast("Snapshot name is empty after stripping unsafe characters."); |
| return; |
| } |
| const doWrite = async () => { |
| if (compareSaving) { |
| toast("A snapshot save is already in progress."); |
| return; |
| } |
| compareSaving = true; |
| try { |
| await writePreset(name, snapshotRef); |
| } catch (e) { |
| console.error("[Koolook] snapshot save failed:", e); |
| toast(`Could not save snapshot "${name}": ${e.message}`); |
| return; |
| } finally { |
| compareSaving = false; |
| } |
| |
| |
| if (compareSnapshot !== snapshotRef) { |
| toast(`Saved snapshot "${name}".`); |
| return; |
| } |
| compareMeta = { fileName: name, displayName: name }; |
| compareDirty = false; |
| rerenderSidebar(); |
| toast(`Saved snapshot "${name}" to disk.`); |
| }; |
| |
| |
| |
| |
| const exists = await presetExists(name); |
| if (exists === null) { |
| toast("Cannot reach the preset library to verify the name. Save canceled — check the library path or your connection."); |
| return; |
| } |
| if (exists === true) { |
| showConfirmModal({ |
| title: "Overwrite existing snapshot?", |
| message: `A snapshot named "${name}" already exists. Overwrite it?`, |
| confirmLabel: "Overwrite", |
| danger: true, |
| onConfirm: doWrite, |
| }); |
| return; |
| } |
| await doWrite(); |
| }, |
| }); |
| } |
|
|
| |
| function copyFolderToLive(dirSegs, label) { |
| const sourceStore = compareSnapshot && compareSnapshot.workflows ? compareSnapshot.workflows : { directories: {} }; |
| const sourceLabel = compareMeta && compareMeta.displayName ? compareMeta.displayName : "snapshot"; |
| let summary = null; |
| persistMutation({ |
| mutate: () => { |
| summary = copyFolderIntoLiveStore(sourceStore, dirSegs, { sourceLabel }); |
| if (summary.added === 0 && summary.keptBoth === 0) return false; |
| pinDestPath(dirSegs); |
| return summary; |
| }, |
| onSuccess: () => toast(folderCopyToast("A", label, summary)), |
| onNoOp: () => toast(folderCopyToast("A", label, summary)), |
| persistFailedMessage: `Folder copy failed — could not write "${label}" to A. See console.`, |
| }); |
| } |
|
|
| function copyFolderToSnapshot(dirSegs, label) { |
| const sourceStore = getAllWorkflowsForExport(); |
| if (!compareSnapshot.workflows || typeof compareSnapshot.workflows !== "object") { |
| compareSnapshot.workflows = { directories: {} }; |
| } |
| const summary = copyFolderIntoStore(compareSnapshot.workflows, sourceStore, dirSegs, { sourceLabel: "your kit" }); |
| if (summary.added === 0 && summary.keptBoth === 0) { |
| toast(folderCopyToast("B", label, summary)); |
| return; |
| } |
| compareDirty = true; |
| openCompareDestPath(dirSegs); |
| rerenderSidebar(); |
| toast(`${folderCopyToast("B", label, summary)} Unsaved — Save to keep it.`); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function applyCompareTint(panelEl, targetPicks, targetStore, sourcePicks, sourceStore) { |
| const newPicks = new Set(diffPicks(targetPicks, sourcePicks).onlyComparison); |
| let newCount = newPicks.size; |
| let diffCount = 0; |
| const wfStatus = diffWorkflows(targetStore, sourceStore); |
| for (const key of Object.keys(wfStatus)) { |
| if (wfStatus[key] === "new") newCount += 1; |
| else if (wfStatus[key] === "diff") diffCount += 1; |
| } |
| if (panelEl) { |
| for (const row of panelEl.querySelectorAll("[data-koolook-node-type]")) { |
| if (newPicks.has(row.dataset.koolookNodeType)) row.classList.add("koolook-cmp-new"); |
| } |
| for (const row of panelEl.querySelectorAll("[data-koolook-wf-path]")) { |
| const status = wfStatus[row.dataset.koolookWfPath]; |
| if (status === "new") row.classList.add("koolook-cmp-new"); |
| else if (status === "diff") row.classList.add("koolook-cmp-diff"); |
| } |
| } |
| return { newCount, diffCount }; |
| } |
|
|
| export function renderPanel(container, options = {}) { |
| const { compare = false, snapshot = null, onToggleCompare = null, signal = null, compareName = "", readOnly = compare, pullIn = null, showUpdateFooter = true } = options; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const withSource = compare |
| ? (fn) => withSnapshotSource(snapshot, () => withComparePathStates(fn)) |
| : (fn) => fn(); |
| |
| |
| const listenerOpts = signal ? { signal } : undefined; |
| ensureStyle(); |
| container.innerHTML = ""; |
| container.classList.add("koolook-sidebar"); |
| if (readOnly) { |
| container.classList.add("koolook-compare-panel"); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const NAV_ALLOW = |
| ".koolook-search, [data-koolook-compare-exit], .koolook-row:not(.koolook-leaf)"; |
| container.addEventListener("click", (e) => { |
| const t = e.target; |
| if (t instanceof Element && t.closest(NAV_ALLOW)) return; |
| e.preventDefault(); |
| e.stopPropagation(); |
| }, true); |
| container.addEventListener("contextmenu", (e) => { |
| |
| |
| |
| e.preventDefault(); |
| e.stopPropagation(); |
| if (!pullIn) return; |
| const t = e.target instanceof Element ? e.target : null; |
| if (!t) return; |
| const nodeRow = t.closest("[data-koolook-node-type]"); |
| if (nodeRow) { |
| showContextMenu(e, [{ |
| label: `Copy to ${pullIn.destLabel}`, |
| action: () => pullIn.copyNode(nodeRow.dataset.koolookNodeType), |
| }]); |
| return; |
| } |
| const wfRow = t.closest("[data-koolook-wf-path]"); |
| if (wfRow) { |
| showContextMenu(e, [{ |
| label: `Copy to ${pullIn.destLabel}`, |
| action: () => pullIn.copyWorkflow(wfRow.dataset.koolookWfPath), |
| }]); |
| return; |
| } |
| |
| |
| |
| |
| const dirRow = t.closest("[data-koolook-folder-path]"); |
| if (dirRow) { |
| const fp = dirRow.dataset.koolookFolderPath || ""; |
| const isWfFolder = fp === SECTION_ID_WORKFLOWS || fp.startsWith(`${SECTION_ID_WORKFLOWS}/`); |
| const segs = fp === SECTION_ID_WORKFLOWS |
| ? [] |
| : fp.slice(SECTION_ID_WORKFLOWS.length + 1).split("/"); |
| if (isWfFolder && segs[segs.length - 1] !== "Archive") { |
| const label = segs.length ? segs[segs.length - 1] : "all workflows"; |
| showContextMenu(e, [{ |
| label: `Copy folder "${label}" → ${pullIn.destLabel} (with contents)`, |
| action: () => pullIn.copyFolder(segs, label), |
| }]); |
| } |
| } |
| }, true); |
| container.addEventListener("dragstart", (e) => { e.preventDefault(); e.stopPropagation(); }, true); |
| container.addEventListener("dragover", (e) => { e.preventDefault(); e.stopPropagation(); }, true); |
| container.addEventListener("drop", (e) => { e.preventDefault(); e.stopPropagation(); }, true); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| let tree; |
| let search; |
| const rerenderTree = () => { |
| if (tree) withSource(() => renderTree({ treeEl: tree, query: search ? search.value : "" })); |
| }; |
|
|
| |
| |
| |
| |
| |
| const openSaveDialog = () => showSaveSnapshotDialog({ |
| getCurrentPresetName, |
| setCurrentPresetName, |
| presetExists, |
| writePreset, |
| gatherSnapshot, |
| sanitizeName, |
| getLibraryInfo, |
| saveSettings, |
| browseDirectories, |
| createBrowseDirectory, |
| revealPresetFolder, |
| markStateSaved, |
| onToast: toast, |
| }); |
| const openLoadDialog = () => showLoadSnapshotDialog({ |
| listPresets, |
| readPreset, |
| deletePreset, |
| applySnapshot, |
| setCurrentPresetName, |
| getCurrentPresetName, |
| getLibraryInfo, |
| saveSettings, |
| browseDirectories, |
| createBrowseDirectory, |
| writePreLoadAutosave, |
| markStateSaved, |
| markStateAutosaved, |
| listAutosaves, |
| revealPresetFolder, |
| onToast: toast, |
| }); |
|
|
| |
| |
| |
| |
| |
| const snapshotRow = document.createElement("div"); |
| snapshotRow.className = "koolook-actions-row"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const snapshotStatus = document.createElement("div"); |
| snapshotStatus.className = "koolook-snap-status"; |
| const snapshotDot = document.createElement("span"); |
| snapshotDot.className = "koolook-snap-status-dot koolook-snap-status-none"; |
| snapshotStatus.appendChild(snapshotDot); |
| const snapshotName = document.createElement("span"); |
| snapshotName.className = "koolook-snap-status-name"; |
| snapshotStatus.appendChild(snapshotName); |
| const snapshotState = document.createElement("span"); |
| snapshotState.className = "koolook-snap-status-state"; |
| snapshotStatus.appendChild(snapshotState); |
| snapshotRow.appendChild(snapshotStatus); |
|
|
| let snapshotLibraryPath = ""; |
| let snapshotStatusRefreshSeq = 0; |
|
|
| function formatLocalTime(iso) { |
| if (!iso) return "not saved yet"; |
| const d = new Date(iso); |
| if (isNaN(d.getTime())) return iso; |
| return formatLocalStamp(d); |
| } |
|
|
| function snapshotTooltip(status) { |
| const stamp = status.state === "autosaved" |
| ? status.lastAutosaveAt |
| : status.lastNamedSaveAt; |
| const location = snapshotLibraryPath || "loading..."; |
| |
| |
| |
| |
| if (status.state === "drifted") { |
| |
| |
| |
| |
| |
| |
| return ( |
| `Tracked snapshot "${status.name || "?"}" diverges from live state.\n` + |
| `Periodic auto-saves are being redirected to _unsaved_autosave/\n` + |
| `to protect the named snapshot's recovery folder.\n` + |
| `\n` + |
| `To resolve: Load the tracked snapshot (discards live changes), or\n` + |
| `Save / Quick Save (overwrites the tracked snapshot with live state).\n` + |
| `\n` + |
| `Drift detected: ${formatLocalTime(status.driftDetectedAt)}\n` + |
| `Location: ${location}` |
| ); |
| } |
| return `Date: ${formatLocalTime(stamp)}\nLocation: ${location}`; |
| } |
|
|
| function refreshSnapshotStatus() { |
| const seq = ++snapshotStatusRefreshSeq; |
| const status = getSnapshotStatus(); |
| snapshotDot.className = "koolook-snap-status-dot koolook-snap-status-" + status.state; |
| if (status.name) { |
| snapshotName.classList.remove("koolook-snap-status-name-empty"); |
| snapshotName.textContent = status.name; |
| } else { |
| snapshotName.classList.add("koolook-snap-status-name-empty"); |
| snapshotName.textContent = "(no snapshot)"; |
| } |
| let stateText = ""; |
| switch (status.state) { |
| case "saved": |
| stateText = "· saved"; |
| break; |
| case "autosaved": |
| stateText = "· auto-saved"; |
| break; |
| case "unsaved": |
| stateText = "· unsaved"; |
| break; |
| case "drifted": |
| stateText = "· drifted (reload?)"; |
| break; |
| case "none": |
| default: |
| stateText = ""; |
| break; |
| } |
| snapshotState.textContent = stateText; |
| snapshotStatus.title = snapshotTooltip(status); |
| getLibraryInfo().then((info) => { |
| if (seq !== snapshotStatusRefreshSeq) return; |
| snapshotLibraryPath = info && typeof info.path === "string" ? info.path : ""; |
| snapshotStatus.title = snapshotTooltip(status); |
| }).catch(() => { |
| if (seq !== snapshotStatusRefreshSeq) return; |
| snapshotLibraryPath = ""; |
| snapshotStatus.title = snapshotTooltip(status); |
| }); |
| } |
| if (compare) { |
| |
| |
| |
| snapshotDot.className = "koolook-snap-status-dot koolook-snap-status-comparing"; |
| snapshotName.classList.remove("koolook-snap-status-name-empty"); |
| snapshotName.textContent = compareName || "(snapshot)"; |
| snapshotState.textContent = "· comparing"; |
| snapshotStatus.title = compareName ? `Comparing: ${compareName}` : "Comparing"; |
| } else { |
| refreshSnapshotStatus(); |
| window.addEventListener(PICKS_CHANGED_EVENT, refreshSnapshotStatus, listenerOpts); |
| window.addEventListener(WORKFLOWS_CHANGED_EVENT, refreshSnapshotStatus, listenerOpts); |
| window.addEventListener(SNAPSHOT_STATUS_CHANGED_EVENT, refreshSnapshotStatus, listenerOpts); |
| } |
|
|
| snapshotRow.appendChild(makeToolbarButton({ |
| icon: TOOLBAR_ICONS.loadSnapshot, |
| title: "Load a saved snapshot (replaces current state)", |
| onClick: openLoadDialog, |
| })); |
| |
| |
| |
| |
| |
| const quickSaveBtn = makeToolbarButton({ |
| iconClass: "pi pi-save", |
| title: "Quick Save — overwrite the currently loaded preset (no dialog)", |
| onClick: async () => { |
| const current = getCurrentPresetName(); |
| if (!current) { |
| toast("No preset loaded — use Save to name a new one."); |
| return; |
| } |
| try { |
| const snap = gatherSnapshot(current); |
| await writePreset(current, snap); |
| markStateSaved(); |
| toast(`Quick-saved "${current}".`); |
| } catch (e) { |
| console.error("[Koolook] quick save failed:", e); |
| toast(`Could not Quick Save: ${e.message}`); |
| } |
| }, |
| }); |
| function refreshQuickSaveDisabled() { |
| quickSaveBtn.disabled = !getCurrentPresetName(); |
| } |
| refreshQuickSaveDisabled(); |
| window.addEventListener(SNAPSHOT_STATUS_CHANGED_EVENT, refreshQuickSaveDisabled, listenerOpts); |
| snapshotRow.appendChild(quickSaveBtn); |
| snapshotRow.appendChild(makeToolbarButton({ |
| icon: TOOLBAR_ICONS.saveSnapshot, |
| title: "Save current state — overwrites the last-loaded preset, or prompts for a name", |
| onClick: openSaveDialog, |
| })); |
| |
| |
| |
| if (onToggleCompare) { |
| const compareBtn = makeToolbarButton({ |
| icon: TOOLBAR_ICONS.compareSnapshot, |
| title: compare |
| ? "Exit Compare mode" |
| : "Compare mode — open another snapshot side by side", |
| onClick: onToggleCompare, |
| }); |
| |
| |
| compareBtn.dataset.koolookCompareExit = "1"; |
| snapshotRow.appendChild(compareBtn); |
| } |
| |
| |
| |
| |
|
|
| container.appendChild(snapshotRow); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const dividerAfterSnapshot = document.createElement("div"); |
| dividerAfterSnapshot.className = "koolook-tree-divider"; |
| container.appendChild(dividerAfterSnapshot); |
|
|
| const toolsRow = document.createElement("div"); |
| toolsRow.className = "koolook-actions-row"; |
|
|
| const toolsLabel = document.createElement("span"); |
| toolsLabel.className = "koolook-actions-label"; |
| toolsLabel.textContent = "Tools"; |
| toolsRow.appendChild(toolsLabel); |
|
|
| toolsRow.appendChild(makeToolbarButton({ |
| icon: TOOLBAR_ICONS.exportStarter, |
| title: "Export current state as starter_preset.json (copies snapshot JSON to clipboard — paste into web/starter_preset.json to ship as the next release's starter)", |
| onClick: exportStarterPreset, |
| })); |
|
|
| |
| |
| |
| |
| |
| |
| |
| toolsRow.appendChild(makeToolbarButton({ |
| icon: TOOLBAR_ICONS.installMissing, |
| title: "Install missing custom nodes for current picks (via ComfyUI-Manager)", |
| onClick: () => showInstallMissingModal({ picks: loadUserPicks() }), |
| })); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| toolsRow.appendChild(makeToolbarButton({ |
| icon: TOOLBAR_ICONS.dropMissing, |
| title: "Drop placeholders for missing packs onto canvas — then use Manager's \"Install Missing Custom Nodes\" (works at security_level=normal)", |
| onClick: async () => { |
| const picks = loadUserPicks(); |
| if (!picks || picks.length === 0) { |
| toast("No picks to check. Add some favorites first."); |
| return; |
| } |
| const discovery = await discoverMissingPacks(picks); |
| if (!discovery.ok) { |
| if (discovery.reason === "manager-unreachable") { |
| toast("ComfyUI-Manager isn't reachable — can't resolve picks to packs."); |
| } else { |
| toast(`Could not load Manager's mapping database: ${discovery.error?.message || discovery.reason}.`); |
| } |
| return; |
| } |
| const byUrl = discovery.result.willInstall.byUrl; |
| if (byUrl.size === 0) { |
| toast("Nothing missing — every pick is installed (or unmapped)."); |
| return; |
| } |
| await dropPlaceholdersForPacks(byUrl); |
| }, |
| })); |
|
|
| toolsRow.appendChild(makeToolbarButton({ |
| icon: TOOLBAR_ICONS.help, |
| title: "Open the Kforge Labs visual guide", |
| onClick: () => { |
| const opened = window.open(GUIDE_URL, "_blank", "noopener,noreferrer"); |
| if (!opened) { |
| toast("Could not open the guide. Allow pop-ups for this page, then try again."); |
| } |
| }, |
| })); |
|
|
| |
| |
| |
| |
| const publishedFilterBtn = makeToolbarButton({ |
| icon: TOOLBAR_ICONS.publishedFilter, |
| title: "Show only published setups (toggle)", |
| onClick: () => { |
| publishedOnly = !publishedOnly; |
| publishedFilterBtn.classList.toggle("koolook-icon-btn-active", publishedOnly); |
| rerenderTree(); |
| }, |
| }); |
| |
| |
| |
| |
| publishedFilterBtn.classList.toggle("koolook-icon-btn-active", publishedOnly); |
| toolsRow.appendChild(publishedFilterBtn); |
|
|
| container.appendChild(toolsRow); |
|
|
| const dividerBeforeSearch = document.createElement("div"); |
| dividerBeforeSearch.className = "koolook-tree-divider"; |
| container.appendChild(dividerBeforeSearch); |
|
|
| |
| const searchRow = document.createElement("div"); |
| searchRow.className = "koolook-search-row"; |
|
|
| const searchWrap = document.createElement("div"); |
| searchWrap.className = "koolook-search-wrap"; |
|
|
| const searchIcon = document.createElement("span"); |
| searchIcon.className = "pi pi-search koolook-search-icon"; |
| searchWrap.appendChild(searchIcon); |
|
|
| search = document.createElement("input"); |
| search.type = "search"; |
| search.className = "koolook-search"; |
| search.placeholder = "Search nodes & workflows..."; |
| searchWrap.appendChild(search); |
|
|
| searchRow.appendChild(searchWrap); |
| container.appendChild(searchRow); |
|
|
| |
| |
| |
| const dividerAfterSearch = document.createElement("div"); |
| dividerAfterSearch.className = "koolook-tree-divider"; |
| container.appendChild(dividerAfterSearch); |
|
|
| |
| const nodesRow = document.createElement("div"); |
| nodesRow.className = "koolook-actions-row"; |
|
|
| const nodesLabel = document.createElement("span"); |
| nodesLabel.className = "koolook-actions-label"; |
| nodesLabel.textContent = "Nodes"; |
| nodesRow.appendChild(nodesLabel); |
|
|
| |
| |
| |
| |
| const modeToggle = document.createElement("div"); |
| modeToggle.className = "koolook-mode-toggle"; |
|
|
| function makeModeBtn(modeId, icon, title) { |
| const btn = document.createElement("button"); |
| btn.className = "koolook-mode-toggle-btn"; |
| btn.title = title; |
| btn.setAttribute("aria-label", title); |
| btn.dataset.mode = modeId; |
| btn.appendChild(makeIconElement(icon)); |
| btn.addEventListener("click", () => { |
| if (loadGroupMode() === modeId) return; |
| saveGroupMode(modeId); |
| refreshModeToggle(); |
| rerenderTree(); |
| }); |
| return btn; |
| } |
|
|
| const repoModeBtn = makeModeBtn( |
| "repo", |
| TOOLBAR_ICONS.repoMode, |
| "Group by pack — each repo's nodes under their original category subtree.", |
| ); |
| const categoryModeBtn = makeModeBtn( |
| "category", |
| TOOLBAR_ICONS.categoryMode, |
| "Group by theme — picks regrouped by category theme (e.g. all image nodes together) regardless of source pack.", |
| ); |
|
|
| function refreshModeToggle() { |
| const m = loadGroupMode(); |
| repoModeBtn.classList.toggle("koolook-mode-active", m === "repo"); |
| categoryModeBtn.classList.toggle("koolook-mode-active", m === "category"); |
| } |
| refreshModeToggle(); |
|
|
| modeToggle.appendChild(repoModeBtn); |
| modeToggle.appendChild(categoryModeBtn); |
| nodesRow.appendChild(modeToggle); |
|
|
| const addBtn = document.createElement("button"); |
| |
| |
| |
| |
| addBtn.className = "koolook-add-btn koolook-icon-btn koolook-add-btn-green"; |
| const addBtnIcon = document.createElement("i"); |
| addBtnIcon.className = "pi pi-plus"; |
| addBtnIcon.setAttribute("aria-hidden", "true"); |
| addBtn.appendChild(addBtnIcon); |
| addBtn.title = "Add the selected canvas node(s) to favorites"; |
| addBtn.setAttribute("aria-label", "Add the selected canvas node(s) to favorites"); |
| addBtn.addEventListener("click", () => { |
| const types = getSelectedNodeTypes(); |
| if (types.length === 0) { |
| toast("Select a node on the canvas first."); |
| return; |
| } |
| let added = 0; |
| let duplicates = 0; |
| let failed = 0; |
| const successfulTypes = []; |
| for (const t of types) { |
| const status = addToMyPicks(t); |
| if (status === "added") added += 1; |
| else if (status === "duplicate") duplicates += 1; |
| else failed += 1; |
| |
| |
| if (status !== "failed") successfulTypes.push(t); |
| } |
| spotlightAddedPicks(successfulTypes); |
| if (added > 0) { |
| const noun = added === 1 ? "node" : "nodes"; |
| toast(`Added ${added} ${noun} to favorites.`); |
| notifyPicksChanged(); |
| } else if (duplicates > 0 && failed === 0) { |
| toast("Already in favorites."); |
| |
| |
| |
| notifyPicksChanged(); |
| } |
| if (failed > 0) { |
| toast(`Failed to save ${failed} pick${failed === 1 ? "" : "s"}. See console.`); |
| } |
| }); |
| nodesRow.appendChild(addBtn); |
|
|
| |
| |
| |
| |
|
|
| container.appendChild(nodesRow); |
|
|
| |
| const dividerBetweenBars = document.createElement("div"); |
| dividerBetweenBars.className = "koolook-tree-divider"; |
| container.appendChild(dividerBetweenBars); |
|
|
| |
| const wfRow = document.createElement("div"); |
| wfRow.className = "koolook-actions-row"; |
|
|
| const wfLabel = document.createElement("span"); |
| wfLabel.className = "koolook-actions-label"; |
| wfLabel.textContent = "Workflows"; |
| wfRow.appendChild(wfLabel); |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const saveAndToast = async (graph, name, dirPath, asModule = false) => { |
| |
| |
| |
| const pinKeys = [SECTION_ID_WORKFLOWS]; |
| let cur = SECTION_ID_WORKFLOWS; |
| for (const seg of dirPath) { |
| cur = `${cur}/${seg}`; |
| pinKeys.push(cur); |
| } |
| pinExpanded(pinKeys); |
| const dirDisplay = dirPath.join(" / "); |
| await persistMutation({ |
| mutate: () => { |
| const result = saveWorkflowEntry(dirPath, name, graph, { module: asModule }); |
| if (!result) return false; |
| if (asModule) addTag(dirPath, name, MODULE_TAG); |
| return result; |
| }, |
| onSuccess: (result) => { |
| const moduleSuffix = asModule ? " as module" : ""; |
| if (result.archivedAs) { |
| toast(`Saved "${name}" in ${dirDisplay}${moduleSuffix}. Previous version moved to Archive.`); |
| } else { |
| toast(`Saved "${name}" in ${dirDisplay}${moduleSuffix}.`); |
| } |
| }, |
| persistFailedMessage: `Save failed — could not write "${name}". See console.`, |
| }); |
| }; |
|
|
| wfRow.appendChild(makeToolbarButton({ |
| icon: TOOLBAR_ICONS.saveWorkflow, |
| title: "Save entire canvas as a workflow", |
| onClick: () => { |
| |
| |
| |
| if (!canvasIsNonEmpty()) { |
| toast("Canvas is empty."); |
| return; |
| } |
| const graph = serializeFullCanvas(); |
| if (!graph || !graph.nodes || graph.nodes.length === 0) { |
| toast("Failed to serialize canvas. See console."); |
| return; |
| } |
| showSaveWorkflowModal({ |
| titleSuffix: "entire canvas", |
| |
| |
| |
| |
| defaultModule: false, |
| onSave: ({ name, dirPath, asModule }) => saveAndToast(graph, name, dirPath, asModule), |
| }); |
| }, |
| })); |
|
|
| wfRow.appendChild(makeToolbarButton({ |
| iconClass: "pi pi-objects-column", |
| title: "Save current selection as a workflow", |
| onClick: () => { |
| const result = serializeSelection(); |
| if (result.kind === "empty") { |
| toast("Select one or more nodes on the canvas first."); |
| return; |
| } |
| if (result.kind === "stale") { |
| |
| |
| |
| |
| |
| toast("Selected node(s) no longer exist. Click a node on the canvas to re-select."); |
| return; |
| } |
| const { graph } = result; |
| showSaveWorkflowModal({ |
| titleSuffix: `${graph.nodes.length} selected node${graph.nodes.length === 1 ? "" : "s"}`, |
| |
| |
| |
| |
| defaultModule: true, |
| onSave: ({ name, dirPath, asModule }) => saveAndToast(graph, name, dirPath, asModule), |
| }); |
| }, |
| })); |
|
|
| container.appendChild(wfRow); |
|
|
| |
| const dividerBeforeTree = document.createElement("div"); |
| dividerBeforeTree.className = "koolook-tree-divider"; |
| container.appendChild(dividerBeforeTree); |
|
|
| |
| tree = document.createElement("div"); |
| tree.className = "koolook-tree"; |
| container.appendChild(tree); |
|
|
| withSource(() => renderTree({ treeEl: tree, query: "" })); |
|
|
| const updateFooter = document.createElement("div"); |
| updateFooter.className = "koolook-update-footer"; |
| container.appendChild(updateFooter); |
| if (showUpdateFooter) { |
| checkForUpdate().then((update) => renderUpdateFooter(updateFooter, update)); |
| } |
|
|
| |
| |
| |
| |
| |
| if (showUpdateFooter) { |
| const starFooter = document.createElement("div"); |
| starFooter.className = "koolook-star-footer"; |
| const starGlyph = document.createElement("span"); |
| starGlyph.className = "koolook-star-glyph"; |
| starGlyph.textContent = "★"; |
| starGlyph.setAttribute("aria-hidden", "true"); |
| const starText = document.createElement("span"); |
| starText.textContent = "Help creators find us on "; |
| const starLink = document.createElement("a"); |
| starLink.href = GITHUB_REPO_URL; |
| starLink.target = "_blank"; |
| starLink.rel = "noopener noreferrer"; |
| starLink.textContent = "GitHub"; |
| starLink.title = "Help creators find Kforge Labs on GitHub"; |
| starText.append(starLink); |
| starFooter.append(starGlyph, starText); |
| container.appendChild(starFooter); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| const buildTag = document.createElement("div"); |
| buildTag.className = "koolook-build-tag"; |
| container.appendChild(buildTag); |
| fetch(new URL("../_dev_build.json", import.meta.url).href + `?t=${Date.now()}`) |
| .then(r => (r.ok ? r.json() : null)) |
| .then(info => { |
| if (!info) return; |
| |
| |
| |
| |
| |
| |
| |
| buildTag.textContent = ""; |
| buildTag.appendChild(document.createTextNode("dev ")); |
| if (info.commit) { |
| const shaEl = document.createElement("span"); |
| shaEl.className = "koolook-build-sha"; |
| shaEl.textContent = info.commit; |
| buildTag.appendChild(shaEl); |
| } |
| if (info.synced_at) { |
| buildTag.appendChild(document.createTextNode(` · ${info.synced_at}`)); |
| } |
| if (info.scope) { |
| const scopeEl = document.createElement("span"); |
| scopeEl.className = "koolook-build-scope"; |
| scopeEl.textContent = info.scope; |
| buildTag.appendChild(scopeEl); |
| } |
| }) |
| .catch(() => { }); |
|
|
| |
| let debounce = null; |
| search.addEventListener("input", (e) => { |
| const q = e.target.value; |
| if (debounce) clearTimeout(debounce); |
| debounce = setTimeout(() => withSource(() => renderTree({ treeEl: tree, query: q })), 60); |
| }); |
|
|
| |
| |
| |
| |
| |
| if (!compare) { |
| window.addEventListener(PICKS_CHANGED_EVENT, () => renderTree({ treeEl: tree, query: search.value }), listenerOpts); |
| window.addEventListener(WORKFLOWS_CHANGED_EVENT, () => renderTree({ treeEl: tree, query: search.value }), listenerOpts); |
| window.addEventListener("storage", (e) => { |
| if (e.key === STORAGE_KEY || e.key === WORKFLOWS_FALLBACK_KEY || e.key === GROUP_MODE_KEY) { |
| renderTree({ treeEl: tree, query: search.value }); |
| } |
| }, listenerOpts); |
| } |
| } |
|
|