| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| (() => { |
| "use strict"; |
|
|
| const DRAFT_KEY_PREFIX = "Comfy.Workflow.Draft"; |
| const V1_DRAFTS_KEY = "Comfy.Workflow.Drafts"; |
| const V1_ORDER_KEY = "Comfy.Workflow.DraftOrder"; |
| const V2_INDEX_PREFIX = "Comfy.Workflow.DraftIndex.v2:"; |
| const V2_PAYLOAD_PREFIX = "Comfy.Workflow.Draft.v2:"; |
|
|
| |
| |
| |
| |
| |
| const MAX_TOTAL_DRAFT_CHARS = 2_000_000; |
| const MAX_DRAFT_ENTRY_CHARS = 750_000; |
| const MAX_EVICTIONS_PER_WRITE = 25; |
| const MAX_PRUNE_EVICTIONS = 200; |
| const LOG_PREFIX = "[Koolook draft-guard]"; |
|
|
| const originalSetItem = localStorage.setItem.bind(localStorage); |
| let rescueToastShown = false; |
| let stillFullToastShown = false; |
|
|
| function isDraftKey(key) { |
| return typeof key === "string" && key.startsWith(DRAFT_KEY_PREFIX); |
| } |
|
|
| function isQuotaError(err) { |
| return ( |
| err instanceof DOMException && |
| (err.name === "QuotaExceededError" || |
| err.name === "NS_ERROR_DOM_QUOTA_REACHED" || |
| err.code === 22 || |
| err.code === 1014) |
| ); |
| } |
|
|
| function listDraftKeys() { |
| const keys = []; |
| for (let i = 0; i < localStorage.length; i += 1) { |
| const key = localStorage.key(i); |
| if (isDraftKey(key)) keys.push(key); |
| } |
| return keys; |
| } |
|
|
| function parseJson(raw) { |
| try { |
| return JSON.parse(raw); |
| } catch (_err) { |
| return undefined; |
| } |
| } |
|
|
| function totalDraftChars() { |
| let total = 0; |
| for (const key of listDraftKeys()) { |
| total += key.length + (localStorage.getItem(key) || "").length; |
| } |
| return total; |
| } |
|
|
| function removeV1Family(suffix) { |
| localStorage.removeItem(V1_DRAFTS_KEY + suffix); |
| localStorage.removeItem(V1_ORDER_KEY + suffix); |
| } |
|
|
| function v1FamilySuffixes(keys) { |
| const suffixes = new Set(); |
| for (const key of keys) { |
| if (key === V1_DRAFTS_KEY || key === V1_ORDER_KEY) suffixes.add(""); |
| else if (key.startsWith(V1_DRAFTS_KEY + ":")) suffixes.add(key.slice(V1_DRAFTS_KEY.length)); |
| else if (key.startsWith(V1_ORDER_KEY + ":")) suffixes.add(key.slice(V1_ORDER_KEY.length)); |
| } |
| return suffixes; |
| } |
|
|
| |
| |
| function readV1Family(suffix) { |
| const rawDrafts = localStorage.getItem(V1_DRAFTS_KEY + suffix); |
| if (rawDrafts === null) { |
| |
| return localStorage.getItem(V1_ORDER_KEY + suffix) === null |
| ? null |
| : { suffix, corrupt: true }; |
| } |
| const drafts = parseJson(rawDrafts); |
| if (!drafts || typeof drafts !== "object" || Array.isArray(drafts)) { |
| return { suffix, corrupt: true }; |
| } |
| let order = parseJson(localStorage.getItem(V1_ORDER_KEY + suffix) || "[]"); |
| if (!Array.isArray(order)) order = []; |
| order = order.filter((path) => typeof path === "string"); |
| const known = Object.keys(drafts); |
| const orderedKeys = [ |
| ...order.filter((path) => Object.prototype.hasOwnProperty.call(drafts, path)), |
| ...known.filter((path) => !order.includes(path)), |
| ]; |
| return { suffix, drafts, orderedKeys }; |
| } |
|
|
| function writeV1Family(family) { |
| originalSetItem(V1_DRAFTS_KEY + family.suffix, JSON.stringify(family.drafts)); |
| originalSetItem( |
| V1_ORDER_KEY + family.suffix, |
| JSON.stringify( |
| family.orderedKeys.filter((path) => |
| Object.prototype.hasOwnProperty.call(family.drafts, path), |
| ), |
| ), |
| ); |
| } |
|
|
| function readV2Index(ws) { |
| const raw = localStorage.getItem(V2_INDEX_PREFIX + ws); |
| if (raw === null) return null; |
| const index = parseJson(raw); |
| if ( |
| !index || |
| typeof index !== "object" || |
| !Array.isArray(index.order) || |
| typeof index.entries !== "object" || |
| index.entries === null |
| ) { |
| return { ws, corrupt: true }; |
| } |
| return { ws, index }; |
| } |
|
|
| function isKnownDraftKey(key) { |
| return ( |
| key === V1_DRAFTS_KEY || |
| key === V1_ORDER_KEY || |
| key.startsWith(V1_DRAFTS_KEY + ":") || |
| key.startsWith(V1_ORDER_KEY + ":") || |
| key.startsWith(V2_INDEX_PREFIX) || |
| key.startsWith(V2_PAYLOAD_PREFIX) |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function collectEvictionCandidates(targetKey) { |
| const keys = listDraftKeys(); |
| const candidates = []; |
|
|
| for (const suffix of v1FamilySuffixes(keys)) { |
| const draftsKey = V1_DRAFTS_KEY + suffix; |
| const family = readV1Family(suffix); |
| if (!family) continue; |
| if (family.corrupt || family.orderedKeys.length === 0) { |
| candidates.push({ |
| age: -1, |
| penalty: 0, |
| label: `unreadable/empty v1 family "${suffix || "(root)"}"`, |
| evict: () => removeV1Family(suffix), |
| }); |
| continue; |
| } |
| const front = family.orderedKeys[0]; |
| const entry = family.drafts[front]; |
| candidates.push({ |
| age: typeof entry?.updatedAt === "number" ? entry.updatedAt : 0, |
| penalty: draftsKey === targetKey ? 2 : 0, |
| label: `v1 draft "${front}"`, |
| evict: () => { |
| delete family.drafts[front]; |
| family.orderedKeys.shift(); |
| try { |
| writeV1Family(family); |
| } catch (_err) { |
| |
| removeV1Family(suffix); |
| } |
| }, |
| }); |
| } |
|
|
| const v2Workspaces = new Set(); |
| const v2PayloadsByWs = new Map(); |
| for (const key of keys) { |
| if (key.startsWith(V2_INDEX_PREFIX)) { |
| v2Workspaces.add(key.slice(V2_INDEX_PREFIX.length)); |
| } else if (key.startsWith(V2_PAYLOAD_PREFIX)) { |
| const rest = key.slice(V2_PAYLOAD_PREFIX.length); |
| const sep = rest.lastIndexOf(":"); |
| const ws = sep >= 0 ? rest.slice(0, sep) : rest; |
| const hash = sep >= 0 ? rest.slice(sep + 1) : ""; |
| if (!v2PayloadsByWs.has(ws)) v2PayloadsByWs.set(ws, []); |
| v2PayloadsByWs.get(ws).push({ key, hash }); |
| v2Workspaces.add(ws); |
| } |
| } |
|
|
| for (const ws of v2Workspaces) { |
| const indexKey = V2_INDEX_PREFIX + ws; |
| const record = readV2Index(ws); |
| const payloads = v2PayloadsByWs.get(ws) || []; |
|
|
| if (record?.corrupt) { |
| candidates.push({ |
| age: -1, |
| penalty: indexKey === targetKey ? 2 : 0, |
| label: `corrupt v2 index "${ws}"`, |
| evict: () => localStorage.removeItem(indexKey), |
| }); |
| } |
|
|
| const order = record && !record.corrupt ? record.index.order : []; |
| const entries = record && !record.corrupt ? record.index.entries : {}; |
| const present = new Set(payloads.map((p) => p.hash)); |
|
|
| for (const payload of payloads) { |
| if (order.includes(payload.hash)) continue; |
| |
| candidates.push({ |
| age: -1, |
| penalty: payload.key === targetKey ? 2 : 0, |
| label: `orphan v2 payload "${ws}:${payload.hash}"`, |
| evict: () => localStorage.removeItem(payload.key), |
| }); |
| } |
|
|
| const front = order.find((hash) => present.has(hash)); |
| if (front !== undefined) { |
| const payloadKey = V2_PAYLOAD_PREFIX + ws + ":" + front; |
| const entryAge = entries[front]?.updatedAt; |
| candidates.push({ |
| age: typeof entryAge === "number" ? entryAge : 0, |
| penalty: payloadKey === targetKey ? 2 : 0, |
| label: `v2 draft "${ws}:${front}"`, |
| evict: () => { |
| localStorage.removeItem(payloadKey); |
| if (indexKey === targetKey) return; |
| try { |
| const next = { |
| ...record.index, |
| updatedAt: Date.now(), |
| order: order.filter((hash) => hash !== front), |
| entries: { ...entries }, |
| }; |
| delete next.entries[front]; |
| originalSetItem(indexKey, JSON.stringify(next)); |
| } catch (_err) { |
| |
| } |
| }, |
| }); |
| } |
| } |
|
|
| for (const key of keys) { |
| if (isKnownDraftKey(key)) continue; |
| candidates.push({ |
| age: 0, |
| penalty: key === targetKey ? 2 : 1, |
| label: `unknown draft key "${key}"`, |
| evict: () => localStorage.removeItem(key), |
| }); |
| } |
|
|
| candidates.sort((a, b) => a.penalty - b.penalty || a.age - b.age); |
| return candidates; |
| } |
|
|
| function evictOneDraftUnit(targetKey) { |
| for (const candidate of collectEvictionCandidates(targetKey)) { |
| try { |
| candidate.evict(); |
| } catch (err) { |
| console.warn(`${LOG_PREFIX} eviction step failed for ${candidate.label}`, err); |
| continue; |
| } |
| console.warn(`${LOG_PREFIX} evicted ${candidate.label} to free draft storage.`); |
| return true; |
| } |
| return false; |
| } |
|
|
| function showDraftQuotaToast(message) { |
| try { |
| const toast = document.createElement("div"); |
| toast.textContent = message; |
| toast.style.cssText = [ |
| "position:fixed", |
| "right:30px", |
| "bottom:30px", |
| "z-index:9999", |
| "max-width:420px", |
| "padding:10px 14px", |
| "border-radius:4px", |
| "background:rgba(180,60,60,0.95)", |
| "color:#fff", |
| "font:12px/1.4 ui-sans-serif,system-ui,sans-serif", |
| "box-shadow:0 2px 8px rgba(0,0,0,0.4)", |
| ].join(";"); |
| document.body.appendChild(toast); |
| setTimeout(() => toast.remove(), 6500); |
| } catch (_err) { |
| |
| |
| } |
| } |
|
|
| function notifyRescueOnce() { |
| if (rescueToastShown) return; |
| rescueToastShown = true; |
| showDraftQuotaToast( |
| "Browser draft storage was full. Koolook removed the oldest workflow draft(s) only, so autosave keeps working.", |
| ); |
| } |
|
|
| function installComfyDraftQuotaGuard() { |
| try { |
| if (localStorage.__koolookDraftQuotaGuardInstalled) return; |
| Object.defineProperty(localStorage, "__koolookDraftQuotaGuardInstalled", { |
| value: true, |
| configurable: true, |
| }); |
| localStorage.setItem = (key, value) => { |
| try { |
| return originalSetItem(key, value); |
| } catch (err) { |
| if (!isQuotaError(err) || !isDraftKey(String(key))) throw err; |
| |
| |
| |
| |
| for (let attempt = 0; attempt < MAX_EVICTIONS_PER_WRITE; attempt += 1) { |
| if (!evictOneDraftUnit(String(key))) break; |
| try { |
| const result = originalSetItem(key, value); |
| notifyRescueOnce(); |
| return result; |
| } catch (retryErr) { |
| if (!isQuotaError(retryErr)) throw retryErr; |
| } |
| } |
| |
| |
| |
| |
| if (!stillFullToastShown) { |
| stillFullToastShown = true; |
| showDraftQuotaToast( |
| "Comfy draft cache is still full. Koolook could not free enough space; export or delete old drafts manually.", |
| ); |
| } |
| throw err; |
| } |
| }; |
| } catch (err) { |
| console.warn(`${LOG_PREFIX} could not install Comfy draft quota guard.`, err); |
| } |
| } |
|
|
| function pruneComfyDraftCache() { |
| try { |
| if (listDraftKeys().length === 0) return; |
| let pruned = false; |
|
|
| |
| |
| for (const suffix of v1FamilySuffixes(listDraftKeys())) { |
| if (!suffix) continue; |
| const ws = suffix.slice(1); |
| if (localStorage.getItem(V2_INDEX_PREFIX + ws) !== null) { |
| removeV1Family(suffix); |
| pruned = true; |
| } |
| } |
|
|
| |
| |
| for (const suffix of v1FamilySuffixes(listDraftKeys())) { |
| const family = readV1Family(suffix); |
| if (!family) continue; |
| if (family.corrupt) { |
| removeV1Family(suffix); |
| pruned = true; |
| continue; |
| } |
| let changed = false; |
| for (const path of [...family.orderedKeys]) { |
| const data = family.drafts[path]?.data; |
| if (typeof data === "string" && data.length > MAX_DRAFT_ENTRY_CHARS) { |
| delete family.drafts[path]; |
| family.orderedKeys.splice(family.orderedKeys.indexOf(path), 1); |
| changed = true; |
| } |
| } |
| if (changed) { |
| try { |
| writeV1Family(family); |
| } catch (_err) { |
| removeV1Family(suffix); |
| } |
| pruned = true; |
| } |
| } |
| for (const key of listDraftKeys()) { |
| if (!key.startsWith(V2_PAYLOAD_PREFIX) && !key.startsWith(V2_INDEX_PREFIX)) continue; |
| const raw = localStorage.getItem(key) || ""; |
| const oversized = key.startsWith(V2_PAYLOAD_PREFIX) && raw.length > MAX_DRAFT_ENTRY_CHARS; |
| if (oversized || parseJson(raw) === undefined) { |
| localStorage.removeItem(key); |
| pruned = true; |
| } |
| } |
|
|
| |
| let attempts = 0; |
| while (totalDraftChars() > MAX_TOTAL_DRAFT_CHARS && attempts < MAX_PRUNE_EVICTIONS) { |
| if (!evictOneDraftUnit(null)) break; |
| attempts += 1; |
| pruned = true; |
| } |
|
|
| if (pruned) { |
| console.warn( |
| `${LOG_PREFIX} pruned Comfy workflow draft storage to prevent save-draft failures.`, |
| ); |
| } |
| } catch (err) { |
| console.warn(`${LOG_PREFIX} draft prune skipped:`, err); |
| } |
| } |
|
|
| installComfyDraftQuotaGuard(); |
| pruneComfyDraftCache(); |
| })(); |
|
|