1use std::collections::{HashMap, HashSet};
2use std::fmt::Write as _;
3use std::path::{Path, PathBuf};
4
5use crate::format::SidecarError;
6
7#[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 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 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 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(¤t_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(§ion.heading);
107 if !section.heading.ends_with('\n') {
108 output.push('\n');
109 }
110
111 if let Some(note) = notes.get(§ion.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(¬es);
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}