import 'package:uuid/uuid.dart'; import '../models/mind_map_model.dart'; /// Парсер Markdown-файлов в MindMapNode структуру /// /// Ожидаемая структура markdown: /// ```markdown /// --- /// title: Название карты /// tags: [crm, project] /// --- /// /// # Корневой узел /// Описание корневого узла /// /// ## Дочерний узел 1 /// Описание /// /// ### Внук 1 /// /// ## Дочерний узел 2 /// ``` class MarkdownMindmapParser { static const Uuid _uuid = Uuid(); /// Парсит markdown строку в дерево MindMapNode /// Возвращает корневой узел + все дочерние static List parse(String markdown, String projectId) { final lines = markdown.split('\n'); final nodes = []; final now = DateTime.now(); String? currentParentId; final Map levelToParentId = {}; for (final line in lines) { final trimmed = line.trim(); if (trimmed.isEmpty) continue; // Пропускаем frontmatter if (trimmed == '---') continue; if (trimmed.startsWith('title:') || trimmed.startsWith('tags:') || trimmed.startsWith('date:')) { continue; } // Определяем уровень заголовка final headerMatch = _headerRegex.firstMatch(trimmed); if (headerMatch != null) { final hashes = headerMatch.group(1)!; final level = hashes.length; final title = trimmed.substring(hashes.length).trim(); final id = _uuid.v4(); // Определяем parentId на основе уровня if (level == 1) { // Корневой узел currentParentId = null; levelToParentId.clear(); } else { // Ищем ближайший родитель с уровнем меньше текущего String? parentId; for (int l = level - 1; l >= 1; l--) { if (levelToParentId.containsKey(l)) { parentId = levelToParentId[l]; break; } } currentParentId = parentId; } levelToParentId[level] = id; nodes.add(MindMapNode( id: id, projectId: projectId, parentId: currentParentId, level: level, title: title, color: _levelColor(level), createdAt: now, updatedAt: now, )); } } // Если нет ни одного заголовка — создаём один корневой узел с текстом if (nodes.isEmpty && markdown.trim().isNotEmpty) { final firstLine = markdown.trim().split('\n').first.trim(); nodes.add(MindMapNode( id: _uuid.v4(), projectId: projectId, parentId: null, level: 0, title: firstLine.length > 50 ? '${firstLine.substring(0, 50)}...' : firstLine, color: _levelColor(0), createdAt: now, updatedAt: now, )); } return nodes; } /// Конвертирует дерево MindMapNode обратно в Markdown static String toMarkdown(List nodes, {String? title}) { if (nodes.isEmpty) return ''; final buffer = StringBuffer(); // Frontmatter buffer.writeln('---'); buffer.writeln('title: ${title ?? 'Mindmap'}'); buffer.writeln('date: ${DateTime.now().toIso8601String()}'); buffer.writeln('---'); buffer.writeln(); // Сортируем по уровню, затем по порядку final sorted = List.from(nodes) ..sort((a, b) { final levelCompare = a.level.compareTo(b.level); if (levelCompare != 0) return levelCompare; return a.createdAt.compareTo(b.createdAt); }); for (final node in sorted) { final hashes = '#' * node.level.clamp(1, 6); buffer.writeln('$hashes ${node.title}'); if (node.description != null && node.description!.isNotEmpty) { buffer.writeln(node.description); } buffer.writeln(); } return buffer.toString().trim(); } /// Извлекает plain text описание из markdown (без заголовков) static String? extractDescription(String markdown) { final lines = markdown.split('\n'); final buffer = StringBuffer(); bool inFrontmatter = false; for (final line in lines) { final trimmed = line.trim(); if (trimmed == '---') { inFrontmatter = !inFrontmatter; continue; } if (inFrontmatter) continue; if (trimmed.startsWith('#')) continue; if (trimmed.isNotEmpty) { buffer.writeln(trimmed); } } final result = buffer.toString().trim(); return result.isEmpty ? null : result; } /// Извлекает wiki-ссылки [[Link]] из текста static List extractWikiLinks(String text) { final matches = _wikiLinkRegex.allMatches(text); return matches.map((m) => m.group(1)!).toList(); } // ==================== PRIVATE ==================== static final RegExp _headerRegex = RegExp(r'^(#{1,6})\s+(.+)$'); static final RegExp _wikiLinkRegex = RegExp(r'\[\[([^\]]+)\]\]'); static String _levelColor(int level) { switch (level) { case 0: return '#E53935'; // red case 1: return '#1E88E5'; // blue case 2: return '#43A047'; // green case 3: return '#FB8C00'; // orange case 4: return '#8E24AA'; // purple case 5: return '#00ACC1'; // cyan default: return '#78909C'; // grey } } }