Files
mnote/wolai-frontend/src/components/editor/blocks/mindmapMindManagerImport.ts
T

235 lines
8.2 KiB
TypeScript

type MindMapData = {
data: Record<string, unknown>;
children?: MindMapData[];
};
const readBlobAsDataUrl = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = (err) => reject(err);
reader.readAsDataURL(blob);
});
const getImageSizeFromBlob = async (blob: Blob): Promise<{ width: number; height: number } | null> => {
try {
if (typeof createImageBitmap === "function") {
const bmp = await createImageBitmap(blob);
const res = { width: bmp.width, height: bmp.height };
try {
bmp.close();
} catch {
// ignore
}
return res;
}
} catch {
// ignore
}
return new Promise((resolve) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const res = { width: img.naturalWidth, height: img.naturalHeight };
URL.revokeObjectURL(url);
resolve(res);
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve(null);
};
img.src = url;
});
};
const walkElements = (root: Element): Element[] => {
const stack: Element[] = [root];
const out: Element[] = [];
while (stack.length) {
const el = stack.pop()!;
out.push(el);
for (let i = el.children.length - 1; i >= 0; i--) {
stack.push(el.children[i] as Element);
}
}
return out;
};
const findFirstByLocalName = (root: Element, name: string): Element | null => {
for (const el of walkElements(root)) {
if (el.localName === name) return el;
}
return null;
};
const findFirstChildByLocalName = (root: Element, name: string): Element | null => {
for (const el of Array.from(root.children)) {
if ((el as Element).localName === name) return el as Element;
}
return null;
};
const findChildrenByLocalName = (root: Element, name: string): Element[] =>
Array.from(root.children).filter((el) => (el as Element).localName === name) as Element[];
const getTopicPlainText = (topicEl: Element): string => {
const textEl = findFirstChildByLocalName(topicEl, "Text");
const raw = textEl?.getAttribute("PlainText") ?? "";
return raw;
};
const getTopicHyperlink = (topicEl: Element): { url: string; title: string } | null => {
const linkEl = findFirstChildByLocalName(topicEl, "Hyperlink");
if (!linkEl) return null;
const url = linkEl.getAttribute("Url") ?? "";
const title = linkEl.getAttribute("Title") ?? "";
if (!url) return null;
return { url, title };
};
const guessMimeFromImageType = (imageType: string): string => {
const t = (imageType || "").toLowerCase();
if (t.includes("png")) return "image/png";
if (t.includes("jpeg") || t.includes("jpg")) return "image/jpeg";
if (t.includes("gif")) return "image/gif";
if (t.includes("bmp")) return "image/bmp";
if (t.includes("svg")) return "image/svg+xml";
return "application/octet-stream";
};
const parseMmArchiveUriToZipPath = (uri: string): string | null => {
// 常见格式:mmarch://bin/<uuid>.bin
const raw = String(uri || "");
const lower = raw.toLowerCase();
const prefix = "mmarch://";
if (!lower.startsWith(prefix)) return null;
let rest = raw.slice(prefix.length);
while (rest.startsWith("/")) rest = rest.slice(1);
return rest || null;
};
const getTopicImageInfo = (topicEl: Element): { uri: string; mime: string } | null => {
const oneImage = findFirstChildByLocalName(topicEl, "OneImage");
if (!oneImage) return null;
const imageEl = findFirstByLocalName(oneImage, "Image");
if (!imageEl) return null;
const imageDataEl = findFirstByLocalName(imageEl, "ImageData");
const uriEl = imageEl ? findFirstByLocalName(imageEl, "Uri") : null;
const uri = (uriEl?.textContent ?? "").trim();
if (!uri) return null;
const mime = guessMimeFromImageType(imageDataEl?.getAttribute("ImageType") ?? "");
return { uri, mime };
};
const compactTree = (node: MindMapData, isRoot = false): MindMapData => {
const children = (node.children ?? []).map((c) => compactTree(c, false));
const data = node.data ?? {};
const hasContent =
Boolean(String(data.text ?? "").trim()) ||
Boolean(String((data as Record<string, unknown>).hyperlink ?? "").trim()) ||
Boolean(String((data as Record<string, unknown>).note ?? "").trim()) ||
Boolean(String((data as Record<string, unknown>).image ?? "").trim()) ||
Boolean(((data as Record<string, unknown>).tag as unknown[] | undefined)?.length) ||
Boolean(String((data as Record<string, unknown>).attachmentUrl ?? "").trim());
if (!isRoot && !hasContent && children.length === 1) {
return children[0];
}
return { ...node, children };
};
export const parseMindManagerMmapFile = async (file: File): Promise<MindMapData> => {
const JSZip = (await import("jszip")).default;
const zip = await JSZip.loadAsync(file);
const xmlFile = zip.file("Document.xml");
if (!xmlFile) {
throw new Error("Document.xml 不存在");
}
const xmlText = await xmlFile.async("string");
const xmlDoc = new DOMParser().parseFromString(xmlText, "application/xml");
if (xmlDoc.getElementsByTagName("parsererror").length > 0) {
throw new Error("Document.xml 解析失败");
}
const docEl = xmlDoc.documentElement;
if (!docEl) throw new Error("Document.xml 内容为空");
const defaultsGroup = findFirstByLocalName(docEl, "RootTopicDefaultsGroup");
const defaultTextEl = defaultsGroup ? findFirstByLocalName(defaultsGroup, "DefaultText") : null;
const defaultRootText = defaultTextEl?.getAttribute("PlainText") ?? "中心主题";
const oneTopicEl = findFirstByLocalName(docEl, "OneTopic");
const rootTopicEl = oneTopicEl ? findFirstChildByLocalName(oneTopicEl, "Topic") : null;
if (!rootTopicEl) {
throw new Error("未找到根主题");
}
const binCache = new Map<string, { dataUrl: string; width: number; height: number }>();
const resolveBinToImageDataUrl = async (
uri: string,
mime: string,
): Promise<{ dataUrl: string; width: number; height: number } | null> => {
const zipPath = parseMmArchiveUriToZipPath(uri);
if (!zipPath) return null;
if (binCache.has(zipPath)) return binCache.get(zipPath)!;
const entry = zip.file(zipPath) ?? zip.file(`/${zipPath}`);
if (!entry) return null;
const bytes = await entry.async("uint8array");
const rawBuffer = bytes.buffer as ArrayBuffer;
const sliced = rawBuffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
const blob = new Blob([sliced], { type: mime });
const dataUrl = await readBlobAsDataUrl(blob);
const size = await getImageSizeFromBlob(blob);
const width = size?.width ?? 0;
const height = size?.height ?? 0;
const res = { dataUrl, width, height };
binCache.set(zipPath, res);
return res;
};
const walkTopic = async (topicEl: Element, isRoot: boolean): Promise<MindMapData> => {
const text = getTopicPlainText(topicEl);
const hyperlink = getTopicHyperlink(topicEl);
const imageInfo = getTopicImageInfo(topicEl);
const nodeData: Record<string, unknown> = {
text: text ?? "",
};
if (hyperlink?.url) {
nodeData.hyperlink = hyperlink.url;
if (hyperlink.title) nodeData.hyperlinkTitle = hyperlink.title;
// MindManager 常见:文本为空但带链接标题,作为节点可见文本更符合导入预期
if (!String(nodeData.text || "").trim() && hyperlink.title) {
nodeData.text = hyperlink.title;
}
}
if (imageInfo?.uri) {
const resolved = await resolveBinToImageDataUrl(imageInfo.uri, imageInfo.mime);
if (resolved?.dataUrl) {
nodeData.image = resolved.dataUrl;
nodeData.imageSize = {
width: resolved.width || 0,
height: resolved.height || 0,
custom: true,
};
nodeData.imgPlacement = "top";
}
}
if (isRoot && !String(nodeData.text || "").trim()) {
nodeData.text = defaultRootText || "中心主题";
}
const subTopicsEl = findFirstChildByLocalName(topicEl, "SubTopics");
const childTopicEls = subTopicsEl ? findChildrenByLocalName(subTopicsEl, "Topic") : [];
const children = await Promise.all(childTopicEls.map((c) => walkTopic(c, false)));
return { data: nodeData, children };
};
const tree = await walkTopic(rootTopicEl, true);
return compactTree(tree, true);
};