From ada5eee679d525673d93068096686634e4818172 Mon Sep 17 00:00:00 2001 From: avi Date: Sat, 19 Sep 2026 00:02:58 -0500 Subject: [PATCH 1/4] feat: open Markdown files and in-app updater Markdown: - File > Open Markdown File... and welcome-screen 'Open .md' import one or more .md files as new books (named after first H1 or the filename; originals never modified, collision-safe folder names). - Book dirs and .md files passed on the command line or via desktop file associations now open directly; single-instance lock forwards second launches to the running window. Updates: - File > Check for Updates... and welcome-screen 'Update' button: queries the Gitea releases API, one-click installs a newer release into ~/.local/share/Folio via a detached swap script that waits for app exit, keeps the old tree until the swap succeeds, and relaunches. Download is gzip-verified and restricted to the trusted release host. No root, no reinstall. Tests: 19 new assertions in tests/run-update-import-test.mjs (wired into npm test); full suite green, tsc clean. --- package.json | 2 +- src/main/import-md.ts | 56 ++++++++ src/main/index.ts | 237 +++++++++++++++++++++++++++++++ src/main/menu.ts | 9 ++ src/main/preload.ts | 10 ++ src/main/update.ts | 173 ++++++++++++++++++++++ src/renderer/book/index.ts | 47 ++++++ src/renderer/book/types.ts | 7 + src/renderer/book/welcome.ts | 21 +++ src/renderer/dialog.ts | 8 +- src/renderer/index.html | 2 + tests/run-update-import-test.mjs | 67 +++++++++ 12 files changed, 634 insertions(+), 5 deletions(-) create mode 100644 src/main/import-md.ts create mode 100644 src/main/update.ts create mode 100644 tests/run-update-import-test.mjs diff --git a/package.json b/package.json index 747ffd3..163be13 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"", "start": "npm run build && npm run electron", "lint": "tsc --noEmit", - "test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-office-export-test.mjs && node tests/run-migration-test.mjs && node tests/run-editor-test.mjs && node tests/run-fullbook-test.mjs && node tests/run-print-test.mjs", + "test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-office-export-test.mjs && node tests/run-migration-test.mjs && node tests/run-editor-test.mjs && node tests/run-fullbook-test.mjs && node tests/run-print-test.mjs && node tests/run-update-import-test.mjs", "package": "electron-builder" }, "devDependencies": { diff --git a/src/main/import-md.ts b/src/main/import-md.ts new file mode 100644 index 0000000..968cfad --- /dev/null +++ b/src/main/import-md.ts @@ -0,0 +1,56 @@ +// Import standalone Markdown files as Folio books. +// +// Electron-free on purpose (like backup.ts): takes the file list and the +// destination root as arguments so the logic is unit-testable without an +// app instance. One book per file, named after the file's first H1 (or its +// filename when there is no heading). The original file is never modified — +// its content is copied into the new book's chapter. +import fs from "fs"; +import path from "path"; +import { createBook, sanitizeBookFolderName } from "./project.js"; +import { createChapter, setChapterContent } from "./chapters.js"; + +export interface ImportResult { + ok: boolean; + file: string; + bookPath?: string; + title?: string; + error?: string; +} + +const MD_EXT = /\.(md|markdown|txt)$/i; + +export function isImportableMarkdown(file: string): boolean { + return MD_EXT.test(file); +} + +// Title = first ATX H1 ("# Title"), else the filename stem. +export function mdTitle(raw: string, fallback: string): string { + const m = /^[ \t]*#[ \t]+(.+?)[ \t]*#*[ \t]*$/m.exec(raw); + const t = (m && m[1] ? m[1] : fallback).trim(); + return t || fallback; +} + +export function importMarkdownFiles(files: string[], root: string): ImportResult[] { + return files.map((file) => { + try { + const raw = fs.readFileSync(file, "utf-8"); + const stem = path.basename(file).replace(MD_EXT, "") || "Untitled"; + const title = mdTitle(raw, stem); + const folder = + sanitizeBookFolderName(title) || sanitizeBookFolderName(stem) || "Imported"; + let dir = path.join(root, folder); + let n = 2; + while (fs.existsSync(dir)) { + dir = path.join(root, `${folder} ${n}`); + n += 1; + } + createBook(dir, title); + const entry = createChapter(dir, title); + setChapterContent(dir, entry.id, raw); + return { ok: true, file, bookPath: dir, title }; + } catch (e) { + return { ok: false, file, error: (e as Error).message }; + } + }); +} diff --git a/src/main/index.ts b/src/main/index.ts index 3b7b272..3f83fb3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -42,6 +42,18 @@ import { buildDocx, buildOdt } from "./office-export.js"; import { getSetting, setSetting } from "./settings.js"; import { migrateChapterToNative, migrateBookToNative } from "./migration.js"; import { loadChapterDoc, saveChapterDoc } from "./chapters.js"; +import { importMarkdownFiles, isImportableMarkdown } from "./import-md.js"; +import { + INSTALL_DIR_DEFAULT, + LAUNCHER_DEFAULT, + UPDATE_BASE_DEFAULT, + UPDATE_REPO, + buildInstallerScript, + isTrustedDownloadUrl, + isNewer, + parseRelease, + pickAsset, +} from "./update.js"; let mainWindow: BrowserWindow | null = null; let bookPath: string | null = null; @@ -116,6 +128,13 @@ app.whenReady().then(() => { () => getWindow()?.webContents.send("folio:open-book-requested") ); createWindow(); + // Open a book folder or Markdown file passed on the command line + // (`folio notes.md`, or file-association launches from the desktop). + const initialFile = fileArgFrom(process.argv); + if (initialFile) { + const win = getWindow(); + win?.webContents.once("did-finish-load", () => openFromArg(initialFile)); + } app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); @@ -756,3 +775,221 @@ handleIpc("folio:importAttachment", async () => { const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/"); return { ok: true, name, relPath }; }); + +// ---- Markdown import ------------------------------------------------------ + +// Import standalone .md files as Folio books (one book per file, named after +// the file's first H1 or its filename). Files are copied — the originals are +// never modified. Books land in Documents, same as "New". +handleIpc("folio:importMarkdown", async () => { + const { canceled, filePaths } = await showOpenDialog({ + title: "Open Markdown file(s)", + properties: ["openFile", "multiSelections"], + filters: [{ name: "Markdown", extensions: ["md", "markdown", "txt"] }], + }); + if (canceled || !filePaths.length) return { canceled: true } as const; + return importMarkdownFiles(filePaths, app.getPath("documents")); +}); + +// Open a path that may be either a Folio book directory or a Markdown file +// (the desktop launcher can be handed either via file associations). +handleIpc("folio:openPathOrImport", async (_e, p: string) => { + if (p && isImportableMarkdown(p)) { + const results = importMarkdownFiles([p], app.getPath("documents")); + const r = results[0]; + if (!r.ok) return { error: r.error }; + return openBookAt(r.bookPath as string); + } + return openBookAt(p); +}); + +// ---- In-app update -------------------------------------------------------- + +// Release source. Defaults to the project's Gitea instance; the installed +// tree may override it with update.json next to the binary (e.g. for +// staging), and FOLIO_UPDATE_BASE/FOLIO_UPDATE_REPO override everything. +function updateConfig(): { base: string; repo: string; installDir: string; launcher: string } { + const cfg = { + base: UPDATE_BASE_DEFAULT, + repo: UPDATE_REPO, + installDir: INSTALL_DIR_DEFAULT, + launcher: LAUNCHER_DEFAULT, + }; + try { + const p = path.join(cfg.installDir, "update.json"); + if (fs.existsSync(p)) { + const j = JSON.parse(fs.readFileSync(p, "utf-8")); + if (typeof j.base === "string") cfg.base = j.base.replace(/\/+$/, ""); + if (typeof j.repo === "string") cfg.repo = j.repo; + if (typeof j.launcher === "string") cfg.launcher = j.launcher; + } + } catch { + /* malformed update.json: keep defaults */ + } + if (process.env.FOLIO_UPDATE_BASE) cfg.base = process.env.FOLIO_UPDATE_BASE.replace(/\/+$/, ""); + if (process.env.FOLIO_UPDATE_REPO) cfg.repo = process.env.FOLIO_UPDATE_REPO; + return cfg; +} + +interface UpdateCheckResult { + current: string; + latest?: string; + tag?: string; + updateAvailable?: boolean; + assetName?: string; + notes?: string; + canInstall?: boolean; + error?: string; +} + +async function fetchLatestRelease(): Promise> { + const { base, repo } = updateConfig(); + const res = await fetch(`${base}/api/v1/repos/${repo}/releases/latest`, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(10000), + }); + if (res.status === 404) throw new Error("No published releases yet."); + if (!res.ok) throw new Error(`Release server returned HTTP ${res.status}.`); + return parseRelease(await res.json()); +} + +handleIpc("folio:checkUpdate", async (): Promise => { + try { + const rel = await fetchLatestRelease(); + const asset = pickAsset(rel.assets); + const current = app.getVersion(); + return { + current, + latest: rel.version, + tag: rel.tag, + updateAvailable: isNewer(rel.version, current), + assetName: asset ? asset.name : undefined, + notes: rel.notes, + canInstall: + !!asset && + isTrustedDownloadUrl(asset.browser_download_url, updateConfig().base) && + process.platform === "linux" && + fs.existsSync(path.join(updateConfig().installDir, "folio")), + }; + } catch (e) { + return { current: app.getVersion(), error: (e as Error).message }; + } +}); + +// Download the release tarball to the OS temp dir, verify it looks like a +// gzip stream, then hand off to the detached swap script and quit. The old +// install is kept until the swap succeeds, so failure can never strand the +// user without a working app. +handleIpc("folio:performUpdate", async () => { + try { + const cfg = updateConfig(); + const rel = await fetchLatestRelease(); + const asset = pickAsset(rel.assets); + if (!asset) return { error: `Release ${rel.tag} has no tar.gz asset to install.` }; + if (!isTrustedDownloadUrl(asset.browser_download_url, cfg.base)) { + return { error: "Download URL is not on the trusted release host; update aborted." }; + } + if (!fs.existsSync(path.join(cfg.installDir, "folio"))) { + return { + error: + "This Folio is not the user-local install (~/.local/share/Folio), so in-place update is unavailable.", + }; + } + const tarball = path.join( + app.getPath("temp"), + `folio-update-${Date.now()}.tar.gz` + ); + const res = await fetch(asset.browser_download_url, { + redirect: "follow", + signal: AbortSignal.timeout(10 * 60 * 1000), + }); + if (!res.ok || !res.body) { + return { error: `Download failed (HTTP ${res.status}).` }; + } + const total = Number(res.headers.get("content-length")) || asset.size || 0; + const { Readable } = await import("stream"); + const { createWriteStream } = await import("fs"); + const { pipeline } = await import("stream/promises"); + let received = 0; + const source = Readable.fromWeb(res.body as never); + source.on("data", (chunk: Buffer) => { + received += chunk.length; + getWindow()?.webContents.send("folio:update-progress", { received, total }); + }); + await pipeline(source, createWriteStream(tarball)); + const head = fs.readFileSync(tarball).subarray(0, 2); + if (head.length < 2 || head[0] !== 0x1f || head[1] !== 0x8b) { + fs.rmSync(tarball, { force: true }); + return { error: "Downloaded file is not a valid archive; update aborted." }; + } + const script = path.join( + app.getPath("temp"), + `folio-update-${process.pid}.sh` + ); + fs.writeFileSync( + script, + buildInstallerScript({ + pid: process.pid, + tarball, + installDir: cfg.installDir, + relaunch: cfg.launcher, + }), + { mode: 0o700 } + ); + const { spawn } = await import("child_process"); + const child = spawn("/bin/sh", [script], { detached: true, stdio: "ignore" }); + child.on("error", (err) => { + console.error("[folio] updater spawn failed:", err.message); + }); + child.unref(); + // Quit so the swap script can replace the app directory. + setTimeout(() => app.quit(), 300); + return { ok: true, version: rel.version }; + } catch (e) { + return { error: (e as Error).message }; + } +}); + +// ---- Files passed on the command line / file associations ----------------- + +// Pull the first usable file path out of argv (the desktop launcher forwards +// %F; `folio path/to/book` or `folio notes.md` both work). +function fileArgFrom(argv: string[]): string | null { + for (const a of argv.slice(1)) { + if (!a || a.startsWith("-")) continue; + try { + if (fs.statSync(a).isFile() || hasBook(a)) return path.resolve(a); + } catch { + /* not a readable path; ignore */ + } + } + return null; +} + +function openFromArg(p: string): void { + if (isImportableMarkdown(p)) { + const results = importMarkdownFiles([p], app.getPath("documents")); + const r = results[0]; + if (r.ok) { + openBookAt(r.bookPath as string); + return; + } + console.error(`[folio] import failed for ${p}: ${r.error}`); + return; + } + void openBookAt(p); +} + +// Single instance: opening a second file (double-clicking while Folio is +// already open) forwards the path to the running window instead of stacking +// another app instance. +if (app.requestSingleInstanceLock()) { + app.on("second-instance", (_e, argv) => { + const f = fileArgFrom(argv); + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + if (f) openFromArg(f); + }); +} diff --git a/src/main/menu.ts b/src/main/menu.ts index f5189fa..e42494c 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -38,6 +38,10 @@ export function buildMenu( submenu: [ { label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew }, { label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen }, + { + label: "Open Markdown File…", + click: () => getWindow()?.webContents.send("folio:open-markdown-requested"), + }, { type: "separator" }, { label: "Save", @@ -113,6 +117,11 @@ export function buildMenu( click: () => getWindow()?.webContents.send("folio:export-odt-requested"), }, { type: "separator" }, + { + label: "Check for Updates…", + click: () => getWindow()?.webContents.send("folio:check-update-requested"), + }, + { type: "separator" }, { label: "Close", accelerator: isMac ? "Cmd+W" : "Ctrl+W", diff --git a/src/main/preload.ts b/src/main/preload.ts index 7431d15..569a145 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -54,6 +54,12 @@ contextBridge.exposeInMainWorld("folio", { exportOdt: (chapterIds?: string[] | null) => ipcRenderer.invoke("folio:exportOdt", chapterIds), importAttachment: () => ipcRenderer.invoke("folio:importAttachment"), + openMarkdownFiles: () => ipcRenderer.invoke("folio:importMarkdown"), + openPathOrImport: (p: string) => ipcRenderer.invoke("folio:openPathOrImport", p), + checkUpdate: () => ipcRenderer.invoke("folio:checkUpdate"), + performUpdate: () => ipcRenderer.invoke("folio:performUpdate"), + onUpdateProgress: (cb: (p: { received: number; total: number }) => void) => + ipcRenderer.on("folio:update-progress", (_e, data) => cb(data)), copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text), getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"), toggleFullscreen: () => ipcRenderer.invoke("folio:toggleFullscreen"), @@ -110,4 +116,8 @@ contextBridge.exposeInMainWorld("folio", { ipcRenderer.on("folio:zoom-out-requested", () => cb()), onZoomResetRequested: (cb: () => void) => ipcRenderer.on("folio:zoom-reset-requested", () => cb()), + onOpenMarkdownRequested: (cb: () => void) => + ipcRenderer.on("folio:open-markdown-requested", () => cb()), + onCheckUpdateRequested: (cb: () => void) => + ipcRenderer.on("folio:check-update-requested", () => cb()), }); diff --git a/src/main/update.ts b/src/main/update.ts new file mode 100644 index 0000000..5774e22 --- /dev/null +++ b/src/main/update.ts @@ -0,0 +1,173 @@ +// In-app update support for the user-local install of Folio. +// +// Architecture: a release published on the Gitea instance carries a +// linux tar.gz asset (the unpacked Electron app). "Install update" +// downloads that tarball, then hands off to a tiny detached shell script +// which waits for this process to exit, swaps ~/.local/share/Folio with the +// new contents (keeping a .old copy until the swap succeeds), and relaunches +// the user launcher. No root, no package manager, no reinstall. +// +// The pure logic lives here (Electron-free) so it is unit-testable; index.ts +// only wires IPC, fetch, and spawn around it. +import path from "path"; + +export const UPDATE_BASE_DEFAULT = "https://git.atitlan.io"; +export const UPDATE_REPO = "avi/Folio"; +export const INSTALL_DIR_DEFAULT = path.join( + process.env.HOME || "~", + ".local", + "share", + "Folio" +); +export const LAUNCHER_DEFAULT = path.join( + process.env.HOME || "~", + ".local", + "bin", + "folio" +); + +export interface ReleaseAsset { + name: string; + browser_download_url: string; + size?: number; +} + +export interface ReleaseInfo { + tag: string; + version: string; + url?: string; + notes?: string; + assets: ReleaseAsset[]; +} + +// "v0.1.2" / "0.1.2-rc1" / "0.1.2" -> [0,1,2]; null when unparseable. +export function parseVersion(v: string): number[] | null { + const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v || "").trim()); + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; +} + +export function compareVersions(a: string, b: string): number { + const va = parseVersion(a); + const vb = parseVersion(b); + if (!va || !vb) return 0; + for (let i = 0; i < 3; i++) { + if (va[i] !== vb[i]) return va[i] < vb[i] ? -1 : 1; + } + return 0; +} + +export function isNewer(latest: string, current: string): boolean { + return compareVersions(latest, current) > 0; +} + +// Normalize a Gitea release JSON object. Throws when the payload has no tag +// (including Gitea's error objects, which carry a message but no tag_name). +export function parseRelease(json: any): ReleaseInfo { + const tag = json && typeof json.tag_name === "string" ? json.tag_name : ""; + if (!tag) { + const msg = json && typeof json.message === "string" ? json.message : "invalid response"; + throw new Error(`No release found (${msg})`); + } + const assets: ReleaseAsset[] = Array.isArray(json.assets) + ? json.assets + .filter( + (a: any) => + a && + typeof a.name === "string" && + typeof a.browser_download_url === "string" + ) + .map((a: any) => ({ + name: a.name, + browser_download_url: a.browser_download_url, + size: typeof a.size === "number" ? a.size : undefined, + })) + : []; + const version = (parseVersion(tag) ? tag.replace(/^v/, "") : "").trim(); + return { + tag, + version: version || tag, + url: typeof json.html_url === "string" ? json.html_url : undefined, + notes: typeof json.body === "string" ? json.body : undefined, + assets, + }; +} + +// Choose the update asset: the linux tar.gz (preferred, unpackable without +// FUSE). AppImage/deb assets exist but the swap logic here installs the +// unpacked tree, so only tar.gz is eligible. +export function pickAsset(assets: ReleaseAsset[]): ReleaseAsset | null { + const tarballs = assets.filter((a) => /\.tar\.gz$/.test(a.name)); + const linux = tarballs.find( + (a) => /linux/i.test(a.name) && /x86_64|amd64/i.test(a.name) + ); + return linux || tarballs[0] || null; +} + +// The download URL must live on the same origin as the configured update +// base — this blocks a malicious release body from redirecting the installer +// to arbitrary hosts. +export function isTrustedDownloadUrl(url: string, base: string): boolean { + try { + const u = new URL(url); + const b = new URL(base); + return u.protocol === b.protocol && u.host === b.host; + } catch { + return false; + } +} + +// Single-quote a path for safe interpolation into /bin/sh scripts. +export function shQuote(p: string): string { + return `'${String(p).replace(/'/g, `'\\''`)}'`; +} + +export interface InstallerSpec { + pid: number; + tarball: string; + installDir: string; + relaunch: string; +} + +// The handoff script. Deliberately forgiving (no set -e): on any failure it +// still relaunches the OLD version, so a bad download can never leave the +// user without an app. The old tree is kept as .old. and pruned only +// after a successful swap. +export function buildInstallerScript(spec: InstallerSpec): string { + const { pid, tarball, installDir, relaunch } = spec; + return [ + "#!/bin/sh", + "# Folio updater — waits for the app to exit, swaps in the new version,", + "# then relaunches. Generated by Folio; safe to delete after one run.", + `PID=${Number(pid) || -1}`, + `TARBALL=${shQuote(tarball)}`, + `INSTALL=${shQuote(installDir)}`, + `RELAUNCH=${shQuote(relaunch)}`, + `NEW="$INSTALL.update-new.$$"`, + `OLD="$INSTALL.old.$$"`, + "# Wait (bounded to ~2 minutes) for the app process to exit.", + "i=0", + 'while kill -0 "$PID" 2>/dev/null && [ "$i" -lt 400 ]; do sleep 0.3; i=$((i+1)); done', + 'rm -rf "$NEW"', + 'mkdir -p "$NEW"', + 'if tar -xzf "$TARBALL" -C "$NEW"; then', + ' # electron-builder nests everything under a top-level dir; flatten it.', + ' if [ ! -e "$NEW/folio" ]; then', + ' ONLY=$(find "$NEW" -mindepth 1 -maxdepth 1 -type d | head -n 1)', + ' [ -n "$ONLY" ] && mv "$ONLY"/* "$NEW"/ 2>/dev/null', + " fi", + "fi", + 'if [ -x "$NEW/folio" ]; then', + ' [ -e "$INSTALL" ] && mv "$INSTALL" "$OLD"', + ' if mv "$NEW" "$INSTALL"; then', + ' rm -rf "$OLD" "$INSTALL.update-new".* "$INSTALL.old".* 2>/dev/null', + ' rm -f "$TARBALL"', + " else", + ' [ -e "$OLD" ] && mv "$OLD" "$INSTALL"', + " fi", + "else", + ' rm -rf "$NEW"', + "fi", + '"$RELAUNCH" >/dev/null 2>&1 &', + "", + ].join("\n"); +} diff --git a/src/renderer/book/index.ts b/src/renderer/book/index.ts index 083de0c..5915827 100644 --- a/src/renderer/book/index.ts +++ b/src/renderer/book/index.ts @@ -131,6 +131,50 @@ export function initBook(api: FolioAPI) { // The comments module owns its own DOM chrome and wiring; here we only keep the // top-level constructor call and the bridge assignments it already set above. + // ---- update flow ---- + // "Check for Updates…" (File menu / welcome button): query the release + // server, then offer a one-click install. The app downloads the tarball, + // hands off to a detached swap script, and relaunches itself — no manual + // reinstall. See src/main/update.ts for the safety properties. + async function checkForUpdates() { + showToast("Checking for updates…"); + const res = await api.checkUpdate(); + if (!res || res.error) { + await alertMessage("Update check failed", (res && res.error) || "No response from the release server."); + return; + } + if (!res.updateAvailable) { + await alertMessage("You're up to date", `Folio ${res.current} is the latest release.`); + return; + } + const go = await confirmMessage( + `Update to ${res.latest}?`, + `Folio ${res.current} → ${res.latest}. Folio will download the update, restart, and finish installing on its own.`, + "Update" + ); + if (!go) return; + if (!res.canInstall) { + await alertMessage( + "Automatic install unavailable", + "This release has no installable Linux archive, or this Folio is not the user-local install. Download it manually from the releases page." + ); + return; + } + let lastPct = -1; + api.onUpdateProgress(({ received, total }) => { + const pct = total ? Math.floor((received / total) * 100) : -1; + if (pct !== lastPct) { + lastPct = pct; + showToast(pct >= 0 ? `Downloading update… ${pct}%` : `Downloading update… ${(received / 1048576).toFixed(1)} MB`); + } + }); + const up = await api.performUpdate(); + if (up && up.error) { + await alertMessage("Update failed", up.error); + } + // On success the app quits momentarily and reopens by itself. + } + // ---- api menu handlers ---- // These now delegate to the extracted feature facades rather than calling // inline functions defined in this module. @@ -140,6 +184,9 @@ export function initBook(api: FolioAPI) { api.onNewBookRequested(() => void welcome.startNewBook()); api.onOpenBookRequested(() => void welcome.startOpenBook()); api.onOpenRecent((p) => void welcome.openPath(p)); + api.onOpenMarkdownRequested(() => void welcome.openMarkdown()); + api.onCheckUpdateRequested(() => void checkForUpdates()); + $("welcomeUpdate")?.addEventListener("click", () => void checkForUpdates()); api.onRevealRequested(async (p) => { const ok = await api.revealInFolder(p); showToast( diff --git a/src/renderer/book/types.ts b/src/renderer/book/types.ts index 109f226..a2739bd 100644 --- a/src/renderer/book/types.ts +++ b/src/renderer/book/types.ts @@ -64,6 +64,11 @@ export type FolioAPI = { exportDocx: (chapterIds?: string[] | null) => Promise; exportOdt: (chapterIds?: string[] | null) => Promise; importAttachment: () => Promise; + openMarkdownFiles: () => Promise; + openPathOrImport: (p: string) => Promise; + checkUpdate: () => Promise; + performUpdate: () => Promise; + onUpdateProgress: (cb: (p: { received: number; total: number }) => void) => void; copyText: (text: string) => Promise; getClipboardText: () => Promise; toggleFullscreen: () => Promise; @@ -91,6 +96,8 @@ export type FolioAPI = { onZoomInRequested: (cb: () => void) => void; onZoomOutRequested: (cb: () => void) => void; onZoomResetRequested: (cb: () => void) => void; + onOpenMarkdownRequested: (cb: () => void) => void; + onCheckUpdateRequested: (cb: () => void) => void; }; // ---- P7 decomposition contracts ---- diff --git a/src/renderer/book/welcome.ts b/src/renderer/book/welcome.ts index ce8800b..31eec94 100644 --- a/src/renderer/book/welcome.ts +++ b/src/renderer/book/welcome.ts @@ -19,6 +19,7 @@ export interface WelcomeFeature { refreshMeta(): Promise; startNewBook(): Promise; startOpenBook(): Promise; + openMarkdown(): Promise; openPath(p: string): Promise; } @@ -162,6 +163,24 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { await renderRecents(); } + // Open one or more standalone Markdown files: each becomes a new book + // (copied, originals untouched) and the first one opens immediately. + async function openMarkdown() { + const res = await api.openMarkdownFiles(); + if (!res || "canceled" in res) return; + if (!Array.isArray(res)) { await alertMessage("Error", (res as { error: string }).error); return; } + const results = res as { ok: boolean; file: string; bookPath?: string; title?: string; error?: string }[]; + const failed = results.filter((r) => !r.ok); + const first = results.find((r) => r.ok); + if (first?.bookPath) await openPath(first.bookPath); + if (failed.length) { + await alertMessage( + "Some files could not be imported", + failed.map((f) => `${f.file}: ${f.error}`).join("\n") + ); + } + } + async function renderRecents() { try { const recents = await api.getRecents(); @@ -223,6 +242,7 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { // ---- wiring (bind once at construction) ---- $("welcomeNewBook").addEventListener("click", () => void startNewBook()); $("welcomeOpenBook").addEventListener("click", () => void startOpenBook()); + $("welcomeOpenMd")?.addEventListener("click", () => void openMarkdown()); homeBtn?.addEventListener("click", async () => { hooks.clearTimers(); if (state.selectedId && state.dirty) { @@ -244,5 +264,6 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature { startNewBook, startOpenBook, openPath, + openMarkdown, }; } diff --git a/src/renderer/dialog.ts b/src/renderer/dialog.ts index 186fc7f..e996b1e 100644 --- a/src/renderer/dialog.ts +++ b/src/renderer/dialog.ts @@ -18,7 +18,7 @@ const cancelBtn = el("modalCancel") as HTMLButtonElement; let currentMode: Mode = "prompt"; let resolver: ((value: string | null) => void) | null = null; -function open(mode: Mode, title: string, body: string, value: string): Promise { +function open(mode: Mode, title: string, body: string, value: string, okLabel?: string): Promise { // If a previous dialog was somehow still open (menu accelerators can open // one while another is visible), settle its promise as cancelled so the // awaiting caller isn't stranded forever. @@ -30,7 +30,7 @@ function open(mode: Mode, title: string, body: string, value: string): Promise { - return open("confirm", title, body ?? "", "").then((v) => v !== null); +export function confirmMessage(title: string, body?: string, okLabel?: string): Promise { + return open("confirm", title, body ?? "", "", okLabel).then((v) => v !== null); } export function alertMessage(title: string, body?: string): Promise { diff --git a/src/renderer/index.html b/src/renderer/index.html index fb1f3d6..a052ce3 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -56,6 +56,8 @@
+ +
Recent
diff --git a/tests/run-update-import-test.mjs b/tests/run-update-import-test.mjs new file mode 100644 index 0000000..d00700c --- /dev/null +++ b/tests/run-update-import-test.mjs @@ -0,0 +1,67 @@ +import esbuild from "esbuild"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import url from "url"; + +await esbuild.build({ + entryPoints: ["src/main/update.ts"], + bundle: true, platform: "node", format: "esm", + outfile: "/tmp/folio-smoke-update.mjs", external: ["electron"], logLevel: "silent", +}); +await esbuild.build({ + entryPoints: ["src/main/import-md.ts"], + bundle: true, platform: "node", format: "esm", + outfile: "/tmp/folio-smoke-import.mjs", external: ["electron"], logLevel: "silent", +}); +const u = await import(url.pathToFileURL("/tmp/folio-smoke-update.mjs").href); +const m = await import(url.pathToFileURL("/tmp/folio-smoke-import.mjs").href); + +let fails = 0; +const assert = (c, msg) => { if (!c) { console.error("FAIL -", msg); fails++; } else console.log("ok -", msg); }; + +assert(u.isNewer("0.2.0", "0.1.0"), "0.2.0 newer than 0.1.0"); +assert(!u.isNewer("v0.1.0", "0.1.0"), "same version not newer (v-prefix tolerated)"); +assert(u.isNewer("0.1.10", "0.1.9"), "numeric compare not lexicographic"); + +const rel = u.parseRelease({ + tag_name: "v0.2.0", + assets: [ + { name: "Folio-0.2.0-linux.tar.gz", browser_download_url: "https://git.atitlan.io/avi/Folio/releases/download/v0.2.0/Folio-0.2.0-linux.tar.gz" }, + { name: "Folio-0.1.0.AppImage", browser_download_url: "https://git.atitlan.io/x/Folio.AppImage" }, + ], +}); +assert(rel.version === "0.2.0", "release version parsed"); +assert(u.pickAsset(rel.assets).name.includes("linux"), "picks linux tar.gz over AppImage"); +assert(u.isTrustedDownloadUrl("https://git.atitlan.io/a/t.tar.gz", "https://git.atitlan.io"), "same-host trusted"); +assert(!u.isTrustedDownloadUrl("https://evil.io/t.tar.gz", "https://git.atitlan.io"), "cross-host rejected"); +let threw = false; +try { u.parseRelease({ message: "not found" }); } catch { threw = true; } +assert(threw, "error payload without tag throws"); + +const sh = u.buildInstallerScript({ pid: 1234, tarball: "/tmp/it's.tar.gz", installDir: "/home/a/.local/share/Folio", relaunch: "/home/a/.local/bin/folio" }); +assert(sh.includes(`'/tmp/it'\\''s.tar.gz'`), "shQuote escapes embedded single quote"); +assert(sh.includes("kill -0"), "installer waits for app pid"); +assert(sh.includes("-xzf"), "installer extracts gz"); + +assert(m.mdTitle("# My Heading\nbody", "file") === "My Heading", "title from H1"); +assert(m.mdTitle("no heading", "file") === "file", "title falls back to filename stem"); + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "imp-")); +fs.writeFileSync(path.join(dir, "a.md"), "# Alpha\n\nhello world"); +fs.writeFileSync(path.join(dir, "b.md"), "plain text no heading"); +const r1 = m.importMarkdownFiles([path.join(dir, "a.md"), path.join(dir, "b.md")], path.join(dir, "out")); +assert(r1[0].ok && r1[0].title === "Alpha", "import a.md -> book 'Alpha'"); +assert(r1[1].ok && r1[1].title === "b", "import b.md -> book 'b'"); +assert(fs.readFileSync(path.join(dir, "a.md"), "utf-8") === "# Alpha\n\nhello world", "original file untouched"); +const r2 = m.importMarkdownFiles([path.join(dir, "a.md")], path.join(dir, "out")); +assert(r2[0].ok && r2[0].bookPath.endsWith("Alpha 2"), "name collision gets numeric suffix"); +const pj = JSON.parse(fs.readFileSync(path.join(dir, "out", "Alpha", "folio.json"), "utf-8")); +const entry = pj.chapters[pj.chapterOrder[0]]; +const ch = fs.readFileSync(path.join(dir, "out", "Alpha", entry.file), "utf-8"); +assert(ch.includes("hello world"), "chapter content copied into book"); +const r3 = m.importMarkdownFiles(["/nonexistent/zz.md"], path.join(dir, "out2")); +assert(!r3[0].ok && r3[0].error, "missing file reports error, no crash"); + +console.log(fails ? `SMOKE: ${fails} FAILURES` : "SMOKE: ALL PASSED"); +process.exit(fails ? 1 : 0); From 341b89cd8a5656ec6ac3ac571a24d4774729e0a9 Mon Sep 17 00:00:00 2001 From: avi Date: Sat, 19 Sep 2026 16:37:45 -0500 Subject: [PATCH 2/4] fix(renderer): chapter reorder follows selection, drag autoscrolls list Keyboard reorder (Alt+Up/Down) restores focus and scrolls the moved row into view so repeated moves keep working. Pointer drags autoscroll near the list edges and show drop position. --- src/renderer/book/chapter-list.ts | 122 +++++++++++++++++++++++++----- 1 file changed, 103 insertions(+), 19 deletions(-) diff --git a/src/renderer/book/chapter-list.ts b/src/renderer/book/chapter-list.ts index f7fd273..d070173 100644 --- a/src/renderer/book/chapter-list.ts +++ b/src/renderer/book/chapter-list.ts @@ -41,6 +41,7 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { "#3b82f6", "#8b5cf6", "#78716c", "#18181b", ]; const HEX_RE = /^#[0-9a-fA-F]{3,8}$/; + let animateNextRender = false; let colorPopover: HTMLDivElement | null = null; function closeColorPopover() { @@ -299,12 +300,29 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { } } - async function persistOrder() { + async function persistOrder(scrollSelected = false) { if (!state.meta) return; state.meta.chapterOrder = state.ordered.map((c) => c.id); const res = await api.reorderChapters(state.meta.chapterOrder); if (res && "error" in res) await alertMessage("Could not reorder chapters", (res as { error: string }).error); - else renderChapterList(); + else { + renderChapterList(); + if (scrollSelected) keepSelectedVisible(); + } + } + + // After renderChapterList() rebuilds the list, DOM focus is lost and the + // moved row can end up outside the visible scroll area. Restore focus so + // repeated Alt+Arrow presses keep working, and scroll the selected row + // into view so it follows the move. + function keepSelectedVisible() { + if (!state.selectedId) return; + const row = chapterList.querySelector( + `li.chapter-row[data-id="${CSS.escape(state.selectedId)}"]` + ) as HTMLElement | null; + if (!row) return; + row.focus({ preventScroll: true }); + row.scrollIntoView({ block: "nearest" }); } function moveSelectedDown() { @@ -313,8 +331,10 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { if (i < 0 || i >= state.ordered.length - 1) return; state.ordered = moveInList(state.ordered, i, i + 1); state.meta!.chapterOrder = state.ordered.map((c) => c.id); + animateNextRender = true; renderChapterList(); - void persistOrder(); + keepSelectedVisible(); + void persistOrder(true); } function moveSelectedUp() { @@ -323,8 +343,10 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { if (i <= 0) return; state.ordered = moveInList(state.ordered, i, i - 1); state.meta!.chapterOrder = state.ordered.map((c) => c.id); + animateNextRender = true; renderChapterList(); - void persistOrder(); + keepSelectedVisible(); + void persistOrder(true); } // ---- pointer-based reordering ---- @@ -333,10 +355,65 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { let dragStartY = 0; let dragMoved = false; let dragJustHappened = false; - let animateNextRender = false; let dragGhost: HTMLElement | null = null; let dragPlaceholder: HTMLElement | null = null; let dragOffsetY = 0; + let dragClientX = 0; + let dragClientY = 0; + let dragScrollRaf = 0; + + function updateDropTarget(clientX: number, clientY: number) { + const overEl = document.elementFromPoint(clientX, clientY); + const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null; + chapterList.querySelectorAll(".chapter-row").forEach((n) => { + n.classList.remove("drop-target", "drop-before", "drop-after"); + }); + if (overLi && overLi.dataset.id !== draggingId) { + overLi.classList.add("drop-target"); + const before = clientY < overLi.getBoundingClientRect().top + overLi.offsetHeight / 2; + overLi.classList.add(before ? "drop-before" : "drop-after"); + if (dragPlaceholder) { + if (before) chapterList.insertBefore(dragPlaceholder, overLi); + else chapterList.insertBefore(dragPlaceholder, overLi.nextSibling); + } + } + } + + function stopDragScroll() { + if (dragScrollRaf) cancelAnimationFrame(dragScrollRaf); + dragScrollRaf = 0; + } + + // Continuously scroll the chapter list while the dragged ghost is held + // near its top/bottom edge, so chapters above/below the fold are reachable. + // pointermove alone is not enough: it stops firing when the pointer holds + // still, which is exactly when the user expects the list to keep scrolling. + function tickDragScroll() { + dragScrollRaf = 0; + if (!dragMoved || draggingId === null) return; + const rect = chapterList.getBoundingClientRect(); + const EDGE = 48; + const MAX_SPEED = 14; + let dy = 0; + if (dragClientY < rect.top + EDGE) { + const strength = Math.min(1, (rect.top + EDGE - dragClientY) / EDGE); + dy = -(2 + MAX_SPEED * strength); + } else if (dragClientY > rect.bottom - EDGE) { + const strength = Math.min(1, (dragClientY - (rect.bottom - EDGE)) / EDGE); + dy = 2 + MAX_SPEED * strength; + } + if (dy !== 0) { + chapterList.scrollTop += dy; + if (dragGhost) dragGhost.style.top = `${dragClientY - dragOffsetY}px`; + updateDropTarget(dragClientX, dragClientY); + } + dragScrollRaf = requestAnimationFrame(tickDragScroll); + } + + function startDragScroll() { + stopDragScroll(); + dragScrollRaf = requestAnimationFrame(tickDragScroll); + } chapterList.addEventListener("pointerdown", (e) => { if (e.button !== 0) return; // left button only @@ -353,6 +430,8 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { document.addEventListener("pointermove", (e) => { if (dragPointerId === null || draggingId === null) return; + dragClientX = e.clientX; + dragClientY = e.clientY; if (!dragMoved) { if (Math.abs(e.clientY - dragStartY) < 4) return; // movement threshold dragMoved = true; @@ -375,27 +454,16 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { src.replaceWith(dragPlaceholder); } document.body.classList.add("dragging-active"); + startDragScroll(); } if (dragGhost) dragGhost.style.top = `${e.clientY - dragOffsetY}px`; - const overEl = document.elementFromPoint(e.clientX, e.clientY); - const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null; - chapterList.querySelectorAll(".chapter-row").forEach((n) => { - n.classList.remove("drop-target", "drop-before", "drop-after"); - }); - if (overLi && overLi.dataset.id !== draggingId) { - overLi.classList.add("drop-target"); - const before = e.clientY < overLi.getBoundingClientRect().top + overLi.offsetHeight / 2; - overLi.classList.add(before ? "drop-before" : "drop-after"); - if (dragPlaceholder) { - if (before) chapterList.insertBefore(dragPlaceholder, overLi); - else chapterList.insertBefore(dragPlaceholder, overLi.nextSibling); - } - } + updateDropTarget(e.clientX, e.clientY); }); document.addEventListener("pointerup", (e) => { if (dragPointerId === null) return; dragPointerId = null; + stopDragScroll(); document.body.classList.remove("dragging-active"); if (!dragMoved || !draggingId) { draggingId = null; @@ -437,6 +505,22 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature { draggingId = null; }); + // If the OS cancels the gesture (touch interruption, alert, etc.), + // stop scrolling and restore the list so no ghost/placeholder is left behind. + document.addEventListener("pointercancel", () => { + if (dragPointerId === null) return; + dragPointerId = null; + stopDragScroll(); + document.body.classList.remove("dragging-active"); + dragGhost?.remove(); + dragGhost = null; + dragPlaceholder?.remove(); + dragPlaceholder = null; + draggingId = null; + dragMoved = false; + renderChapterList(); + }); + addChapterBtn.addEventListener("click", () => void create()); return { From 1413e73015a477904266f0371ec217a6f7719c0d Mon Sep 17 00:00:00 2001 From: avi Date: Sat, 19 Sep 2026 16:37:56 -0500 Subject: [PATCH 3/4] feat(backup): autosave backend, continuous snapshots, and Settings modal Background autosave toggle plus whole-book snapshots (default /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. --- src/main/backup.ts | 129 ++++++++++++++++++++++++ src/main/index.ts | 118 ++++++++++++++++++++++ src/main/menu.ts | 15 +-- src/main/preload.ts | 11 +++ src/renderer/book/editor.ts | 29 +++++- src/renderer/book/index.ts | 8 ++ src/renderer/book/settings.ts | 179 ++++++++++++++++++++++++++++++++++ src/renderer/book/types.ts | 12 +++ src/renderer/index.css | 54 ++++++++++ src/renderer/index.html | 42 ++++++++ tests/run-snapshots-test.mjs | 99 +++++++++++++++++++ 11 files changed, 682 insertions(+), 14 deletions(-) create mode 100644 src/renderer/book/settings.ts create mode 100644 tests/run-snapshots-test.mjs diff --git a/src/main/backup.ts b/src/main/backup.ts index ba9c651..9d93187 100644 --- a/src/main/backup.ts +++ b/src/main/backup.ts @@ -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: +// +// /Folio-Backups/-/ +// +// 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("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; +} diff --git a/src/main/index.ts b/src/main/index.ts index 3f83fb3..9db9721 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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, "" = /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("snapshotLocation", ""); + const resolved = bookPath ? getSnapshotRoot(bookPath) : getSnapshotRoot(); + const snaps = bookPath ? listSnapshots(bookPath) : []; + return { + autosaveEnabled: getSetting("autosaveEnabled", SNAPSHOT_DEFAULTS.autosaveEnabled), + snapshotsEnabled: getSetting("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("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; + 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("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({ diff --git a/src/main/menu.ts b/src/main/menu.ts index e42494c..67b9399 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -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" }, { diff --git a/src/main/preload.ts b/src/main/preload.ts index 569a145..3ee2b2d 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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()), }); diff --git a/src/renderer/book/editor.ts b/src/renderer/book/editor.ts index 38e8a4c..0d57d87 100644 --- a/src/renderer/book/editor.ts +++ b/src/renderer/book/editor.ts @@ -35,6 +35,9 @@ export interface EditorFeature { saveAndNotify(): Promise; flushAutosave(): Promise; 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 | 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 | 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, }; } diff --git a/src/renderer/book/index.ts b/src/renderer/book/index.ts index 5915827..2980f3e 100644 --- a/src/renderer/book/index.ts +++ b/src/renderer/book/index.ts @@ -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); diff --git a/src/renderer/book/settings.ts b/src/renderer/book/settings.ts new file mode 100644 index 0000000..f14e02e --- /dev/null +++ b/src/renderer/book/settings.ts @@ -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: /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, 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 }; +} diff --git a/src/renderer/book/types.ts b/src/renderer/book/types.ts index a2739bd..d60af22 100644 --- a/src/renderer/book/types.ts +++ b/src/renderer/book/types.ts @@ -76,6 +76,12 @@ export type FolioAPI = { setTheme: (id: string) => Promise; getZoom: () => Promise; setZoom: (pct: number) => Promise; + getAutosave: () => Promise; + setAutosave: (on: boolean) => Promise; + getSnapshotSettings: () => Promise; + setSnapshotSettings: (patch: unknown) => Promise; + pickSnapshotFolder: () => Promise; + snapshotNow: () => Promise; 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; flushAutosave(): Promise; clearTimers(): void; + setAutosaveEnabled(on: boolean): void; + setTypewriterEnabled(on: boolean): void; + isTypewriterEnabled(): boolean; + // settings feature + openSettings(): void; // session coordinators (orchestrator) refreshMeta(): Promise; showPlaceholder(): void; diff --git a/src/renderer/index.css b/src/renderer/index.css index c2087f4..1cb21dd 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -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); diff --git a/src/renderer/index.html b/src/renderer/index.html index a052ce3..9e7a5e8 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -30,6 +30,7 @@