diff --git a/package.json b/package.json index 747ffd3..163be13 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"", "start": "npm run build && npm run electron", "lint": "tsc --noEmit", - "test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-office-export-test.mjs && node tests/run-migration-test.mjs && node tests/run-editor-test.mjs && node tests/run-fullbook-test.mjs && node tests/run-print-test.mjs", + "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" }, "devDependencies": { diff --git a/src/main/import-md.ts b/src/main/import-md.ts new file mode 100644 index 0000000..968cfad --- /dev/null +++ b/src/main/import-md.ts @@ -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 }; + } + }); +} diff --git a/src/main/index.ts b/src/main/index.ts index 3b7b272..3f83fb3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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> { + 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 => { + 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); + }); +} diff --git a/src/main/menu.ts b/src/main/menu.ts index f5189fa..e42494c 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -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", diff --git a/src/main/preload.ts b/src/main/preload.ts index 7431d15..569a145 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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()), }); diff --git a/src/main/update.ts b/src/main/update.ts new file mode 100644 index 0000000..5774e22 --- /dev/null +++ b/src/main/update.ts @@ -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. 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"); +} diff --git a/src/renderer/book/index.ts b/src/renderer/book/index.ts index 083de0c..5915827 100644 --- a/src/renderer/book/index.ts +++ b/src/renderer/book/index.ts @@ -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( diff --git a/src/renderer/book/types.ts b/src/renderer/book/types.ts index 109f226..a2739bd 100644 --- a/src/renderer/book/types.ts +++ b/src/renderer/book/types.ts @@ -64,6 +64,11 @@ export type FolioAPI = { exportDocx: (chapterIds?: string[] | null) => Promise; exportOdt: (chapterIds?: string[] | null) => Promise; importAttachment: () => Promise; + openMarkdownFiles: () => Promise; + openPathOrImport: (p: string) => Promise; + checkUpdate: () => Promise; + performUpdate: () => Promise; + onUpdateProgress: (cb: (p: { received: number; total: number }) => void) => void; copyText: (text: string) => Promise; getClipboardText: () => Promise; toggleFullscreen: () => Promise; @@ -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 ---- diff --git a/src/renderer/book/welcome.ts b/src/renderer/book/welcome.ts index ce8800b..31eec94 100644 --- a/src/renderer/book/welcome.ts +++ b/src/renderer/book/welcome.ts @@ -19,6 +19,7 @@ export interface WelcomeFeature { refreshMeta(): Promise; startNewBook(): Promise; startOpenBook(): Promise; + openMarkdown(): Promise; openPath(p: string): Promise; } @@ -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, }; } diff --git a/src/renderer/dialog.ts b/src/renderer/dialog.ts index 186fc7f..e996b1e 100644 --- a/src/renderer/dialog.ts +++ b/src/renderer/dialog.ts @@ -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 { +function open(mode: Mode, title: string, body: string, value: string, okLabel?: string): Promise { // 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 { - return open("confirm", title, body ?? "", "").then((v) => v !== null); +export function confirmMessage(title: string, body?: string, okLabel?: string): Promise { + return open("confirm", title, body ?? "", "", okLabel).then((v) => v !== null); } export function alertMessage(title: string, body?: string): Promise { diff --git a/src/renderer/index.html b/src/renderer/index.html index fb1f3d6..a052ce3 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -56,6 +56,8 @@
+ +
Recent
diff --git a/tests/run-update-import-test.mjs b/tests/run-update-import-test.mjs new file mode 100644 index 0000000..d00700c --- /dev/null +++ b/tests/run-update-import-test.mjs @@ -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);