From 020563ed85030caad71f4109a9e603b96de9b0f1 Mon Sep 17 00:00:00 2001
From: Schwitzz
Date: Fri, 30 Oct 2026 16:51:33 -0500
Subject: [PATCH] feat(import): add universal manuscript import pipeline
(.docx, .odt, .epub, .md, .txt, .html, .rtf, .json)
---
apps/web/src/App.tsx | 9 +-
apps/web/src/components/Topbar.tsx | 15 +-
apps/web/src/features/import/ImportDialog.tsx | 361 ++++++
apps/web/src/routes/Projects.tsx | 18 +-
apps/web/src/store/project-store.ts | 109 ++
packages/exporters/src/importer.test.ts | 173 +++
packages/exporters/src/importer.ts | 1107 +++++++++++++++++
packages/exporters/src/index.ts | 1 +
8 files changed, 1788 insertions(+), 5 deletions(-)
create mode 100644 apps/web/src/features/import/ImportDialog.tsx
create mode 100644 packages/exporters/src/importer.test.ts
create mode 100644 packages/exporters/src/importer.ts
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 && (
+
+ )}
+
-
- + New project
-
+
+ setImportOpen(true)}>
+ 📥 Import from File
+
+
+ + New project
+
+
{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 WallChapter 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(/([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";