/** * Validates JSON structure of config files, handles corrupted files * gracefully (falling back to defaults), and provides a single */ import { getConfigFilePath } from "./config-dir"; // --- Validation helpers --- /** Check that a value is a non-null object */ function isObject(v: unknown): v is Record { return v !== null && typeof v === "object" && !Array.isArray(v); } /** Validate AppState JSON structure */ export function validateAppState(data: unknown): { valid: boolean; errors: string[]; } { const errors: string[] = []; if (!isObject(data)) { return { valid: false, errors: ["app-state.json is not an object"] }; } // settings if (data.settings !== undefined) { if (!isObject(data.settings)) { errors.push("settings must be an object"); } else { const s = data.settings as Record; if (s.theme !== undefined && typeof s.theme !== "string") errors.push("settings.theme must be a string"); if (s.fontSize !== undefined && typeof s.fontSize !== "number") errors.push("settings.fontSize must be a number"); if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number") errors.push("settings.playbackSpeed must be a number"); if (s.downloadPath !== undefined && typeof s.downloadPath !== "string") errors.push("settings.downloadPath must be a string"); } } // preferences if (data.preferences !== undefined) { if (!isObject(data.preferences)) { errors.push("preferences must be an object"); } else { const p = data.preferences as Record; if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean") errors.push("preferences.showExplicit must be a boolean"); if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean") errors.push("preferences.autoDownload must be a boolean"); } } // customTheme if (data.customTheme !== undefined && !isObject(data.customTheme)) { errors.push("customTheme must be an object"); } return { valid: errors.length === 0, errors }; } /** Validate feeds JSON structure */ export function validateFeeds(data: unknown): { valid: boolean; errors: string[]; } { const errors: string[] = []; if (!Array.isArray(data)) { return { valid: false, errors: ["feeds.json is not an array"] }; } for (let i = 0; i < data.length; i++) { const feed = data[i]; if (!isObject(feed)) { errors.push(`feeds[${i}] is not an object`); continue; } if (typeof feed.id !== "string") errors.push(`feeds[${i}].id must be a string`); if (!isObject(feed.podcast)) errors.push(`feeds[${i}].podcast must be an object`); if (!Array.isArray(feed.episodes)) errors.push(`feeds[${i}].episodes must be an array`); } return { valid: errors.length === 0, errors }; } /** Validate progress JSON structure */ export function validateProgress(data: unknown): { valid: boolean; errors: string[]; } { const errors: string[] = []; if (!isObject(data)) { return { valid: false, errors: ["progress.json is not an object"] }; } for (const [key, value] of Object.entries(data)) { if (!isObject(value)) { errors.push(`progress["${key}"] is not an object`); continue; } const p = value as Record; if (typeof p.episodeId !== "string") errors.push(`progress["${key}"].episodeId must be a string`); if (typeof p.position !== "number") errors.push(`progress["${key}"].position must be a number`); if (typeof p.duration !== "number") errors.push(`progress["${key}"].duration must be a number`); } return { valid: errors.length === 0, errors }; } // --- Safe config file reading --- /** * Safely read and validate a config file. * Returns the parsed data if valid, or null if the file is missing/corrupt. */ export async function safeReadConfigFile( filename: string, validator: (data: unknown) => { valid: boolean; errors: string[] }, ): Promise<{ data: T | null; errors: string[] }> { try { const filePath = getConfigFilePath(filename); const file = Bun.file(filePath); if (!(await file.exists())) { return { data: null, errors: [] }; } const text = await file.text(); let parsed: unknown; try { parsed = JSON.parse(text); } catch { return { data: null, errors: [`${filename}: invalid JSON`] }; } const result = validator(parsed); if (!result.valid) { return { data: null, errors: result.errors }; } return { data: parsed as T, errors: [] }; } catch (err) { return { data: null, errors: [`${filename}: ${String(err)}`] }; } }