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:
parent
341b89cd8a
commit
1413e73015
11 changed files with 682 additions and 14 deletions
|
|
@ -70,3 +70,132 @@ export function ensureRecoveryBackup(bookPath: string): string | null {
|
||||||
export function _resetSessionBackupGuard(): void {
|
export function _resetSessionBackupGuard(): void {
|
||||||
sessionBackedUp.clear();
|
sessionBackedUp.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Continuous snapshots -----------------------------------------------
|
||||||
|
// Unlike the migration recovery backup above (external location, once per
|
||||||
|
// session), snapshots are periodic copies of the whole book used as an
|
||||||
|
// ongoing safety net while writing. Defaults keep them next to the work:
|
||||||
|
//
|
||||||
|
// <book>/Folio-Backups/<bookName>-<stamp>/
|
||||||
|
//
|
||||||
|
// A custom root can be chosen in Settings; it then applies to every book.
|
||||||
|
// Only the newest `keep` snapshots are retained (oldest pruned).
|
||||||
|
|
||||||
|
export const SNAPSHOT_DIR_NAME = "Folio-Backups";
|
||||||
|
export const SNAPSHOT_KEEP_DEFAULT = 20;
|
||||||
|
|
||||||
|
export function getSnapshotRoot(bookPath?: string): string {
|
||||||
|
const custom = store ? store.get<string>("snapshotLocation", "") : "";
|
||||||
|
if (custom && custom.trim()) return path.resolve(custom.trim());
|
||||||
|
if (bookPath) return path.join(path.resolve(bookPath), SNAPSHOT_DIR_NAME);
|
||||||
|
return getRecoveryRoot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotPrefix(bookPath: string): string {
|
||||||
|
return `${path.basename(path.resolve(bookPath)) || "book"}-`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot directories for a book, oldest first.
|
||||||
|
export function listSnapshots(bookPath: string): string[] {
|
||||||
|
const root = getSnapshotRoot(bookPath);
|
||||||
|
const prefix = snapshotPrefix(bookPath);
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(root);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
.filter((e) => e.startsWith(prefix))
|
||||||
|
.sort()
|
||||||
|
.map((e) => path.join(root, e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when any file in the book (outside its own snapshot dir) changed
|
||||||
|
// after `sinceMs`. Lets the interval timer skip quiet periods instead of
|
||||||
|
// piling up identical copies.
|
||||||
|
export function bookChangedSince(bookPath: string, sinceMs: number): boolean {
|
||||||
|
const key = path.resolve(bookPath);
|
||||||
|
const snapRoot = path.join(key, SNAPSHOT_DIR_NAME) + path.sep;
|
||||||
|
const stack: string[] = [key];
|
||||||
|
while (stack.length) {
|
||||||
|
const cur = stack.pop() as string;
|
||||||
|
let stat: fs.Stats;
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(cur);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
if ((cur + path.sep).startsWith(snapRoot)) continue;
|
||||||
|
let kids: string[];
|
||||||
|
try {
|
||||||
|
kids = fs.readdirSync(cur);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const k of kids) stack.push(path.join(cur, k));
|
||||||
|
} else if (stat.mtimeMs > sinceMs) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneSnapshots(bookPath: string, keep: number): void {
|
||||||
|
const n = Number.isFinite(keep) && keep >= 0 ? Math.floor(keep) : SNAPSHOT_KEEP_DEFAULT;
|
||||||
|
const snaps = listSnapshots(bookPath);
|
||||||
|
const excess = snaps.length - n;
|
||||||
|
for (let i = 0; i < excess; i++) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(snaps[i], { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// best-effort pruning only; a leftover old snapshot is harmless
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the book to a new timestamped snapshot directory (excluding the
|
||||||
|
// snapshot dir itself so snapshots never nest). Returns the new directory,
|
||||||
|
// or null when the book path does not exist.
|
||||||
|
export function createSnapshot(bookPath: string, keep?: number): string | null {
|
||||||
|
const key = path.resolve(bookPath);
|
||||||
|
let stat: fs.Stats;
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!stat.isDirectory()) return null;
|
||||||
|
const root = getSnapshotRoot(bookPath);
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
let dest = path.join(root, `${snapshotPrefix(bookPath)}${stamp}`);
|
||||||
|
// Same-millisecond snapshots (e.g. double-clicked "Snapshot now") must not
|
||||||
|
// silently merge into one directory.
|
||||||
|
for (let n = 2; fs.existsSync(dest); n++) {
|
||||||
|
dest = path.join(root, `${snapshotPrefix(bookPath)}${stamp}-${n}`);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(root, { recursive: true });
|
||||||
|
try {
|
||||||
|
// Copy entry-by-entry (skipping the snapshot dir) instead of one cpSync
|
||||||
|
// of the book root: Node refuses to copy a directory into a subdirectory
|
||||||
|
// of itself even when a filter excludes the destination.
|
||||||
|
const entries = fs.readdirSync(key);
|
||||||
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e === SNAPSHOT_DIR_NAME) continue;
|
||||||
|
fs.cpSync(path.join(key, e), path.join(dest, e), { recursive: true });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// A half-copied directory is worse than none — it looks like a good
|
||||||
|
// snapshot. Remove the partial copy, then surface the failure.
|
||||||
|
try {
|
||||||
|
fs.rmSync(dest, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup only
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
pruneSnapshots(bookPath, keep ?? SNAPSHOT_KEEP_DEFAULT);
|
||||||
|
return dest;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,14 @@ import {
|
||||||
import { combineMarkdown, buildZip } from "./export.js";
|
import { combineMarkdown, buildZip } from "./export.js";
|
||||||
import {
|
import {
|
||||||
bindBackupSettings,
|
bindBackupSettings,
|
||||||
|
bookChangedSince,
|
||||||
|
createSnapshot,
|
||||||
getRecoveryRoot,
|
getRecoveryRoot,
|
||||||
|
getSnapshotRoot,
|
||||||
|
listSnapshots,
|
||||||
|
pruneSnapshots,
|
||||||
setRecoveryRoot,
|
setRecoveryRoot,
|
||||||
|
SNAPSHOT_KEEP_DEFAULT,
|
||||||
} from "./backup.js";
|
} from "./backup.js";
|
||||||
import { sanitizeBookFolderName } from "./ids.js";
|
import { sanitizeBookFolderName } from "./ids.js";
|
||||||
import { buildDocx, buildOdt } from "./office-export.js";
|
import { buildDocx, buildOdt } from "./office-export.js";
|
||||||
|
|
@ -752,6 +758,118 @@ handleIpc("folio:setBackupLocation", (_e, loc: unknown) => {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Autosave + continuous snapshots --------------------------------------
|
||||||
|
// Settings keys (all in the shared settings store, defaults = on / sane):
|
||||||
|
// autosaveEnabled background autosave while typing (default true)
|
||||||
|
// snapshotsEnabled periodic whole-book snapshots (default true)
|
||||||
|
// snapshotIntervalMin minutes between snapshot checks (default 5)
|
||||||
|
// snapshotKeep how many recent snapshots to retain (default 20)
|
||||||
|
// snapshotLocation custom snapshot root, "" = <book>/Folio-Backups
|
||||||
|
const SNAPSHOT_DEFAULTS = {
|
||||||
|
autosaveEnabled: true,
|
||||||
|
snapshotsEnabled: true,
|
||||||
|
intervalMin: 5,
|
||||||
|
keep: SNAPSHOT_KEEP_DEFAULT,
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampInt(v: unknown, fallback: number, min: number, max: number): number {
|
||||||
|
const n = typeof v === "number" ? Math.round(v) : fallback;
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.max(min, Math.min(max, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotSettingsSnapshot() {
|
||||||
|
const location = getSetting<string>("snapshotLocation", "");
|
||||||
|
const resolved = bookPath ? getSnapshotRoot(bookPath) : getSnapshotRoot();
|
||||||
|
const snaps = bookPath ? listSnapshots(bookPath) : [];
|
||||||
|
return {
|
||||||
|
autosaveEnabled: getSetting<boolean>("autosaveEnabled", SNAPSHOT_DEFAULTS.autosaveEnabled),
|
||||||
|
snapshotsEnabled: getSetting<boolean>("snapshotsEnabled", SNAPSHOT_DEFAULTS.snapshotsEnabled),
|
||||||
|
intervalMin: clampInt(getSetting("snapshotIntervalMin", SNAPSHOT_DEFAULTS.intervalMin), SNAPSHOT_DEFAULTS.intervalMin, 1, 1440),
|
||||||
|
keep: clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200),
|
||||||
|
location,
|
||||||
|
resolvedRoot: resolved,
|
||||||
|
snapshotCount: snaps.length,
|
||||||
|
lastSnapshot: snaps.length ? snaps[snaps.length - 1] : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
handleIpc("folio:getAutosave", () =>
|
||||||
|
getSetting<boolean>("autosaveEnabled", SNAPSHOT_DEFAULTS.autosaveEnabled)
|
||||||
|
);
|
||||||
|
handleIpc("folio:setAutosave", (_e, on: unknown) => {
|
||||||
|
setSetting("autosaveEnabled", on !== false);
|
||||||
|
return { ok: true, enabled: on !== false };
|
||||||
|
});
|
||||||
|
handleIpc("folio:getSnapshotSettings", () => snapshotSettingsSnapshot());
|
||||||
|
handleIpc("folio:setSnapshotSettings", (_e, patch: unknown) => {
|
||||||
|
const p = (patch && typeof patch === "object" ? patch : {}) as Record<string, unknown>;
|
||||||
|
if ("snapshotsEnabled" in p) setSetting("snapshotsEnabled", p.snapshotsEnabled !== false);
|
||||||
|
if ("intervalMin" in p) {
|
||||||
|
setSetting("snapshotIntervalMin", clampInt(p.intervalMin, SNAPSHOT_DEFAULTS.intervalMin, 1, 1440));
|
||||||
|
}
|
||||||
|
if ("keep" in p) {
|
||||||
|
const keep = clampInt(p.keep, SNAPSHOT_DEFAULTS.keep, 0, 200);
|
||||||
|
setSetting("snapshotKeep", keep);
|
||||||
|
// Apply a lowered retention immediately to the open book.
|
||||||
|
if (bookPath) {
|
||||||
|
try { pruneSnapshots(bookPath, keep); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ("location" in p) {
|
||||||
|
const s = typeof p.location === "string" ? p.location.trim() : "";
|
||||||
|
setSetting("snapshotLocation", s);
|
||||||
|
}
|
||||||
|
return { ok: true, settings: snapshotSettingsSnapshot() };
|
||||||
|
});
|
||||||
|
handleIpc("folio:pickSnapshotFolder", async () => {
|
||||||
|
const { canceled, filePaths } = await showOpenDialog({
|
||||||
|
properties: ["openDirectory"],
|
||||||
|
title: "Choose snapshot folder",
|
||||||
|
});
|
||||||
|
return { canceled, filePaths };
|
||||||
|
});
|
||||||
|
// Interval-snapshot bookkeeping (declared before use by the handlers below).
|
||||||
|
let snapshotLastRunMs = 0;
|
||||||
|
let snapshotLastSnapMs = 0;
|
||||||
|
let snapshotBook: string | null = null;
|
||||||
|
handleIpc("folio:snapshotNow", () => {
|
||||||
|
const bp = requireBook();
|
||||||
|
const keep = clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200);
|
||||||
|
const dest = createSnapshot(bp, keep);
|
||||||
|
snapshotLastSnapMs = Date.now();
|
||||||
|
return { ok: true, path: dest, settings: snapshotSettingsSnapshot() };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Interval snapshots: once a minute, check whether the open book changed
|
||||||
|
// since the last snapshot and whether the configured interval elapsed. The
|
||||||
|
// 1-minute tick keeps wakeups cheap; the user-facing cadence is intervalMin.
|
||||||
|
setInterval(() => {
|
||||||
|
try {
|
||||||
|
if (!bookPath) return;
|
||||||
|
if (snapshotBook !== bookPath) {
|
||||||
|
snapshotBook = bookPath;
|
||||||
|
snapshotLastSnapMs = Date.now();
|
||||||
|
snapshotLastRunMs = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (getSetting<boolean>("snapshotsEnabled", SNAPSHOT_DEFAULTS.snapshotsEnabled) !== true) return;
|
||||||
|
const intervalMin = clampInt(
|
||||||
|
getSetting("snapshotIntervalMin", SNAPSHOT_DEFAULTS.intervalMin),
|
||||||
|
SNAPSHOT_DEFAULTS.intervalMin, 1, 1440
|
||||||
|
);
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - snapshotLastRunMs < intervalMin * 60_000) return;
|
||||||
|
snapshotLastRunMs = now;
|
||||||
|
if (!bookChangedSince(bookPath, snapshotLastSnapMs)) return;
|
||||||
|
const keep = clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200);
|
||||||
|
createSnapshot(bookPath, keep);
|
||||||
|
snapshotLastSnapMs = now;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[folio] interval snapshot failed:", (err as Error).message);
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
handleIpc("folio:importAttachment", async () => {
|
handleIpc("folio:importAttachment", async () => {
|
||||||
const bp = requireBook();
|
const bp = requireBook();
|
||||||
const { canceled, filePaths } = await showOpenDialog({
|
const { canceled, filePaths } = await showOpenDialog({
|
||||||
|
|
|
||||||
|
|
@ -75,18 +75,9 @@ export function buildMenu(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Backup Location…",
|
label: "Settings…",
|
||||||
click: async () => {
|
accelerator: "CmdOrCtrl+,",
|
||||||
const win = getWindow();
|
click: () => getWindow()?.webContents.send("folio:settings-requested"),
|
||||||
const opts = {
|
|
||||||
properties: ["openDirectory" as const],
|
|
||||||
title: "Choose recovery backup folder",
|
|
||||||
};
|
|
||||||
const { canceled, filePaths } = win
|
|
||||||
? await dialog.showOpenDialog(win, opts)
|
|
||||||
: await dialog.showOpenDialog(opts);
|
|
||||||
if (!canceled && filePaths[0]) setRecoveryRoot(filePaths[0]);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,15 @@ contextBridge.exposeInMainWorld("folio", {
|
||||||
setBackupLocation: (loc: string) => ipcRenderer.invoke("folio:setBackupLocation", loc),
|
setBackupLocation: (loc: string) => ipcRenderer.invoke("folio:setBackupLocation", loc),
|
||||||
pickBackupFolder: () => ipcRenderer.invoke("folio:pickBackupFolder"),
|
pickBackupFolder: () => ipcRenderer.invoke("folio:pickBackupFolder"),
|
||||||
|
|
||||||
|
// autosave + continuous snapshots
|
||||||
|
getAutosave: () => ipcRenderer.invoke("folio:getAutosave"),
|
||||||
|
setAutosave: (on: boolean) => ipcRenderer.invoke("folio:setAutosave", on),
|
||||||
|
getSnapshotSettings: () => ipcRenderer.invoke("folio:getSnapshotSettings"),
|
||||||
|
setSnapshotSettings: (patch: unknown) =>
|
||||||
|
ipcRenderer.invoke("folio:setSnapshotSettings", patch),
|
||||||
|
pickSnapshotFolder: () => ipcRenderer.invoke("folio:pickSnapshotFolder"),
|
||||||
|
snapshotNow: () => ipcRenderer.invoke("folio:snapshotNow"),
|
||||||
|
|
||||||
// events
|
// events
|
||||||
onBookOpened: (cb: (data: { path: string; title: string }) => void) =>
|
onBookOpened: (cb: (data: { path: string; title: string }) => void) =>
|
||||||
ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)),
|
ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)),
|
||||||
|
|
@ -120,4 +129,6 @@ contextBridge.exposeInMainWorld("folio", {
|
||||||
ipcRenderer.on("folio:open-markdown-requested", () => cb()),
|
ipcRenderer.on("folio:open-markdown-requested", () => cb()),
|
||||||
onCheckUpdateRequested: (cb: () => void) =>
|
onCheckUpdateRequested: (cb: () => void) =>
|
||||||
ipcRenderer.on("folio:check-update-requested", () => cb()),
|
ipcRenderer.on("folio:check-update-requested", () => cb()),
|
||||||
|
onSettingsRequested: (cb: () => void) =>
|
||||||
|
ipcRenderer.on("folio:settings-requested", () => cb()),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ export interface EditorFeature {
|
||||||
saveAndNotify(): Promise<void>;
|
saveAndNotify(): Promise<void>;
|
||||||
flushAutosave(): Promise<void>;
|
flushAutosave(): Promise<void>;
|
||||||
clearTimers(): void;
|
clearTimers(): void;
|
||||||
|
setAutosaveEnabled(on: boolean): void;
|
||||||
|
setTypewriterEnabled(on: boolean): void;
|
||||||
|
isTypewriterEnabled(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
||||||
|
|
@ -63,6 +66,19 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
||||||
const AUTOSAVE_MS = 1000;
|
const AUTOSAVE_MS = 1000;
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
const saveStateEl = $("saveState");
|
const saveStateEl = $("saveState");
|
||||||
|
// Background autosave switch (Settings). When off, typing only marks the
|
||||||
|
// chapter Unsaved; explicit Save, chapter switches, and app-hide flushes
|
||||||
|
// still persist so work is never silently lost.
|
||||||
|
let autosaveOn = true;
|
||||||
|
void api.getAutosave().then(
|
||||||
|
(v) => { autosaveOn = v !== false; },
|
||||||
|
() => { /* default on when the backend is unreachable */ }
|
||||||
|
);
|
||||||
|
function setAutosaveEnabled(on: boolean) {
|
||||||
|
autosaveOn = on !== false;
|
||||||
|
if (autosaveOn && state.selectedId && state.dirty) scheduleAutosave();
|
||||||
|
else if (!autosaveOn && saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
function clearTimers() {
|
function clearTimers() {
|
||||||
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
|
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
|
||||||
|
|
@ -153,9 +169,15 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
||||||
|
|
||||||
// Schedule a background save a short time after the last edit. The indicator
|
// Schedule a background save a short time after the last edit. The indicator
|
||||||
// shows "Saving…" while the debounce is pending so the user knows the chapter
|
// shows "Saving…" while the debounce is pending so the user knows the chapter
|
||||||
// is about to be persisted automatically.
|
// is about to be persisted automatically. With background autosave off this
|
||||||
|
// only marks the chapter Unsaved until the user saves explicitly.
|
||||||
function scheduleAutosave() {
|
function scheduleAutosave() {
|
||||||
clearTimers();
|
clearTimers();
|
||||||
|
if (!autosaveOn) {
|
||||||
|
saveStateEl.textContent = "Unsaved";
|
||||||
|
saveStateEl.classList.add("unsaved");
|
||||||
|
return;
|
||||||
|
}
|
||||||
saveStateEl.textContent = "Saving…";
|
saveStateEl.textContent = "Saving…";
|
||||||
saveStateEl.classList.remove("unsaved");
|
saveStateEl.classList.remove("unsaved");
|
||||||
saveTimer = setTimeout(() => void flushAutosave(), AUTOSAVE_MS);
|
saveTimer = setTimeout(() => void flushAutosave(), AUTOSAVE_MS);
|
||||||
|
|
@ -174,7 +196,7 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
||||||
const FORCED_AUTOSAVE_MS = 30_000;
|
const FORCED_AUTOSAVE_MS = 30_000;
|
||||||
let forcedSaveTimer: ReturnType<typeof setInterval> | null = null;
|
let forcedSaveTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
function startForcedAutosave() {
|
function startForcedAutosave() {
|
||||||
if (forcedSaveTimer) return;
|
if (forcedSaveTimer || !autosaveOn) return;
|
||||||
forcedSaveTimer = setInterval(() => {
|
forcedSaveTimer = setInterval(() => {
|
||||||
if (state.selectedId && state.dirty) void flushAutosave();
|
if (state.selectedId && state.dirty) void flushAutosave();
|
||||||
}, FORCED_AUTOSAVE_MS);
|
}, FORCED_AUTOSAVE_MS);
|
||||||
|
|
@ -359,5 +381,8 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
||||||
saveAndNotify,
|
saveAndNotify,
|
||||||
flushAutosave,
|
flushAutosave,
|
||||||
clearTimers,
|
clearTimers,
|
||||||
|
setAutosaveEnabled,
|
||||||
|
setTypewriterEnabled,
|
||||||
|
isTypewriterEnabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { createChapterListFeature } from "./chapter-list.js";
|
||||||
import { createWelcomeFeature } from "./welcome.js";
|
import { createWelcomeFeature } from "./welcome.js";
|
||||||
import { createExportsFeature } from "./exports.js";
|
import { createExportsFeature } from "./exports.js";
|
||||||
import { createCommentsFeature } from "./comments.js";
|
import { createCommentsFeature } from "./comments.js";
|
||||||
|
import { createSettingsFeature } from "./settings.js";
|
||||||
import {
|
import {
|
||||||
$,
|
$,
|
||||||
basename,
|
basename,
|
||||||
|
|
@ -60,6 +61,13 @@ export function initBook(api: FolioAPI) {
|
||||||
hooks.saveAndNotify = ed.saveAndNotify;
|
hooks.saveAndNotify = ed.saveAndNotify;
|
||||||
hooks.flushAutosave = ed.flushAutosave;
|
hooks.flushAutosave = ed.flushAutosave;
|
||||||
hooks.clearTimers = ed.clearTimers;
|
hooks.clearTimers = ed.clearTimers;
|
||||||
|
hooks.setAutosaveEnabled = ed.setAutosaveEnabled;
|
||||||
|
hooks.setTypewriterEnabled = ed.setTypewriterEnabled;
|
||||||
|
hooks.isTypewriterEnabled = ed.isTypewriterEnabled;
|
||||||
|
|
||||||
|
// Feature: settings modal (autosave + snapshot backups).
|
||||||
|
const settings = createSettingsFeature(ctx);
|
||||||
|
hooks.openSettings = settings.openSettings;
|
||||||
|
|
||||||
// Feature: formatting toolbar + font controls.
|
// Feature: formatting toolbar + font controls.
|
||||||
createToolbarFeature(ctx, editor);
|
createToolbarFeature(ctx, editor);
|
||||||
|
|
|
||||||
179
src/renderer/book/settings.ts
Normal file
179
src/renderer/book/settings.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
// Settings: autosave + continuous snapshot backups.
|
||||||
|
//
|
||||||
|
// Owns the Settings modal (#settingsModal): background-autosave switch,
|
||||||
|
// snapshot cadence/retention/location, and a manual "Snapshot now" button.
|
||||||
|
// Persisted through the main-process settings store; the editor's background
|
||||||
|
// engine is toggled live via hooks.setAutosaveEnabled. Snapshot cadence runs
|
||||||
|
// in the main process (1-minute tick), so this module only edits preferences.
|
||||||
|
//
|
||||||
|
// Cross-feature calls go through BookHooks exclusively — no sibling imports.
|
||||||
|
|
||||||
|
import type { BookCtx } from "./types.js";
|
||||||
|
import { alertMessage } from "../dialog.js";
|
||||||
|
import { $, closeEl, openEl, showToast } from "./ui.js";
|
||||||
|
|
||||||
|
export interface SettingsFeature {
|
||||||
|
openSettings(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnapshotSettings {
|
||||||
|
autosaveEnabled: boolean;
|
||||||
|
snapshotsEnabled: boolean;
|
||||||
|
intervalMin: number;
|
||||||
|
keep: number;
|
||||||
|
location: string;
|
||||||
|
resolvedRoot: string;
|
||||||
|
snapshotCount: number;
|
||||||
|
lastSnapshot: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSettingsFeature(ctx: BookCtx): SettingsFeature {
|
||||||
|
const { api, hooks } = ctx;
|
||||||
|
|
||||||
|
const modal = $("settingsModal") as HTMLDivElement;
|
||||||
|
const autosaveCb = $("settingAutosave") as HTMLInputElement;
|
||||||
|
const snapsCb = $("settingSnapshots") as HTMLInputElement;
|
||||||
|
const spellCb = $("settingSpellcheck") as HTMLInputElement;
|
||||||
|
const typewriterCb = $("settingTypewriter") as HTMLInputElement;
|
||||||
|
const intervalInput = $("settingInterval") as HTMLInputElement;
|
||||||
|
const keepInput = $("settingKeep") as HTMLInputElement;
|
||||||
|
const locationInput = $("settingLocation") as HTMLInputElement;
|
||||||
|
const resolvedEl = $("settingResolved") as HTMLElement;
|
||||||
|
const lastSnapEl = $("settingLastSnap") as HTMLElement;
|
||||||
|
const closeBtn = $("settingsClose") as HTMLButtonElement;
|
||||||
|
const browseBtn = $("settingBrowse") as HTMLButtonElement;
|
||||||
|
const resetBtn = $("settingResetLoc") as HTMLButtonElement;
|
||||||
|
const snapNowBtn = $("settingSnapNow") as HTMLButtonElement;
|
||||||
|
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
function shortPath(p: string): string {
|
||||||
|
const home = (window as unknown as { __folioHome?: string }).__folioHome;
|
||||||
|
if (home && p.startsWith(home)) return "~" + p.slice(home.length);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(s: SnapshotSettings) {
|
||||||
|
autosaveCb.checked = s.autosaveEnabled !== false;
|
||||||
|
snapsCb.checked = s.snapshotsEnabled !== false;
|
||||||
|
intervalInput.value = String(s.intervalMin ?? 5);
|
||||||
|
keepInput.value = String(s.keep ?? 20);
|
||||||
|
locationInput.value = s.location || "";
|
||||||
|
locationInput.placeholder = "Default: <book>/Folio-Backups";
|
||||||
|
resolvedEl.textContent = `Saving to: ${shortPath(s.resolvedRoot || "—")}`;
|
||||||
|
lastSnapEl.textContent = s.lastSnapshot
|
||||||
|
? `${s.snapshotCount} snapshot${s.snapshotCount === 1 ? "" : "s"} · latest ${s.lastSnapshot.split("/").pop()}`
|
||||||
|
: "No snapshots yet.";
|
||||||
|
const dis = !snapsCb.checked;
|
||||||
|
intervalInput.disabled = dis;
|
||||||
|
keepInput.disabled = dis;
|
||||||
|
snapNowBtn.disabled = dis;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const s = (await api.getSnapshotSettings()) as SnapshotSettings;
|
||||||
|
render(s);
|
||||||
|
loaded = true;
|
||||||
|
} catch {
|
||||||
|
// Settings are best-effort; the modal still opens with defaults.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
spellCb.checked = (await api.getSpellcheck()) !== false;
|
||||||
|
} catch {
|
||||||
|
// keep default checked state
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
typewriterCb.checked = hooks.isTypewriterEnabled();
|
||||||
|
} catch {
|
||||||
|
// keep default unchecked state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettings(): void {
|
||||||
|
openEl(modal);
|
||||||
|
void load();
|
||||||
|
setTimeout(() => autosaveCb.focus(), 0);
|
||||||
|
}
|
||||||
|
function closeSettings(): void {
|
||||||
|
closeEl(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function push(patch: Record<string, unknown>, applyAutosave = false) {
|
||||||
|
const res = await api.setSnapshotSettings(patch);
|
||||||
|
if (res && "error" in res) {
|
||||||
|
await alertMessage("Could not save settings", (res as { error: string }).error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = (res as { settings: SnapshotSettings }).settings;
|
||||||
|
if (s) render(s);
|
||||||
|
if (applyAutosave && s) hooks.setAutosaveEnabled(s.autosaveEnabled !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
autosaveCb.addEventListener("change", () => {
|
||||||
|
const on = autosaveCb.checked;
|
||||||
|
// Apply instantly to the editor; persist alongside.
|
||||||
|
hooks.setAutosaveEnabled(on);
|
||||||
|
void (async () => {
|
||||||
|
await api.setAutosave(on);
|
||||||
|
await load();
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
snapsCb.addEventListener("change", () => void push({ snapshotsEnabled: snapsCb.checked }));
|
||||||
|
spellCb.addEventListener("change", () => void (async () => {
|
||||||
|
const res = await api.setSpellcheck(spellCb.checked);
|
||||||
|
if (res && "error" in res) {
|
||||||
|
await alertMessage("Could not save settings", (res as { error: string }).error);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
typewriterCb.addEventListener("change", () => {
|
||||||
|
hooks.setTypewriterEnabled(typewriterCb.checked);
|
||||||
|
});
|
||||||
|
intervalInput.addEventListener("change", () => {
|
||||||
|
const v = Math.max(1, Math.min(1440, Math.round(Number(intervalInput.value) || 5)));
|
||||||
|
intervalInput.value = String(v);
|
||||||
|
void push({ intervalMin: v });
|
||||||
|
});
|
||||||
|
keepInput.addEventListener("change", () => {
|
||||||
|
const v = Math.max(0, Math.min(200, Math.round(Number(keepInput.value) || 0)));
|
||||||
|
keepInput.value = String(v);
|
||||||
|
void push({ keep: v });
|
||||||
|
});
|
||||||
|
browseBtn.addEventListener("click", () => void (async () => {
|
||||||
|
const res = await api.pickSnapshotFolder();
|
||||||
|
if (!res || res.canceled || !res.filePaths?.length) return;
|
||||||
|
await push({ location: res.filePaths[0] });
|
||||||
|
})());
|
||||||
|
resetBtn.addEventListener("click", () => void push({ location: "" }));
|
||||||
|
snapNowBtn.addEventListener("click", () => void (async () => {
|
||||||
|
const res = await api.snapshotNow();
|
||||||
|
if (!res) return;
|
||||||
|
if ("error" in res) {
|
||||||
|
await alertMessage("Snapshot failed", (res as { error: string }).error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast("Snapshot saved");
|
||||||
|
await load();
|
||||||
|
})());
|
||||||
|
closeBtn.addEventListener("click", closeSettings);
|
||||||
|
modal.addEventListener("click", (e) => {
|
||||||
|
if (e.target === modal) closeSettings();
|
||||||
|
});
|
||||||
|
window.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && !modal.classList.contains("hidden")) closeSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sidebar + File-menu entry points.
|
||||||
|
$("settingsBtn")?.addEventListener("click", openSettings);
|
||||||
|
api.onSettingsRequested(() => openSettings());
|
||||||
|
|
||||||
|
// Refresh the resolved-path line whenever a book opens (the default root
|
||||||
|
// is per-book). Only refresh while the modal is open to avoid IPC noise.
|
||||||
|
api.onBookOpened(() => {
|
||||||
|
if (!modal.classList.contains("hidden") || !loaded) return;
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { openSettings };
|
||||||
|
}
|
||||||
|
|
@ -76,6 +76,12 @@ export type FolioAPI = {
|
||||||
setTheme: (id: string) => Promise<any>;
|
setTheme: (id: string) => Promise<any>;
|
||||||
getZoom: () => Promise<number>;
|
getZoom: () => Promise<number>;
|
||||||
setZoom: (pct: number) => Promise<any>;
|
setZoom: (pct: number) => Promise<any>;
|
||||||
|
getAutosave: () => Promise<boolean>;
|
||||||
|
setAutosave: (on: boolean) => Promise<any>;
|
||||||
|
getSnapshotSettings: () => Promise<any>;
|
||||||
|
setSnapshotSettings: (patch: unknown) => Promise<any>;
|
||||||
|
pickSnapshotFolder: () => Promise<any>;
|
||||||
|
snapshotNow: () => Promise<any>;
|
||||||
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
|
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
|
||||||
onBookClosed: (cb: () => void) => void;
|
onBookClosed: (cb: () => void) => void;
|
||||||
onChaptersChanged: (cb: () => void) => void;
|
onChaptersChanged: (cb: () => void) => void;
|
||||||
|
|
@ -98,6 +104,7 @@ export type FolioAPI = {
|
||||||
onZoomResetRequested: (cb: () => void) => void;
|
onZoomResetRequested: (cb: () => void) => void;
|
||||||
onOpenMarkdownRequested: (cb: () => void) => void;
|
onOpenMarkdownRequested: (cb: () => void) => void;
|
||||||
onCheckUpdateRequested: (cb: () => void) => void;
|
onCheckUpdateRequested: (cb: () => void) => void;
|
||||||
|
onSettingsRequested: (cb: () => void) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- P7 decomposition contracts ----
|
// ---- P7 decomposition contracts ----
|
||||||
|
|
@ -125,6 +132,11 @@ export interface BookHooks {
|
||||||
saveAndNotify(): Promise<void>;
|
saveAndNotify(): Promise<void>;
|
||||||
flushAutosave(): Promise<void>;
|
flushAutosave(): Promise<void>;
|
||||||
clearTimers(): void;
|
clearTimers(): void;
|
||||||
|
setAutosaveEnabled(on: boolean): void;
|
||||||
|
setTypewriterEnabled(on: boolean): void;
|
||||||
|
isTypewriterEnabled(): boolean;
|
||||||
|
// settings feature
|
||||||
|
openSettings(): void;
|
||||||
// session coordinators (orchestrator)
|
// session coordinators (orchestrator)
|
||||||
refreshMeta(): Promise<void>;
|
refreshMeta(): Promise<void>;
|
||||||
showPlaceholder(): void;
|
showPlaceholder(): void;
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,60 @@ html, body {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- settings modal ---- */
|
||||||
|
.settings-modal {
|
||||||
|
min-width: min(480px, 92vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row input[type="checkbox"] {
|
||||||
|
margin-top: 3px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 6px 0 10px 26px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-grid label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-num {
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-meta {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
margin: 2px 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- full book nav ---- */
|
/* ---- full book nav ---- */
|
||||||
.full-book-nav {
|
.full-book-nav {
|
||||||
margin-top: var(--gap);
|
margin-top: var(--gap);
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button class="button button-ghost" id="searchBtn" title="Search all chapters (Ctrl+F)">Search</button>
|
<button class="button button-ghost" id="searchBtn" title="Search all chapters (Ctrl+F)">Search</button>
|
||||||
|
<button class="button button-ghost" id="settingsBtn" title="Settings (autosave, backups)">Settings</button>
|
||||||
<div class="theme-control">
|
<div class="theme-control">
|
||||||
<label class="theme-label" for="themeSelect">Theme</label>
|
<label class="theme-label" for="themeSelect">Theme</label>
|
||||||
<select id="themeSelect" class="theme-select" title="Interface theme">
|
<select id="themeSelect" class="theme-select" title="Interface theme">
|
||||||
|
|
@ -315,6 +316,47 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal-backdrop hidden" id="settingsModal">
|
||||||
|
<div class="modal settings-modal" role="dialog" aria-modal="true" aria-label="Settings">
|
||||||
|
<h3 class="modal-title">Settings</h3>
|
||||||
|
<label class="setting-row">
|
||||||
|
<input type="checkbox" id="settingAutosave" checked />
|
||||||
|
<span><strong>Autosave while typing</strong><br />
|
||||||
|
<span class="setting-hint">Saves ~1s after you stop typing. When off, use Save (Ctrl+S). Switching chapters or closing still saves, so work is never lost.</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="setting-row">
|
||||||
|
<input type="checkbox" id="settingSnapshots" checked />
|
||||||
|
<span><strong>Continuous snapshots</strong><br />
|
||||||
|
<span class="setting-hint">Copies the whole book in the background when it changes. Only the newest snapshots are kept.</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="setting-row">
|
||||||
|
<input type="checkbox" id="settingSpellcheck" checked />
|
||||||
|
<span><strong>Spellcheck (English)</strong><br />
|
||||||
|
<span class="setting-hint">Underline misspellings; right-click a word for suggestions or to add it to the dictionary.</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="setting-row">
|
||||||
|
<input type="checkbox" id="settingTypewriter" />
|
||||||
|
<span><strong>Typewriter mode</strong><br />
|
||||||
|
<span class="setting-hint">Keep the line you're writing near the middle of the editor as you type.</span></span>
|
||||||
|
</label>
|
||||||
|
<div class="setting-grid">
|
||||||
|
<label>Every (minutes) <input type="number" id="settingInterval" class="modal-input setting-num" min="1" max="1440" value="5" /></label>
|
||||||
|
<label>Keep last <input type="number" id="settingKeep" class="modal-input setting-num" min="0" max="200" value="20" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="backup-control">
|
||||||
|
<span class="backup-label">Folder</span>
|
||||||
|
<input type="text" id="settingLocation" class="backup-input" readonly placeholder="Default: <book>/Folio-Backups" title="Snapshot folder (empty = default)" />
|
||||||
|
<button type="button" class="button button-ghost backup-browse" id="settingBrowse" title="Choose snapshot folder">…</button>
|
||||||
|
<button type="button" class="button button-ghost" id="settingResetLoc" title="Back to the default folder">Reset</button>
|
||||||
|
</div>
|
||||||
|
<p class="setting-meta" id="settingResolved"></p>
|
||||||
|
<p class="setting-meta" id="settingLastSnap"></p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="button button-ghost" id="settingSnapNow" title="Take a snapshot right now">Snapshot now</button>
|
||||||
|
<button class="button" id="settingsClose">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="modal-backdrop hidden" id="statsModal">
|
<div class="modal-backdrop hidden" id="statsModal">
|
||||||
<div class="modal stats-modal" role="dialog" aria-modal="true" aria-label="Writing statistics">
|
<div class="modal stats-modal" role="dialog" aria-modal="true" aria-label="Writing statistics">
|
||||||
<h3 class="modal-title">Writing statistics</h3>
|
<h3 class="modal-title">Writing statistics</h3>
|
||||||
|
|
|
||||||
99
tests/run-snapshots-test.mjs
Normal file
99
tests/run-snapshots-test.mjs
Normal 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.");
|
||||||
Loading…
Add table
Add a link
Reference in a new issue