442 lines
14 KiB
JavaScript
442 lines
14 KiB
JavaScript
(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;
|
|
})();
|