ai-chatbot/lib/flow-board/mind-map-import.ts

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