// localStorage layer for the export filter UI: working snapshot + named // profiles + active-profile pointer + UI-only collapse state. Strict parser // — any malformed value resets storage and returns null so the UI falls // back to group defaults. import { VIDEO_STATES, isVideoState, type VideoState, } from "../lib/availability"; import type { SearchMode } from "./urlState"; const KEY = "ytdlp-tb:export-filters"; const VERSION = 1; // Per-channel delta vs. group defaults. Storing the *override* (not the // resolved selection) means newly-added channels naturally inherit their // group's selectedByDefault without showing up as accidentally-unchecked. // // `query` is the JSON-stringified composite-search root (see // `common/lib/searchQuery.ts:stringifyRoot`). Persisted on profiles so a // saved profile carries both the filters AND the layered query. // // `mode` and `tracks` are vestigial — kept only to deserialize older v1 // payloads without spuriously wiping them. New writes don't set them. export type FilterSnapshot = { channels: { included: string[]; excluded: string[] }; nov?: boolean; // The "Search in" row (release 16): what a plain query — a "transcripts" // leaf — reads. Each is stored only off its default, so a profile saved // before the row existed reads transcripts and posts, as it always did. // notr — Transcripts unticked (the transcript cues are not read) // nop — Posts unticked (the social-post corpus; before the row this was // the Posts box in the Type row, and it also emptied a leaf of // scope "Posts" — since release 16 it does not: a leaf asked for by // name reads what it names) // lc — Live chat ticked (the live_chat track is read; off by default) notr?: boolean; nop?: boolean; lc?: boolean; nol?: boolean; naa?: boolean; nar?: boolean; // Availability states to KEEP. Absent => keep everything, which is also what // a profile saved before the state enum existed means once `nav`/`nd`/`nu` // below are folded in. Those three are legacy: still read (so saved profiles // migrate instead of resetting) but never written. av?: VideoState[]; nav?: boolean; nd?: boolean; nu?: boolean; // Inclusive upload-date bounds, "YYYYMMDD". Absent => unbounded on that end. dateFrom?: string; dateTo?: string; mode?: SearchMode; tracks?: string[]; // Curated tag ids selected (lib/curatedTags.ts), ORed. Absent => no tag // filter, which is what every profile saved before tags existed means. NOT // `tracks` above and not the yt-dlp keyword scope — see urlState's `tg`. tg?: string[]; query?: string; }; export type StoredState = { v: 1; working: FilterSnapshot; profiles: Record; activeProfileName: string | null; // UI-only: persistent collapse state per group id. Lives here rather than // inside snapshots so toggling a section never marks a profile dirty. collapsedGroups?: string[]; // UI-only: persistent collapse state for the whole filter section. // Absent => open (default). filtersCollapsed?: boolean; }; export function emptySnapshot(): FilterSnapshot { return { channels: { included: [], excluded: [] } }; } // Fold the legacy exclusion booleans into a kept-set. `nav` meant "exclude // available" when the taxonomy was available/unlisted/deleted and nothing else, // so it also drops `maybe_missing`: a video now flagged unconfirmed would have // counted as available for whoever saved the profile. export function statesFromLegacy(legacy: { nav?: boolean; nd?: boolean; nu?: boolean; }): Set { const keep = new Set(VIDEO_STATES); if (legacy.nav) { keep.delete("available"); keep.delete("maybe_missing"); } if (legacy.nd) keep.delete("deleted"); if (legacy.nu) keep.delete("unlisted"); return keep; } // The availability states a stored snapshot keeps. `av` wins; otherwise the // legacy booleans are migrated; a snapshot with neither keeps everything. export function snapshotStates( snapshot: FilterSnapshot | null | undefined, ): Set { if (snapshot?.av) return new Set(snapshot.av); return statesFromLegacy({ nav: snapshot?.nav, nd: snapshot?.nd, nu: snapshot?.nu, }); } // Write the kept-set onto a snapshot, omitting it when everything is kept so // the default profile stays byte-identical to one saved before this existed. export function writeSnapshotStates( snap: FilterSnapshot, states: ReadonlySet, ): void { if (VIDEO_STATES.every((s) => states.has(s))) return; snap.av = VIDEO_STATES.filter((s) => states.has(s)); } export function emptyStoredState(): StoredState { return { v: VERSION, working: emptySnapshot(), profiles: {}, activeProfileName: null, }; } function isStringArray(v: unknown): v is string[] { return Array.isArray(v) && v.every((x) => typeof x === "string"); } // Exported for the round-trip test; the app reads through loadStoredState. export function parseSnapshot(raw: unknown): FilterSnapshot | null { if (!raw || typeof raw !== "object") return null; const r = raw as Record; const ch = r.channels as Record | undefined; if (!ch || typeof ch !== "object") return null; if (!isStringArray(ch.included) || !isStringArray(ch.excluded)) return null; const snap: FilterSnapshot = { channels: { included: ch.included.slice(), excluded: ch.excluded.slice(), }, }; if (typeof r.nov === "boolean") snap.nov = r.nov; // `nop` and `nu` were written by committedSnapshot but never read back here, // so both were silently dropped on every reload. if (typeof r.nop === "boolean") snap.nop = r.nop; if (typeof r.notr === "boolean") snap.notr = r.notr; if (typeof r.lc === "boolean") snap.lc = r.lc; if (typeof r.nol === "boolean") snap.nol = r.nol; if (typeof r.naa === "boolean") snap.naa = r.naa; if (typeof r.nar === "boolean") snap.nar = r.nar; if (Array.isArray(r.av) && r.av.every(isVideoState)) snap.av = r.av.slice(); if (typeof r.nav === "boolean") snap.nav = r.nav; if (typeof r.nd === "boolean") snap.nd = r.nd; if (typeof r.nu === "boolean") snap.nu = r.nu; if (typeof r.dateFrom === "string" && /^\d{8}$/.test(r.dateFrom)) { snap.dateFrom = r.dateFrom; } if (typeof r.dateTo === "string" && /^\d{8}$/.test(r.dateTo)) { snap.dateTo = r.dateTo; } if (r.mode === "transcripts" || r.mode === "subs") snap.mode = r.mode; if (isStringArray(r.tracks)) snap.tracks = r.tracks.slice(); if (isStringArray(r.tg)) snap.tg = r.tg.slice(); if (typeof r.query === "string") snap.query = r.query; return snap; } function parseStored(raw: string): StoredState | null { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return null; } if (!parsed || typeof parsed !== "object") return null; const r = parsed as Record; if (r.v !== VERSION) return null; const working = parseSnapshot(r.working); if (!working) return null; const profilesRaw = r.profiles; if (!profilesRaw || typeof profilesRaw !== "object" || Array.isArray(profilesRaw)) { return null; } const profiles: Record = {}; for (const [name, snapRaw] of Object.entries( profilesRaw as Record, )) { if (!name) continue; const snap = parseSnapshot(snapRaw); if (!snap) return null; profiles[name] = snap; } const active = typeof r.activeProfileName === "string" && r.activeProfileName in profiles ? r.activeProfileName : null; const out: StoredState = { v: VERSION, working, profiles, activeProfileName: active, }; if (isStringArray(r.collapsedGroups)) { out.collapsedGroups = r.collapsedGroups.slice(); } if (typeof r.filtersCollapsed === "boolean") { out.filtersCollapsed = r.filtersCollapsed; } return out; } export function loadStoredState(): StoredState | null { if (typeof window === "undefined") return null; let raw: string | null = null; try { raw = window.localStorage.getItem(KEY); } catch { return null; } if (raw === null) return null; const parsed = parseStored(raw); if (!parsed) { // Reset on malformed payloads per spec. try { window.localStorage.removeItem(KEY); } catch { /* ignore */ } return null; } return parsed; } export function saveStoredState(next: StoredState): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(KEY, JSON.stringify(next)); } catch { /* quota/serialization errors are non-fatal */ } } export function clearStoredState(): void { if (typeof window === "undefined") return; try { window.localStorage.removeItem(KEY); } catch { /* ignore */ } } function sortedJson(arr: string[]): string { return JSON.stringify(arr.slice().sort()); } export function snapshotsEqual(a: FilterSnapshot, b: FilterSnapshot): boolean { if (sortedJson(a.channels.included) !== sortedJson(b.channels.included)) { return false; } if (sortedJson(a.channels.excluded) !== sortedJson(b.channels.excluded)) { return false; } if ((a.nov ?? false) !== (b.nov ?? false)) return false; if ((a.nop ?? false) !== (b.nop ?? false)) return false; if ((a.notr ?? false) !== (b.notr ?? false)) return false; if ((a.lc ?? false) !== (b.lc ?? false)) return false; if ((a.nol ?? false) !== (b.nol ?? false)) return false; if ((a.naa ?? false) !== (b.naa ?? false)) return false; if ((a.nar ?? false) !== (b.nar ?? false)) return false; // Compared through the resolved kept-set, so an `av` snapshot and the legacy // booleans it migrates from don't read as different profiles. if ( sortedJson([...snapshotStates(a)]) !== sortedJson([...snapshotStates(b)]) ) { return false; } if ((a.dateFrom ?? "") !== (b.dateFrom ?? "")) return false; if ((a.dateTo ?? "") !== (b.dateTo ?? "")) return false; if ((a.mode ?? "transcripts") !== (b.mode ?? "transcripts")) return false; if (sortedJson(a.tracks ?? []) !== sortedJson(b.tracks ?? [])) return false; if (sortedJson(a.tg ?? []) !== sortedJson(b.tg ?? [])) return false; if ((a.query ?? "") !== (b.query ?? "")) return false; return true; }