Harden IPC handlers: error protocol on exports/imports/settings, menu null-title crash fix, recents dedupe by path, no silent permanent book delete
This commit is contained in:
parent
c5c7c80b81
commit
ed3f3cd84b
4 changed files with 176 additions and 111 deletions
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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) });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue