diff --git a/src/main/java/com/guo/learningprogresstracker/service/impl/MindMapFileParser.java b/src/main/java/com/guo/learningprogresstracker/service/impl/MindMapFileParser.java deleted file mode 100644 index 01eb4e1..0000000 --- a/src/main/java/com/guo/learningprogresstracker/service/impl/MindMapFileParser.java +++ /dev/null @@ -1,314 +0,0 @@ -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) { - } -}