diff --git a/src/main/index.ts b/src/main/index.ts index 0741425..f6e0330 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -138,6 +138,23 @@ function notifyChange() { getWindow()?.webContents.send("folio:chapters-changed"); } +// Standard IPC registration. Every handler gets the same crash safety net: +// a thrown error is logged (with its channel, for diagnosability) and +// returned to the renderer as `{ error }` instead of surfacing as an +// unhandled promise rejection that the UI would silently swallow. +/* eslint-disable @typescript-eslint/no-explicit-any */ +function handleIpc(channel: string, fn: (...args: any[]) => unknown): void { + ipcMain.handle(channel, async (_e, ...args: any[]) => { + try { + return await fn(...args); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[folio] ${channel} failed: ${msg}`); + return { error: msg }; + } + }); +} + // Route undo/redo/select-all shortcuts to the renderer so the active editor's // own history is used when it has focus, instead of Electron's native // (menu-role) undo/redo which does not understand the editor's history. @@ -191,68 +208,60 @@ async function openBookAt(p: string): Promise { // Create a new book as a dedicated folder / inside the user's Documents // directory, then open it. The folder name is the sanitized book title; if a // folder with that name already exists we append a numeric suffix. -ipcMain.handle("folio:newBookNamed", async (_e, name: string) => { +handleIpc("folio:newBookNamed", async (_e, name: string) => { const title = (name || "").trim(); if (!title) return { error: "Please provide a book name." }; - try { - const base = app.getPath("documents"); - const folder = sanitizeBookFolderName(title) || "Untitled"; - let target = path.join(base, folder); - let n = 2; - while (hasBook(target) || fs.existsSync(target)) { - target = path.join(base, `${folder} ${n}`); - n++; - } - const meta = createBook(target, title); - if (process.env.FOLIO_SELF_TEST !== "1") { - addRecent({ path: target, title: meta.title, lastOpened: new Date().toISOString() }); - } - bookPath = target; - mainWindow?.setTitle(`${meta.title} — Folio`); - refreshMenu(); - getWindow()?.webContents.send("folio:bookOpened", { path: target, title: meta.title }); - return { path: target, title: meta.title }; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; + const base = app.getPath("documents"); + const folder = sanitizeBookFolderName(title) || "Untitled"; + let target = path.join(base, folder); + let n = 2; + while (hasBook(target) || fs.existsSync(target)) { + target = path.join(base, `${folder} ${n}`); + n++; } + const meta = createBook(target, title); + if (process.env.FOLIO_SELF_TEST !== "1") { + addRecent({ path: target, title: meta.title, lastOpened: new Date().toISOString() }); + } + bookPath = target; + mainWindow?.setTitle(`${meta.title} — Folio`); + refreshMenu(); + getWindow()?.webContents.send("folio:bookOpened", { path: target, title: meta.title }); + return { path: target, title: meta.title }; }); -ipcMain.handle("folio:deleteBook", async (_e, p: string) => { - try { - if (!hasBook(p)) { - // Stale recent entry: nothing to trash, just clear it from the list. - removeRecent(p); - if (bookPath === p) bookPath = null; - refreshMenu(); - return { ok: true }; - } - // Move to the system trash rather than permanently erasing. If the - // trash refuses, STOP and report instead of silently destroying a whole - // book directory — an unrecoverable rmSync here used to masquerade as a - // safe "move to trash". - let trashed = false; - try { - await shell.trashItem(p); - trashed = true; - } catch { - return { - error: - "Could not move the book to the system trash, so it was left untouched. " + - "Delete it manually from your file manager if you are sure.", - }; - } +handleIpc("folio:deleteBook", async (_e, p: string) => { + if (!hasBook(p)) { + // Stale recent entry: nothing to trash, just clear it from the list. removeRecent(p); if (bookPath === p) bookPath = null; refreshMenu(); - // In-app Undo reads the Linux home trash layout; on other platforms the - // OS trash is still recoverable by hand, so no false promise is made. - return { ok: true, trashed, canUndoRestore: process.platform === "linux" }; - } catch (e) { - return { error: (e as Error).message }; + return { ok: true }; } + // Move to the system trash rather than permanently erasing. If the + // trash refuses, STOP and report instead of silently destroying a whole + // book directory — an unrecoverable rmSync here used to masquerade as a + // safe "move to trash". + let trashed = false; + try { + await shell.trashItem(p); + trashed = true; + } catch { + return { + error: + "Could not move the book to the system trash, so it was left untouched. " + + "Delete it manually from your file manager if you are sure.", + }; + } + removeRecent(p); + if (bookPath === p) bookPath = null; + refreshMenu(); + // In-app Undo reads the Linux home trash layout; on other platforms the + // OS trash is still recoverable by hand, so no false promise is made. + return { ok: true, trashed, canUndoRestore: process.platform === "linux" }; }); -ipcMain.handle("folio:openBook", async () => { +handleIpc("folio:openBook", async () => { const { canceled, filePaths } = await showOpenDialog({ properties: ["openDirectory"], title: "Open a Folio book", @@ -265,9 +274,9 @@ ipcMain.handle("folio:openBook", async () => { return openBookAt(p); }); -ipcMain.handle("folio:openPath", async (_e, p: string) => openBookAt(p)); +handleIpc("folio:openPath", async (_e, p: string) => openBookAt(p)); -ipcMain.handle("folio:reveal", async (_e, p: string) => { +handleIpc("folio:reveal", async (_e, p: string) => { if (!p) return false; // True "reveal": show the book's folio.json selected in the file manager. // Fall back to opening the folder itself if the meta file is missing. @@ -280,7 +289,7 @@ ipcMain.handle("folio:reveal", async (_e, p: string) => { return !err; }); -ipcMain.handle("folio:pickBackupFolder", async () => { +handleIpc("folio:pickBackupFolder", async () => { const { canceled, filePaths } = await showOpenDialog({ properties: ["openDirectory"], title: "Choose recovery backup folder", @@ -288,9 +297,9 @@ ipcMain.handle("folio:pickBackupFolder", async () => { return { canceled, filePaths }; }); -ipcMain.handle("folio:getRecents", (): Recent[] => listRecents()); +handleIpc("folio:getRecents", (): Recent[] => listRecents()); -ipcMain.handle("folio:closeBook", () => { +handleIpc("folio:closeBook", () => { bookPath = null; mainWindow?.setTitle("Folio"); // Notify the renderer BEFORE rebuilding the menu: a refreshMenu failure @@ -309,71 +318,41 @@ function requireBook(): string { return bookPath; } -ipcMain.handle("folio:getBookMeta", () => { - try { - return loadBook(requireBook()); - } catch (e) { - return { error: (e as Error).message }; - } +handleIpc("folio:getBookMeta", () => loadBook(requireBook())); + +handleIpc("folio:setComments", (_e, comments: Record) => { + const meta = loadBook(requireBook()); + meta.comments = comments as never; + saveMeta(requireBook(), meta); + return { ok: true }; }); -ipcMain.handle("folio:setComments", (_e, comments: Record) => { - try { - const meta = loadBook(requireBook()); - meta.comments = comments as never; - saveMeta(requireBook(), meta); - return { ok: true }; - } catch (e) { - return { error: (e as Error).message }; - } +handleIpc("folio:getChapterContent", (_e, id: string) => + getChapterContent(requireBook(), id) +); + +handleIpc("folio:createChapter", (_e, title: string) => { + const entry = createChapter(requireBook(), title); + notifyChange(); + return { id: entry.id, title: entry.title, file: entry.file }; }); -ipcMain.handle("folio:getChapterContent", (_e, id: string) => { - try { - return getChapterContent(requireBook(), id); - } catch (e) { - return { error: (e as Error).message }; - } +handleIpc("folio:renameChapter", (_e, id: string, title: string) => { + renameChapter(requireBook(), id, title); + notifyChange(); + return { ok: true }; }); -ipcMain.handle("folio:createChapter", (_e, title: string) => { - try { - const entry = createChapter(requireBook(), title); - notifyChange(); - return { id: entry.id, title: entry.title, file: entry.file }; - } catch (e) { - return { error: (e as Error).message }; - } +handleIpc("folio:deleteChapter", (_e, id: string) => { + deleteChapter(requireBook(), id); + notifyChange(); + return { ok: true }; }); -ipcMain.handle("folio:renameChapter", (_e, id: string, title: string) => { - try { - renameChapter(requireBook(), id, title); - notifyChange(); - return { ok: true }; - } catch (e) { - return { error: (e as Error).message }; - } -}); - -ipcMain.handle("folio:deleteChapter", (_e, id: string) => { - try { - deleteChapter(requireBook(), id); - notifyChange(); - return { ok: true }; - } catch (e) { - return { error: (e as Error).message }; - } -}); - -ipcMain.handle("folio:restoreChapter", (_e, snap: unknown) => { - try { - restoreChapter(requireBook(), snap as never); - notifyChange(); - return { ok: true }; - } catch (e) { - return { error: (e as Error).message }; - } +handleIpc("folio:restoreChapter", (_e, snap: unknown) => { + restoreChapter(requireBook(), snap as never); + notifyChange(); + return { ok: true }; }); // Restore a book the user just trashed (Undo for book deletion). The desktop @@ -416,47 +395,28 @@ function restoreBookFromTrash(p: string): { ok?: boolean; error?: string } { } } -ipcMain.handle("folio:restoreBook", (_e, p: string) => restoreBookFromTrash(p)); +handleIpc("folio:restoreBook", (_e, p: string) => restoreBookFromTrash(p)); -ipcMain.handle("folio:duplicateChapter", (_e, id: string, title?: string) => { - try { - const entry = duplicateChapter(requireBook(), id, title); - notifyChange(); - return { id: entry.id, title: entry.title }; - } catch (e) { - return { error: (e as Error).message }; - } +handleIpc("folio:duplicateChapter", (_e, id: string, title?: string) => { + const entry = duplicateChapter(requireBook(), id, title); + notifyChange(); + return { id: entry.id, title: entry.title }; }); -ipcMain.handle("folio:setChapterColor", (_e, id: string, color: string | null) => { - try { - setChapterColor(requireBook(), id, color); - notifyChange(); - return { ok: true }; - } catch (e) { - return { error: (e as Error).message }; - } +handleIpc("folio:setChapterColor", (_e, id: string, color: string | null) => { + setChapterColor(requireBook(), id, color); + notifyChange(); + return { ok: true }; }); -ipcMain.handle("folio:reorderChapters", (_e, newOrder: string[]) => { - try { - reorderChapters(requireBook(), newOrder); - notifyChange(); - return { ok: true }; - } catch (e) { - return { error: (e as Error).message }; - } +handleIpc("folio:reorderChapters", (_e, newOrder: string[]) => { + reorderChapters(requireBook(), newOrder); + notifyChange(); + return { ok: true }; }); // expose ordered chapter list for the renderer -ipcMain.handle("folio:getChapterList", () => { - try { - const meta = loadBook(requireBook()); - return listChaptersInOrder(meta); - } catch (e) { - return { error: (e as Error).message }; - } -}); +handleIpc("folio:getChapterList", () => listChaptersInOrder(loadBook(requireBook()))); function currentBookTitle(): string { try { @@ -466,37 +426,29 @@ function currentBookTitle(): string { } } -ipcMain.handle("folio:exportMarkdown", async () => { - try { - const bp = requireBook(); - const { canceled, filePath } = await showSaveDialog({ - title: "Export combined Markdown", - defaultPath: `${currentBookTitle()}.md`, - filters: [{ name: "Markdown", extensions: ["md"] }], - }); - if (canceled || !filePath) return { canceled: true } as const; - fs.writeFileSync(filePath, combineMarkdown(bp), "utf-8"); - return { ok: true, filePath }; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } +handleIpc("folio:exportMarkdown", async () => { + const bp = requireBook(); + const { canceled, filePath } = await showSaveDialog({ + title: "Export combined Markdown", + defaultPath: `${currentBookTitle()}.md`, + filters: [{ name: "Markdown", extensions: ["md"] }], + }); + if (canceled || !filePath) return { canceled: true } as const; + fs.writeFileSync(filePath, combineMarkdown(bp), "utf-8"); + return { ok: true, filePath }; }); -ipcMain.handle("folio:exportZip", async () => { - try { - const bp = requireBook(); - const { canceled, filePath } = await showSaveDialog({ - title: "Export book bundle (ZIP)", - defaultPath: `${currentBookTitle()}.zip`, - filters: [{ name: "ZIP Archive", extensions: ["zip"] }], - }); - if (canceled || !filePath) return { canceled: true } as const; - const buf = await buildZip(bp); - fs.writeFileSync(filePath, buf); - return { ok: true, filePath }; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } +handleIpc("folio:exportZip", async () => { + const bp = requireBook(); + const { canceled, filePath } = await showSaveDialog({ + title: "Export book bundle (ZIP)", + defaultPath: `${currentBookTitle()}.zip`, + filters: [{ name: "ZIP Archive", extensions: ["zip"] }], + }); + if (canceled || !filePath) return { canceled: true } as const; + const buf = await buildZip(bp); + fs.writeFileSync(filePath, buf); + return { ok: true, filePath }; }); // Export the Full Book view as a PDF. The renderer shows only the assembled @@ -504,43 +456,39 @@ ipcMain.handle("folio:exportZip", async () => { // chrome, so printing the window produces a clean, book-only PDF with an // automatic page break before each chapter after the first. Chapter files and // the assembled Markdown are never touched. -ipcMain.handle("folio:exportPdf", async (_e, chapterIds?: string[] | null) => { - try { - requireBook(); - // An explicitly empty selection is an error; undefined/null means the - // whole book. The old guard checked length>0 before comparing to zero, - // so it could never fire and empty arrays fell through to a full export. - if (Array.isArray(chapterIds) && chapterIds.length === 0) { - return { error: "Select at least one chapter to export." }; - } - const selected = Array.isArray(chapterIds) && chapterIds.length > 0; - const base = currentBookTitle(); - const suffix = selected ? " - Selected Chapters" : ""; - let filePath: string; - if (process.env.FOLIO_SELF_TEST === "1" && process.env.FOLIO_TEST_PDF) { - filePath = process.env.FOLIO_TEST_PDF; - } else { - const { canceled, filePath: chosen } = await showSaveDialog({ - title: "Export as PDF", - defaultPath: `${base}${suffix}.pdf`, - filters: [{ name: "PDF Document", extensions: ["pdf"] }], - }); - if (canceled || !chosen) return { canceled: true } as const; - filePath = chosen.endsWith(".pdf") ? chosen : `${chosen}.pdf`; - } - const win = getWindow(); - if (!win) return { error: "No window is open." }; - const buf = await win.webContents.printToPDF({ - pageSize: "A4", - printBackground: true, - margins: { top: 1, bottom: 1, left: 1, right: 1 }, - }); - fs.writeFileSync(filePath, buf); - const pdfOk = buf.subarray(0, 4).toString("utf-8") === "%PDF"; - return { ok: true, filePath, bytes: buf.length, pdfOk }; - } catch (e) { - return { error: (e as Error).message }; +handleIpc("folio:exportPdf", async (_e, chapterIds?: string[] | null) => { +requireBook(); + // An explicitly empty selection is an error; undefined/null means the + // whole book. The old guard checked length>0 before comparing to zero, + // so it could never fire and empty arrays fell through to a full export. + if (Array.isArray(chapterIds) && chapterIds.length === 0) { + return { error: "Select at least one chapter to export." }; } + const selected = Array.isArray(chapterIds) && chapterIds.length > 0; + const base = currentBookTitle(); + const suffix = selected ? " - Selected Chapters" : ""; + let filePath: string; + if (process.env.FOLIO_SELF_TEST === "1" && process.env.FOLIO_TEST_PDF) { + filePath = process.env.FOLIO_TEST_PDF; + } else { + const { canceled, filePath: chosen } = await showSaveDialog({ + title: "Export as PDF", + defaultPath: `${base}${suffix}.pdf`, + filters: [{ name: "PDF Document", extensions: ["pdf"] }], + }); + if (canceled || !chosen) return { canceled: true } as const; + filePath = chosen.endsWith(".pdf") ? chosen : `${chosen}.pdf`; + } + const win = getWindow(); + if (!win) return { error: "No window is open." }; + const buf = await win.webContents.printToPDF({ + pageSize: "A4", + printBackground: true, + margins: { top: 1, bottom: 1, left: 1, right: 1 }, + }); + fs.writeFileSync(filePath, buf); + const pdfOk = buf.subarray(0, 4).toString("utf-8") === "%PDF"; + return { ok: true, filePath, bytes: buf.length, pdfOk }; }); // Export the manuscript as a DOCX or ODT. Both formats share one pipeline: the @@ -590,103 +538,77 @@ async function exportOffice(format: "docx" | "odt", chapterIds?: string[] | null } } -ipcMain.handle("folio:exportDocx", (_e, chapterIds) => exportOffice("docx", chapterIds)); -ipcMain.handle("folio:exportOdt", (_e, chapterIds) => exportOffice("odt", chapterIds)); +handleIpc("folio:exportDocx", (_e, chapterIds) => exportOffice("docx", chapterIds)); +handleIpc("folio:exportOdt", (_e, chapterIds) => exportOffice("odt", chapterIds)); -ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => { - try { - setChapterContent(requireBook(), id, content); - return { ok: true }; - } catch (e) { - return { error: (e as Error).message }; - } +handleIpc("folio:saveChapter", (_e, id: string, content: string) => { + setChapterContent(requireBook(), id, content); + return { ok: true }; }); // Explicit, opt-in migration of a single chapter (or the whole book) to the // native Folio document format. Never automatic — the UI triggers this. The // migration pipeline makes a recovery backup and keeps the original .md files. -ipcMain.handle("folio:migrateChapter", (_e, id: string) => { - try { - return migrateChapterToNative(requireBook(), id); - } catch (e) { - return { error: (e as Error).message }; - } -}); +handleIpc("folio:migrateChapter", (_e, id: string) => + migrateChapterToNative(requireBook(), id) +); -ipcMain.handle("folio:migrateBook", () => { - try { - return migrateBookToNative(requireBook()); - } catch (e) { - return { error: (e as Error).message }; - } -}); +handleIpc("folio:migrateBook", () => migrateBookToNative(requireBook())); // Load a chapter as a native document for the Tiptap editor. Legacy Markdown // chapters are converted on the fly (see chapters.loadChapterDoc). -ipcMain.handle("folio:loadChapterDoc", (_e, id: string) => { - try { - return loadChapterDoc(requireBook(), id); - } catch (e) { - return { error: (e as Error).message }; - } -}); +handleIpc("folio:loadChapterDoc", (_e, id: string) => + loadChapterDoc(requireBook(), id) +); // Persist a native document from the editor. Autosave writes directly to the // native JSON model (no Markdown round-trip). -ipcMain.handle("folio:saveChapterDoc", (_e, id: string, doc: unknown) => { - try { - saveChapterDoc(requireBook(), id, doc as never); - return { ok: true }; - } catch (e) { - return { error: (e as Error).message }; - } +handleIpc("folio:saveChapterDoc", (_e, id: string, doc: unknown) => { + saveChapterDoc(requireBook(), id, doc as never); + return { ok: true }; }); // Copy a user-chosen image into the book's assets/ directory and return a path // relative to the book root (e.g. "assets/photo.png") for embedding. -ipcMain.handle("folio:importImageAsset", async () => { - try { - const bp = requireBook(); - const { canceled, filePaths } = await showOpenDialog({ - title: "Insert image", - properties: ["openFile"], - filters: [ - { name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] }, - ], - }); - if (canceled || !filePaths.length) return { canceled: true } as const; - const src = filePaths[0]; - const dir = path.join(bp, "assets"); - fs.mkdirSync(dir, { recursive: true }); - const base = path.basename(src); - const ext = path.extname(base); - const stem = path.basename(base, ext); - let name = base; - let counter = 2; - while (fs.existsSync(path.join(dir, name))) { - name = `${stem}-${counter}${ext}`; - counter += 1; - } - fs.copyFileSync(src, path.join(dir, name)); - const relPath = `assets/${name}`; - return { ok: true, name, relPath }; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; +handleIpc("folio:importImageAsset", async () => { + const bp = requireBook(); + const { canceled, filePaths } = await showOpenDialog({ + title: "Insert image", + properties: ["openFile"], + filters: [ + { name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] }, + ], + }); + if (canceled || !filePaths.length) return { canceled: true } as const; + const src = filePaths[0]; + const dir = path.join(bp, "assets"); + fs.mkdirSync(dir, { recursive: true }); + const base = path.basename(src); + const ext = path.extname(base); + const stem = path.basename(base, ext); + let name = base; + let counter = 2; + while (fs.existsSync(path.join(dir, name))) { + name = `${stem}-${counter}${ext}`; + counter += 1; } + fs.copyFileSync(src, path.join(dir, name)); + const relPath = `assets/${name}`; + return { ok: true, name, relPath }; }); -ipcMain.handle("folio:copyText", (_e, text: string) => { +handleIpc("folio:copyText", (_e, text: string) => { clipboard.writeText(typeof text === "string" ? text : ""); return { ok: true }; }); -ipcMain.handle("folio:getClipboardText", () => { +handleIpc("folio:getClipboardText", () => { return clipboard.readText(); }); // Full screen is driven by the Electron window (not the DOM Fullscreen API, // which is unreliable inside Electron and fails silently here). -ipcMain.handle("folio:toggleFullscreen", () => { +handleIpc("folio:toggleFullscreen", () => { const win = getWindow(); if (!win) return false; win.setFullScreen(!win.isFullScreen()); @@ -711,72 +633,56 @@ ipcMain.on("folio:context-menu", (event, x: number, y: number) => { // Theme preference. Stored as a plain string so future themes can be added // without touching the main process. "default" from older builds maps to // "light" in the renderer, so it is normalized here too. -ipcMain.handle("folio:getTheme", () => getSetting("theme", "light")); -ipcMain.handle("folio:setTheme", (_e, id: unknown) => { - try { - const theme = - typeof id === "string" && id && id !== "default" ? id : "light"; - setSetting("theme", theme); - return { ok: true }; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } +handleIpc("folio:getTheme", () => getSetting("theme", "light")); +handleIpc("folio:setTheme", (_e, id: unknown) => { + const theme = + typeof id === "string" && id && id !== "default" ? id : "light"; + setSetting("theme", theme); + return { ok: true }; }); // Editor zoom preference (percentage). Persisted app-wide, not per book, so the // writing view stays at the user's chosen size across books and restarts. -ipcMain.handle("folio:getZoom", () => { +handleIpc("folio:getZoom", () => { const z = getSetting("zoom", 100); return Number.isFinite(z) && z > 0 ? Math.round(z) : 100; }); -ipcMain.handle("folio:setZoom", (_e, pct: unknown) => { - try { - const z = typeof pct === "number" && Number.isFinite(pct) ? Math.round(pct) : 100; - setSetting("zoom", z); - return { ok: true }; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } +handleIpc("folio:setZoom", (_e, pct: unknown) => { + const z = typeof pct === "number" && Number.isFinite(pct) ? Math.round(pct) : 100; + setSetting("zoom", z); + return { ok: true }; }); // Backup location preference. Backup reads/writes settings through the // injected store so the module itself stays Electron-free for tests. bindBackupSettings({ get: getSetting, set: setSetting }); -ipcMain.handle("folio:getBackupLocation", () => getRecoveryRoot()); -ipcMain.handle("folio:setBackupLocation", (_e, loc: unknown) => { - try { - const s = typeof loc === "string" ? loc.trim() : ""; - setRecoveryRoot(s); - return { ok: true }; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } +handleIpc("folio:getBackupLocation", () => getRecoveryRoot()); +handleIpc("folio:setBackupLocation", (_e, loc: unknown) => { + const s = typeof loc === "string" ? loc.trim() : ""; + setRecoveryRoot(s); + return { ok: true }; }); -ipcMain.handle("folio:importAttachment", async () => { - try { - const bp = requireBook(); - const { canceled, filePaths } = await showOpenDialog({ - title: "Attach a file", - properties: ["openFile"], - }); - if (canceled || !filePaths.length) return { canceled: true } as const; - const src = filePaths[0]; - const dir = path.join(bp, "attachments"); - fs.mkdirSync(dir, { recursive: true }); - const base = path.basename(src); - const ext = path.extname(base); - const stem = path.basename(base, ext); - let name = base; - let counter = 2; - while (fs.existsSync(path.join(dir, name))) { - name = `${stem}-${counter}${ext}`; - counter += 1; - } - fs.copyFileSync(src, path.join(dir, name)); - const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/"); - return { ok: true, name, relPath }; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; +handleIpc("folio:importAttachment", async () => { + const bp = requireBook(); + const { canceled, filePaths } = await showOpenDialog({ + title: "Attach a file", + properties: ["openFile"], + }); + if (canceled || !filePaths.length) return { canceled: true } as const; + const src = filePaths[0]; + const dir = path.join(bp, "attachments"); + fs.mkdirSync(dir, { recursive: true }); + const base = path.basename(src); + const ext = path.extname(base); + const stem = path.basename(base, ext); + let name = base; + let counter = 2; + while (fs.existsSync(path.join(dir, name))) { + name = `${stem}-${counter}${ext}`; + counter += 1; } + fs.copyFileSync(src, path.join(dir, name)); + const relPath = path.relative(bp, path.join(dir, name)).split(path.sep).join("/"); + return { ok: true, name, relPath }; });