fix(backup): exclude snapshots from recovery copy, harden change walk

Migration recovery backups no longer re-copy Folio-Backups (up to 21x bloat). bookChangedSince tracks visited dev:ino so a symlink loop in the book cannot wedge the main-process interval tick. Snapshot toggle check aligned to === false; drop dead home-shortening branch in Settings.
This commit is contained in:
avi 2026-09-19 17:20:31 -05:00
commit b80aa798b2
4 changed files with 49 additions and 4 deletions

View file

@ -49,7 +49,17 @@ export function ensureRecoveryBackup(bookPath: string): string | null {
fs.mkdirSync(getRecoveryRoot(), { recursive: true });
try {
fs.cpSync(key, dest, { recursive: true });
// Entry-by-entry, skipping the snapshot dir: snapshots are expendable
// copies already retained under their own root — re-backing them up here
// would multiply the recovery backup up to (keep + 1)x the book size.
// (One cpSync of the root is also refused by Node when the destination
// would sit inside the source.)
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
// backup. Remove the partial copy, then surface the failure.
@ -118,6 +128,9 @@ 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];
// dev:ino of visited directories — a symlink loop inside the book must not
// wedge the main process in an infinite walk on every interval tick.
const seen = new Set<string>();
while (stack.length) {
const cur = stack.pop() as string;
let stat: fs.Stats;
@ -128,6 +141,9 @@ export function bookChangedSince(bookPath: string, sinceMs: number): boolean {
}
if (stat.isDirectory()) {
if ((cur + path.sep).startsWith(snapRoot)) continue;
const id = `${stat.dev}:${stat.ino}`;
if (seen.has(id)) continue;
seen.add(id);
let kids: string[];
try {
kids = fs.readdirSync(cur);

View file

@ -901,7 +901,7 @@ setInterval(() => {
snapshotLastRunMs = Date.now();
return;
}
if (getSetting<boolean>("snapshotsEnabled", SNAPSHOT_DEFAULTS.snapshotsEnabled) !== true) return;
if (getSetting<boolean>("snapshotsEnabled", SNAPSHOT_DEFAULTS.snapshotsEnabled) === false) return;
const intervalMin = clampInt(
getSetting("snapshotIntervalMin", SNAPSHOT_DEFAULTS.intervalMin),
SNAPSHOT_DEFAULTS.intervalMin, 1, 1440

View file

@ -48,8 +48,8 @@ export function createSettingsFeature(ctx: BookCtx): SettingsFeature {
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);
// The backend resolves the path; display it verbatim (no HOME shortening —
// the renderer has no reliable home-dir lookup under context isolation).
return p;
}