Skip to main content

dais_sidecar/
markdown_notes.rs

1use std::collections::{HashMap, HashSet};
2use std::fmt::Write as _;
3use std::path::{Path, PathBuf};
4
5use crate::format::SidecarError;
6
7/// A parsed Markdown notes file that can be updated without rewriting headings.
8#[derive(Debug, Clone)]
9pub struct MarkdownNotesDocument {
10    path: PathBuf,
11    preamble: String,
12    sections: Vec<MarkdownNoteSection>,
13}
14
15#[derive(Debug, Clone)]
16struct MarkdownNoteSection {
17    heading: String,
18    slide_index: usize,
19    body: String,
20}
21
22impl MarkdownNotesDocument {
23    /// Read and parse a Markdown notes file.
24    pub fn read(path: &Path, total_logical_slides: usize) -> Result<Self, SidecarError> {
25        let content = std::fs::read_to_string(path)?;
26        Self::parse(path.to_path_buf(), &content, total_logical_slides)
27    }
28
29    /// Return notes keyed by zero-based logical slide index.
30    pub fn notes_by_slide(&self) -> HashMap<usize, String> {
31        self.sections
32            .iter()
33            .filter_map(|section| {
34                let body = section.body.trim().to_string();
35                (!body.is_empty()).then_some((section.slide_index, body))
36            })
37            .collect()
38    }
39
40    /// Write updated notes back to the original Markdown file.
41    pub fn write_notes(&self, notes: &HashMap<usize, String>) -> Result<(), SidecarError> {
42        let content = self.render_with_notes(notes);
43        std::fs::write(&self.path, content)?;
44        Ok(())
45    }
46
47    fn parse(
48        path: PathBuf,
49        content: &str,
50        total_logical_slides: usize,
51    ) -> Result<Self, SidecarError> {
52        let mut preamble = String::new();
53        let mut sections = Vec::new();
54        let mut current_heading: Option<(usize, String, usize)> = None;
55        let mut current_body = String::new();
56        let mut next_slide_index = 0usize;
57        let mut seen = HashSet::new();
58
59        for (line_index, line) in content.split_inclusive('\n').enumerate() {
60            if is_top_level_heading(line) {
61                if let Some((slide_index, heading, heading_line)) = current_heading.take() {
62                    push_section(
63                        &mut sections,
64                        &mut seen,
65                        slide_index,
66                        heading,
67                        std::mem::take(&mut current_body),
68                        heading_line,
69                        total_logical_slides,
70                    )?;
71                } else {
72                    preamble.clone_from(&current_body);
73                    current_body.clear();
74                }
75
76                let parsed = parse_heading_slide(line, next_slide_index, line_index + 1)?;
77                next_slide_index = parsed.slide_index + 1;
78                current_heading = Some((parsed.slide_index, line.to_string(), line_index + 1));
79            } else {
80                current_body.push_str(line);
81            }
82        }
83
84        if let Some((slide_index, heading, heading_line)) = current_heading {
85            push_section(
86                &mut sections,
87                &mut seen,
88                slide_index,
89                heading,
90                current_body,
91                heading_line,
92                total_logical_slides,
93            )?;
94        } else {
95            preamble = current_body;
96        }
97
98        Ok(Self { path, preamble, sections })
99    }
100
101    fn render_with_notes(&self, notes: &HashMap<usize, String>) -> String {
102        let mut output = self.preamble.clone();
103        let mut written = HashSet::new();
104
105        for section in &self.sections {
106            output.push_str(&section.heading);
107            if !section.heading.ends_with('\n') {
108                output.push('\n');
109            }
110
111            if let Some(note) = notes.get(&section.slide_index) {
112                push_note_body(&mut output, note);
113            }
114
115            written.insert(section.slide_index);
116        }
117
118        let mut added: Vec<_> = notes
119            .iter()
120            .filter(|(slide, note)| !written.contains(slide) && !note.trim().is_empty())
121            .collect();
122        added.sort_by_key(|(slide, _)| **slide);
123
124        for (slide, note) in added {
125            ensure_blank_line_before_appended_section(&mut output);
126            let _ = writeln!(output, "# Slide {} {{slide={}}}", slide + 1, slide + 1);
127            push_note_body(&mut output, note);
128        }
129
130        output
131    }
132}
133
134struct ParsedHeading {
135    slide_index: usize,
136}
137
138fn is_top_level_heading(line: &str) -> bool {
139    line.strip_prefix("# ").is_some()
140}
141
142fn parse_heading_slide(
143    line: &str,
144    default_slide_index: usize,
145    line_number: usize,
146) -> Result<ParsedHeading, SidecarError> {
147    let Some(anchor_start) = line.find("{slide") else {
148        return Ok(ParsedHeading { slide_index: default_slide_index });
149    };
150
151    let anchor = line[anchor_start..].trim();
152    let Some(anchor_body) = anchor.strip_prefix("{slide=") else {
153        return Err(parse_error(line_number, "Malformed slide anchor"));
154    };
155    let Some(anchor_end) = anchor_body.find('}') else {
156        return Err(parse_error(line_number, "Malformed slide anchor"));
157    };
158
159    let number = anchor_body[..anchor_end]
160        .trim()
161        .parse::<usize>()
162        .map_err(|_| parse_error(line_number, "Slide anchor must contain a positive integer"))?;
163    let slide_index = number
164        .checked_sub(1)
165        .ok_or_else(|| parse_error(line_number, "Slide anchor must use one-based slide numbers"))?;
166
167    Ok(ParsedHeading { slide_index })
168}
169
170fn push_section(
171    sections: &mut Vec<MarkdownNoteSection>,
172    seen: &mut HashSet<usize>,
173    slide_index: usize,
174    heading: String,
175    body: String,
176    heading_line: usize,
177    total_logical_slides: usize,
178) -> Result<(), SidecarError> {
179    if slide_index >= total_logical_slides {
180        return Err(parse_error(
181            heading_line,
182            format!(
183                "Slide {} is outside this presentation's {} logical slides",
184                slide_index + 1,
185                total_logical_slides
186            ),
187        ));
188    }
189    if !seen.insert(slide_index) {
190        return Err(parse_error(
191            heading_line,
192            format!("Duplicate notes section for slide {}", slide_index + 1),
193        ));
194    }
195    sections.push(MarkdownNoteSection { heading, slide_index, body });
196    Ok(())
197}
198
199fn push_note_body(output: &mut String, note: &str) {
200    let trimmed = note.trim();
201    if trimmed.is_empty() {
202        return;
203    }
204    if !output.ends_with('\n') {
205        output.push('\n');
206    }
207    output.push('\n');
208    output.push_str(trimmed);
209    output.push('\n');
210}
211
212fn ensure_blank_line_before_appended_section(output: &mut String) {
213    if output.is_empty() {
214        return;
215    }
216    if !output.ends_with('\n') {
217        output.push('\n');
218    }
219    if !output.ends_with("\n\n") {
220        output.push('\n');
221    }
222}
223
224fn parse_error(line: usize, message: impl Into<String>) -> SidecarError {
225    SidecarError::Parse { line, message: message.into() }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn maps_headings_in_order() {
234        let doc = MarkdownNotesDocument::parse(
235            PathBuf::from("notes.md"),
236            "# Motivation\n\nOpening.\n\n# Prior Work\n\nCompare.\n",
237            3,
238        )
239        .unwrap();
240
241        let notes = doc.notes_by_slide();
242        assert_eq!(notes[&0], "Opening.");
243        assert_eq!(notes[&1], "Compare.");
244    }
245
246    #[test]
247    fn explicit_slide_anchor_skips_and_resets_counter() {
248        let doc = MarkdownNotesDocument::parse(
249            PathBuf::from("notes.md"),
250            "# Motivation\n\nOpening.\n\n# Main Result {slide=4}\n\nSlow down.\n\n# Extra\n\nMore.\n",
251            5,
252        )
253        .unwrap();
254
255        let notes = doc.notes_by_slide();
256        assert_eq!(notes[&0], "Opening.");
257        assert_eq!(notes[&3], "Slow down.");
258        assert_eq!(notes[&4], "More.");
259        assert!(!notes.contains_key(&2));
260    }
261
262    #[test]
263    fn lower_level_headings_stay_in_body() {
264        let doc = MarkdownNotesDocument::parse(
265            PathBuf::from("notes.md"),
266            "# Main {slide=2}\n\n## Setup\n\nExplain.\n",
267            2,
268        )
269        .unwrap();
270
271        assert_eq!(doc.notes_by_slide()[&1], "## Setup\n\nExplain.");
272    }
273
274    #[test]
275    fn duplicate_slide_is_error() {
276        let err = MarkdownNotesDocument::parse(
277            PathBuf::from("notes.md"),
278            "# A {slide=1}\n\nA\n\n# B {slide=1}\n\nB\n",
279            2,
280        )
281        .unwrap_err();
282
283        assert!(err.to_string().contains("Duplicate"));
284    }
285
286    #[test]
287    fn render_updates_existing_and_appends_new_sections() {
288        let doc = MarkdownNotesDocument::parse(
289            PathBuf::from("notes.md"),
290            "---\ntitle: Talk\n---\n\n# Motivation\n\nOld.\n",
291            3,
292        )
293        .unwrap();
294        let notes = HashMap::from([(0, "New.".to_string()), (2, "Third.".to_string())]);
295
296        let rendered = doc.render_with_notes(&notes);
297
298        assert!(rendered.contains("---\ntitle: Talk\n---"));
299        assert!(rendered.contains("# Motivation\n\nNew.\n"));
300        assert!(rendered.contains("# Slide 3 {slide=3}\n\nThird.\n"));
301        assert!(!rendered.contains("Old."));
302    }
303}