import { app } from "../../../scripts/app.js"; console.log("🦊 Settings Manager JS v30 (Hybrid Save) loaded at", new Date().toLocaleTimeString()); const STORAGE_KEY = "RaykoStudio.ShowIcon"; const ACTIVE_PRESET_KEY = "RaykoStudio.ActivePreset"; let _activePresetName = sessionStorage.getItem(ACTIVE_PRESET_KEY) || null; app.registerExtension({ name: "RaykoStudio.SettingsManager", async setup() { injectStyles(); app.ui.settings.addSetting({ id: "RaykoStudio.SettingsManager.Open", name: "Settings Manager", type: () => buildSettingsEntry(), }); if (localStorage.getItem(STORAGE_KEY) === "true") { waitForActionBar((bar) => injectTopbarButton(bar)); } _startTopbarWatchdog(); }, }); function buildSettingsEntry() { const container = document.createElement("div"); container.style.display = "flex"; container.style.flexDirection = "column"; container.style.gap = "8px"; const toggleRow = document.createElement("label"); toggleRow.style.display = "flex"; toggleRow.style.alignItems = "center"; toggleRow.style.gap = "10px"; toggleRow.style.cursor = "pointer"; const toggle = document.createElement("input"); toggle.type = "checkbox"; toggle.checked = localStorage.getItem(STORAGE_KEY) === "true"; toggle.onchange = () => { localStorage.setItem(STORAGE_KEY, toggle.checked ? "true" : "false"); const existing = document.getElementById("sm-topbar-block"); if (toggle.checked) { waitForActionBar((bar) => injectTopbarButton(bar)); } else if (existing) { existing.remove(); } }; const toggleLabel = document.createElement("span"); toggleLabel.textContent = "Show icon in top menu"; toggleLabel.style.color = "var(--fg-color)"; toggleRow.appendChild(toggle); toggleRow.appendChild(toggleLabel); container.appendChild(toggleRow); return container; } // ============================================================ // TOPBAR INJECTION // ============================================================ function waitForActionBar(callback) { const existing = document.querySelector(".actionbar-container"); if (existing) return callback(existing); const observer = new MutationObserver(() => { const bar = document.querySelector(".actionbar-container"); if (bar) { observer.disconnect(); callback(bar); } }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => observer.disconnect(), 30000); } function injectTopbarButton(actionBar) { if (localStorage.getItem(STORAGE_KEY) !== "true") return; if (document.getElementById("sm-topbar-block")) return; if (!actionBar || !document.body.contains(actionBar)) return; const block = document.createElement("div"); block.id = "sm-topbar-block"; block.className = "sm-topbar-block"; const btn = document.createElement("button"); btn.className = "sm-topbar-btn"; btn.title = "Settings Manager"; btn.innerHTML = `🖥️`; btn.onclick = () => openSettingsManagerModal(); block.appendChild(btn); const closeAllBtn = document.createElement("button"); closeAllBtn.className = "sm-topbar-btn"; closeAllBtn.title = "Close All Workflows (Keeps fresh Unsaved)"; closeAllBtn.innerHTML = `🗑️`; closeAllBtn.onclick = () => closeAllWorkflowsWithConfirm(); block.appendChild(closeAllBtn); const runContainer = actionBar.querySelector(".flex.h-full.items-center"); if (runContainer) { actionBar.insertBefore(block, runContainer); } else { actionBar.appendChild(block); } } function _startTopbarWatchdog() { if (window.__smTopbarWatchdog) return; window.__smTopbarWatchdog = true; setInterval(() => { if (localStorage.getItem(STORAGE_KEY) !== "true") return; const block = document.getElementById("sm-topbar-block"); const bar = document.querySelector(".actionbar-container"); if (!bar) return; if (!block) { injectTopbarButton(bar); } else if (!bar.contains(block)) { try { block.remove(); } catch (e) { } injectTopbarButton(bar); } }, 500); } // ============================================================ // WORKFLOW HELPERS // ============================================================ function isUnsavedWorkflow(wf) { if (!wf) return false; const path = wf.path || ""; return path === "" || path.includes("Unsaved"); } function setActivePreset(name) { _activePresetName = name; if (name) { sessionStorage.setItem(ACTIVE_PRESET_KEY, name); } else { sessionStorage.removeItem(ACTIVE_PRESET_KEY); } updatePresetUI(); } async function checkActivePresetValidity() { if (!_activePresetName) return; const wfStore = app.extensionManager?.workflow; if (!wfStore) return; try { const res = await fetch(`/rayko_settings_manager/presets/get/${encodeURIComponent(_activePresetName)}`); if (!res.ok) { setActivePreset(null); return; } const data = await res.json(); const paths = data.workflows || []; const stringPaths = paths.filter(p => typeof p === "string"); if (stringPaths.length === 0) return; const openPaths = (wfStore.openWorkflows || []).map(w => w.path).filter(Boolean); const hasOverlap = stringPaths.some(p => openPaths.some(op => op.includes(p))); if (!hasOverlap) { console.log("[Rayko] All preset tabs are closed. Detaching preset."); setActivePreset(null); } } catch (e) { console.warn("[Rayko] Failed to check preset validity", e); } } function getOpenWorkflowPaths() { const result = []; try { const wfStore = app.extensionManager?.workflow; const openWorkflows = wfStore?.openWorkflows; if (Array.isArray(openWorkflows) && openWorkflows.length > 0) { for (const wf of openWorkflows) { if (isUnsavedWorkflow(wf)) continue; if (wf.path) { result.push(wf.path); } } } } catch (e) { console.warn("[Rayko] Workflow store access failed", e); } return result; } async function getWorkflowLookup() { try { const res = await fetch('/api/userdata?dir=workflows&recurse=true'); if (!res.ok) return {}; const data = await res.json(); const map = {}; for (const item of data) { const path = typeof item === "string" ? item : item.name; if (!path) continue; const fileName = path.split(/[/\\]/).pop(); if (!map[fileName]) map[fileName] = []; map[fileName].push("workflows/" + path); } return map; } catch (e) { console.warn("[Rayko] Failed to build workflow lookup", e); return {}; } } // ГИБРИДНОЕ СОХРАНЕНИЕ (НАТИВНОЕ ДЛЯ АКТИВНОЙ, POST ДЛЯ ОСТАЛЬНЫХ) async function saveAllModifiedWorkflows() { const wfStore = app.extensionManager?.workflow; if (!wfStore) return; const openWfs = wfStore.openWorkflows || []; console.log(`[Rayko] Starting to save ${openWfs.length} open workflows...`); let workflowLookup = null; let savedAny = false; for (const wf of openWfs) { let finalPath = wf.path || ""; console.log(`[Rayko] Checking workflow: ${finalPath}`); if (finalPath === "" || finalPath.includes("Unsaved")) { console.log(`[Rayko] Skipping unsaved/empty: ${finalPath}`); continue; } if (!finalPath.includes("/") && !finalPath.includes("\\")) { if (!workflowLookup) { workflowLookup = await getWorkflowLookup(); } if (workflowLookup[finalPath] && workflowLookup[finalPath].length > 0) { finalPath = workflowLookup[finalPath][0]; } } try { const isActive = (wfStore.activeWorkflow === wf) || (wfStore.activeWorkflow && wfStore.activeWorkflow.path === wf.path); console.log(`[Rayko] Is active: ${isActive}`); // 1. Для АКТИВНОЙ вкладки используем нативный saveWorkflow (он точно сохранит новые ноды) if (isActive && typeof wfStore.saveWorkflow === "function") { console.log(`[Rayko] Saving active workflow natively: ${finalPath}`); try { await wfStore.saveWorkflow(wf); console.log(`[Rayko] ✅ Natively saved: ${finalPath}`); savedAny = true; continue; } catch (e) { console.warn(`[Rayko] Native save failed, falling back to POST`, e); } } // 2. Для НЕАКТИВНЫХ вкладок или при ошибке натива - сохраняем через POST let state = null; if (isActive) { state = app.graph.serialize(); } else if (wf.changeTracker && wf.changeTracker.activeState) { state = JSON.parse(JSON.stringify(wf.changeTracker.activeState)); } else if (wf.activeState) { try { state = JSON.parse(JSON.stringify(wf.activeState)); } catch(e) {} } if (!state || !state.nodes) { console.warn(`[Rayko] No state for ${finalPath}, skipping.`); continue; } console.log(`[Rayko] Force saving ${state.nodes.length} nodes via POST: ${finalPath}`); const safePath = encodeURIComponent(finalPath); const res = await fetch(`/api/userdata/${safePath}?overwrite=true`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(state) }); if (res.ok) { console.log(`[Rayko] ✅ Successfully saved via POST: ${finalPath}`); if (wf.changeTracker) wf.changeTracker.isDirty = false; if (wf.isModified !== undefined) wf.isModified = false; savedAny = true; } else { console.error(`[Rayko] ❌ Failed to save ${finalPath}: HTTP ${res.status}`); } } catch (e) { console.error(`[Rayko] ❌ Exception saving ${finalPath}`, e); } } if (savedAny) { try { if (app.extensionManager?.workflow && typeof app.extensionManager.workflow.loadWorkflows === "function") { await app.extensionManager.workflow.loadWorkflows(); console.log("[Rayko] Sidebar workflows reloaded."); } } catch(e) {} } } async function saveCurrentStateToActivePreset() { if (!_activePresetName) return; console.log(`[Rayko] Preparing to update preset: ${_activePresetName}`); await saveAllModifiedWorkflows(); const workflows = getOpenWorkflowPaths(); if (workflows.length === 0) throw new Error("No valid workflows to save."); const res = await fetch("/rayko_settings_manager/presets/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: _activePresetName, workflows }), }); const data = await res.json(); if (!data.success) throw new Error(data.error || "Save failed"); console.log(`[Rayko] ✅ Preset JSON updated successfully.`); } // ============================================================ // CLOSE ALL LOGIC // ============================================================ async function closeAllWorkflowsWithConfirm() { const wfStore = app.extensionManager?.workflow; if (!wfStore || !wfStore.openWorkflows || wfStore.openWorkflows.length === 0) { if (_activePresetName) setActivePreset(null); return; } if (_activePresetName) { const action = await smConfirmSavePreset(_activePresetName); if (action === "cancel") return; if (action === "save") { try { await saveCurrentStateToActivePreset(); } catch (e) { await smAlert("Save Failed", e.message, true); return; } } } else { const confirmed = await smConfirm({ title: "Close All Workflows", message: "All open workflows will be closed. A fresh Unsaved Workflow will be created. Continue?", okText: "Close All", danger: true }); if (!confirmed) return; } await performCloseAll(); setActivePreset(null); } async function performCloseAll() { const wfStore = app.extensionManager?.workflow; if (!wfStore) return; const allWfs = [...(wfStore.openWorkflows || [])]; let freshWf = null; try { if (typeof wfStore.createTemporaryWorkflow === "function") { freshWf = await wfStore.createTemporaryWorkflow(); } else if (typeof wfStore.createTemporary === "function") { freshWf = await wfStore.createTemporary(); } if (freshWf) { const emptyState = { nodes: [], links: [], groups: [], config: {}, extra: {}, version: 0.4 }; await app.loadGraphData(emptyState, true, true, freshWf); } } catch (e) { console.warn("[Rayko] Failed to create fresh workflow", e); } for (const wf of allWfs) { if (wf === freshWf) continue; try { if (typeof wfStore.closeWorkflow === "function") { await wfStore.closeWorkflow(wf); } else if (typeof wf.unload === "function") { await wf.unload(); } } catch (e) { console.warn("[Rayko] Failed to close", wf, e); } } if (Array.isArray(wfStore.openWorkflowPaths) && freshWf?.path) { wfStore.openWorkflowPaths = [freshWf.path]; } } // ============================================================ // SETTINGS MODAL // ============================================================ async function openSettingsManagerModal() { if (document.getElementById("sm-modal-overlay")) return; try { await checkActivePresetValidity(); } catch (e) {} const overlay = document.createElement("div"); overlay.id = "sm-modal-overlay"; overlay.className = "sm-modal-overlay"; overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; const modal = document.createElement("div"); modal.className = "sm-modal-content"; const closeBtn = document.createElement("button"); closeBtn.className = "sm-btn sm-btn-danger sm-btn-small"; closeBtn.textContent = "✕ Close"; closeBtn.style.width = "100%"; closeBtn.style.marginTop = "10px"; closeBtn.onclick = () => overlay.remove(); renderSettingsPanel(modal); const statusDiv = document.getElementById("sm-status"); if (statusDiv) { statusDiv.className = "sm-status"; statusDiv.textContent = ""; } modal.appendChild(closeBtn); overlay.appendChild(modal); document.body.appendChild(overlay); updatePresetUI(); } function renderSettingsPanel(container) { container.innerHTML = ""; const mainDiv = document.createElement("div"); mainDiv.className = "sm-container"; renderPresetsSection(mainDiv); const saveSection = document.createElement("div"); saveSection.className = "sm-section"; saveSection.innerHTML = `
Save Interface Settings
Backup current interface configuration to Documents folder
If no name is entered, the creation date will be used.
`; const saveBtn = document.createElement("button"); saveBtn.className = "sm-btn sm-btn-success"; saveBtn.textContent = "💾 Save Settings"; saveBtn.onclick = async function () { const nameInput = document.getElementById("sm-backup-name"); const backupName = nameInput ? nameInput.value.trim() : ""; showStatus("Saving...", "info"); saveBtn.disabled = true; saveBtn.textContent = "⏳ Saving..."; try { const response = await fetch("/rayko_settings_manager/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ backup_name: backupName }), }); const data = await response.json(); if (data.success) { showStatus(`✅ Saved as: ${data.folder}`, "success"); if (nameInput) nameInput.value = ""; if (backupListDiv && !backupListDiv.classList.contains("sm-hidden")) { const newItem = createBackupItem(data.folder, backupListDiv); backupListDiv.insertBefore(newItem, backupListDiv.firstChild); } } else { showStatus(`❌ Error: ${data.error}`, "error"); } } catch (e) { showStatus(`❌ Error: ${e.message}`, "error"); } saveBtn.disabled = false; saveBtn.textContent = "💾 Save Settings"; }; saveSection.appendChild(saveBtn); mainDiv.appendChild(saveSection); const restoreSection = document.createElement("div"); restoreSection.className = "sm-section"; restoreSection.innerHTML = `
Restore Interface Settings
Load previously saved configuration and restart server
`; const loadBtn = document.createElement("button"); loadBtn.className = "sm-btn"; loadBtn.textContent = "📂 Load Backups"; loadBtn.onclick = async function () { await loadBackups(backupListDiv, loadBtn); }; restoreSection.appendChild(loadBtn); const backupListDiv = document.createElement("div"); backupListDiv.id = "sm-backup-list"; backupListDiv.className = "sm-hidden"; backupListDiv.style.marginTop = "10px"; restoreSection.appendChild(backupListDiv); mainDiv.appendChild(restoreSection); const statusContainer = document.createElement("div"); statusContainer.className = "sm-status-container"; const statusDiv = document.createElement("div"); statusDiv.id = "sm-status"; statusDiv.className = "sm-status"; statusContainer.appendChild(statusDiv); mainDiv.appendChild(statusContainer); container.appendChild(mainDiv); } function showStatus(message, type) { const statusDiv = document.getElementById("sm-status"); if (statusDiv) { statusDiv.textContent = message; statusDiv.className = "sm-status"; if (type === "success") statusDiv.classList.add("sm-success"); else if (type === "error") statusDiv.classList.add("sm-error"); else if (type === "info") statusDiv.classList.add("sm-info"); if (type !== "info") { setTimeout(() => { statusDiv.className = "sm-status"; statusDiv.textContent = ""; }, 5000); } } } // ============================================================ // BACKUPS LOGIC // ============================================================ async function loadBackups(backupListDiv, loadBtn) { showStatus("Loading backups...", "info"); loadBtn.disabled = true; loadBtn.textContent = "⏳ Loading..."; backupListDiv.classList.remove("sm-hidden"); backupListDiv.innerHTML = '
Loading...
'; try { const response = await fetch("/rayko_settings_manager/list"); const data = await response.json(); backupListDiv.innerHTML = ""; if (data.success && data.backups && data.backups.length > 0) { data.backups.forEach((backupName) => { const item = createBackupItem(backupName, backupListDiv); backupListDiv.appendChild(item); }); showStatus(`✅ Found ${data.backups.length} backups`, "success"); } else { backupListDiv.innerHTML = '
No backups found
'; showStatus("ℹ️ No backups found", "info"); } } catch (e) { backupListDiv.innerHTML = '
Error loading
'; showStatus(`❌ Error: ${e.message}`, "error"); } loadBtn.disabled = false; loadBtn.textContent = "🔄 Refresh Backups"; } function createBackupItem(backupName, backupListDiv) { const item = document.createElement("div"); item.className = "sm-backup-item"; const nameSpan = document.createElement("span"); nameSpan.className = "sm-backup-name"; nameSpan.textContent = backupName; nameSpan.title = backupName; item.appendChild(nameSpan); const buttonsDiv = document.createElement("div"); buttonsDiv.className = "sm-restore-container"; const restoreBtn = document.createElement("button"); restoreBtn.className = "sm-btn sm-btn-small"; restoreBtn.textContent = "✓ Restore"; restoreBtn.onclick = async function () { showStatus("Restoring...", "info"); restoreBtn.disabled = true; restoreBtn.textContent = "⏳..."; try { const res = await fetch("/rayko_settings_manager/restore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ backup_name: backupName }), }); const data = await res.json(); if (data.success) { showStatus(`✅ Settings restored from ${backupName}`, "success"); restoreBtn.textContent = "✓ Restored"; restoreBtn.disabled = true; const restartBtn = document.createElement("button"); restartBtn.className = "sm-btn sm-btn-small sm-btn-warning"; restartBtn.textContent = "🔄 RESTART SERVER"; restartBtn.onclick = async function () { restartBtn.disabled = true; restartBtn.textContent = "⏳ Restarting..."; showStatus("Server restarting...", "info"); try { await fetch("/rayko_settings_manager/restart", { method: "POST" }); } catch (e) { } startPolling(restartBtn); }; buttonsDiv.appendChild(restartBtn); } else { showStatus(`❌ Error: ${data.error}`, "error"); restoreBtn.disabled = false; restoreBtn.textContent = "✓ Restore"; } } catch (e) { showStatus(`❌ Error: ${e.message}`, "error"); restoreBtn.disabled = false; restoreBtn.textContent = "✓ Restore"; } }; const deleteBtn = document.createElement("button"); deleteBtn.className = "sm-btn sm-btn-small sm-btn-danger"; deleteBtn.textContent = "✕"; deleteBtn.title = "Delete backup"; deleteBtn.onclick = async function () { showStatus("Deleting...", "info"); deleteBtn.disabled = true; deleteBtn.textContent = "⏳"; try { const res = await fetch(`/rayko_settings_manager/delete/${encodeURIComponent(backupName)}`, { method: "DELETE" }); const data = await res.json(); if (data.success) { item.remove(); showStatus(`✅ Deleted: ${backupName}`, "success"); if (backupListDiv.children.length === 0) { backupListDiv.innerHTML = '
No backups found
'; } } else { showStatus(`❌ Error: ${data.error}`, "error"); deleteBtn.disabled = false; deleteBtn.textContent = "✕"; } } catch (e) { showStatus(`❌ Error: ${e.message}`, "error"); deleteBtn.disabled = false; deleteBtn.textContent = "✕"; } }; buttonsDiv.appendChild(restoreBtn); buttonsDiv.appendChild(deleteBtn); item.appendChild(buttonsDiv); return item; } function startPolling(restartBtn) { let attempts = 0; const pollInterval = setInterval(async () => { attempts++; showStatus(`Waiting for server... (${attempts}/10)`, "info"); try { const res = await fetch("/rayko_settings_manager/ping"); const data = await res.json(); if (data.status === "ok") { clearInterval(pollInterval); showStatus("✅ Server is ready! Refreshing page...", "success"); setTimeout(() => window.location.reload(), 2000); } } catch (e) { if (attempts >= 10) { clearInterval(pollInterval); showStatus("⚠️ Server may not have restarted. Please refresh manually.", "error"); restartBtn.disabled = false; restartBtn.textContent = "🔄 RESTART SERVER"; } } }, 5000); } // ============================================================ // WORKFLOW PRESETS // ============================================================ function renderPresetsSection(mainDiv) { const section = document.createElement("div"); section.className = "sm-section"; const banner = document.createElement("div"); banner.id = "sm-active-preset-banner"; banner.className = "sm-active-banner sm-hidden"; banner.innerHTML = `
Active Preset:
`; const updateBtn = document.createElement("button"); updateBtn.className = "sm-btn sm-btn-primary sm-btn-small"; updateBtn.textContent = "💾 Update This Preset"; updateBtn.onclick = async () => { if (!_activePresetName) return; updateBtn.disabled = true; updateBtn.textContent = "⏳ Saving..."; try { await saveCurrentStateToActivePreset(); updateBtn.textContent = "✅ Updated!"; updateBtn.style.backgroundColor = "#10b981"; updateBtn.style.borderColor = "#10b981"; setTimeout(() => { if (updateBtn) { updateBtn.textContent = "💾 Update This Preset"; updateBtn.style.backgroundColor = ""; updateBtn.style.borderColor = ""; } }, 2000); } catch (e) { updateBtn.textContent = "❌ Failed"; updateBtn.style.backgroundColor = "#dc2626"; setTimeout(() => { if (updateBtn) { updateBtn.textContent = "💾 Update This Preset"; updateBtn.style.backgroundColor = ""; } }, 2000); showStatus(`❌ ${e.message}`, "error"); } updateBtn.disabled = false; }; banner.appendChild(updateBtn); banner.querySelector("#sm-detach-preset").onclick = () => { setActivePreset(null); showStatus("Detached from active preset.", "info"); }; section.appendChild(banner); const formDiv = document.createElement("div"); formDiv.innerHTML = `
Workflow Presets
Save open workflows (excl. Unsaved) or load a preset. Presets store file paths.
Stored in custom_nodes/ComfyUI_RaykoStudio/presets_workflow
`; section.appendChild(formDiv); const saveBtn = document.createElement("button"); saveBtn.className = "sm-btn sm-btn-success"; saveBtn.textContent = "💾 Save Open Tabs as New Preset"; saveBtn.style.marginBottom = "10px"; saveBtn.onclick = async () => { const nameInput = section.querySelector("#sm-preset-name"); const name = (nameInput?.value || "").trim(); if (!name) return showStatus("Enter preset name", "error"); const workflows = getOpenWorkflowPaths(); if (workflows.length === 0) return showStatus("No valid open workflows to save (Unsaved filtered)", "error"); saveBtn.disabled = true; saveBtn.textContent = "⏳ Saving..."; try { await saveAllModifiedWorkflows(); const res = await fetch("/rayko_settings_manager/presets/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, workflows }), }); const data = await res.json(); if (data.success) { showStatus(`✅ Preset "${data.name}" saved (${data.count} workflows)`, "success"); nameInput.value = ""; setActivePreset(data.name); const list = section.querySelector("#sm-preset-list"); if (list && !list.classList.contains("sm-hidden")) await refreshPresetList(list); } else { showStatus(`❌ ${data.error || "Save failed"}`, "error"); } } catch (e) { showStatus(`❌ ${e.message}`, "error"); } saveBtn.disabled = false; saveBtn.textContent = "💾 Save Open Tabs as New Preset"; }; section.appendChild(saveBtn); const toggleBtn = document.createElement("button"); toggleBtn.className = "sm-btn"; toggleBtn.textContent = "📂 Show Presets"; toggleBtn.onclick = async () => { const list = section.querySelector("#sm-preset-list"); list.classList.toggle("sm-hidden"); if (!list.classList.contains("sm-hidden")) await refreshPresetList(list); }; section.appendChild(toggleBtn); const list = document.createElement("div"); list.id = "sm-preset-list"; list.className = "sm-hidden"; list.style.marginTop = "10px"; section.appendChild(list); mainDiv.appendChild(section); } function updatePresetUI() { const banner = document.getElementById("sm-active-preset-banner"); const nameSpan = document.getElementById("sm-active-preset-name"); if (banner && nameSpan) { if (_activePresetName) { nameSpan.textContent = `"${_activePresetName}"`; banner.classList.remove("sm-hidden"); } else { banner.classList.add("sm-hidden"); } } } async function loadPresetAsNewTabs(presetName) { const wfStore = app.extensionManager?.workflow; if (!wfStore) throw new Error("Workflow store not available"); if (_activePresetName && _activePresetName !== presetName) { const action = await smConfirmSavePreset(_activePresetName); if (action === "cancel") return 0; if (action === "save") { try { await saveCurrentStateToActivePreset(); } catch (e) { await smAlert("Save Failed", e.message, true); return 0; } } } else if (!_activePresetName) { const confirmed = await smConfirm({ title: "Load Preset", message: `Loading preset "${presetName}" will close other tabs (Unsaved tabs are kept). Continue?`, okText: "Load & Replace", danger: true }); if (!confirmed) return 0; } const res = await fetch(`/rayko_settings_manager/presets/get/${encodeURIComponent(presetName)}`); if (!res.ok) throw new Error("Preset not found"); const data = await res.json(); if (!data.workflows || data.workflows.length === 0) throw new Error("Preset is empty"); // --- БЕЗОПАСНОЕ ЗАКРЫТИЕ ВКЛАДОК --- let safeWf = null; try { if (typeof wfStore.createTemporaryWorkflow === "function") { safeWf = await wfStore.createTemporaryWorkflow(); } else if (typeof wfStore.createTemporary === "function") { safeWf = await wfStore.createTemporary(); } if (safeWf) { const emptyState = { nodes: [], links: [], groups: [], config: {}, extra: {}, version: 0.4 }; await app.loadGraphData(emptyState, true, true, safeWf); if (typeof wfStore.openWorkflow === "function") { await wfStore.openWorkflow(safeWf); } } } catch (e) { console.warn("[Rayko] Failed to create safe workflow", e); } const oldWfs = [...(wfStore.openWorkflows || [])]; for (const wf of oldWfs) { if (wf === safeWf) continue; if (isUnsavedWorkflow(wf)) continue; if (wfStore.openWorkflows.includes(wf)) { try { await wfStore.closeWorkflow(wf); } catch (e) { console.warn("[Rayko] Failed to close old tab", e); } } } // ----------------------------------- let loaded = 0; let failed = []; let firstLoadedWf = null; let workflowLookup = null; for (const item of data.workflows) { try { let finalPath = item; if (typeof item === "string") { if (!item.includes("/") && !item.includes("\\")) { if (!workflowLookup) { workflowLookup = await getWorkflowLookup(); } if (workflowLookup[item] && workflowLookup[item].length > 0) { finalPath = workflowLookup[item][0]; console.log(`[Rayko] Auto-healed path: ${item} -> ${finalPath}`); } } let currentWf = null; // 1. Пытаемся использовать нативный load, но предварительно чистим кэш if (typeof wfStore.load === "function") { try { if (wfStore.workflows) { delete wfStore.workflows[finalPath]; delete wfStore.workflows[finalPath.replace(/^workflows\//, '')]; } currentWf = await wfStore.load(finalPath); if (currentWf) { await wfStore.openWorkflow(currentWf); } } catch (e) { console.warn("wfStore.load failed", e); } } // 2. Если нативный метод не сработал, используем fetch + loadGraphData if (!currentWf) { const safePath = encodeURIComponent(finalPath); const fileRes = await fetch(`/api/userdata/${safePath}?rand=${Date.now()}`); if (!fileRes.ok) throw new Error(`HTTP ${fileRes.status}`); const textData = await fileRes.text(); let fileData; try { fileData = JSON.parse(textData); } catch (e) { throw new Error(`Failed to parse workflow JSON: ${e.message}`); } if (!fileData || typeof fileData !== 'object' || !fileData.nodes) { throw new Error("Invalid workflow structure (missing nodes)"); } const fileName = finalPath.split(/[/\\]/).pop() || finalPath; if (typeof wfStore.createTemporaryWorkflow === "function") { currentWf = await wfStore.createTemporaryWorkflow(fileName); } else if (typeof wfStore.createTemporary === "function") { currentWf = await wfStore.createTemporary(); } if (currentWf) { currentWf.path = finalPath; await app.loadGraphData(fileData, true, true, currentWf); if (currentWf.changeTracker) { try { currentWf.changeTracker.activeState = JSON.parse(JSON.stringify(fileData)); } catch(e) {} currentWf.changeTracker.isDirty = false; } if (wfStore.workflows) wfStore.workflows[finalPath] = currentWf; if (typeof wfStore.openWorkflow === "function") { await wfStore.openWorkflow(currentWf); } } } if (!firstLoadedWf && currentWf) { firstLoadedWf = currentWf; } loaded++; } else if (item && item.data) { let targetWf = null; if (typeof wfStore.createTemporaryWorkflow === "function") { targetWf = await wfStore.createTemporaryWorkflow(item.name); } else if (typeof wfStore.createTemporary === "function") { targetWf = await wfStore.createTemporary(); } if (targetWf) { targetWf.path = item.name; await app.loadGraphData(item.data, true, true, targetWf); if (targetWf.changeTracker) { try { targetWf.changeTracker.activeState = JSON.parse(JSON.stringify(item.data)); } catch(e) {} targetWf.changeTracker.isDirty = false; } if (typeof wfStore.openWorkflow === "function") { await wfStore.openWorkflow(targetWf); } if (!firstLoadedWf) firstLoadedWf = targetWf; } loaded++; } } catch (e) { console.error("[Rayko] Failed to load workflow", item, e); failed.push(typeof item === "string" ? item : (item.name || "Unknown")); } } if (safeWf && loaded > 0) { try { await wfStore.closeWorkflow(safeWf); } catch (e) {} } if (failed.length > 0) { await smAlert("Some workflows not found", `The following files could not be opened (they may have been moved or deleted):\n\n${failed.join('\n')}`, true); } if (loaded === 0) throw new Error("Failed to load any workflow"); setActivePreset(presetName); return loaded; } async function refreshPresetList(listEl) { listEl.innerHTML = '
Loading...
'; try { const res = await fetch("/rayko_settings_manager/presets/list"); const data = await res.json(); listEl.innerHTML = ""; if (!data.success || !data.presets || data.presets.length === 0) { listEl.innerHTML = '
No presets
'; return; } for (const p of data.presets) { const item = document.createElement("div"); item.className = "sm-backup-item"; const nameSpan = document.createElement("span"); nameSpan.className = "sm-backup-name"; nameSpan.textContent = `${p.name} (${p.count})`; nameSpan.title = `${p.count} workflows`; item.appendChild(nameSpan); const btns = document.createElement("div"); btns.className = "sm-restore-container"; const loadBtn = document.createElement("button"); loadBtn.className = "sm-btn sm-btn-small"; loadBtn.textContent = "📂 Load"; loadBtn.onclick = async () => { loadBtn.disabled = true; loadBtn.textContent = "⏳..."; try { const count = await loadPresetAsNewTabs(p.name); if (count > 0) { showStatus(`✅ Loaded "${p.name}" (${count} workflows)`, "success"); const overlay = document.getElementById("sm-modal-overlay"); if (overlay) overlay.remove(); } else { loadBtn.disabled = false; loadBtn.textContent = "📂 Load"; } } catch (e) { showStatus(`❌ ${e.message}`, "error"); loadBtn.disabled = false; loadBtn.textContent = "📂 Load"; } }; btns.appendChild(loadBtn); const delBtn = document.createElement("button"); delBtn.className = "sm-btn sm-btn-small sm-btn-danger"; delBtn.textContent = "✕"; delBtn.title = "Delete preset"; delBtn.onclick = async () => { if (!confirm(`Delete preset "${p.name}"?`)) return; delBtn.disabled = true; try { const r = await fetch(`/rayko_settings_manager/presets/delete/${encodeURIComponent(p.name)}`, { method: "DELETE" }); const d = await r.json(); if (d.success) { item.remove(); showStatus(`✅ Deleted "${p.name}"`, "success"); if (_activePresetName === p.name) setActivePreset(null); } else { showStatus(`❌ ${d.error || "Delete failed"}`, "error"); } } catch (e) { showStatus(`❌ ${e.message}`, "error"); } delBtn.disabled = false; }; btns.appendChild(delBtn); item.appendChild(btns); listEl.appendChild(item); } } catch (e) { listEl.innerHTML = `
Error: ${e.message}
`; } } // ============================================================ // DIALOGS // ============================================================ function smConfirmSavePreset(presetName) { return new Promise((resolve) => { const overlay = document.createElement("div"); overlay.className = "sm-modal-overlay"; overlay.style.zIndex = "1000000"; const modal = document.createElement("div"); modal.className = "sm-modal-content"; modal.style.maxWidth = "400px"; const title = document.createElement("div"); title.className = "sm-title"; title.textContent = "Save Changes?"; const msg = document.createElement("div"); msg.className = "sm-description"; msg.style.marginBottom = "20px"; msg.style.lineHeight = "1.5"; msg.innerHTML = `You have an active preset: "${presetName}".

Do you want to save the current workflows to this preset before continuing?`; const btnRow = document.createElement("div"); btnRow.style.display = "flex"; btnRow.style.flexDirection = "column"; btnRow.style.gap = "10px"; const cancelBtn = document.createElement("button"); cancelBtn.className = "sm-btn"; cancelBtn.textContent = "Cancel"; cancelBtn.onclick = () => { overlay.remove(); resolve("cancel"); }; const saveBtn = document.createElement("button"); saveBtn.className = "sm-btn sm-btn-success"; saveBtn.textContent = "💾 Save & Continue"; saveBtn.onclick = () => { overlay.remove(); resolve("save"); }; const discardBtn = document.createElement("button"); discardBtn.className = "sm-btn sm-btn-danger"; discardBtn.textContent = "Discard Changes & Continue"; discardBtn.onclick = () => { overlay.remove(); resolve("discard"); }; btnRow.appendChild(saveBtn); btnRow.appendChild(discardBtn); btnRow.appendChild(cancelBtn); modal.appendChild(title); modal.appendChild(msg); modal.appendChild(btnRow); overlay.appendChild(modal); document.body.appendChild(overlay); }); } function smConfirm(opts) { return new Promise((resolve) => { const overlay = document.createElement("div"); overlay.className = "sm-modal-overlay"; overlay.style.zIndex = "1000000"; const modal = document.createElement("div"); modal.className = "sm-modal-content"; modal.style.maxWidth = "350px"; const title = document.createElement("div"); title.className = "sm-title"; title.textContent = opts.title || "Confirm"; const msg = document.createElement("div"); msg.className = "sm-description"; msg.style.marginBottom = "20px"; msg.style.lineHeight = "1.5"; msg.textContent = opts.message || "Are you sure?"; const btnRow = document.createElement("div"); btnRow.style.display = "flex"; btnRow.style.gap = "10px"; const cancelBtn = document.createElement("button"); cancelBtn.className = "sm-btn"; cancelBtn.textContent = "Cancel"; cancelBtn.onclick = () => { overlay.remove(); resolve(false); }; const okBtn = document.createElement("button"); okBtn.className = opts.danger ? "sm-btn sm-btn-danger" : "sm-btn sm-btn-success"; okBtn.textContent = opts.okText || "OK"; okBtn.onclick = () => { overlay.remove(); resolve(true); }; btnRow.appendChild(cancelBtn); btnRow.appendChild(okBtn); modal.appendChild(title); modal.appendChild(msg); modal.appendChild(btnRow); overlay.appendChild(modal); document.body.appendChild(overlay); }); } function smAlert(titleText, message, danger) { return new Promise((resolve) => { const overlay = document.createElement("div"); overlay.className = "sm-modal-overlay"; overlay.style.zIndex = "1000000"; const modal = document.createElement("div"); modal.className = "sm-modal-content"; modal.style.maxWidth = "350px"; const title = document.createElement("div"); title.className = "sm-title"; title.textContent = titleText || "Alert"; const msg = document.createElement("div"); msg.className = "sm-description"; msg.style.marginBottom = "20px"; msg.style.lineHeight = "1.5"; msg.textContent = message || ""; const btnRow = document.createElement("div"); btnRow.style.display = "flex"; btnRow.style.gap = "10px"; const okBtn = document.createElement("button"); okBtn.className = danger ? "sm-btn sm-btn-danger" : "sm-btn sm-btn-primary"; okBtn.textContent = "OK"; okBtn.onclick = () => { overlay.remove(); resolve(); }; btnRow.appendChild(okBtn); modal.appendChild(title); modal.appendChild(msg); modal.appendChild(btnRow); overlay.appendChild(modal); document.body.appendChild(overlay); }); } // ============================================================ // STYLES // ============================================================ function injectStyles() { if (document.getElementById("sm-styles")) return; const style = document.createElement("style"); style.id = "sm-styles"; style.textContent = ` /* ---------- Topbar block ---------- */ .sm-topbar-block { display: inline-flex; align-items: stretch; align-self: center; flex: 0 0 auto; height: 32px; margin: 0 0 0 5px; background: #2b2a32; border-radius: 6px; overflow: hidden; box-sizing: border-box; } .sm-topbar-btn { display: inline-flex; align-items: center; justify-content: center; gap: 3px; padding: 0 10px; margin: 0; background: transparent; border: none; color: var(--fg-color); cursor: pointer; transition: background 0.15s; height: 100%; font-family: inherit; font-size: 13px; box-sizing: border-box; } .sm-topbar-btn:hover:not(:disabled) { background: #52525e; } .sm-topbar-btn:active:not(:disabled) { background: #52525e; filter: brightness(0.92); } .sm-topbar-btn:disabled { opacity: 0.5; cursor: not-allowed; } .sm-topbar-icon { font-size: 16px; line-height: 1; } /* ---------- Modal ---------- */ .sm-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; justify-content: center; align-items: center; z-index: 999999; } .sm-modal-content { background: var(--comfy-menu-bg); border: 1px solid var(--border-color); border-radius: 10px; padding: 20px; width: 90%; max-width: 480px; max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 24px rgba(0,0,0,0.4); position: relative; } .sm-container { display: flex; flex-direction: column; gap: 20px; min-height: 300px; } .sm-section { background-color: var(--comfy-input-bg); border: 1px solid var(--border-color); border-radius: 8px; padding: 15px; } .sm-title { font-size: 16px; font-weight: bold; margin-bottom: 10px; color: var(--fg-color); } .sm-description { font-size: 12px; color: var(--descrip-text); margin-bottom: 15px; } .sm-input { width: 100%; padding: 8px 12px; margin-bottom: 5px; background-color: var(--bg-color); border: 1px solid var(--border-color); border-radius: 4px; color: var(--fg-color); font-size: 14px; box-sizing: border-box; } .sm-input:focus { border-color: var(--primary-color); outline: none; } .sm-hint { font-size: 11px; color: var(--descrip-text); margin-bottom: 15px; font-style: italic; } .sm-btn { padding: 10px 20px; background-color: var(--comfy-menu-bg); border: 1px solid var(--border-color); border-radius: 4px; color: var(--fg-color); cursor: pointer; font-size: 14px; width: 100%; transition: all 0.3s ease; } .sm-btn:hover:not(:disabled) { background-color: var(--comfy-menu-secondary-bg); } .sm-btn:disabled { opacity: 0.5; cursor: not-allowed; } .sm-btn-success { background-color: #10b981; color: white; border-color: #10b981; } .sm-btn-success:hover:not(:disabled) { background-color: #059669; } .sm-btn-primary { background-color: #3b82f6; color: white; border-color: #3b82f6; } .sm-btn-primary:hover:not(:disabled) { background-color: #2563eb; } .sm-btn-warning { background-color: #f59e0b; color: white; border-color: #f59e0b; animation: smPulse 2s infinite; } .sm-btn-warning:hover:not(:disabled) { background-color: #d97706; } @keyframes smPulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.7); } 50% { box-shadow: 0 0 0 10px rgba(245, 158, 11, 0); } } .sm-btn-danger { background-color: #dc2626; color: white; border-color: #dc2626; } .sm-btn-danger:hover:not(:disabled) { background-color: #b91c1c; } .sm-backup-item { display: flex; justify-content: space-between; align-items: center; padding: 10px; background-color: var(--bg-color); border: 1px solid var(--border-color); border-radius: 4px; margin-bottom: 8px; } .sm-backup-name { flex: 1; color: var(--fg-color); font-size: 13px; word-break: break-all; margin-right: 10px; } .sm-btn-small { padding: 5px 10px; font-size: 12px; min-width: 80px; width: auto; } .sm-status-container { margin-top: auto; padding-top: 10px; min-height: 40px; display: flex; align-items: center; justify-content: center; } .sm-status { padding: 10px 20px; border-radius: 6px; font-size: 13px; text-align: center; display: none; width: 100%; animation: smFadeIn 0.3s ease; } @keyframes smFadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } .sm-status.sm-success { display: block; background-color: #10b981; color: white; } .sm-status.sm-error { display: block; background-color: #ef4444; color: white; } .sm-status.sm-info { display: block; background-color: #3b82f6; color: white; } .sm-hidden { display: none; } .sm-restore-container { display: flex; gap: 10px; margin-top: 10px; } /* ---------- Active Preset Banner ---------- */ .sm-active-banner { background: rgba(16, 185, 129, 0.15); border: 1px solid #10b981; border-radius: 8px; padding: 12px; margin-bottom: 15px; color: var(--fg-color); font-size: 13px; animation: smFadeIn 0.3s ease; } `; document.head.appendChild(style); }