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:
parent
e0c0ceec78
commit
5dcdec86b9
9 changed files with 768 additions and 1 deletions
77
src/main/export.ts
Normal file
77
src/main/export.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue