From beaf8715d8e53bb0028f2d79291b9da2abe89369 Mon Sep 17 00:00:00 2001 From: avi Date: Thu, 20 Aug 2026 12:20:23 -0500 Subject: [PATCH] Add whole-book vs selected-chapters export workflow - Export menu (PDF/DOCX/ODT) opens a workflow to export the entire book or only selected chapters, with a canonical-order checklist + Select/Clear All - Selected exports pass an ordered chapter list through the shared pipeline so output follows book order (not click order); empty selection is rejected - Filenames use a ' - Selected Chapters' suffix for partial exports - PDF renders only the chosen chapters into the print container; DOCX/ODT build from the selected chapters in order --- src/main/index.ts | 34 ++++-- src/main/preload.ts | 9 +- src/renderer/book.ts | 182 +++++++++++++++++++++++++++---- src/renderer/index.css | 49 +++++++++ src/renderer/index.html | 27 +++++ tests/run-office-export-test.mjs | 14 +++ 6 files changed, 284 insertions(+), 31 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 5c51cdd..8d31afc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -352,16 +352,22 @@ 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 () => { +ipcMain.handle("folio:exportPdf", async (_e, chapterIds?: string[] | null) => { try { requireBook(); + const selected = Array.isArray(chapterIds) && chapterIds.length > 0; + if (selected && chapterIds!.length === 0) { + return { error: "Select at least one chapter to export." }; + } + 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 Full Book as PDF", - defaultPath: `${currentBookTitle()}.pdf`, + title: "Export as PDF", + defaultPath: `${base}${suffix}.pdf`, filters: [{ name: "PDF Document", extensions: ["pdf"] }], }); if (canceled || !chosen) return { canceled: true } as const; @@ -386,18 +392,24 @@ ipcMain.handle("folio:exportPdf", async () => { // native chapter documents are assembled in canonical order (see buildDocx), and // ODT is produced from that same DOCX via LibreOffice so the two stay consistent. // The e2e harness overrides the save dialog with FOLIO_TEST_DOCX / FOLIO_TEST_ODT. -async function exportOffice(format: "docx" | "odt") { +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) { + return { error: "Select at least one chapter to export." }; + } const ext = format; const envKey = format === "docx" ? "FOLIO_TEST_DOCX" : "FOLIO_TEST_ODT"; + const base = currentBookTitle(); + const suffix = selected ? " - Selected Chapters" : ""; let filePath: string; if (process.env.FOLIO_SELF_TEST === "1" && process.env[envKey]) { filePath = process.env[envKey] as string; } else { const { canceled, filePath: chosen } = await showSaveDialog({ - title: `Export Full Book as ${format.toUpperCase()}`, - defaultPath: `${currentBookTitle()}.${ext}`, + title: `Export${selected ? " Selected Chapters" : " Full Book"} as ${format.toUpperCase()}`, + defaultPath: `${base}${suffix}.${ext}`, filters: [ { name: format === "docx" ? "Word Document" : "OpenDocument Text", @@ -408,7 +420,11 @@ async function exportOffice(format: "docx" | "odt") { if (canceled || !chosen) return { canceled: true } as const; filePath = chosen.endsWith(`.${ext}`) ? chosen : `${chosen}.${ext}`; } - const buf = format === "docx" ? await buildDocx(bp) : await buildOdt(bp); + const meta = loadBook(bp); + const chapters = selected + ? (chapterIds as string[]).map((id) => meta.chapters[id]).filter(Boolean) + : undefined; + const buf = format === "docx" ? await buildDocx(bp, chapters) : await buildOdt(bp, chapters); fs.writeFileSync(filePath, buf); const ok = buf.length > 0 && buf.subarray(0, 2).toString("utf-8") === "PK"; return { ok: true, filePath, bytes: buf.length, [`${format}Ok`]: ok }; @@ -417,8 +433,8 @@ async function exportOffice(format: "docx" | "odt") { } } -ipcMain.handle("folio:exportDocx", () => exportOffice("docx")); -ipcMain.handle("folio:exportOdt", () => exportOffice("odt")); +ipcMain.handle("folio:exportDocx", (_e, chapterIds) => exportOffice("docx", chapterIds)); +ipcMain.handle("folio:exportOdt", (_e, chapterIds) => exportOffice("odt", chapterIds)); ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => { try { diff --git a/src/main/preload.ts b/src/main/preload.ts index 141324a..5303d68 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -39,9 +39,12 @@ contextBridge.exposeInMainWorld("folio", { importImageAsset: () => ipcRenderer.invoke("folio:importImageAsset"), exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"), exportZip: () => ipcRenderer.invoke("folio:exportZip"), - exportPdf: () => ipcRenderer.invoke("folio:exportPdf"), - exportDocx: () => ipcRenderer.invoke("folio:exportDocx"), - exportOdt: () => ipcRenderer.invoke("folio:exportOdt"), + exportPdf: (chapterIds?: string[] | null) => + ipcRenderer.invoke("folio:exportPdf", chapterIds), + exportDocx: (chapterIds?: string[] | null) => + ipcRenderer.invoke("folio:exportDocx", chapterIds), + exportOdt: (chapterIds?: string[] | null) => + ipcRenderer.invoke("folio:exportOdt", chapterIds), importAttachment: () => ipcRenderer.invoke("folio:importAttachment"), copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text), getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"), diff --git a/src/renderer/book.ts b/src/renderer/book.ts index 5e4a78c..e73f035 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -42,9 +42,9 @@ export type FolioAPI = { importImageAsset: () => Promise; exportMarkdown: () => Promise; exportZip: () => Promise; - exportPdf: () => Promise; - exportDocx: () => Promise; - exportOdt: () => Promise; + exportPdf: (chapterIds?: string[] | null) => Promise; + exportDocx: (chapterIds?: string[] | null) => Promise; + exportOdt: (chapterIds?: string[] | null) => Promise; importAttachment: () => Promise; copyText: (text: string) => Promise; getClipboardText: () => Promise; @@ -973,41 +973,77 @@ export function initBook(api: FolioAPI) { }, 3000); } - // Export the assembled book as a PDF. Only meaningful from the Full Book - // view (that is what gets printed), so jump there first if invoked from - // the File menu. The main process prints the visible window using the - // print stylesheet, which strips all app chrome and starts each chapter - // after the first on its own page. - async function exportPdf() { + // Build a native Full Book document from an explicit, ordered set of chapter + // ids. Used for "selected chapters" PDF export so the printed manuscript + // contains exactly those chapters in canonical order. + async function assembleChapters( + ids: string[] + ): Promise<{ nativeDoc: FolioNode; chapterEntries: { id: string; title: string }[] }> { + const res = await api.getBookMeta(); + const inputs: { id: string; title: string; doc: FolioNode }[] = []; + if (res && typeof res === "object" && !("error" in res)) { + const book = res as BookMeta; + for (const id of ids) { + const ch = book.chapters[id]; + if (!ch) continue; + const docRes = await api.loadChapterDoc(id); + const doc = + docRes && "doc" in docRes ? (docRes as { doc: FolioNode }).doc : { type: "doc", content: [] }; + inputs.push({ id, title: ch.title, doc }); + } + } + return buildFullBookDoc(inputs); + } + + // Render a specific set of chapters into the print container for PDF export. + async function renderChaptersToPrint(ids: string[]) { + const asm = await assembleChapters(ids); + printBook.innerHTML = renderPrintChaptersNative(asm.nativeDoc); + } + + // Export the assembled book as a PDF. With no chapter selection it prints the + // whole book (jumping to the Full Book view first if needed); with a selection + // it renders just those chapters into the print container before printing. + async function exportPdf(chapterIds?: string[] | null) { if (!currentBook) return; - if (fullBookView.classList.contains("hidden")) await selectFullBook(); - const res = await api.exportPdf(); + if (chapterIds && chapterIds.length) { + await renderChaptersToPrint(chapterIds); + } else { + if (fullBookView.classList.contains("hidden")) await selectFullBook(); + } + const res = await api.exportPdf(chapterIds && chapterIds.length ? chapterIds : undefined); if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); else if (res && "canceled" in res) fullBookNotice("Export canceled"); else if (res && "filePath" in res) fullBookNotice(`PDF saved to ${basename(res.filePath)}`); } - async function exportDocx() { + async function exportDocx(chapterIds?: string[] | null) { if (!currentBook) return; - const res = await api.exportDocx(); + const res = await api.exportDocx(chapterIds && chapterIds.length ? chapterIds : undefined); if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); else if (res && "canceled" in res) fullBookNotice("Export canceled"); else if (res && "filePath" in res) fullBookNotice(`DOCX saved to ${basename(res.filePath)}`); } - async function exportOdt() { + async function exportOdt(chapterIds?: string[] | null) { if (!currentBook) return; - const res = await api.exportOdt(); + const res = await api.exportOdt(chapterIds && chapterIds.length ? chapterIds : undefined); if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); else if (res && "canceled" in res) fullBookNotice("Export canceled"); else if (res && "filePath" in res) fullBookNotice(`ODT saved to ${basename(res.filePath)}`); } async function exportFromMenu(kind: string) { - if (kind === "pdf") await exportPdf(); - else if (kind === "docx") await exportDocx(); - else if (kind === "odt") await exportOdt(); - else if (kind === "native") await migrateBookNative(); + if (kind === "native") { + await migrateBookNative(); + return; + } + // PDF/DOCX/ODT open the scope/selection workflow so the user can export the + // whole book or just selected chapters. + if (kind === "pdf" || kind === "docx" || kind === "odt") { + openExportWorkflow(kind); + return; + } } // Explicitly migrate the whole book to the native document format. The main @@ -1284,6 +1320,114 @@ export function initBook(api: FolioAPI) { if (exportDropdown && !(e.target as HTMLElement).closest("#exportDropdown")) closeExportMenu(); }); + // ---- export workflow (whole book vs selected chapters) ---- + const exportModal = $("exportModal") as HTMLDivElement; + const exportFormatLabel = $("exportFormatLabel") as HTMLElement; + const exportChapterList = $("exportChapterList") as HTMLElement; + const exportChapterItems = $("exportChapterItems") as HTMLUListElement; + const exportSelectAll = $("exportSelectAll") as HTMLButtonElement; + const exportClearAll = $("exportClearAll") as HTMLButtonElement; + const exportConfirm = $("exportConfirm") as HTMLButtonElement; + const exportCancel = $("exportCancel") as HTMLButtonElement; + const exportError = $("exportError") as HTMLElement; + let pendingExportFormat: "pdf" | "docx" | "odt" | null = null; + + function openExportWorkflow(format: string) { + if (format !== "pdf" && format !== "docx" && format !== "odt") return; + pendingExportFormat = format; + exportFormatLabel.textContent = format.toUpperCase(); + exportError.classList.add("hidden"); + const allRadio = exportModal.querySelector( + 'input[name="exportScope"][value="all"]' + ); + const selRadio = exportModal.querySelector( + 'input[name="exportScope"][value="selected"]' + ); + if (allRadio) allRadio.checked = true; + if (selRadio) selRadio.checked = false; + exportChapterList.classList.add("hidden"); + // Populate the chapter checklist in canonical order (the `ordered` list). + exportChapterItems.innerHTML = ""; + for (const ch of ordered) { + const li = document.createElement("li"); + li.className = "export-chapter-item"; + const label = document.createElement("label"); + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.value = ch.id; + cb.dataset.id = ch.id; + label.appendChild(cb); + const span = document.createElement("span"); + span.textContent = ch.title; + label.appendChild(span); + li.appendChild(label); + exportChapterItems.appendChild(li); + } + exportModal.classList.remove("hidden"); + } + + function closeExportModal() { + exportModal.classList.add("hidden"); + pendingExportFormat = null; + } + + // Selected ids in canonical order (not the order they were clicked). + function getSelectedChapterIds(): string[] { + const checked = new Set( + Array.from( + exportChapterItems.querySelectorAll('input[type="checkbox"]') + ) + .filter((cb) => cb.checked) + .map((cb) => cb.dataset.id as string) + ); + return ordered.filter((ch) => checked.has(ch.id)).map((ch) => ch.id); + } + + exportModal + .querySelectorAll('input[name="exportScope"]') + .forEach((r) => + r.addEventListener("change", () => { + const selected = exportModal.querySelector( + 'input[name="exportScope"]:checked' + )?.value === "selected"; + exportChapterList.classList.toggle("hidden", !selected); + }) + ); + exportSelectAll.addEventListener("click", () => { + exportChapterItems + .querySelectorAll('input[type="checkbox"]') + .forEach((cb) => (cb.checked = true)); + }); + exportClearAll.addEventListener("click", () => { + exportChapterItems + .querySelectorAll('input[type="checkbox"]') + .forEach((cb) => (cb.checked = false)); + }); + exportCancel.addEventListener("click", closeExportModal); + exportModal.addEventListener("click", (e) => { + if (e.target === exportModal) closeExportModal(); + }); + exportConfirm.addEventListener("click", async () => { + if (!pendingExportFormat) return; + const scope = exportModal.querySelector( + 'input[name="exportScope"]:checked' + )?.value; + let chapterIds: string[] | null = null; + if (scope === "selected") { + chapterIds = getSelectedChapterIds(); + if (chapterIds.length === 0) { + exportError.textContent = "Select at least one chapter to export."; + exportError.classList.remove("hidden"); + return; + } + } + const fmt = pendingExportFormat; + closeExportModal(); + if (fmt === "pdf") await exportPdf(chapterIds); + else if (fmt === "docx") await exportDocx(chapterIds); + else if (fmt === "odt") await exportOdt(chapterIds); + }); + copyAllBtn.addEventListener("click", async () => { // Copy the assembled manuscript as native content (the combined ProseMirror // document), not concatenated Markdown. diff --git a/src/renderer/index.css b/src/renderer/index.css index 315ee73..46f5c08 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -526,6 +526,55 @@ body.dragging-active * { border-color: var(--color-on-accent-border); } +/* Export workflow modal (whole book vs selected chapters). */ +.export-modal { + max-width: 380px; +} +.export-scope { + display: flex; + flex-direction: column; + gap: 6px; + margin: 4px 0 12px; +} +.export-scope-option { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.95rem; + cursor: pointer; +} +.export-chapters { + border-top: 1px solid var(--color-border); + padding-top: 10px; + margin-bottom: 10px; +} +.export-chapter-actions { + display: flex; + gap: 8px; + margin-bottom: 8px; +} +.export-chapter-items { + list-style: none; + margin: 0; + padding: 0; + max-height: 240px; + overflow-y: auto; +} +.export-chapter-item { + padding: 4px 2px; +} +.export-chapter-item label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} +.export-error { + color: var(--color-error, #b91c1c); + font-size: 0.85rem; + margin: 0 0 8px; +} + /* Editor zoom controls (writing view only; sidebar/nav stay fixed size). */ .zoom-control { display: inline-flex; diff --git a/src/renderer/index.html b/src/renderer/index.html index b2e2e1e..489d7a8 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -269,6 +269,33 @@ + diff --git a/tests/run-office-export-test.mjs b/tests/run-office-export-test.mjs index 37bb371..1669c71 100644 --- a/tests/run-office-export-test.mjs +++ b/tests/run-office-export-test.mjs @@ -183,6 +183,20 @@ check( idxAlpha >= 0 && idxAlpha < idxBeta && idxBeta < idxGamma && idxGamma < idxUnicode && idxUnicode < idxPics ); +// The explicitly passed chapter list is the single source of truth for export +// ordering: a non-canonical subset exports in the passed order (and only those +// chapters), not the book's stored order. +const subsetBuf = await mod.buildDocx(bookDir, [chapters.ch3, chapters.ch1, chapters.ch2]); +const subsetZip = await JSZip.loadAsync(subsetBuf); +const subsetXml = await subsetZip.file("word/document.xml").async("string"); +const sText = stripTags(subsetXml); +const sG = sText.indexOf("Gamma"); +const sA = sText.indexOf("Alpha"); +const sB = sText.indexOf("Beta"); +check("docx subset honors the passed order (Gamma, Alpha, Beta)", sG >= 0 && sG < sA && sA < sB); +check("docx subset excludes unselected chapters", !sText.includes("Unicode") && !sText.includes("Pics")); + + const mediaFiles = Object.keys(docxZip.files).filter((f) => f.startsWith("word/media/")); check("docx embeds the image (no data loss)", mediaFiles.length >= 1);