/** * Per-recollection persistence: graph (Flux) and logos (BlockNote) state. * Single API for loading, saving, and removing all data for a recollection. */ import type { StoredGraphState } from '@/lib/graph/state' export type { StoredGraphState } /** BlockNote document: array of blocks (PartialBlock). Stored as JSON. */ export type StoredLogosContent = Record[] /** Id for a Logos page or subpage. */ export type LogosPageId = string /** Metadata for one page or subpage in the Logos hierarchy (one level: page → subpages). */ export type LogosPageMeta = { id: LogosPageId title: string /** null = top-level page; non-null = subpage under that page. */ parentId: LogosPageId | null /** Order among siblings (same parent). */ position: number } /** One entry in the render-output cache (one per rendering node, overwritten on each update). */ export type RenderOutputCacheEntry = { nodeId: string label: string type: 'image' | 'html' content: string /** Timestamp (ms) when this entry was last updated. */ updatedAt?: number } /** Returns a short "time since" string for display (e.g. "Just now", "2 min ago"). */ export function formatTimeSinceLastUpdate(updatedAt: number | undefined): string { if (updatedAt == null || typeof updatedAt !== 'number') return '' const delta = Date.now() - updatedAt if (delta < 15_000) return 'Just now' if (delta < 60_000) return `${Math.round(delta / 1000)}s ago` if (delta < 3600_000) return `${Math.round(delta / 60_000)} min ago` if (delta < 86400_000) return `${Math.round(delta / 3600_000)}h ago` return `${Math.round(delta / 86400_000)}d ago` } export const RECOLLECTION_FILE_EXT = '.zui.json' export const RECOLLECTION_VERSION = 1 const GRAPH_KEY_PREFIX = 'zui_graph_' const LOGOS_KEY_PREFIX = 'zui_logos_' const LOGOS_PAGE_TREE_PREFIX = 'zui_logos_pagetree_' const LOGOS_PAGE_CONTENT_PREFIX = 'zui_logos_page_' const RENDER_CACHE_KEY_PREFIX = 'zui_render_cache_' function getGraphKey(recollectionId: string): string { return `${GRAPH_KEY_PREFIX}${recollectionId}` } function getLogosKey(recollectionId: string): string { return `${LOGOS_KEY_PREFIX}${recollectionId}` } function getLogosPageTreeKey(recollectionId: string): string { return `${LOGOS_PAGE_TREE_PREFIX}${recollectionId}` } function getLogosPageContentKey(recollectionId: string, pageId: LogosPageId): string { return `${LOGOS_PAGE_CONTENT_PREFIX}${recollectionId}_${pageId}` } function getRenderCacheKey(recollectionId: string): string { return `${RENDER_CACHE_KEY_PREFIX}${recollectionId}` } export function getGraph(recollectionId: string): StoredGraphState | null { try { const raw = localStorage.getItem(getGraphKey(recollectionId)) if (!raw) return null const data = JSON.parse(raw) as unknown if ( !data || typeof data !== 'object' || !Array.isArray((data as StoredGraphState).nodes) || !Array.isArray((data as StoredGraphState).edges) ) return null return data as StoredGraphState } catch { return null } } export function setGraph(recollectionId: string, state: StoredGraphState): void { localStorage.setItem(getGraphKey(recollectionId), JSON.stringify(state)) } export function getLogosContent(recollectionId: string): StoredLogosContent | null { try { const raw = localStorage.getItem(getLogosKey(recollectionId)) if (!raw) return null const data = JSON.parse(raw) as unknown if (!Array.isArray(data)) return null if (!data.every((item) => item != null && typeof item === 'object')) return null return data as StoredLogosContent } catch { return null } } export function setLogosContent(recollectionId: string, content: StoredLogosContent): void { localStorage.setItem(getLogosKey(recollectionId), JSON.stringify(content)) } /** Logos page tree: ordered list of page metas (pages and subpages). */ export function getLogosPageTree(recollectionId: string): LogosPageMeta[] { try { const raw = localStorage.getItem(getLogosPageTreeKey(recollectionId)) if (!raw) return [] const data = JSON.parse(raw) as unknown if (!Array.isArray(data)) return [] return data.filter( (item): item is LogosPageMeta => item != null && typeof item === 'object' && typeof (item as LogosPageMeta).id === 'string' && typeof (item as LogosPageMeta).title === 'string' && ((item as LogosPageMeta).parentId === null || typeof (item as LogosPageMeta).parentId === 'string') && typeof (item as LogosPageMeta).position === 'number' ) as LogosPageMeta[] } catch { return [] } } export function setLogosPageTree(recollectionId: string, tree: LogosPageMeta[]): void { localStorage.setItem(getLogosPageTreeKey(recollectionId), JSON.stringify(tree)) } /** Per-page Logos content (for page/subpage hierarchy). */ export function getLogosContentForPage( recollectionId: string, pageId: LogosPageId ): StoredLogosContent | null { try { const raw = localStorage.getItem(getLogosPageContentKey(recollectionId, pageId)) if (!raw) return null const data = JSON.parse(raw) as unknown if (!Array.isArray(data)) return null if (!data.every((item) => item != null && typeof item === 'object')) return null return data as StoredLogosContent } catch { return null } } export function setLogosContentForPage( recollectionId: string, pageId: LogosPageId, content: StoredLogosContent ): void { localStorage.setItem(getLogosPageContentKey(recollectionId, pageId), JSON.stringify(content)) } /** Remove stored content for a single page (e.g. when deleting that page). */ export function removeLogosPageContent(recollectionId: string, pageId: LogosPageId): void { localStorage.removeItem(getLogosPageContentKey(recollectionId, pageId)) } /** Render output cache: one entry per rendering node (keyed by nodeId). Used by Logos "Insert from Flux" block. */ export function getRenderOutputCache(recollectionId: string): RenderOutputCacheEntry[] { try { const raw = localStorage.getItem(getRenderCacheKey(recollectionId)) if (!raw) return [] const data = JSON.parse(raw) as unknown if (!Array.isArray(data)) return [] return data.filter( (item): item is RenderOutputCacheEntry => item != null && typeof item === 'object' && typeof (item as RenderOutputCacheEntry).nodeId === 'string' && typeof (item as RenderOutputCacheEntry).label === 'string' && ((item as RenderOutputCacheEntry).type === 'image' || (item as RenderOutputCacheEntry).type === 'html') && typeof (item as RenderOutputCacheEntry).content === 'string' ) as RenderOutputCacheEntry[] } catch { return [] } } export function upsertRenderOutputEntry(recollectionId: string, entry: RenderOutputCacheEntry): void { const entries = getRenderOutputCache(recollectionId) const byNodeId = new Map(entries.map((e) => [e.nodeId, e])) byNodeId.set(entry.nodeId, entry) localStorage.setItem( getRenderCacheKey(recollectionId), JSON.stringify(Array.from(byNodeId.values())) ) } /** Removes both graph, logos (legacy + page tree + all per-page content), and render cache for the recollection. */ export function removeRecollectionData(recollectionId: string): void { localStorage.removeItem(getGraphKey(recollectionId)) localStorage.removeItem(getLogosKey(recollectionId)) const tree = getLogosPageTree(recollectionId) for (const page of tree) { removeLogosPageContent(recollectionId, page.id) } localStorage.removeItem(getLogosPageTreeKey(recollectionId)) localStorage.removeItem(getRenderCacheKey(recollectionId)) }