Skip to main content

dioxus_mind_elixir/
text.rs

1//! Deliberately small, loss-aware Mind Elixir plaintext subset.
2use crate::{MindMapData, MindMapError, Node};
3
4fn invalid(line: usize, message: &str) -> MindMapError {
5    MindMapError::InvalidData {
6        path: format!("line {line}"),
7        message: message.into(),
8    }
9}
10/// Parse two-space indented `- Topic` lines, `\\n` and `\\\\` escapes.
11/// Multiple top-level entries acquire a synthetic `Root`. Styles, references,
12/// arrows and summaries are rejected, rather than silently discarded.
13pub fn from_plaintext(input: &str) -> Result<MindMapData, MindMapError> {
14    let mut lines = Vec::<(usize, Node)>::new();
15    for (number, line) in input.lines().enumerate() {
16        if line.trim().is_empty() {
17            continue;
18        }
19        let spaces = line.bytes().take_while(|b| *b == b' ').count();
20        if spaces % 2 != 0 {
21            return Err(invalid(number + 1, "Indentation must use pairs of spaces"));
22        }
23        let content = line
24            .get(spaces..)
25            .and_then(|s| s.strip_prefix("- "))
26            .ok_or_else(|| invalid(number + 1, "Expected - Topic"))?;
27        if content.starts_with(['>', '}']) || content.contains("[^") || content.contains(" {\"") {
28            return Err(invalid(
29                number + 1,
30                "References, arrows, summaries and style suffixes are unsupported",
31            ));
32        }
33        let mut topic = String::new();
34        let mut chars = content.chars();
35        while let Some(c) = chars.next() {
36            if c == '\\' {
37                match chars.next() {
38                    Some('n') => topic.push('\n'),
39                    Some('\\') => topic.push('\\'),
40                    _ => return Err(invalid(number + 1, "Unknown or incomplete escape")),
41                }
42            } else {
43                topic.push(c);
44            }
45        }
46        let depth = spaces / 2;
47        if depth > 128
48            || (lines.is_empty() && depth != 0)
49            || lines
50                .last()
51                .is_some_and(|(previous, _)| depth > previous + 1)
52        {
53            return Err(invalid(number + 1, "Invalid indentation depth"));
54        }
55        lines.push((depth, Node::new(format!("text-{}", number + 1), topic)));
56    }
57    if lines.is_empty() {
58        return Err(invalid(1, "No nodes"));
59    }
60    let mut stack = Vec::<(usize, Node)>::new();
61    let mut roots = Vec::new();
62    for (depth, node) in lines {
63        while stack.last().is_some_and(|(d, _)| *d >= depth) {
64            attach(&mut stack, &mut roots);
65        }
66        stack.push((depth, node));
67    }
68    while !stack.is_empty() {
69        attach(&mut stack, &mut roots);
70    }
71    let root = if roots.len() == 1 {
72        roots.pop().ok_or_else(|| invalid(1, "No root"))?
73    } else {
74        Node::new("text-root", "Root").with_children(roots)
75    };
76    let document = MindMapData::new(root);
77    document.validate()?;
78    Ok(document)
79}
80fn attach(stack: &mut Vec<(usize, Node)>, roots: &mut Vec<Node>) {
81    if let Some((_, node)) = stack.pop() {
82        if let Some((_, parent)) = stack.last_mut() {
83            parent.children.push(node);
84        } else {
85            roots.push(node);
86        }
87    }
88}
89/// Serialize labels and tree order only. Reject rich persistent fields so callers
90/// cannot accidentally treat this subset as a complete backup; use JSON instead.
91pub fn to_plaintext(data: &MindMapData) -> Result<String, MindMapError> {
92    data.validate()?;
93    if data.meta.is_some() {
94        return Err(MindMapError::InvalidOperation(
95            "Plaintext cannot preserve document metadata; use JSON".into(),
96        ));
97    }
98    let mut result = String::new();
99    let mut pending = vec![(&data.node_data, 0)];
100    while let Some((node, depth)) = pending.pop() {
101        if node.style.is_some()
102            || node.note.is_some()
103            || node.image.is_some()
104            || node.metadata.is_some()
105            || node.branch_color.is_some()
106        {
107            return Err(MindMapError::InvalidOperation(
108                "Plaintext cannot preserve style, note, image or metadata; use JSON".into(),
109            ));
110        }
111        let topic = node.topic.replace('\\', "\\\\").replace('\n', "\\n");
112        if topic.starts_with(['>', '}']) || topic.contains("[^") || topic.contains(" {\"") {
113            return Err(MindMapError::InvalidOperation(
114                "Label conflicts with reserved plaintext syntax".into(),
115            ));
116        }
117        result.push_str(&format!("{}- {topic}\n", "  ".repeat(depth)));
118        pending.extend(node.children.iter().rev().map(|child| (child, depth + 1)));
119    }
120    Ok(result)
121}