Stage 6: export book as combined Markdown or ZIP bundle (title page, book.md, chapters/, manifest) with File menu + toolbar entries and export tests

This commit is contained in:
avi 2026-08-17 09:22:41 -05:00
commit 5dcdec86b9
9 changed files with 768 additions and 1 deletions

77
src/main/export.ts Normal file
View file

@ -0,0 +1,77 @@
import { ZipArchive } from "archiver";
import fs from "fs";
import path from "path";
import { loadBook } from "./project.js";
import { listChaptersInOrder } from "./chapters.js";
export function combineMarkdown(bookPath: string): string {
const meta = loadBook(bookPath);
const chapters = listChaptersInOrder(meta);
const parts: string[] = [];
parts.push(`# ${meta.title}`);
parts.push("");
parts.push("_A book compiled with Folio._");
for (const ch of chapters) {
let content = "";
try {
content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8");
} catch {
content = "";
}
const body = content.replace(/\s+$/, "");
parts.push("");
parts.push("---");
parts.push("");
parts.push(`## ${ch.title}`);
parts.push("");
parts.push(body);
}
parts.push("");
return parts.join("\n");
}
export async function buildZip(bookPath: string): Promise<Buffer> {
const meta = loadBook(bookPath);
const chapters = listChaptersInOrder(meta);
const chunks: Buffer[] = [];
const archive = new ZipArchive({ zlib: { level: 9 } });
return new Promise<Buffer>((resolve, reject) => {
archive.on("error", reject);
archive.on("data", (c: Buffer) => chunks.push(c));
archive.on("end", () => resolve(Buffer.concat(chunks)));
archive.append(`# ${meta.title}\n\n_A book compiled with Folio._\n`, {
name: "title_page.md",
});
archive.append(combineMarkdown(bookPath), { name: "book.md" });
for (const ch of chapters) {
let content = "";
try {
content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8");
} catch {
content = "";
}
const entryName = path.posix.join("chapters", path.basename(ch.file));
archive.append(content, { name: entryName });
}
const manifest = {
title: meta.title,
app: meta.app,
version: meta.version,
generatedAt: new Date().toISOString(),
chapterCount: chapters.length,
chapters: chapters.map((c) => ({
id: c.id,
title: c.title,
file: path.basename(c.file),
})),
};
archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });
void archive.finalize();
});
}

View file

@ -1,5 +1,5 @@
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
import type { OpenDialogOptions } from "electron";
import type { OpenDialogOptions, SaveDialogOptions } from "electron";
import fs from "fs";
import path from "path";
import {
@ -22,6 +22,7 @@ import {
listChaptersInOrder,
setChapterContent,
} from "./chapters.js";
import { combineMarkdown, buildZip } from "./export.js";
let mainWindow: BrowserWindow | null = null;
let bookPath: string | null = null;
@ -35,6 +36,11 @@ function showOpenDialog(options: OpenDialogOptions) {
return win ? dialog.showOpenDialog(win, options) : dialog.showOpenDialog(options);
}
function showSaveDialog(options: SaveDialogOptions) {
const win = getWindow();
return win ? dialog.showSaveDialog(win, options) : dialog.showSaveDialog(options);
}
function getState() {
return { bookPath };
}
@ -251,6 +257,39 @@ ipcMain.handle("folio:getChapterList", () => {
}
});
function currentBookTitle(): string {
try {
return loadBook(requireBook()).title;
} catch {
return "book";
}
}
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 };
});
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 };
});
ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => {
try {
setChapterContent(requireBook(), id, content);

View file

@ -42,6 +42,18 @@ export function buildMenu(
},
},
{ type: "separator" },
{
label: "Export Markdown…",
accelerator: "CmdOrCtrl+Shift+E",
enabled: !!getState().bookPath,
click: () => getWindow()?.webContents.send("folio:export-markdown-requested"),
},
{
label: "Export Bundle (ZIP)…",
enabled: !!getState().bookPath,
click: () => getWindow()?.webContents.send("folio:export-zip-requested"),
},
{ type: "separator" },
{
label: "Close",
accelerator: isMac ? "Cmd+W" : "Ctrl+W",

View file

@ -25,6 +25,8 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.invoke("folio:reorderChapters", newOrder),
saveChapter: (id: string, content: string) =>
ipcRenderer.invoke("folio:saveChapter", id, content),
exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"),
exportZip: () => ipcRenderer.invoke("folio:exportZip"),
// events
onBookOpened: (cb: (data: { path: string; title: string }) => void) =>
@ -41,4 +43,8 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.on("folio:open-recent", (_e, p) => cb(p)),
onRevealRequested: (cb: (p: string) => void) =>
ipcRenderer.on("folio:reveal-requested", (_e, p) => cb(p)),
onExportMarkdownRequested: (cb: () => void) =>
ipcRenderer.on("folio:export-markdown-requested", () => cb()),
onExportZipRequested: (cb: () => void) =>
ipcRenderer.on("folio:export-zip-requested", () => cb()),
});

View file

@ -17,6 +17,8 @@ export type FolioAPI = {
duplicateChapter: (id: string, title?: string) => Promise<any>;
reorderChapters: (newOrder: string[]) => Promise<any>;
saveChapter: (id: string, content: string) => Promise<any>;
exportMarkdown: () => Promise<any>;
exportZip: () => Promise<any>;
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
onBookClosed: (cb: () => void) => void;
onChaptersChanged: (cb: () => void) => void;
@ -24,6 +26,8 @@ export type FolioAPI = {
onOpenBookRequested: (cb: () => void) => void;
onOpenRecent: (cb: (p: string) => void) => void;
onRevealRequested: (cb: (p: string) => void) => void;
onExportMarkdownRequested: (cb: () => void) => void;
onExportZipRequested: (cb: () => void) => void;
};
const $ = (id: string) => document.getElementById(id) as HTMLElement;
@ -68,6 +72,8 @@ export function initBook(api: FolioAPI) {
const findPrevBtn = $("findPrev") as HTMLButtonElement;
const findNextBtn = $("findNext") as HTMLButtonElement;
const findCloseBtn = $("findClose") as HTMLButtonElement;
const exportBtn = $("exportBtn") as HTMLButtonElement;
const exportZipBtn = $("exportZipBtn") as HTMLButtonElement;
const addChapterBtn = $("addChapterBtn") as HTMLButtonElement;
const projectBar = $("projectBar");
@ -282,6 +288,35 @@ export function initBook(api: FolioAPI) {
sourceArea.focus();
}
function status(text: string, ms = 3000) {
saveStateEl.textContent = text;
saveStateEl.classList.add("unsaved");
setTimeout(() => {
saveStateEl.textContent = dirty ? "Unsaved" : "Saved";
saveStateEl.classList.toggle("unsaved", dirty);
}, ms);
}
async function exportMarkdown() {
if (!currentBook) return;
const res = await api.exportMarkdown();
if (res && "error" in res) alert((res as { error: string }).error);
else if (res && "filePath" in res) status(`Exported to ${basename(res.filePath)}`);
else if (res && "canceled" in res) status("Export canceled");
}
async function exportZip() {
if (!currentBook) return;
const res = await api.exportZip();
if (res && "error" in res) alert((res as { error: string }).error);
else if (res && "filePath" in res) status(`Bundle saved to ${basename(res.filePath)}`);
else if (res && "canceled" in res) status("Export canceled");
}
function basename(p: string): string {
return p.split(/[/\\]/).pop() || p;
}
function countWords(text: string) {
const trimmed = text.trim();
if (!trimmed) return 0;
@ -479,6 +514,8 @@ export function initBook(api: FolioAPI) {
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
newChapterBtn.addEventListener("click", () => void create());
addChapterBtn.addEventListener("click", () => void create());
exportBtn.addEventListener("click", () => void exportMarkdown());
exportZipBtn.addEventListener("click", () => void exportZip());
$("revealBtn").addEventListener("click", () => {
if (currentBook) void api.revealInFolder(currentBook.path);
});
@ -519,6 +556,8 @@ export function initBook(api: FolioAPI) {
api.onOpenBookRequested(() => void startOpenBook());
api.onOpenRecent((p) => void openPath(p));
api.onRevealRequested((p) => void api.revealInFolder(p));
api.onExportMarkdownRequested(() => void exportMarkdown());
api.onExportZipRequested(() => void exportZip());
void renderRecents();
}

View file

@ -44,6 +44,8 @@
<div class="editor-actions">
<span class="save-state" id="saveState">Saved</span>
<span class="word-count" id="wordCount">words: 0</span>
<button class="icon-button" id="exportBtn" title="Export book (Markdown)">⬇</button>
<button class="icon-button" id="exportZipBtn" title="Export bundle (ZIP)">📦</button>
<button class="icon-button" id="findBtn" title="Find (Ctrl+F)">⌕</button>
</div>
</header>