feat(backup): autosave backend, continuous snapshots, and Settings modal

Background autosave toggle plus whole-book snapshots (default <book>/Folio-Backups, configurable location, retention, interval) with a one-minute change check in the main process. New Settings modal (sidebar, Ctrl+, , File menu) exposes autosave, snapshots, folder picker, and Snapshot now.
This commit is contained in:
avi 2026-09-19 16:37:56 -05:00
commit 1413e73015
11 changed files with 682 additions and 14 deletions

View file

@ -0,0 +1,99 @@
// Standalone test for continuous snapshot logic (src/main/backup.ts).
// Run with: node tests/run-snapshots-test.mjs
import fs from "fs";
import os from "os";
import path from "path";
import { bundleForTest } from "./helpers/bundle.mjs";
const { mod: project, cleanupBundle: cleanupProj } = await bundleForTest({
name: "folio-snap-project",
entryPoints: ["src/main/project.ts"],
});
const { mod: chapters, cleanupBundle: cleanupChap } = await bundleForTest({
name: "folio-snap-chapters",
entryPoints: ["src/main/chapters.ts"],
});
const { mod: backup, cleanupBundle: cleanupBackup } = await bundleForTest({
name: "folio-snap-backup",
entryPoints: ["src/main/backup.ts"],
});
const { createBook } = project;
const { createChapter, setChapterContent } = chapters;
const {
bindBackupSettings,
getSnapshotRoot,
listSnapshots,
createSnapshot,
pruneSnapshots,
bookChangedSince,
SNAPSHOT_DIR_NAME,
} = backup;
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else { failures++; console.error("FAIL -", msg); }
}
// In-memory settings store (backup.ts stays Electron-free).
const settings = new Map();
bindBackupSettings({
get: (k, fb) => (settings.has(k) ? settings.get(k) : fb),
set: (k, v) => { settings.set(k, v); },
});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-snap-"));
createBook(dir, "Snap Book");
const c = createChapter(dir, "One");
setChapterContent(dir, c.id, "hello");
// default root = <book>/Folio-Backups
ok(getSnapshotRoot(dir) === path.join(dir, SNAPSHOT_DIR_NAME), "default snapshot root is per-book folder");
// snapshot creates a timestamped copy with content
const s1 = createSnapshot(dir, 20);
ok(typeof s1 === "string" && fs.existsSync(s1), "createSnapshot returns existing dir: " + s1);
ok(fs.existsSync(path.join(s1, "folio.json")), "snapshot contains folio.json");
ok(listSnapshots(dir).length === 1, "one snapshot listed");
// snapshots never nest: second snapshot must not contain the backup dir
await new Promise((r) => setTimeout(r, 5));
const s2 = createSnapshot(dir, 20);
ok(listSnapshots(dir).length === 2, "two snapshots listed");
ok(!fs.existsSync(path.join(s2, SNAPSHOT_DIR_NAME)), "snapshot does not contain nested backup dir");
// bookChangedSince: quiet -> false, touched -> true
ok(bookChangedSince(dir, Date.now()) === false, "no change since now");
setChapterContent(dir, c.id, "hello edited");
ok(bookChangedSince(dir, Date.now() - 1000) === true, "detects content change");
// pruning keeps newest N
await new Promise((r) => setTimeout(r, 5));
createSnapshot(dir, 20);
ok(listSnapshots(dir).length === 3, "three snapshots before prune");
pruneSnapshots(dir, 2);
const kept = listSnapshots(dir);
ok(kept.length === 2, "prune keeps newest 2");
ok(!fs.existsSync(s1), "oldest snapshot pruned");
// custom root honored
const custom = fs.mkdtempSync(path.join(os.tmpdir(), "folio-snaproot-"));
settings.set("snapshotLocation", custom);
ok(getSnapshotRoot(dir) === custom, "custom snapshot root honored");
const s3 = createSnapshot(dir, 20);
ok(s3.startsWith(custom), "snapshot lands in custom root");
settings.delete("snapshotLocation");
// missing dir -> null, no throw
ok(createSnapshot(path.join(dir, "nope"), 5) === null, "missing book returns null");
// cleanup
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(custom, { recursive: true, force: true });
cleanupProj();
cleanupChap();
cleanupBackup();
if (failures) { console.error(`${failures} snapshot check(s) FAILED`); process.exit(1); }
console.log("All snapshot checks passed.");