Compare commits

...

4 commits

Author SHA1 Message Date
avi
542a545c0d feat: spellcheck menu, typewriter mode, file associations, updater hardening
English spellcheck suggestions with Add-to-dictionary in the native context menu plus a Settings toggle. Typewriter caret-centering mode. OS file associations for .md/.txt and Open File wording. Updater: release URL surfaced, single progress listener, two-byte tarball check, installer wait guard. Search/installer test suites registered; chapter-list layout polish; ignore KDE .directory files.
2026-09-19 16:38:17 -05:00
avi
1413e73015 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.
2026-09-19 16:37:56 -05:00
avi
341b89cd8a 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.
2026-09-19 16:37:45 -05:00
avi
ada5eee679 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.
2026-09-19 00:02:58 -05:00
23 changed files with 1847 additions and 94 deletions

1
.gitignore vendored
View file

@ -4,6 +4,7 @@ dist/
*.asar *.asar
releases/ releases/
.DS_Store .DS_Store
.directory
tests/.tmp-folio-format-*.mjs tests/.tmp-folio-format-*.mjs
.opencode/ .opencode/

View file

@ -22,6 +22,25 @@ linux:
- AppImage - AppImage
- deb - deb
# OS file associations: double-click / "Open with Folio" on Markdown and
# plain-text files imports them as books (see folio:openPathOrImport and
# fileArgFrom in src/main/index.ts). Without this the desktop environment
# never offers Folio for .txt/.md files.
fileAssociations:
- ext:
- md
- markdown
name: Folio Markdown document
description: Markdown document opened as a Folio book
role: Editor
mimeType: text/markdown
- ext:
- txt
name: Folio text document
description: Plain-text document opened as a Folio book
role: Editor
mimeType: text/plain
# Debian package options live at the config root in electron-builder v26. # Debian package options live at the config root in electron-builder v26.
# Setting `depends` replaces the auto-generated list, so keep its entries and # Setting `depends` replaces the auto-generated list, so keep its entries and
# add what Electron links but the defaults omit — notably libasound2 (ALSA), # add what Electron links but the defaults omit — notably libasound2 (ALSA),

View file

@ -15,7 +15,7 @@
"dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"", "dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"",
"start": "npm run build && npm run electron", "start": "npm run build && npm run electron",
"lint": "tsc --noEmit", "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 && node tests/run-update-installer-test.mjs && node tests/run-snapshots-test.mjs && node tests/run-search-test.mjs",
"package": "electron-builder" "package": "electron-builder"
}, },
"devDependencies": { "devDependencies": {

View file

@ -70,3 +70,132 @@ export function ensureRecoveryBackup(bookPath: string): string | null {
export function _resetSessionBackupGuard(): void { export function _resetSessionBackupGuard(): void {
sessionBackedUp.clear(); sessionBackedUp.clear();
} }
// ---- Continuous snapshots -----------------------------------------------
// Unlike the migration recovery backup above (external location, once per
// session), snapshots are periodic copies of the whole book used as an
// ongoing safety net while writing. Defaults keep them next to the work:
//
// <book>/Folio-Backups/<bookName>-<stamp>/
//
// A custom root can be chosen in Settings; it then applies to every book.
// Only the newest `keep` snapshots are retained (oldest pruned).
export const SNAPSHOT_DIR_NAME = "Folio-Backups";
export const SNAPSHOT_KEEP_DEFAULT = 20;
export function getSnapshotRoot(bookPath?: string): string {
const custom = store ? store.get<string>("snapshotLocation", "") : "";
if (custom && custom.trim()) return path.resolve(custom.trim());
if (bookPath) return path.join(path.resolve(bookPath), SNAPSHOT_DIR_NAME);
return getRecoveryRoot();
}
function snapshotPrefix(bookPath: string): string {
return `${path.basename(path.resolve(bookPath)) || "book"}-`;
}
// Snapshot directories for a book, oldest first.
export function listSnapshots(bookPath: string): string[] {
const root = getSnapshotRoot(bookPath);
const prefix = snapshotPrefix(bookPath);
let entries: string[];
try {
entries = fs.readdirSync(root);
} catch {
return [];
}
return entries
.filter((e) => e.startsWith(prefix))
.sort()
.map((e) => path.join(root, e));
}
// True when any file in the book (outside its own snapshot dir) changed
// after `sinceMs`. Lets the interval timer skip quiet periods instead of
// piling up identical copies.
export function bookChangedSince(bookPath: string, sinceMs: number): boolean {
const key = path.resolve(bookPath);
const snapRoot = path.join(key, SNAPSHOT_DIR_NAME) + path.sep;
const stack: string[] = [key];
while (stack.length) {
const cur = stack.pop() as string;
let stat: fs.Stats;
try {
stat = fs.statSync(cur);
} catch {
continue;
}
if (stat.isDirectory()) {
if ((cur + path.sep).startsWith(snapRoot)) continue;
let kids: string[];
try {
kids = fs.readdirSync(cur);
} catch {
continue;
}
for (const k of kids) stack.push(path.join(cur, k));
} else if (stat.mtimeMs > sinceMs) {
return true;
}
}
return false;
}
export function pruneSnapshots(bookPath: string, keep: number): void {
const n = Number.isFinite(keep) && keep >= 0 ? Math.floor(keep) : SNAPSHOT_KEEP_DEFAULT;
const snaps = listSnapshots(bookPath);
const excess = snaps.length - n;
for (let i = 0; i < excess; i++) {
try {
fs.rmSync(snaps[i], { recursive: true, force: true });
} catch {
// best-effort pruning only; a leftover old snapshot is harmless
}
}
}
// Copy the book to a new timestamped snapshot directory (excluding the
// snapshot dir itself so snapshots never nest). Returns the new directory,
// or null when the book path does not exist.
export function createSnapshot(bookPath: string, keep?: number): string | null {
const key = path.resolve(bookPath);
let stat: fs.Stats;
try {
stat = fs.statSync(key);
} catch {
return null;
}
if (!stat.isDirectory()) return null;
const root = getSnapshotRoot(bookPath);
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
let dest = path.join(root, `${snapshotPrefix(bookPath)}${stamp}`);
// Same-millisecond snapshots (e.g. double-clicked "Snapshot now") must not
// silently merge into one directory.
for (let n = 2; fs.existsSync(dest); n++) {
dest = path.join(root, `${snapshotPrefix(bookPath)}${stamp}-${n}`);
}
fs.mkdirSync(root, { recursive: true });
try {
// Copy entry-by-entry (skipping the snapshot dir) instead of one cpSync
// of the book root: Node refuses to copy a directory into a subdirectory
// of itself even when a filter excludes the destination.
const entries = fs.readdirSync(key);
fs.mkdirSync(dest, { recursive: true });
for (const e of entries) {
if (e === SNAPSHOT_DIR_NAME) continue;
fs.cpSync(path.join(key, e), path.join(dest, e), { recursive: true });
}
} catch (err) {
// A half-copied directory is worse than none — it looks like a good
// snapshot. Remove the partial copy, then surface the failure.
try {
fs.rmSync(dest, { recursive: true, force: true });
} catch {
// best-effort cleanup only
}
throw err;
}
pruneSnapshots(bookPath, keep ?? SNAPSHOT_KEEP_DEFAULT);
return dest;
}

56
src/main/import-md.ts Normal file
View file

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

View file

@ -1,4 +1,4 @@
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from "electron"; import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, session, shell } from "electron";
import type { OpenDialogOptions, SaveDialogOptions } from "electron"; import type { OpenDialogOptions, SaveDialogOptions } from "electron";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
@ -34,14 +34,32 @@ import {
import { combineMarkdown, buildZip } from "./export.js"; import { combineMarkdown, buildZip } from "./export.js";
import { import {
bindBackupSettings, bindBackupSettings,
bookChangedSince,
createSnapshot,
getRecoveryRoot, getRecoveryRoot,
getSnapshotRoot,
listSnapshots,
pruneSnapshots,
setRecoveryRoot, setRecoveryRoot,
SNAPSHOT_KEEP_DEFAULT,
} from "./backup.js"; } from "./backup.js";
import { sanitizeBookFolderName } from "./ids.js"; import { sanitizeBookFolderName } from "./ids.js";
import { buildDocx, buildOdt } from "./office-export.js"; import { buildDocx, buildOdt } from "./office-export.js";
import { getSetting, setSetting } from "./settings.js"; import { getSetting, setSetting } from "./settings.js";
import { migrateChapterToNative, migrateBookToNative } from "./migration.js"; import { migrateChapterToNative, migrateBookToNative } from "./migration.js";
import { loadChapterDoc, saveChapterDoc } from "./chapters.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 mainWindow: BrowserWindow | null = null;
let bookPath: string | null = null; let bookPath: string | null = null;
@ -68,8 +86,8 @@ function createWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1280, width: 1280,
height: 780, height: 780,
minWidth: 960, minWidth: 560,
minHeight: 640, minHeight: 400,
title: "Folio", title: "Folio",
icon: path.join(__dirname, "icons", "icon.png"), icon: path.join(__dirname, "icons", "icon.png"),
webPreferences: { webPreferences: {
@ -79,6 +97,7 @@ function createWindow() {
}, },
}); });
forwardEditShortcuts(mainWindow); forwardEditShortcuts(mainWindow);
wireContextMenu(mainWindow);
mainWindow.loadFile(path.join(__dirname, "index.html")); mainWindow.loadFile(path.join(__dirname, "index.html"));
mainWindow.webContents.on("did-finish-load", () => { mainWindow.webContents.on("did-finish-load", () => {
console.log("[folio] renderer loaded"); console.log("[folio] renderer loaded");
@ -109,6 +128,15 @@ function createWindow() {
app.whenReady().then(() => { app.whenReady().then(() => {
migrateLegacyRecents(); migrateLegacyRecents();
// English-only spellcheck dictionary + stored on/off preference.
try {
session.defaultSession.setSpellCheckerLanguages(["en-US"]);
if (getSetting<boolean>("spellcheckEnabled", true) === false) {
session.defaultSession.setSpellCheckerEnabled(false);
}
} catch (err) {
console.error("[folio] spellchecker setup failed:", (err as Error).message);
}
buildMenu( buildMenu(
getWindow, getWindow,
getState, getState,
@ -116,6 +144,13 @@ app.whenReady().then(() => {
() => getWindow()?.webContents.send("folio:open-book-requested") () => getWindow()?.webContents.send("folio:open-book-requested")
); );
createWindow(); 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", () => { app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow(); if (BrowserWindow.getAllWindows().length === 0) createWindow();
}); });
@ -685,19 +720,57 @@ handleIpc("folio:toggleFullscreen", () => {
return win.isFullScreen(); return win.isFullScreen();
}); });
// Right-click context menu (Electron has none by default), so Cut/Copy/Paste // Right-click menu with spelling suggestions (English-only dictionary).
// via the mouse work. The roles act on whatever element is focused. // Electron builds no default context menu, so without this the red
ipcMain.on("folio:context-menu", (event, x: number, y: number) => { // misspelling underlines had no way to reach a suggestion. Chromium reports
const win = BrowserWindow.fromWebContents(event.sender); // the misspelled word + candidates via the webContents event; suggestions
if (!win) return; // replace the word in place, and "Add to dictionary" persists to the
const menu = Menu.buildFromTemplate([ // session's custom words.
function wireContextMenu(win: BrowserWindow) {
win.webContents.on("context-menu", (_e, params) => {
const target = getWindow();
if (!target) return;
const items: Electron.MenuItemConstructorOptions[] = [];
if (params.misspelledWord) {
const sugs = (params.dictionarySuggestions || []).slice(0, 5);
if (sugs.length) {
for (const s of sugs) {
items.push({ label: s, click: () => target.webContents.replaceMisspelling(s) });
}
} else {
items.push({ label: "(no suggestions)", enabled: false });
}
items.push({
label: "Add to dictionary",
click: () =>
target.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord),
});
items.push({ type: "separator" });
}
items.push(
{ role: "cut" }, { role: "cut" },
{ role: "copy" }, { role: "copy" },
{ role: "paste" }, { role: "paste" },
{ type: "separator" }, { type: "separator" },
{ role: "selectAll" }, { role: "selectAll" }
]); );
menu.popup({ window: win, x, y }); Menu.buildFromTemplate(items).popup({ window: target, x: params.x, y: params.y });
});
}
// Spellcheck preference (English-only dictionary). Applied at startup and
// toggled live from Settings; the editor's spellcheck attribute stays on so
// Chromium underlines misspellings whenever the session checker is enabled.
handleIpc("folio:getSpellcheck", () => getSetting<boolean>("spellcheckEnabled", true));
handleIpc("folio:setSpellcheck", (_e, on: unknown) => {
const enabled = on !== false;
setSetting("spellcheckEnabled", enabled);
try {
session.defaultSession.setSpellCheckerEnabled(enabled);
} catch {
// applied on next launch if the session is not ready yet
}
return { ok: true, enabled };
}); });
// Theme preference. Stored as a plain string so future themes can be added // Theme preference. Stored as a plain string so future themes can be added
@ -733,6 +806,118 @@ handleIpc("folio:setBackupLocation", (_e, loc: unknown) => {
return { ok: true }; return { ok: true };
}); });
// ---- Autosave + continuous snapshots --------------------------------------
// Settings keys (all in the shared settings store, defaults = on / sane):
// autosaveEnabled background autosave while typing (default true)
// snapshotsEnabled periodic whole-book snapshots (default true)
// snapshotIntervalMin minutes between snapshot checks (default 5)
// snapshotKeep how many recent snapshots to retain (default 20)
// snapshotLocation custom snapshot root, "" = <book>/Folio-Backups
const SNAPSHOT_DEFAULTS = {
autosaveEnabled: true,
snapshotsEnabled: true,
intervalMin: 5,
keep: SNAPSHOT_KEEP_DEFAULT,
};
function clampInt(v: unknown, fallback: number, min: number, max: number): number {
const n = typeof v === "number" ? Math.round(v) : fallback;
if (!Number.isFinite(n)) return fallback;
return Math.max(min, Math.min(max, n));
}
function snapshotSettingsSnapshot() {
const location = getSetting<string>("snapshotLocation", "");
const resolved = bookPath ? getSnapshotRoot(bookPath) : getSnapshotRoot();
const snaps = bookPath ? listSnapshots(bookPath) : [];
return {
autosaveEnabled: getSetting<boolean>("autosaveEnabled", SNAPSHOT_DEFAULTS.autosaveEnabled),
snapshotsEnabled: getSetting<boolean>("snapshotsEnabled", SNAPSHOT_DEFAULTS.snapshotsEnabled),
intervalMin: clampInt(getSetting("snapshotIntervalMin", SNAPSHOT_DEFAULTS.intervalMin), SNAPSHOT_DEFAULTS.intervalMin, 1, 1440),
keep: clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200),
location,
resolvedRoot: resolved,
snapshotCount: snaps.length,
lastSnapshot: snaps.length ? snaps[snaps.length - 1] : null,
};
}
handleIpc("folio:getAutosave", () =>
getSetting<boolean>("autosaveEnabled", SNAPSHOT_DEFAULTS.autosaveEnabled)
);
handleIpc("folio:setAutosave", (_e, on: unknown) => {
setSetting("autosaveEnabled", on !== false);
return { ok: true, enabled: on !== false };
});
handleIpc("folio:getSnapshotSettings", () => snapshotSettingsSnapshot());
handleIpc("folio:setSnapshotSettings", (_e, patch: unknown) => {
const p = (patch && typeof patch === "object" ? patch : {}) as Record<string, unknown>;
if ("snapshotsEnabled" in p) setSetting("snapshotsEnabled", p.snapshotsEnabled !== false);
if ("intervalMin" in p) {
setSetting("snapshotIntervalMin", clampInt(p.intervalMin, SNAPSHOT_DEFAULTS.intervalMin, 1, 1440));
}
if ("keep" in p) {
const keep = clampInt(p.keep, SNAPSHOT_DEFAULTS.keep, 0, 200);
setSetting("snapshotKeep", keep);
// Apply a lowered retention immediately to the open book.
if (bookPath) {
try { pruneSnapshots(bookPath, keep); } catch { /* best effort */ }
}
}
if ("location" in p) {
const s = typeof p.location === "string" ? p.location.trim() : "";
setSetting("snapshotLocation", s);
}
return { ok: true, settings: snapshotSettingsSnapshot() };
});
handleIpc("folio:pickSnapshotFolder", async () => {
const { canceled, filePaths } = await showOpenDialog({
properties: ["openDirectory"],
title: "Choose snapshot folder",
});
return { canceled, filePaths };
});
// Interval-snapshot bookkeeping (declared before use by the handlers below).
let snapshotLastRunMs = 0;
let snapshotLastSnapMs = 0;
let snapshotBook: string | null = null;
handleIpc("folio:snapshotNow", () => {
const bp = requireBook();
const keep = clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200);
const dest = createSnapshot(bp, keep);
snapshotLastSnapMs = Date.now();
return { ok: true, path: dest, settings: snapshotSettingsSnapshot() };
});
// Interval snapshots: once a minute, check whether the open book changed
// since the last snapshot and whether the configured interval elapsed. The
// 1-minute tick keeps wakeups cheap; the user-facing cadence is intervalMin.
setInterval(() => {
try {
if (!bookPath) return;
if (snapshotBook !== bookPath) {
snapshotBook = bookPath;
snapshotLastSnapMs = Date.now();
snapshotLastRunMs = Date.now();
return;
}
if (getSetting<boolean>("snapshotsEnabled", SNAPSHOT_DEFAULTS.snapshotsEnabled) !== true) return;
const intervalMin = clampInt(
getSetting("snapshotIntervalMin", SNAPSHOT_DEFAULTS.intervalMin),
SNAPSHOT_DEFAULTS.intervalMin, 1, 1440
);
const now = Date.now();
if (now - snapshotLastRunMs < intervalMin * 60_000) return;
snapshotLastRunMs = now;
if (!bookChangedSince(bookPath, snapshotLastSnapMs)) return;
const keep = clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200);
createSnapshot(bookPath, keep);
snapshotLastSnapMs = now;
} catch (err) {
console.error("[folio] interval snapshot failed:", (err as Error).message);
}
}, 60_000);
handleIpc("folio:importAttachment", async () => { handleIpc("folio:importAttachment", async () => {
const bp = requireBook(); const bp = requireBook();
const { canceled, filePaths } = await showOpenDialog({ const { canceled, filePaths } = await showOpenDialog({
@ -756,3 +941,237 @@ handleIpc("folio:importAttachment", async () => {
const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/"); const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/");
return { ok: true, name, relPath }; 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 or text file(s)",
properties: ["openFile", "multiSelections"],
filters: [{ name: "Markdown and text", 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;
url?: string;
canInstall?: boolean;
error?: string;
}
async function fetchLatestRelease(): Promise<ReturnType<typeof parseRelease>> {
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<UpdateCheckResult> => {
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,
url: rel.url,
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));
// Verify the gzip magic (2 bytes) without loading a ~100MB tarball into
// memory. Anything else means a truncated download or an HTML error page.
let head = Buffer.alloc(0);
try {
const fd = fs.openSync(tarball, "r");
try {
const buf = Buffer.alloc(2);
const n = fs.readSync(fd, buf, 0, 2, 0);
head = buf.subarray(0, n);
} finally {
fs.closeSync(fd);
}
} catch {
head = Buffer.alloc(0);
}
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);
});
}

View file

@ -1,8 +1,7 @@
import path from "path"; import path from "path";
import { app, dialog, Menu, BrowserWindow } from "electron"; import { app, Menu, BrowserWindow } from "electron";
import { listRecents } from "./recents.js"; import { listRecents } from "./recents.js";
import type { Recent } from "./recents.js"; import type { Recent } from "./recents.js";
import { setRecoveryRoot } from "./backup.js";
type MenuItem = Electron.MenuItemConstructorOptions; type MenuItem = Electron.MenuItemConstructorOptions;
type GetState = () => { bookPath: string | null }; type GetState = () => { bookPath: string | null };
@ -38,6 +37,10 @@ export function buildMenu(
submenu: [ submenu: [
{ label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew }, { label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew },
{ label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen }, { label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen },
{
label: "Open File…",
click: () => getWindow()?.webContents.send("folio:open-markdown-requested"),
},
{ type: "separator" }, { type: "separator" },
{ {
label: "Save", label: "Save",
@ -71,18 +74,9 @@ export function buildMenu(
}, },
}, },
{ {
label: "Backup Location…", label: "Settings…",
click: async () => { accelerator: "CmdOrCtrl+,",
const win = getWindow(); click: () => getWindow()?.webContents.send("folio:settings-requested"),
const opts = {
properties: ["openDirectory" as const],
title: "Choose recovery backup folder",
};
const { canceled, filePaths } = win
? await dialog.showOpenDialog(win, opts)
: await dialog.showOpenDialog(opts);
if (!canceled && filePaths[0]) setRecoveryRoot(filePaths[0]);
},
}, },
{ type: "separator" }, { type: "separator" },
{ {
@ -113,6 +107,11 @@ export function buildMenu(
click: () => getWindow()?.webContents.send("folio:export-odt-requested"), click: () => getWindow()?.webContents.send("folio:export-odt-requested"),
}, },
{ type: "separator" }, { type: "separator" },
{
label: "Check for Updates…",
click: () => getWindow()?.webContents.send("folio:check-update-requested"),
},
{ type: "separator" },
{ {
label: "Close", label: "Close",
accelerator: isMac ? "Cmd+W" : "Ctrl+W", accelerator: isMac ? "Cmd+W" : "Ctrl+W",

View file

@ -10,8 +10,10 @@ contextBridge.exposeInMainWorld("folio", {
revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p), revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p),
getRecents: () => ipcRenderer.invoke("folio:getRecents"), getRecents: () => ipcRenderer.invoke("folio:getRecents"),
closeBook: () => ipcRenderer.invoke("folio:closeBook"), closeBook: () => ipcRenderer.invoke("folio:closeBook"),
showContextMenu: (x: number, y: number) =>
ipcRenderer.send("folio:context-menu", x, y), // spelling (English dictionary, suggestions in the native context menu)
getSpellcheck: () => ipcRenderer.invoke("folio:getSpellcheck"),
setSpellcheck: (on: boolean) => ipcRenderer.invoke("folio:setSpellcheck", on),
// chapters // chapters
getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"), getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"),
@ -54,6 +56,12 @@ contextBridge.exposeInMainWorld("folio", {
exportOdt: (chapterIds?: string[] | null) => exportOdt: (chapterIds?: string[] | null) =>
ipcRenderer.invoke("folio:exportOdt", chapterIds), ipcRenderer.invoke("folio:exportOdt", chapterIds),
importAttachment: () => ipcRenderer.invoke("folio:importAttachment"), 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), copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text),
getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"), getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"),
toggleFullscreen: () => ipcRenderer.invoke("folio:toggleFullscreen"), toggleFullscreen: () => ipcRenderer.invoke("folio:toggleFullscreen"),
@ -69,6 +77,15 @@ contextBridge.exposeInMainWorld("folio", {
setBackupLocation: (loc: string) => ipcRenderer.invoke("folio:setBackupLocation", loc), setBackupLocation: (loc: string) => ipcRenderer.invoke("folio:setBackupLocation", loc),
pickBackupFolder: () => ipcRenderer.invoke("folio:pickBackupFolder"), pickBackupFolder: () => ipcRenderer.invoke("folio:pickBackupFolder"),
// autosave + continuous snapshots
getAutosave: () => ipcRenderer.invoke("folio:getAutosave"),
setAutosave: (on: boolean) => ipcRenderer.invoke("folio:setAutosave", on),
getSnapshotSettings: () => ipcRenderer.invoke("folio:getSnapshotSettings"),
setSnapshotSettings: (patch: unknown) =>
ipcRenderer.invoke("folio:setSnapshotSettings", patch),
pickSnapshotFolder: () => ipcRenderer.invoke("folio:pickSnapshotFolder"),
snapshotNow: () => ipcRenderer.invoke("folio:snapshotNow"),
// events // events
onBookOpened: (cb: (data: { path: string; title: string }) => void) => onBookOpened: (cb: (data: { path: string; title: string }) => void) =>
ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)), ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)),
@ -110,4 +127,10 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.on("folio:zoom-out-requested", () => cb()), ipcRenderer.on("folio:zoom-out-requested", () => cb()),
onZoomResetRequested: (cb: () => void) => onZoomResetRequested: (cb: () => void) =>
ipcRenderer.on("folio:zoom-reset-requested", () => cb()), 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()),
onSettingsRequested: (cb: () => void) =>
ipcRenderer.on("folio:settings-requested", () => cb()),
}); });

173
src/main/update.ts Normal file
View file

@ -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.<pid> 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 [ "$PID" -gt 0 ] 2>/dev/null && 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");
}

View file

@ -41,6 +41,7 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
"#3b82f6", "#8b5cf6", "#78716c", "#18181b", "#3b82f6", "#8b5cf6", "#78716c", "#18181b",
]; ];
const HEX_RE = /^#[0-9a-fA-F]{3,8}$/; const HEX_RE = /^#[0-9a-fA-F]{3,8}$/;
let animateNextRender = false;
let colorPopover: HTMLDivElement | null = null; let colorPopover: HTMLDivElement | null = null;
function closeColorPopover() { function closeColorPopover() {
@ -150,7 +151,6 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
li.draggable = false; li.draggable = false;
li.tabIndex = -1; li.tabIndex = -1;
li.innerHTML = ` li.innerHTML = `
<span class="drag-handle" title="Drag to reorder">⋮</span>
<span class="row-title" title="${ch.title}">${ch.title}</span> <span class="row-title" title="${ch.title}">${ch.title}</span>
<span class="row-actions"> <span class="row-actions">
<button class="icon-btn" data-act="color" title="Chapter color">●</button> <button class="icon-btn" data-act="color" title="Chapter color">●</button>
@ -299,12 +299,29 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
} }
} }
async function persistOrder() { async function persistOrder(scrollSelected = false) {
if (!state.meta) return; if (!state.meta) return;
state.meta.chapterOrder = state.ordered.map((c) => c.id); state.meta.chapterOrder = state.ordered.map((c) => c.id);
const res = await api.reorderChapters(state.meta.chapterOrder); const res = await api.reorderChapters(state.meta.chapterOrder);
if (res && "error" in res) await alertMessage("Could not reorder chapters", (res as { error: string }).error); 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() { function moveSelectedDown() {
@ -313,8 +330,10 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
if (i < 0 || i >= state.ordered.length - 1) return; if (i < 0 || i >= state.ordered.length - 1) return;
state.ordered = moveInList(state.ordered, i, i + 1); state.ordered = moveInList(state.ordered, i, i + 1);
state.meta!.chapterOrder = state.ordered.map((c) => c.id); state.meta!.chapterOrder = state.ordered.map((c) => c.id);
animateNextRender = true;
renderChapterList(); renderChapterList();
void persistOrder(); keepSelectedVisible();
void persistOrder(true);
} }
function moveSelectedUp() { function moveSelectedUp() {
@ -323,8 +342,10 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
if (i <= 0) return; if (i <= 0) return;
state.ordered = moveInList(state.ordered, i, i - 1); state.ordered = moveInList(state.ordered, i, i - 1);
state.meta!.chapterOrder = state.ordered.map((c) => c.id); state.meta!.chapterOrder = state.ordered.map((c) => c.id);
animateNextRender = true;
renderChapterList(); renderChapterList();
void persistOrder(); keepSelectedVisible();
void persistOrder(true);
} }
// ---- pointer-based reordering ---- // ---- pointer-based reordering ----
@ -333,10 +354,65 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
let dragStartY = 0; let dragStartY = 0;
let dragMoved = false; let dragMoved = false;
let dragJustHappened = false; let dragJustHappened = false;
let animateNextRender = false;
let dragGhost: HTMLElement | null = null; let dragGhost: HTMLElement | null = null;
let dragPlaceholder: HTMLElement | null = null; let dragPlaceholder: HTMLElement | null = null;
let dragOffsetY = 0; 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) => { chapterList.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return; // left button only if (e.button !== 0) return; // left button only
@ -353,6 +429,8 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
document.addEventListener("pointermove", (e) => { document.addEventListener("pointermove", (e) => {
if (dragPointerId === null || draggingId === null) return; if (dragPointerId === null || draggingId === null) return;
dragClientX = e.clientX;
dragClientY = e.clientY;
if (!dragMoved) { if (!dragMoved) {
if (Math.abs(e.clientY - dragStartY) < 4) return; // movement threshold if (Math.abs(e.clientY - dragStartY) < 4) return; // movement threshold
dragMoved = true; dragMoved = true;
@ -375,27 +453,16 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
src.replaceWith(dragPlaceholder); src.replaceWith(dragPlaceholder);
} }
document.body.classList.add("dragging-active"); document.body.classList.add("dragging-active");
startDragScroll();
} }
if (dragGhost) dragGhost.style.top = `${e.clientY - dragOffsetY}px`; if (dragGhost) dragGhost.style.top = `${e.clientY - dragOffsetY}px`;
const overEl = document.elementFromPoint(e.clientX, e.clientY); updateDropTarget(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);
}
}
}); });
document.addEventListener("pointerup", (e) => { document.addEventListener("pointerup", (e) => {
if (dragPointerId === null) return; if (dragPointerId === null) return;
dragPointerId = null; dragPointerId = null;
stopDragScroll();
document.body.classList.remove("dragging-active"); document.body.classList.remove("dragging-active");
if (!dragMoved || !draggingId) { if (!dragMoved || !draggingId) {
draggingId = null; draggingId = null;
@ -437,6 +504,22 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
draggingId = null; 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()); addChapterBtn.addEventListener("click", () => void create());
return { return {

View file

@ -35,6 +35,9 @@ export interface EditorFeature {
saveAndNotify(): Promise<void>; saveAndNotify(): Promise<void>;
flushAutosave(): Promise<void>; flushAutosave(): Promise<void>;
clearTimers(): void; clearTimers(): void;
setAutosaveEnabled(on: boolean): void;
setTypewriterEnabled(on: boolean): void;
isTypewriterEnabled(): boolean;
} }
export function createEditorFeature(ctx: BookCtx): EditorFeature { export function createEditorFeature(ctx: BookCtx): EditorFeature {
@ -48,6 +51,45 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
}); });
const editorHost = $("editorHost"); const editorHost = $("editorHost");
// ---- typewriter mode ----
// Keeps the caret near 42% of the visible editor height by scrolling the
// ProseMirror surface (which is its own scroll container) as the selection
// moves. Renderer-local preference; nothing leaves the machine.
const TYPEWRITER_KEY = "folio:typewriter";
let typewriterOn = false;
try {
typewriterOn = localStorage.getItem(TYPEWRITER_KEY) === "1";
} catch {
typewriterOn = false;
}
function setTypewriterEnabled(on: boolean) {
typewriterOn = on !== false;
try {
localStorage.setItem(TYPEWRITER_KEY, typewriterOn ? "1" : "0");
} catch {
// private-mode storage failure: mode still applies for the session
}
if (typewriterOn) centerCaret();
}
function isTypewriterEnabled(): boolean {
return typewriterOn;
}
function centerCaret() {
if (!typewriterOn || !editor.isFocused) return;
if (!editor.state.selection.empty) return;
const scroller = editor.view.dom as HTMLElement;
const rect = scroller.getBoundingClientRect();
if (rect.height <= 0) return;
let y = 0;
try {
y = editor.view.coordsAtPos(editor.state.selection.from).top;
} catch {
return;
}
const dy = y - (rect.top + rect.height * 0.42);
if (Math.abs(dy) > 2) scroller.scrollTop += dy;
}
// The ProseMirror surface is narrower than its host for readable line // The ProseMirror surface is narrower than its host for readable line
// lengths. Keep the surrounding editor area clickable so a new chapter can // lengths. Keep the surrounding editor area clickable so a new chapter can
// be focused without requiring a click on the centered content column. // be focused without requiring a click on the centered content column.
@ -63,6 +105,19 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
const AUTOSAVE_MS = 1000; const AUTOSAVE_MS = 1000;
let saveTimer: ReturnType<typeof setTimeout> | null = null; let saveTimer: ReturnType<typeof setTimeout> | null = null;
const saveStateEl = $("saveState"); const saveStateEl = $("saveState");
// Background autosave switch (Settings). When off, typing only marks the
// chapter Unsaved; explicit Save, chapter switches, and app-hide flushes
// still persist so work is never silently lost.
let autosaveOn = true;
void api.getAutosave().then(
(v) => { autosaveOn = v !== false; },
() => { /* default on when the backend is unreachable */ }
);
function setAutosaveEnabled(on: boolean) {
autosaveOn = on !== false;
if (autosaveOn && state.selectedId && state.dirty) scheduleAutosave();
else if (!autosaveOn && saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
}
function clearTimers() { function clearTimers() {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
@ -153,9 +208,15 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
// Schedule a background save a short time after the last edit. The indicator // Schedule a background save a short time after the last edit. The indicator
// shows "Saving…" while the debounce is pending so the user knows the chapter // shows "Saving…" while the debounce is pending so the user knows the chapter
// is about to be persisted automatically. // is about to be persisted automatically. With background autosave off this
// only marks the chapter Unsaved until the user saves explicitly.
function scheduleAutosave() { function scheduleAutosave() {
clearTimers(); clearTimers();
if (!autosaveOn) {
saveStateEl.textContent = "Unsaved";
saveStateEl.classList.add("unsaved");
return;
}
saveStateEl.textContent = "Saving…"; saveStateEl.textContent = "Saving…";
saveStateEl.classList.remove("unsaved"); saveStateEl.classList.remove("unsaved");
saveTimer = setTimeout(() => void flushAutosave(), AUTOSAVE_MS); saveTimer = setTimeout(() => void flushAutosave(), AUTOSAVE_MS);
@ -174,7 +235,7 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
const FORCED_AUTOSAVE_MS = 30_000; const FORCED_AUTOSAVE_MS = 30_000;
let forcedSaveTimer: ReturnType<typeof setInterval> | null = null; let forcedSaveTimer: ReturnType<typeof setInterval> | null = null;
function startForcedAutosave() { function startForcedAutosave() {
if (forcedSaveTimer) return; if (forcedSaveTimer || !autosaveOn) return;
forcedSaveTimer = setInterval(() => { forcedSaveTimer = setInterval(() => {
if (state.selectedId && state.dirty) void flushAutosave(); if (state.selectedId && state.dirty) void flushAutosave();
}, FORCED_AUTOSAVE_MS); }, FORCED_AUTOSAVE_MS);
@ -217,6 +278,8 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
setActive("align-left", fmt.align === "left" || fmt.align === ""); setActive("align-left", fmt.align === "left" || fmt.align === "");
setActive("align-center", fmt.align === "center"); setActive("align-center", fmt.align === "center");
setActive("align-right", fmt.align === "right"); setActive("align-right", fmt.align === "right");
// Defer to the next frame so coords reflect the updated layout.
if (typewriterOn) requestAnimationFrame(centerCaret);
} }
// Ctrl/Cmd+S saves the current chapter (in addition to the menu item). // Ctrl/Cmd+S saves the current chapter (in addition to the menu item).
@ -359,5 +422,8 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
saveAndNotify, saveAndNotify,
flushAutosave, flushAutosave,
clearTimers, clearTimers,
setAutosaveEnabled,
setTypewriterEnabled,
isTypewriterEnabled,
}; };
} }

View file

@ -10,6 +10,7 @@ import { createChapterListFeature } from "./chapter-list.js";
import { createWelcomeFeature } from "./welcome.js"; import { createWelcomeFeature } from "./welcome.js";
import { createExportsFeature } from "./exports.js"; import { createExportsFeature } from "./exports.js";
import { createCommentsFeature } from "./comments.js"; import { createCommentsFeature } from "./comments.js";
import { createSettingsFeature } from "./settings.js";
import { import {
$, $,
basename, basename,
@ -60,6 +61,13 @@ export function initBook(api: FolioAPI) {
hooks.saveAndNotify = ed.saveAndNotify; hooks.saveAndNotify = ed.saveAndNotify;
hooks.flushAutosave = ed.flushAutosave; hooks.flushAutosave = ed.flushAutosave;
hooks.clearTimers = ed.clearTimers; hooks.clearTimers = ed.clearTimers;
hooks.setAutosaveEnabled = ed.setAutosaveEnabled;
hooks.setTypewriterEnabled = ed.setTypewriterEnabled;
hooks.isTypewriterEnabled = ed.isTypewriterEnabled;
// Feature: settings modal (autosave + snapshot backups).
const settings = createSettingsFeature(ctx);
hooks.openSettings = settings.openSettings;
// Feature: formatting toolbar + font controls. // Feature: formatting toolbar + font controls.
createToolbarFeature(ctx, editor); createToolbarFeature(ctx, editor);
@ -131,6 +139,56 @@ export function initBook(api: FolioAPI) {
// The comments module owns its own DOM chrome and wiring; here we only keep the // 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. // 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.
let updateProgressWired = false;
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." +
(res.url ? ` Download it manually from ${res.url}` : " Download it manually from the releases page.")
);
return;
}
// Registered once: checkForUpdates can run repeatedly per session and
// ipcRenderer.on would otherwise stack duplicate progress listeners.
if (!updateProgressWired) {
updateProgressWired = true;
let lastPct = -1;
api.onUpdateProgress(({ received, total }) => {
const pct = total ? Math.floor((received / total) * 100) : -1;
if (pct === lastPct) return;
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 ---- // ---- api menu handlers ----
// These now delegate to the extracted feature facades rather than calling // These now delegate to the extracted feature facades rather than calling
// inline functions defined in this module. // inline functions defined in this module.
@ -140,6 +198,9 @@ export function initBook(api: FolioAPI) {
api.onNewBookRequested(() => void welcome.startNewBook()); api.onNewBookRequested(() => void welcome.startNewBook());
api.onOpenBookRequested(() => void welcome.startOpenBook()); api.onOpenBookRequested(() => void welcome.startOpenBook());
api.onOpenRecent((p) => void welcome.openPath(p)); 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) => { api.onRevealRequested(async (p) => {
const ok = await api.revealInFolder(p); const ok = await api.revealInFolder(p);
showToast( showToast(

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

@ -201,17 +201,10 @@ export function createToolbarFeature(ctx: BookCtx, editor: Editor): ToolbarFeatu
// any of those closes the dropdowns. // any of those closes the dropdowns.
window.addEventListener("scroll", closeDropdowns, true); window.addEventListener("scroll", closeDropdowns, true);
window.addEventListener("resize", closeDropdowns); window.addEventListener("resize", closeDropdowns);
// Electron has no default right-click menu, so show one with Cut/Copy/Paste. // Note: right-click is handled by the main process (webContents
window.addEventListener("contextmenu", (e) => { // context-menu with spelling suggestions + Cut/Copy/Paste), so no DOM
// Let Chromium show its native spelling suggestions when the target is a // contextmenu listener here — preventing the default would suppress the
// misspelled word. The custom menu is used for the rest of the editor. // native suggestion menu for misspelled words.
const target = e.target as HTMLElement | null;
if (target?.closest(".ProseMirror") && window.getSelection()?.isCollapsed) {
return;
}
e.preventDefault();
api.showContextMenu(e.clientX, e.clientY);
});
const headingSelect = $("headingSelect") as HTMLSelectElement; const headingSelect = $("headingSelect") as HTMLSelectElement;
headingSelect.addEventListener("change", () => { headingSelect.addEventListener("change", () => {

View file

@ -39,7 +39,8 @@ export type FolioAPI = {
revealInFolder: (p: string) => Promise<boolean>; revealInFolder: (p: string) => Promise<boolean>;
getRecents: () => Promise<any[]>; getRecents: () => Promise<any[]>;
closeBook: () => Promise<boolean>; closeBook: () => Promise<boolean>;
showContextMenu: (x: number, y: number) => void; getSpellcheck: () => Promise<boolean>;
setSpellcheck: (on: boolean) => Promise<any>;
getBookMeta: () => Promise<BookMeta | { error: string }>; getBookMeta: () => Promise<BookMeta | { error: string }>;
setComments: (comments: Record<string, unknown>) => Promise<any>; setComments: (comments: Record<string, unknown>) => Promise<any>;
getChapterList: () => Promise<any[]>; getChapterList: () => Promise<any[]>;
@ -64,6 +65,11 @@ export type FolioAPI = {
exportDocx: (chapterIds?: string[] | null) => Promise<any>; exportDocx: (chapterIds?: string[] | null) => Promise<any>;
exportOdt: (chapterIds?: string[] | null) => Promise<any>; exportOdt: (chapterIds?: string[] | null) => Promise<any>;
importAttachment: () => Promise<any>; importAttachment: () => Promise<any>;
openMarkdownFiles: () => Promise<any>;
openPathOrImport: (p: string) => Promise<any>;
checkUpdate: () => Promise<any>;
performUpdate: () => Promise<any>;
onUpdateProgress: (cb: (p: { received: number; total: number }) => void) => void;
copyText: (text: string) => Promise<any>; copyText: (text: string) => Promise<any>;
getClipboardText: () => Promise<string>; getClipboardText: () => Promise<string>;
toggleFullscreen: () => Promise<boolean>; toggleFullscreen: () => Promise<boolean>;
@ -71,6 +77,12 @@ export type FolioAPI = {
setTheme: (id: string) => Promise<any>; setTheme: (id: string) => Promise<any>;
getZoom: () => Promise<number>; getZoom: () => Promise<number>;
setZoom: (pct: number) => Promise<any>; setZoom: (pct: number) => Promise<any>;
getAutosave: () => Promise<boolean>;
setAutosave: (on: boolean) => Promise<any>;
getSnapshotSettings: () => Promise<any>;
setSnapshotSettings: (patch: unknown) => Promise<any>;
pickSnapshotFolder: () => Promise<any>;
snapshotNow: () => Promise<any>;
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void; onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
onBookClosed: (cb: () => void) => void; onBookClosed: (cb: () => void) => void;
onChaptersChanged: (cb: () => void) => void; onChaptersChanged: (cb: () => void) => void;
@ -91,6 +103,9 @@ export type FolioAPI = {
onZoomInRequested: (cb: () => void) => void; onZoomInRequested: (cb: () => void) => void;
onZoomOutRequested: (cb: () => void) => void; onZoomOutRequested: (cb: () => void) => void;
onZoomResetRequested: (cb: () => void) => void; onZoomResetRequested: (cb: () => void) => void;
onOpenMarkdownRequested: (cb: () => void) => void;
onCheckUpdateRequested: (cb: () => void) => void;
onSettingsRequested: (cb: () => void) => void;
}; };
// ---- P7 decomposition contracts ---- // ---- P7 decomposition contracts ----
@ -118,6 +133,11 @@ export interface BookHooks {
saveAndNotify(): Promise<void>; saveAndNotify(): Promise<void>;
flushAutosave(): Promise<void>; flushAutosave(): Promise<void>;
clearTimers(): void; clearTimers(): void;
setAutosaveEnabled(on: boolean): void;
setTypewriterEnabled(on: boolean): void;
isTypewriterEnabled(): boolean;
// settings feature
openSettings(): void;
// session coordinators (orchestrator) // session coordinators (orchestrator)
refreshMeta(): Promise<void>; refreshMeta(): Promise<void>;
showPlaceholder(): void; showPlaceholder(): void;

View file

@ -19,6 +19,7 @@ export interface WelcomeFeature {
refreshMeta(): Promise<void>; refreshMeta(): Promise<void>;
startNewBook(): Promise<void>; startNewBook(): Promise<void>;
startOpenBook(): Promise<void>; startOpenBook(): Promise<void>;
openMarkdown(): Promise<void>;
openPath(p: string): Promise<void>; openPath(p: string): Promise<void>;
} }
@ -162,6 +163,24 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
await renderRecents(); 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() { async function renderRecents() {
try { try {
const recents = await api.getRecents(); const recents = await api.getRecents();
@ -223,6 +242,7 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
// ---- wiring (bind once at construction) ---- // ---- wiring (bind once at construction) ----
$("welcomeNewBook").addEventListener("click", () => void startNewBook()); $("welcomeNewBook").addEventListener("click", () => void startNewBook());
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook()); $("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
$("welcomeOpenMd")?.addEventListener("click", () => void openMarkdown());
homeBtn?.addEventListener("click", async () => { homeBtn?.addEventListener("click", async () => {
hooks.clearTimers(); hooks.clearTimers();
if (state.selectedId && state.dirty) { if (state.selectedId && state.dirty) {
@ -244,5 +264,6 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
startNewBook, startNewBook,
startOpenBook, startOpenBook,
openPath, openPath,
openMarkdown,
}; };
} }

View file

@ -18,7 +18,7 @@ const cancelBtn = el("modalCancel") as HTMLButtonElement;
let currentMode: Mode = "prompt"; let currentMode: Mode = "prompt";
let resolver: ((value: string | null) => void) | null = null; let resolver: ((value: string | null) => void) | null = null;
function open(mode: Mode, title: string, body: string, value: string): Promise<string | null> { function open(mode: Mode, title: string, body: string, value: string, okLabel?: string): Promise<string | null> {
// If a previous dialog was somehow still open (menu accelerators can open // If a previous dialog was somehow still open (menu accelerators can open
// one while another is visible), settle its promise as cancelled so the // one while another is visible), settle its promise as cancelled so the
// awaiting caller isn't stranded forever. // awaiting caller isn't stranded forever.
@ -30,7 +30,7 @@ function open(mode: Mode, title: string, body: string, value: string): Promise<s
inputEl.classList.toggle("hidden", mode !== "prompt"); inputEl.classList.toggle("hidden", mode !== "prompt");
msgEl.classList.toggle("hidden", mode === "prompt"); msgEl.classList.toggle("hidden", mode === "prompt");
cancelBtn.classList.toggle("hidden", mode === "alert"); cancelBtn.classList.toggle("hidden", mode === "alert");
okBtn.textContent = mode === "confirm" ? "Delete" : "OK"; okBtn.textContent = okLabel || (mode === "confirm" ? "Delete" : "OK");
openEl(backdrop); openEl(backdrop);
if (mode === "prompt") { if (mode === "prompt") {
inputEl.focus(); inputEl.focus();
@ -84,8 +84,8 @@ export function promptInput(title: string, value?: string): Promise<string | nul
return open("prompt", title, "", value ?? ""); return open("prompt", title, "", value ?? "");
} }
export function confirmMessage(title: string, body?: string): Promise<boolean> { export function confirmMessage(title: string, body?: string, okLabel?: string): Promise<boolean> {
return open("confirm", title, body ?? "", "").then((v) => v !== null); return open("confirm", title, body ?? "", "", okLabel).then((v) => v !== null);
} }
export function alertMessage(title: string, body?: string): Promise<void> { export function alertMessage(title: string, body?: string): Promise<void> {

View file

@ -44,23 +44,29 @@ html, body {
color: var(--color-text); color: var(--color-text);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
overflow: hidden;
max-width: 100vw;
} }
#root { #root {
display: flex; display: flex;
height: 100vh; height: 100vh;
width: 100vw;
max-width: 100vw;
overflow: hidden; overflow: hidden;
} }
.sidebar { .sidebar {
flex: 0 0 280px; flex: 0 0 280px;
min-width: 240px; min-width: 240px;
max-width: 360px; max-width: min(360px, 85vw);
flex-shrink: 0;
background: var(--color-panel); background: var(--color-panel);
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: var(--gap); padding: var(--gap);
overflow-x: clip;
} }
.sidebar-header { .sidebar-header {
@ -221,6 +227,60 @@ html, body {
font-size: 0.9rem; font-size: 0.9rem;
} }
/* ---- settings modal ---- */
.settings-modal {
min-width: min(480px, 92vw);
}
.setting-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 0;
cursor: pointer;
font-size: 0.88rem;
color: var(--color-text);
}
.setting-row input[type="checkbox"] {
margin-top: 3px;
width: 16px;
height: 16px;
accent-color: var(--color-accent);
flex: 0 0 auto;
}
.setting-hint {
font-size: 0.78rem;
color: var(--color-muted);
}
.setting-grid {
display: flex;
gap: 16px;
padding: 6px 0 10px 26px;
font-size: 0.85rem;
color: var(--color-text);
}
.setting-grid label {
display: flex;
align-items: center;
gap: 6px;
}
.setting-num {
width: 72px;
}
.setting-meta {
font-size: 0.76rem;
color: var(--color-muted);
font-family: var(--font-mono);
margin: 2px 0;
word-break: break-all;
}
/* ---- full book nav ---- */ /* ---- full book nav ---- */
.full-book-nav { .full-book-nav {
margin-top: var(--gap); margin-top: var(--gap);
@ -288,8 +348,10 @@ html, body {
.editor { .editor {
flex: 1; flex: 1;
display: flex; display: flex;
min-width: 0;
max-width: 100%;
background: var(--color-bg); background: var(--color-bg);
overflow: visible; overflow: hidden;
} }
.editor-placeholder { .editor-placeholder {
@ -662,23 +724,6 @@ body.dragging-active * {
font-weight: 600; font-weight: 600;
} }
.drag-handle {
cursor: grab;
padding: 4px 6px;
font-size: 1.05rem;
line-height: 1;
color: var(--color-muted);
opacity: 0.6;
border-radius: 4px;
transition: opacity 0.12s ease, color 0.12s ease;
user-select: none;
}
.chapter-row:hover .drag-handle {
opacity: 0.9;
color: var(--color-on-accent);
}
.row-title { .row-title {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
@ -855,6 +900,7 @@ body.dragging-active * {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
} }
@ -863,17 +909,22 @@ body.dragging-active * {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
} }
.chapter-header { .chapter-header {
display: flex; display: flex;
align-items: baseline; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 14px 20px; padding: 14px 20px;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
min-width: 0;
max-width: 100%;
overflow-x: clip;
} }
.chapter-title { .chapter-title {
@ -882,6 +933,11 @@ body.dragging-active * {
font-weight: 600; font-weight: 600;
margin: 0; margin: 0;
color: var(--color-text); color: var(--color-text);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
} }
.word-count { .word-count {
@ -892,6 +948,11 @@ body.dragging-active * {
.editor-actions { .editor-actions {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
row-gap: 6px;
justify-content: flex-end;
min-width: 0;
max-width: 100%;
gap: 10px; gap: 10px;
color: var(--color-muted); color: var(--color-muted);
font-size: 0.72rem; font-size: 0.72rem;
@ -1102,9 +1163,13 @@ body.dragging-active * {
.editor-toolbar { .editor-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: nowrap; align-content: flex-start;
overflow: visible; flex-wrap: wrap;
overflow-x: clip;
max-width: 100%;
min-width: 0;
gap: 4px; gap: 4px;
row-gap: 6px;
padding: 6px 12px; padding: 6px 12px;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
background: var(--color-panel); background: var(--color-panel);
@ -1116,11 +1181,15 @@ body.dragging-active * {
.toolbar-group { .toolbar-group {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
row-gap: 6px;
gap: 2px; gap: 2px;
padding-right: 8px; padding-right: 8px;
margin-right: 8px; margin-right: 8px;
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
flex: 0 0 auto; flex: 0 1 auto;
min-width: 0;
max-width: 100%;
} }
.toolbar-group:last-child { .toolbar-group:last-child {
@ -1937,3 +2006,40 @@ body:not(.dragging-active) .chapter-row {
animation: none !important; animation: none !important;
} }
} }
/* ---- narrow / tiled windows (Hyprland split-screen etc.) ----
Below ~900px the sidebar + full header + toolbar can't fit side by side,
so shrink the chrome instead of overflowing past the tile edge. */
@media (max-width: 900px) {
.sidebar {
flex-basis: 220px;
min-width: 200px;
}
.brand-icon {
width: 48px;
height: 48px;
}
.chapter-header {
padding: 10px 12px;
}
.editor-toolbar {
padding: 6px 8px;
}
.toolbar-select {
max-width: 110px;
}
.toolbar-group {
padding-right: 6px;
margin-right: 6px;
}
}
@media (max-width: 640px) {
.sidebar {
flex-basis: 200px;
min-width: 180px;
}
.chapter-title {
font-size: 1.15rem;
}
}

View file

@ -30,6 +30,7 @@
</div> </div>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="button button-ghost" id="searchBtn" title="Search all chapters (Ctrl+F)">Search</button> <button class="button button-ghost" id="searchBtn" title="Search all chapters (Ctrl+F)">Search</button>
<button class="button button-ghost" id="settingsBtn" title="Settings (autosave, backups)">Settings</button>
<div class="theme-control"> <div class="theme-control">
<label class="theme-label" for="themeSelect">Theme</label> <label class="theme-label" for="themeSelect">Theme</label>
<select id="themeSelect" class="theme-select" title="Interface theme"> <select id="themeSelect" class="theme-select" title="Interface theme">
@ -56,6 +57,8 @@
<div class="welcome-actions"> <div class="welcome-actions">
<button class="button" id="welcomeNewBook">New</button> <button class="button" id="welcomeNewBook">New</button>
<button class="button button-ghost" id="welcomeOpenBook">Open</button> <button class="button button-ghost" id="welcomeOpenBook">Open</button>
<button class="button button-ghost" id="welcomeOpenMd" title="Open a Markdown or text file as a new book">Open file</button>
<button class="button button-ghost" id="welcomeUpdate" title="Check for a new version of Folio">Update</button>
</div> </div>
<div class="recents-block"> <div class="recents-block">
<div class="recents-title">Recent</div> <div class="recents-title">Recent</div>
@ -313,6 +316,47 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal-backdrop hidden" id="settingsModal">
<div class="modal settings-modal" role="dialog" aria-modal="true" aria-label="Settings">
<h3 class="modal-title">Settings</h3>
<label class="setting-row">
<input type="checkbox" id="settingAutosave" checked />
<span><strong>Autosave while typing</strong><br />
<span class="setting-hint">Saves ~1s after you stop typing. When off, use Save (Ctrl+S). Switching chapters or closing still saves, so work is never lost.</span></span>
</label>
<label class="setting-row">
<input type="checkbox" id="settingSnapshots" checked />
<span><strong>Continuous snapshots</strong><br />
<span class="setting-hint">Copies the whole book in the background when it changes. Only the newest snapshots are kept.</span></span>
</label>
<label class="setting-row">
<input type="checkbox" id="settingSpellcheck" checked />
<span><strong>Spellcheck (English)</strong><br />
<span class="setting-hint">Underline misspellings; right-click a word for suggestions or to add it to the dictionary.</span></span>
</label>
<label class="setting-row">
<input type="checkbox" id="settingTypewriter" />
<span><strong>Typewriter mode</strong><br />
<span class="setting-hint">Keep the line you're writing near the middle of the editor as you type.</span></span>
</label>
<div class="setting-grid">
<label>Every (minutes) <input type="number" id="settingInterval" class="modal-input setting-num" min="1" max="1440" value="5" /></label>
<label>Keep last <input type="number" id="settingKeep" class="modal-input setting-num" min="0" max="200" value="20" /></label>
</div>
<div class="backup-control">
<span class="backup-label">Folder</span>
<input type="text" id="settingLocation" class="backup-input" readonly placeholder="Default: <book>/Folio-Backups" title="Snapshot folder (empty = default)" />
<button type="button" class="button button-ghost backup-browse" id="settingBrowse" title="Choose snapshot folder">…</button>
<button type="button" class="button button-ghost" id="settingResetLoc" title="Back to the default folder">Reset</button>
</div>
<p class="setting-meta" id="settingResolved"></p>
<p class="setting-meta" id="settingLastSnap"></p>
<div class="modal-actions">
<button class="button button-ghost" id="settingSnapNow" title="Take a snapshot right now">Snapshot now</button>
<button class="button" id="settingsClose">Close</button>
</div>
</div>
</div>
<div class="modal-backdrop hidden" id="statsModal"> <div class="modal-backdrop hidden" id="statsModal">
<div class="modal stats-modal" role="dialog" aria-modal="true" aria-label="Writing statistics"> <div class="modal stats-modal" role="dialog" aria-modal="true" aria-label="Writing statistics">
<h3 class="modal-title">Writing statistics</h3> <h3 class="modal-title">Writing statistics</h3>

41
tests/run-search-test.mjs Normal file
View file

@ -0,0 +1,41 @@
// Standalone test for the whole-book search helpers (src/renderer/book/ui.ts).
// These pure functions power the Search modal: Markdown flattening, literal
// matching (phrases included), and result snippets.
// Run with: node tests/run-search-test.mjs
import { bundleForTest } from "./helpers/bundle.mjs";
const { mod: ui, cleanupBundle } = await bundleForTest({
name: "folio-search-ui",
entryPoints: ["src/renderer/book/ui.ts"],
});
const { findMatches, markdownToPlain, makeSnippet, moveInList, escapeHtml } = ui;
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else { failures++; console.error("FAIL -", msg); }
}
// markdown -> searchable plain text
ok(markdownToPlain("# Title\nSome **bold** text") === "Title\nSome bold text", "headings/emphasis flattened");
ok(markdownToPlain("[link](http://x)") === "link", "links flattened to text");
ok(markdownToPlain("`code`") === "code", "inline code flattened");
// literal matching incl. phrases, case handling
ok(JSON.stringify(findMatches("hello world hello", "hello", true)) === "[0,12]", "finds all occurrences");
ok(findMatches("Hello", "hello", true).length === 0, "match-case respected");
ok(findMatches("Hello", "hello", false).length === 1, "case-insensitive match");
ok(JSON.stringify(findMatches("the quick brown fox", "quick brown", false)) === "[4]", "phrase search works");
ok(findMatches("a.b", "a.b", false).length === 1, "regex chars treated literally");
// snippets highlight the match and escape HTML
const snip = makeSnippet("say <hi> & bye", 4, 4);
ok(snip.includes("<mark>") && snip.includes("&lt;hi&gt;"), "snippet highlights + escapes");
// shared reorder math used by drag + keyboard reorder
ok(JSON.stringify(moveInList([1, 2, 3], 0, 2)) === "[2,3,1]", "moveInList moves item");
ok(escapeHtml("<a>&") === "&lt;a&gt;&amp;", "escapeHtml works");
cleanupBundle();
if (failures) { console.error(`${failures} search check(s) FAILED`); process.exit(1); }
console.log("All search checks passed.");

View file

@ -0,0 +1,99 @@
// Standalone test for continuous snapshot logic (src/main/backup.ts).
// Run with: node tests/run-snapshots-test.mjs
import fs from "fs";
import os from "os";
import path from "path";
import { bundleForTest } from "./helpers/bundle.mjs";
const { mod: project, cleanupBundle: cleanupProj } = await bundleForTest({
name: "folio-snap-project",
entryPoints: ["src/main/project.ts"],
});
const { mod: chapters, cleanupBundle: cleanupChap } = await bundleForTest({
name: "folio-snap-chapters",
entryPoints: ["src/main/chapters.ts"],
});
const { mod: backup, cleanupBundle: cleanupBackup } = await bundleForTest({
name: "folio-snap-backup",
entryPoints: ["src/main/backup.ts"],
});
const { createBook } = project;
const { createChapter, setChapterContent } = chapters;
const {
bindBackupSettings,
getSnapshotRoot,
listSnapshots,
createSnapshot,
pruneSnapshots,
bookChangedSince,
SNAPSHOT_DIR_NAME,
} = backup;
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else { failures++; console.error("FAIL -", msg); }
}
// In-memory settings store (backup.ts stays Electron-free).
const settings = new Map();
bindBackupSettings({
get: (k, fb) => (settings.has(k) ? settings.get(k) : fb),
set: (k, v) => { settings.set(k, v); },
});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-snap-"));
createBook(dir, "Snap Book");
const c = createChapter(dir, "One");
setChapterContent(dir, c.id, "hello");
// default root = <book>/Folio-Backups
ok(getSnapshotRoot(dir) === path.join(dir, SNAPSHOT_DIR_NAME), "default snapshot root is per-book folder");
// snapshot creates a timestamped copy with content
const s1 = createSnapshot(dir, 20);
ok(typeof s1 === "string" && fs.existsSync(s1), "createSnapshot returns existing dir: " + s1);
ok(fs.existsSync(path.join(s1, "folio.json")), "snapshot contains folio.json");
ok(listSnapshots(dir).length === 1, "one snapshot listed");
// snapshots never nest: second snapshot must not contain the backup dir
await new Promise((r) => setTimeout(r, 5));
const s2 = createSnapshot(dir, 20);
ok(listSnapshots(dir).length === 2, "two snapshots listed");
ok(!fs.existsSync(path.join(s2, SNAPSHOT_DIR_NAME)), "snapshot does not contain nested backup dir");
// bookChangedSince: quiet -> false, touched -> true
ok(bookChangedSince(dir, Date.now()) === false, "no change since now");
setChapterContent(dir, c.id, "hello edited");
ok(bookChangedSince(dir, Date.now() - 1000) === true, "detects content change");
// pruning keeps newest N
await new Promise((r) => setTimeout(r, 5));
createSnapshot(dir, 20);
ok(listSnapshots(dir).length === 3, "three snapshots before prune");
pruneSnapshots(dir, 2);
const kept = listSnapshots(dir);
ok(kept.length === 2, "prune keeps newest 2");
ok(!fs.existsSync(s1), "oldest snapshot pruned");
// custom root honored
const custom = fs.mkdtempSync(path.join(os.tmpdir(), "folio-snaproot-"));
settings.set("snapshotLocation", custom);
ok(getSnapshotRoot(dir) === custom, "custom snapshot root honored");
const s3 = createSnapshot(dir, 20);
ok(s3.startsWith(custom), "snapshot lands in custom root");
settings.delete("snapshotLocation");
// missing dir -> null, no throw
ok(createSnapshot(path.join(dir, "nope"), 5) === null, "missing book returns null");
// cleanup
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(custom, { recursive: true, force: true });
cleanupProj();
cleanupChap();
cleanupBackup();
if (failures) { console.error(`${failures} snapshot check(s) FAILED`); process.exit(1); }
console.log("All snapshot checks passed.");

View file

@ -0,0 +1,98 @@
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");
// Real Gitea release-JSON shape (tag_name/html_url/body/assets with sizes).
const gitea = u.parseRelease({
id: 5,
tag_name: "v0.2.0",
html_url: "https://git.atitlan.io/avi/Folio/releases/tag/v0.2.0",
body: "Bug fixes",
assets: [
{ name: "Folio-0.2.0-linux-x86_64.tar.gz", browser_download_url: "https://git.atitlan.io/avi/Folio/releases/download/v0.2.0/Folio-0.2.0-linux-x86_64.tar.gz", size: 123456 },
{ name: "Folio-0.2.0-linux-amd64.deb", browser_download_url: "https://git.atitlan.io/avi/Folio/releases/download/v0.2.0/Folio-0.2.0-linux-amd64.deb", size: 111 },
{ name: "Folio-0.2.0.AppImage", browser_download_url: "https://git.atitlan.io/avi/Folio/releases/download/v0.2.0/Folio-0.2.0.AppImage", size: 222 },
],
});
assert(gitea.version === "0.2.0", "gitea tag parsed");
assert(gitea.url === "https://git.atitlan.io/avi/Folio/releases/tag/v0.2.0", "gitea release url kept");
assert(gitea.notes === "Bug fixes", "gitea notes kept");
assert(u.pickAsset(gitea.assets).name.endsWith(".tar.gz"), "gitea: tar.gz picked over deb/AppImage");
assert(u.isTrustedDownloadUrl(gitea.assets[0].browser_download_url, "https://git.atitlan.io"), "gitea asset url trusted");
// No installable archive cases.
assert(u.pickAsset([]) === null, "empty assets -> no pick");
const debOnly = u.parseRelease({ tag_name: "v0.3.0", assets: [{ name: "Folio.deb", browser_download_url: "https://git.atitlan.io/x/Folio.deb" }] });
assert(u.pickAsset(debOnly.assets) === null, "deb-only release -> no pick (tar.gz required)");
// Version edge cases.
assert(!u.isNewer("0.1.0", "0.1.0"), "same version not an update");
assert(!u.isNewer("0.1.0", "0.2.0"), "older release not an update");
assert(u.compareVersions("0.1.2-rc1", "0.1.2") === 0, "rc suffix ignored in compare");
assert(u.parseVersion("nope") === null, "unparseable version -> null");
assert(!u.isTrustedDownloadUrl("http://git.atitlan.io/a.tar.gz", "https://git.atitlan.io"), "http vs https rejected");
assert(!u.isTrustedDownloadUrl("not a url", "https://git.atitlan.io"), "malformed url rejected");
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);

View file

@ -0,0 +1,123 @@
// Sandbox execution test for the updater handoff script (buildInstallerScript).
// Runs the generated /bin/sh script against fake install dirs in os.tmpdir() —
// never the real ~/.local/share/Folio — proving the swap works and failures
// always relaunch the OLD version.
// Run with: node tests/run-update-installer-test.mjs
import { execFileSync, spawn } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import url from "url";
import { bundleForTest } from "./helpers/bundle.mjs";
const { mod: u, cleanupBundle } = await bundleForTest({
name: "folio-update-script",
entryPoints: ["src/main/update.ts"],
});
let fails = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else { fails++; console.error("FAIL -", msg); }
}
function sleepPid(ms) {
// Short-lived stand-in for the app process the script waits on.
const p = spawn("sleep", [String(ms / 1000)], { stdio: "ignore" });
return p.pid;
}
// A pid that is dead AND reaped (kill -0 fails), so the script proceeds
// without waiting. NOTE: a Node-spawned child that just exited stays a
// zombie while our event loop is blocked inside execFileSync — and kill -0
// succeeds on zombies — so the pid must be observed dead before running the
// script, not merely spawned with a short sleep.
async function deadPid() {
const p = spawn("sleep", ["0.01"], { stdio: "ignore" });
const pid = p.pid;
p.on("error", () => {});
for (let i = 0; i < 200; i++) {
try {
process.kill(pid, 0);
} catch {
return pid;
}
await new Promise((r) => setTimeout(r, 10));
}
throw new Error("helper process would not die");
}
function runScript(script, timeoutMs = 30000) {
execFileSync("/bin/sh", [script], { timeout: timeoutMs, stdio: "ignore" });
}
// Fake "installed" app tree: an executable `folio` stub + a data file.
function makeInstall(box, versionMarker) {
const dir = path.join(box, "install");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "folio"), `#!/bin/sh\necho ${versionMarker}\n`, { mode: 0o755 });
fs.writeFileSync(path.join(dir, "other.txt"), "keepme");
return dir;
}
// Fake release tarball with an electron-builder-style nested top-level dir.
function makeTarball(box, versionMarker) {
const staged = path.join(box, "staged", "Folio-linux-x64");
fs.mkdirSync(staged, { recursive: true });
fs.writeFileSync(path.join(staged, "folio"), `#!/bin/sh\necho ${versionMarker}\n`, { mode: 0o755 });
fs.writeFileSync(path.join(staged, "extra.txt"), "newfile");
const tarball = path.join(box, "release.tar.gz");
execFileSync("tar", ["-czf", tarball, "-C", path.join(box, "staged"), "Folio-linux-x64"]);
return tarball;
}
// Relaunch stub records that it was invoked.
function makeRelaunch(box) {
const marker = path.join(box, "relaunched");
const launcher = path.join(box, "relaunch.sh");
fs.writeFileSync(launcher, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\n`, { mode: 0o755 });
return { launcher, marker };
}
function writeScript(box, spec) {
const script = path.join(box, "installer.sh");
fs.writeFileSync(script, u.buildInstallerScript(spec), { mode: 0o700 });
return script;
}
// --- success path: valid tarball replaces the install, relaunches ---
{
const box = fs.mkdtempSync(path.join(os.tmpdir(), "folio-upd-ok-"));
const install = makeInstall(box, "OLD");
const tarball = makeTarball(box, "NEW");
const { launcher, marker } = makeRelaunch(box);
const pid = await deadPid();
runScript(writeScript(box, { pid, tarball, installDir: install, relaunch: launcher }));
await new Promise((r) => setTimeout(r, 800)); // relaunched in background (&)
ok(fs.readFileSync(path.join(install, "folio"), "utf-8").includes("NEW"), "install swapped to new version");
ok(fs.readFileSync(path.join(install, "extra.txt"), "utf-8") === "newfile", "new files land in install");
ok(fs.existsSync(marker), "relaunch invoked after swap");
ok(!fs.existsSync(tarball), "tarball cleaned up after success");
ok(!fs.readdirSync(path.dirname(install)).some((e) => e.includes(".old.")), "old tree pruned after success");
fs.rmSync(box, { recursive: true, force: true });
}
// --- failure path: corrupt tarball keeps the old install, still relaunches ---
{
const box = fs.mkdtempSync(path.join(os.tmpdir(), "folio-upd-bad-"));
const install = makeInstall(box, "OLD");
const tarball = path.join(box, "release.tar.gz");
fs.writeFileSync(tarball, "this is not a gzip stream");
const { launcher, marker } = makeRelaunch(box);
const pid = await deadPid();
runScript(writeScript(box, { pid, tarball, installDir: install, relaunch: launcher }));
await new Promise((r) => setTimeout(r, 800));
ok(fs.readFileSync(path.join(install, "folio"), "utf-8").includes("OLD"), "old install intact after bad tarball");
ok(fs.existsSync(marker), "relaunch still invoked after failure");
ok(!fs.readdirSync(path.dirname(install)).some((e) => e.includes(".old.")), "no .old residue after failure");
fs.rmSync(box, { recursive: true, force: true });
}
cleanupBundle();
if (fails) { console.error(`${fails} installer check(s) FAILED`); process.exit(1); }
console.log("All installer checks passed.");