237 lines
6.5 KiB
TypeScript
237 lines
6.5 KiB
TypeScript
import type { PlaitElement, Point } from "@plait/core";
|
|
import { MindLayoutType } from "@plait/layouts";
|
|
import {
|
|
createMindElement,
|
|
type MindElement,
|
|
type PlaitMind,
|
|
} from "@plait/mind";
|
|
|
|
import { importXmind, type ImportedMindMap } from "./xmind-import";
|
|
export { MindMapImportError } from "./xmind-import";
|
|
|
|
export const FLOW_BOARD_MIND_MAP_IMPORT_ACCEPT =
|
|
".md,.markdown,.mdown,.mkd,.opml,.mm,.xmind,text/markdown,text/plain,text/xml,application/xml";
|
|
|
|
type MindMapImportFormat = "markdown" | "opml" | "freemind" | "xmind";
|
|
|
|
type OutlineNode = {
|
|
text: string;
|
|
children: OutlineNode[];
|
|
};
|
|
|
|
function normalizeText(value: string | null | undefined) {
|
|
return String(value || "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
export function getMindMapImportTitle(fileName: string) {
|
|
const title = fileName
|
|
.replace(/\.(md|markdown|mdown|mkd|opml|mm|xmind)$/i, "")
|
|
.trim();
|
|
return title || "导入思维导图";
|
|
}
|
|
|
|
function detectMindMapImportFormat(file: File): MindMapImportFormat | null {
|
|
const fileName = file.name.toLowerCase();
|
|
if (
|
|
fileName.endsWith(".md") ||
|
|
fileName.endsWith(".markdown") ||
|
|
fileName.endsWith(".mdown") ||
|
|
fileName.endsWith(".mkd")
|
|
) {
|
|
return "markdown";
|
|
}
|
|
if (fileName.endsWith(".opml")) {
|
|
return "opml";
|
|
}
|
|
if (fileName.endsWith(".mm")) {
|
|
return "freemind";
|
|
}
|
|
if (fileName.endsWith(".xmind")) {
|
|
return "xmind";
|
|
}
|
|
if (file.type === "text/markdown" || file.type === "text/plain") {
|
|
return "markdown";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function isSupportedMindMapImportFile(file: File) {
|
|
return Boolean(detectMindMapImportFormat(file));
|
|
}
|
|
|
|
function hasMarkdownHeading(markdownText: string) {
|
|
return /^\s{0,3}#{1,6}\s+\S+/m.test(markdownText);
|
|
}
|
|
|
|
function parseXmlDocument(xmlText: string, formatLabel: string) {
|
|
if (typeof DOMParser === "undefined") {
|
|
throw new Error(`${formatLabel} import requires DOMParser`);
|
|
}
|
|
|
|
const document = new DOMParser().parseFromString(xmlText, "application/xml");
|
|
if (document.getElementsByTagName("parsererror").length > 0) {
|
|
throw new Error(`${formatLabel} XML parse failed`);
|
|
}
|
|
return document;
|
|
}
|
|
|
|
function getDirectChildrenByTag(element: Element, tagName: string) {
|
|
const normalizedTagName = tagName.toLowerCase();
|
|
return Array.from(element.children).filter(
|
|
(child) => child.tagName.toLowerCase() === normalizedTagName
|
|
);
|
|
}
|
|
|
|
function createMindFromOutline(root: OutlineNode): PlaitMind {
|
|
const build = (node: OutlineNode): MindElement => {
|
|
const element = createMindElement(node.text || "未命名节点", {});
|
|
element.children = node.children.map(build);
|
|
return element;
|
|
};
|
|
|
|
const mind = createMindElement(root.text || "中心主题", {
|
|
layout: MindLayoutType.right,
|
|
}) as PlaitMind;
|
|
mind.isRoot = true;
|
|
mind.type = "mindmap";
|
|
mind.points = [[0, 0]] as Point[];
|
|
mind.children = root.children.map(build);
|
|
return mind;
|
|
}
|
|
|
|
function parseOpmlOutlineNode(element: Element): OutlineNode {
|
|
const text =
|
|
normalizeText(element.getAttribute("text")) ||
|
|
normalizeText(element.getAttribute("title")) ||
|
|
"未命名节点";
|
|
|
|
return {
|
|
text,
|
|
children: getDirectChildrenByTag(element, "outline").map(
|
|
parseOpmlOutlineNode
|
|
),
|
|
};
|
|
}
|
|
|
|
function parseOpmlToMind(xmlText: string, fallbackTitle: string): PlaitMind {
|
|
const document = parseXmlDocument(xmlText, "OPML");
|
|
const body = document.getElementsByTagName("body")[0];
|
|
if (!body) {
|
|
throw new Error("OPML body not found");
|
|
}
|
|
|
|
const headTitle = normalizeText(
|
|
document.getElementsByTagName("title")[0]?.textContent
|
|
);
|
|
const topOutlines = getDirectChildrenByTag(body, "outline").map(
|
|
parseOpmlOutlineNode
|
|
);
|
|
if (topOutlines.length === 0) {
|
|
throw new Error("OPML outline not found");
|
|
}
|
|
|
|
const root =
|
|
topOutlines.length === 1
|
|
? topOutlines[0]
|
|
: {
|
|
text: headTitle || fallbackTitle || "OPML 思维导图",
|
|
children: topOutlines,
|
|
};
|
|
|
|
return createMindFromOutline(root);
|
|
}
|
|
|
|
function getFreeMindNodeText(element: Element) {
|
|
const attrText = normalizeText(element.getAttribute("TEXT"));
|
|
if (attrText) {
|
|
return attrText;
|
|
}
|
|
|
|
const richContent = getDirectChildrenByTag(element, "richcontent").find(
|
|
(child) => (child.getAttribute("TYPE") || "").toUpperCase() === "NODE"
|
|
);
|
|
return normalizeText(richContent?.textContent) || "未命名节点";
|
|
}
|
|
|
|
function parseFreeMindNode(element: Element): OutlineNode {
|
|
return {
|
|
text: getFreeMindNodeText(element),
|
|
children: getDirectChildrenByTag(element, "node").map(parseFreeMindNode),
|
|
};
|
|
}
|
|
|
|
function parseFreeMindToMind(xmlText: string): PlaitMind {
|
|
const document = parseXmlDocument(xmlText, "FreeMind");
|
|
const rootElement = document.documentElement;
|
|
const rootNode =
|
|
rootElement.tagName.toLowerCase() === "map"
|
|
? getDirectChildrenByTag(rootElement, "node")[0]
|
|
: rootElement.tagName.toLowerCase() === "node"
|
|
? rootElement
|
|
: null;
|
|
|
|
if (!rootNode) {
|
|
throw new Error("FreeMind root node not found");
|
|
}
|
|
|
|
return createMindFromOutline(parseFreeMindNode(rootNode));
|
|
}
|
|
|
|
async function parseOutlineFileToMind(
|
|
file: File,
|
|
fallbackTitle = getMindMapImportTitle(file.name)
|
|
): Promise<PlaitElement & { points?: Point[] }> {
|
|
const format = detectMindMapImportFormat(file);
|
|
if (!format) {
|
|
throw new Error("Unsupported mind map import file");
|
|
}
|
|
|
|
|
|
const fileText = await file.text();
|
|
if (!fileText.trim()) {
|
|
throw new Error("Mind map import file is empty");
|
|
}
|
|
|
|
if (format === "markdown") {
|
|
const { parseMarkdownToDrawnix } = await import(
|
|
"@plait-board/markdown-to-drawnix"
|
|
);
|
|
return parseMarkdownToDrawnix(
|
|
fileText,
|
|
hasMarkdownHeading(fileText) ? undefined : fallbackTitle
|
|
) as unknown as PlaitElement & { points?: Point[] };
|
|
}
|
|
|
|
if (format === "opml") {
|
|
return parseOpmlToMind(fileText, fallbackTitle) as PlaitElement & {
|
|
points?: Point[];
|
|
};
|
|
}
|
|
|
|
return parseFreeMindToMind(fileText) as PlaitElement & { points?: Point[] };
|
|
}
|
|
|
|
/** Preserve workbook sheets rather than flattening them into the topic tree. */
|
|
export async function parseMindMapFileToDocument(
|
|
file: File,
|
|
fallbackTitle = getMindMapImportTitle(file.name)
|
|
): Promise<ImportedMindMap> {
|
|
if (detectMindMapImportFormat(file) === "xmind")
|
|
return importXmind(file, fallbackTitle);
|
|
const mind = await parseOutlineFileToMind(file, fallbackTitle);
|
|
return {
|
|
sheets: [
|
|
{
|
|
id: `import-${crypto.randomUUID()}`,
|
|
title: "画布 1",
|
|
children: [
|
|
{ ...mind, points: mind.points?.length ? mind.points : [[0, 0]] },
|
|
],
|
|
},
|
|
],
|
|
warnings: [],
|
|
};
|
|
}
|