package com.guo.learningprogresstracker.utils; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Getter; import java.util.*; /** * 思维导图树结构与缩进大纲文本之间的双向转换工具。 *

大纲格式:每行一条节点,缩进表示层级(2 空格 / tab / # 前缀),行首 - * 数字. 等标记被自动剥离。

*/ public class MindMapTreeTool { private static final int INDENT_SPACES = 2; private MindMapTreeTool() { } // ============ 序列化:树 → 缩进大纲 ============ /** * 将根节点序列化为缩进大纲文本 */ public static String toOutline(MindMapNode root) { StringBuilder sb = new StringBuilder(); appendOutline(sb, root, 0); return sb.toString(); } private static void appendOutline(StringBuilder sb, MindMapNode node, int level) { if (level > 0) { sb.append(" ".repeat(level)).append("- ").append(node.getTitle() != null ? node.getTitle() : "").append('\n'); } if (node.getChildren() != null) { for (MindMapNode child : node.getChildren()) { appendOutline(sb, child, level + 1); } } // level 0 是根节点标题本身不输出,但其子节点输出 if (level == 0 && node.getChildren() != null) { for (MindMapNode child : node.getChildren()) { appendOutline(sb, child, 0); } } } /** * 根节点序列化为完整大纲,第一行为根标题 */ public static String toFullOutline(MindMapNode root) { StringBuilder sb = new StringBuilder(); sb.append(root.getTitle() != null ? root.getTitle() : "").append('\n'); if (root.getChildren() != null) { for (MindMapNode child : root.getChildren()) { appendOutline(sb, child, 1); } } return sb.toString(); } // ============ 反序列化:缩进大纲 → 树 ============ /** * 将缩进大纲文本解析为根节点。 * 第一行非空文本作为根标题,后续行作为子节点。 */ public static MindMapNode parseOutline(String outlineText) { if (outlineText == null || outlineText.isBlank()) { return new MindMapNode("未命名"); } String[] lines = outlineText.split("\\R"); List nonBlank = new ArrayList<>(); for (String line : lines) { if (!line.trim().isBlank()) { nonBlank.add(line.stripTrailing()); } } if (nonBlank.isEmpty()) { return new MindMapNode("未命名"); } MindMapNode root = new MindMapNode(stripMarker(nonBlank.get(0).strip())); List roots = new ArrayList<>(); Deque stack = new ArrayDeque<>(); stack.push(new StackEntry(-1, roots)); for (int i = 1; i < nonBlank.size(); i++) { String raw = nonBlank.get(i); int level = detectLevel(raw); String title = stripMarker(raw.strip()); MindMapNode child = new MindMapNode(title); while (stack.peek().level >= level) { stack.pop(); } stack.peek().children.add(child); List children = new ArrayList<>(); child.setChildren(children); stack.push(new StackEntry(level, children)); } root.setChildren(roots); return root; } private static int detectLevel(String line) { String trimmed = line.stripLeading(); int indent = line.length() - trimmed.length(); int hashCount = 0; while (hashCount < trimmed.length() && trimmed.charAt(hashCount) == '#') { hashCount++; } if (hashCount > 0) return hashCount; if (indent == 0) return 1; return Math.max(1, indent / INDENT_SPACES + 1); } private static String stripMarker(String s) { return s.replaceFirst("^#{1,6}\\s*", "") .replaceFirst("^[-*+]\\s*", "") .replaceFirst("^\\d+\\.\\s*", "") .strip(); } private record StackEntry(int level, List children) { } // ============ 工具方法 ============ /** 展开树为平铺列表(前序遍历) */ public static List flatten(MindMapNode root) { List list = new ArrayList<>(); flattenRecursive(root, list); return list; } private static void flattenRecursive(MindMapNode node, List acc) { acc.add(node); if (node.getChildren() != null) { for (MindMapNode child : node.getChildren()) { flattenRecursive(child, acc); } } } /** 统计节点总数 */ public static int countNodes(MindMapNode root) { return flatten(root).size(); } /** 计算最大深度 */ public static int maxDepth(MindMapNode node) { if (node.getChildren() == null || node.getChildren().isEmpty()) { return 1; } return 1 + node.getChildren().stream().mapToInt(MindMapTreeTool::maxDepth).max().orElse(0); } /** 浅拷贝节点(仅拷贝标量字段,不拷贝 children) */ private static MindMapNode copyNodeShallow(MindMapNode node) { MindMapNode copy = new MindMapNode(); copy.setTitle(node.getTitle()); copy.setNotes(node.getNotes()); copy.setSourceType(node.getSourceType()); copy.setSourceId(node.getSourceId()); return copy; } /** * 按路径提取子树:以 / 分隔的节点标题路径,提取目标节点为根的新树。 * @param root 完整树根 * @param path 节点路径,如 "根标题 / 分支A / 子节点B" * @return 以路径末端节点为根的深拷贝子树,匹配失败时返回 null */ public static MindMapNode extractSubtree(MindMapNode root, String path) { if (root == null || path == null || path.isBlank()) return null; String[] segments = path.split("\\s*/\\s*"); MindMapNode current = root; for (int i = 0; i < segments.length; i++) { String seg = segments[i].trim(); if (seg.isEmpty()) continue; if (current.getTitle() != null && normalizeForMerge(current.getTitle()).equals(normalizeForMerge(seg))) { continue; // 当前节点已匹配,看下一段 } MindMapNode found = null; if (current.getChildren() != null) { for (MindMapNode child : current.getChildren()) { if (child.getTitle() != null && normalizeForMerge(child.getTitle()).equals(normalizeForMerge(seg))) { found = child; break; } } } if (found != null) { current = found; } else { return null; } } return deepCopy(current); } /** 深拷贝节点及其子树 */ private static MindMapNode deepCopy(MindMapNode node) { if (node == null) return null; MindMapNode copy = new MindMapNode(); copy.setTitle(node.getTitle()); copy.setNotes(node.getNotes()); copy.setSourceType(node.getSourceType()); copy.setSourceId(node.getSourceId()); List children = new ArrayList<>(); if (node.getChildren() != null) { for (MindMapNode child : node.getChildren()) { children.add(deepCopy(child)); } } copy.setChildren(children); return copy; } /** * 查找与 content 最相似的节点。基于 title 的 bigram Jaccard 相似度。 * @return 得分最高的节点,若所有节点得分均低于 0.1 则返回 null */ public static MindMapNode findClosestNode(MindMapNode root, String content) { if (root == null || content == null || content.isBlank()) return null; List flat = flatten(root); MindMapNode best = null; double bestScore = 0.0; for (MindMapNode node : flat) { if (node.getTitle() == null || node.getTitle().isBlank()) continue; double score = similarityScore(node.getTitle(), content); // notes 也参与匹配,但权重减半 if (node.getNotes() != null && !node.getNotes().isBlank()) { double noteScore = similarityScore(node.getNotes(), content); score = Math.max(score, noteScore * 0.5); } if (score > bestScore) { bestScore = score; best = node; } } return bestScore > 0.1 ? best : null; } /** * 获取节点在树中的路径(以 / 分隔的 title 序列) */ public static String getPath(MindMapNode root, String targetTitle) { if (root == null || targetTitle == null) return ""; List path = new ArrayList<>(); if (findPathRecursive(root, targetTitle, path)) { return String.join(" / ", path); } return ""; } private static boolean findPathRecursive(MindMapNode node, String targetTitle, List path) { if (node == null) return false; path.add(node.getTitle() != null ? node.getTitle() : ""); if (normalizeForMerge(node.getTitle()).equals(normalizeForMerge(targetTitle))) return true; if (node.getChildren() != null) { for (MindMapNode child : node.getChildren()) { if (findPathRecursive(child, targetTitle, path)) return true; } } path.remove(path.size() - 1); return false; } /** bigram Jaccard 相似度 */ public static double similarityScore(String a, String b) { if (a == null || b == null) return 0; String na = normalizeForMerge(a); String nb = normalizeForMerge(b); if (na.isEmpty() || nb.isEmpty()) return 0; if (na.equals(nb)) return 1.0; // 子串匹配 if (na.contains(nb) || nb.contains(na)) return 0.9; Set bigramsA = bigrams(na); Set bigramsB = bigrams(nb); if (bigramsA.isEmpty() && bigramsB.isEmpty()) return 0; Set union = new HashSet<>(bigramsA); union.addAll(bigramsB); Set intersect = new HashSet<>(bigramsA); intersect.retainAll(bigramsB); return (double) intersect.size() / union.size(); } private static Set bigrams(String s) { Set set = new LinkedHashSet<>(); for (int i = 0; i < s.length() - 1; i++) { set.add(s.substring(i, i + 2)); } return set; } /** * 合并新旧树:保留旧树中已有节点(用户编辑),追加新树中的新节点。 * 按标准化标题匹配同级节点,旧树匹配到的节点优先保留。 * * @param oldRoot 旧树根(用户可能编辑过) * @param newRoot 新树根(AI/BUILTIN 最新生成) * @return 合并后的树 */ public static MindMapNode mergeTrees(MindMapNode oldRoot, MindMapNode newRoot) { if (oldRoot == null) return newRoot; if (newRoot == null) return oldRoot; MindMapNode result = copyNodeShallow(oldRoot); result.setChildren(mergeChildren(oldRoot.getChildren(), newRoot.getChildren())); return result; } /** 递归合并子节点列表 */ private static List mergeChildren(List oldChildren, List newChildren) { Map oldByNormalized = new LinkedHashMap<>(); if (oldChildren != null) { for (MindMapNode child : oldChildren) { oldByNormalized.put(normalizeForMerge(child.getTitle()), child); } } List merged = new ArrayList<>(); Set usedKeys = new HashSet<>(); if (newChildren != null) { for (MindMapNode newNode : newChildren) { String key = normalizeForMerge(newNode.getTitle()); MindMapNode oldNode = oldByNormalized.get(key); if (oldNode != null) { // 旧节点存在:保留旧节点标题,递归合并子节点 MindMapNode kept = copyNodeShallow(oldNode); kept.setChildren(mergeChildren(oldNode.getChildren(), newNode.getChildren())); merged.add(kept); usedKeys.add(key); } else { // 新节点:直接添加 merged.add(newNode); } } } // 添加旧树中有但新树中没有的节点(用户添加的额外节点) if (oldChildren != null) { for (MindMapNode oldNode : oldChildren) { if (!usedKeys.contains(normalizeForMerge(oldNode.getTitle()))) { merged.add(oldNode); } } } return merged; } private static String normalizeForMerge(String s) { if (s == null) return ""; return s.replaceAll("[\\s 、,。!?:;()\\[\\]{},.!?:;()\\-—/\\\\|]", "") .toLowerCase(Locale.ROOT) .trim(); } /** 序列化整棵树为 JSON */ public static String toJson(MindMapNode root, ObjectMapper mapper) { try { return mapper.writeValueAsString(root.toMap()); } catch (Exception e) { return "{\"title\":\"序列化失败\"}"; } } /** 从 JSON 反序列化 */ public static MindMapNode fromJson(String json, ObjectMapper mapper) { return MindMapNode.fromJson(json, mapper); } }