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 {
|
||||
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 {
|
||||
bindBackupSettings,
|
||||
bookChangedSince,
|
||||
createSnapshot,
|
||||
getRecoveryRoot,
|
||||
getSnapshotRoot,
|
||||
listSnapshots,
|
||||
pruneSnapshots,
|
||||
setRecoveryRoot,
|
||||
SNAPSHOT_KEEP_DEFAULT,
|
||||
} from "./backup.js";
|
||||
import { sanitizeBookFolderName } from "./ids.js";
|
||||
import { buildDocx, buildOdt } from "./office-export.js";
|
||||
|
|
@ -752,6 +758,118 @@ handleIpc("folio:setBackupLocation", (_e, loc: unknown) => {
|
|||
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 () => {
|
||||
const bp = requireBook();
|
||||
const { canceled, filePaths } = await showOpenDialog({
|
||||
|
|
|
|||
|
|
@ -75,18 +75,9 @@ export function buildMenu(
|
|||
},
|
||||
},
|
||||
{
|
||||
label: "Backup Location…",
|
||||
click: async () => {
|
||||
const win = getWindow();
|
||||
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]);
|
||||
},
|
||||
label: "Settings…",
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
click: () => getWindow()?.webContents.send("folio:settings-requested"),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -75,6 +75,15 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
setBackupLocation: (loc: string) => ipcRenderer.invoke("folio:setBackupLocation", loc),
|
||||
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
|
||||
onBookOpened: (cb: (data: { path: string; title: string }) => void) =>
|
||||
ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)),
|
||||
|
|
@ -120,4 +129,6 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
ipcRenderer.on("folio:open-markdown-requested", () => cb()),
|
||||
onCheckUpdateRequested: (cb: () => void) =>
|
||||
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>;
|
||||
flushAutosave(): Promise<void>;
|
||||
clearTimers(): void;
|
||||
setAutosaveEnabled(on: boolean): void;
|
||||
setTypewriterEnabled(on: boolean): void;
|
||||
isTypewriterEnabled(): boolean;
|
||||
}
|
||||
|
||||
export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
||||
|
|
@ -63,6 +66,19 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
|||
const AUTOSAVE_MS = 1000;
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
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() {
|
||||
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
|
||||
// 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() {
|
||||
clearTimers();
|
||||
if (!autosaveOn) {
|
||||
saveStateEl.textContent = "Unsaved";
|
||||
saveStateEl.classList.add("unsaved");
|
||||
return;
|
||||
}
|
||||
saveStateEl.textContent = "Saving…";
|
||||
saveStateEl.classList.remove("unsaved");
|
||||
saveTimer = setTimeout(() => void flushAutosave(), AUTOSAVE_MS);
|
||||
|
|
@ -174,7 +196,7 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
|||
const FORCED_AUTOSAVE_MS = 30_000;
|
||||
let forcedSaveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
function startForcedAutosave() {
|
||||
if (forcedSaveTimer) return;
|
||||
if (forcedSaveTimer || !autosaveOn) return;
|
||||
forcedSaveTimer = setInterval(() => {
|
||||
if (state.selectedId && state.dirty) void flushAutosave();
|
||||
}, FORCED_AUTOSAVE_MS);
|
||||
|
|
@ -359,5 +381,8 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
|||
saveAndNotify,
|
||||
flushAutosave,
|
||||
clearTimers,
|
||||
setAutosaveEnabled,
|
||||
setTypewriterEnabled,
|
||||
isTypewriterEnabled,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { createChapterListFeature } from "./chapter-list.js";
|
|||
import { createWelcomeFeature } from "./welcome.js";
|
||||
import { createExportsFeature } from "./exports.js";
|
||||
import { createCommentsFeature } from "./comments.js";
|
||||
import { createSettingsFeature } from "./settings.js";
|
||||
import {
|
||||
$,
|
||||
basename,
|
||||
|
|
@ -60,6 +61,13 @@ export function initBook(api: FolioAPI) {
|
|||
hooks.saveAndNotify = ed.saveAndNotify;
|
||||
hooks.flushAutosave = ed.flushAutosave;
|
||||
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.
|
||||
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>;
|
||||
getZoom: () => Promise<number>;
|
||||
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;
|
||||
onBookClosed: (cb: () => void) => void;
|
||||
onChaptersChanged: (cb: () => void) => void;
|
||||
|
|
@ -98,6 +104,7 @@ export type FolioAPI = {
|
|||
onZoomResetRequested: (cb: () => void) => void;
|
||||
onOpenMarkdownRequested: (cb: () => void) => void;
|
||||
onCheckUpdateRequested: (cb: () => void) => void;
|
||||
onSettingsRequested: (cb: () => void) => void;
|
||||
};
|
||||
|
||||
// ---- P7 decomposition contracts ----
|
||||
|
|
@ -125,6 +132,11 @@ export interface BookHooks {
|
|||
saveAndNotify(): Promise<void>;
|
||||
flushAutosave(): Promise<void>;
|
||||
clearTimers(): void;
|
||||
setAutosaveEnabled(on: boolean): void;
|
||||
setTypewriterEnabled(on: boolean): void;
|
||||
isTypewriterEnabled(): boolean;
|
||||
// settings feature
|
||||
openSettings(): void;
|
||||
// session coordinators (orchestrator)
|
||||
refreshMeta(): Promise<void>;
|
||||
showPlaceholder(): void;
|
||||
|
|
|
|||
|
|
@ -221,6 +221,60 @@ html, body {
|
|||
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 {
|
||||
margin-top: var(--gap);
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
</div>
|
||||
<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="settingsBtn" title="Settings (autosave, backups)">Settings</button>
|
||||
<div class="theme-control">
|
||||
<label class="theme-label" for="themeSelect">Theme</label>
|
||||
<select id="themeSelect" class="theme-select" title="Interface theme">
|
||||
|
|
@ -315,6 +316,47 @@
|
|||
</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 stats-modal" role="dialog" aria-modal="true" aria-label="Writing statistics">
|
||||
<h3 class="modal-title">Writing statistics</h3>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue