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:
parent
7037cd9fc0
commit
beaf8715d8
6 changed files with 284 additions and 31 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -42,9 +42,9 @@ export type FolioAPI = {
|
|||
importImageAsset: () => Promise<any>;
|
||||
exportMarkdown: () => Promise<any>;
|
||||
exportZip: () => Promise<any>;
|
||||
exportPdf: () => Promise<any>;
|
||||
exportDocx: () => Promise<any>;
|
||||
exportOdt: () => Promise<any>;
|
||||
exportPdf: (chapterIds?: string[] | null) => Promise<any>;
|
||||
exportDocx: (chapterIds?: string[] | null) => Promise<any>;
|
||||
exportOdt: (chapterIds?: string[] | null) => Promise<any>;
|
||||
importAttachment: () => Promise<any>;
|
||||
copyText: (text: string) => Promise<any>;
|
||||
getClipboardText: () => Promise<string>;
|
||||
|
|
@ -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<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 () => {
|
||||
// Copy the assembled manuscript as native content (the combined ProseMirror
|
||||
// document), not concatenated Markdown.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -269,6 +269,33 @@
|
|||
</div>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue