212 lines
7.5 KiB
TypeScript
212 lines
7.5 KiB
TypeScript
/**
|
|
* 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<string, unknown>[]
|
|
|
|
/** 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))
|
|
}
|