refactor(ipc): unify 41 handlers into handleIpc wrapper; improve error logging and non-Error handling
This commit is contained in:
parent
3afb43d4ed
commit
b5db582538
1 changed files with 242 additions and 336 deletions
|
|
@ -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,10 +208,9 @@ async function openBookAt(p: string): Promise<BookResult | ErrResult> {
|
|||
// 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
|
||||
// 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);
|
||||
|
|
@ -212,13 +228,9 @@ ipcMain.handle("folio:newBookNamed", async (_e, name: string) => {
|
|||
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) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:deleteBook", async (_e, p: string) => {
|
||||
try {
|
||||
handleIpc("folio:deleteBook", async (_e, p: string) => {
|
||||
if (!hasBook(p)) {
|
||||
// Stale recent entry: nothing to trash, just clear it from the list.
|
||||
removeRecent(p);
|
||||
|
|
@ -247,12 +259,9 @@ ipcMain.handle("folio:deleteBook", async (_e, p: string) => {
|
|||
// 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 };
|
||||
}
|
||||
});
|
||||
|
||||
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()));
|
||||
|
||||
ipcMain.handle("folio:setComments", (_e, comments: Record<string, unknown>) => {
|
||||
try {
|
||||
handleIpc("folio:setComments", (_e, comments: Record<string, unknown>) => {
|
||||
const meta = loadBook(requireBook());
|
||||
meta.comments = comments as never;
|
||||
saveMeta(requireBook(), meta);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:getChapterContent", (_e, id: string) => {
|
||||
try {
|
||||
return getChapterContent(requireBook(), id);
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
handleIpc("folio:getChapterContent", (_e, id: string) =>
|
||||
getChapterContent(requireBook(), id)
|
||||
);
|
||||
|
||||
ipcMain.handle("folio:createChapter", (_e, title: string) => {
|
||||
try {
|
||||
handleIpc("folio:createChapter", (_e, title: string) => {
|
||||
const entry = createChapter(requireBook(), title);
|
||||
notifyChange();
|
||||
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) => {
|
||||
try {
|
||||
handleIpc("folio:renameChapter", (_e, id: string, title: string) => {
|
||||
renameChapter(requireBook(), id, title);
|
||||
notifyChange();
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:deleteChapter", (_e, id: string) => {
|
||||
try {
|
||||
handleIpc("folio:deleteChapter", (_e, id: string) => {
|
||||
deleteChapter(requireBook(), id);
|
||||
notifyChange();
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:restoreChapter", (_e, snap: unknown) => {
|
||||
try {
|
||||
handleIpc("folio:restoreChapter", (_e, snap: unknown) => {
|
||||
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
|
||||
|
|
@ -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 {
|
||||
handleIpc("folio:duplicateChapter", (_e, id: string, title?: string) => {
|
||||
const entry = duplicateChapter(requireBook(), id, title);
|
||||
notifyChange();
|
||||
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) => {
|
||||
try {
|
||||
handleIpc("folio:setChapterColor", (_e, id: string, color: string | null) => {
|
||||
setChapterColor(requireBook(), id, color);
|
||||
notifyChange();
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:reorderChapters", (_e, newOrder: string[]) => {
|
||||
try {
|
||||
handleIpc("folio:reorderChapters", (_e, newOrder: string[]) => {
|
||||
reorderChapters(requireBook(), newOrder);
|
||||
notifyChange();
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// 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,8 +426,7 @@ function currentBookTitle(): string {
|
|||
}
|
||||
}
|
||||
|
||||
ipcMain.handle("folio:exportMarkdown", async () => {
|
||||
try {
|
||||
handleIpc("folio:exportMarkdown", async () => {
|
||||
const bp = requireBook();
|
||||
const { canceled, filePath } = await showSaveDialog({
|
||||
title: "Export combined Markdown",
|
||||
|
|
@ -477,13 +436,9 @@ ipcMain.handle("folio:exportMarkdown", async () => {
|
|||
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) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:exportZip", async () => {
|
||||
try {
|
||||
handleIpc("folio:exportZip", async () => {
|
||||
const bp = requireBook();
|
||||
const { canceled, filePath } = await showSaveDialog({
|
||||
title: "Export book bundle (ZIP)",
|
||||
|
|
@ -494,9 +449,6 @@ ipcMain.handle("folio:exportZip", async () => {
|
|||
const buf = await buildZip(bp);
|
||||
fs.writeFileSync(filePath, buf);
|
||||
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
|
||||
|
|
@ -504,8 +456,7 @@ 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 {
|
||||
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,
|
||||
|
|
@ -538,9 +489,6 @@ ipcMain.handle("folio:exportPdf", async (_e, chapterIds?: string[] | null) => {
|
|||
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 };
|
||||
}
|
||||
});
|
||||
|
||||
// Export the manuscript as a DOCX or ODT. Both formats share one pipeline: the
|
||||
|
|
@ -590,62 +538,39 @@ 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 {
|
||||
handleIpc("folio:saveChapter", (_e, id: string, content: string) => {
|
||||
setChapterContent(requireBook(), id, content);
|
||||
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
|
||||
// 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 {
|
||||
handleIpc("folio:saveChapterDoc", (_e, id: string, doc: unknown) => {
|
||||
saveChapterDoc(requireBook(), id, doc as never);
|
||||
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
|
||||
// relative to the book root (e.g. "assets/photo.png") for embedding.
|
||||
ipcMain.handle("folio:importImageAsset", async () => {
|
||||
try {
|
||||
handleIpc("folio:importImageAsset", async () => {
|
||||
const bp = requireBook();
|
||||
const { canceled, filePaths } = await showOpenDialog({
|
||||
title: "Insert image",
|
||||
|
|
@ -670,23 +595,20 @@ ipcMain.handle("folio:importImageAsset", async () => {
|
|||
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) };
|
||||
}
|
||||
});
|
||||
|
||||
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,50 +633,37 @@ 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 {
|
||||
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 };
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
// 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<number>("zoom", 100);
|
||||
return Number.isFinite(z) && z > 0 ? Math.round(z) : 100;
|
||||
});
|
||||
ipcMain.handle("folio:setZoom", (_e, pct: unknown) => {
|
||||
try {
|
||||
handleIpc("folio:setZoom", (_e, pct: unknown) => {
|
||||
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) };
|
||||
}
|
||||
});
|
||||
|
||||
// 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 {
|
||||
handleIpc("folio:getBackupLocation", () => getRecoveryRoot());
|
||||
handleIpc("folio:setBackupLocation", (_e, loc: unknown) => {
|
||||
const s = typeof loc === "string" ? loc.trim() : "";
|
||||
setRecoveryRoot(s);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("folio:importAttachment", async () => {
|
||||
try {
|
||||
handleIpc("folio:importAttachment", async () => {
|
||||
const bp = requireBook();
|
||||
const { canceled, filePaths } = await showOpenDialog({
|
||||
title: "Attach a file",
|
||||
|
|
@ -776,7 +685,4 @@ ipcMain.handle("folio:importAttachment", async () => {
|
|||
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) };
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue