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

@ -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;
}

View file

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

View file

@ -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" },
{

View file

@ -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()),
});

View file

@ -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,
};
}

View file

@ -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);

View 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 };
}

View file

@ -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;

View file

@ -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);

View file

@ -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>