195 lines
5.6 KiB
Dart
195 lines
5.6 KiB
Dart
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<MindMapNode> parse(String markdown, String projectId) {
|
|
final lines = markdown.split('\n');
|
|
final nodes = <MindMapNode>[];
|
|
final now = DateTime.now();
|
|
|
|
String? currentParentId;
|
|
final Map<int, String> 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<MindMapNode> 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<MindMapNode>.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<String> 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
|
|
}
|
|
}
|
|
}
|