Просмотр исходного кода

feat(cli): add EPUB export format

inkos export <book> --format epub converts markdown chapters to HTML
via marked, packages with epub-gen-memory, outputs {bookId}.epub.
Ma 7 месяцев назад
Родитель
Сommit
8ccdd0266c
1 измененных файлов с 58 добавлено и 1 удалено
  1. 58 1
      packages/cli/src/commands/export.ts

+ 58 - 1
packages/cli/src/commands/export.ts

@@ -7,7 +7,7 @@ import { findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 export const exportCommand = new Command("export")
   .description("Export book chapters to a single file")
   .argument("[book-id]", "Book ID (auto-detected if only one book)")
-  .option("--format <format>", "Output format (txt, md)", "txt")
+  .option("--format <format>", "Output format (txt, md, epub)", "txt")
   .option("--output <path>", "Output file path")
   .option("--approved-only", "Only export approved chapters")
   .option("--json", "Output JSON metadata")
@@ -30,6 +30,11 @@ export const exportCommand = new Command("export")
         throw new Error("No chapters to export.");
       }
 
+      if (opts.format === "epub") {
+        await exportEpub(book, chapters, chaptersDir, bookId, root, opts);
+        return;
+      }
+
       const parts: string[] = [];
 
       if (opts.format === "md") {
@@ -77,3 +82,55 @@ export const exportCommand = new Command("export")
       process.exit(1);
     }
   });
+
+async function exportEpub(
+  book: { readonly title: string },
+  chapters: ReadonlyArray<{ readonly number: number; readonly wordCount: number }>,
+  chaptersDir: string,
+  bookId: string,
+  root: string,
+  opts: { readonly output?: string; readonly json?: boolean },
+): Promise<void> {
+  const { marked } = await import("marked");
+  const { EPub } = await import("epub-gen-memory");
+
+  const epubChapters: Array<{ title: string; content: string }> = [];
+
+  for (const ch of chapters) {
+    const paddedNum = String(ch.number).padStart(4, "0");
+    const files = await readdir(chaptersDir);
+    const match = files.find((f) => f.startsWith(paddedNum));
+    if (!match) continue;
+
+    const markdown = await readFile(join(chaptersDir, match), "utf-8");
+    const html = await marked.parse(markdown);
+    // Extract title from first heading or fall back to filename
+    const titleMatch = markdown.match(/^#\s+(.+)/m);
+    const title = titleMatch?.[1] ?? match.replace(/\.md$/, "");
+
+    epubChapters.push({ title, content: html });
+  }
+
+  const epubInstance = new EPub(
+    { title: book.title, lang: "zh-CN" },
+    epubChapters,
+  );
+  const epubBuffer: Buffer = await epubInstance.genEpub();
+
+  const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
+  const outputPath = opts.output ?? join(root, `${bookId}.epub`);
+  await writeFile(outputPath, epubBuffer);
+
+  if (opts.json) {
+    log(JSON.stringify({
+      bookId,
+      chaptersExported: chapters.length,
+      totalWords,
+      format: "epub",
+      outputPath,
+    }, null, 2));
+  } else {
+    log(`Exported ${chapters.length} chapters (${totalWords} words) to EPUB`);
+    log(`Output: ${outputPath}`);
+  }
+}