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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user