Compare commits
4 commits
9aebde4c35
...
542a545c0d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
542a545c0d | ||
|
|
1413e73015 | ||
|
|
341b89cd8a | ||
|
|
ada5eee679 |
23 changed files with 1847 additions and 94 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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
56
src/main/import-md.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
173
src/main/update.ts
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
179
src/renderer/book/settings.ts
Normal file
179
src/renderer/book/settings.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
// Settings: autosave + continuous snapshot backups.
|
||||||
|
//
|
||||||
|
// Owns the Settings modal (#settingsModal): background-autosave switch,
|
||||||
|
// snapshot cadence/retention/location, and a manual "Snapshot now" button.
|
||||||
|
// Persisted through the main-process settings store; the editor's background
|
||||||
|
// engine is toggled live via hooks.setAutosaveEnabled. Snapshot cadence runs
|
||||||
|
// in the main process (1-minute tick), so this module only edits preferences.
|
||||||
|
//
|
||||||
|
// Cross-feature calls go through BookHooks exclusively — no sibling imports.
|
||||||
|
|
||||||
|
import type { BookCtx } from "./types.js";
|
||||||
|
import { alertMessage } from "../dialog.js";
|
||||||
|
import { $, closeEl, openEl, showToast } from "./ui.js";
|
||||||
|
|
||||||
|
export interface SettingsFeature {
|
||||||
|
openSettings(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnapshotSettings {
|
||||||
|
autosaveEnabled: boolean;
|
||||||
|
snapshotsEnabled: boolean;
|
||||||
|
intervalMin: number;
|
||||||
|
keep: number;
|
||||||
|
location: string;
|
||||||
|
resolvedRoot: string;
|
||||||
|
snapshotCount: number;
|
||||||
|
lastSnapshot: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSettingsFeature(ctx: BookCtx): SettingsFeature {
|
||||||
|
const { api, hooks } = ctx;
|
||||||
|
|
||||||
|
const modal = $("settingsModal") as HTMLDivElement;
|
||||||
|
const autosaveCb = $("settingAutosave") as HTMLInputElement;
|
||||||
|
const snapsCb = $("settingSnapshots") as HTMLInputElement;
|
||||||
|
const spellCb = $("settingSpellcheck") as HTMLInputElement;
|
||||||
|
const typewriterCb = $("settingTypewriter") as HTMLInputElement;
|
||||||
|
const intervalInput = $("settingInterval") as HTMLInputElement;
|
||||||
|
const keepInput = $("settingKeep") as HTMLInputElement;
|
||||||
|
const locationInput = $("settingLocation") as HTMLInputElement;
|
||||||
|
const resolvedEl = $("settingResolved") as HTMLElement;
|
||||||
|
const lastSnapEl = $("settingLastSnap") as HTMLElement;
|
||||||
|
const closeBtn = $("settingsClose") as HTMLButtonElement;
|
||||||
|
const browseBtn = $("settingBrowse") as HTMLButtonElement;
|
||||||
|
const resetBtn = $("settingResetLoc") as HTMLButtonElement;
|
||||||
|
const snapNowBtn = $("settingSnapNow") as HTMLButtonElement;
|
||||||
|
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
function shortPath(p: string): string {
|
||||||
|
const home = (window as unknown as { __folioHome?: string }).__folioHome;
|
||||||
|
if (home && p.startsWith(home)) return "~" + p.slice(home.length);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(s: SnapshotSettings) {
|
||||||
|
autosaveCb.checked = s.autosaveEnabled !== false;
|
||||||
|
snapsCb.checked = s.snapshotsEnabled !== false;
|
||||||
|
intervalInput.value = String(s.intervalMin ?? 5);
|
||||||
|
keepInput.value = String(s.keep ?? 20);
|
||||||
|
locationInput.value = s.location || "";
|
||||||
|
locationInput.placeholder = "Default: <book>/Folio-Backups";
|
||||||
|
resolvedEl.textContent = `Saving to: ${shortPath(s.resolvedRoot || "—")}`;
|
||||||
|
lastSnapEl.textContent = s.lastSnapshot
|
||||||
|
? `${s.snapshotCount} snapshot${s.snapshotCount === 1 ? "" : "s"} · latest ${s.lastSnapshot.split("/").pop()}`
|
||||||
|
: "No snapshots yet.";
|
||||||
|
const dis = !snapsCb.checked;
|
||||||
|
intervalInput.disabled = dis;
|
||||||
|
keepInput.disabled = dis;
|
||||||
|
snapNowBtn.disabled = dis;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const s = (await api.getSnapshotSettings()) as SnapshotSettings;
|
||||||
|
render(s);
|
||||||
|
loaded = true;
|
||||||
|
} catch {
|
||||||
|
// Settings are best-effort; the modal still opens with defaults.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
spellCb.checked = (await api.getSpellcheck()) !== false;
|
||||||
|
} catch {
|
||||||
|
// keep default checked state
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
typewriterCb.checked = hooks.isTypewriterEnabled();
|
||||||
|
} catch {
|
||||||
|
// keep default unchecked state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettings(): void {
|
||||||
|
openEl(modal);
|
||||||
|
void load();
|
||||||
|
setTimeout(() => autosaveCb.focus(), 0);
|
||||||
|
}
|
||||||
|
function closeSettings(): void {
|
||||||
|
closeEl(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function push(patch: Record<string, unknown>, applyAutosave = false) {
|
||||||
|
const res = await api.setSnapshotSettings(patch);
|
||||||
|
if (res && "error" in res) {
|
||||||
|
await alertMessage("Could not save settings", (res as { error: string }).error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = (res as { settings: SnapshotSettings }).settings;
|
||||||
|
if (s) render(s);
|
||||||
|
if (applyAutosave && s) hooks.setAutosaveEnabled(s.autosaveEnabled !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
autosaveCb.addEventListener("change", () => {
|
||||||
|
const on = autosaveCb.checked;
|
||||||
|
// Apply instantly to the editor; persist alongside.
|
||||||
|
hooks.setAutosaveEnabled(on);
|
||||||
|
void (async () => {
|
||||||
|
await api.setAutosave(on);
|
||||||
|
await load();
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
snapsCb.addEventListener("change", () => void push({ snapshotsEnabled: snapsCb.checked }));
|
||||||
|
spellCb.addEventListener("change", () => void (async () => {
|
||||||
|
const res = await api.setSpellcheck(spellCb.checked);
|
||||||
|
if (res && "error" in res) {
|
||||||
|
await alertMessage("Could not save settings", (res as { error: string }).error);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
typewriterCb.addEventListener("change", () => {
|
||||||
|
hooks.setTypewriterEnabled(typewriterCb.checked);
|
||||||
|
});
|
||||||
|
intervalInput.addEventListener("change", () => {
|
||||||
|
const v = Math.max(1, Math.min(1440, Math.round(Number(intervalInput.value) || 5)));
|
||||||
|
intervalInput.value = String(v);
|
||||||
|
void push({ intervalMin: v });
|
||||||
|
});
|
||||||
|
keepInput.addEventListener("change", () => {
|
||||||
|
const v = Math.max(0, Math.min(200, Math.round(Number(keepInput.value) || 0)));
|
||||||
|
keepInput.value = String(v);
|
||||||
|
void push({ keep: v });
|
||||||
|
});
|
||||||
|
browseBtn.addEventListener("click", () => void (async () => {
|
||||||
|
const res = await api.pickSnapshotFolder();
|
||||||
|
if (!res || res.canceled || !res.filePaths?.length) return;
|
||||||
|
await push({ location: res.filePaths[0] });
|
||||||
|
})());
|
||||||
|
resetBtn.addEventListener("click", () => void push({ location: "" }));
|
||||||
|
snapNowBtn.addEventListener("click", () => void (async () => {
|
||||||
|
const res = await api.snapshotNow();
|
||||||
|
if (!res) return;
|
||||||
|
if ("error" in res) {
|
||||||
|
await alertMessage("Snapshot failed", (res as { error: string }).error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast("Snapshot saved");
|
||||||
|
await load();
|
||||||
|
})());
|
||||||
|
closeBtn.addEventListener("click", closeSettings);
|
||||||
|
modal.addEventListener("click", (e) => {
|
||||||
|
if (e.target === modal) closeSettings();
|
||||||
|
});
|
||||||
|
window.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && !modal.classList.contains("hidden")) closeSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sidebar + File-menu entry points.
|
||||||
|
$("settingsBtn")?.addEventListener("click", openSettings);
|
||||||
|
api.onSettingsRequested(() => openSettings());
|
||||||
|
|
||||||
|
// Refresh the resolved-path line whenever a book opens (the default root
|
||||||
|
// is per-book). Only refresh while the modal is open to avoid IPC noise.
|
||||||
|
api.onBookOpened(() => {
|
||||||
|
if (!modal.classList.contains("hidden") || !loaded) return;
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { openSettings };
|
||||||
|
}
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
41
tests/run-search-test.mjs
Normal 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("<hi>"), "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>&") === "<a>&", "escapeHtml works");
|
||||||
|
|
||||||
|
cleanupBundle();
|
||||||
|
if (failures) { console.error(`${failures} search check(s) FAILED`); process.exit(1); }
|
||||||
|
console.log("All search checks passed.");
|
||||||
99
tests/run-snapshots-test.mjs
Normal file
99
tests/run-snapshots-test.mjs
Normal 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.");
|
||||||
98
tests/run-update-import-test.mjs
Normal file
98
tests/run-update-import-test.mjs
Normal 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);
|
||||||
123
tests/run-update-installer-test.mjs
Normal file
123
tests/run-update-installer-test.mjs
Normal 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.");
|
||||||
Loading…
Add table
Add a link
Reference in a new issue