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