package com.guo.learningprogresstracker.service.impl; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.guo.learningprogresstracker.enums.ReviewMindMapFileFormatEnum; import org.springframework.web.multipart.MultipartFile; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NodeList; import javax.xml.parsers.DocumentBuilderFactory; import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; class MindMapFileParser { private final ObjectMapper objectMapper; MindMapFileParser(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } ParseResult parse(MultipartFile file, ReviewMindMapFileFormatEnum format) throws IOException { byte[] bytes = file.getBytes(); Map root = switch (format) { case XMIND -> parseXMind(bytes); case MARKDOWN -> parseMarkdown(new String(bytes, StandardCharsets.UTF_8), format); case OPML -> parseOutlineXml(bytes, "text", format); case FREEMIND -> parseOutlineXml(bytes, "TEXT", format); case TEXT -> parseMarkdown(new String(bytes, StandardCharsets.UTF_8), format); }; String title = String.valueOf(root.getOrDefault("title", file.getOriginalFilename())); String outline = toOutline(root); int nodeCount = countNodes(root); int depth = maxDepth(root); String summary = "共解析 " + nodeCount + " 个节点,最大层级 " + depth + "。"; return new ParseResult(title, format.getCode(), objectMapper.writeValueAsString(root), outline, summary); } private Map parseXMind(byte[] bytes) throws IOException { byte[] contentJson = null; byte[] contentXml = null; try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(bytes))) { ZipEntry entry; while ((entry = zipInputStream.getNextEntry()) != null) { if ("content.json".equals(entry.getName())) { contentJson = zipInputStream.readAllBytes(); } else if ("content.xml".equals(entry.getName())) { contentXml = zipInputStream.readAllBytes(); } } } if (contentJson != null) { return parseXMindJson(contentJson); } if (contentXml != null) { return parseXMindXml(contentXml); } throw new IOException("未找到 XMind 内容文件"); } private Map parseXMindJson(byte[] bytes) throws IOException { JsonNode sheets = objectMapper.readTree(bytes); JsonNode sheet = sheets.isArray() && !sheets.isEmpty() ? sheets.get(0) : sheets; JsonNode rootTopic = sheet.path("rootTopic"); Map root = node(rootTopic.path("title").asText("XMind 导图")); root.put("sourceFormat", ReviewMindMapFileFormatEnum.XMIND.getCode()); root.put("nodes", parseXMindTopicChildren(rootTopic)); return root; } private List> parseXMindTopicChildren(JsonNode topic) { List> result = new ArrayList<>(); JsonNode attached = topic.path("children").path("attached"); if (!attached.isArray()) { return result; } for (JsonNode child : attached) { Map item = node(child.path("title").asText("未命名节点")); item.put("children", parseXMindTopicChildren(child)); result.add(item); } return result; } private Map parseXMindXml(byte[] bytes) throws IOException { Document document = xmlDocument(bytes); Element sheet = firstElementByName(document.getDocumentElement(), "sheet"); Element topic = sheet == null ? firstElementByName(document.getDocumentElement(), "topic") : firstElementByName(sheet, "topic"); if (topic == null) { throw new IOException("未找到 XMind 根节点"); } Map root = parseTopicElement(topic); root.put("sourceFormat", ReviewMindMapFileFormatEnum.XMIND.getCode()); root.put("nodes", root.remove("children")); return root; } private Map parseMarkdown(String content, ReviewMindMapFileFormatEnum format) { Map root = node("导图大纲"); root.put("sourceFormat", format.getCode()); List> roots = new ArrayList<>(); root.put("nodes", roots); ArrayDeque stack = new ArrayDeque<>(); stack.push(new StackItem(0, roots)); for (String rawLine : content.split("\\R")) { String line = rawLine.stripTrailing(); if (line.isBlank()) { continue; } int level = markdownLevel(line); String title = markdownTitle(line); Map item = node(title); while (stack.peek().level >= level) { stack.pop(); } stack.peek().children.add(item); @SuppressWarnings("unchecked") List> children = (List>) item.get("children"); stack.push(new StackItem(level, children)); } if (!roots.isEmpty()) { root.put("title", roots.get(0).get("title")); } return root; } private Map parseOutlineXml(byte[] bytes, String titleAttribute, ReviewMindMapFileFormatEnum format) throws IOException { Document document = xmlDocument(bytes); Element rootElement = document.getDocumentElement(); Element first = firstElementByName(rootElement, "outline"); if (first == null) { first = firstElementByName(rootElement, "node"); titleAttribute = "TEXT"; } if (first == null) { throw new IOException("未找到导图节点"); } Map root = parseOutlineElement(first, titleAttribute); root.put("sourceFormat", format.getCode()); root.put("nodes", root.remove("children")); return root; } private Document xmlDocument(byte[] bytes) throws IOException { try { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); factory.setFeature("http://xml.org/sax/features/external-general-entities", false); factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); factory.setNamespaceAware(false); return factory.newDocumentBuilder().parse(new ByteArrayInputStream(bytes)); } catch (Exception e) { throw new IOException("导图 XML 解析失败", e); } } private Map parseTopicElement(Element element) { Map item = node(textOfFirstChild(element, "title", "未命名节点")); item.put("children", childTopicElements(element).stream() .map(this::parseTopicElement) .toList()); return item; } private Map parseOutlineElement(Element element, String titleAttribute) { String title = element.getAttribute(titleAttribute); if (title == null || title.isBlank()) { title = element.getAttribute("text"); } if (title == null || title.isBlank()) { title = "未命名节点"; } Map item = node(title); List> children = directChildElements(element).stream() .filter(child -> "outline".equalsIgnoreCase(child.getTagName()) || "node".equalsIgnoreCase(child.getTagName())) .map(child -> parseOutlineElement(child, titleAttribute)) .toList(); item.put("children", children); return item; } private Map node(String title) { Map node = new LinkedHashMap<>(); node.put("title", title); node.put("notes", ""); node.put("children", new ArrayList>()); return node; } private int markdownLevel(String line) { String trimmed = line.stripLeading(); if (trimmed.startsWith("#")) { int count = 0; while (count < trimmed.length() && trimmed.charAt(count) == '#') { count++; } return count; } int indent = line.length() - trimmed.length(); return 1 + indent / 2; } private String markdownTitle(String line) { return line.strip() .replaceFirst("^#{1,6}\\s*", "") .replaceFirst("^[-*+]\\s*", "") .replaceFirst("^\\d+\\.\\s*", ""); } private List childTopicElements(Element element) { return directChildElements(element).stream() .flatMap(child -> { if ("topic".equalsIgnoreCase(child.getTagName())) { return List.of(child).stream(); } return childTopicElements(child).stream(); }) .toList(); } private Element firstElementByName(Element element, String name) { if (name.equalsIgnoreCase(element.getTagName())) { return element; } NodeList nodes = element.getChildNodes(); for (int i = 0; i < nodes.getLength(); i++) { if (nodes.item(i) instanceof Element child) { Element found = firstElementByName(child, name); if (found != null) { return found; } } } return null; } private List directChildElements(Element element) { List result = new ArrayList<>(); NodeList nodes = element.getChildNodes(); for (int i = 0; i < nodes.getLength(); i++) { if (nodes.item(i) instanceof Element child) { result.add(child); } } return result; } private String textOfFirstChild(Element element, String name, String fallback) { for (Element child : directChildElements(element)) { if (name.equalsIgnoreCase(child.getTagName())) { return child.getTextContent(); } } return fallback; } private String toOutline(Map root) { StringBuilder builder = new StringBuilder(); appendOutline(builder, root, 0); return builder.toString(); } @SuppressWarnings("unchecked") private void appendOutline(StringBuilder builder, Map node, int level) { builder.append(" ".repeat(level)).append("- ").append(node.get("title")).append('\n'); Object children = node.get(level == 0 && node.containsKey("nodes") ? "nodes" : "children"); if (children instanceof List list) { for (Object child : list) { appendOutline(builder, (Map) child, level + 1); } } } @SuppressWarnings("unchecked") private int countNodes(Map node) { int count = 1; Object children = node.get(node.containsKey("nodes") ? "nodes" : "children"); if (children instanceof List list) { for (Object child : list) { count += countNodes((Map) child); } } return count; } @SuppressWarnings("unchecked") private int maxDepth(Map node) { Object children = node.get(node.containsKey("nodes") ? "nodes" : "children"); if (!(children instanceof List list) || list.isEmpty()) { return 1; } int max = 1; for (Object child : list) { max = Math.max(max, 1 + maxDepth((Map) child)); } return max; } record ParseResult(String title, String fileFormat, String parsedContent, String outline, String summary) { } private record StackItem(int level, List> children) { } }