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

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 { migrateChapterToNative, migrateBookToNative } from "./migration.js";
import { loadChapterDoc, saveChapterDoc } from "./chapters.js";
import { importMarkdownFiles, isImportableMarkdown } from "./import-md.js";
import {
INSTALL_DIR_DEFAULT,
LAUNCHER_DEFAULT,
UPDATE_BASE_DEFAULT,
UPDATE_REPO,
buildInstallerScript,
isTrustedDownloadUrl,
isNewer,
parseRelease,
pickAsset,
} from "./update.js";
let mainWindow: BrowserWindow | null = null;
let bookPath: string | null = null;
@ -116,6 +128,13 @@ app.whenReady().then(() => {
() => getWindow()?.webContents.send("folio:open-book-requested")
);
createWindow();
// Open a book folder or Markdown file passed on the command line
// (`folio notes.md`, or file-association launches from the desktop).
const initialFile = fileArgFrom(process.argv);
if (initialFile) {
const win = getWindow();
win?.webContents.once("did-finish-load", () => openFromArg(initialFile));
}
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
@ -756,3 +775,221 @@ handleIpc("folio:importAttachment", async () => {
const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/");
return { ok: true, name, relPath };
});
// ---- Markdown import ------------------------------------------------------
// Import standalone .md files as Folio books (one book per file, named after
// the file's first H1 or its filename). Files are copied — the originals are
// never modified. Books land in Documents, same as "New".
handleIpc("folio:importMarkdown", async () => {
const { canceled, filePaths } = await showOpenDialog({
title: "Open Markdown file(s)",
properties: ["openFile", "multiSelections"],
filters: [{ name: "Markdown", extensions: ["md", "markdown", "txt"] }],
});
if (canceled || !filePaths.length) return { canceled: true } as const;
return importMarkdownFiles(filePaths, app.getPath("documents"));
});
// Open a path that may be either a Folio book directory or a Markdown file
// (the desktop launcher can be handed either via file associations).
handleIpc("folio:openPathOrImport", async (_e, p: string) => {
if (p && isImportableMarkdown(p)) {
const results = importMarkdownFiles([p], app.getPath("documents"));
const r = results[0];
if (!r.ok) return { error: r.error };
return openBookAt(r.bookPath as string);
}
return openBookAt(p);
});
// ---- In-app update --------------------------------------------------------
// Release source. Defaults to the project's Gitea instance; the installed
// tree may override it with update.json next to the binary (e.g. for
// staging), and FOLIO_UPDATE_BASE/FOLIO_UPDATE_REPO override everything.
function updateConfig(): { base: string; repo: string; installDir: string; launcher: string } {
const cfg = {
base: UPDATE_BASE_DEFAULT,
repo: UPDATE_REPO,
installDir: INSTALL_DIR_DEFAULT,
launcher: LAUNCHER_DEFAULT,
};
try {
const p = path.join(cfg.installDir, "update.json");
if (fs.existsSync(p)) {
const j = JSON.parse(fs.readFileSync(p, "utf-8"));
if (typeof j.base === "string") cfg.base = j.base.replace(/\/+$/, "");
if (typeof j.repo === "string") cfg.repo = j.repo;
if (typeof j.launcher === "string") cfg.launcher = j.launcher;
}
} catch {
/* malformed update.json: keep defaults */
}
if (process.env.FOLIO_UPDATE_BASE) cfg.base = process.env.FOLIO_UPDATE_BASE.replace(/\/+$/, "");
if (process.env.FOLIO_UPDATE_REPO) cfg.repo = process.env.FOLIO_UPDATE_REPO;
return cfg;
}
interface UpdateCheckResult {
current: string;
latest?: string;
tag?: string;
updateAvailable?: boolean;
assetName?: string;
notes?: string;
canInstall?: boolean;
error?: string;
}
async function fetchLatestRelease(): Promise<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: [
{ label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew },
{ label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen },
{
label: "Open Markdown File…",
click: () => getWindow()?.webContents.send("folio:open-markdown-requested"),
},
{ type: "separator" },
{
label: "Save",
@ -113,6 +117,11 @@ export function buildMenu(
click: () => getWindow()?.webContents.send("folio:export-odt-requested"),
},
{ type: "separator" },
{
label: "Check for Updates…",
click: () => getWindow()?.webContents.send("folio:check-update-requested"),
},
{ type: "separator" },
{
label: "Close",
accelerator: isMac ? "Cmd+W" : "Ctrl+W",

View file

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

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

View file

@ -64,6 +64,11 @@ 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>;
@ -91,6 +96,8 @@ export type FolioAPI = {
onZoomInRequested: (cb: () => void) => void;
onZoomOutRequested: (cb: () => void) => void;
onZoomResetRequested: (cb: () => void) => void;
onOpenMarkdownRequested: (cb: () => void) => void;
onCheckUpdateRequested: (cb: () => void) => void;
};
// ---- P7 decomposition contracts ----

View file

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

View file

@ -18,7 +18,7 @@ const cancelBtn = el("modalCancel") as HTMLButtonElement;
let currentMode: Mode = "prompt";
let resolver: ((value: string | null) => void) | null = null;
function open(mode: Mode, title: string, body: string, value: string): Promise<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
// one while another is visible), settle its promise as cancelled so the
// awaiting caller isn't stranded forever.
@ -30,7 +30,7 @@ function open(mode: Mode, title: string, body: string, value: string): Promise<s
inputEl.classList.toggle("hidden", mode !== "prompt");
msgEl.classList.toggle("hidden", mode === "prompt");
cancelBtn.classList.toggle("hidden", mode === "alert");
okBtn.textContent = mode === "confirm" ? "Delete" : "OK";
okBtn.textContent = okLabel || (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): Promise<boolean> {
return open("confirm", title, body ?? "", "").then((v) => v !== null);
export function confirmMessage(title: string, body?: string, okLabel?: string): Promise<boolean> {
return open("confirm", title, body ?? "", "", okLabel).then((v) => v !== null);
}
export function alertMessage(title: string, body?: string): Promise<void> {

View file

@ -56,6 +56,8 @@
<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 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 class="recents-block">
<div class="recents-title">Recent</div>