diff --git a/src/main/backup.ts b/src/main/backup.ts index 7e74bc6..ba9c651 100644 --- a/src/main/backup.ts +++ b/src/main/backup.ts @@ -48,7 +48,18 @@ export function ensureRecoveryBackup(bookPath: string): string | null { const dest = path.join(getRecoveryRoot(), `${bookName}-${stamp}`); fs.mkdirSync(getRecoveryRoot(), { recursive: true }); - fs.cpSync(key, dest, { recursive: true }); + try { + fs.cpSync(key, dest, { recursive: true }); + } catch (err) { + // A half-copied directory is worse than none — it looks like a good + // backup. Remove the partial copy, then surface the failure. + try { + fs.rmSync(dest, { recursive: true, force: true }); + } catch { + // best-effort cleanup only + } + throw err; + } sessionBackedUp.add(key); return dest; diff --git a/src/main/migration.ts b/src/main/migration.ts index 61c2245..67b80aa 100644 --- a/src/main/migration.ts +++ b/src/main/migration.ts @@ -107,7 +107,19 @@ export function migrateChapterToNative( entry.file = newFile; meta.docFormat = "folio-doc"; meta.docSchemaVersion = CURRENT_DOC_SCHEMA_VERSION; - saveMeta(bookPath, meta); + try { + saveMeta(bookPath, meta); + } catch (err) { + // Meta write failed: roll the orphan chapter JSON back so the book + // stays consistent with folio.json (still pointing at the .md). A + // later retry simply rewrites it. + try { + fs.rmSync(path.join(bookPath, newFile), { force: true }); + } catch { + // best-effort cleanup only + } + throw err; + } return { status: "migrated", id, file: newFile }; } catch (err) { diff --git a/src/renderer/dialog.ts b/src/renderer/dialog.ts index f3cb155..4b812cd 100644 --- a/src/renderer/dialog.ts +++ b/src/renderer/dialog.ts @@ -17,6 +17,10 @@ let currentMode: Mode = "prompt"; let resolver: ((value: string | null) => void) | null = null; function open(mode: Mode, title: string, body: string, value: string): Promise { + // If a previous dialog was somehow still open (menu accelerators can open + // one while another is visible), settle its promise as cancelled so the + // awaiting caller isn't stranded forever. + close(null); currentMode = mode; titleEl.textContent = title; msgEl.textContent = body; diff --git a/src/renderer/print-format.ts b/src/renderer/print-format.ts index 185df45..1b3ad2b 100644 --- a/src/renderer/print-format.ts +++ b/src/renderer/print-format.ts @@ -139,7 +139,18 @@ function tableToHtml(node: FolioNode): string { const cells = (row.content ?? []) .map((cell) => { const tag = cell.type === "tableHeader" ? "th" : "td"; - return `<${tag}>${inlineToHtml((cell.content?.[0]?.content ?? []) as FolioNode[])}`; + const blocks = cell.content ?? []; + // A cell can hold multiple blocks (Tiptap allows multi-paragraph + // cells); render them all instead of silently dropping every block + // after the first. The common single-paragraph cell keeps its old + // bare-inline shape so existing table styling is untouched. + let inner: string; + if (blocks.length === 1 && blocks[0].type === "paragraph") { + inner = inlineToHtml(blocks[0].content); + } else { + inner = blocks.map(blockToHtml).join(""); + } + return `<${tag}>${inner}`; }) .join(""); return `${cells}`; diff --git a/src/renderer/theme.ts b/src/renderer/theme.ts index d1162e6..60bdf23 100644 --- a/src/renderer/theme.ts +++ b/src/renderer/theme.ts @@ -14,9 +14,14 @@ export function selector(): HTMLSelectElement { // theme, which replaced it as Folio's default light appearance. const LIGHT = "light"; +// Themes with a real entry in themes.css + the selector. Anything else that +// might linger in saved settings (a removed theme, a typo) falls back to +// Light so the