315 lines
12 KiB
Java
315 lines
12 KiB
Java
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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> root = node(rootTopic.path("title").asText("XMind 导图"));
|
|
root.put("sourceFormat", ReviewMindMapFileFormatEnum.XMIND.getCode());
|
|
root.put("nodes", parseXMindTopicChildren(rootTopic));
|
|
return root;
|
|
}
|
|
|
|
private List<Map<String, Object>> parseXMindTopicChildren(JsonNode topic) {
|
|
List<Map<String, Object>> result = new ArrayList<>();
|
|
JsonNode attached = topic.path("children").path("attached");
|
|
if (!attached.isArray()) {
|
|
return result;
|
|
}
|
|
for (JsonNode child : attached) {
|
|
Map<String, Object> item = node(child.path("title").asText("未命名节点"));
|
|
item.put("children", parseXMindTopicChildren(child));
|
|
result.add(item);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private Map<String, Object> 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<String, Object> root = parseTopicElement(topic);
|
|
root.put("sourceFormat", ReviewMindMapFileFormatEnum.XMIND.getCode());
|
|
root.put("nodes", root.remove("children"));
|
|
return root;
|
|
}
|
|
|
|
private Map<String, Object> parseMarkdown(String content, ReviewMindMapFileFormatEnum format) {
|
|
Map<String, Object> root = node("导图大纲");
|
|
root.put("sourceFormat", format.getCode());
|
|
List<Map<String, Object>> roots = new ArrayList<>();
|
|
root.put("nodes", roots);
|
|
|
|
ArrayDeque<StackItem> 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<String, Object> item = node(title);
|
|
while (stack.peek().level >= level) {
|
|
stack.pop();
|
|
}
|
|
stack.peek().children.add(item);
|
|
@SuppressWarnings("unchecked")
|
|
List<Map<String, Object>> children = (List<Map<String, Object>>) item.get("children");
|
|
stack.push(new StackItem(level, children));
|
|
}
|
|
if (!roots.isEmpty()) {
|
|
root.put("title", roots.get(0).get("title"));
|
|
}
|
|
return root;
|
|
}
|
|
|
|
private Map<String, Object> 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<String, Object> 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<String, Object> parseTopicElement(Element element) {
|
|
Map<String, Object> item = node(textOfFirstChild(element, "title", "未命名节点"));
|
|
item.put("children", childTopicElements(element).stream()
|
|
.map(this::parseTopicElement)
|
|
.toList());
|
|
return item;
|
|
}
|
|
|
|
private Map<String, Object> 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<String, Object> item = node(title);
|
|
List<Map<String, Object>> 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<String, Object> node(String title) {
|
|
Map<String, Object> node = new LinkedHashMap<>();
|
|
node.put("title", title);
|
|
node.put("notes", "");
|
|
node.put("children", new ArrayList<Map<String, Object>>());
|
|
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<Element> 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<Element> directChildElements(Element element) {
|
|
List<Element> 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<String, Object> root) {
|
|
StringBuilder builder = new StringBuilder();
|
|
appendOutline(builder, root, 0);
|
|
return builder.toString();
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
private void appendOutline(StringBuilder builder, Map<String, Object> 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<String, Object>) child, level + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
private int countNodes(Map<String, Object> 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<String, Object>) child);
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
private int maxDepth(Map<String, Object> 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<String, Object>) child));
|
|
}
|
|
return max;
|
|
}
|
|
|
|
record ParseResult(String title, String fileFormat, String parsedContent, String outline, String summary) {
|
|
}
|
|
|
|
private record StackItem(int level, List<Map<String, Object>> children) {
|
|
}
|
|
}
|