diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6970933..928a780 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,6 +3,7 @@ import { Routes, Route } from "react-router-dom"; import { Sidebar, Topbar, CommandPalette, SplashScreen } from "./components/index.js"; import { AuthGate } from "./components/AuthGate.js"; import { ExportDialog } from "./features/export/ExportDialog.js"; +import { ImportDialog } from "./features/import/ImportDialog.js"; import { AISidebar } from "./features/ai/AISidebar.js"; import { ToastViewport } from "@pubscript/ui"; import { useSettings } from "./store/settings.js"; @@ -30,6 +31,7 @@ const NAV = [ export default function App() { const [paletteOpen, setPaletteOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); const navCollapsed = useSettings((s) => s.navCollapsed); const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen); const focusMode = useSettings((s) => s.focusMode); @@ -50,7 +52,11 @@ export default function App() {
- setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} /> + setPaletteOpen(true)} + onOpenExport={() => setExportOpen(true)} + onOpenImport={() => setImportOpen(true)} + />
@@ -70,6 +76,7 @@ export default function App() {
setPaletteOpen(false)} nav={NAV} /> + setImportOpen(false)} /> setExportOpen(false)} />
diff --git a/apps/web/src/components/Topbar.tsx b/apps/web/src/components/Topbar.tsx index 70f3e4b..01ecd79 100644 --- a/apps/web/src/components/Topbar.tsx +++ b/apps/web/src/components/Topbar.tsx @@ -21,6 +21,7 @@ const TITLES: Record = { export interface TopbarProps { onOpenPalette?: () => void; onOpenExport: () => void; + onOpenImport?: () => void; } function entityName(e: Entity): string { @@ -28,7 +29,7 @@ function entityName(e: Entity): string { return (e.body || "Untitled note").slice(0, 48); } -export function Topbar({ onOpenExport }: TopbarProps) { +export function Topbar({ onOpenExport, onOpenImport }: TopbarProps) { const location = useLocation(); const navigate = useNavigate(); const { user, logout } = useAuth(); @@ -176,6 +177,18 @@ export function Topbar({ onOpenExport }: TopbarProps) { ✨ AI + {onOpenImport && ( + + )} + + + + } + > +
+
e.preventDefault()} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + style={{ + border: "2px dashed var(--ps-border)", + borderRadius: 10, + padding: "20px 16px", + textAlign: "center", + background: "var(--ps-bg)", + cursor: "pointer", + }} + > + void handleFileChange(e.target.files?.[0] ?? null)} + /> +
📥
+
+ {selectedFile + ? `${selectedFile.name} (${(selectedFile.size / 1024).toFixed(1)} KB)` + : "Click to choose a file or drag & drop here"} +
+
+ Compatible formats: .docx (Word / OnlyOffice), .odt{" "} + (OpenOffice / LibreOffice / OnlyOffice), .epub, .md,{" "} + .txt, .html, .rtf,{" "} + .json +
+
+ + {error && ( +
+ {error} +
+ )} + + {parsing && ( +
+ Running PubScript format conversion pipeline… +
+ )} + + {parsed && !parsing && ( +
+
+
+ {parsed.formatLabel} + + {parsed.chapters.length} chapter(s) · {parsed.totalWords.toLocaleString()} words + +
+ {parsed.author && ( + + Author: {parsed.author} + + )} +
+ + {currentProject && ( +
+ +
+ + +
+
+ )} + + {mode === "new-project" && ( +
+ + setCustomTitle(e.target.value)} + placeholder="Manuscript Title" + /> +
+ )} + + {parsed.format !== "epub" && parsed.format !== "json" && ( + + )} + +
+
+ Converted Chapters Preview ({parsed.chapters.length}) +
+
+ {parsed.chapters.map((ch, idx) => ( +
+ + {idx + 1}. {ch.title} + + + {ch.wordCount.toLocaleString()} words + +
+ ))} +
+
+
+ )} +
+ + ); +} diff --git a/apps/web/src/routes/Projects.tsx b/apps/web/src/routes/Projects.tsx index 73d47a7..cdba26d 100644 --- a/apps/web/src/routes/Projects.tsx +++ b/apps/web/src/routes/Projects.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { Badge, Button, EmptyState, Input, Modal, Select, toast } from "@pubscript/ui"; import { useProjectStore } from "../store/project-store.js"; import { PLOT_TEMPLATES } from "../features/plot/templates.js"; +import { ImportDialog } from "../features/import/ImportDialog.js"; type WizardMode = "tutorial" | "manual"; @@ -88,6 +89,7 @@ export function Projects() { } = useProjectStore(); const [wizardOpen, setWizardOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); const [wizardMode, setWizardMode] = useState("tutorial"); const [tutorialStep, setTutorialStep] = useState<1 | 2 | 3 | 4>(1); const [answers, setAnswers] = useState({ @@ -200,9 +202,14 @@ export function Projects() { Switch between manuscripts, start a guided story template, or manage existing projects.

- +
+ + +
{projects.length === 0 ? ( @@ -613,6 +620,11 @@ export function Projects() { )} )} + setImportOpen(false)} + defaultMode="new-project" + /> ); } diff --git a/apps/web/src/store/project-store.ts b/apps/web/src/store/project-store.ts index 84b282d..a2fd2ed 100644 --- a/apps/web/src/store/project-store.ts +++ b/apps/web/src/store/project-store.ts @@ -22,6 +22,7 @@ import { type ProjectMeta, type StorageAdapter, } from "@pubscript/core"; +import type { ImportedManuscript } from "@pubscript/exporters"; import { storageLocal } from "@pubscript/core/local"; import { createUlid } from "@pubscript/core"; import { create } from "zustand"; @@ -113,6 +114,11 @@ interface ProjectStore { openProject: (id: string) => Promise; /** Create a new project with optional structural template and chapter skeleton. */ createProjectWithScaffold: (opts: CreateProjectScaffoldOptions) => Promise; + /** Import a parsed manuscript as either a new project or chapters in the active project. */ + importManuscript: ( + parsed: ImportedManuscript, + opts: { mode: "new-project" | "append-chapters"; title?: string }, + ) => Promise; /** Delete a project by id. */ deleteProject: (id: string) => Promise; @@ -632,6 +638,109 @@ export const useProjectStore = create((set, get) => { return meta; }, + importManuscript: async (parsed, opts) => { + if (autosave) { + await autosave.flush(); + } + const adapter = get().adapter ?? storageLocal; + + if (opts.mode === "append-chapters" && get().project) { + const project = get().project!; + let bookId = get().activeBookId; + if (!bookId) { + const books = await adapter.listBooks(project.id); + bookId = + books[0]?.id ?? + (await adapter.createBook({ projectId: project.id, title: "Book One" })).id; + } + let firstNewChapterId: string | null = null; + for (const ch of parsed.chapters) { + const cid = createUlid(); + if (!firstNewChapterId) firstNewChapterId = cid; + await adapter.saveDocument({ + id: cid, + projectId: project.id, + bookId, + title: ch.title || "Imported Chapter", + doc: ch.doc, + rev: 0, + }); + } + const updatedChapters = await adapter.listChapters(bookId); + const projectStats = await computeAllProjectStats(adapter, get().projects); + const revisionsByChapter = loadProjectRevisions(project.id, updatedChapters); + set({ + activeBookId: bookId, + chapters: updatedChapters, + activeChapterId: firstNewChapterId ?? get().activeChapterId, + projectStats, + revisionsByChapter, + docExternalRev: get().docExternalRev + 1, + }); + return; + } + + // Create a new project from the imported manuscript + const projectTitle = opts.title?.trim() || parsed.title || "Imported Manuscript"; + const created = await adapter.createProject({ + title: projectTitle, + cover: { color: "#7c8bf5" }, + }); + const book = await adapter.createBook({ + projectId: created.id, + title: "Book One", + }); + + for (const ch of parsed.chapters) { + await adapter.saveDocument({ + id: createUlid(), + projectId: created.id, + bookId: book.id, + title: ch.title || "Chapter 1", + doc: ch.doc, + rev: 0, + }); + } + + if (parsed.entities && parsed.entities.length > 0) { + for (const ent of parsed.entities) { + await adapter.saveEntity({ ...ent, id: createUlid(), projectId: created.id }); + } + } + if (parsed.plotBeats && parsed.plotBeats.length > 0) { + for (const beat of parsed.plotBeats) { + await adapter.savePlotBeat({ ...beat, id: createUlid(), projectId: created.id }); + } + } + if (parsed.board) { + await adapter.saveBoard({ + ...parsed.board, + id: `board-${created.id}`, + projectId: created.id, + }); + } else { + await adapter.saveBoard({ + id: `board-${created.id}`, + projectId: created.id, + nodes: [ + { + id: createUlid(), + type: "note", + position: { x: 120, y: 120 }, + data: { + title: projectTitle, + text: projectTitle, + body: `Imported from ${parsed.formatLabel} (${parsed.totalWords.toLocaleString()} words)`, + }, + }, + ], + edges: [], + }); + } + + await get().openProject(created.id); + }, + deleteProject: async (id: string) => { const adapter = get().adapter ?? storageLocal; if (adapter.deleteProject) { diff --git a/packages/exporters/src/importer.test.ts b/packages/exporters/src/importer.test.ts new file mode 100644 index 0000000..0749c63 --- /dev/null +++ b/packages/exporters/src/importer.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; +import { + exportProjectData, + importManuscriptFile, + type ExportProject, +} from "./index.js"; + +const SAMPLE_PROJECT: ExportProject = { + meta: { + title: "The Harbor Ledger", + author: "Austin Breinholt", + language: "en", + }, + books: [ + { + id: "book-1", + projectId: "proj-import-1", + title: "Book One", + order: 0, + chapters: [ + { + id: "ch-1", + bookId: "book-1", + projectId: "proj-import-1", + title: "Chapter 1: Fog Over the Pier", + order: 0, + rev: 1, + meta: { wordCount: 15, scenes: [], updatedAt: "2026-01-01T00:00:00.000Z" }, + doc: { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: "Chapter 1: Fog Over the Pier" }], + }, + { + type: "paragraph", + content: [ + { type: "text", text: "The lantern swung in the " }, + { type: "text", text: "harbor wind", marks: [{ type: "bold" }] }, + { type: "text", text: " as Mara opened the ledger." }, + ], + }, + ], + }, + }, + { + id: "ch-2", + bookId: "book-1", + projectId: "proj-import-1", + title: "Chapter 2: The Bell Tower", + order: 1, + rev: 1, + meta: { wordCount: 6, scenes: [], updatedAt: "2026-01-01T00:00:00.000Z" }, + doc: { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1 }, + content: [{ type: "text", text: "Chapter 2: The Bell Tower" }], + }, + { + type: "paragraph", + content: [ + { type: "text", text: "Twelve strokes echoed across the water." }, + ], + }, + ], + }, + }, + ], + }, + ], +}; + +describe("Universal Manuscript Importer (importer.ts)", () => { + it("imports plain text (.txt) and splits chapters on Chapter headings", async () => { + const txt = [ + "Chapter 1: The Arrival", + "", + "The train pulled into the station at dusk.", + "", + "* * *", + "", + "She stepped onto the wet platform.", + "", + "Chapter 2: The Departure", + "", + "By morning, the tracks were empty.", + ].join("\n"); + + const res = await importManuscriptFile("arrival-story.txt", txt); + expect(res.format).toBe("txt"); + expect(res.chapters).toHaveLength(2); + expect(res.chapters[0]?.title).toBe("Chapter 1: The Arrival"); + expect(res.chapters[1]?.title).toBe("Chapter 2: The Departure"); + expect(res.totalWords).toBeGreaterThan(10); + }); + + it("imports Markdown (.md) with frontmatter, inline marks, and chapters", async () => { + const md = [ + "---", + "title: Glass Harbor", + "author: Schwitzz", + "---", + "# Chapter One", + "", + "He read the **bold entry** and *italic margin note*.", + "", + "# Chapter Two", + "", + "The second chapter begins here.", + ].join("\n"); + + const res = await importManuscriptFile("glass-harbor.md", md); + expect(res.format).toBe("md"); + expect(res.title).toBe("Glass Harbor"); + expect(res.author).toBe("Schwitzz"); + expect(res.chapters).toHaveLength(2); + expect(res.chapters[0]?.title).toBe("Chapter One"); + expect(res.chapters[1]?.title).toBe("Chapter Two"); + }); + + it("round-trips DOCX (Word / OnlyOffice) into PubScript chapters", async () => { + const docxBytes = (await exportProjectData(SAMPLE_PROJECT, "docx", { + generatedAt: "2026-01-01T00:00:00.000Z", + })) as Uint8Array; + + const imported = await importManuscriptFile("the-harbor-ledger.docx", docxBytes); + expect(imported.format).toBe("docx"); + expect(imported.chapters.length).toBeGreaterThanOrEqual(2); + expect(imported.totalWords).toBeGreaterThan(10); + }); + + it("round-trips ODT (OpenOffice / OnlyOffice / LibreOffice) into PubScript chapters", async () => { + const odtBytes = (await exportProjectData(SAMPLE_PROJECT, "odt", { + generatedAt: "2026-01-01T00:00:00.000Z", + })) as Uint8Array; + + const imported = await importManuscriptFile("the-harbor-ledger.odt", odtBytes); + expect(imported.format).toBe("odt"); + expect(imported.title).toBe("The Harbor Ledger"); + expect(imported.chapters.length).toBeGreaterThanOrEqual(2); + expect(imported.totalWords).toBeGreaterThan(10); + }); + + it("round-trips EPUB into PubScript chapters", async () => { + const epubBytes = (await exportProjectData(SAMPLE_PROJECT, "epub", { + generatedAt: "2026-01-01T00:00:00.000Z", + })) as Uint8Array; + + const imported = await importManuscriptFile("the-harbor-ledger.epub", epubBytes); + expect(imported.format).toBe("epub"); + expect(imported.title).toBe("The Harbor Ledger"); + expect(imported.chapters).toHaveLength(2); + expect(imported.totalWords).toBeGreaterThan(10); + }); + + it("imports RTF (.rtf) and HTML (.html) manuscripts", async () => { + const rtf = "{\\rtf1\\ansi{\\fonttbl\\f0 Times;}\\pard Chapter 1: Dawn\\par The sun rose over the ridge.\\par}"; + const rtfRes = await importManuscriptFile("dawn.rtf", rtf); + expect(rtfRes.format).toBe("rtf"); + expect(rtfRes.totalWords).toBeGreaterThan(4); + + const html = "Sea Wall

Chapter 1

Waves crashed against the stone.

"; + const htmlRes = await importManuscriptFile("seawall.html", html); + expect(htmlRes.format).toBe("html"); + expect(htmlRes.title).toBe("Sea Wall"); + expect(htmlRes.chapters[0]?.title).toBe("Chapter 1"); + }); +}); diff --git a/packages/exporters/src/importer.ts b/packages/exporters/src/importer.ts new file mode 100644 index 0000000..a8602f9 --- /dev/null +++ b/packages/exporters/src/importer.ts @@ -0,0 +1,1107 @@ +/** + * PubScript Universal Manuscript Import & Conversion Pipeline. + * + * Converts external manuscript files into standard PubScript `ProseMirrorDoc` + * chapters and project structures. + * + * Supported formats: + * - `.txt` (Plain Text) + * - `.md`, `.markdown` (Markdown / CommonMark) + * - `.docx` (Microsoft Word / OnlyOffice OOXML archive) + * - `.odt` (OpenOffice / LibreOffice / OnlyOffice OpenDocument Text archive) + * - `.epub` (EPUB 2 / EPUB 3 e-book archive) + * - `.html`, `.htm` (HTML document) + * - `.rtf` (Rich Text Format) + * - `.json` (PubScript project.json bundle or ProseMirror JSON) + */ +import JSZip from "jszip"; +import type { Board, Entity, PlotBeat, ProseMirrorDoc } from "@pubscript/core"; + +export type ImportFormat = + | "txt" + | "md" + | "docx" + | "odt" + | "epub" + | "html" + | "rtf" + | "json"; + +export interface ImportedChapter { + title: string; + doc: ProseMirrorDoc; + wordCount: number; +} + +export interface ImportedManuscript { + format: ImportFormat; + formatLabel: string; + title: string; + author?: string | undefined; + chapters: ImportedChapter[]; + totalWords: number; + /** Present when importing a full PubScript project.json bundle */ + entities?: Entity[] | undefined; + plotBeats?: PlotBeat[] | undefined; + board?: Board | undefined; +} + +export interface ImportOptions { + /** Split into multiple chapters on H1 / "Chapter N" headings (default: true). */ + splitChapters?: boolean | undefined; + /** Override the inferred manuscript title. */ + defaultTitle?: string | undefined; +} + +type PMInlineNode = { + type: "text"; + text: string; + marks?: Array<{ type: string; attrs?: Record }>; +}; + +type PMBlockNode = { + type: string; + attrs?: Record; + content?: Array; +}; + +export const SUPPORTED_IMPORT_EXTENSIONS = [ + ".txt", + ".md", + ".markdown", + ".docx", + ".odt", + ".epub", + ".html", + ".htm", + ".rtf", + ".json", +] as const; + +export function detectImportFormat(fileName: string): ImportFormat { + const lower = fileName.trim().toLowerCase(); + if (lower.endsWith(".docx")) return "docx"; + if (lower.endsWith(".odt")) return "odt"; + if (lower.endsWith(".epub")) return "epub"; + if (lower.endsWith(".md") || lower.endsWith(".markdown")) return "md"; + if (lower.endsWith(".html") || lower.endsWith(".htm")) return "html"; + if (lower.endsWith(".rtf")) return "rtf"; + if (lower.endsWith(".json")) return "json"; + return "txt"; +} + +export function formatLabelFor(format: ImportFormat): string { + switch (format) { + case "docx": + return "DOCX (MS Word / OnlyOffice)"; + case "odt": + return "ODT (OpenOffice / OnlyOffice / LibreOffice)"; + case "epub": + return "EPUB E-Book"; + case "md": + return "Markdown (.md)"; + case "html": + return "HTML Document"; + case "rtf": + return "Rich Text Format (.rtf)"; + case "json": + return "PubScript Project Bundle (.json)"; + case "txt": + default: + return "Plain Text (.txt)"; + } +} + +function titleFromFileName(fileName: string): string { + const base = fileName + .replace(/^.*[\\/]/, "") + .replace(/\.[a-z0-9]+$/i, "") + .replace(/[-_]+/g, " ") + .trim(); + if (!base) return "Imported Manuscript"; + return base.replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function countDocWords(doc: ProseMirrorDoc): number { + let count = 0; + const walk = (node: Record) => { + if (typeof node.text === "string") { + count += node.text.trim().split(/\s+/).filter(Boolean).length; + } + if (Array.isArray(node.content)) { + for (const child of node.content) { + if (child && typeof child === "object") walk(child as Record); + } + } + }; + walk(doc as unknown as Record); + return count; +} + +function decodeXmlEntities(str: string): string { + return str + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number.parseInt(dec, 10))) + .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16))); +} + +function makeParagraph(inlines: PMInlineNode[], attrs?: Record): PMBlockNode { + const cleaned = inlines.filter((n) => n.text.length > 0); + return { + type: "paragraph", + ...(attrs && Object.keys(attrs).length > 0 ? { attrs } : {}), + ...(cleaned.length > 0 ? { content: cleaned } : {}), + }; +} + +function makeHeading(level: number, inlines: PMInlineNode[]): PMBlockNode { + const cleaned = inlines.filter((n) => n.text.length > 0); + return { + type: "heading", + attrs: { level: Math.min(6, Math.max(1, level)) }, + ...(cleaned.length > 0 ? { content: cleaned } : {}), + }; +} + +function extractBlockPlainText(block: PMBlockNode): string { + const parts: string[] = []; + const walk = (n: PMInlineNode | PMBlockNode) => { + if ("text" in n && typeof n.text === "string") { + parts.push(n.text); + } else if ("content" in n && Array.isArray(n.content)) { + for (const c of n.content) walk(c); + } + }; + walk(block); + return parts.join("").trim(); +} + +const CHAPTER_HEADING_RE = /^(?:chapter|part|book|prologue|epilogue|act)\b/i; + +/** + * Group a flat list of ProseMirror block nodes into chapters based on + * top-level headings (`level === 1` or headings matching `Chapter N`). + */ +function splitBlocksIntoChapters( + blocks: PMBlockNode[], + fallbackTitle: string, + splitChapters: boolean, +): ImportedChapter[] { + const nonEmptyBlocks = blocks.filter((b) => { + if (b.type === "sceneBreak" || b.type === "horizontalRule") return true; + return extractBlockPlainText(b).length > 0; + }); + + if (nonEmptyBlocks.length === 0) { + const emptyDoc: ProseMirrorDoc = { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }], + }; + return [{ title: fallbackTitle || "Chapter 1", doc: emptyDoc, wordCount: 0 }]; + } + + if (!splitChapters) { + const doc: ProseMirrorDoc = { type: "doc", content: nonEmptyBlocks }; + return [ + { + title: fallbackTitle || "Chapter 1", + doc, + wordCount: countDocWords(doc), + }, + ]; + } + + // Determine whether we have H1 headings or Chapter headings to split on + const hasH1 = nonEmptyBlocks.some( + (b) => b.type === "heading" && Number(b.attrs?.level ?? 1) === 1, + ); + const isChapterSplitHeading = (b: PMBlockNode): boolean => { + if (b.type === "heading") { + const lvl = Number(b.attrs?.level ?? 1); + const txt = extractBlockPlainText(b); + if (lvl === 1) return true; + if (lvl === 2 && (!hasH1 || CHAPTER_HEADING_RE.test(txt))) return true; + } + if (b.type === "paragraph") { + const txt = extractBlockPlainText(b); + if (/^(?:chapter|prologue|epilogue)\s+(?:\d+|[ivxlcdm]+|[a-z]+)(?:\s*[:.—-]\s*.*)?$/i.test(txt) && txt.length <= 80) { + return true; + } + } + return false; + }; + + const chapters: ImportedChapter[] = []; + let currentTitle = fallbackTitle || "Chapter 1"; + let currentBlocks: PMBlockNode[] = []; + let seenFirstHeading = false; + + for (const block of nonEmptyBlocks) { + if (isChapterSplitHeading(block)) { + const headingText = extractBlockPlainText(block); + if (!seenFirstHeading && currentBlocks.length === 0) { + currentTitle = headingText || currentTitle; + seenFirstHeading = true; + continue; + } + if (currentBlocks.length > 0) { + const doc: ProseMirrorDoc = { type: "doc", content: currentBlocks }; + chapters.push({ + title: currentTitle, + doc, + wordCount: countDocWords(doc), + }); + } + currentTitle = headingText || `Chapter ${chapters.length + 1}`; + currentBlocks = []; + seenFirstHeading = true; + } else { + currentBlocks.push(block); + } + } + + if (currentBlocks.length > 0) { + const doc: ProseMirrorDoc = { type: "doc", content: currentBlocks }; + chapters.push({ + title: currentTitle, + doc, + wordCount: countDocWords(doc), + }); + } else if (chapters.length === 0) { + const doc: ProseMirrorDoc = { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: currentTitle }] }], + }; + chapters.push({ + title: currentTitle, + doc, + wordCount: countDocWords(doc), + }); + } + + return chapters; +} + +// ============================================================================ +// 1. Plain Text (.txt) & Markdown (.md) Parser +// ============================================================================ + +function parseInlineMarkdown(text: string): PMInlineNode[] { + const result: PMInlineNode[] = []; + const tokenRe = + /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|__(.+?)__|\*(.+?)\*|_(.+?)_|~~(.+?)~~|`(.+?)`)/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = tokenRe.exec(text)) !== null) { + if (match.index > lastIndex) { + result.push({ type: "text", text: text.slice(lastIndex, match.index) }); + } + if (match[2] !== undefined) { + result.push({ + type: "text", + text: match[2], + marks: [{ type: "bold" }, { type: "italic" }], + }); + } else if (match[3] !== undefined || match[4] !== undefined) { + result.push({ + type: "text", + text: (match[3] ?? match[4])!, + marks: [{ type: "bold" }], + }); + } else if (match[5] !== undefined || match[6] !== undefined) { + result.push({ + type: "text", + text: (match[5] ?? match[6])!, + marks: [{ type: "italic" }], + }); + } else if (match[7] !== undefined) { + result.push({ + type: "text", + text: match[7], + marks: [{ type: "strike" }], + }); + } else if (match[8] !== undefined) { + result.push({ + type: "text", + text: match[8], + marks: [{ type: "code" }], + }); + } + lastIndex = tokenRe.lastIndex; + } + + if (lastIndex < text.length) { + result.push({ type: "text", text: text.slice(lastIndex) }); + } + return result.length > 0 ? result : [{ type: "text", text }]; +} + +function parseMarkdownOrTextBlocks( + rawText: string, + isMarkdown: boolean, +): { title?: string | undefined; author?: string | undefined; blocks: PMBlockNode[] } { + let content = rawText.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + let fmTitle: string | undefined; + let fmAuthor: string | undefined; + + // Strip YAML frontmatter if present + if (content.startsWith("---\n")) { + const endIdx = content.indexOf("\n---\n", 4); + if (endIdx !== -1) { + const yaml = content.slice(4, endIdx); + for (const line of yaml.split("\n")) { + const m = line.match(/^([a-zA-Z0-9_-]+)\s*:\s*(.+)$/); + if (m) { + const k = m[1]!.toLowerCase(); + const v = m[2]!.trim().replace(/^["']|["']$/g, ""); + if (k === "title") fmTitle = v; + if (k === "author") fmAuthor = v; + } + } + content = content.slice(endIdx + 5); + } + } + + const lines = content.split("\n"); + const blocks: PMBlockNode[] = []; + let paraBuffer: string[] = []; + + const flushParagraph = () => { + if (paraBuffer.length === 0) return; + const joined = paraBuffer.join(" ").trim(); + paraBuffer = []; + if (!joined) return; + blocks.push( + makeParagraph(isMarkdown ? parseInlineMarkdown(joined) : [{ type: "text", text: joined }]), + ); + }; + + for (const rawLine of lines) { + const trimmed = rawLine.trim(); + if (!trimmed) { + flushParagraph(); + continue; + } + + // Scene breaks + if (trimmed === "***" || trimmed === "* * *" || trimmed === "---" || trimmed === "• • •") { + flushParagraph(); + blocks.push({ type: "sceneBreak" }); + continue; + } + + // Markdown headings + const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + flushParagraph(); + const level = headingMatch[1]!.length; + const headingText = headingMatch[2]!.trim(); + if (level === 1 && !fmTitle && blocks.length === 0) { + fmTitle = headingText; + } + blocks.push( + makeHeading( + level, + isMarkdown ? parseInlineMarkdown(headingText) : [{ type: "text", text: headingText }], + ), + ); + continue; + } + + // Plain-text "Chapter N" line + if (!isMarkdown && /^(?:chapter|prologue|epilogue)\s+(?:\d+|[ivxlcdm]+|[a-z]+)(?:\s*[:.—-]\s*.*)?$/i.test(trimmed) && trimmed.length <= 80) { + flushParagraph(); + blocks.push(makeHeading(1, [{ type: "text", text: trimmed }])); + continue; + } + + // Blockquote + if (isMarkdown && trimmed.startsWith("> ")) { + flushParagraph(); + const quoteText = trimmed.slice(2).trim(); + blocks.push({ + type: "blockquote", + content: [makeParagraph(parseInlineMarkdown(quoteText))], + }); + continue; + } + + // Bullet list item + const ulMatch = isMarkdown ? trimmed.match(/^[-*+]\s+(.+)$/) : null; + if (ulMatch) { + flushParagraph(); + const itemPara = makeParagraph(parseInlineMarkdown(ulMatch[1]!)); + const prev = blocks[blocks.length - 1]; + if (prev && prev.type === "bulletList" && Array.isArray(prev.content)) { + prev.content.push({ type: "listItem", content: [itemPara] }); + } else { + blocks.push({ + type: "bulletList", + content: [{ type: "listItem", content: [itemPara] }], + }); + } + continue; + } + + // Ordered list item + const olMatch = isMarkdown ? trimmed.match(/^\d+\.\s+(.+)$/) : null; + if (olMatch) { + flushParagraph(); + const itemPara = makeParagraph(parseInlineMarkdown(olMatch[1]!)); + const prev = blocks[blocks.length - 1]; + if (prev && prev.type === "orderedList" && Array.isArray(prev.content)) { + prev.content.push({ type: "listItem", content: [itemPara] }); + } else { + blocks.push({ + type: "orderedList", + content: [{ type: "listItem", content: [itemPara] }], + }); + } + continue; + } + + // In plain text where each line is its own paragraph, flush per line if indented or long + if (!isMarkdown) { + paraBuffer.push(trimmed); + flushParagraph(); + } else { + paraBuffer.push(trimmed); + } + } + + flushParagraph(); + return { title: fmTitle, author: fmAuthor, blocks }; +} + +// ============================================================================ +// 2. DOCX (Word / OnlyOffice OOXML) Parser +// ============================================================================ + +async function parseDocxArchive( + bytes: Uint8Array, +): Promise<{ title?: string | undefined; author?: string | undefined; blocks: PMBlockNode[] }> { + const zip = await JSZip.loadAsync(bytes); + const docFile = zip.file("word/document.xml"); + if (!docFile) { + throw new Error("Invalid .docx file: missing word/document.xml"); + } + const xml = await docFile.async("string"); + + let title: string | undefined; + let author: string | undefined; + const coreFile = zip.file("docProps/core.xml"); + if (coreFile) { + const coreXml = await coreFile.async("string"); + const tMatch = coreXml.match(/]*>([\s\S]*?)<\/dc:title>/i); + if (tMatch && tMatch[1]?.trim()) title = decodeXmlEntities(tMatch[1].trim()); + const cMatch = coreXml.match(/]*>([\s\S]*?)<\/dc:creator>/i); + if (cMatch && cMatch[1]?.trim()) author = decodeXmlEntities(cMatch[1].trim()); + } + + const blocks: PMBlockNode[] = []; + const pRegex = /]*>([\s\S]*?)<\/w:p>/g; + let pMatch: RegExpExecArray | null; + + while ((pMatch = pRegex.exec(xml)) !== null) { + const pInner = pMatch[1] ?? ""; + const styleMatch = pInner.match(/]*w:val="([^"]+)"/i); + const styleVal = styleMatch?.[1] ?? ""; + const alignMatch = pInner.match(/]*w:val="([^"]+)"/i); + const alignVal = alignMatch?.[1]; + + // Extract runs ... + const inlines: PMInlineNode[] = []; + const rRegex = /]*>([\s\S]*?)<\/w:r>/g; + let rMatch: RegExpExecArray | null; + while ((rMatch = rRegex.exec(pInner)) !== null) { + const rInner = rMatch[1] ?? ""; + const rPrMatch = rInner.match(/]*>([\s\S]*?)<\/w:rPr>/); + const rPr = rPrMatch?.[1] ?? ""; + + const marks: Array<{ type: string; attrs?: Record }> = []; + if (/]*w:val="(?:false|0)")/i.test(rPr)) marks.push({ type: "bold" }); + if (/]*w:val="(?:false|0)")/i.test(rPr)) marks.push({ type: "italic" }); + if (/]*w:val="(?:none|false|0)")/i.test(rPr)) marks.push({ type: "underline" }); + if (/]*w:val="(?:false|0)")/i.test(rPr)) marks.push({ type: "strike" }); + if (/]*w:val="superscript"/i.test(rPr)) marks.push({ type: "superscript" }); + if (/]*w:val="subscript"/i.test(rPr)) marks.push({ type: "subscript" }); + + const tRegex = /]*)?>([\s\S]*?)<\/w:t>|]*\/>|]*\/>/g; + let tMatch: RegExpExecArray | null; + let runText = ""; + while ((tMatch = tRegex.exec(rInner)) !== null) { + if (tMatch[0].startsWith(" 0) { + inlines.push({ + type: "text", + text: runText, + ...(marks.length > 0 ? { marks } : {}), + }); + } + } + + const plain = inlines.map((i) => i.text).join("").trim(); + if (!plain) continue; + + if (plain === "* * *" || plain === "***" || plain === "---") { + blocks.push({ type: "sceneBreak" }); + continue; + } + + const headingLvlMatch = styleVal.match(/^(?:Heading|heading)\s*([1-6])$/i); + if (headingLvlMatch) { + const lvl = Number.parseInt(headingLvlMatch[1]!, 10); + blocks.push(makeHeading(lvl, inlines)); + continue; + } + if (/^Title$/i.test(styleVal) && !title) { + title = plain; + continue; + } + + const attrs: Record = {}; + if (alignVal === "center" || alignVal === "right") { + attrs.textAlign = alignVal; + } else if (alignVal === "both") { + attrs.textAlign = "justify"; + } + blocks.push(makeParagraph(inlines, attrs)); + } + + return { title, author, blocks }; +} + +// ============================================================================ +// 3. ODT (OpenOffice / LibreOffice / OnlyOffice OpenDocument Text) Parser +// ============================================================================ + +async function parseOdtArchive( + bytes: Uint8Array, +): Promise<{ title?: string | undefined; author?: string | undefined; blocks: PMBlockNode[] }> { + const zip = await JSZip.loadAsync(bytes); + const contentFile = zip.file("content.xml"); + if (!contentFile) { + throw new Error("Invalid .odt file: missing content.xml"); + } + const xml = await contentFile.async("string"); + + let title: string | undefined; + let author: string | undefined; + const metaFile = zip.file("meta.xml"); + if (metaFile) { + const metaXml = await metaFile.async("string"); + const tMatch = metaXml.match(/]*>([\s\S]*?)<\/dc:title>/i); + if (tMatch && tMatch[1]?.trim()) title = decodeXmlEntities(tMatch[1].trim()); + const cMatch = metaXml.match(/]*>([\s\S]*?)<\/dc:creator>/i); + if (cMatch && cMatch[1]?.trim()) author = decodeXmlEntities(cMatch[1].trim()); + } + + // Map automatic and named styles in content.xml + styles.xml to marks + const styleMarks = new Map>(); + styleMarks.set("Bold", [{ type: "bold" }]); + styleMarks.set("Italic", [{ type: "italic" }]); + styleMarks.set("Underline", [{ type: "underline" }]); + styleMarks.set("StrikeThrough", [{ type: "strike" }]); + styleMarks.set("Code", [{ type: "code" }]); + + const styleRegex = /]*style:name="([^"]+)"[^>]*>([\s\S]*?)<\/style:style>/g; + let sMatch: RegExpExecArray | null; + while ((sMatch = styleRegex.exec(xml)) !== null) { + const sName = sMatch[1]!; + const sBody = sMatch[2] ?? ""; + const marks: Array<{ type: string }> = []; + if (/fo:font-weight="bold"|style:font-weight="bold"|style:font-weight:bold/i.test(sBody)) { + marks.push({ type: "bold" }); + } + if (/fo:font-style="italic"|style:font-style="italic"/i.test(sBody)) { + marks.push({ type: "italic" }); + } + if (/style:text-underline-style="solid"|style:text-decoration="underline"/i.test(sBody)) { + marks.push({ type: "underline" }); + } + if (/style:text-line-through-style/i.test(sBody)) { + marks.push({ type: "strike" }); + } + if (marks.length > 0) { + styleMarks.set(sName, marks); + } + } + + const parseOdtInlines = (innerXml: string): PMInlineNode[] => { + const out: PMInlineNode[] = []; + const tokenRe = /]*text:style-name="([^"]+)"[^>]*>([\s\S]*?)<\/text:span>|]*xlink:href="([^"]+)"[^>]*>([\s\S]*?)<\/text:a>|<[^>]+>|([^<]+)/g; + let m: RegExpExecArray | null; + while ((m = tokenRe.exec(innerXml)) !== null) { + if (m[1] !== undefined && m[2] !== undefined) { + const sName = m[1]; + const spanText = decodeXmlEntities(m[2].replace(/<[^>]+>/g, "")); + const marks = styleMarks.get(sName); + if (spanText) { + out.push({ + type: "text", + text: spanText, + ...(marks && marks.length > 0 ? { marks } : {}), + }); + } + } else if (m[3] !== undefined && m[4] !== undefined) { + const linkText = decodeXmlEntities(m[4].replace(/<[^>]+>/g, "")); + if (linkText) { + out.push({ + type: "text", + text: linkText, + marks: [{ type: "link", attrs: { href: m[3] } }], + }); + } + } else if (m[5] !== undefined) { + const txt = decodeXmlEntities(m[5]); + if (txt) out.push({ type: "text", text: txt }); + } + } + return out; + }; + + const blocks: PMBlockNode[] = []; + const blockRe = /<(text:h|text:p)\b([^>]*)>([\s\S]*?)<\/\1>/g; + let bMatch: RegExpExecArray | null; + + while ((bMatch = blockRe.exec(xml)) !== null) { + const tag = bMatch[1]!; + const attrsStr = bMatch[2] ?? ""; + const inner = bMatch[3] ?? ""; + const inlines = parseOdtInlines(inner); + const plain = inlines.map((i) => i.text).join("").trim(); + if (!plain) continue; + + if (plain === "* * *" || plain === "***" || plain === "---") { + blocks.push({ type: "sceneBreak" }); + continue; + } + + if (tag === "text:h") { + const lvlMatch = attrsStr.match(/text:outline-level="(\d+)"/i); + const styleMatch = attrsStr.match(/text:style-name="([^"]+)"/i); + if (styleMatch?.[1] === "Heading" && !lvlMatch && blocks.length === 0) { + if (!title) title = plain; + continue; + } + const lvl = lvlMatch ? Number.parseInt(lvlMatch[1]!, 10) : 1; + blocks.push(makeHeading(lvl, inlines)); + } else { + const styleMatch = attrsStr.match(/text:style-name="([^"]+)"/i); + if (styleMatch?.[1] === "Source_Text" && blocks.length === 0 && author && plain === author) { + continue; + } + blocks.push(makeParagraph(inlines)); + } + } + + return { title, author, blocks }; +} + +// ============================================================================ +// 4. HTML & EPUB Parser +// ============================================================================ + +function parseHtmlFragmentToBlocks(html: string): { title?: string | undefined; blocks: PMBlockNode[] } { + let docTitle: string | undefined; + const titleMatch = html.match(/]*>([\s\S]*?)<\/title>/i); + if (titleMatch && titleMatch[1]?.trim()) { + docTitle = decodeXmlEntities(titleMatch[1].replace(/<[^>]+>/g, "").trim()); + } + + // Strip head, script, style + const bodyMatch = html.match(/]*>([\s\S]*?)<\/body>/i); + const content = (bodyMatch ? bodyMatch[1]! : html) + .replace(/<(script|style|head|nav)\b[^>]*>[\s\S]*?<\/\1>/gi, ""); + + const parseHtmlInlines = (fragment: string): PMInlineNode[] => { + const out: PMInlineNode[] = []; + const tokenRe = + /<(strong|b|em|i|u|s|strike|del|code|mark|sup|sub)\b[^>]*>([\s\S]*?)<\/\1>||<[^>]+>|([^<]+)/gi; + let m: RegExpExecArray | null; + while ((m = tokenRe.exec(fragment)) !== null) { + if (m[1] !== undefined && m[2] !== undefined) { + const tag = m[1].toLowerCase(); + const innerText = decodeXmlEntities(m[2].replace(/<[^>]+>/g, "")); + if (!innerText) continue; + const markType = + tag === "strong" || tag === "b" + ? "bold" + : tag === "em" || tag === "i" + ? "italic" + : tag === "u" + ? "underline" + : tag === "s" || tag === "strike" || tag === "del" + ? "strike" + : tag === "code" + ? "code" + : tag === "mark" + ? "highlight" + : tag === "sup" + ? "superscript" + : "subscript"; + out.push({ + type: "text", + text: innerText, + marks: [{ type: markType }], + }); + } else if (m[0].toLowerCase().startsWith("]*?)(?:\/>|>([\s\S]*?)<\/\1>)/gi; + let match: RegExpExecArray | null; + + while ((match = blockRe.exec(content)) !== null) { + const tag = match[1]!.toLowerCase(); + const inner = match[3] ?? ""; + + if (tag === "hr") { + blocks.push({ type: "sceneBreak" }); + continue; + } + + const inlines = parseHtmlInlines(inner); + const plain = inlines.map((i) => i.text).join("").trim(); + if (!plain) continue; + + if (plain === "* * *" || plain === "***" || plain === "---") { + blocks.push({ type: "sceneBreak" }); + continue; + } + + if (tag.startsWith("h")) { + const lvl = Number.parseInt(tag.slice(1), 10); + blocks.push(makeHeading(lvl, inlines)); + } else if (tag === "blockquote") { + blocks.push({ + type: "blockquote", + content: [makeParagraph(inlines)], + }); + } else { + blocks.push(makeParagraph(inlines)); + } + } + + if (blocks.length === 0) { + const stripped = decodeXmlEntities(content.replace(/<[^>]+>/g, "\n")).trim(); + if (stripped) { + return parseMarkdownOrTextBlocks(stripped, false); + } + } + + return { title: docTitle, blocks }; +} + +async function parseEpubArchive( + bytes: Uint8Array, + fallbackTitle: string, +): Promise<{ title: string; author?: string | undefined; chapters: ImportedChapter[] }> { + const zip = await JSZip.loadAsync(bytes); + + let opfPath = "OEBPS/content.opf"; + const containerFile = zip.file("META-INF/container.xml"); + if (containerFile) { + const containerXml = await containerFile.async("string"); + const rootfileMatch = containerXml.match(/full-path="([^"]+)"/i); + if (rootfileMatch?.[1]) opfPath = rootfileMatch[1]; + } + + const opfFile = zip.file(opfPath); + const opfDir = opfPath.includes("/") ? opfPath.slice(0, opfPath.lastIndexOf("/") + 1) : ""; + let bookTitle = fallbackTitle; + let author: string | undefined; + const chapterFiles: string[] = []; + + if (opfFile) { + const opfXml = await opfFile.async("string"); + const tMatch = opfXml.match(/]*>([\s\S]*?)<\/dc:title>/i); + if (tMatch?.[1]?.trim()) bookTitle = decodeXmlEntities(tMatch[1].trim()); + const cMatch = opfXml.match(/]*>([\s\S]*?)<\/dc:creator>/i); + if (cMatch?.[1]?.trim()) author = decodeXmlEntities(cMatch[1].trim()); + + const manifest = new Map(); + const itemRe = /]+)\/?>/gi; + let iMatch: RegExpExecArray | null; + while ((iMatch = itemRe.exec(opfXml)) !== null) { + const attrs = iMatch[1] ?? ""; + const id = attrs.match(/\bid="([^"]+)"/i)?.[1]; + const href = attrs.match(/\bhref="([^"]+)"/i)?.[1]; + const media = attrs.match(/\bmedia-type="([^"]+)"/i)?.[1] ?? ""; + if ( + id && + href && + (media.includes("html") || href.endsWith(".xhtml") || href.endsWith(".html")) && + !/^(?:nav|toc|title|cover)\.(?:x?html)$/i.test(href.replace(/^.*\//, "")) + ) { + manifest.set(id, opfDir + href); + } + } + + const itemrefRe = /]*idref="([^"]+)"/gi; + let refMatch: RegExpExecArray | null; + while ((refMatch = itemrefRe.exec(opfXml)) !== null) { + const target = manifest.get(refMatch[1]!); + if (target) chapterFiles.push(target); + } + + if (chapterFiles.length === 0) { + for (const path of manifest.values()) { + if (!path.toLowerCase().includes("nav") && !path.toLowerCase().includes("toc")) { + chapterFiles.push(path); + } + } + } + } + + if (chapterFiles.length === 0) { + zip.forEach((relPath) => { + if ( + (relPath.endsWith(".xhtml") || relPath.endsWith(".html")) && + !relPath.toLowerCase().includes("nav") && + !relPath.toLowerCase().includes("toc") + ) { + chapterFiles.push(relPath); + } + }); + } + + const chapters: ImportedChapter[] = []; + for (const [idx, chPath] of chapterFiles.entries()) { + const file = zip.file(chPath); + if (!file) continue; + const xhtml = await file.async("string"); + const parsed = parseHtmlFragmentToBlocks(xhtml); + if (parsed.blocks.length === 0) continue; + + let chTitle = parsed.title || `Chapter ${idx + 1}`; + let bodyBlocks = parsed.blocks; + const firstBlock = bodyBlocks[0]; + if (firstBlock && firstBlock.type === "heading") { + const headingText = extractBlockPlainText(firstBlock); + if (headingText) { + chTitle = headingText; + bodyBlocks = bodyBlocks.slice(1); + } + } + if (bodyBlocks.length === 0) { + bodyBlocks = [{ type: "paragraph", content: [{ type: "text", text: "" }] }]; + } + const doc: ProseMirrorDoc = { type: "doc", content: bodyBlocks }; + chapters.push({ + title: chTitle, + doc, + wordCount: countDocWords(doc), + }); + } + + return { title: bookTitle, author, chapters }; +} + +// ============================================================================ +// 5. RTF (Rich Text Format) Parser +// ============================================================================ + +function parseRtfToText(rtf: string): string { + // Remove header groups like fonttbl, colortbl, stylesheet, info + let cleaned = rtf.replace(/\{\\(?:fonttbl|colortbl|stylesheet|info|pict|\*\\[a-z0-9]+)[\s\S]*?\}/gi, ""); + cleaned = cleaned + .replace(/\\par\b\s*/gi, "\n\n") + .replace(/\\line\b\s*/gi, "\n") + .replace(/\\tab\b\s*/gi, "\t") + .replace(/\\'([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))) + .replace(/\\u(-?\d+)\??/g, (_, code) => { + const n = Number.parseInt(code, 10); + return String.fromCodePoint(n < 0 ? n + 65536 : n); + }) + .replace(/\\[a-zA-Z]+-?\d*\s?/g, "") + .replace(/[{}]/g, ""); + return cleaned.trim(); +} + +// ============================================================================ +// 6. Main Entry Point: `importManuscriptFile` +// ============================================================================ + +function toUint8Array(data: Uint8Array | ArrayBuffer | string): Uint8Array { + if (data instanceof Uint8Array) return data; + if (data instanceof ArrayBuffer) return new Uint8Array(data); + return new TextEncoder().encode(data); +} + +function toUtf8String(data: Uint8Array | ArrayBuffer | string): string { + if (typeof data === "string") return data; + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + return new TextDecoder("utf-8").decode(bytes); +} + +export async function importManuscriptFile( + fileName: string, + data: Uint8Array | ArrayBuffer | string, + options?: ImportOptions, +): Promise { + const format = detectImportFormat(fileName); + const formatLabel = formatLabelFor(format); + const fallbackTitle = options?.defaultTitle?.trim() || titleFromFileName(fileName); + const splitChapters = options?.splitChapters ?? true; + + if (format === "json") { + const raw = JSON.parse(toUtf8String(data)) as Record; + // Support full PubScript project.json bundle + if (raw.project && Array.isArray(raw.chapters)) { + const proj = raw.project as { title?: string }; + const rawChapters = raw.chapters as Array<{ title?: string; doc?: ProseMirrorDoc }>; + const chapters: ImportedChapter[] = rawChapters.map((c, idx) => { + const doc: ProseMirrorDoc = c.doc ?? { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }], + }; + return { + title: c.title || `Chapter ${idx + 1}`, + doc, + wordCount: countDocWords(doc), + }; + }); + return { + format, + formatLabel, + title: proj.title || fallbackTitle, + chapters, + totalWords: chapters.reduce((s, c) => s + c.wordCount, 0), + entities: Array.isArray(raw.entities) ? (raw.entities as Entity[]) : undefined, + plotBeats: Array.isArray(raw.plotBeats) ? (raw.plotBeats as PlotBeat[]) : undefined, + board: Array.isArray(raw.boards) && raw.boards[0] ? (raw.boards[0] as Board) : undefined, + }; + } + // Single ProseMirror JSON doc + if (raw.type === "doc" && Array.isArray(raw.content)) { + const doc = raw as unknown as ProseMirrorDoc; + const wc = countDocWords(doc); + return { + format, + formatLabel, + title: fallbackTitle, + chapters: [{ title: fallbackTitle, doc, wordCount: wc }], + totalWords: wc, + }; + } + throw new Error("Unrecognized JSON structure. Expected a PubScript project.json bundle or ProseMirror doc."); + } + + if (format === "epub") { + const epubRes = await parseEpubArchive(toUint8Array(data), fallbackTitle); + const chapters = + epubRes.chapters.length > 0 + ? epubRes.chapters + : [ + { + title: fallbackTitle, + doc: { type: "doc", content: [{ type: "paragraph" }] } as ProseMirrorDoc, + wordCount: 0, + }, + ]; + return { + format, + formatLabel, + title: epubRes.title || fallbackTitle, + author: epubRes.author, + chapters, + totalWords: chapters.reduce((s, c) => s + c.wordCount, 0), + }; + } + + if (format === "docx") { + const docxRes = await parseDocxArchive(toUint8Array(data)); + const chapters = splitBlocksIntoChapters( + docxRes.blocks, + docxRes.title || fallbackTitle, + splitChapters, + ); + return { + format, + formatLabel, + title: docxRes.title || fallbackTitle, + author: docxRes.author, + chapters, + totalWords: chapters.reduce((s, c) => s + c.wordCount, 0), + }; + } + + if (format === "odt") { + const odtRes = await parseOdtArchive(toUint8Array(data)); + const chapters = splitBlocksIntoChapters( + odtRes.blocks, + odtRes.title || fallbackTitle, + splitChapters, + ); + return { + format, + formatLabel, + title: odtRes.title || fallbackTitle, + author: odtRes.author, + chapters, + totalWords: chapters.reduce((s, c) => s + c.wordCount, 0), + }; + } + + if (format === "html") { + const htmlRes = parseHtmlFragmentToBlocks(toUtf8String(data)); + const chapters = splitBlocksIntoChapters( + htmlRes.blocks, + htmlRes.title || fallbackTitle, + splitChapters, + ); + return { + format, + formatLabel, + title: htmlRes.title || fallbackTitle, + chapters, + totalWords: chapters.reduce((s, c) => s + c.wordCount, 0), + }; + } + + if (format === "rtf") { + const plain = parseRtfToText(toUtf8String(data)); + const rtfRes = parseMarkdownOrTextBlocks(plain, false); + const chapters = splitBlocksIntoChapters(rtfRes.blocks, fallbackTitle, splitChapters); + return { + format, + formatLabel, + title: fallbackTitle, + chapters, + totalWords: chapters.reduce((s, c) => s + c.wordCount, 0), + }; + } + + // .md or .txt + const textRes = parseMarkdownOrTextBlocks(toUtf8String(data), format === "md"); + const chapters = splitBlocksIntoChapters( + textRes.blocks, + textRes.title || fallbackTitle, + splitChapters, + ); + return { + format, + formatLabel, + title: textRes.title || fallbackTitle, + author: textRes.author, + chapters, + totalWords: chapters.reduce((s, c) => s + c.wordCount, 0), + }; +} diff --git a/packages/exporters/src/index.ts b/packages/exporters/src/index.ts index f6bfbbb..2246baf 100644 --- a/packages/exporters/src/index.ts +++ b/packages/exporters/src/index.ts @@ -132,3 +132,4 @@ export { exportEpub } from "./epub.js"; export { exportDocx } from "./docx.js"; export { exportOdt } from "./odt.js"; export { exportPdf } from "./pdf.js"; +export * from "./importer.js";