(function () { function createFlowDataStore(options) { const { config, getFlowModelPayload, getStepMeta, getEntityMeta = () => ({}), setStepMeta, setEntityMeta = () => {}, applyFlowModelObject, migrateStepMetaKeys = () => false, setSyncStatus = () => {}, onRemoteSaved = () => {} } = options; const { apiFlowDataUrl, stepMetaKey, flowModelKey, entityMetaKey, bootstrapMarkerKey, bootstrapMarkerValue, idbDbName, idbStoreName, defaultJsonCandidates = [] } = config; let idbOpenPromise = null; let remoteSaveTimer = null; let remoteSaveInFlight = false; let remoteSaveQueued = false; function buildNoCacheUrl(url) { const target = String(url || '').trim(); if (!target) return target; const joiner = target.includes('?') ? '&' : '?'; return `${target}${joiner}_ts=${Date.now()}`; } function openIdb() { if (!('indexedDB' in window)) return Promise.resolve(null); if (idbOpenPromise) return idbOpenPromise; idbOpenPromise = new Promise((resolve) => { try { const req = indexedDB.open(idbDbName, 1); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(idbStoreName)) { db.createObjectStore(idbStoreName, { keyPath: 'k' }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => resolve(null); } catch (error) { resolve(null); } }); return idbOpenPromise; } async function idbSet(key, value) { const db = await openIdb(); if (!db) return false; return new Promise((resolve) => { try { const tx = db.transaction(idbStoreName, 'readwrite'); tx.objectStore(idbStoreName).put({ k: key, v: value }); tx.oncomplete = () => resolve(true); tx.onerror = () => resolve(false); } catch (error) { resolve(false); } }); } async function idbGet(key) { const db = await openIdb(); if (!db) return null; return new Promise((resolve) => { try { const tx = db.transaction(idbStoreName, 'readonly'); const req = tx.objectStore(idbStoreName).get(key); req.onsuccess = () => resolve(req.result ? req.result.v : null); req.onerror = () => resolve(null); } catch (error) { resolve(null); } }); } function persistMarker() { try { localStorage.setItem(bootstrapMarkerKey, bootstrapMarkerValue); } catch (error) { // ignore localStorage failures } idbSet(bootstrapMarkerKey, bootstrapMarkerValue); } function persistLocalSnapshot( flowModelPayload = getFlowModelPayload(), stepMetaPayload = getStepMeta(), entityMetaPayload = getEntityMeta() ) { try { localStorage.setItem(flowModelKey, JSON.stringify(flowModelPayload)); } catch (error) { // localStorage quota exceeded; IndexedDB fallback handles persistence } try { localStorage.setItem(stepMetaKey, JSON.stringify(stepMetaPayload)); } catch (error) { // localStorage quota exceeded; IndexedDB fallback handles persistence } try { localStorage.setItem(entityMetaKey, JSON.stringify(entityMetaPayload)); } catch (error) { // localStorage quota exceeded; IndexedDB fallback handles persistence } idbSet(flowModelKey, flowModelPayload); idbSet(stepMetaKey, stepMetaPayload); idbSet(entityMetaKey, entityMetaPayload); } function buildCurrentPayload() { return { version: 1, exportedAt: new Date().toISOString(), flowModel: getFlowModelPayload(), stepMeta: getStepMeta(), entityMeta: getEntityMeta() }; } function extractImportPayload(parsed) { if (!parsed || typeof parsed !== 'object') return null; const flowModel = (parsed.flowModel && typeof parsed.flowModel === 'object') ? parsed.flowModel : ((Array.isArray(parsed.mainSteps) || (parsed.subFlow && typeof parsed.subFlow === 'object')) ? parsed : null); if (!flowModel || typeof flowModel !== 'object') return null; return { flowModel, importedMeta: (parsed.stepMeta && typeof parsed.stepMeta === 'object') ? parsed.stepMeta : {}, entityMeta: (parsed.entityMeta && typeof parsed.entityMeta === 'object') ? parsed.entityMeta : {} }; } function applyImportedData(flowModel, importedMeta, entityMeta, persistRemote = true) { applyFlowModelObject(flowModel); setStepMeta((importedMeta && typeof importedMeta === 'object') ? importedMeta : {}); setEntityMeta((entityMeta && typeof entityMeta === 'object') ? entityMeta : {}); migrateStepMetaKeys(); persistLocalSnapshot(getFlowModelPayload(), getStepMeta(), getEntityMeta()); if (persistRemote) scheduleRemoteSave(); return true; } async function persistRemoteData() { if (remoteSaveInFlight) { remoteSaveQueued = true; return; } remoteSaveInFlight = true; setSyncStatus('saving', 'DB 저장 중'); try { const res = await fetch(buildNoCacheUrl(apiFlowDataUrl), { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', Pragma: 'no-cache' }, body: JSON.stringify(buildCurrentPayload()) }); if (!res.ok) { console.warn('DB save failed', res.status); setSyncStatus('error', 'DB 저장 실패'); } else { setSyncStatus('ready', 'DB 저장됨'); onRemoteSaved(); } } catch (error) { console.warn('DB save failed', error); setSyncStatus('error', 'DB 저장 실패'); } finally { remoteSaveInFlight = false; if (remoteSaveQueued) { remoteSaveQueued = false; persistRemoteData(); } } } function scheduleRemoteSave() { if (remoteSaveTimer) clearTimeout(remoteSaveTimer); remoteSaveTimer = window.setTimeout(() => { remoteSaveTimer = null; persistRemoteData(); }, 250); } function loadStepMeta() { try { const raw = localStorage.getItem(stepMetaKey); if (!raw) return; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object') return; setStepMeta(parsed); migrateStepMetaKeys(); } catch (error) { setStepMeta({}); } } function loadFlowModel() { try { const raw = localStorage.getItem(flowModelKey); if (!raw) return; const parsed = JSON.parse(raw); applyFlowModelObject(parsed); } catch (error) { // ignore malformed saved model } } async function loadFromIdbFallback() { const hasLocalFlow = !!localStorage.getItem(flowModelKey); const hasLocalMeta = !!localStorage.getItem(stepMetaKey); const hasLocalEntityMeta = !!localStorage.getItem(entityMetaKey); if (!hasLocalFlow) { const flow = await idbGet(flowModelKey); if (flow && typeof flow === 'object') applyFlowModelObject(flow); } if (!hasLocalMeta) { const meta = await idbGet(stepMetaKey); if (meta && typeof meta === 'object') { setStepMeta(meta); migrateStepMetaKeys(); } } if (!hasLocalEntityMeta) { const entityMeta = await idbGet(entityMetaKey); if (entityMeta && typeof entityMeta === 'object') { setEntityMeta(entityMeta); } } if (localStorage.getItem(bootstrapMarkerKey) !== bootstrapMarkerValue) { const marker = await idbGet(bootstrapMarkerKey); if (marker === bootstrapMarkerValue) { try { localStorage.setItem(bootstrapMarkerKey, bootstrapMarkerValue); } catch (error) { // ignore localStorage failures } } } } async function loadRemoteData() { setSyncStatus('loading', 'DB 불러오는 중'); try { const res = await fetch(buildNoCacheUrl(apiFlowDataUrl), { cache: 'no-store', headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' } }); if (!res.ok) { if (res.status === 404) return false; throw new Error(`HTTP ${res.status}`); } const parsed = await res.json(); const extracted = extractImportPayload(parsed); if (!extracted) return false; applyImportedData(extracted.flowModel, extracted.importedMeta, extracted.entityMeta, false); persistMarker(); setSyncStatus('ready', 'DB 연결됨'); return true; } catch (error) { console.warn('DB load failed', error); const loadedFromJson = await loadFromDefaultJsonCandidates(false); if (loadedFromJson) { setSyncStatus('ready', 'Gitea 데이터 불러옴'); return true; } return false; } } async function loadFromDefaultJsonCandidates(persistRemote = true) { for (const path of defaultJsonCandidates) { try { let parsed = null; try { const res = await fetch(path, { cache: 'no-store' }); if (res.ok) parsed = await res.json(); } catch (error) { // fall through to XHR } if (!parsed) { parsed = await new Promise((resolve) => { try { const xhr = new XMLHttpRequest(); xhr.open('GET', path, true); xhr.onreadystatechange = () => { if (xhr.readyState !== 4) return; if (xhr.status === 200 || (xhr.status === 0 && xhr.responseText)) { try { resolve(JSON.parse(xhr.responseText)); } catch (error) { resolve(null); } } else { resolve(null); } }; xhr.send(); } catch (error) { resolve(null); } }); } if (!parsed) continue; const extracted = extractImportPayload(parsed); if (!extracted) continue; applyImportedData( extracted.flowModel, extracted.importedMeta, extracted.entityMeta, persistRemote ); persistMarker(); return true; } catch (error) { // ignore and try next candidate } } return false; } async function bootstrapDefaultJsonIfEmpty() { let hasMarker = localStorage.getItem(bootstrapMarkerKey) === bootstrapMarkerValue; if (!hasMarker) { const marker = await idbGet(bootstrapMarkerKey); hasMarker = marker === bootstrapMarkerValue; } if (hasMarker) return false; try { const res = await fetch(buildNoCacheUrl(apiFlowDataUrl), { cache: 'no-store', headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' } }); if (res.ok) { const parsed = await res.json(); const extracted = extractImportPayload(parsed); if (extracted) { applyImportedData(extracted.flowModel, extracted.importedMeta, extracted.entityMeta, true); persistMarker(); setSyncStatus('ready', '기본 데이터 적재됨'); return true; } } } catch (error) { // fallback to local json seed } const loadedFromJson = await loadFromDefaultJsonCandidates(true); if (loadedFromJson) { setSyncStatus('ready', '기본 데이터 적재됨'); return true; } return false; } function saveStepMeta() { persistLocalSnapshot(getFlowModelPayload(), getStepMeta(), getEntityMeta()); scheduleRemoteSave(); } function saveFlowModel() { persistLocalSnapshot(getFlowModelPayload(), getStepMeta(), getEntityMeta()); scheduleRemoteSave(); } function exportAllData() { const payload = buildCurrentPayload(); const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); const day = new Date().toISOString().slice(0, 10); anchor.href = url; anchor.download = `flow-data-${day}.json`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(url); } function importAllData(rawText) { let parsed; try { parsed = JSON.parse(rawText); } catch (error) { return { ok: false, message: 'JSON 파일 형식이 올바르지 않습니다.' }; } if (!parsed || typeof parsed !== 'object') { return { ok: false, message: '가져올 데이터 형식이 아닙니다.' }; } const extracted = extractImportPayload(parsed); if (!extracted) { return { ok: false, message: 'flowModel 데이터가 없습니다.' }; } applyImportedData(extracted.flowModel, extracted.importedMeta, extracted.entityMeta, true); persistMarker(); return { ok: true }; } return { persistMarker, saveStepMeta, saveFlowModel, applyImportedData, exportAllData, importAllData, loadFlowModel, loadStepMeta, loadFromIdbFallback, loadRemoteData, bootstrapDefaultJsonIfEmpty }; } window.createFlowDataStore = createFlowDataStore; })();