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 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<BookMeta>(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<Recent>(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;
}

View file

@ -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<Recent>(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 {

View file

@ -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<string, unknown> | null = null;
function load(): Record<string, unknown> {
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<Record<string, unknown>>(SETTINGS_FILE()) ?? {};
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 {
const settings = load();
settings[key] = value;
fs.writeFileSync(SETTINGS_FILE(), JSON.stringify(settings, null, 2), "utf-8");
writeJsonFile(SETTINGS_FILE(), settings);
}