import type { NetworkModel } from './inpModel'; import type { BasemapType, Scenario, SimulationResult, UserGisLayer } from './types'; import type { CRS } from './crs'; import type { DisplayUnitMode } from './unitSystem'; import type { MapStyleSettings } from './StyleSettingsPanel'; // Legacy "GISPipe" prefix kept on purpose (here and in the gispipe_* localStorage // keys): renaming storage keys would drop every user's saved session/settings. const DATABASE_NAME = 'gispipe-session'; const DATABASE_VERSION = 1; const STORE_NAME = 'autosave'; const LATEST_KEY = 'latest'; const TAB_KEY_STORAGE_NAME = 'gispipe_tab_session_key'; // Autosave keeps one record per browser tab (see getTabSessionKey), so the // store is trimmed to the most recently-saved tabs to avoid growing forever // as tabs are opened and closed over time. const MAX_RETAINED_SNAPSHOTS = 10; export interface SessionSnapshot { version: 2; savedAt: string; fileName: string | null; networkModel: NetworkModel; crs: CRS; basemap: BasemapType; displayUnitMode: DisplayUnitMode; styleSettings: MapStyleSettings; isModified: boolean; // Full project state -- without these, restoring a session silently lost // every alternative scenario, its results, and any GIS reference layers. rawInp: string | null; scenarios: Scenario[]; activeScenarioId: string; wasmResult: SimulationResult | null; serverResult: SimulationResult | null; resultInpSnapshot: string | null; userLayers: UserGisLayer[]; } /** Pre-P0-8 snapshot shape, kept only so an old autosave record from a * previous session doesn't hard-fail to load; it restores with no * scenarios/results/layers rather than being discarded outright. */ interface LegacySessionSnapshotV1 { version: 1; savedAt: string; fileName: string | null; networkModel: NetworkModel; crs: CRS; basemap: BasemapType; displayUnitMode: DisplayUnitMode; styleSettings: MapStyleSettings; isModified: boolean; } type StoredSnapshot = (SessionSnapshot | LegacySessionSnapshotV1) & { id: string }; /** * A stable id for this browser TAB (sessionStorage, unlike localStorage/ * IndexedDB, is private per tab and survives reloads of that same tab but * not a fresh tab/window). Using it as the IndexedDB record key means * multiple tabs autosaving concurrently each get their own record instead of * clobbering a single shared 'latest' record. */ function getTabSessionKey(): string { try { let key = sessionStorage.getItem(TAB_KEY_STORAGE_NAME); if (!key) { key = `tab_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; sessionStorage.setItem(TAB_KEY_STORAGE_NAME, key); } return key; } catch { // sessionStorage unavailable (e.g. private-mode restrictions): fall back // to the old shared key rather than failing autosave entirely. return LATEST_KEY; } } function openSessionDatabase(): Promise { const { promise, resolve, reject } = Promise.withResolvers(); const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); request.onerror = () => reject(request.error ?? new Error('IndexedDB 열기 실패')); request.onupgradeneeded = () => { const database = request.result; if (!database.objectStoreNames.contains(STORE_NAME)) { database.createObjectStore(STORE_NAME, { keyPath: 'id' }); } }; request.onsuccess = () => resolve(request.result); return promise; } function requestToPromise(request: IDBRequest): Promise { const { promise, resolve, reject } = Promise.withResolvers(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error('IndexedDB 요청 실패')); return promise; } export async function saveSessionSnapshot(snapshot: SessionSnapshot): Promise { const database = await openSessionDatabase(); try { const key = getTabSessionKey(); const { promise, resolve, reject } = Promise.withResolvers(); const transaction = database.transaction(STORE_NAME, 'readwrite'); transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error ?? new Error('자동 저장 실패')); transaction.onabort = () => reject(transaction.error ?? new Error('자동 저장 중단')); const store = transaction.objectStore(STORE_NAME); const stored: StoredSnapshot = { ...snapshot, id: key }; store.put(stored); // Trim to the most recently-saved MAX_RETAINED_SNAPSHOTS records so // autosaving from many tabs over time doesn't grow the store forever. const allRequest = store.getAll(); allRequest.onsuccess = () => { const all = (allRequest.result as StoredSnapshot[]) || []; if (all.length > MAX_RETAINED_SNAPSHOTS) { const sorted = [...all].sort((a, b) => (a.savedAt < b.savedAt ? 1 : -1)); for (const stale of sorted.slice(MAX_RETAINED_SNAPSHOTS)) { store.delete(stale.id); } } }; await promise; } finally { database.close(); } } /** Exported for unit testing the v1 -> v2 migration without a real IndexedDB. */ export function normalizeStoredSnapshot(stored: StoredSnapshot): SessionSnapshot | null { if (!stored || !stored.networkModel) return null; if (stored.version === 2) { const { id: _id, ...snapshot } = stored; return snapshot; } if (stored.version === 1) { const { id: _id, ...legacy } = stored; return { ...legacy, version: 2, rawInp: null, scenarios: [], activeScenarioId: 'base', wasmResult: null, serverResult: null, resultInpSnapshot: null, userLayers: [], }; } return null; } export async function loadSessionSnapshot(): Promise { const database = await openSessionDatabase(); try { const key = getTabSessionKey(); const transaction = database.transaction(STORE_NAME, 'readonly'); const stored = (await requestToPromise( transaction.objectStore(STORE_NAME).get(key) )) as StoredSnapshot | undefined; if (!stored) return null; return normalizeStoredSnapshot(stored); } finally { database.close(); } } export async function clearSessionSnapshot(): Promise { const database = await openSessionDatabase(); try { const key = getTabSessionKey(); const { promise, resolve, reject } = Promise.withResolvers(); const transaction = database.transaction(STORE_NAME, 'readwrite'); transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error ?? new Error('자동 저장본 삭제 실패')); transaction.objectStore(STORE_NAME).delete(key); await promise; } finally { database.close(); } }