diff --git a/src/main/index.ts b/src/main/index.ts index 261af8b..eab3d02 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -201,48 +201,59 @@ function sanitizeName(name: string): string { ipcMain.handle("folio:newBookNamed", async (_e, name: string) => { const title = (name || "").trim(); if (!title) return { error: "Please provide a book name." }; - const base = app.getPath("documents"); - const folder = sanitizeName(title) || "Untitled"; - let target = path.join(base, folder); - let n = 2; - while (hasBook(target) || fs.existsSync(target)) { - target = path.join(base, `${folder} ${n}`); - n++; + try { + const base = app.getPath("documents"); + const folder = sanitizeName(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 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 { - // Always drop the recent entry, even if the folder is gone or was moved, - // so stale/missing books can be cleared from the list. - removeRecent(p); 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, so an - // accidental delete can be undone from the desktop trash. + // 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 { - // Fall back to a permanent delete only when the system trash is - // unavailable (e.g. headless environments). On a normal desktop the - // book is moved to the trash and remains recoverable. - fs.rmSync(p, { recursive: true, force: true }); + 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(); - return { ok: true }; + // 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 }; } @@ -289,8 +300,14 @@ ipcMain.handle("folio:getRecents", (): Recent[] => listRecents()); ipcMain.handle("folio:closeBook", () => { bookPath = null; mainWindow?.setTitle("Folio"); - refreshMenu(); + // Notify the renderer BEFORE rebuilding the menu: a refreshMenu failure + // must not leave main with no book while the renderer still shows one. getWindow()?.webContents.send("folio:bookClosed"); + try { + refreshMenu(); + } catch (err) { + console.error("[folio] refreshMenu after close failed:", err); + } return true; }); @@ -457,28 +474,36 @@ function currentBookTitle(): string { } ipcMain.handle("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 }; + 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) }; + } }); ipcMain.handle("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 }; + 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) }; + } }); // Export the Full Book view as a PDF. The renderer shows only the assembled @@ -489,10 +514,13 @@ ipcMain.handle("folio:exportZip", async () => { ipcMain.handle("folio:exportPdf", async (_e, chapterIds?: string[] | null) => { try { requireBook(); - const selected = Array.isArray(chapterIds) && chapterIds.length > 0; - if (selected && chapterIds!.length === 0) { + // 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; @@ -529,10 +557,12 @@ ipcMain.handle("folio:exportPdf", async (_e, chapterIds?: string[] | null) => { async function exportOffice(format: "docx" | "odt", chapterIds?: string[] | null) { try { const bp = requireBook(); - const selected = Array.isArray(chapterIds) && chapterIds.length > 0; - if (selected && chapterIds!.length === 0) { + // Same fix as the PDF path: an explicitly empty array is an error, + // undefined/null means the whole book. + 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 ext = format; const envKey = format === "docx" ? "FOLIO_TEST_DOCX" : "FOLIO_TEST_ODT"; const base = currentBookTitle(); @@ -622,30 +652,34 @@ ipcMain.handle("folio:saveChapterDoc", (_e, id: string, doc: unknown) => { // 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 () => { - 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; + 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) }; } - fs.copyFileSync(src, path.join(dir, name)); - const relPath = `assets/${name}`; - return { ok: true, name, relPath }; }); ipcMain.handle("folio:copyText", (_e, text: string) => { @@ -686,10 +720,14 @@ ipcMain.on("folio:context-menu", (event, x: number, y: number) => { // "light" in the renderer, so it is normalized here too. ipcMain.handle("folio:getTheme", () => getSetting("theme", "light")); ipcMain.handle("folio:setTheme", (_e, id: unknown) => { - const theme = - typeof id === "string" && id && id !== "default" ? id : "light"; - setSetting("theme", theme); - return { ok: true }; + 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) }; + } }); // Editor zoom preference (percentage). Persisted app-wide, not per book, so the @@ -699,9 +737,13 @@ ipcMain.handle("folio:getZoom", () => { return Number.isFinite(z) && z > 0 ? Math.round(z) : 100; }); ipcMain.handle("folio:setZoom", (_e, pct: unknown) => { - const z = typeof pct === "number" && Number.isFinite(pct) ? Math.round(pct) : 100; - setSetting("zoom", z); - return { ok: true }; + 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) }; + } }); // Backup location preference. Backup reads/writes settings through the @@ -709,31 +751,39 @@ ipcMain.handle("folio:setZoom", (_e, pct: unknown) => { bindBackupSettings({ get: getSetting, set: setSetting }); ipcMain.handle("folio:getBackupLocation", () => getRecoveryRoot()); ipcMain.handle("folio:setBackupLocation", (_e, loc: unknown) => { - const s = typeof loc === "string" ? loc.trim() : ""; - setRecoveryRoot(s); - return { ok: true }; + try { + 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 () => { - 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; + 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) }; } - 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 }; }); diff --git a/src/main/menu.ts b/src/main/menu.ts index cc6b848..497cedc 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -18,11 +18,19 @@ export function buildMenu( const recents = listRecents(); const recentItems: MenuItem[] = recents.length - ? recents.map((r: Recent) => ({ - label: r.title.length ? `${r.title} — ${path.dirname(r.path)}` : path.basename(r.path), - tooltip: r.path, - click: () => getWindow()?.webContents.send("folio:open-recent", r.path), - })) + ? recents.map((r: Recent) => { + // A folio.json with a null/missing title used to throw here and, + // at startup, take the whole app down before any window existed. + const title = typeof r.title === "string" ? r.title.trim() : ""; + const label = title + ? `${title} — ${path.dirname(r.path)}` + : path.basename(r.path); + return { + label, + tooltip: r.path, + click: () => getWindow()?.webContents.send("folio:open-recent", r.path), + }; + }) : [{ label: "(no recent books)", enabled: false }]; const fileMenu: MenuItem = { diff --git a/src/main/recents.ts b/src/main/recents.ts index 8044cf3..5666ca8 100644 --- a/src/main/recents.ts +++ b/src/main/recents.ts @@ -61,10 +61,13 @@ export function listRecents(): (Recent & { missing?: boolean })[] { // Keep every saved entry so a book never "disappears" from the list just // because its folder moved or was renamed. Mark entries whose book can't be // found so the UI can show them as missing instead of hiding them. + // Dedupe by PATH: keying on title used to hide genuinely distinct books + // that happened to share a display name ("Untitled"), making one of them + // unreachable from the recents menu. const seen = new Set(); const deduped: (Recent & { missing?: boolean })[] = []; for (const r of loadRaw(recentsFilePath())) { - const key = (r.title || r.path).toLowerCase(); + const key = path.resolve(r.path); if (seen.has(key)) continue; seen.add(key); deduped.push({ ...r, missing: !hasBook(r.path) }); diff --git a/src/renderer/book.ts b/src/renderer/book.ts index 46e76e9..267acbe 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -1803,6 +1803,10 @@ const tmp = createTiptapEditor(host); return; } await renderRecents(); + // Only promise an in-app Undo where restore actually works (Linux + // home trash). Elsewhere the book sits in the OS trash, which the + // user can restore manually — no false "Undo" button. + if (!(res as { canUndoRestore?: boolean }).canUndoRestore) return; showUndoToast(`Deleted "${r.title || r.path}"`, async () => { const rr = await api.restoreBook(r.path); if (rr && "error" in rr) {