diff --git a/DEV-GUIDE.md b/DEV-GUIDE.md index d7a8269..bbc76a3 100644 --- a/DEV-GUIDE.md +++ b/DEV-GUIDE.md @@ -8,3 +8,41 @@ The file open size limit is configured in [src/toolbarActions/toolbarFunctions.j - `MAX_FILE_SIZE` is derived from it as bytes. To change the limit, update `MAX_FILE_SIZE_MB` only. + +## Workspace persistence (hybrid storage) + +Workspace persistence now uses a hybrid model: + +- Files/binary artifacts (imports/exports) stay on the browser filesystem path. +- Graph/session metadata is stored in IndexedDB (`concore-editor-storage`) when available. +- Local storage is used as a fallback if IndexedDB is unavailable. + +### IndexedDB schema (v1) + +- `graphs` store (key: `id`): graph snapshot payloads used by autosave/recovery. +- `meta` store (key: `key`): + - `graph_order` + - `session` + - `author_name` + - `migrated_local_storage_v1` + +### Migration behavior + +On first successful IndexedDB init, legacy localStorage data is migrated once: + +- all graph snapshots from `ALL_GRAPHS` +- session metadata from `SESSION_STATE` (if present) +- author metadata from `AUTHOR_NAME` + +### Fallback behavior + +If IndexedDB cannot be initialized, the app stays functional with localStorage fallback. +The same public storage manager API is used in both cases. + +### Manual verification checklist + +1. Open multiple graphs, switch active tab, reload page. +2. Confirm open tabs and active tab are restored. +3. Edit graphs and verify autosave keeps action history. +4. Corrupt one graph record manually and confirm app still loads remaining workspace. +5. Simulate IndexedDB unavailability and verify fallback load/save still works. diff --git a/src/GraphWorkspace.jsx b/src/GraphWorkspace.jsx index 53e3504..d189d3e 100644 --- a/src/GraphWorkspace.jsx +++ b/src/GraphWorkspace.jsx @@ -13,15 +13,65 @@ const GraphComp = (props) => { const { dispatcher, superState } = props; React.useEffect(() => { - const allIDs = localStorageManager.getAllGraphs(); - const validIDs = allIDs.filter((id) => typeof id === 'string' && id && localStorageManager.get(id) !== null); - if (validIDs.length !== allIDs.length) { - allIDs.filter((id) => !validIDs.includes(id)).forEach((id) => localStorageManager.remove(id)); - } - if (validIDs.length === 0) return; - dispatcher({ type: T.ADD_GRAPH_BULK, payload: validIDs.map((graphID) => ({ graphID })) }); + let mounted = true; + const restoreWorkspace = async () => { + await localStorageManager.initialize(); + if (!mounted) return; + + const session = localStorageManager.getSession(); + const allIDs = Array.isArray(session?.openGraphIDs) && session.openGraphIDs.length + ? session.openGraphIDs + : localStorageManager.getAllGraphs(); + const validIDs = allIDs.filter( + (id) => typeof id === 'string' && id && localStorageManager.get(id) !== null, + ); + if (validIDs.length !== allIDs.length) { + allIDs.filter((id) => !validIDs.includes(id)).forEach((id) => localStorageManager.remove(id)); + } + + if (Array.isArray(session?.fileState)) { + dispatcher({ type: T.SET_FILE_STATE, payload: session.fileState }); + } + if (typeof session?.uploadedDirName === 'string') { + dispatcher({ type: T.SET_DIR_NAME, payload: session.uploadedDirName }); + } + + if (validIDs.length === 0) return; + dispatcher({ + type: T.ADD_GRAPH_BULK, + payload: { + graphs: validIDs.map((graphID) => ({ graphID })), + activeGraphID: session?.activeGraphID || null, + }, + }); + }; + restoreWorkspace(); + + return () => { + mounted = false; + }; }, []); + React.useEffect(() => { + const openGraphIDs = superState.graphs + .map((graph) => graph.graphID) + .filter((graphID) => typeof graphID === 'string' && graphID); + const activeGraphID = superState.curGraphIndex >= 0 && superState.curGraphIndex < superState.graphs.length + ? superState.graphs[superState.curGraphIndex].graphID + : null; + localStorageManager.saveSession({ + openGraphIDs, + activeGraphID, + fileState: superState.fileState, + uploadedDirName: superState.uploadedDirName, + }); + }, [ + superState.graphs, + superState.curGraphIndex, + superState.fileState, + superState.uploadedDirName, + ]); + // Remote server implementation - Not being used. // useEffect(() => { // if (!loadedFromStorage) return; diff --git a/src/component/fileBrowser.jsx b/src/component/fileBrowser.jsx index 1ab72c1..e46cf80 100644 --- a/src/component/fileBrowser.jsx +++ b/src/component/fileBrowser.jsx @@ -53,6 +53,10 @@ const LocalFileBrowser = ({ superState, dispatcher }) => { }, [superState.fileState]); const handleSelectFile = (data) => { + if (!data.fileObj) { + toast.info('File handle is not available after reload. Re-open the file/directory.'); + return; + } const fileExtensions = ['exe']; const fileExt = data.fileObj.name.split('.').pop().toLowerCase(); if (fileExtensions.includes(fileExt)) { diff --git a/src/graph-builder/local-storage-manager.js b/src/graph-builder/local-storage-manager.js index 666865f..2bd00b4 100644 --- a/src/graph-builder/local-storage-manager.js +++ b/src/graph-builder/local-storage-manager.js @@ -23,6 +23,47 @@ const parseStoredJson = (raw) => { } }; +const isValidGraphSnapshot = (graph) => !!( + graph + && typeof graph === 'object' + && Array.isArray(graph.nodes) + && Array.isArray(graph.edges) +); + +const requestAsPromise = (request) => new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); +}); + +const sanitizeSession = (session) => { + if (!session || typeof session !== 'object') return null; + const openGraphIDs = Array.isArray(session.openGraphIDs) + ? session.openGraphIDs.filter((id) => typeof id === 'string' && id) + : []; + const activeGraphID = typeof session.activeGraphID === 'string' ? session.activeGraphID : null; + const fileState = Array.isArray(session.fileState) + ? session.fileState + .filter((file) => file && typeof file === 'object' && typeof file.key === 'string') + .map((file) => { + let fileName = null; + if (typeof file.fileName === 'string') fileName = file.fileName; + else if (file.fileObj && file.fileObj.name) fileName = file.fileObj.name; + return { + key: file.key, + modified: Number.isFinite(file.modified) ? file.modified : 0, + size: Number.isFinite(file.size) ? file.size : 0, + fileName, + }; + }) + : []; + return { + openGraphIDs, + activeGraphID, + fileState, + uploadedDirName: typeof session.uploadedDirName === 'string' ? session.uploadedDirName : null, + }; +}; + const localStorageGet = (key) => { try { return window.localStorage.getItem(key); @@ -53,64 +94,348 @@ const localStorageRemove = (key) => { const localStorageManager = { ALL_GRAPHS: window.btoa('ALL_GRAPHS'), AUTHOR_NAME: window.btoa('AUTHOR_NAME'), + SESSION_STATE: window.btoa('SESSION_STATE'), - get(id) { + DB_NAME: 'concore-editor-storage', + DB_VERSION: 1, + GRAPH_STORE: 'graphs', + META_STORE: 'meta', + META_GRAPH_ORDER: 'graph_order', + META_SESSION: 'session', + META_AUTHOR: 'author_name', + META_MIGRATION_V1: 'migrated_local_storage_v1', + + db: null, + initialized: false, + initPromise: null, + useFallback: false, + graphCache: new Map(), + graphOrder: [], + sessionCache: null, + authorNameCache: '', + + isIndexedDBAvailable() { + return typeof window !== 'undefined' && !!window.indexedDB; + }, + + openDB() { + return new Promise((resolve, reject) => { + const request = window.indexedDB.open(this.DB_NAME, this.DB_VERSION); + request.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains(this.GRAPH_STORE)) { + db.createObjectStore(this.GRAPH_STORE, { keyPath: 'id' }); + } + if (!db.objectStoreNames.contains(this.META_STORE)) { + db.createObjectStore(this.META_STORE, { keyPath: 'key' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + }, + + getLegacyGraph(id) { const raw = localStorageGet(id); if (raw === null) return null; const parsed = parseStoredJson(raw); - if (parsed !== null) return parsed; - // fallback for legacy plain JSON data saved before base64 encoding was introduced + if (isValidGraphSnapshot(parsed)) return parsed; try { - return JSON.parse(raw); + const plainJson = JSON.parse(raw); + return isValidGraphSnapshot(plainJson) ? plainJson : null; } catch { return null; } }, + + getLegacyGraphOrder() { + const raw = localStorageGet(this.ALL_GRAPHS); + if (!raw) return []; + const parsed = parseStoredJson(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter((id) => typeof id === 'string' && id); + }, + + getLegacySession() { + const raw = localStorageGet(this.SESSION_STATE); + if (!raw) return null; + const parsed = parseStoredJson(raw); + return sanitizeSession(parsed); + }, + + setFallbackMode() { + this.useFallback = true; + this.db = null; + this.graphOrder = this.getLegacyGraphOrder(); + this.graphCache = new Map(); + this.graphOrder.forEach((id) => { + const content = this.getLegacyGraph(id); + if (content !== null) this.graphCache.set(id, content); + }); + this.sessionCache = this.getLegacySession(); + this.authorNameCache = localStorageGet(this.AUTHOR_NAME) || ''; + }, + + async getMetaValue(key) { + if (!this.db) return null; + const tx = this.db.transaction(this.META_STORE, 'readonly'); + const store = tx.objectStore(this.META_STORE); + const record = await requestAsPromise(store.get(key)); + return record ? record.value : null; + }, + + async putMetaValue(key, value) { + if (!this.db) return; + const tx = this.db.transaction(this.META_STORE, 'readwrite'); + const store = tx.objectStore(this.META_STORE); + await requestAsPromise(store.put({ key, value })); + }, + + async deleteGraphRecord(id) { + if (!this.db) return; + const tx = this.db.transaction(this.GRAPH_STORE, 'readwrite'); + const store = tx.objectStore(this.GRAPH_STORE); + await requestAsPromise(store.delete(id)); + }, + + async putGraphRecord(id, data) { + if (!this.db) return; + const tx = this.db.transaction(this.GRAPH_STORE, 'readwrite'); + const store = tx.objectStore(this.GRAPH_STORE); + await requestAsPromise(store.put({ id, data, updatedAt: Date.now() })); + }, + + async persistGraphOrder() { + if (this.useFallback) { + localStorageSet(this.ALL_GRAPHS, encodeBase64(JSON.stringify(this.graphOrder))); + return; + } + await this.putMetaValue(this.META_GRAPH_ORDER, this.graphOrder); + }, + + async migrateLegacyLocalStorage() { + const migrated = await this.getMetaValue(this.META_MIGRATION_V1); + if (migrated) return; + + const legacyOrder = this.getLegacyGraphOrder(); + legacyOrder.forEach((id) => { + if (this.graphCache.has(id)) return; + const graph = this.getLegacyGraph(id); + if (!graph) return; + this.graphCache.set(id, graph); + }); + + if (!this.graphOrder.length && legacyOrder.length) { + this.graphOrder = legacyOrder; + } + + const legacySession = this.getLegacySession(); + if (!this.sessionCache && legacySession) { + this.sessionCache = legacySession; + } + + if (!this.authorNameCache) { + this.authorNameCache = localStorageGet(this.AUTHOR_NAME) || ''; + } + + await Promise.all( + Array.from(this.graphCache.entries()).map(([id, data]) => this.putGraphRecord(id, data)), + ); + await this.persistGraphOrder(); + if (this.sessionCache) await this.putMetaValue(this.META_SESSION, this.sessionCache); + if (this.authorNameCache) await this.putMetaValue(this.META_AUTHOR, this.authorNameCache); + await this.putMetaValue(this.META_MIGRATION_V1, true); + }, + + async initialize() { + if (this.initPromise) return this.initPromise; + this.initPromise = (async () => { + if (!this.isIndexedDBAvailable()) { + this.setFallbackMode(); + this.initialized = true; + return; + } + try { + this.db = await this.openDB(); + + const tx = this.db.transaction(this.GRAPH_STORE, 'readonly'); + const graphStore = tx.objectStore(this.GRAPH_STORE); + const graphRecords = await requestAsPromise(graphStore.getAll()); + this.graphCache = new Map(); + graphRecords.forEach((record) => { + if (!record || typeof record.id !== 'string') return; + if (!isValidGraphSnapshot(record.data)) return; + this.graphCache.set(record.id, record.data); + }); + + const orderFromMeta = await this.getMetaValue(this.META_GRAPH_ORDER); + if (Array.isArray(orderFromMeta)) { + this.graphOrder = orderFromMeta.filter((id) => typeof id === 'string' && this.graphCache.has(id)); + this.graphCache.forEach((value, id) => { + if (!this.graphOrder.includes(id)) this.graphOrder.push(id); + }); + } else { + this.graphOrder = Array.from(this.graphCache.keys()); + } + + this.sessionCache = sanitizeSession(await this.getMetaValue(this.META_SESSION)); + this.authorNameCache = await this.getMetaValue(this.META_AUTHOR) || ''; + + await this.migrateLegacyLocalStorage(); + this.initialized = true; + } catch (e) { + this.setFallbackMode(); + this.initialized = true; + } + })(); + return this.initPromise; + }, + + ensureInitialized() { + if (!this.initPromise && !this.initialized) this.initialize(); + }, + + get(id) { + this.ensureInitialized(); + if (typeof id !== 'string' || !id) return null; + if (this.graphCache.has(id)) { + const graph = this.graphCache.get(id); + return isValidGraphSnapshot(graph) ? graph : null; + } + if (this.useFallback) return this.getLegacyGraph(id); + return null; + }, save(id, graphContent) { + this.ensureInitialized(); + if (typeof id !== 'string' || !id || !graphContent) return; this.addGraph(id); - if (!localStorageSet(id, encodeBase64(JSON.stringify(graphContent)))) { - const stripped = { ...graphContent, actionHistory: [] }; - localStorageSet(id, encodeBase64(JSON.stringify(stripped))); + this.graphCache.set(id, graphContent); + + if (this.useFallback) { + localStorageSet(id, encodeBase64(JSON.stringify(graphContent))); + return; } + + this.initialize().then(async () => { + if (this.useFallback) { + localStorageSet(id, encodeBase64(JSON.stringify(graphContent))); + return; + } + await this.putGraphRecord(id, graphContent); + }).catch(() => { + localStorageSet(id, encodeBase64(JSON.stringify(graphContent))); + }); }, remove(id) { - const list = this.getAllGraphs().filter((g) => g !== id); - localStorageSet(this.ALL_GRAPHS, encodeBase64(JSON.stringify(list))); - localStorageRemove(id); + this.ensureInitialized(); + if (typeof id !== 'string' || !id) return; + this.graphOrder = this.getAllGraphs().filter((g) => g !== id); + this.graphCache.delete(id); + if (this.useFallback) { + localStorageSet(this.ALL_GRAPHS, encodeBase64(JSON.stringify(this.graphOrder))); + localStorageRemove(id); + return; + } + this.initialize().then(async () => { + await this.persistGraphOrder(); + await this.deleteGraphRecord(id); + }).catch(() => { + localStorageSet(this.ALL_GRAPHS, encodeBase64(JSON.stringify(this.graphOrder))); + localStorageRemove(id); + }); }, addGraph(id) { + this.ensureInitialized(); + if (typeof id !== 'string' || !id) return; const list = this.getAllGraphs(); if (list.includes(id)) return; - list.push(id); - localStorageSet(this.ALL_GRAPHS, encodeBase64(JSON.stringify(list))); + this.graphOrder = [...list, id]; + if (this.useFallback) { + localStorageSet(this.ALL_GRAPHS, encodeBase64(JSON.stringify(this.graphOrder))); + return; + } + this.initialize().then(() => this.persistGraphOrder()).catch(() => { + localStorageSet(this.ALL_GRAPHS, encodeBase64(JSON.stringify(this.graphOrder))); + }); }, getAllGraphs() { - const raw = localStorageGet(this.ALL_GRAPHS); - if (!raw) return []; - const parsed = parseStoredJson(raw); - if (!Array.isArray(parsed)) { - localStorageSet(this.ALL_GRAPHS, encodeBase64(JSON.stringify([]))); - return []; + this.ensureInitialized(); + if (Array.isArray(this.graphOrder) && this.graphOrder.length) return this.graphOrder; + if (this.useFallback) { + const parsed = this.getLegacyGraphOrder(); + this.graphOrder = parsed; + return parsed; } - return parsed; + return this.graphOrder; }, addToFront(id) { + this.ensureInitialized(); + if (typeof id !== 'string' || !id) return; const list = this.getAllGraphs(); if (list.includes(id)) return; - list.unshift(id); - localStorageSet(this.ALL_GRAPHS, encodeBase64(JSON.stringify(list))); + this.graphOrder = [id, ...list]; + if (this.useFallback) { + localStorageSet(this.ALL_GRAPHS, encodeBase64(JSON.stringify(this.graphOrder))); + return; + } + this.initialize().then(() => this.persistGraphOrder()).catch(() => { + localStorageSet(this.ALL_GRAPHS, encodeBase64(JSON.stringify(this.graphOrder))); + }); }, getAuthorName() { - return localStorageGet(this.AUTHOR_NAME) || ''; + this.ensureInitialized(); + if (this.authorNameCache) return this.authorNameCache; + if (this.useFallback) { + this.authorNameCache = localStorageGet(this.AUTHOR_NAME) || ''; + } + return this.authorNameCache || ''; }, setAuthorName(authorName) { - localStorageSet(this.AUTHOR_NAME, authorName); + this.ensureInitialized(); + this.authorNameCache = authorName; + if (this.useFallback) { + localStorageSet(this.AUTHOR_NAME, authorName); + return; + } + this.initialize().then(() => this.putMetaValue(this.META_AUTHOR, authorName)).catch(() => { + localStorageSet(this.AUTHOR_NAME, authorName); + }); }, clearGraph(id) { - localStorageRemove(id); + this.ensureInitialized(); + if (typeof id !== 'string' || !id) return; + this.graphCache.delete(id); + if (this.useFallback) { + localStorageRemove(id); + return; + } + this.initialize().then(() => this.deleteGraphRecord(id)).catch(() => { + localStorageRemove(id); + }); }, getFileList() { return localStorageGet('fileList') || ''; }, + saveSession(sessionState) { + this.ensureInitialized(); + const safeSession = sanitizeSession(sessionState); + if (!safeSession) return; + this.sessionCache = safeSession; + if (this.useFallback) { + localStorageSet(this.SESSION_STATE, encodeBase64(JSON.stringify(safeSession))); + return; + } + this.initialize().then(() => this.putMetaValue(this.META_SESSION, safeSession)).catch(() => { + localStorageSet(this.SESSION_STATE, encodeBase64(JSON.stringify(safeSession))); + }); + }, + getSession() { + this.ensureInitialized(); + if (this.sessionCache) return this.sessionCache; + if (this.useFallback) { + this.sessionCache = this.getLegacySession(); + } + return this.sessionCache; + }, }; export default localStorageManager; diff --git a/src/graph-builder/local-storage-manager.test.js b/src/graph-builder/local-storage-manager.test.js new file mode 100644 index 0000000..77a0798 --- /dev/null +++ b/src/graph-builder/local-storage-manager.test.js @@ -0,0 +1,119 @@ +import { toast } from 'react-toastify'; +import { TextEncoder, TextDecoder } from 'util'; + +global.TextEncoder = TextEncoder; +global.TextDecoder = TextDecoder; + +jest.mock('react-toastify', () => ({ + toast: { + error: jest.fn(), + }, +})); + +const loadManager = async () => { + jest.resetModules(); + // eslint-disable-next-line global-require + const manager = require('./local-storage-manager').default; + await manager.initialize(); + return manager; +}; + +describe('localStorageManager fallback/session behavior', () => { + let originalIndexedDB; + + beforeEach(() => { + localStorage.clear(); + jest.clearAllMocks(); + originalIndexedDB = window.indexedDB; + Object.defineProperty(window, 'indexedDB', { + configurable: true, + writable: true, + value: undefined, + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'indexedDB', { + configurable: true, + writable: true, + value: originalIndexedDB, + }); + }); + + test('restores legacy graphs from localStorage when indexedDB is unavailable', async () => { + const allGraphsKey = window.btoa('ALL_GRAPHS'); + const graphID = 'graph-1'; + const graphPayload = { + id: graphID, + projectName: 'Workflow 1', + nodes: [], + edges: [], + actionHistory: [{ actionName: 'ADD_NODE', parameters: '{}' }], + }; + + localStorage.setItem(allGraphsKey, window.btoa(JSON.stringify([graphID]))); + localStorage.setItem(graphID, window.btoa(JSON.stringify(graphPayload))); + + const manager = await loadManager(); + + expect(manager.getAllGraphs()).toEqual([graphID]); + expect(manager.get(graphID)).toEqual(graphPayload); + }); + + test('persists and restores session metadata in fallback mode', async () => { + const manager = await loadManager(); + const session = { + openGraphIDs: ['g1', 'g2'], + activeGraphID: 'g2', + fileState: [{ key: 'demo/main.py', modified: 1, size: 12, fileName: 'main.py' }], + uploadedDirName: 'demo', + }; + + manager.saveSession(session); + + expect(manager.getSession()).toEqual(session); + }); + + test('keeps full actionHistory when saving graph content', async () => { + const manager = await loadManager(); + const graphID = 'graph-history'; + const graphPayload = { + id: graphID, + projectName: 'Workflow history', + nodes: [], + edges: [], + actionHistory: [ + { actionName: 'ADD_NODE', parameters: '{}' }, + { actionName: 'ADD_EDGE', parameters: '{}' }, + ], + }; + + manager.save(graphID, graphPayload); + + const stored = JSON.parse(window.atob(localStorage.getItem(graphID))); + expect(stored.actionHistory).toHaveLength(2); + expect(toast.error).not.toHaveBeenCalled(); + }); + + test('skips corrupted graph records and keeps valid records accessible', async () => { + const allGraphsKey = window.btoa('ALL_GRAPHS'); + const validGraphID = 'graph-valid'; + const corruptedGraphID = 'graph-corrupted'; + const validPayload = { + id: validGraphID, + projectName: 'Healthy workflow', + nodes: [], + edges: [], + actionHistory: [], + }; + + localStorage.setItem(allGraphsKey, window.btoa(JSON.stringify([validGraphID, corruptedGraphID]))); + localStorage.setItem(validGraphID, window.btoa(JSON.stringify(validPayload))); + localStorage.setItem(corruptedGraphID, window.btoa(JSON.stringify({ broken: true }))); + + const manager = await loadManager(); + + expect(manager.get(validGraphID)).toEqual(validPayload); + expect(manager.get(corruptedGraphID)).toBeNull(); + }); +}); diff --git a/src/reducer/reducer.js b/src/reducer/reducer.js index ebadbe3..4d8970f 100644 --- a/src/reducer/reducer.js +++ b/src/reducer/reducer.js @@ -110,8 +110,19 @@ const reducer = (state, action) => { }; } case T.ADD_GRAPH_BULK: { - const newGraphs = action.payload.map((g) => ({ ...initialGraphState, ...g })); - return { ...state, graphs: [...state.graphs, ...newGraphs], curGraphIndex: 0 }; + const payload = Array.isArray(action.payload) + ? { graphs: action.payload, activeGraphID: null } + : action.payload; + const newGraphs = payload.graphs.map((g) => ({ ...initialGraphState, ...g })); + const graphs = [...state.graphs, ...newGraphs]; + const activeGraphIndex = payload.activeGraphID + ? graphs.findIndex((g) => g.graphID === payload.activeGraphID) + : -1; + return { + ...state, + graphs, + curGraphIndex: activeGraphIndex >= 0 ? activeGraphIndex : 0, + }; } case T.SET_CUR_INSTANCE: { return { ...state, curGraphInstance: action.payload };