clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / apps / editor / src / local-documents.js
local-documents.js 7.7K
1export const RECENTS_KEY = 'diagram.zip:recents:v1'2export const LOCAL_DOCUMENT_PREFIX = 'diagram.zip:doc:v1:'3export const LEGACY_DRAFT_KEY = 'diagram.zip:draft:local:v2'4export const WRITE_CAPABILITY_PREFIX = 'diagram.zip:write:v1:'5export const MAX_RECENTS = 5067const LOCAL_ID = /^l_[0-9a-f]{8}$/89function isObject(value) {10 return value !== null && typeof value === 'object' && !Array.isArray(value)11}1213function bodyKey(localId) {14 return `${LOCAL_DOCUMENT_PREFIX}${localId}`15}1617function validState(value) {18 return isObject(value) && typeof value.type === 'string' && typeof value.source === 'string'19}2021function normalizeBody(value) {22 if (!isObject(value) || !validState(value.state)) return null23 return {24 state: value.state,25 detailsSource: typeof value.detailsSource === 'string' ? value.detailsSource : null,26 fileSnapshot: value.fileSnapshot ?? null,27 drafts: isObject(value.drafts) ? value.drafts : {},28 }29}3031function normalizeRecent(value) {32 if (!isObject(value) || typeof value.updatedAt !== 'number') return null33 if (value.kind === 'local' && LOCAL_ID.test(value.localId)) {34 return {35 kind: 'local', localId: value.localId, title: typeof value.title === 'string' ? value.title : '',36 type: typeof value.type === 'string' ? value.type : '', updatedAt: value.updatedAt,37 dirty: value.dirty !== false, bytes: Number.isFinite(value.bytes) ? Math.max(0, value.bytes) : 0,38 }39 }40 if (value.kind === 'alias' && typeof value.aliasId === 'string' && value.aliasId) {41 return {42 kind: 'alias', aliasId: value.aliasId, title: value.mode === 'locked' ? null : (typeof value.title === 'string' ? value.title : null),43 type: value.mode === 'locked' ? '' : (typeof value.type === 'string' ? value.type : ''), updatedAt: value.updatedAt,44 mode: value.mode === 'locked' ? 'locked' : 'open', bytes: 0,45 }46 }47 return null48}4950function parseRecents(storage, maxRecents = MAX_RECENTS) {51 try {52 const raw = storage.getItem(RECENTS_KEY)53 if (!raw) return []54 const parsed = JSON.parse(raw)55 const entries = Array.isArray(parsed) ? parsed : parsed?.v === 1 ? parsed.entries : null56 if (!Array.isArray(entries)) return []57 return entries.map(normalizeRecent).filter(Boolean).sort((a, b) => b.updatedAt - a.updatedAt).slice(0, maxRecents)58 } catch {59 return []60 }61}6263function createId(cryptoImpl) {64 const bytes = new Uint8Array(4)65 if (!cryptoImpl || typeof cryptoImpl.getRandomValues !== 'function') throw new Error('Secure randomness is unavailable.')66 cryptoImpl.getRandomValues(bytes)67 return `l_${Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')}`68}6970function isQuotaError(error) {71 return error?.name === 'QuotaExceededError' || error?.code === 22 || error?.code === 101472}7374export function createLocalDocuments({75 storage = globalThis.localStorage,76 cryptoImpl = globalThis.crypto,77 now = () => Date.now(),78 maxRecents = MAX_RECENTS,79} = {}) {80 if (!storage) throw new Error('Storage is unavailable.')8182 const recents = () => parseRecents(storage, maxRecents)83 const writeRecents = entries => storage.setItem(RECENTS_KEY, JSON.stringify({ v: 1, entries: entries.slice(0, maxRecents) }))84 const evictOne = currentLocalId => {85 const entries = recents()86 const candidate = entries.filter(entry => entry.kind === 'local' && entry.localId !== currentLocalId && entry.dirty === false)87 .sort((a, b) => a.updatedAt - b.updatedAt)[0]88 if (!candidate) return false89 try { storage.removeItem(bodyKey(candidate.localId)) } catch { /* best effort */ }90 try {91 writeRecents(entries.filter(entry => !(entry.kind === 'local' && entry.localId === candidate.localId)))92 return true93 } catch {94 return false95 }96 }97 const withQuotaRetry = (operation, currentLocalId = null) => {98 try { operation(); return true } catch (error) {99 if (!isQuotaError(error)) return false100 if (!evictOne(currentLocalId)) return false101 try { operation(); return true } catch { return false }102 }103 }104 const saveIndexEntry = (entry, currentLocalId) => withQuotaRetry(() => writeRecents([entry, ...recents().filter(item => !(105 (entry.kind === 'local' && item.kind === 'local' && item.localId === entry.localId)106 || (entry.kind === 'alias' && item.kind === 'alias' && item.aliasId === entry.aliasId)107 ))]), currentLocalId)108109 return {110 loadRecents: recents,111 upsertRecent(entry) {112 const normalized = normalizeRecent({ ...entry, updatedAt: entry.updatedAt ?? now() })113 if (!normalized) return false114 return saveIndexEntry(normalized, normalized.kind === 'local' ? normalized.localId : null)115 },116 touchRecent(identity, changes = {}) {117 const entries = recents()118 const found = entries.find(entry => identity.localId ? entry.kind === 'local' && entry.localId === identity.localId : entry.kind === 'alias' && entry.aliasId === identity.aliasId)119 if (!found) return false120 return saveIndexEntry(normalizeRecent({ ...found, ...changes, updatedAt: now() }), found.kind === 'local' ? found.localId : null)121 },122 saveLocalDocument(document, { localId = document.localId ?? createId(cryptoImpl), currentLocalId = localId } = {}) {123 if (!LOCAL_ID.test(localId)) throw new Error('Invalid local document ID.')124 const body = normalizeBody(document)125 if (!body) throw new Error('Invalid local document.')126 const serializedBody = JSON.stringify(body)127 const entry = normalizeRecent({128 kind: 'local',129 localId,130 title: document.title ?? body.state.meta?.title ?? '',131 type: body.state.type,132 dirty: document.dirty !== false,133 bytes: new TextEncoder().encode(serializedBody).byteLength,134 updatedAt: now(),135 })136 const ok = withQuotaRetry(() => storage.setItem(bodyKey(localId), serializedBody), currentLocalId)137 if (!ok) return null138 if (!saveIndexEntry(entry, currentLocalId)) return null139 return localId140 },141 loadLocalDocument(localId) {142 if (!LOCAL_ID.test(localId)) return null143 try { return normalizeBody(JSON.parse(storage.getItem(bodyKey(localId)) ?? 'null')) } catch { return null }144 },145 deleteLocalDocument(localId) {146 if (!LOCAL_ID.test(localId)) return false147 try { storage.removeItem(bodyKey(localId)) } catch { return false }148 try { writeRecents(recents().filter(entry => !(entry.kind === 'local' && entry.localId === localId))); return true } catch { return false }149 },150 removeRecent(identity) {151 const remaining = recents().filter(entry => identity.localId152 ? !(entry.kind === 'local' && entry.localId === identity.localId)153 : !(entry.kind === 'alias' && entry.aliasId === identity.aliasId))154 try { writeRecents(remaining); return true } catch { return false }155 },156 clearRecents() {157 const keys = new Set(recents().filter(entry => entry.kind === 'local').map(entry => bodyKey(entry.localId)))158 if (Number.isInteger(storage.length) && typeof storage.key === 'function') {159 for (let index = 0; index < storage.length; index++) {160 const key = storage.key(index)161 if (key?.startsWith(LOCAL_DOCUMENT_PREFIX)) keys.add(key)162 }163 }164 for (const key of keys) try { storage.removeItem(key) } catch { /* best effort */ }165 try { writeRecents([]); return true } catch { return false }166 },167 migrateLegacyDraft() {168 let raw169 try { raw = storage.getItem(LEGACY_DRAFT_KEY) } catch { return null }170 if (!raw) return null171 let draft172 try { draft = JSON.parse(raw) } catch { storage.removeItem(LEGACY_DRAFT_KEY); return null }173 const localId = this.saveLocalDocument(draft)174 if (!localId) return null175 storage.removeItem(LEGACY_DRAFT_KEY)176 return localId177 },178 }179}180181export const localDocumentBodyKey = bodyKey