feat(import): add universal manuscript import pipeline (.docx, .odt, .epub, .md, .txt, .html, .rtf, .json)
CI / build (push) Waiting to run

This commit is contained in:
Schwitzz
2026-10-30 16:51:33 -05:00
parent aee9c428fc
commit 020563ed85
8 changed files with 1788 additions and 5 deletions
+8 -1
View File
@@ -3,6 +3,7 @@ import { Routes, Route } from "react-router-dom";
import { Sidebar, Topbar, CommandPalette, SplashScreen } from "./components/index.js"; import { Sidebar, Topbar, CommandPalette, SplashScreen } from "./components/index.js";
import { AuthGate } from "./components/AuthGate.js"; import { AuthGate } from "./components/AuthGate.js";
import { ExportDialog } from "./features/export/ExportDialog.js"; import { ExportDialog } from "./features/export/ExportDialog.js";
import { ImportDialog } from "./features/import/ImportDialog.js";
import { AISidebar } from "./features/ai/AISidebar.js"; import { AISidebar } from "./features/ai/AISidebar.js";
import { ToastViewport } from "@pubscript/ui"; import { ToastViewport } from "@pubscript/ui";
import { useSettings } from "./store/settings.js"; import { useSettings } from "./store/settings.js";
@@ -30,6 +31,7 @@ const NAV = [
export default function App() { export default function App() {
const [paletteOpen, setPaletteOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const navCollapsed = useSettings((s) => s.navCollapsed); const navCollapsed = useSettings((s) => s.navCollapsed);
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen); const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
const focusMode = useSettings((s) => s.focusMode); const focusMode = useSettings((s) => s.focusMode);
@@ -50,7 +52,11 @@ export default function App() {
<div className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}`}> <div className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}`}>
<SplashScreen /> <SplashScreen />
<Sidebar nav={NAV} /> <Sidebar nav={NAV} />
<Topbar onOpenPalette={() => setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} /> <Topbar
onOpenPalette={() => setPaletteOpen(true)}
onOpenExport={() => setExportOpen(true)}
onOpenImport={() => setImportOpen(true)}
/>
<div className="ps-workspace-row"> <div className="ps-workspace-row">
<main className="ps-main"> <main className="ps-main">
<Routes> <Routes>
@@ -70,6 +76,7 @@ export default function App() {
</div> </div>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} /> <CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} nav={NAV} />
<ImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
<ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} /> <ExportDialog open={exportOpen} onClose={() => setExportOpen(false)} />
<ToastViewport /> <ToastViewport />
</div> </div>
+14 -1
View File
@@ -21,6 +21,7 @@ const TITLES: Record<string, string> = {
export interface TopbarProps { export interface TopbarProps {
onOpenPalette?: () => void; onOpenPalette?: () => void;
onOpenExport: () => void; onOpenExport: () => void;
onOpenImport?: () => void;
} }
function entityName(e: Entity): string { function entityName(e: Entity): string {
@@ -28,7 +29,7 @@ function entityName(e: Entity): string {
return (e.body || "Untitled note").slice(0, 48); return (e.body || "Untitled note").slice(0, 48);
} }
export function Topbar({ onOpenExport }: TopbarProps) { export function Topbar({ onOpenExport, onOpenImport }: TopbarProps) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
@@ -176,6 +177,18 @@ export function Topbar({ onOpenExport }: TopbarProps) {
<span>✨ AI</span> <span>✨ AI</span>
</button> </button>
{onOpenImport && (
<button
type="button"
className="ps-nav-item"
onClick={onOpenImport}
style={{ width: "auto", padding: "6px 10px" }}
title="Import manuscript (.docx, .odt, .epub, .md, .txt, .html, .rtf, .json)"
>
<span>Import</span>
</button>
)}
<button <button
type="button" type="button"
className="ps-nav-item" className="ps-nav-item"
@@ -0,0 +1,361 @@
import React, { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge, Button, Modal, toast } from "@pubscript/ui";
import {
importManuscriptFile,
SUPPORTED_IMPORT_EXTENSIONS,
type ImportedManuscript,
} from "@pubscript/exporters";
import { useProjectStore } from "../../store/project-store.js";
export interface ImportDialogProps {
open: boolean;
onClose: () => void;
defaultMode?: "new-project" | "append-chapters";
}
export function ImportDialog({
open,
onClose,
defaultMode = "new-project",
}: ImportDialogProps) {
const navigate = useNavigate();
const currentProject = useProjectStore((s) => s.project);
const importManuscript = useProjectStore((s) => s.importManuscript);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [rawBytes, setRawBytes] = useState<Uint8Array | null>(null);
const [parsed, setParsed] = useState<ImportedManuscript | null>(null);
const [mode, setMode] = useState<"new-project" | "append-chapters">(defaultMode);
const [customTitle, setCustomTitle] = useState("");
const [splitChapters, setSplitChapters] = useState(true);
const [parsing, setParsing] = useState(false);
const [importing, setImporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const runParse = async (fileName: string, bytes: Uint8Array, split: boolean) => {
setParsing(true);
setError(null);
try {
const res = await importManuscriptFile(fileName, bytes, {
splitChapters: split,
});
setParsed(res);
setCustomTitle((prev) => prev || res.title);
} catch (err) {
setParsed(null);
setError(err instanceof Error ? err.message : "Failed to parse manuscript file.");
} finally {
setParsing(false);
}
};
const handleFileChange = async (file: File | null) => {
if (!file) return;
setSelectedFile(file);
setCustomTitle("");
const buf = await file.arrayBuffer();
const bytes = new Uint8Array(buf);
setRawBytes(bytes);
await runParse(file.name, bytes, splitChapters);
};
const handleToggleSplit = async (nextSplit: boolean) => {
setSplitChapters(nextSplit);
if (selectedFile && rawBytes) {
await runParse(selectedFile.name, rawBytes, nextSplit);
}
};
const handleConfirmImport = async () => {
if (!parsed) return;
setImporting(true);
setError(null);
try {
await importManuscript(parsed, {
mode: currentProject ? mode : "new-project",
title: customTitle.trim() || parsed.title,
});
toast.success(
mode === "append-chapters" && currentProject
? `Imported ${parsed.chapters.length} chapter(s) (${parsed.totalWords.toLocaleString()} words) into "${currentProject.title}"`
: `Created project "${customTitle.trim() || parsed.title}" (${parsed.chapters.length} chapter(s), ${parsed.totalWords.toLocaleString()} words)`,
);
setSelectedFile(null);
setRawBytes(null);
setParsed(null);
setCustomTitle("");
onClose();
navigate("/");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to import manuscript.");
} finally {
setImporting(false);
}
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const f = e.dataTransfer.files?.[0];
if (f) void handleFileChange(f);
};
return (
<Modal
open={open}
title="Import Manuscript from File"
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={importing}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void handleConfirmImport()}
disabled={!parsed || parsing || importing}
>
{importing
? "Converting & Saving…"
: mode === "append-chapters" && currentProject
? `Import ${parsed?.chapters.length ?? 0} Chapter(s)`
: "Import as New Project"}
</Button>
</>
}
>
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<div
onDragOver={(e) => 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",
}}
>
<input
ref={fileInputRef}
type="file"
accept={SUPPORTED_IMPORT_EXTENSIONS.join(",")}
style={{ display: "none" }}
onChange={(e) => void handleFileChange(e.target.files?.[0] ?? null)}
/>
<div style={{ fontSize: 22, marginBottom: 6 }}>📥</div>
<div style={{ fontWeight: 600, fontSize: 13.5, marginBottom: 4 }}>
{selectedFile
? `${selectedFile.name} (${(selectedFile.size / 1024).toFixed(1)} KB)`
: "Click to choose a file or drag & drop here"}
</div>
<div style={{ fontSize: 11.5, color: "var(--ps-text-muted)" }}>
Compatible formats: <strong>.docx</strong> (Word / OnlyOffice), <strong>.odt</strong>{" "}
(OpenOffice / LibreOffice / OnlyOffice), <strong>.epub</strong>, <strong>.md</strong>,{" "}
<strong>.txt</strong>, <strong>.html</strong>, <strong>.rtf</strong>,{" "}
<strong>.json</strong>
</div>
</div>
{error && (
<div
role="alert"
style={{
padding: "10px 12px",
borderRadius: 8,
border: "1px solid var(--ps-danger)",
background: "color-mix(in srgb, var(--ps-danger) 14%, transparent)",
color: "var(--ps-danger)",
fontSize: 12.5,
}}
>
{error}
</div>
)}
{parsing && (
<div style={{ fontSize: 12.5, color: "var(--ps-text-muted)", textAlign: "center" }}>
Running PubScript format conversion pipeline…
</div>
)}
{parsed && !parsing && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 12,
padding: 14,
borderRadius: 10,
border: "1px solid var(--ps-border)",
background: "var(--ps-surface)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Badge variant="accent">{parsed.formatLabel}</Badge>
<span style={{ fontSize: 12, color: "var(--ps-text-muted)" }}>
{parsed.chapters.length} chapter(s) · {parsed.totalWords.toLocaleString()} words
</span>
</div>
{parsed.author && (
<span style={{ fontSize: 11.5, color: "var(--ps-text-muted)" }}>
Author: {parsed.author}
</span>
)}
</div>
{currentProject && (
<div className="ps-field">
<label className="ps-field-label">Import Destination</label>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
onClick={() => setMode("new-project")}
style={{
flex: 1,
padding: "8px 10px",
borderRadius: 8,
border:
mode === "new-project"
? "2px solid var(--ps-accent)"
: "1px solid var(--ps-border)",
background:
mode === "new-project"
? "color-mix(in srgb, var(--ps-accent) 14%, var(--ps-bg))"
: "var(--ps-bg)",
color: "var(--ps-text)",
fontSize: 12.5,
fontWeight: mode === "new-project" ? 600 : 500,
cursor: "pointer",
}}
>
✨ Create New Project
</button>
<button
type="button"
onClick={() => setMode("append-chapters")}
style={{
flex: 1,
padding: "8px 10px",
borderRadius: 8,
border:
mode === "append-chapters"
? "2px solid var(--ps-accent)"
: "1px solid var(--ps-border)",
background:
mode === "append-chapters"
? "color-mix(in srgb, var(--ps-accent) 14%, var(--ps-bg))"
: "var(--ps-bg)",
color: "var(--ps-text)",
fontSize: 12.5,
fontWeight: mode === "append-chapters" ? 600 : 500,
cursor: "pointer",
}}
>
📖 Add Chapters to &ldquo;{currentProject.title}&rdquo;
</button>
</div>
</div>
)}
{mode === "new-project" && (
<div className="ps-field">
<label className="ps-field-label" htmlFor="ps-import-title">
Project Title
</label>
<input
id="ps-import-title"
type="text"
className="ps-input"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder="Manuscript Title"
/>
</div>
)}
{parsed.format !== "epub" && parsed.format !== "json" && (
<label
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
fontSize: 12.5,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={splitChapters}
onChange={(e) => void handleToggleSplit(e.target.checked)}
/>
<span>
Automatically split into chapters on headings (<code>H1</code> /{" "}
<code>Chapter N</code>)
</span>
</label>
)}
<div>
<div
style={{
fontSize: 11.5,
fontWeight: 600,
color: "var(--ps-text-muted)",
marginBottom: 6,
}}
>
Converted Chapters Preview ({parsed.chapters.length})
</div>
<div
style={{
maxHeight: 160,
overflowY: "auto",
border: "1px solid var(--ps-border)",
borderRadius: 8,
background: "var(--ps-bg)",
padding: 6,
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
{parsed.chapters.map((ch, idx) => (
<div
key={idx}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "5px 8px",
borderRadius: 6,
fontSize: 12.5,
background: "var(--ps-surface)",
}}
>
<span style={{ fontWeight: 500 }}>
{idx + 1}. {ch.title}
</span>
<span
style={{
fontFamily: "var(--ps-font-mono)",
fontSize: 11.5,
color: "var(--ps-text-muted)",
}}
>
{ch.wordCount.toLocaleString()} words
</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
</Modal>
);
}
+15 -3
View File
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { Badge, Button, EmptyState, Input, Modal, Select, toast } from "@pubscript/ui"; import { Badge, Button, EmptyState, Input, Modal, Select, toast } from "@pubscript/ui";
import { useProjectStore } from "../store/project-store.js"; import { useProjectStore } from "../store/project-store.js";
import { PLOT_TEMPLATES } from "../features/plot/templates.js"; import { PLOT_TEMPLATES } from "../features/plot/templates.js";
import { ImportDialog } from "../features/import/ImportDialog.js";
type WizardMode = "tutorial" | "manual"; type WizardMode = "tutorial" | "manual";
@@ -88,6 +89,7 @@ export function Projects() {
} = useProjectStore(); } = useProjectStore();
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [wizardMode, setWizardMode] = useState<WizardMode>("tutorial"); const [wizardMode, setWizardMode] = useState<WizardMode>("tutorial");
const [tutorialStep, setTutorialStep] = useState<1 | 2 | 3 | 4>(1); const [tutorialStep, setTutorialStep] = useState<1 | 2 | 3 | 4>(1);
const [answers, setAnswers] = useState<TutorialAnswers>({ const [answers, setAnswers] = useState<TutorialAnswers>({
@@ -200,9 +202,14 @@ export function Projects() {
Switch between manuscripts, start a guided story template, or manage existing projects. Switch between manuscripts, start a guided story template, or manage existing projects.
</p> </p>
</div> </div>
<Button variant="primary" onClick={openWizard}> <div style={{ display: "flex", gap: 8 }}>
+ New project <Button variant="secondary" onClick={() => setImportOpen(true)}>
</Button> 📥 Import from File
</Button>
<Button variant="primary" onClick={openWizard}>
+ New project
</Button>
</div>
</div> </div>
{projects.length === 0 ? ( {projects.length === 0 ? (
@@ -613,6 +620,11 @@ export function Projects() {
)} )}
</Modal> </Modal>
)} )}
<ImportDialog
open={importOpen}
onClose={() => setImportOpen(false)}
defaultMode="new-project"
/>
</div> </div>
); );
} }
+109
View File
@@ -22,6 +22,7 @@ import {
type ProjectMeta, type ProjectMeta,
type StorageAdapter, type StorageAdapter,
} from "@pubscript/core"; } from "@pubscript/core";
import type { ImportedManuscript } from "@pubscript/exporters";
import { storageLocal } from "@pubscript/core/local"; import { storageLocal } from "@pubscript/core/local";
import { createUlid } from "@pubscript/core"; import { createUlid } from "@pubscript/core";
import { create } from "zustand"; import { create } from "zustand";
@@ -113,6 +114,11 @@ interface ProjectStore {
openProject: (id: string) => Promise<void>; openProject: (id: string) => Promise<void>;
/** Create a new project with optional structural template and chapter skeleton. */ /** Create a new project with optional structural template and chapter skeleton. */
createProjectWithScaffold: (opts: CreateProjectScaffoldOptions) => Promise<ProjectMeta>; createProjectWithScaffold: (opts: CreateProjectScaffoldOptions) => Promise<ProjectMeta>;
/** 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<void>;
/** Delete a project by id. */ /** Delete a project by id. */
deleteProject: (id: string) => Promise<void>; deleteProject: (id: string) => Promise<void>;
@@ -632,6 +638,109 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
return meta; 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) => { deleteProject: async (id: string) => {
const adapter = get().adapter ?? storageLocal; const adapter = get().adapter ?? storageLocal;
if (adapter.deleteProject) { if (adapter.deleteProject) {
+173
View File
@@ -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 = "<html><head><title>Sea Wall</title></head><body><h1>Chapter 1</h1><p>Waves crashed against the <strong>stone</strong>.</p></body></html>";
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");
});
});
File diff suppressed because it is too large Load Diff
+1
View File
@@ -132,3 +132,4 @@ export { exportEpub } from "./epub.js";
export { exportDocx } from "./docx.js"; export { exportDocx } from "./docx.js";
export { exportOdt } from "./odt.js"; export { exportOdt } from "./odt.js";
export { exportPdf } from "./pdf.js"; export { exportPdf } from "./pdf.js";
export * from "./importer.js";