/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Persistence for user scripts via localStorage. * * Uses a versioned schema so future additions (tags, description, etc.) * can be migrated without data loss. */ /** Current schema version */ const SCHEMA_VERSION = 1; export interface SavedScript { id: string; name: string; code: string; createdAt: number; updatedAt: number; version: number; } /** Stored wrapper with schema version for migration */ interface StoredScripts { schemaVersion: number; scripts: SavedScript[]; } const STORAGE_KEY = 'ifc-lite-scripts'; /** Maximum scripts allowed (prevents storage exhaustion) */ const MAX_SCRIPTS = 500; /** Maximum code size per script in characters (~100KB) */ const MAX_SCRIPT_SIZE = 100_000; export function loadSavedScripts(): SavedScript[] { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return []; const parsed: unknown = JSON.parse(raw); // Handle legacy format (bare array without schema version) if (Array.isArray(parsed)) { return migrateFromLegacy(parsed); } // Versioned format — validate structure if ( typeof parsed === 'object' && parsed !== null && 'schemaVersion' in parsed && 'scripts' in parsed && Array.isArray((parsed as StoredScripts).scripts) ) { return (parsed as StoredScripts).scripts; } return []; } catch { return []; } } /** Migrate from the original unversioned format — discards corrupted entries */ function migrateFromLegacy(scripts: unknown[]): SavedScript[] { const migrated: SavedScript[] = []; for (const s of scripts) { if (s === null || typeof s !== 'object') continue; const script = s as Record; // Validate essential fields — discard garbage values from String()/Number() coercion const id = typeof script.id === 'string' && script.id.length > 0 ? script.id : crypto.randomUUID(); const name = typeof script.name === 'string' && script.name.length > 0 ? script.name : 'Untitled'; const code = typeof script.code === 'string' ? script.code : ''; const createdAt = typeof script.createdAt === 'number' && isFinite(script.createdAt) ? script.createdAt : Date.now(); const updatedAt = typeof script.updatedAt === 'number' && isFinite(script.updatedAt) ? script.updatedAt : Date.now(); migrated.push({ id, name, code, createdAt, updatedAt, version: SCHEMA_VERSION }); } // Save in new format saveScripts(migrated); return migrated; } export type SaveResult = | { ok: true } | { ok: false; reason: 'quota_exceeded' | 'serialization_error' | 'unknown'; message: string }; export function saveScripts(scripts: SavedScript[]): SaveResult { const stored: StoredScripts = { schemaVersion: SCHEMA_VERSION, scripts, }; try { const json = JSON.stringify(stored); localStorage.setItem(STORAGE_KEY, json); return { ok: true }; } catch (err: unknown) { if (err instanceof DOMException && err.name === 'QuotaExceededError') { console.warn('[Scripts] localStorage quota exceeded. Consider deleting unused scripts.'); return { ok: false, reason: 'quota_exceeded', message: 'Storage quota exceeded. Delete unused scripts to free space.' }; } if (err instanceof TypeError) { console.warn('[Scripts] Failed to serialize scripts:', err.message); return { ok: false, reason: 'serialization_error', message: err.message }; } console.warn('[Scripts] Failed to save scripts to localStorage'); return { ok: false, reason: 'unknown', message: String(err) }; } } /** Validate a script name — returns sanitized name or null if invalid */ export function validateScriptName(name: string): string | null { const trimmed = name.trim(); if (trimmed.length === 0) return null; if (trimmed.length > 100) return trimmed.slice(0, 100); return trimmed; } /** Check if adding another script is within limits */ export function canCreateScript(currentCount: number): boolean { return currentCount < MAX_SCRIPTS; } /** Check if script code is within size limits */ export function isScriptWithinSizeLimit(code: string): boolean { return code.length <= MAX_SCRIPT_SIZE; }