From 23cef0658026fbdc0fea5c835daf0fb269ef09df Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 24 Aug 2026 21:27:04 -0500 Subject: [PATCH] refactor(persistence): extract shared JSON I/O into json-store.ts; add parent-dir creation --- src/main/json-store.ts | 32 ++++++++++++++++++++++++++++++++ src/main/project.ts | 32 +++++++++----------------------- src/main/recents.ts | 16 +++++++--------- src/main/settings.ts | 14 +++++--------- 4 files changed, 53 insertions(+), 41 deletions(-) create mode 100644 src/main/json-store.ts diff --git a/src/main/json-store.ts b/src/main/json-store.ts new file mode 100644 index 0000000..9b5a211 --- /dev/null +++ b/src/main/json-store.ts @@ -0,0 +1,32 @@ +// Tolerant JSON file primitives shared by the persistence modules +// (project/recents/settings). Deliberately Electron-free so test harnesses +// can bundle them alongside their consumers. +// +// Semantics every caller used to hand-roll: +// - read: missing/corrupt file resolves to null (caller picks the +// fallback shape: [], {}, or a thrown domain error) +// - write: pretty-printed UTF-8, parent directories created on demand. +import fs from "fs"; +import path from "path"; + +/** Parse a JSON file, or return null when it is missing or unreadable. */ +export function readJsonFile(file: string): T | null { + try { + const raw = fs.readFileSync(file, "utf-8"); + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +/** Read a JSON file expected to hold an array; anything else yields []. */ +export function readJsonArray(file: string): T[] { + const parsed = readJsonFile(file); + return Array.isArray(parsed) ? parsed : []; +} + +/** Pretty-write a JSON file, creating parent directories when needed. */ +export function writeJsonFile(file: string, value: unknown): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(value, null, 2), "utf-8"); +} diff --git a/src/main/project.ts b/src/main/project.ts index 24507e6..2407a7b 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -1,5 +1,6 @@ import fs from "fs"; import path from "path"; +import { readJsonFile, readJsonArray, writeJsonFile } from "./json-store.js"; export interface ChapterEntry { id: string; @@ -103,9 +104,10 @@ export function createBook(bookPath: string, title: string): BookMeta { } export function loadBook(bookPath: string): BookMeta { - const raw = fs.readFileSync(metaPath(bookPath), "utf-8"); - const data = JSON.parse(raw) as BookMeta; - if (data.app !== APP_ID) { + // Strict on purpose: a wrong app tag means "not a Folio book" and callers + // rely on the throw. This is domain validation, not generic JSON I/O. + const data = readJsonFile(metaPath(bookPath)); + if (!data || data.app !== APP_ID) { throw new Error( `Not a Folio book (no valid folio.json): ${bookPath}` ); @@ -117,38 +119,22 @@ export function loadBook(bookPath: string): BookMeta { } export function saveMeta(bookPath: string, meta: BookMeta): void { - fs.writeFileSync( - metaPath(bookPath), - JSON.stringify(meta, null, 2), - "utf-8" - ); -} - -function ensureParent(filePath: string): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); + writeJsonFile(metaPath(bookPath), meta); } export function listRecents(filePath: string): Recent[] { - try { - const raw = fs.readFileSync(filePath, "utf-8"); - const arr = JSON.parse(raw); - return Array.isArray(arr) ? (arr as Recent[]) : []; - } catch { - return []; - } + return readJsonArray(filePath); } export function addRecent(filePath: string, recent: Recent): Recent[] { const recents = listRecents(filePath).filter((r) => r.path !== recent.path); recents.unshift(recent); - ensureParent(filePath); - fs.writeFileSync(filePath, JSON.stringify(recents, null, 2), "utf-8"); + writeJsonFile(filePath, recents); return recents; } export function removeRecent(filePath: string, recentPath: string): Recent[] { const recents = listRecents(filePath).filter((r) => r.path !== recentPath); - ensureParent(filePath); - fs.writeFileSync(filePath, JSON.stringify(recents, null, 2), "utf-8"); + writeJsonFile(filePath, recents); return recents; } diff --git a/src/main/recents.ts b/src/main/recents.ts index 5666ca8..06b2496 100644 --- a/src/main/recents.ts +++ b/src/main/recents.ts @@ -1,13 +1,12 @@ import fs from "fs"; import path from "path"; import { app } from "electron"; +import { APP_ID, hasBook } from "./project.js"; import { - APP_ID, - hasBook, - listRecents as _listRecents, addRecent as _addRecent, removeRecent as _removeRecent, } from "./project.js"; +import { readJsonArray, writeJsonFile } from "./json-store.js"; import type { Recent } from "./project.js"; export type { Recent }; @@ -28,11 +27,10 @@ function legacyElectronRecentsFile(): string { } function loadRaw(file: string): Recent[] { - try { - return _listRecents(file).filter((r) => r && typeof r.path === "string"); - } catch { - return []; - } + // Tolerate corrupt files ([]), then drop entries without a path. + return readJsonArray(file).filter( + (r) => r && typeof r.path === "string" + ); } // One-time migration: fold entries from a stray ~/.config/Electron recents @@ -49,7 +47,7 @@ export function migrateLegacyRecents(): void { const known = new Set(current.map((r) => r.path)); const merged = [...current, ...incoming.filter((r) => !known.has(r.path))]; if (merged.length !== current.length) { - fs.writeFileSync(canonical, JSON.stringify(merged, null, 2), "utf-8"); + writeJsonFile(canonical, merged); } fs.rmSync(legacy, { force: true }); } catch { diff --git a/src/main/settings.ts b/src/main/settings.ts index 7a45d3f..35bae99 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -1,7 +1,7 @@ -import fs from "fs"; import path from "path"; import { app } from "electron"; import { APP_ID } from "./project.js"; +import { readJsonFile, writeJsonFile } from "./json-store.js"; // Tiny per-user settings store (a JSON file in the Electron userData dir). // Used for preferences that should survive restarts, such as the theme. @@ -12,13 +12,9 @@ let cache: Record | null = null; function load(): Record { if (cache) return cache; - try { - const raw = fs.readFileSync(SETTINGS_FILE(), "utf-8"); - const parsed = JSON.parse(raw); - cache = parsed && typeof parsed === "object" ? parsed : {}; - } catch { - cache = {}; - } + // Missing or corrupt file both mean "no settings yet" -> {}. + cache = + readJsonFile>(SETTINGS_FILE()) ?? {}; return cache as Record; } @@ -30,5 +26,5 @@ export function getSetting(key: string, fallback: T): T { export function setSetting(key: string, value: unknown): void { const settings = load(); settings[key] = value; - fs.writeFileSync(SETTINGS_FILE(), JSON.stringify(settings, null, 2), "utf-8"); + writeJsonFile(SETTINGS_FILE(), settings); } \ No newline at end of file