ai-chatbot/lib/flow-board/mind-map-import.ts
Your Name e054dcabcc
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: update canvas and flow board features
2026-04-11 22:28:18 +08:00

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[] };
}