refactor(persistence): extract shared JSON I/O into json-store.ts; add parent-dir creation

This commit is contained in:
avi 2026-08-24 21:27:04 -05:00
commit 23cef06580
4 changed files with 53 additions and 41 deletions

32
src/main/json-store.ts Normal file
View file

@ -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<T = unknown>(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<T = unknown>(file: string): T[] {
const parsed = readJsonFile<T[]>(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");
}

View file

@ -1,5 +1,6 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { readJsonFile, readJsonArray, writeJsonFile } from "./json-store.js";
export interface ChapterEntry { export interface ChapterEntry {
id: string; id: string;
@ -103,9 +104,10 @@ export function createBook(bookPath: string, title: string): BookMeta {
} }
export function loadBook(bookPath: string): BookMeta { export function loadBook(bookPath: string): BookMeta {
const raw = fs.readFileSync(metaPath(bookPath), "utf-8"); // Strict on purpose: a wrong app tag means "not a Folio book" and callers
const data = JSON.parse(raw) as BookMeta; // rely on the throw. This is domain validation, not generic JSON I/O.
if (data.app !== APP_ID) { const data = readJsonFile<BookMeta>(metaPath(bookPath));
if (!data || data.app !== APP_ID) {
throw new Error( throw new Error(
`Not a Folio book (no valid folio.json): ${bookPath}` `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 { export function saveMeta(bookPath: string, meta: BookMeta): void {
fs.writeFileSync( writeJsonFile(metaPath(bookPath), meta);
metaPath(bookPath),
JSON.stringify(meta, null, 2),
"utf-8"
);
}
function ensureParent(filePath: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
} }
export function listRecents(filePath: string): Recent[] { export function listRecents(filePath: string): Recent[] {
try { return readJsonArray<Recent>(filePath);
const raw = fs.readFileSync(filePath, "utf-8");
const arr = JSON.parse(raw);
return Array.isArray(arr) ? (arr as Recent[]) : [];
} catch {
return [];
}
} }
export function addRecent(filePath: string, recent: Recent): Recent[] { export function addRecent(filePath: string, recent: Recent): Recent[] {
const recents = listRecents(filePath).filter((r) => r.path !== recent.path); const recents = listRecents(filePath).filter((r) => r.path !== recent.path);
recents.unshift(recent); recents.unshift(recent);
ensureParent(filePath); writeJsonFile(filePath, recents);
fs.writeFileSync(filePath, JSON.stringify(recents, null, 2), "utf-8");
return recents; return recents;
} }
export function removeRecent(filePath: string, recentPath: string): Recent[] { export function removeRecent(filePath: string, recentPath: string): Recent[] {
const recents = listRecents(filePath).filter((r) => r.path !== recentPath); const recents = listRecents(filePath).filter((r) => r.path !== recentPath);
ensureParent(filePath); writeJsonFile(filePath, recents);
fs.writeFileSync(filePath, JSON.stringify(recents, null, 2), "utf-8");
return recents; return recents;
} }

View file

@ -1,13 +1,12 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { app } from "electron"; import { app } from "electron";
import { APP_ID, hasBook } from "./project.js";
import { import {
APP_ID,
hasBook,
listRecents as _listRecents,
addRecent as _addRecent, addRecent as _addRecent,
removeRecent as _removeRecent, removeRecent as _removeRecent,
} from "./project.js"; } from "./project.js";
import { readJsonArray, writeJsonFile } from "./json-store.js";
import type { Recent } from "./project.js"; import type { Recent } from "./project.js";
export type { Recent }; export type { Recent };
@ -28,11 +27,10 @@ function legacyElectronRecentsFile(): string {
} }
function loadRaw(file: string): Recent[] { function loadRaw(file: string): Recent[] {
try { // Tolerate corrupt files ([]), then drop entries without a path.
return _listRecents(file).filter((r) => r && typeof r.path === "string"); return readJsonArray<Recent>(file).filter(
} catch { (r) => r && typeof r.path === "string"
return []; );
}
} }
// One-time migration: fold entries from a stray ~/.config/Electron recents // 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 known = new Set(current.map((r) => r.path));
const merged = [...current, ...incoming.filter((r) => !known.has(r.path))]; const merged = [...current, ...incoming.filter((r) => !known.has(r.path))];
if (merged.length !== current.length) { if (merged.length !== current.length) {
fs.writeFileSync(canonical, JSON.stringify(merged, null, 2), "utf-8"); writeJsonFile(canonical, merged);
} }
fs.rmSync(legacy, { force: true }); fs.rmSync(legacy, { force: true });
} catch { } catch {

View file

@ -1,7 +1,7 @@
import fs from "fs";
import path from "path"; import path from "path";
import { app } from "electron"; import { app } from "electron";
import { APP_ID } from "./project.js"; 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). // Tiny per-user settings store (a JSON file in the Electron userData dir).
// Used for preferences that should survive restarts, such as the theme. // Used for preferences that should survive restarts, such as the theme.
@ -12,13 +12,9 @@ let cache: Record<string, unknown> | null = null;
function load(): Record<string, unknown> { function load(): Record<string, unknown> {
if (cache) return cache; if (cache) return cache;
try { // Missing or corrupt file both mean "no settings yet" -> {}.
const raw = fs.readFileSync(SETTINGS_FILE(), "utf-8"); cache =
const parsed = JSON.parse(raw); readJsonFile<Record<string, unknown>>(SETTINGS_FILE()) ?? {};
cache = parsed && typeof parsed === "object" ? parsed : {};
} catch {
cache = {};
}
return cache as Record<string, unknown>; return cache as Record<string, unknown>;
} }
@ -30,5 +26,5 @@ export function getSetting<T>(key: string, fallback: T): T {
export function setSetting(key: string, value: unknown): void { export function setSetting(key: string, value: unknown): void {
const settings = load(); const settings = load();
settings[key] = value; settings[key] = value;
fs.writeFileSync(SETTINGS_FILE(), JSON.stringify(settings, null, 2), "utf-8"); writeJsonFile(SETTINGS_FILE(), settings);
} }