refactor(ipc): unify 41 handlers into handleIpc wrapper; improve error logging and non-Error handling

This commit is contained in:
avi 2026-08-24 22:45:59 -05:00
commit b5db582538

View file

@ -138,6 +138,23 @@ function notifyChange() {
getWindow()?.webContents.send("folio:chapters-changed"); 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 // 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 // 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. // (menu-role) undo/redo which does not understand the editor's history.
@ -191,68 +208,60 @@ async function openBookAt(p: string): Promise<BookResult | ErrResult> {
// Create a new book as a dedicated folder <name>/ inside the user's Documents // Create a new book as a dedicated folder <name>/ inside the user's Documents
// directory, then open it. The folder name is the sanitized book title; if a // 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. // 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(); const title = (name || "").trim();
if (!title) return { error: "Please provide a book name." }; if (!title) return { error: "Please provide a book name." };
try { const base = app.getPath("documents");
const base = app.getPath("documents"); const folder = sanitizeBookFolderName(title) || "Untitled";
const folder = sanitizeBookFolderName(title) || "Untitled"; let target = path.join(base, folder);
let target = path.join(base, folder); let n = 2;
let n = 2; while (hasBook(target) || fs.existsSync(target)) {
while (hasBook(target) || fs.existsSync(target)) { target = path.join(base, `${folder} ${n}`);
target = path.join(base, `${folder} ${n}`); 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 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) => { handleIpc("folio:deleteBook", async (_e, p: string) => {
try { if (!hasBook(p)) {
if (!hasBook(p)) { // Stale recent entry: nothing to trash, just clear it from the list.
// 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.",
};
}
removeRecent(p); removeRecent(p);
if (bookPath === p) bookPath = null; if (bookPath === p) bookPath = null;
refreshMenu(); refreshMenu();
// In-app Undo reads the Linux home trash layout; on other platforms the return { ok: true };
// 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 };
} }
// 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({ const { canceled, filePaths } = await showOpenDialog({
properties: ["openDirectory"], properties: ["openDirectory"],
title: "Open a Folio book", title: "Open a Folio book",
@ -265,9 +274,9 @@ ipcMain.handle("folio:openBook", async () => {
return openBookAt(p); 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; if (!p) return false;
// True "reveal": show the book's folio.json selected in the file manager. // 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. // 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; return !err;
}); });
ipcMain.handle("folio:pickBackupFolder", async () => { handleIpc("folio:pickBackupFolder", async () => {
const { canceled, filePaths } = await showOpenDialog({ const { canceled, filePaths } = await showOpenDialog({
properties: ["openDirectory"], properties: ["openDirectory"],
title: "Choose recovery backup folder", title: "Choose recovery backup folder",
@ -288,9 +297,9 @@ ipcMain.handle("folio:pickBackupFolder", async () => {
return { canceled, filePaths }; return { canceled, filePaths };
}); });
ipcMain.handle("folio:getRecents", (): Recent[] => listRecents()); handleIpc("folio:getRecents", (): Recent[] => listRecents());
ipcMain.handle("folio:closeBook", () => { handleIpc("folio:closeBook", () => {
bookPath = null; bookPath = null;
mainWindow?.setTitle("Folio"); mainWindow?.setTitle("Folio");
// Notify the renderer BEFORE rebuilding the menu: a refreshMenu failure // Notify the renderer BEFORE rebuilding the menu: a refreshMenu failure
@ -309,71 +318,41 @@ function requireBook(): string {
return bookPath; return bookPath;
} }
ipcMain.handle("folio:getBookMeta", () => { handleIpc("folio:getBookMeta", () => loadBook(requireBook()));
try {
return loadBook(requireBook()); handleIpc("folio:setComments", (_e, comments: Record<string, unknown>) => {
} catch (e) { const meta = loadBook(requireBook());
return { error: (e as Error).message }; meta.comments = comments as never;
} saveMeta(requireBook(), meta);
return { ok: true };
}); });
ipcMain.handle("folio:setComments", (_e, comments: Record<string, unknown>) => { handleIpc("folio:getChapterContent", (_e, id: string) =>
try { getChapterContent(requireBook(), id)
const meta = loadBook(requireBook()); );
meta.comments = comments as never;
saveMeta(requireBook(), meta); handleIpc("folio:createChapter", (_e, title: string) => {
return { ok: true }; const entry = createChapter(requireBook(), title);
} catch (e) { notifyChange();
return { error: (e as Error).message }; return { id: entry.id, title: entry.title, file: entry.file };
}
}); });
ipcMain.handle("folio:getChapterContent", (_e, id: string) => { handleIpc("folio:renameChapter", (_e, id: string, title: string) => {
try { renameChapter(requireBook(), id, title);
return getChapterContent(requireBook(), id); notifyChange();
} catch (e) { return { ok: true };
return { error: (e as Error).message };
}
}); });
ipcMain.handle("folio:createChapter", (_e, title: string) => { handleIpc("folio:deleteChapter", (_e, id: string) => {
try { deleteChapter(requireBook(), id);
const entry = createChapter(requireBook(), title); notifyChange();
notifyChange(); return { ok: true };
return { id: entry.id, title: entry.title, file: entry.file };
} catch (e) {
return { error: (e as Error).message };
}
}); });
ipcMain.handle("folio:renameChapter", (_e, id: string, title: string) => { handleIpc("folio:restoreChapter", (_e, snap: unknown) => {
try { restoreChapter(requireBook(), snap as never);
renameChapter(requireBook(), id, title); notifyChange();
notifyChange(); return { ok: true };
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 };
}
}); });
// Restore a book the user just trashed (Undo for book deletion). The desktop // 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) => { handleIpc("folio:duplicateChapter", (_e, id: string, title?: string) => {
try { const entry = duplicateChapter(requireBook(), id, title);
const entry = duplicateChapter(requireBook(), id, title); notifyChange();
notifyChange(); return { id: entry.id, title: entry.title };
return { id: entry.id, title: entry.title };
} catch (e) {
return { error: (e as Error).message };
}
}); });
ipcMain.handle("folio:setChapterColor", (_e, id: string, color: string | null) => { handleIpc("folio:setChapterColor", (_e, id: string, color: string | null) => {
try { setChapterColor(requireBook(), id, color);
setChapterColor(requireBook(), id, color); notifyChange();
notifyChange(); return { ok: true };
return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
}); });
ipcMain.handle("folio:reorderChapters", (_e, newOrder: string[]) => { handleIpc("folio:reorderChapters", (_e, newOrder: string[]) => {
try { reorderChapters(requireBook(), newOrder);
reorderChapters(requireBook(), newOrder); notifyChange();
notifyChange(); return { ok: true };
return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
}); });
// expose ordered chapter list for the renderer // expose ordered chapter list for the renderer
ipcMain.handle("folio:getChapterList", () => { handleIpc("folio:getChapterList", () => listChaptersInOrder(loadBook(requireBook())));
try {
const meta = loadBook(requireBook());
return listChaptersInOrder(meta);
} catch (e) {
return { error: (e as Error).message };
}
});
function currentBookTitle(): string { function currentBookTitle(): string {
try { try {
@ -466,37 +426,29 @@ function currentBookTitle(): string {
} }
} }
ipcMain.handle("folio:exportMarkdown", async () => { handleIpc("folio:exportMarkdown", async () => {
try { const bp = requireBook();
const bp = requireBook(); const { canceled, filePath } = await showSaveDialog({
const { canceled, filePath } = await showSaveDialog({ title: "Export combined Markdown",
title: "Export combined Markdown", defaultPath: `${currentBookTitle()}.md`,
defaultPath: `${currentBookTitle()}.md`, filters: [{ name: "Markdown", extensions: ["md"] }],
filters: [{ name: "Markdown", extensions: ["md"] }], });
}); if (canceled || !filePath) return { canceled: true } as const;
if (canceled || !filePath) return { canceled: true } as const; fs.writeFileSync(filePath, combineMarkdown(bp), "utf-8");
fs.writeFileSync(filePath, combineMarkdown(bp), "utf-8"); return { ok: true, filePath };
return { ok: true, filePath };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}); });
ipcMain.handle("folio:exportZip", async () => { handleIpc("folio:exportZip", async () => {
try { const bp = requireBook();
const bp = requireBook(); const { canceled, filePath } = await showSaveDialog({
const { canceled, filePath } = await showSaveDialog({ title: "Export book bundle (ZIP)",
title: "Export book bundle (ZIP)", defaultPath: `${currentBookTitle()}.zip`,
defaultPath: `${currentBookTitle()}.zip`, filters: [{ name: "ZIP Archive", extensions: ["zip"] }],
filters: [{ name: "ZIP Archive", extensions: ["zip"] }], });
}); if (canceled || !filePath) return { canceled: true } as const;
if (canceled || !filePath) return { canceled: true } as const; const buf = await buildZip(bp);
const buf = await buildZip(bp); fs.writeFileSync(filePath, buf);
fs.writeFileSync(filePath, buf); return { ok: true, filePath };
return { ok: true, filePath };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}); });
// Export the Full Book view as a PDF. The renderer shows only the assembled // 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 // 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 // automatic page break before each chapter after the first. Chapter files and
// the assembled Markdown are never touched. // the assembled Markdown are never touched.
ipcMain.handle("folio:exportPdf", async (_e, chapterIds?: string[] | null) => { handleIpc("folio:exportPdf", async (_e, chapterIds?: string[] | null) => {
try { requireBook();
requireBook(); // An explicitly empty selection is an error; undefined/null means the
// An explicitly empty selection is an error; undefined/null means the // whole book. The old guard checked length>0 before comparing to zero,
// 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.
// so it could never fire and empty arrays fell through to a full export. if (Array.isArray(chapterIds) && chapterIds.length === 0) {
if (Array.isArray(chapterIds) && chapterIds.length === 0) { return { error: "Select at least one chapter to export." };
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 };
} }
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 // 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)); handleIpc("folio:exportDocx", (_e, chapterIds) => exportOffice("docx", chapterIds));
ipcMain.handle("folio:exportOdt", (_e, chapterIds) => exportOffice("odt", chapterIds)); handleIpc("folio:exportOdt", (_e, chapterIds) => exportOffice("odt", chapterIds));
ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => { handleIpc("folio:saveChapter", (_e, id: string, content: string) => {
try { setChapterContent(requireBook(), id, content);
setChapterContent(requireBook(), id, content); return { ok: true };
return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
}); });
// Explicit, opt-in migration of a single chapter (or the whole book) to the // 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 // native Folio document format. Never automatic — the UI triggers this. The
// migration pipeline makes a recovery backup and keeps the original .md files. // migration pipeline makes a recovery backup and keeps the original .md files.
ipcMain.handle("folio:migrateChapter", (_e, id: string) => { handleIpc("folio:migrateChapter", (_e, id: string) =>
try { migrateChapterToNative(requireBook(), id)
return migrateChapterToNative(requireBook(), id); );
} catch (e) {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:migrateBook", () => { handleIpc("folio:migrateBook", () => migrateBookToNative(requireBook()));
try {
return migrateBookToNative(requireBook());
} catch (e) {
return { error: (e as Error).message };
}
});
// Load a chapter as a native document for the Tiptap editor. Legacy Markdown // Load a chapter as a native document for the Tiptap editor. Legacy Markdown
// chapters are converted on the fly (see chapters.loadChapterDoc). // chapters are converted on the fly (see chapters.loadChapterDoc).
ipcMain.handle("folio:loadChapterDoc", (_e, id: string) => { handleIpc("folio:loadChapterDoc", (_e, id: string) =>
try { loadChapterDoc(requireBook(), id)
return loadChapterDoc(requireBook(), id); );
} catch (e) {
return { error: (e as Error).message };
}
});
// Persist a native document from the editor. Autosave writes directly to the // Persist a native document from the editor. Autosave writes directly to the
// native JSON model (no Markdown round-trip). // native JSON model (no Markdown round-trip).
ipcMain.handle("folio:saveChapterDoc", (_e, id: string, doc: unknown) => { handleIpc("folio:saveChapterDoc", (_e, id: string, doc: unknown) => {
try { saveChapterDoc(requireBook(), id, doc as never);
saveChapterDoc(requireBook(), id, doc as never); return { ok: true };
return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
}); });
// Copy a user-chosen image into the book's assets/ directory and return a path // 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. // relative to the book root (e.g. "assets/photo.png") for embedding.
ipcMain.handle("folio:importImageAsset", async () => { handleIpc("folio:importImageAsset", async () => {
try { const bp = requireBook();
const bp = requireBook(); const { canceled, filePaths } = await showOpenDialog({
const { canceled, filePaths } = await showOpenDialog({ title: "Insert image",
title: "Insert image", properties: ["openFile"],
properties: ["openFile"], filters: [
filters: [ { name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] },
{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] }, ],
], });
}); if (canceled || !filePaths.length) return { canceled: true } as const;
if (canceled || !filePaths.length) return { canceled: true } as const; const src = filePaths[0];
const src = filePaths[0]; const dir = path.join(bp, "assets");
const dir = path.join(bp, "assets"); fs.mkdirSync(dir, { recursive: true });
fs.mkdirSync(dir, { recursive: true }); const base = path.basename(src);
const base = path.basename(src); const ext = path.extname(base);
const ext = path.extname(base); const stem = path.basename(base, ext);
const stem = path.basename(base, ext); let name = base;
let name = base; let counter = 2;
let counter = 2; while (fs.existsSync(path.join(dir, name))) {
while (fs.existsSync(path.join(dir, name))) { name = `${stem}-${counter}${ext}`;
name = `${stem}-${counter}${ext}`; counter += 1;
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) };
} }
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 : ""); clipboard.writeText(typeof text === "string" ? text : "");
return { ok: true }; return { ok: true };
}); });
ipcMain.handle("folio:getClipboardText", () => { handleIpc("folio:getClipboardText", () => {
return clipboard.readText(); return clipboard.readText();
}); });
// Full screen is driven by the Electron window (not the DOM Fullscreen API, // Full screen is driven by the Electron window (not the DOM Fullscreen API,
// which is unreliable inside Electron and fails silently here). // which is unreliable inside Electron and fails silently here).
ipcMain.handle("folio:toggleFullscreen", () => { handleIpc("folio:toggleFullscreen", () => {
const win = getWindow(); const win = getWindow();
if (!win) return false; if (!win) return false;
win.setFullScreen(!win.isFullScreen()); 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 // Theme preference. Stored as a plain string so future themes can be added
// without touching the main process. "default" from older builds maps to // without touching the main process. "default" from older builds maps to
// "light" in the renderer, so it is normalized here too. // "light" in the renderer, so it is normalized here too.
ipcMain.handle("folio:getTheme", () => getSetting("theme", "light")); handleIpc("folio:getTheme", () => getSetting("theme", "light"));
ipcMain.handle("folio:setTheme", (_e, id: unknown) => { handleIpc("folio:setTheme", (_e, id: unknown) => {
try { const theme =
const theme = typeof id === "string" && id && id !== "default" ? id : "light";
typeof id === "string" && id && id !== "default" ? id : "light"; setSetting("theme", theme);
setSetting("theme", theme); return { ok: true };
return { ok: true };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}); });
// Editor zoom preference (percentage). Persisted app-wide, not per book, so the // 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. // writing view stays at the user's chosen size across books and restarts.
ipcMain.handle("folio:getZoom", () => { handleIpc("folio:getZoom", () => {
const z = getSetting<number>("zoom", 100); const z = getSetting<number>("zoom", 100);
return Number.isFinite(z) && z > 0 ? Math.round(z) : 100; return Number.isFinite(z) && z > 0 ? Math.round(z) : 100;
}); });
ipcMain.handle("folio:setZoom", (_e, pct: unknown) => { handleIpc("folio:setZoom", (_e, pct: unknown) => {
try { const z = typeof pct === "number" && Number.isFinite(pct) ? Math.round(pct) : 100;
const z = typeof pct === "number" && Number.isFinite(pct) ? Math.round(pct) : 100; setSetting("zoom", z);
setSetting("zoom", z); return { ok: true };
return { ok: true };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}); });
// Backup location preference. Backup reads/writes settings through the // Backup location preference. Backup reads/writes settings through the
// injected store so the module itself stays Electron-free for tests. // injected store so the module itself stays Electron-free for tests.
bindBackupSettings({ get: getSetting, set: setSetting }); bindBackupSettings({ get: getSetting, set: setSetting });
ipcMain.handle("folio:getBackupLocation", () => getRecoveryRoot()); handleIpc("folio:getBackupLocation", () => getRecoveryRoot());
ipcMain.handle("folio:setBackupLocation", (_e, loc: unknown) => { handleIpc("folio:setBackupLocation", (_e, loc: unknown) => {
try { const s = typeof loc === "string" ? loc.trim() : "";
const s = typeof loc === "string" ? loc.trim() : ""; setRecoveryRoot(s);
setRecoveryRoot(s); return { ok: true };
return { ok: true };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}); });
ipcMain.handle("folio:importAttachment", async () => { handleIpc("folio:importAttachment", async () => {
try { const bp = requireBook();
const bp = requireBook(); const { canceled, filePaths } = await showOpenDialog({
const { canceled, filePaths } = await showOpenDialog({ title: "Attach a file",
title: "Attach a file", properties: ["openFile"],
properties: ["openFile"], });
}); if (canceled || !filePaths.length) return { canceled: true } as const;
if (canceled || !filePaths.length) return { canceled: true } as const; const src = filePaths[0];
const src = filePaths[0]; const dir = path.join(bp, "attachments");
const dir = path.join(bp, "attachments"); fs.mkdirSync(dir, { recursive: true });
fs.mkdirSync(dir, { recursive: true }); const base = path.basename(src);
const base = path.basename(src); const ext = path.extname(base);
const ext = path.extname(base); const stem = path.basename(base, ext);
const stem = path.basename(base, ext); let name = base;
let name = base; let counter = 2;
let counter = 2; while (fs.existsSync(path.join(dir, name))) {
while (fs.existsSync(path.join(dir, name))) { name = `${stem}-${counter}${ext}`;
name = `${stem}-${counter}${ext}`; counter += 1;
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) };
} }
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 };
}); });