151 lines
4.6 KiB
TypeScript
151 lines
4.6 KiB
TypeScript
/**
|
|
* 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<string, unknown> {
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<T>(
|
|
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)}`] };
|
|
}
|
|
}
|