/* 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/. */ /** * Saved filter presets — localStorage-backed catalog of named * `FilterRule[]` snapshots. Mirrors the `filter_presets` table from * the Tauri-side `filter.rs` engine: each preset stores a name, the * rule list, and the AND/OR combinator. Presets are surfaced in the * builder toolbar as a dropdown; clicking one replaces the current * filter state. * * Pure module — safe to import from tests (stubs storage when * `window.localStorage` is unavailable). Names are trimmed and * deduplicated by case-insensitive match, so re-saving a preset under * the same name overwrites it (matching the Rust ON CONFLICT behaviour). */ import { parseFilterRules, type Combinator, type FilterRule, } from './filter-rules.js'; const STORAGE_KEY = 'ifc-lite:search:saved-filters'; const MAX_ENTRIES = 50; const MAX_NAME_LEN = 80; export interface SavedFilterPreset { name: string; combinator: Combinator; rules: FilterRule[]; /** Wall-clock ms when this preset was last written. */ updatedAt: number; } interface StorageLike { getItem(key: string): string | null; setItem(key: string, value: string): void; removeItem(key: string): void; } function safeStorage(): StorageLike | null { try { const ls = (globalThis as typeof globalThis & { localStorage?: StorageLike }).localStorage; if (!ls) return null; const probe = `${STORAGE_KEY}:__probe__`; ls.setItem(probe, '1'); ls.removeItem(probe); return ls; } catch { return null; } } function readRaw(): SavedFilterPreset[] { const ls = safeStorage(); if (!ls) return []; const raw = ls.getItem(STORAGE_KEY); if (!raw) return []; try { const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return []; const out: SavedFilterPreset[] = []; for (const item of parsed) { if (typeof item !== 'object' || item === null) continue; const o = item as Record; const name = typeof o.name === 'string' ? o.name.trim() : ''; if (!name || name.length > MAX_NAME_LEN) continue; const combinator: Combinator = o.combinator === 'OR' ? 'OR' : 'AND'; const rules = parseFilterRules(o.rules); const updatedAt = typeof o.updatedAt === 'number' ? o.updatedAt : Date.now(); out.push({ name, combinator, rules, updatedAt }); } return out; } catch { ls.removeItem(STORAGE_KEY); return []; } } function writeRaw(list: SavedFilterPreset[]): void { const ls = safeStorage(); if (!ls) return; try { ls.setItem(STORAGE_KEY, JSON.stringify(list)); } catch { // Quota exceeded — swallow; the next save attempt may succeed. } } /** All saved presets, sorted by name (A→Z) for stable UI ordering. */ export function loadSavedFilters(): SavedFilterPreset[] { const list = readRaw(); list.sort((a, b) => a.name.localeCompare(b.name)); return list; } /** * Insert or update a preset by case-insensitive name match. Returns the * resulting full catalog (sorted) so callers can refresh UI without a * second read. */ export function saveFilter( name: string, combinator: Combinator, rules: readonly FilterRule[], ): SavedFilterPreset[] { const trimmed = name.trim(); if (!trimmed || trimmed.length > MAX_NAME_LEN) return loadSavedFilters(); const existing = readRaw(); const idx = existing.findIndex((p) => p.name.toLowerCase() === trimmed.toLowerCase()); const preset: SavedFilterPreset = { name: trimmed, combinator, // Defensive copy so callers can mutate their own array without // corrupting the saved list (they share references via parseFilterRules // on read, but write should snapshot). rules: rules.map((r) => ({ ...r }) as FilterRule), updatedAt: Date.now(), }; if (idx >= 0) existing[idx] = preset; else existing.unshift(preset); // Cap on size — newest survive when capacity overflows. const sortedByRecency = existing .slice() .sort((a, b) => b.updatedAt - a.updatedAt) .slice(0, MAX_ENTRIES); writeRaw(sortedByRecency); return loadSavedFilters(); } /** Delete a preset by exact (case-insensitive) name. Returns the new list. */ export function deleteSavedFilter(name: string): SavedFilterPreset[] { const trimmed = name.trim().toLowerCase(); if (!trimmed) return loadSavedFilters(); const existing = readRaw(); const next = existing.filter((p) => p.name.toLowerCase() !== trimmed); if (next.length === existing.length) return loadSavedFilters(); writeRaw(next); return loadSavedFilters(); } /** Wipe the entire catalog. */ export function clearSavedFilters(): void { const ls = safeStorage(); if (!ls) return; ls.removeItem(STORAGE_KEY); } export const __internal = { STORAGE_KEY, MAX_ENTRIES, MAX_NAME_LEN };