/** * public/prefs.ts — client for the cross-device Projects UI prefs (GET/PUT /prefs). * * Favourites + group collapse-state live server-side (`~/.web-terminal-prefs.json`) * so they follow the user from laptop to phone — the whole point of this app. * localStorage is kept as an OFFLINE MIRROR: writes go there synchronously so a * reload works while the server is briefly unreachable, and a device that had the * legacy per-device favourites (`web-terminal:proj-favs`) is migrated on first load. * * loadPrefs/savePrefs are the only surface projects.ts needs. Both are defensive: * a failed fetch degrades to the local mirror rather than throwing. */ import type { UiPrefs } from '../src/types.js' const LS_KEY = 'web-terminal:proj-prefs' const LEGACY_FAV_KEY = 'web-terminal:proj-favs' // pre-v0.6 per-device favourites export function emptyPrefs(): UiPrefs { return { favourites: [], collapsed: {} } } /** Coerce untrusted data (localStorage or a /prefs response) into a safe UiPrefs. */ export function sanitizePrefs(input: unknown): UiPrefs { if (input === null || typeof input !== 'object') return emptyPrefs() const o = input as Record const favourites: string[] = [] const seen = new Set() if (Array.isArray(o['favourites'])) { for (const v of o['favourites']) { if (typeof v !== 'string' || v.length === 0 || seen.has(v)) continue seen.add(v) favourites.push(v) } } const collapsed: Record = {} const c = o['collapsed'] if (c !== null && typeof c === 'object' && !Array.isArray(c)) { for (const [k, v] of Object.entries(c as Record)) { if (k.length > 0 && v === true) collapsed[k] = true } } return { favourites, collapsed } } function isEmpty(p: UiPrefs): boolean { return p.favourites.length === 0 && Object.keys(p.collapsed).length === 0 } /** Read the local mirror, migrating legacy per-device favourites when present. */ function readLocal(): UiPrefs { try { const raw = localStorage.getItem(LS_KEY) if (raw !== null) return sanitizePrefs(JSON.parse(raw)) } catch { // fall through to legacy / empty } try { const legacy = localStorage.getItem(LEGACY_FAV_KEY) if (legacy !== null) { const arr: unknown = JSON.parse(legacy) if (Array.isArray(arr)) return sanitizePrefs({ favourites: arr, collapsed: {} }) } } catch { // ignore — corrupt legacy data } return emptyPrefs() } function writeLocal(prefs: UiPrefs): void { try { localStorage.setItem(LS_KEY, JSON.stringify(prefs)) } catch { // localStorage unavailable — run without an offline mirror } } /** Load prefs: server is source of truth; fall back to the local mirror offline. * When the server is empty but this device has local/legacy prefs, seed the server. */ export async function loadPrefs(): Promise { const local = readLocal() try { const res = await fetch('/prefs') if (res.ok) { const server = sanitizePrefs(await res.json()) if (isEmpty(server) && !isEmpty(local)) { void savePrefs(local) // migrate this device's prefs up to the server return local } writeLocal(server) // refresh the offline mirror return server } } catch { // offline — use the local mirror } return local } /** Persist prefs: write the local mirror synchronously, then PUT best-effort. */ export async function savePrefs(prefs: UiPrefs): Promise { writeLocal(prefs) try { await fetch('/prefs', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(prefs), }) } catch { // offline — the local mirror keeps the change until the next successful PUT } }