Compare commits
No commits in common. "542a545c0d276d6ddb844fb13ed92014cfd9973a" and "9aebde4c358387af119729b0072df9955d05644c" have entirely different histories.
542a545c0d
...
9aebde4c35
23 changed files with 94 additions and 1847 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,7 +4,6 @@ dist/
|
|||
*.asar
|
||||
releases/
|
||||
.DS_Store
|
||||
.directory
|
||||
tests/.tmp-folio-format-*.mjs
|
||||
|
||||
.opencode/
|
||||
|
|
|
|||
|
|
@ -22,25 +22,6 @@ linux:
|
|||
- AppImage
|
||||
- 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.
|
||||
# Setting `depends` replaces the auto-generated list, so keep its entries and
|
||||
# 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\"",
|
||||
"start": "npm run build && npm run electron",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-office-export-test.mjs && node tests/run-migration-test.mjs && node tests/run-editor-test.mjs && node tests/run-fullbook-test.mjs && node tests/run-print-test.mjs && 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",
|
||||
"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",
|
||||
"package": "electron-builder"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -70,132 +70,3 @@ export function ensureRecoveryBackup(bookPath: string): string | null {
|
|||
export function _resetSessionBackupGuard(): void {
|
||||
sessionBackedUp.clear();
|
||||
}
|
||||
|
||||
// ---- Continuous snapshots -----------------------------------------------
|
||||
// Unlike the migration recovery backup above (external location, once per
|
||||
// session), snapshots are periodic copies of the whole book used as an
|
||||
// ongoing safety net while writing. Defaults keep them next to the work:
|
||||
//
|
||||
// <book>/Folio-Backups/<bookName>-<stamp>/
|
||||
//
|
||||
// A custom root can be chosen in Settings; it then applies to every book.
|
||||
// Only the newest `keep` snapshots are retained (oldest pruned).
|
||||
|
||||
export const SNAPSHOT_DIR_NAME = "Folio-Backups";
|
||||
export const SNAPSHOT_KEEP_DEFAULT = 20;
|
||||
|
||||
export function getSnapshotRoot(bookPath?: string): string {
|
||||
const custom = store ? store.get<string>("snapshotLocation", "") : "";
|
||||
if (custom && custom.trim()) return path.resolve(custom.trim());
|
||||
if (bookPath) return path.join(path.resolve(bookPath), SNAPSHOT_DIR_NAME);
|
||||
return getRecoveryRoot();
|
||||
}
|
||||
|
||||
function snapshotPrefix(bookPath: string): string {
|
||||
return `${path.basename(path.resolve(bookPath)) || "book"}-`;
|
||||
}
|
||||
|
||||
// Snapshot directories for a book, oldest first.
|
||||
export function listSnapshots(bookPath: string): string[] {
|
||||
const root = getSnapshotRoot(bookPath);
|
||||
const prefix = snapshotPrefix(bookPath);
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(root);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return entries
|
||||
.filter((e) => e.startsWith(prefix))
|
||||
.sort()
|
||||
.map((e) => path.join(root, e));
|
||||
}
|
||||
|
||||
// True when any file in the book (outside its own snapshot dir) changed
|
||||
// after `sinceMs`. Lets the interval timer skip quiet periods instead of
|
||||
// piling up identical copies.
|
||||
export function bookChangedSince(bookPath: string, sinceMs: number): boolean {
|
||||
const key = path.resolve(bookPath);
|
||||
const snapRoot = path.join(key, SNAPSHOT_DIR_NAME) + path.sep;
|
||||
const stack: string[] = [key];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop() as string;
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(cur);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
if ((cur + path.sep).startsWith(snapRoot)) continue;
|
||||
let kids: string[];
|
||||
try {
|
||||
kids = fs.readdirSync(cur);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const k of kids) stack.push(path.join(cur, k));
|
||||
} else if (stat.mtimeMs > sinceMs) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function pruneSnapshots(bookPath: string, keep: number): void {
|
||||
const n = Number.isFinite(keep) && keep >= 0 ? Math.floor(keep) : SNAPSHOT_KEEP_DEFAULT;
|
||||
const snaps = listSnapshots(bookPath);
|
||||
const excess = snaps.length - n;
|
||||
for (let i = 0; i < excess; i++) {
|
||||
try {
|
||||
fs.rmSync(snaps[i], { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort pruning only; a leftover old snapshot is harmless
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy the book to a new timestamped snapshot directory (excluding the
|
||||
// snapshot dir itself so snapshots never nest). Returns the new directory,
|
||||
// or null when the book path does not exist.
|
||||
export function createSnapshot(bookPath: string, keep?: number): string | null {
|
||||
const key = path.resolve(bookPath);
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!stat.isDirectory()) return null;
|
||||
const root = getSnapshotRoot(bookPath);
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
let dest = path.join(root, `${snapshotPrefix(bookPath)}${stamp}`);
|
||||
// Same-millisecond snapshots (e.g. double-clicked "Snapshot now") must not
|
||||
// silently merge into one directory.
|
||||
for (let n = 2; fs.existsSync(dest); n++) {
|
||||
dest = path.join(root, `${snapshotPrefix(bookPath)}${stamp}-${n}`);
|
||||
}
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
try {
|
||||
// Copy entry-by-entry (skipping the snapshot dir) instead of one cpSync
|
||||
// of the book root: Node refuses to copy a directory into a subdirectory
|
||||
// of itself even when a filter excludes the destination.
|
||||
const entries = fs.readdirSync(key);
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
for (const e of entries) {
|
||||
if (e === SNAPSHOT_DIR_NAME) continue;
|
||||
fs.cpSync(path.join(key, e), path.join(dest, e), { recursive: true });
|
||||
}
|
||||
} catch (err) {
|
||||
// A half-copied directory is worse than none — it looks like a good
|
||||
// snapshot. Remove the partial copy, then surface the failure.
|
||||
try {
|
||||
fs.rmSync(dest, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup only
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
pruneSnapshots(bookPath, keep ?? SNAPSHOT_KEEP_DEFAULT);
|
||||
return dest;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
// 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, session, shell } from "electron";
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from "electron";
|
||||
import type { OpenDialogOptions, SaveDialogOptions } from "electron";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
|
@ -34,32 +34,14 @@ import {
|
|||
import { combineMarkdown, buildZip } from "./export.js";
|
||||
import {
|
||||
bindBackupSettings,
|
||||
bookChangedSince,
|
||||
createSnapshot,
|
||||
getRecoveryRoot,
|
||||
getSnapshotRoot,
|
||||
listSnapshots,
|
||||
pruneSnapshots,
|
||||
setRecoveryRoot,
|
||||
SNAPSHOT_KEEP_DEFAULT,
|
||||
} from "./backup.js";
|
||||
import { sanitizeBookFolderName } from "./ids.js";
|
||||
import { buildDocx, buildOdt } from "./office-export.js";
|
||||
import { getSetting, setSetting } from "./settings.js";
|
||||
import { migrateChapterToNative, migrateBookToNative } from "./migration.js";
|
||||
import { loadChapterDoc, saveChapterDoc } from "./chapters.js";
|
||||
import { importMarkdownFiles, isImportableMarkdown } from "./import-md.js";
|
||||
import {
|
||||
INSTALL_DIR_DEFAULT,
|
||||
LAUNCHER_DEFAULT,
|
||||
UPDATE_BASE_DEFAULT,
|
||||
UPDATE_REPO,
|
||||
buildInstallerScript,
|
||||
isTrustedDownloadUrl,
|
||||
isNewer,
|
||||
parseRelease,
|
||||
pickAsset,
|
||||
} from "./update.js";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let bookPath: string | null = null;
|
||||
|
|
@ -86,8 +68,8 @@ function createWindow() {
|
|||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 780,
|
||||
minWidth: 560,
|
||||
minHeight: 400,
|
||||
minWidth: 960,
|
||||
minHeight: 640,
|
||||
title: "Folio",
|
||||
icon: path.join(__dirname, "icons", "icon.png"),
|
||||
webPreferences: {
|
||||
|
|
@ -97,7 +79,6 @@ function createWindow() {
|
|||
},
|
||||
});
|
||||
forwardEditShortcuts(mainWindow);
|
||||
wireContextMenu(mainWindow);
|
||||
mainWindow.loadFile(path.join(__dirname, "index.html"));
|
||||
mainWindow.webContents.on("did-finish-load", () => {
|
||||
console.log("[folio] renderer loaded");
|
||||
|
|
@ -128,15 +109,6 @@ function createWindow() {
|
|||
|
||||
app.whenReady().then(() => {
|
||||
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(
|
||||
getWindow,
|
||||
getState,
|
||||
|
|
@ -144,13 +116,6 @@ app.whenReady().then(() => {
|
|||
() => getWindow()?.webContents.send("folio:open-book-requested")
|
||||
);
|
||||
createWindow();
|
||||
// Open a book folder or Markdown file passed on the command line
|
||||
// (`folio notes.md`, or file-association launches from the desktop).
|
||||
const initialFile = fileArgFrom(process.argv);
|
||||
if (initialFile) {
|
||||
const win = getWindow();
|
||||
win?.webContents.once("did-finish-load", () => openFromArg(initialFile));
|
||||
}
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
|
|
@ -720,57 +685,19 @@ handleIpc("folio:toggleFullscreen", () => {
|
|||
return win.isFullScreen();
|
||||
});
|
||||
|
||||
// Right-click menu with spelling suggestions (English-only dictionary).
|
||||
// Electron builds no default context menu, so without this the red
|
||||
// misspelling underlines had no way to reach a suggestion. Chromium reports
|
||||
// the misspelled word + candidates via the webContents event; suggestions
|
||||
// replace the word in place, and "Add to dictionary" persists to the
|
||||
// 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: "copy" },
|
||||
{ role: "paste" },
|
||||
{ type: "separator" },
|
||||
{ role: "selectAll" }
|
||||
);
|
||||
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 };
|
||||
// Right-click context menu (Electron has none by default), so Cut/Copy/Paste
|
||||
// via the mouse work. The roles act on whatever element is focused.
|
||||
ipcMain.on("folio:context-menu", (event, x: number, y: number) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return;
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
{ type: "separator" },
|
||||
{ role: "selectAll" },
|
||||
]);
|
||||
menu.popup({ window: win, x, y });
|
||||
});
|
||||
|
||||
// Theme preference. Stored as a plain string so future themes can be added
|
||||
|
|
@ -806,118 +733,6 @@ handleIpc("folio:setBackupLocation", (_e, loc: unknown) => {
|
|||
return { ok: true };
|
||||
});
|
||||
|
||||
// ---- Autosave + continuous snapshots --------------------------------------
|
||||
// Settings keys (all in the shared settings store, defaults = on / sane):
|
||||
// autosaveEnabled background autosave while typing (default true)
|
||||
// snapshotsEnabled periodic whole-book snapshots (default true)
|
||||
// snapshotIntervalMin minutes between snapshot checks (default 5)
|
||||
// snapshotKeep how many recent snapshots to retain (default 20)
|
||||
// snapshotLocation custom snapshot root, "" = <book>/Folio-Backups
|
||||
const SNAPSHOT_DEFAULTS = {
|
||||
autosaveEnabled: true,
|
||||
snapshotsEnabled: true,
|
||||
intervalMin: 5,
|
||||
keep: SNAPSHOT_KEEP_DEFAULT,
|
||||
};
|
||||
|
||||
function clampInt(v: unknown, fallback: number, min: number, max: number): number {
|
||||
const n = typeof v === "number" ? Math.round(v) : fallback;
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
function snapshotSettingsSnapshot() {
|
||||
const location = getSetting<string>("snapshotLocation", "");
|
||||
const resolved = bookPath ? getSnapshotRoot(bookPath) : getSnapshotRoot();
|
||||
const snaps = bookPath ? listSnapshots(bookPath) : [];
|
||||
return {
|
||||
autosaveEnabled: getSetting<boolean>("autosaveEnabled", SNAPSHOT_DEFAULTS.autosaveEnabled),
|
||||
snapshotsEnabled: getSetting<boolean>("snapshotsEnabled", SNAPSHOT_DEFAULTS.snapshotsEnabled),
|
||||
intervalMin: clampInt(getSetting("snapshotIntervalMin", SNAPSHOT_DEFAULTS.intervalMin), SNAPSHOT_DEFAULTS.intervalMin, 1, 1440),
|
||||
keep: clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200),
|
||||
location,
|
||||
resolvedRoot: resolved,
|
||||
snapshotCount: snaps.length,
|
||||
lastSnapshot: snaps.length ? snaps[snaps.length - 1] : null,
|
||||
};
|
||||
}
|
||||
|
||||
handleIpc("folio:getAutosave", () =>
|
||||
getSetting<boolean>("autosaveEnabled", SNAPSHOT_DEFAULTS.autosaveEnabled)
|
||||
);
|
||||
handleIpc("folio:setAutosave", (_e, on: unknown) => {
|
||||
setSetting("autosaveEnabled", on !== false);
|
||||
return { ok: true, enabled: on !== false };
|
||||
});
|
||||
handleIpc("folio:getSnapshotSettings", () => snapshotSettingsSnapshot());
|
||||
handleIpc("folio:setSnapshotSettings", (_e, patch: unknown) => {
|
||||
const p = (patch && typeof patch === "object" ? patch : {}) as Record<string, unknown>;
|
||||
if ("snapshotsEnabled" in p) setSetting("snapshotsEnabled", p.snapshotsEnabled !== false);
|
||||
if ("intervalMin" in p) {
|
||||
setSetting("snapshotIntervalMin", clampInt(p.intervalMin, SNAPSHOT_DEFAULTS.intervalMin, 1, 1440));
|
||||
}
|
||||
if ("keep" in p) {
|
||||
const keep = clampInt(p.keep, SNAPSHOT_DEFAULTS.keep, 0, 200);
|
||||
setSetting("snapshotKeep", keep);
|
||||
// Apply a lowered retention immediately to the open book.
|
||||
if (bookPath) {
|
||||
try { pruneSnapshots(bookPath, keep); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
if ("location" in p) {
|
||||
const s = typeof p.location === "string" ? p.location.trim() : "";
|
||||
setSetting("snapshotLocation", s);
|
||||
}
|
||||
return { ok: true, settings: snapshotSettingsSnapshot() };
|
||||
});
|
||||
handleIpc("folio:pickSnapshotFolder", async () => {
|
||||
const { canceled, filePaths } = await showOpenDialog({
|
||||
properties: ["openDirectory"],
|
||||
title: "Choose snapshot folder",
|
||||
});
|
||||
return { canceled, filePaths };
|
||||
});
|
||||
// Interval-snapshot bookkeeping (declared before use by the handlers below).
|
||||
let snapshotLastRunMs = 0;
|
||||
let snapshotLastSnapMs = 0;
|
||||
let snapshotBook: string | null = null;
|
||||
handleIpc("folio:snapshotNow", () => {
|
||||
const bp = requireBook();
|
||||
const keep = clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200);
|
||||
const dest = createSnapshot(bp, keep);
|
||||
snapshotLastSnapMs = Date.now();
|
||||
return { ok: true, path: dest, settings: snapshotSettingsSnapshot() };
|
||||
});
|
||||
|
||||
// Interval snapshots: once a minute, check whether the open book changed
|
||||
// since the last snapshot and whether the configured interval elapsed. The
|
||||
// 1-minute tick keeps wakeups cheap; the user-facing cadence is intervalMin.
|
||||
setInterval(() => {
|
||||
try {
|
||||
if (!bookPath) return;
|
||||
if (snapshotBook !== bookPath) {
|
||||
snapshotBook = bookPath;
|
||||
snapshotLastSnapMs = Date.now();
|
||||
snapshotLastRunMs = Date.now();
|
||||
return;
|
||||
}
|
||||
if (getSetting<boolean>("snapshotsEnabled", SNAPSHOT_DEFAULTS.snapshotsEnabled) !== true) return;
|
||||
const intervalMin = clampInt(
|
||||
getSetting("snapshotIntervalMin", SNAPSHOT_DEFAULTS.intervalMin),
|
||||
SNAPSHOT_DEFAULTS.intervalMin, 1, 1440
|
||||
);
|
||||
const now = Date.now();
|
||||
if (now - snapshotLastRunMs < intervalMin * 60_000) return;
|
||||
snapshotLastRunMs = now;
|
||||
if (!bookChangedSince(bookPath, snapshotLastSnapMs)) return;
|
||||
const keep = clampInt(getSetting("snapshotKeep", SNAPSHOT_DEFAULTS.keep), SNAPSHOT_DEFAULTS.keep, 0, 200);
|
||||
createSnapshot(bookPath, keep);
|
||||
snapshotLastSnapMs = now;
|
||||
} catch (err) {
|
||||
console.error("[folio] interval snapshot failed:", (err as Error).message);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
handleIpc("folio:importAttachment", async () => {
|
||||
const bp = requireBook();
|
||||
const { canceled, filePaths } = await showOpenDialog({
|
||||
|
|
@ -941,237 +756,3 @@ handleIpc("folio:importAttachment", async () => {
|
|||
const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/");
|
||||
return { ok: true, name, relPath };
|
||||
});
|
||||
|
||||
// ---- Markdown import ------------------------------------------------------
|
||||
|
||||
// Import standalone .md files as Folio books (one book per file, named after
|
||||
// the file's first H1 or its filename). Files are copied — the originals are
|
||||
// never modified. Books land in Documents, same as "New".
|
||||
handleIpc("folio:importMarkdown", async () => {
|
||||
const { canceled, filePaths } = await showOpenDialog({
|
||||
title: "Open Markdown 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,7 +1,8 @@
|
|||
import path from "path";
|
||||
import { app, Menu, BrowserWindow } from "electron";
|
||||
import { app, dialog, Menu, BrowserWindow } from "electron";
|
||||
import { listRecents } from "./recents.js";
|
||||
import type { Recent } from "./recents.js";
|
||||
import { setRecoveryRoot } from "./backup.js";
|
||||
|
||||
type MenuItem = Electron.MenuItemConstructorOptions;
|
||||
type GetState = () => { bookPath: string | null };
|
||||
|
|
@ -37,10 +38,6 @@ export function buildMenu(
|
|||
submenu: [
|
||||
{ label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew },
|
||||
{ label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen },
|
||||
{
|
||||
label: "Open File…",
|
||||
click: () => getWindow()?.webContents.send("folio:open-markdown-requested"),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Save",
|
||||
|
|
@ -74,9 +71,18 @@ export function buildMenu(
|
|||
},
|
||||
},
|
||||
{
|
||||
label: "Settings…",
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
click: () => getWindow()?.webContents.send("folio:settings-requested"),
|
||||
label: "Backup Location…",
|
||||
click: async () => {
|
||||
const win = getWindow();
|
||||
const opts = {
|
||||
properties: ["openDirectory" as const],
|
||||
title: "Choose recovery backup folder",
|
||||
};
|
||||
const { canceled, filePaths } = win
|
||||
? await dialog.showOpenDialog(win, opts)
|
||||
: await dialog.showOpenDialog(opts);
|
||||
if (!canceled && filePaths[0]) setRecoveryRoot(filePaths[0]);
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
|
|
@ -107,11 +113,6 @@ export function buildMenu(
|
|||
click: () => getWindow()?.webContents.send("folio:export-odt-requested"),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Check for Updates…",
|
||||
click: () => getWindow()?.webContents.send("folio:check-update-requested"),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Close",
|
||||
accelerator: isMac ? "Cmd+W" : "Ctrl+W",
|
||||
|
|
|
|||
|
|
@ -10,10 +10,8 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p),
|
||||
getRecents: () => ipcRenderer.invoke("folio:getRecents"),
|
||||
closeBook: () => ipcRenderer.invoke("folio:closeBook"),
|
||||
|
||||
// spelling (English dictionary, suggestions in the native context menu)
|
||||
getSpellcheck: () => ipcRenderer.invoke("folio:getSpellcheck"),
|
||||
setSpellcheck: (on: boolean) => ipcRenderer.invoke("folio:setSpellcheck", on),
|
||||
showContextMenu: (x: number, y: number) =>
|
||||
ipcRenderer.send("folio:context-menu", x, y),
|
||||
|
||||
// chapters
|
||||
getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"),
|
||||
|
|
@ -56,12 +54,6 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
exportOdt: (chapterIds?: string[] | null) =>
|
||||
ipcRenderer.invoke("folio:exportOdt", chapterIds),
|
||||
importAttachment: () => ipcRenderer.invoke("folio:importAttachment"),
|
||||
openMarkdownFiles: () => ipcRenderer.invoke("folio:importMarkdown"),
|
||||
openPathOrImport: (p: string) => ipcRenderer.invoke("folio:openPathOrImport", p),
|
||||
checkUpdate: () => ipcRenderer.invoke("folio:checkUpdate"),
|
||||
performUpdate: () => ipcRenderer.invoke("folio:performUpdate"),
|
||||
onUpdateProgress: (cb: (p: { received: number; total: number }) => void) =>
|
||||
ipcRenderer.on("folio:update-progress", (_e, data) => cb(data)),
|
||||
copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text),
|
||||
getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"),
|
||||
toggleFullscreen: () => ipcRenderer.invoke("folio:toggleFullscreen"),
|
||||
|
|
@ -77,15 +69,6 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
setBackupLocation: (loc: string) => ipcRenderer.invoke("folio:setBackupLocation", loc),
|
||||
pickBackupFolder: () => ipcRenderer.invoke("folio:pickBackupFolder"),
|
||||
|
||||
// autosave + continuous snapshots
|
||||
getAutosave: () => ipcRenderer.invoke("folio:getAutosave"),
|
||||
setAutosave: (on: boolean) => ipcRenderer.invoke("folio:setAutosave", on),
|
||||
getSnapshotSettings: () => ipcRenderer.invoke("folio:getSnapshotSettings"),
|
||||
setSnapshotSettings: (patch: unknown) =>
|
||||
ipcRenderer.invoke("folio:setSnapshotSettings", patch),
|
||||
pickSnapshotFolder: () => ipcRenderer.invoke("folio:pickSnapshotFolder"),
|
||||
snapshotNow: () => ipcRenderer.invoke("folio:snapshotNow"),
|
||||
|
||||
// events
|
||||
onBookOpened: (cb: (data: { path: string; title: string }) => void) =>
|
||||
ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)),
|
||||
|
|
@ -127,10 +110,4 @@ contextBridge.exposeInMainWorld("folio", {
|
|||
ipcRenderer.on("folio:zoom-out-requested", () => cb()),
|
||||
onZoomResetRequested: (cb: () => void) =>
|
||||
ipcRenderer.on("folio:zoom-reset-requested", () => cb()),
|
||||
onOpenMarkdownRequested: (cb: () => void) =>
|
||||
ipcRenderer.on("folio:open-markdown-requested", () => cb()),
|
||||
onCheckUpdateRequested: (cb: () => void) =>
|
||||
ipcRenderer.on("folio:check-update-requested", () => cb()),
|
||||
onSettingsRequested: (cb: () => void) =>
|
||||
ipcRenderer.on("folio:settings-requested", () => cb()),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,173 +0,0 @@
|
|||
// 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,7 +41,6 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
|
|||
"#3b82f6", "#8b5cf6", "#78716c", "#18181b",
|
||||
];
|
||||
const HEX_RE = /^#[0-9a-fA-F]{3,8}$/;
|
||||
let animateNextRender = false;
|
||||
|
||||
let colorPopover: HTMLDivElement | null = null;
|
||||
function closeColorPopover() {
|
||||
|
|
@ -151,6 +150,7 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
|
|||
li.draggable = false;
|
||||
li.tabIndex = -1;
|
||||
li.innerHTML = `
|
||||
<span class="drag-handle" title="Drag to reorder">⋮</span>
|
||||
<span class="row-title" title="${ch.title}">${ch.title}</span>
|
||||
<span class="row-actions">
|
||||
<button class="icon-btn" data-act="color" title="Chapter color">●</button>
|
||||
|
|
@ -299,29 +299,12 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
|
|||
}
|
||||
}
|
||||
|
||||
async function persistOrder(scrollSelected = false) {
|
||||
async function persistOrder() {
|
||||
if (!state.meta) return;
|
||||
state.meta.chapterOrder = state.ordered.map((c) => c.id);
|
||||
const res = await api.reorderChapters(state.meta.chapterOrder);
|
||||
if (res && "error" in res) await alertMessage("Could not reorder chapters", (res as { error: string }).error);
|
||||
else {
|
||||
renderChapterList();
|
||||
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" });
|
||||
else renderChapterList();
|
||||
}
|
||||
|
||||
function moveSelectedDown() {
|
||||
|
|
@ -330,10 +313,8 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
|
|||
if (i < 0 || i >= state.ordered.length - 1) return;
|
||||
state.ordered = moveInList(state.ordered, i, i + 1);
|
||||
state.meta!.chapterOrder = state.ordered.map((c) => c.id);
|
||||
animateNextRender = true;
|
||||
renderChapterList();
|
||||
keepSelectedVisible();
|
||||
void persistOrder(true);
|
||||
void persistOrder();
|
||||
}
|
||||
|
||||
function moveSelectedUp() {
|
||||
|
|
@ -342,10 +323,8 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
|
|||
if (i <= 0) return;
|
||||
state.ordered = moveInList(state.ordered, i, i - 1);
|
||||
state.meta!.chapterOrder = state.ordered.map((c) => c.id);
|
||||
animateNextRender = true;
|
||||
renderChapterList();
|
||||
keepSelectedVisible();
|
||||
void persistOrder(true);
|
||||
void persistOrder();
|
||||
}
|
||||
|
||||
// ---- pointer-based reordering ----
|
||||
|
|
@ -354,65 +333,10 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
|
|||
let dragStartY = 0;
|
||||
let dragMoved = false;
|
||||
let dragJustHappened = false;
|
||||
let animateNextRender = false;
|
||||
let dragGhost: HTMLElement | null = null;
|
||||
let dragPlaceholder: HTMLElement | null = null;
|
||||
let dragOffsetY = 0;
|
||||
let dragClientX = 0;
|
||||
let dragClientY = 0;
|
||||
let dragScrollRaf = 0;
|
||||
|
||||
function updateDropTarget(clientX: number, clientY: number) {
|
||||
const overEl = document.elementFromPoint(clientX, clientY);
|
||||
const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null;
|
||||
chapterList.querySelectorAll(".chapter-row").forEach((n) => {
|
||||
n.classList.remove("drop-target", "drop-before", "drop-after");
|
||||
});
|
||||
if (overLi && overLi.dataset.id !== draggingId) {
|
||||
overLi.classList.add("drop-target");
|
||||
const before = clientY < overLi.getBoundingClientRect().top + overLi.offsetHeight / 2;
|
||||
overLi.classList.add(before ? "drop-before" : "drop-after");
|
||||
if (dragPlaceholder) {
|
||||
if (before) chapterList.insertBefore(dragPlaceholder, overLi);
|
||||
else chapterList.insertBefore(dragPlaceholder, overLi.nextSibling);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopDragScroll() {
|
||||
if (dragScrollRaf) cancelAnimationFrame(dragScrollRaf);
|
||||
dragScrollRaf = 0;
|
||||
}
|
||||
|
||||
// Continuously scroll the chapter list while the dragged ghost is held
|
||||
// near its top/bottom edge, so chapters above/below the fold are reachable.
|
||||
// pointermove alone is not enough: it stops firing when the pointer holds
|
||||
// still, which is exactly when the user expects the list to keep scrolling.
|
||||
function tickDragScroll() {
|
||||
dragScrollRaf = 0;
|
||||
if (!dragMoved || draggingId === null) return;
|
||||
const rect = chapterList.getBoundingClientRect();
|
||||
const EDGE = 48;
|
||||
const MAX_SPEED = 14;
|
||||
let dy = 0;
|
||||
if (dragClientY < rect.top + EDGE) {
|
||||
const strength = Math.min(1, (rect.top + EDGE - dragClientY) / EDGE);
|
||||
dy = -(2 + MAX_SPEED * strength);
|
||||
} else if (dragClientY > rect.bottom - EDGE) {
|
||||
const strength = Math.min(1, (dragClientY - (rect.bottom - EDGE)) / EDGE);
|
||||
dy = 2 + MAX_SPEED * strength;
|
||||
}
|
||||
if (dy !== 0) {
|
||||
chapterList.scrollTop += dy;
|
||||
if (dragGhost) dragGhost.style.top = `${dragClientY - dragOffsetY}px`;
|
||||
updateDropTarget(dragClientX, dragClientY);
|
||||
}
|
||||
dragScrollRaf = requestAnimationFrame(tickDragScroll);
|
||||
}
|
||||
|
||||
function startDragScroll() {
|
||||
stopDragScroll();
|
||||
dragScrollRaf = requestAnimationFrame(tickDragScroll);
|
||||
}
|
||||
|
||||
chapterList.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0) return; // left button only
|
||||
|
|
@ -429,8 +353,6 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
|
|||
|
||||
document.addEventListener("pointermove", (e) => {
|
||||
if (dragPointerId === null || draggingId === null) return;
|
||||
dragClientX = e.clientX;
|
||||
dragClientY = e.clientY;
|
||||
if (!dragMoved) {
|
||||
if (Math.abs(e.clientY - dragStartY) < 4) return; // movement threshold
|
||||
dragMoved = true;
|
||||
|
|
@ -453,16 +375,27 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
|
|||
src.replaceWith(dragPlaceholder);
|
||||
}
|
||||
document.body.classList.add("dragging-active");
|
||||
startDragScroll();
|
||||
}
|
||||
if (dragGhost) dragGhost.style.top = `${e.clientY - dragOffsetY}px`;
|
||||
updateDropTarget(e.clientX, e.clientY);
|
||||
const overEl = document.elementFromPoint(e.clientX, e.clientY);
|
||||
const overLi = overEl?.closest("li.chapter-row") as HTMLElement | null;
|
||||
chapterList.querySelectorAll(".chapter-row").forEach((n) => {
|
||||
n.classList.remove("drop-target", "drop-before", "drop-after");
|
||||
});
|
||||
if (overLi && overLi.dataset.id !== draggingId) {
|
||||
overLi.classList.add("drop-target");
|
||||
const before = e.clientY < overLi.getBoundingClientRect().top + overLi.offsetHeight / 2;
|
||||
overLi.classList.add(before ? "drop-before" : "drop-after");
|
||||
if (dragPlaceholder) {
|
||||
if (before) chapterList.insertBefore(dragPlaceholder, overLi);
|
||||
else chapterList.insertBefore(dragPlaceholder, overLi.nextSibling);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("pointerup", (e) => {
|
||||
if (dragPointerId === null) return;
|
||||
dragPointerId = null;
|
||||
stopDragScroll();
|
||||
document.body.classList.remove("dragging-active");
|
||||
if (!dragMoved || !draggingId) {
|
||||
draggingId = null;
|
||||
|
|
@ -504,22 +437,6 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
|
|||
draggingId = null;
|
||||
});
|
||||
|
||||
// If the OS cancels the gesture (touch interruption, alert, etc.),
|
||||
// stop scrolling and restore the list so no ghost/placeholder is left behind.
|
||||
document.addEventListener("pointercancel", () => {
|
||||
if (dragPointerId === null) return;
|
||||
dragPointerId = null;
|
||||
stopDragScroll();
|
||||
document.body.classList.remove("dragging-active");
|
||||
dragGhost?.remove();
|
||||
dragGhost = null;
|
||||
dragPlaceholder?.remove();
|
||||
dragPlaceholder = null;
|
||||
draggingId = null;
|
||||
dragMoved = false;
|
||||
renderChapterList();
|
||||
});
|
||||
|
||||
addChapterBtn.addEventListener("click", () => void create());
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -35,9 +35,6 @@ export interface EditorFeature {
|
|||
saveAndNotify(): Promise<void>;
|
||||
flushAutosave(): Promise<void>;
|
||||
clearTimers(): void;
|
||||
setAutosaveEnabled(on: boolean): void;
|
||||
setTypewriterEnabled(on: boolean): void;
|
||||
isTypewriterEnabled(): boolean;
|
||||
}
|
||||
|
||||
export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
||||
|
|
@ -51,45 +48,6 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
|||
});
|
||||
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
|
||||
// lengths. Keep the surrounding editor area clickable so a new chapter can
|
||||
// be focused without requiring a click on the centered content column.
|
||||
|
|
@ -105,19 +63,6 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
|||
const AUTOSAVE_MS = 1000;
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const saveStateEl = $("saveState");
|
||||
// Background autosave switch (Settings). When off, typing only marks the
|
||||
// chapter Unsaved; explicit Save, chapter switches, and app-hide flushes
|
||||
// still persist so work is never silently lost.
|
||||
let autosaveOn = true;
|
||||
void api.getAutosave().then(
|
||||
(v) => { autosaveOn = v !== false; },
|
||||
() => { /* default on when the backend is unreachable */ }
|
||||
);
|
||||
function setAutosaveEnabled(on: boolean) {
|
||||
autosaveOn = on !== false;
|
||||
if (autosaveOn && state.selectedId && state.dirty) scheduleAutosave();
|
||||
else if (!autosaveOn && saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
|
||||
|
|
@ -208,15 +153,9 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
|||
|
||||
// Schedule a background save a short time after the last edit. The indicator
|
||||
// shows "Saving…" while the debounce is pending so the user knows the chapter
|
||||
// is about to be persisted automatically. With background autosave off this
|
||||
// only marks the chapter Unsaved until the user saves explicitly.
|
||||
// is about to be persisted automatically.
|
||||
function scheduleAutosave() {
|
||||
clearTimers();
|
||||
if (!autosaveOn) {
|
||||
saveStateEl.textContent = "Unsaved";
|
||||
saveStateEl.classList.add("unsaved");
|
||||
return;
|
||||
}
|
||||
saveStateEl.textContent = "Saving…";
|
||||
saveStateEl.classList.remove("unsaved");
|
||||
saveTimer = setTimeout(() => void flushAutosave(), AUTOSAVE_MS);
|
||||
|
|
@ -235,7 +174,7 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
|||
const FORCED_AUTOSAVE_MS = 30_000;
|
||||
let forcedSaveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
function startForcedAutosave() {
|
||||
if (forcedSaveTimer || !autosaveOn) return;
|
||||
if (forcedSaveTimer) return;
|
||||
forcedSaveTimer = setInterval(() => {
|
||||
if (state.selectedId && state.dirty) void flushAutosave();
|
||||
}, FORCED_AUTOSAVE_MS);
|
||||
|
|
@ -278,8 +217,6 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
|||
setActive("align-left", fmt.align === "left" || fmt.align === "");
|
||||
setActive("align-center", fmt.align === "center");
|
||||
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).
|
||||
|
|
@ -422,8 +359,5 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
|
|||
saveAndNotify,
|
||||
flushAutosave,
|
||||
clearTimers,
|
||||
setAutosaveEnabled,
|
||||
setTypewriterEnabled,
|
||||
isTypewriterEnabled,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import { createChapterListFeature } from "./chapter-list.js";
|
|||
import { createWelcomeFeature } from "./welcome.js";
|
||||
import { createExportsFeature } from "./exports.js";
|
||||
import { createCommentsFeature } from "./comments.js";
|
||||
import { createSettingsFeature } from "./settings.js";
|
||||
import {
|
||||
$,
|
||||
basename,
|
||||
|
|
@ -61,13 +60,6 @@ export function initBook(api: FolioAPI) {
|
|||
hooks.saveAndNotify = ed.saveAndNotify;
|
||||
hooks.flushAutosave = ed.flushAutosave;
|
||||
hooks.clearTimers = ed.clearTimers;
|
||||
hooks.setAutosaveEnabled = ed.setAutosaveEnabled;
|
||||
hooks.setTypewriterEnabled = ed.setTypewriterEnabled;
|
||||
hooks.isTypewriterEnabled = ed.isTypewriterEnabled;
|
||||
|
||||
// Feature: settings modal (autosave + snapshot backups).
|
||||
const settings = createSettingsFeature(ctx);
|
||||
hooks.openSettings = settings.openSettings;
|
||||
|
||||
// Feature: formatting toolbar + font controls.
|
||||
createToolbarFeature(ctx, editor);
|
||||
|
|
@ -139,56 +131,6 @@ export function initBook(api: FolioAPI) {
|
|||
// The comments module owns its own DOM chrome and wiring; here we only keep the
|
||||
// top-level constructor call and the bridge assignments it already set above.
|
||||
|
||||
// ---- update flow ----
|
||||
// "Check for Updates…" (File menu / welcome button): query the release
|
||||
// server, then offer a one-click install. The app downloads the tarball,
|
||||
// hands off to a detached swap script, and relaunches itself — no manual
|
||||
// reinstall. See src/main/update.ts for the safety properties.
|
||||
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 ----
|
||||
// These now delegate to the extracted feature facades rather than calling
|
||||
// inline functions defined in this module.
|
||||
|
|
@ -198,9 +140,6 @@ export function initBook(api: FolioAPI) {
|
|||
api.onNewBookRequested(() => void welcome.startNewBook());
|
||||
api.onOpenBookRequested(() => void welcome.startOpenBook());
|
||||
api.onOpenRecent((p) => void welcome.openPath(p));
|
||||
api.onOpenMarkdownRequested(() => void welcome.openMarkdown());
|
||||
api.onCheckUpdateRequested(() => void checkForUpdates());
|
||||
$("welcomeUpdate")?.addEventListener("click", () => void checkForUpdates());
|
||||
api.onRevealRequested(async (p) => {
|
||||
const ok = await api.revealInFolder(p);
|
||||
showToast(
|
||||
|
|
|
|||
|
|
@ -1,179 +0,0 @@
|
|||
// 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,10 +201,17 @@ export function createToolbarFeature(ctx: BookCtx, editor: Editor): ToolbarFeatu
|
|||
// any of those closes the dropdowns.
|
||||
window.addEventListener("scroll", closeDropdowns, true);
|
||||
window.addEventListener("resize", closeDropdowns);
|
||||
// Note: right-click is handled by the main process (webContents
|
||||
// context-menu with spelling suggestions + Cut/Copy/Paste), so no DOM
|
||||
// contextmenu listener here — preventing the default would suppress the
|
||||
// native suggestion menu for misspelled words.
|
||||
// Electron has no default right-click menu, so show one with Cut/Copy/Paste.
|
||||
window.addEventListener("contextmenu", (e) => {
|
||||
// Let Chromium show its native spelling suggestions when the target is a
|
||||
// misspelled word. The custom menu is used for the rest of the editor.
|
||||
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;
|
||||
headingSelect.addEventListener("change", () => {
|
||||
|
|
|
|||
|
|
@ -39,8 +39,7 @@ export type FolioAPI = {
|
|||
revealInFolder: (p: string) => Promise<boolean>;
|
||||
getRecents: () => Promise<any[]>;
|
||||
closeBook: () => Promise<boolean>;
|
||||
getSpellcheck: () => Promise<boolean>;
|
||||
setSpellcheck: (on: boolean) => Promise<any>;
|
||||
showContextMenu: (x: number, y: number) => void;
|
||||
getBookMeta: () => Promise<BookMeta | { error: string }>;
|
||||
setComments: (comments: Record<string, unknown>) => Promise<any>;
|
||||
getChapterList: () => Promise<any[]>;
|
||||
|
|
@ -65,11 +64,6 @@ export type FolioAPI = {
|
|||
exportDocx: (chapterIds?: string[] | null) => Promise<any>;
|
||||
exportOdt: (chapterIds?: string[] | null) => 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>;
|
||||
getClipboardText: () => Promise<string>;
|
||||
toggleFullscreen: () => Promise<boolean>;
|
||||
|
|
@ -77,12 +71,6 @@ export type FolioAPI = {
|
|||
setTheme: (id: string) => Promise<any>;
|
||||
getZoom: () => Promise<number>;
|
||||
setZoom: (pct: number) => Promise<any>;
|
||||
getAutosave: () => Promise<boolean>;
|
||||
setAutosave: (on: boolean) => Promise<any>;
|
||||
getSnapshotSettings: () => Promise<any>;
|
||||
setSnapshotSettings: (patch: unknown) => Promise<any>;
|
||||
pickSnapshotFolder: () => Promise<any>;
|
||||
snapshotNow: () => Promise<any>;
|
||||
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
|
||||
onBookClosed: (cb: () => void) => void;
|
||||
onChaptersChanged: (cb: () => void) => void;
|
||||
|
|
@ -103,9 +91,6 @@ export type FolioAPI = {
|
|||
onZoomInRequested: (cb: () => void) => void;
|
||||
onZoomOutRequested: (cb: () => void) => void;
|
||||
onZoomResetRequested: (cb: () => void) => void;
|
||||
onOpenMarkdownRequested: (cb: () => void) => void;
|
||||
onCheckUpdateRequested: (cb: () => void) => void;
|
||||
onSettingsRequested: (cb: () => void) => void;
|
||||
};
|
||||
|
||||
// ---- P7 decomposition contracts ----
|
||||
|
|
@ -133,11 +118,6 @@ export interface BookHooks {
|
|||
saveAndNotify(): Promise<void>;
|
||||
flushAutosave(): Promise<void>;
|
||||
clearTimers(): void;
|
||||
setAutosaveEnabled(on: boolean): void;
|
||||
setTypewriterEnabled(on: boolean): void;
|
||||
isTypewriterEnabled(): boolean;
|
||||
// settings feature
|
||||
openSettings(): void;
|
||||
// session coordinators (orchestrator)
|
||||
refreshMeta(): Promise<void>;
|
||||
showPlaceholder(): void;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ export interface WelcomeFeature {
|
|||
refreshMeta(): Promise<void>;
|
||||
startNewBook(): Promise<void>;
|
||||
startOpenBook(): Promise<void>;
|
||||
openMarkdown(): Promise<void>;
|
||||
openPath(p: string): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -163,24 +162,6 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
|||
await renderRecents();
|
||||
}
|
||||
|
||||
// Open one or more standalone Markdown files: each becomes a new book
|
||||
// (copied, originals untouched) and the first one opens immediately.
|
||||
async function openMarkdown() {
|
||||
const res = await api.openMarkdownFiles();
|
||||
if (!res || "canceled" in res) return;
|
||||
if (!Array.isArray(res)) { await alertMessage("Error", (res as { error: string }).error); return; }
|
||||
const results = res as { ok: boolean; file: string; bookPath?: string; title?: string; error?: string }[];
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
const first = results.find((r) => r.ok);
|
||||
if (first?.bookPath) await openPath(first.bookPath);
|
||||
if (failed.length) {
|
||||
await alertMessage(
|
||||
"Some files could not be imported",
|
||||
failed.map((f) => `${f.file}: ${f.error}`).join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderRecents() {
|
||||
try {
|
||||
const recents = await api.getRecents();
|
||||
|
|
@ -242,7 +223,6 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
|||
// ---- wiring (bind once at construction) ----
|
||||
$("welcomeNewBook").addEventListener("click", () => void startNewBook());
|
||||
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
|
||||
$("welcomeOpenMd")?.addEventListener("click", () => void openMarkdown());
|
||||
homeBtn?.addEventListener("click", async () => {
|
||||
hooks.clearTimers();
|
||||
if (state.selectedId && state.dirty) {
|
||||
|
|
@ -264,6 +244,5 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
|||
startNewBook,
|
||||
startOpenBook,
|
||||
openPath,
|
||||
openMarkdown,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const cancelBtn = el("modalCancel") as HTMLButtonElement;
|
|||
let currentMode: Mode = "prompt";
|
||||
let resolver: ((value: string | null) => void) | null = null;
|
||||
|
||||
function open(mode: Mode, title: string, body: string, value: string, okLabel?: string): Promise<string | null> {
|
||||
function open(mode: Mode, title: string, body: string, value: string): Promise<string | null> {
|
||||
// If a previous dialog was somehow still open (menu accelerators can open
|
||||
// one while another is visible), settle its promise as cancelled so the
|
||||
// awaiting caller isn't stranded forever.
|
||||
|
|
@ -30,7 +30,7 @@ function open(mode: Mode, title: string, body: string, value: string, okLabel?:
|
|||
inputEl.classList.toggle("hidden", mode !== "prompt");
|
||||
msgEl.classList.toggle("hidden", mode === "prompt");
|
||||
cancelBtn.classList.toggle("hidden", mode === "alert");
|
||||
okBtn.textContent = okLabel || (mode === "confirm" ? "Delete" : "OK");
|
||||
okBtn.textContent = mode === "confirm" ? "Delete" : "OK";
|
||||
openEl(backdrop);
|
||||
if (mode === "prompt") {
|
||||
inputEl.focus();
|
||||
|
|
@ -84,8 +84,8 @@ export function promptInput(title: string, value?: string): Promise<string | nul
|
|||
return open("prompt", title, "", value ?? "");
|
||||
}
|
||||
|
||||
export function confirmMessage(title: string, body?: string, okLabel?: string): Promise<boolean> {
|
||||
return open("confirm", title, body ?? "", "", okLabel).then((v) => v !== null);
|
||||
export function confirmMessage(title: string, body?: string): Promise<boolean> {
|
||||
return open("confirm", title, body ?? "", "").then((v) => v !== null);
|
||||
}
|
||||
|
||||
export function alertMessage(title: string, body?: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -44,29 +44,23 @@ html, body {
|
|||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex: 0 0 280px;
|
||||
min-width: 240px;
|
||||
max-width: min(360px, 85vw);
|
||||
flex-shrink: 0;
|
||||
max-width: 360px;
|
||||
background: var(--color-panel);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--gap);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
|
|
@ -227,60 +221,6 @@ html, body {
|
|||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ---- settings modal ---- */
|
||||
.settings-modal {
|
||||
min-width: min(480px, 92vw);
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.setting-row input[type="checkbox"] {
|
||||
margin-top: 3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--color-accent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.setting-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.setting-grid {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 6px 0 10px 26px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.setting-grid label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.setting-num {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.setting-meta {
|
||||
font-size: 0.76rem;
|
||||
color: var(--color-muted);
|
||||
font-family: var(--font-mono);
|
||||
margin: 2px 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ---- full book nav ---- */
|
||||
.full-book-nav {
|
||||
margin-top: var(--gap);
|
||||
|
|
@ -348,10 +288,8 @@ html, body {
|
|||
.editor {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
background: var(--color-bg);
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.editor-placeholder {
|
||||
|
|
@ -724,6 +662,23 @@ body.dragging-active * {
|
|||
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 {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
|
@ -900,7 +855,6 @@ body.dragging-active * {
|
|||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -909,22 +863,17 @@ body.dragging-active * {
|
|||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chapter-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
|
|
@ -933,11 +882,6 @@ body.dragging-active * {
|
|||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.word-count {
|
||||
|
|
@ -948,11 +892,6 @@ body.dragging-active * {
|
|||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
gap: 10px;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.72rem;
|
||||
|
|
@ -1163,13 +1102,9 @@ body.dragging-active * {
|
|||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
overflow-x: clip;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
overflow: visible;
|
||||
gap: 4px;
|
||||
row-gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-panel);
|
||||
|
|
@ -1181,15 +1116,11 @@ body.dragging-active * {
|
|||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
gap: 2px;
|
||||
padding-right: 8px;
|
||||
margin-right: 8px;
|
||||
border-right: 1px solid var(--color-border);
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.toolbar-group:last-child {
|
||||
|
|
@ -2006,40 +1937,3 @@ body:not(.dragging-active) .chapter-row {
|
|||
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,7 +30,6 @@
|
|||
</div>
|
||||
<div class="sidebar-footer">
|
||||
<button class="button button-ghost" id="searchBtn" title="Search all chapters (Ctrl+F)">Search</button>
|
||||
<button class="button button-ghost" id="settingsBtn" title="Settings (autosave, backups)">Settings</button>
|
||||
<div class="theme-control">
|
||||
<label class="theme-label" for="themeSelect">Theme</label>
|
||||
<select id="themeSelect" class="theme-select" title="Interface theme">
|
||||
|
|
@ -57,8 +56,6 @@
|
|||
<div class="welcome-actions">
|
||||
<button class="button" id="welcomeNewBook">New</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 class="recents-block">
|
||||
<div class="recents-title">Recent</div>
|
||||
|
|
@ -316,47 +313,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop hidden" id="settingsModal">
|
||||
<div class="modal settings-modal" role="dialog" aria-modal="true" aria-label="Settings">
|
||||
<h3 class="modal-title">Settings</h3>
|
||||
<label class="setting-row">
|
||||
<input type="checkbox" id="settingAutosave" checked />
|
||||
<span><strong>Autosave while typing</strong><br />
|
||||
<span class="setting-hint">Saves ~1s after you stop typing. When off, use Save (Ctrl+S). Switching chapters or closing still saves, so work is never lost.</span></span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<input type="checkbox" id="settingSnapshots" checked />
|
||||
<span><strong>Continuous snapshots</strong><br />
|
||||
<span class="setting-hint">Copies the whole book in the background when it changes. Only the newest snapshots are kept.</span></span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<input type="checkbox" id="settingSpellcheck" checked />
|
||||
<span><strong>Spellcheck (English)</strong><br />
|
||||
<span class="setting-hint">Underline misspellings; right-click a word for suggestions or to add it to the dictionary.</span></span>
|
||||
</label>
|
||||
<label class="setting-row">
|
||||
<input type="checkbox" id="settingTypewriter" />
|
||||
<span><strong>Typewriter mode</strong><br />
|
||||
<span class="setting-hint">Keep the line you're writing near the middle of the editor as you type.</span></span>
|
||||
</label>
|
||||
<div class="setting-grid">
|
||||
<label>Every (minutes) <input type="number" id="settingInterval" class="modal-input setting-num" min="1" max="1440" value="5" /></label>
|
||||
<label>Keep last <input type="number" id="settingKeep" class="modal-input setting-num" min="0" max="200" value="20" /></label>
|
||||
</div>
|
||||
<div class="backup-control">
|
||||
<span class="backup-label">Folder</span>
|
||||
<input type="text" id="settingLocation" class="backup-input" readonly placeholder="Default: <book>/Folio-Backups" title="Snapshot folder (empty = default)" />
|
||||
<button type="button" class="button button-ghost backup-browse" id="settingBrowse" title="Choose snapshot folder">…</button>
|
||||
<button type="button" class="button button-ghost" id="settingResetLoc" title="Back to the default folder">Reset</button>
|
||||
</div>
|
||||
<p class="setting-meta" id="settingResolved"></p>
|
||||
<p class="setting-meta" id="settingLastSnap"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="button button-ghost" id="settingSnapNow" title="Take a snapshot right now">Snapshot now</button>
|
||||
<button class="button" id="settingsClose">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop hidden" id="statsModal">
|
||||
<div class="modal stats-modal" role="dialog" aria-modal="true" aria-label="Writing statistics">
|
||||
<h3 class="modal-title">Writing statistics</h3>
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
// 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.");
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
// 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.");
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
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);
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
// 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