feat: open Markdown files and in-app updater

Markdown:
- File > Open Markdown File... and welcome-screen 'Open .md' import
  one or more .md files as new books (named after first H1 or the
  filename; originals never modified, collision-safe folder names).
- Book dirs and .md files passed on the command line or via desktop
  file associations now open directly; single-instance lock forwards
  second launches to the running window.

Updates:
- File > Check for Updates... and welcome-screen 'Update' button:
  queries the Gitea releases API, one-click installs a newer release
  into ~/.local/share/Folio via a detached swap script that waits for
  app exit, keeps the old tree until the swap succeeds, and relaunches.
  Download is gzip-verified and restricted to the trusted release host.
  No root, no reinstall.

Tests: 19 new assertions in tests/run-update-import-test.mjs (wired
into npm test); full suite green, tsc clean.
This commit is contained in:
avi 2026-09-19 00:02:58 -05:00
commit ada5eee679
12 changed files with 634 additions and 5 deletions

View file

@ -15,7 +15,7 @@
"dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"", "dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"",
"start": "npm run build && npm run electron", "start": "npm run build && npm run electron",
"lint": "tsc --noEmit", "lint": "tsc --noEmit",
"test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-office-export-test.mjs && node tests/run-migration-test.mjs && node tests/run-editor-test.mjs && node tests/run-fullbook-test.mjs && node tests/run-print-test.mjs", "test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-office-export-test.mjs && node tests/run-migration-test.mjs && node tests/run-editor-test.mjs && node tests/run-fullbook-test.mjs && node tests/run-print-test.mjs && node tests/run-update-import-test.mjs",
"package": "electron-builder" "package": "electron-builder"
}, },
"devDependencies": { "devDependencies": {

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

@ -0,0 +1,56 @@
// Import standalone Markdown files as Folio books.
//
// Electron-free on purpose (like backup.ts): takes the file list and the
// destination root as arguments so the logic is unit-testable without an
// app instance. One book per file, named after the file's first H1 (or its
// filename when there is no heading). The original file is never modified —
// its content is copied into the new book's chapter.
import fs from "fs";
import path from "path";
import { createBook, sanitizeBookFolderName } from "./project.js";
import { createChapter, setChapterContent } from "./chapters.js";
export interface ImportResult {
ok: boolean;
file: string;
bookPath?: string;
title?: string;
error?: string;
}
const MD_EXT = /\.(md|markdown|txt)$/i;
export function isImportableMarkdown(file: string): boolean {
return MD_EXT.test(file);
}
// Title = first ATX H1 ("# Title"), else the filename stem.
export function mdTitle(raw: string, fallback: string): string {
const m = /^[ \t]*#[ \t]+(.+?)[ \t]*#*[ \t]*$/m.exec(raw);
const t = (m && m[1] ? m[1] : fallback).trim();
return t || fallback;
}
export function importMarkdownFiles(files: string[], root: string): ImportResult[] {
return files.map((file) => {
try {
const raw = fs.readFileSync(file, "utf-8");
const stem = path.basename(file).replace(MD_EXT, "") || "Untitled";
const title = mdTitle(raw, stem);
const folder =
sanitizeBookFolderName(title) || sanitizeBookFolderName(stem) || "Imported";
let dir = path.join(root, folder);
let n = 2;
while (fs.existsSync(dir)) {
dir = path.join(root, `${folder} ${n}`);
n += 1;
}
createBook(dir, title);
const entry = createChapter(dir, title);
setChapterContent(dir, entry.id, raw);
return { ok: true, file, bookPath: dir, title };
} catch (e) {
return { ok: false, file, error: (e as Error).message };
}
});
}

View file

@ -42,6 +42,18 @@ import { buildDocx, buildOdt } from "./office-export.js";
import { getSetting, setSetting } from "./settings.js"; import { getSetting, setSetting } from "./settings.js";
import { migrateChapterToNative, migrateBookToNative } from "./migration.js"; import { migrateChapterToNative, migrateBookToNative } from "./migration.js";
import { loadChapterDoc, saveChapterDoc } from "./chapters.js"; import { loadChapterDoc, saveChapterDoc } from "./chapters.js";
import { importMarkdownFiles, isImportableMarkdown } from "./import-md.js";
import {
INSTALL_DIR_DEFAULT,
LAUNCHER_DEFAULT,
UPDATE_BASE_DEFAULT,
UPDATE_REPO,
buildInstallerScript,
isTrustedDownloadUrl,
isNewer,
parseRelease,
pickAsset,
} from "./update.js";
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let bookPath: string | null = null; let bookPath: string | null = null;
@ -116,6 +128,13 @@ app.whenReady().then(() => {
() => getWindow()?.webContents.send("folio:open-book-requested") () => getWindow()?.webContents.send("folio:open-book-requested")
); );
createWindow(); createWindow();
// Open a book folder or Markdown file passed on the command line
// (`folio notes.md`, or file-association launches from the desktop).
const initialFile = fileArgFrom(process.argv);
if (initialFile) {
const win = getWindow();
win?.webContents.once("did-finish-load", () => openFromArg(initialFile));
}
app.on("activate", () => { app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow(); if (BrowserWindow.getAllWindows().length === 0) createWindow();
}); });
@ -756,3 +775,221 @@ handleIpc("folio:importAttachment", async () => {
const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/"); const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/");
return { ok: true, name, relPath }; return { ok: true, name, relPath };
}); });
// ---- Markdown import ------------------------------------------------------
// Import standalone .md files as Folio books (one book per file, named after
// the file's first H1 or its filename). Files are copied — the originals are
// never modified. Books land in Documents, same as "New".
handleIpc("folio:importMarkdown", async () => {
const { canceled, filePaths } = await showOpenDialog({
title: "Open Markdown file(s)",
properties: ["openFile", "multiSelections"],
filters: [{ name: "Markdown", extensions: ["md", "markdown", "txt"] }],
});
if (canceled || !filePaths.length) return { canceled: true } as const;
return importMarkdownFiles(filePaths, app.getPath("documents"));
});
// Open a path that may be either a Folio book directory or a Markdown file
// (the desktop launcher can be handed either via file associations).
handleIpc("folio:openPathOrImport", async (_e, p: string) => {
if (p && isImportableMarkdown(p)) {
const results = importMarkdownFiles([p], app.getPath("documents"));
const r = results[0];
if (!r.ok) return { error: r.error };
return openBookAt(r.bookPath as string);
}
return openBookAt(p);
});
// ---- In-app update --------------------------------------------------------
// Release source. Defaults to the project's Gitea instance; the installed
// tree may override it with update.json next to the binary (e.g. for
// staging), and FOLIO_UPDATE_BASE/FOLIO_UPDATE_REPO override everything.
function updateConfig(): { base: string; repo: string; installDir: string; launcher: string } {
const cfg = {
base: UPDATE_BASE_DEFAULT,
repo: UPDATE_REPO,
installDir: INSTALL_DIR_DEFAULT,
launcher: LAUNCHER_DEFAULT,
};
try {
const p = path.join(cfg.installDir, "update.json");
if (fs.existsSync(p)) {
const j = JSON.parse(fs.readFileSync(p, "utf-8"));
if (typeof j.base === "string") cfg.base = j.base.replace(/\/+$/, "");
if (typeof j.repo === "string") cfg.repo = j.repo;
if (typeof j.launcher === "string") cfg.launcher = j.launcher;
}
} catch {
/* malformed update.json: keep defaults */
}
if (process.env.FOLIO_UPDATE_BASE) cfg.base = process.env.FOLIO_UPDATE_BASE.replace(/\/+$/, "");
if (process.env.FOLIO_UPDATE_REPO) cfg.repo = process.env.FOLIO_UPDATE_REPO;
return cfg;
}
interface UpdateCheckResult {
current: string;
latest?: string;
tag?: string;
updateAvailable?: boolean;
assetName?: string;
notes?: string;
canInstall?: boolean;
error?: string;
}
async function fetchLatestRelease(): Promise<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,
canInstall:
!!asset &&
isTrustedDownloadUrl(asset.browser_download_url, updateConfig().base) &&
process.platform === "linux" &&
fs.existsSync(path.join(updateConfig().installDir, "folio")),
};
} catch (e) {
return { current: app.getVersion(), error: (e as Error).message };
}
});
// Download the release tarball to the OS temp dir, verify it looks like a
// gzip stream, then hand off to the detached swap script and quit. The old
// install is kept until the swap succeeds, so failure can never strand the
// user without a working app.
handleIpc("folio:performUpdate", async () => {
try {
const cfg = updateConfig();
const rel = await fetchLatestRelease();
const asset = pickAsset(rel.assets);
if (!asset) return { error: `Release ${rel.tag} has no tar.gz asset to install.` };
if (!isTrustedDownloadUrl(asset.browser_download_url, cfg.base)) {
return { error: "Download URL is not on the trusted release host; update aborted." };
}
if (!fs.existsSync(path.join(cfg.installDir, "folio"))) {
return {
error:
"This Folio is not the user-local install (~/.local/share/Folio), so in-place update is unavailable.",
};
}
const tarball = path.join(
app.getPath("temp"),
`folio-update-${Date.now()}.tar.gz`
);
const res = await fetch(asset.browser_download_url, {
redirect: "follow",
signal: AbortSignal.timeout(10 * 60 * 1000),
});
if (!res.ok || !res.body) {
return { error: `Download failed (HTTP ${res.status}).` };
}
const total = Number(res.headers.get("content-length")) || asset.size || 0;
const { Readable } = await import("stream");
const { createWriteStream } = await import("fs");
const { pipeline } = await import("stream/promises");
let received = 0;
const source = Readable.fromWeb(res.body as never);
source.on("data", (chunk: Buffer) => {
received += chunk.length;
getWindow()?.webContents.send("folio:update-progress", { received, total });
});
await pipeline(source, createWriteStream(tarball));
const head = fs.readFileSync(tarball).subarray(0, 2);
if (head.length < 2 || head[0] !== 0x1f || head[1] !== 0x8b) {
fs.rmSync(tarball, { force: true });
return { error: "Downloaded file is not a valid archive; update aborted." };
}
const script = path.join(
app.getPath("temp"),
`folio-update-${process.pid}.sh`
);
fs.writeFileSync(
script,
buildInstallerScript({
pid: process.pid,
tarball,
installDir: cfg.installDir,
relaunch: cfg.launcher,
}),
{ mode: 0o700 }
);
const { spawn } = await import("child_process");
const child = spawn("/bin/sh", [script], { detached: true, stdio: "ignore" });
child.on("error", (err) => {
console.error("[folio] updater spawn failed:", err.message);
});
child.unref();
// Quit so the swap script can replace the app directory.
setTimeout(() => app.quit(), 300);
return { ok: true, version: rel.version };
} catch (e) {
return { error: (e as Error).message };
}
});
// ---- Files passed on the command line / file associations -----------------
// Pull the first usable file path out of argv (the desktop launcher forwards
// %F; `folio path/to/book` or `folio notes.md` both work).
function fileArgFrom(argv: string[]): string | null {
for (const a of argv.slice(1)) {
if (!a || a.startsWith("-")) continue;
try {
if (fs.statSync(a).isFile() || hasBook(a)) return path.resolve(a);
} catch {
/* not a readable path; ignore */
}
}
return null;
}
function openFromArg(p: string): void {
if (isImportableMarkdown(p)) {
const results = importMarkdownFiles([p], app.getPath("documents"));
const r = results[0];
if (r.ok) {
openBookAt(r.bookPath as string);
return;
}
console.error(`[folio] import failed for ${p}: ${r.error}`);
return;
}
void openBookAt(p);
}
// Single instance: opening a second file (double-clicking while Folio is
// already open) forwards the path to the running window instead of stacking
// another app instance.
if (app.requestSingleInstanceLock()) {
app.on("second-instance", (_e, argv) => {
const f = fileArgFrom(argv);
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
if (f) openFromArg(f);
});
}

View file

@ -38,6 +38,10 @@ export function buildMenu(
submenu: [ submenu: [
{ label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew }, { label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew },
{ label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen }, { label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen },
{
label: "Open Markdown File…",
click: () => getWindow()?.webContents.send("folio:open-markdown-requested"),
},
{ type: "separator" }, { type: "separator" },
{ {
label: "Save", label: "Save",
@ -113,6 +117,11 @@ export function buildMenu(
click: () => getWindow()?.webContents.send("folio:export-odt-requested"), click: () => getWindow()?.webContents.send("folio:export-odt-requested"),
}, },
{ type: "separator" }, { type: "separator" },
{
label: "Check for Updates…",
click: () => getWindow()?.webContents.send("folio:check-update-requested"),
},
{ type: "separator" },
{ {
label: "Close", label: "Close",
accelerator: isMac ? "Cmd+W" : "Ctrl+W", accelerator: isMac ? "Cmd+W" : "Ctrl+W",

View file

@ -54,6 +54,12 @@ contextBridge.exposeInMainWorld("folio", {
exportOdt: (chapterIds?: string[] | null) => exportOdt: (chapterIds?: string[] | null) =>
ipcRenderer.invoke("folio:exportOdt", chapterIds), ipcRenderer.invoke("folio:exportOdt", chapterIds),
importAttachment: () => ipcRenderer.invoke("folio:importAttachment"), importAttachment: () => ipcRenderer.invoke("folio:importAttachment"),
openMarkdownFiles: () => ipcRenderer.invoke("folio:importMarkdown"),
openPathOrImport: (p: string) => ipcRenderer.invoke("folio:openPathOrImport", p),
checkUpdate: () => ipcRenderer.invoke("folio:checkUpdate"),
performUpdate: () => ipcRenderer.invoke("folio:performUpdate"),
onUpdateProgress: (cb: (p: { received: number; total: number }) => void) =>
ipcRenderer.on("folio:update-progress", (_e, data) => cb(data)),
copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text), copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text),
getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"), getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"),
toggleFullscreen: () => ipcRenderer.invoke("folio:toggleFullscreen"), toggleFullscreen: () => ipcRenderer.invoke("folio:toggleFullscreen"),
@ -110,4 +116,8 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.on("folio:zoom-out-requested", () => cb()), ipcRenderer.on("folio:zoom-out-requested", () => cb()),
onZoomResetRequested: (cb: () => void) => onZoomResetRequested: (cb: () => void) =>
ipcRenderer.on("folio:zoom-reset-requested", () => cb()), ipcRenderer.on("folio:zoom-reset-requested", () => cb()),
onOpenMarkdownRequested: (cb: () => void) =>
ipcRenderer.on("folio:open-markdown-requested", () => cb()),
onCheckUpdateRequested: (cb: () => void) =>
ipcRenderer.on("folio:check-update-requested", () => cb()),
}); });

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

@ -0,0 +1,173 @@
// In-app update support for the user-local install of Folio.
//
// Architecture: a release published on the Gitea instance carries a
// linux tar.gz asset (the unpacked Electron app). "Install update"
// downloads that tarball, then hands off to a tiny detached shell script
// which waits for this process to exit, swaps ~/.local/share/Folio with the
// new contents (keeping a .old copy until the swap succeeds), and relaunches
// the user launcher. No root, no package manager, no reinstall.
//
// The pure logic lives here (Electron-free) so it is unit-testable; index.ts
// only wires IPC, fetch, and spawn around it.
import path from "path";
export const UPDATE_BASE_DEFAULT = "https://git.atitlan.io";
export const UPDATE_REPO = "avi/Folio";
export const INSTALL_DIR_DEFAULT = path.join(
process.env.HOME || "~",
".local",
"share",
"Folio"
);
export const LAUNCHER_DEFAULT = path.join(
process.env.HOME || "~",
".local",
"bin",
"folio"
);
export interface ReleaseAsset {
name: string;
browser_download_url: string;
size?: number;
}
export interface ReleaseInfo {
tag: string;
version: string;
url?: string;
notes?: string;
assets: ReleaseAsset[];
}
// "v0.1.2" / "0.1.2-rc1" / "0.1.2" -> [0,1,2]; null when unparseable.
export function parseVersion(v: string): number[] | null {
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v || "").trim());
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}
export function compareVersions(a: string, b: string): number {
const va = parseVersion(a);
const vb = parseVersion(b);
if (!va || !vb) return 0;
for (let i = 0; i < 3; i++) {
if (va[i] !== vb[i]) return va[i] < vb[i] ? -1 : 1;
}
return 0;
}
export function isNewer(latest: string, current: string): boolean {
return compareVersions(latest, current) > 0;
}
// Normalize a Gitea release JSON object. Throws when the payload has no tag
// (including Gitea's error objects, which carry a message but no tag_name).
export function parseRelease(json: any): ReleaseInfo {
const tag = json && typeof json.tag_name === "string" ? json.tag_name : "";
if (!tag) {
const msg = json && typeof json.message === "string" ? json.message : "invalid response";
throw new Error(`No release found (${msg})`);
}
const assets: ReleaseAsset[] = Array.isArray(json.assets)
? json.assets
.filter(
(a: any) =>
a &&
typeof a.name === "string" &&
typeof a.browser_download_url === "string"
)
.map((a: any) => ({
name: a.name,
browser_download_url: a.browser_download_url,
size: typeof a.size === "number" ? a.size : undefined,
}))
: [];
const version = (parseVersion(tag) ? tag.replace(/^v/, "") : "").trim();
return {
tag,
version: version || tag,
url: typeof json.html_url === "string" ? json.html_url : undefined,
notes: typeof json.body === "string" ? json.body : undefined,
assets,
};
}
// Choose the update asset: the linux tar.gz (preferred, unpackable without
// FUSE). AppImage/deb assets exist but the swap logic here installs the
// unpacked tree, so only tar.gz is eligible.
export function pickAsset(assets: ReleaseAsset[]): ReleaseAsset | null {
const tarballs = assets.filter((a) => /\.tar\.gz$/.test(a.name));
const linux = tarballs.find(
(a) => /linux/i.test(a.name) && /x86_64|amd64/i.test(a.name)
);
return linux || tarballs[0] || null;
}
// The download URL must live on the same origin as the configured update
// base — this blocks a malicious release body from redirecting the installer
// to arbitrary hosts.
export function isTrustedDownloadUrl(url: string, base: string): boolean {
try {
const u = new URL(url);
const b = new URL(base);
return u.protocol === b.protocol && u.host === b.host;
} catch {
return false;
}
}
// Single-quote a path for safe interpolation into /bin/sh scripts.
export function shQuote(p: string): string {
return `'${String(p).replace(/'/g, `'\\''`)}'`;
}
export interface InstallerSpec {
pid: number;
tarball: string;
installDir: string;
relaunch: string;
}
// The handoff script. Deliberately forgiving (no set -e): on any failure it
// still relaunches the OLD version, so a bad download can never leave the
// user without an app. The old tree is kept as .old.<pid> and pruned only
// after a successful swap.
export function buildInstallerScript(spec: InstallerSpec): string {
const { pid, tarball, installDir, relaunch } = spec;
return [
"#!/bin/sh",
"# Folio updater — waits for the app to exit, swaps in the new version,",
"# then relaunches. Generated by Folio; safe to delete after one run.",
`PID=${Number(pid) || -1}`,
`TARBALL=${shQuote(tarball)}`,
`INSTALL=${shQuote(installDir)}`,
`RELAUNCH=${shQuote(relaunch)}`,
`NEW="$INSTALL.update-new.$$"`,
`OLD="$INSTALL.old.$$"`,
"# Wait (bounded to ~2 minutes) for the app process to exit.",
"i=0",
'while kill -0 "$PID" 2>/dev/null && [ "$i" -lt 400 ]; do sleep 0.3; i=$((i+1)); done',
'rm -rf "$NEW"',
'mkdir -p "$NEW"',
'if tar -xzf "$TARBALL" -C "$NEW"; then',
' # electron-builder nests everything under a top-level dir; flatten it.',
' if [ ! -e "$NEW/folio" ]; then',
' ONLY=$(find "$NEW" -mindepth 1 -maxdepth 1 -type d | head -n 1)',
' [ -n "$ONLY" ] && mv "$ONLY"/* "$NEW"/ 2>/dev/null',
" fi",
"fi",
'if [ -x "$NEW/folio" ]; then',
' [ -e "$INSTALL" ] && mv "$INSTALL" "$OLD"',
' if mv "$NEW" "$INSTALL"; then',
' rm -rf "$OLD" "$INSTALL.update-new".* "$INSTALL.old".* 2>/dev/null',
' rm -f "$TARBALL"',
" else",
' [ -e "$OLD" ] && mv "$OLD" "$INSTALL"',
" fi",
"else",
' rm -rf "$NEW"',
"fi",
'"$RELAUNCH" >/dev/null 2>&1 &',
"",
].join("\n");
}

View file

@ -131,6 +131,50 @@ export function initBook(api: FolioAPI) {
// The comments module owns its own DOM chrome and wiring; here we only keep the // The comments module owns its own DOM chrome and wiring; here we only keep the
// top-level constructor call and the bridge assignments it already set above. // top-level constructor call and the bridge assignments it already set above.
// ---- update flow ----
// "Check for Updates…" (File menu / welcome button): query the release
// server, then offer a one-click install. The app downloads the tarball,
// hands off to a detached swap script, and relaunches itself — no manual
// reinstall. See src/main/update.ts for the safety properties.
async function checkForUpdates() {
showToast("Checking for updates…");
const res = await api.checkUpdate();
if (!res || res.error) {
await alertMessage("Update check failed", (res && res.error) || "No response from the release server.");
return;
}
if (!res.updateAvailable) {
await alertMessage("You're up to date", `Folio ${res.current} is the latest release.`);
return;
}
const go = await confirmMessage(
`Update to ${res.latest}?`,
`Folio ${res.current} → ${res.latest}. Folio will download the update, restart, and finish installing on its own.`,
"Update"
);
if (!go) return;
if (!res.canInstall) {
await alertMessage(
"Automatic install unavailable",
"This release has no installable Linux archive, or this Folio is not the user-local install. Download it manually from the releases page."
);
return;
}
let lastPct = -1;
api.onUpdateProgress(({ received, total }) => {
const pct = total ? Math.floor((received / total) * 100) : -1;
if (pct !== lastPct) {
lastPct = pct;
showToast(pct >= 0 ? `Downloading update… ${pct}%` : `Downloading update… ${(received / 1048576).toFixed(1)} MB`);
}
});
const up = await api.performUpdate();
if (up && up.error) {
await alertMessage("Update failed", up.error);
}
// On success the app quits momentarily and reopens by itself.
}
// ---- api menu handlers ---- // ---- api menu handlers ----
// These now delegate to the extracted feature facades rather than calling // These now delegate to the extracted feature facades rather than calling
// inline functions defined in this module. // inline functions defined in this module.
@ -140,6 +184,9 @@ export function initBook(api: FolioAPI) {
api.onNewBookRequested(() => void welcome.startNewBook()); api.onNewBookRequested(() => void welcome.startNewBook());
api.onOpenBookRequested(() => void welcome.startOpenBook()); api.onOpenBookRequested(() => void welcome.startOpenBook());
api.onOpenRecent((p) => void welcome.openPath(p)); api.onOpenRecent((p) => void welcome.openPath(p));
api.onOpenMarkdownRequested(() => void welcome.openMarkdown());
api.onCheckUpdateRequested(() => void checkForUpdates());
$("welcomeUpdate")?.addEventListener("click", () => void checkForUpdates());
api.onRevealRequested(async (p) => { api.onRevealRequested(async (p) => {
const ok = await api.revealInFolder(p); const ok = await api.revealInFolder(p);
showToast( showToast(

View file

@ -64,6 +64,11 @@ export type FolioAPI = {
exportDocx: (chapterIds?: string[] | null) => Promise<any>; exportDocx: (chapterIds?: string[] | null) => Promise<any>;
exportOdt: (chapterIds?: string[] | null) => Promise<any>; exportOdt: (chapterIds?: string[] | null) => Promise<any>;
importAttachment: () => Promise<any>; importAttachment: () => Promise<any>;
openMarkdownFiles: () => Promise<any>;
openPathOrImport: (p: string) => Promise<any>;
checkUpdate: () => Promise<any>;
performUpdate: () => Promise<any>;
onUpdateProgress: (cb: (p: { received: number; total: number }) => void) => void;
copyText: (text: string) => Promise<any>; copyText: (text: string) => Promise<any>;
getClipboardText: () => Promise<string>; getClipboardText: () => Promise<string>;
toggleFullscreen: () => Promise<boolean>; toggleFullscreen: () => Promise<boolean>;
@ -91,6 +96,8 @@ export type FolioAPI = {
onZoomInRequested: (cb: () => void) => void; onZoomInRequested: (cb: () => void) => void;
onZoomOutRequested: (cb: () => void) => void; onZoomOutRequested: (cb: () => void) => void;
onZoomResetRequested: (cb: () => void) => void; onZoomResetRequested: (cb: () => void) => void;
onOpenMarkdownRequested: (cb: () => void) => void;
onCheckUpdateRequested: (cb: () => void) => void;
}; };
// ---- P7 decomposition contracts ---- // ---- P7 decomposition contracts ----

View file

@ -19,6 +19,7 @@ export interface WelcomeFeature {
refreshMeta(): Promise<void>; refreshMeta(): Promise<void>;
startNewBook(): Promise<void>; startNewBook(): Promise<void>;
startOpenBook(): Promise<void>; startOpenBook(): Promise<void>;
openMarkdown(): Promise<void>;
openPath(p: string): Promise<void>; openPath(p: string): Promise<void>;
} }
@ -162,6 +163,24 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
await renderRecents(); await renderRecents();
} }
// Open one or more standalone Markdown files: each becomes a new book
// (copied, originals untouched) and the first one opens immediately.
async function openMarkdown() {
const res = await api.openMarkdownFiles();
if (!res || "canceled" in res) return;
if (!Array.isArray(res)) { await alertMessage("Error", (res as { error: string }).error); return; }
const results = res as { ok: boolean; file: string; bookPath?: string; title?: string; error?: string }[];
const failed = results.filter((r) => !r.ok);
const first = results.find((r) => r.ok);
if (first?.bookPath) await openPath(first.bookPath);
if (failed.length) {
await alertMessage(
"Some files could not be imported",
failed.map((f) => `${f.file}: ${f.error}`).join("\n")
);
}
}
async function renderRecents() { async function renderRecents() {
try { try {
const recents = await api.getRecents(); const recents = await api.getRecents();
@ -223,6 +242,7 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
// ---- wiring (bind once at construction) ---- // ---- wiring (bind once at construction) ----
$("welcomeNewBook").addEventListener("click", () => void startNewBook()); $("welcomeNewBook").addEventListener("click", () => void startNewBook());
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook()); $("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
$("welcomeOpenMd")?.addEventListener("click", () => void openMarkdown());
homeBtn?.addEventListener("click", async () => { homeBtn?.addEventListener("click", async () => {
hooks.clearTimers(); hooks.clearTimers();
if (state.selectedId && state.dirty) { if (state.selectedId && state.dirty) {
@ -244,5 +264,6 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
startNewBook, startNewBook,
startOpenBook, startOpenBook,
openPath, openPath,
openMarkdown,
}; };
} }

View file

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

View file

@ -56,6 +56,8 @@
<div class="welcome-actions"> <div class="welcome-actions">
<button class="button" id="welcomeNewBook">New</button> <button class="button" id="welcomeNewBook">New</button>
<button class="button button-ghost" id="welcomeOpenBook">Open</button> <button class="button button-ghost" id="welcomeOpenBook">Open</button>
<button class="button button-ghost" id="welcomeOpenMd" title="Open a Markdown file as a new book">Open .md</button>
<button class="button button-ghost" id="welcomeUpdate" title="Check for a new version of Folio">Update</button>
</div> </div>
<div class="recents-block"> <div class="recents-block">
<div class="recents-title">Recent</div> <div class="recents-title">Recent</div>

View file

@ -0,0 +1,67 @@
import esbuild from "esbuild";
import fs from "fs";
import os from "os";
import path from "path";
import url from "url";
await esbuild.build({
entryPoints: ["src/main/update.ts"],
bundle: true, platform: "node", format: "esm",
outfile: "/tmp/folio-smoke-update.mjs", external: ["electron"], logLevel: "silent",
});
await esbuild.build({
entryPoints: ["src/main/import-md.ts"],
bundle: true, platform: "node", format: "esm",
outfile: "/tmp/folio-smoke-import.mjs", external: ["electron"], logLevel: "silent",
});
const u = await import(url.pathToFileURL("/tmp/folio-smoke-update.mjs").href);
const m = await import(url.pathToFileURL("/tmp/folio-smoke-import.mjs").href);
let fails = 0;
const assert = (c, msg) => { if (!c) { console.error("FAIL -", msg); fails++; } else console.log("ok -", msg); };
assert(u.isNewer("0.2.0", "0.1.0"), "0.2.0 newer than 0.1.0");
assert(!u.isNewer("v0.1.0", "0.1.0"), "same version not newer (v-prefix tolerated)");
assert(u.isNewer("0.1.10", "0.1.9"), "numeric compare not lexicographic");
const rel = u.parseRelease({
tag_name: "v0.2.0",
assets: [
{ name: "Folio-0.2.0-linux.tar.gz", browser_download_url: "https://git.atitlan.io/avi/Folio/releases/download/v0.2.0/Folio-0.2.0-linux.tar.gz" },
{ name: "Folio-0.1.0.AppImage", browser_download_url: "https://git.atitlan.io/x/Folio.AppImage" },
],
});
assert(rel.version === "0.2.0", "release version parsed");
assert(u.pickAsset(rel.assets).name.includes("linux"), "picks linux tar.gz over AppImage");
assert(u.isTrustedDownloadUrl("https://git.atitlan.io/a/t.tar.gz", "https://git.atitlan.io"), "same-host trusted");
assert(!u.isTrustedDownloadUrl("https://evil.io/t.tar.gz", "https://git.atitlan.io"), "cross-host rejected");
let threw = false;
try { u.parseRelease({ message: "not found" }); } catch { threw = true; }
assert(threw, "error payload without tag throws");
const sh = u.buildInstallerScript({ pid: 1234, tarball: "/tmp/it's.tar.gz", installDir: "/home/a/.local/share/Folio", relaunch: "/home/a/.local/bin/folio" });
assert(sh.includes(`'/tmp/it'\\''s.tar.gz'`), "shQuote escapes embedded single quote");
assert(sh.includes("kill -0"), "installer waits for app pid");
assert(sh.includes("-xzf"), "installer extracts gz");
assert(m.mdTitle("# My Heading\nbody", "file") === "My Heading", "title from H1");
assert(m.mdTitle("no heading", "file") === "file", "title falls back to filename stem");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "imp-"));
fs.writeFileSync(path.join(dir, "a.md"), "# Alpha\n\nhello world");
fs.writeFileSync(path.join(dir, "b.md"), "plain text no heading");
const r1 = m.importMarkdownFiles([path.join(dir, "a.md"), path.join(dir, "b.md")], path.join(dir, "out"));
assert(r1[0].ok && r1[0].title === "Alpha", "import a.md -> book 'Alpha'");
assert(r1[1].ok && r1[1].title === "b", "import b.md -> book 'b'");
assert(fs.readFileSync(path.join(dir, "a.md"), "utf-8") === "# Alpha\n\nhello world", "original file untouched");
const r2 = m.importMarkdownFiles([path.join(dir, "a.md")], path.join(dir, "out"));
assert(r2[0].ok && r2[0].bookPath.endsWith("Alpha 2"), "name collision gets numeric suffix");
const pj = JSON.parse(fs.readFileSync(path.join(dir, "out", "Alpha", "folio.json"), "utf-8"));
const entry = pj.chapters[pj.chapterOrder[0]];
const ch = fs.readFileSync(path.join(dir, "out", "Alpha", entry.file), "utf-8");
assert(ch.includes("hello world"), "chapter content copied into book");
const r3 = m.importMarkdownFiles(["/nonexistent/zz.md"], path.join(dir, "out2"));
assert(!r3[0].ok && r3[0].error, "missing file reports error, no crash");
console.log(fails ? `SMOKE: ${fails} FAILURES` : "SMOKE: ALL PASSED");
process.exit(fails ? 1 : 0);