350 lines
10 KiB
TypeScript
350 lines
10 KiB
TypeScript
import type { PlaitElement, Point } from "@plait/core";
|
|
import { MindLayoutType } from "@plait/layouts";
|
|
import { createMindElement, type MindElement, type PlaitMind } from "@plait/mind";
|
|
|
|
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 getDirectChildrenByLocalName(element: Element, localName: string) {
|
|
const normalizedLocalName = localName.toLowerCase();
|
|
return Array.from(element.children).filter(
|
|
(child) =>
|
|
(child.localName || child.tagName).toLowerCase() === normalizedLocalName
|
|
);
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function getXmindTopicTitle(topic: Record<string, unknown>) {
|
|
const title = normalizeText(String(topic.title || topic.text || ""));
|
|
return title || "未命名节点";
|
|
}
|
|
|
|
function getXmindJsonChildTopics(topic: Record<string, unknown>) {
|
|
const children = topic.children;
|
|
if (Array.isArray(children)) {
|
|
return children.filter(isRecord);
|
|
}
|
|
if (!isRecord(children)) {
|
|
return [];
|
|
}
|
|
|
|
return Object.values(children).flatMap((value) => {
|
|
if (Array.isArray(value)) {
|
|
return value.filter(isRecord);
|
|
}
|
|
if (isRecord(value)) {
|
|
return [value];
|
|
}
|
|
return [];
|
|
});
|
|
}
|
|
|
|
function parseXmindJsonTopic(topic: Record<string, unknown>): OutlineNode {
|
|
return {
|
|
text: getXmindTopicTitle(topic),
|
|
children: getXmindJsonChildTopics(topic).map(parseXmindJsonTopic),
|
|
};
|
|
}
|
|
|
|
function parseXmindJsonToMind(jsonText: string, fallbackTitle: string) {
|
|
const parsed = JSON.parse(jsonText) as unknown;
|
|
const sheets = Array.isArray(parsed) ? parsed.filter(isRecord) : [];
|
|
const sheetRoots = sheets
|
|
.map((sheet) => {
|
|
const rootTopic = sheet.rootTopic;
|
|
return isRecord(rootTopic) ? parseXmindJsonTopic(rootTopic) : null;
|
|
})
|
|
.filter((sheet): sheet is OutlineNode => Boolean(sheet));
|
|
|
|
if (sheetRoots.length === 0) {
|
|
throw new Error("XMind content.json rootTopic not found");
|
|
}
|
|
|
|
const root =
|
|
sheetRoots.length === 1
|
|
? sheetRoots[0]
|
|
: {
|
|
text: fallbackTitle || "XMind 思维导图",
|
|
children: sheetRoots,
|
|
};
|
|
|
|
return createMindFromOutline(root);
|
|
}
|
|
|
|
function getDirectXmindXmlTitle(element: Element) {
|
|
return (
|
|
normalizeText(getDirectChildrenByLocalName(element, "title")[0]?.textContent) ||
|
|
normalizeText(element.getAttribute("title")) ||
|
|
normalizeText(element.getAttribute("TEXT")) ||
|
|
"未命名节点"
|
|
);
|
|
}
|
|
|
|
function getXmindXmlTopicChildren(element: Element) {
|
|
const childrenContainers = getDirectChildrenByLocalName(element, "children");
|
|
return childrenContainers.flatMap((childrenContainer) =>
|
|
getDirectChildrenByLocalName(childrenContainer, "topics").flatMap((topics) =>
|
|
getDirectChildrenByLocalName(topics, "topic")
|
|
)
|
|
);
|
|
}
|
|
|
|
function parseXmindXmlTopic(element: Element): OutlineNode {
|
|
return {
|
|
text: getDirectXmindXmlTitle(element),
|
|
children: getXmindXmlTopicChildren(element).map(parseXmindXmlTopic),
|
|
};
|
|
}
|
|
|
|
function parseXmindXmlToMind(xmlText: string, fallbackTitle: string) {
|
|
const document = parseXmlDocument(xmlText, "XMind");
|
|
const sheets = Array.from(document.getElementsByTagName("*")).filter(
|
|
(element) => (element.localName || element.tagName).toLowerCase() === "sheet"
|
|
);
|
|
const sheetRoots = sheets
|
|
.map((sheet) => getDirectChildrenByLocalName(sheet, "topic")[0])
|
|
.filter((topic): topic is Element => Boolean(topic))
|
|
.map(parseXmindXmlTopic);
|
|
|
|
if (sheetRoots.length === 0) {
|
|
const rootTopic = Array.from(document.getElementsByTagName("*")).find(
|
|
(element) => (element.localName || element.tagName).toLowerCase() === "topic"
|
|
);
|
|
if (!rootTopic) {
|
|
throw new Error("XMind content.xml topic not found");
|
|
}
|
|
return createMindFromOutline(parseXmindXmlTopic(rootTopic));
|
|
}
|
|
|
|
const root =
|
|
sheetRoots.length === 1
|
|
? sheetRoots[0]
|
|
: {
|
|
text: fallbackTitle || "XMind 思维导图",
|
|
children: sheetRoots,
|
|
};
|
|
|
|
return createMindFromOutline(root);
|
|
}
|
|
|
|
async function parseXmindToMind(file: File, fallbackTitle: string) {
|
|
const { default: JSZip } = await import("jszip");
|
|
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
|
const contentJsonFile =
|
|
zip.file("content.json") ||
|
|
zip.file(/(^|\/)content\.json$/i).find((entry) => !entry.dir);
|
|
if (contentJsonFile) {
|
|
return parseXmindJsonToMind(await contentJsonFile.async("string"), fallbackTitle);
|
|
}
|
|
|
|
const contentXmlFile =
|
|
zip.file("content.xml") ||
|
|
zip.file(/(^|\/)content\.xml$/i).find((entry) => !entry.dir);
|
|
if (contentXmlFile) {
|
|
return parseXmindXmlToMind(await contentXmlFile.async("string"), fallbackTitle);
|
|
}
|
|
|
|
throw new Error("XMind content file not found");
|
|
}
|
|
|
|
export async function parseMindMapFileToDrawnixMind(
|
|
file: File,
|
|
fallbackTitle = getMindMapImportTitle(file.name)
|
|
): Promise<PlaitElement & { points?: Point[] }> {
|
|
const format = detectMindMapImportFormat(file);
|
|
if (!format) {
|
|
throw new Error("Unsupported mind map import file");
|
|
}
|
|
|
|
if (format === "xmind") {
|
|
return parseXmindToMind(file, fallbackTitle) as Promise<
|
|
PlaitElement & { points?: Point[] }
|
|
>;
|
|
}
|
|
|
|
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[] };
|
|
}
|