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:
parent
9aebde4c35
commit
ada5eee679
12 changed files with 634 additions and 5 deletions
|
|
@ -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
56
src/main/import-md.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
// Import standalone Markdown files as Folio books.
|
||||||
|
//
|
||||||
|
// Electron-free on purpose (like backup.ts): takes the file list and the
|
||||||
|
// destination root as arguments so the logic is unit-testable without an
|
||||||
|
// app instance. One book per file, named after the file's first H1 (or its
|
||||||
|
// filename when there is no heading). The original file is never modified —
|
||||||
|
// its content is copied into the new book's chapter.
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { createBook, sanitizeBookFolderName } from "./project.js";
|
||||||
|
import { createChapter, setChapterContent } from "./chapters.js";
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
ok: boolean;
|
||||||
|
file: string;
|
||||||
|
bookPath?: string;
|
||||||
|
title?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MD_EXT = /\.(md|markdown|txt)$/i;
|
||||||
|
|
||||||
|
export function isImportableMarkdown(file: string): boolean {
|
||||||
|
return MD_EXT.test(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title = first ATX H1 ("# Title"), else the filename stem.
|
||||||
|
export function mdTitle(raw: string, fallback: string): string {
|
||||||
|
const m = /^[ \t]*#[ \t]+(.+?)[ \t]*#*[ \t]*$/m.exec(raw);
|
||||||
|
const t = (m && m[1] ? m[1] : fallback).trim();
|
||||||
|
return t || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importMarkdownFiles(files: string[], root: string): ImportResult[] {
|
||||||
|
return files.map((file) => {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(file, "utf-8");
|
||||||
|
const stem = path.basename(file).replace(MD_EXT, "") || "Untitled";
|
||||||
|
const title = mdTitle(raw, stem);
|
||||||
|
const folder =
|
||||||
|
sanitizeBookFolderName(title) || sanitizeBookFolderName(stem) || "Imported";
|
||||||
|
let dir = path.join(root, folder);
|
||||||
|
let n = 2;
|
||||||
|
while (fs.existsSync(dir)) {
|
||||||
|
dir = path.join(root, `${folder} ${n}`);
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
createBook(dir, title);
|
||||||
|
const entry = createChapter(dir, title);
|
||||||
|
setChapterContent(dir, entry.id, raw);
|
||||||
|
return { ok: true, file, bookPath: dir, title };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, file, error: (e as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
173
src/main/update.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
// In-app update support for the user-local install of Folio.
|
||||||
|
//
|
||||||
|
// Architecture: a release published on the Gitea instance carries a
|
||||||
|
// linux tar.gz asset (the unpacked Electron app). "Install update"
|
||||||
|
// downloads that tarball, then hands off to a tiny detached shell script
|
||||||
|
// which waits for this process to exit, swaps ~/.local/share/Folio with the
|
||||||
|
// new contents (keeping a .old copy until the swap succeeds), and relaunches
|
||||||
|
// the user launcher. No root, no package manager, no reinstall.
|
||||||
|
//
|
||||||
|
// The pure logic lives here (Electron-free) so it is unit-testable; index.ts
|
||||||
|
// only wires IPC, fetch, and spawn around it.
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export const UPDATE_BASE_DEFAULT = "https://git.atitlan.io";
|
||||||
|
export const UPDATE_REPO = "avi/Folio";
|
||||||
|
export const INSTALL_DIR_DEFAULT = path.join(
|
||||||
|
process.env.HOME || "~",
|
||||||
|
".local",
|
||||||
|
"share",
|
||||||
|
"Folio"
|
||||||
|
);
|
||||||
|
export const LAUNCHER_DEFAULT = path.join(
|
||||||
|
process.env.HOME || "~",
|
||||||
|
".local",
|
||||||
|
"bin",
|
||||||
|
"folio"
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ReleaseAsset {
|
||||||
|
name: string;
|
||||||
|
browser_download_url: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseInfo {
|
||||||
|
tag: string;
|
||||||
|
version: string;
|
||||||
|
url?: string;
|
||||||
|
notes?: string;
|
||||||
|
assets: ReleaseAsset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// "v0.1.2" / "0.1.2-rc1" / "0.1.2" -> [0,1,2]; null when unparseable.
|
||||||
|
export function parseVersion(v: string): number[] | null {
|
||||||
|
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v || "").trim());
|
||||||
|
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareVersions(a: string, b: string): number {
|
||||||
|
const va = parseVersion(a);
|
||||||
|
const vb = parseVersion(b);
|
||||||
|
if (!va || !vb) return 0;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (va[i] !== vb[i]) return va[i] < vb[i] ? -1 : 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNewer(latest: string, current: string): boolean {
|
||||||
|
return compareVersions(latest, current) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize a Gitea release JSON object. Throws when the payload has no tag
|
||||||
|
// (including Gitea's error objects, which carry a message but no tag_name).
|
||||||
|
export function parseRelease(json: any): ReleaseInfo {
|
||||||
|
const tag = json && typeof json.tag_name === "string" ? json.tag_name : "";
|
||||||
|
if (!tag) {
|
||||||
|
const msg = json && typeof json.message === "string" ? json.message : "invalid response";
|
||||||
|
throw new Error(`No release found (${msg})`);
|
||||||
|
}
|
||||||
|
const assets: ReleaseAsset[] = Array.isArray(json.assets)
|
||||||
|
? json.assets
|
||||||
|
.filter(
|
||||||
|
(a: any) =>
|
||||||
|
a &&
|
||||||
|
typeof a.name === "string" &&
|
||||||
|
typeof a.browser_download_url === "string"
|
||||||
|
)
|
||||||
|
.map((a: any) => ({
|
||||||
|
name: a.name,
|
||||||
|
browser_download_url: a.browser_download_url,
|
||||||
|
size: typeof a.size === "number" ? a.size : undefined,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const version = (parseVersion(tag) ? tag.replace(/^v/, "") : "").trim();
|
||||||
|
return {
|
||||||
|
tag,
|
||||||
|
version: version || tag,
|
||||||
|
url: typeof json.html_url === "string" ? json.html_url : undefined,
|
||||||
|
notes: typeof json.body === "string" ? json.body : undefined,
|
||||||
|
assets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose the update asset: the linux tar.gz (preferred, unpackable without
|
||||||
|
// FUSE). AppImage/deb assets exist but the swap logic here installs the
|
||||||
|
// unpacked tree, so only tar.gz is eligible.
|
||||||
|
export function pickAsset(assets: ReleaseAsset[]): ReleaseAsset | null {
|
||||||
|
const tarballs = assets.filter((a) => /\.tar\.gz$/.test(a.name));
|
||||||
|
const linux = tarballs.find(
|
||||||
|
(a) => /linux/i.test(a.name) && /x86_64|amd64/i.test(a.name)
|
||||||
|
);
|
||||||
|
return linux || tarballs[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The download URL must live on the same origin as the configured update
|
||||||
|
// base — this blocks a malicious release body from redirecting the installer
|
||||||
|
// to arbitrary hosts.
|
||||||
|
export function isTrustedDownloadUrl(url: string, base: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const b = new URL(base);
|
||||||
|
return u.protocol === b.protocol && u.host === b.host;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-quote a path for safe interpolation into /bin/sh scripts.
|
||||||
|
export function shQuote(p: string): string {
|
||||||
|
return `'${String(p).replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallerSpec {
|
||||||
|
pid: number;
|
||||||
|
tarball: string;
|
||||||
|
installDir: string;
|
||||||
|
relaunch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The handoff script. Deliberately forgiving (no set -e): on any failure it
|
||||||
|
// still relaunches the OLD version, so a bad download can never leave the
|
||||||
|
// user without an app. The old tree is kept as .old.<pid> and pruned only
|
||||||
|
// after a successful swap.
|
||||||
|
export function buildInstallerScript(spec: InstallerSpec): string {
|
||||||
|
const { pid, tarball, installDir, relaunch } = spec;
|
||||||
|
return [
|
||||||
|
"#!/bin/sh",
|
||||||
|
"# Folio updater — waits for the app to exit, swaps in the new version,",
|
||||||
|
"# then relaunches. Generated by Folio; safe to delete after one run.",
|
||||||
|
`PID=${Number(pid) || -1}`,
|
||||||
|
`TARBALL=${shQuote(tarball)}`,
|
||||||
|
`INSTALL=${shQuote(installDir)}`,
|
||||||
|
`RELAUNCH=${shQuote(relaunch)}`,
|
||||||
|
`NEW="$INSTALL.update-new.$$"`,
|
||||||
|
`OLD="$INSTALL.old.$$"`,
|
||||||
|
"# Wait (bounded to ~2 minutes) for the app process to exit.",
|
||||||
|
"i=0",
|
||||||
|
'while 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");
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 ----
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export interface WelcomeFeature {
|
||||||
refreshMeta(): Promise<void>;
|
refreshMeta(): Promise<void>;
|
||||||
startNewBook(): Promise<void>;
|
startNewBook(): Promise<void>;
|
||||||
startOpenBook(): Promise<void>;
|
startOpenBook(): Promise<void>;
|
||||||
|
openMarkdown(): Promise<void>;
|
||||||
openPath(p: string): Promise<void>;
|
openPath(p: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,6 +163,24 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
||||||
await renderRecents();
|
await renderRecents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open one or more standalone Markdown files: each becomes a new book
|
||||||
|
// (copied, originals untouched) and the first one opens immediately.
|
||||||
|
async function openMarkdown() {
|
||||||
|
const res = await api.openMarkdownFiles();
|
||||||
|
if (!res || "canceled" in res) return;
|
||||||
|
if (!Array.isArray(res)) { await alertMessage("Error", (res as { error: string }).error); return; }
|
||||||
|
const results = res as { ok: boolean; file: string; bookPath?: string; title?: string; error?: string }[];
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
const first = results.find((r) => r.ok);
|
||||||
|
if (first?.bookPath) await openPath(first.bookPath);
|
||||||
|
if (failed.length) {
|
||||||
|
await alertMessage(
|
||||||
|
"Some files could not be imported",
|
||||||
|
failed.map((f) => `${f.file}: ${f.error}`).join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function renderRecents() {
|
async function renderRecents() {
|
||||||
try {
|
try {
|
||||||
const recents = await api.getRecents();
|
const recents = await api.getRecents();
|
||||||
|
|
@ -223,6 +242,7 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
||||||
// ---- wiring (bind once at construction) ----
|
// ---- wiring (bind once at construction) ----
|
||||||
$("welcomeNewBook").addEventListener("click", () => void startNewBook());
|
$("welcomeNewBook").addEventListener("click", () => void startNewBook());
|
||||||
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
|
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
|
||||||
|
$("welcomeOpenMd")?.addEventListener("click", () => void openMarkdown());
|
||||||
homeBtn?.addEventListener("click", async () => {
|
homeBtn?.addEventListener("click", async () => {
|
||||||
hooks.clearTimers();
|
hooks.clearTimers();
|
||||||
if (state.selectedId && state.dirty) {
|
if (state.selectedId && state.dirty) {
|
||||||
|
|
@ -244,5 +264,6 @@ export function createWelcomeFeature(ctx: BookCtx): WelcomeFeature {
|
||||||
startNewBook,
|
startNewBook,
|
||||||
startOpenBook,
|
startOpenBook,
|
||||||
openPath,
|
openPath,
|
||||||
|
openMarkdown,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const cancelBtn = el("modalCancel") as HTMLButtonElement;
|
||||||
let currentMode: Mode = "prompt";
|
let currentMode: Mode = "prompt";
|
||||||
let resolver: ((value: string | null) => void) | null = null;
|
let resolver: ((value: string | null) => void) | null = null;
|
||||||
|
|
||||||
function open(mode: Mode, title: string, body: string, value: string): Promise<string | null> {
|
function open(mode: Mode, title: string, body: string, value: string, okLabel?: string): Promise<string | null> {
|
||||||
// If a previous dialog was somehow still open (menu accelerators can open
|
// If a previous dialog was somehow still open (menu accelerators can open
|
||||||
// one while another is visible), settle its promise as cancelled so the
|
// one while another is visible), settle its promise as cancelled so the
|
||||||
// awaiting caller isn't stranded forever.
|
// awaiting caller isn't stranded forever.
|
||||||
|
|
@ -30,7 +30,7 @@ function open(mode: Mode, title: string, body: string, value: string): Promise<s
|
||||||
inputEl.classList.toggle("hidden", mode !== "prompt");
|
inputEl.classList.toggle("hidden", mode !== "prompt");
|
||||||
msgEl.classList.toggle("hidden", mode === "prompt");
|
msgEl.classList.toggle("hidden", mode === "prompt");
|
||||||
cancelBtn.classList.toggle("hidden", mode === "alert");
|
cancelBtn.classList.toggle("hidden", mode === "alert");
|
||||||
okBtn.textContent = mode === "confirm" ? "Delete" : "OK";
|
okBtn.textContent = okLabel || (mode === "confirm" ? "Delete" : "OK");
|
||||||
openEl(backdrop);
|
openEl(backdrop);
|
||||||
if (mode === "prompt") {
|
if (mode === "prompt") {
|
||||||
inputEl.focus();
|
inputEl.focus();
|
||||||
|
|
@ -84,8 +84,8 @@ export function promptInput(title: string, value?: string): Promise<string | nul
|
||||||
return open("prompt", title, "", value ?? "");
|
return open("prompt", title, "", value ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function confirmMessage(title: string, body?: string): Promise<boolean> {
|
export function confirmMessage(title: string, body?: string, okLabel?: string): Promise<boolean> {
|
||||||
return open("confirm", title, body ?? "", "").then((v) => v !== null);
|
return open("confirm", title, body ?? "", "", okLabel).then((v) => v !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function alertMessage(title: string, body?: string): Promise<void> {
|
export function alertMessage(title: string, body?: string): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
67
tests/run-update-import-test.mjs
Normal file
67
tests/run-update-import-test.mjs
Normal 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);
|
||||||
Loading…
Add table
Add a link
Reference in a new issue