feat(import): add universal manuscript import pipeline (.docx, .odt, .epub, .md, .txt, .html, .rtf, .json)
CI / build (push) Waiting to run
CI / build (push) Waiting to run
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 “{currentProject.title}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,10 +202,15 @@ 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>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Button variant="secondary" onClick={() => setImportOpen(true)}>
|
||||||
|
📥 Import from File
|
||||||
|
</Button>
|
||||||
<Button variant="primary" onClick={openWizard}>
|
<Button variant="primary" onClick={openWizard}>
|
||||||
+ New project
|
+ New project
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -613,6 +620,11 @@ export function Projects() {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
<ImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onClose={() => setImportOpen(false)}
|
||||||
|
defaultMode="new-project"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user