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
This commit is contained in:
avi 2026-08-20 12:20:23 -05:00
commit beaf8715d8
6 changed files with 284 additions and 31 deletions

View file

@ -352,16 +352,22 @@ ipcMain.handle("folio:exportZip", async () => {
// chrome, so printing the window produces a clean, book-only PDF with an // 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 // automatic page break before each chapter after the first. Chapter files and
// the assembled Markdown are never touched. // the assembled Markdown are never touched.
ipcMain.handle("folio:exportPdf", async () => { ipcMain.handle("folio:exportPdf", async (_e, chapterIds?: string[] | null) => {
try { try {
requireBook(); 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; let filePath: string;
if (process.env.FOLIO_SELF_TEST === "1" && process.env.FOLIO_TEST_PDF) { if (process.env.FOLIO_SELF_TEST === "1" && process.env.FOLIO_TEST_PDF) {
filePath = process.env.FOLIO_TEST_PDF; filePath = process.env.FOLIO_TEST_PDF;
} else { } else {
const { canceled, filePath: chosen } = await showSaveDialog({ const { canceled, filePath: chosen } = await showSaveDialog({
title: "Export Full Book as PDF", title: "Export as PDF",
defaultPath: `${currentBookTitle()}.pdf`, defaultPath: `${base}${suffix}.pdf`,
filters: [{ name: "PDF Document", extensions: ["pdf"] }], filters: [{ name: "PDF Document", extensions: ["pdf"] }],
}); });
if (canceled || !chosen) return { canceled: true } as const; 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 // 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. // 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. // 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 { try {
const bp = requireBook(); 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 ext = format;
const envKey = format === "docx" ? "FOLIO_TEST_DOCX" : "FOLIO_TEST_ODT"; const envKey = format === "docx" ? "FOLIO_TEST_DOCX" : "FOLIO_TEST_ODT";
const base = currentBookTitle();
const suffix = selected ? " - Selected Chapters" : "";
let filePath: string; let filePath: string;
if (process.env.FOLIO_SELF_TEST === "1" && process.env[envKey]) { if (process.env.FOLIO_SELF_TEST === "1" && process.env[envKey]) {
filePath = process.env[envKey] as string; filePath = process.env[envKey] as string;
} else { } else {
const { canceled, filePath: chosen } = await showSaveDialog({ const { canceled, filePath: chosen } = await showSaveDialog({
title: `Export Full Book as ${format.toUpperCase()}`, title: `Export${selected ? " Selected Chapters" : " Full Book"} as ${format.toUpperCase()}`,
defaultPath: `${currentBookTitle()}.${ext}`, defaultPath: `${base}${suffix}.${ext}`,
filters: [ filters: [
{ {
name: format === "docx" ? "Word Document" : "OpenDocument Text", 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; if (canceled || !chosen) return { canceled: true } as const;
filePath = chosen.endsWith(`.${ext}`) ? chosen : `${chosen}.${ext}`; 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); fs.writeFileSync(filePath, buf);
const ok = buf.length > 0 && buf.subarray(0, 2).toString("utf-8") === "PK"; const ok = buf.length > 0 && buf.subarray(0, 2).toString("utf-8") === "PK";
return { ok: true, filePath, bytes: buf.length, [`${format}Ok`]: ok }; 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:exportDocx", (_e, chapterIds) => exportOffice("docx", chapterIds));
ipcMain.handle("folio:exportOdt", () => exportOffice("odt")); ipcMain.handle("folio:exportOdt", (_e, chapterIds) => exportOffice("odt", chapterIds));
ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => { ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => {
try { try {

View file

@ -39,9 +39,12 @@ contextBridge.exposeInMainWorld("folio", {
importImageAsset: () => ipcRenderer.invoke("folio:importImageAsset"), importImageAsset: () => ipcRenderer.invoke("folio:importImageAsset"),
exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"), exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"),
exportZip: () => ipcRenderer.invoke("folio:exportZip"), exportZip: () => ipcRenderer.invoke("folio:exportZip"),
exportPdf: () => ipcRenderer.invoke("folio:exportPdf"), exportPdf: (chapterIds?: string[] | null) =>
exportDocx: () => ipcRenderer.invoke("folio:exportDocx"), ipcRenderer.invoke("folio:exportPdf", chapterIds),
exportOdt: () => ipcRenderer.invoke("folio:exportOdt"), exportDocx: (chapterIds?: string[] | null) =>
ipcRenderer.invoke("folio:exportDocx", chapterIds),
exportOdt: (chapterIds?: string[] | null) =>
ipcRenderer.invoke("folio:exportOdt", chapterIds),
importAttachment: () => ipcRenderer.invoke("folio:importAttachment"), importAttachment: () => ipcRenderer.invoke("folio:importAttachment"),
copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text), copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text),
getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"), getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"),

View file

@ -42,9 +42,9 @@ export type FolioAPI = {
importImageAsset: () => Promise<any>; importImageAsset: () => Promise<any>;
exportMarkdown: () => Promise<any>; exportMarkdown: () => Promise<any>;
exportZip: () => Promise<any>; exportZip: () => Promise<any>;
exportPdf: () => Promise<any>; exportPdf: (chapterIds?: string[] | null) => Promise<any>;
exportDocx: () => Promise<any>; exportDocx: (chapterIds?: string[] | null) => Promise<any>;
exportOdt: () => Promise<any>; exportOdt: (chapterIds?: string[] | null) => Promise<any>;
importAttachment: () => Promise<any>; importAttachment: () => Promise<any>;
copyText: (text: string) => Promise<any>; copyText: (text: string) => Promise<any>;
getClipboardText: () => Promise<string>; getClipboardText: () => Promise<string>;
@ -973,41 +973,77 @@ export function initBook(api: FolioAPI) {
}, 3000); }, 3000);
} }
// Export the assembled book as a PDF. Only meaningful from the Full Book // Build a native Full Book document from an explicit, ordered set of chapter
// view (that is what gets printed), so jump there first if invoked from // ids. Used for "selected chapters" PDF export so the printed manuscript
// the File menu. The main process prints the visible window using the // contains exactly those chapters in canonical order.
// print stylesheet, which strips all app chrome and starts each chapter async function assembleChapters(
// after the first on its own page. ids: string[]
async function exportPdf() { ): 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 (!currentBook) return;
if (chapterIds && chapterIds.length) {
await renderChaptersToPrint(chapterIds);
} else {
if (fullBookView.classList.contains("hidden")) await selectFullBook(); if (fullBookView.classList.contains("hidden")) await selectFullBook();
const res = await api.exportPdf(); }
const res = await api.exportPdf(chapterIds && chapterIds.length ? chapterIds : undefined);
if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error); 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 && "canceled" in res) fullBookNotice("Export canceled");
else if (res && "filePath" in res) fullBookNotice(`PDF saved to ${basename(res.filePath)}`); 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; 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); 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 && "canceled" in res) fullBookNotice("Export canceled");
else if (res && "filePath" in res) fullBookNotice(`DOCX saved to ${basename(res.filePath)}`); 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; 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); 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 && "canceled" in res) fullBookNotice("Export canceled");
else if (res && "filePath" in res) fullBookNotice(`ODT saved to ${basename(res.filePath)}`); else if (res && "filePath" in res) fullBookNotice(`ODT saved to ${basename(res.filePath)}`);
} }
async function exportFromMenu(kind: string) { async function exportFromMenu(kind: string) {
if (kind === "pdf") await exportPdf(); if (kind === "native") {
else if (kind === "docx") await exportDocx(); await migrateBookNative();
else if (kind === "odt") await exportOdt(); return;
else if (kind === "native") await migrateBookNative(); }
// 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 // 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(); 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<HTMLInputElement>(
'input[name="exportScope"][value="all"]'
);
const selRadio = exportModal.querySelector<HTMLInputElement>(
'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<HTMLInputElement>('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<HTMLInputElement>('input[name="exportScope"]')
.forEach((r) =>
r.addEventListener("change", () => {
const selected = exportModal.querySelector<HTMLInputElement>(
'input[name="exportScope"]:checked'
)?.value === "selected";
exportChapterList.classList.toggle("hidden", !selected);
})
);
exportSelectAll.addEventListener("click", () => {
exportChapterItems
.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
.forEach((cb) => (cb.checked = true));
});
exportClearAll.addEventListener("click", () => {
exportChapterItems
.querySelectorAll<HTMLInputElement>('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<HTMLInputElement>(
'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 () => { copyAllBtn.addEventListener("click", async () => {
// Copy the assembled manuscript as native content (the combined ProseMirror // Copy the assembled manuscript as native content (the combined ProseMirror
// document), not concatenated Markdown. // document), not concatenated Markdown.

View file

@ -526,6 +526,55 @@ body.dragging-active * {
border-color: var(--color-on-accent-border); 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). */ /* Editor zoom controls (writing view only; sidebar/nav stay fixed size). */
.zoom-control { .zoom-control {
display: inline-flex; display: inline-flex;

View file

@ -269,6 +269,33 @@
</div> </div>
</div> </div>
<div class="toast hidden" id="toast"></div> <div class="toast hidden" id="toast"></div>
<div class="modal-backdrop hidden" id="exportModal">
<div class="modal export-modal" role="dialog" aria-modal="true" aria-label="Export book">
<h3 class="modal-title">Export <span id="exportFormatLabel"></span></h3>
<div class="export-scope">
<label class="export-scope-option">
<input type="radio" name="exportScope" value="all" checked />
<span>Entire book</span>
</label>
<label class="export-scope-option">
<input type="radio" name="exportScope" value="selected" />
<span>Selected chapters</span>
</label>
</div>
<div class="export-chapters hidden" id="exportChapterList">
<div class="export-chapter-actions">
<button type="button" class="button button-ghost" id="exportSelectAll">Select All</button>
<button type="button" class="button button-ghost" id="exportClearAll">Clear All</button>
</div>
<ul class="export-chapter-items" id="exportChapterItems"></ul>
</div>
<p class="export-error hidden" id="exportError"></p>
<div class="modal-actions">
<button type="button" class="button" id="exportCancel">Cancel</button>
<button type="button" class="button button-primary" id="exportConfirm">Export</button>
</div>
</div>
</div>
<script src="./renderer.js"></script> <script src="./renderer.js"></script>
</body> </body>
</html> </html>

View file

@ -183,6 +183,20 @@ check(
idxAlpha >= 0 && idxAlpha < idxBeta && idxBeta < idxGamma && idxGamma < idxUnicode && idxUnicode < idxPics 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/")); const mediaFiles = Object.keys(docxZip.files).filter((f) => f.startsWith("word/media/"));
check("docx embeds the image (no data loss)", mediaFiles.length >= 1); check("docx embeds the image (no data loss)", mediaFiles.length >= 1);