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 { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function getXmindTopicTitle(topic: Record) { const title = normalizeText(String(topic.title || topic.text || "")); return title || "未命名节点"; } function getXmindJsonChildTopics(topic: Record) { 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): 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 { 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[] }; }