Skip to main content

dais_sidecar/
dais_format.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::format::{SidecarError, SidecarFormat};
7use crate::types::{InkStrokeMeta, PresentationMetadata, SlideGroupMeta, TextBoxMeta};
8
9/// The native `.dais` sidecar format, stored as EON.
10pub struct DaisFormat;
11
12#[derive(Serialize, Deserialize)]
13struct DaisFile {
14    version: u32,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    title: Option<String>,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    end_slide: Option<usize>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    last_minutes: Option<u32>,
21    #[serde(default, skip_serializing_if = "Vec::is_empty")]
22    groups: Vec<DaisGroup>,
23    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
24    notes: HashMap<String, String>,
25    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
26    slide_timings: HashMap<String, f64>,
27    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
28    slide_target_durations: HashMap<String, f64>,
29    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
30    slide_annotations: HashMap<String, Vec<DaisInkStroke>>,
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    whiteboard_annotations: Vec<DaisInkStroke>,
33    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
34    slide_text_boxes: HashMap<String, Vec<DaisTextBox>>,
35}
36
37#[derive(Serialize, Deserialize)]
38struct DaisGroup {
39    start_page: usize,
40    end_page: usize,
41}
42
43#[derive(Serialize, Deserialize)]
44struct DaisInkStroke {
45    points: Vec<(f32, f32)>,
46    color: [u8; 4],
47    width: f32,
48}
49
50#[derive(Serialize, Deserialize)]
51struct DaisTextBox {
52    id: u64,
53    rect: (f32, f32, f32, f32),
54    content: String,
55    font_size: f32,
56    color: [u8; 4],
57    #[serde(skip_serializing_if = "Option::is_none")]
58    background: Option<[u8; 4]>,
59    #[serde(default, skip_serializing_if = "String::is_empty")]
60    typst_prelude: String,
61}
62
63/// `.dais` stores user-facing page and slide references as 1-based numbers.
64/// Convert at the format boundary so engine metadata can stay 0-based.
65impl DaisFile {
66    fn from_metadata(meta: &PresentationMetadata) -> Self {
67        Self {
68            version: 1,
69            title: meta.title.clone(),
70            end_slide: meta.end_slide.map(to_one_based),
71            last_minutes: meta.last_minutes,
72            groups: meta
73                .groups
74                .iter()
75                .map(|g| DaisGroup {
76                    start_page: to_one_based(g.start_page),
77                    end_page: to_one_based(g.end_page),
78                })
79                .collect(),
80            notes: one_based_map(&meta.notes),
81            slide_timings: one_based_f64_map(&meta.slide_timings),
82            slide_target_durations: meta
83                .slide_target_durations
84                .iter()
85                .map(|(k, v)| (to_one_based(*k).to_string(), *v))
86                .collect(),
87            slide_annotations: meta
88                .slide_annotations
89                .iter()
90                .filter(|(_, strokes)| !strokes.is_empty())
91                .map(|(k, v)| {
92                    (
93                        to_one_based(*k).to_string(),
94                        v.iter()
95                            .map(|s| DaisInkStroke {
96                                points: s.points.clone(),
97                                color: s.color,
98                                width: s.width,
99                            })
100                            .collect(),
101                    )
102                })
103                .collect(),
104            whiteboard_annotations: meta
105                .whiteboard_annotations
106                .iter()
107                .map(|s| DaisInkStroke { points: s.points.clone(), color: s.color, width: s.width })
108                .collect(),
109            slide_text_boxes: meta
110                .slide_text_boxes
111                .iter()
112                .filter(|(_, boxes)| !boxes.is_empty())
113                .map(|(k, v)| {
114                    (
115                        to_one_based(*k).to_string(),
116                        v.iter()
117                            .map(|tb| DaisTextBox {
118                                id: tb.id,
119                                rect: tb.rect,
120                                content: tb.content.clone(),
121                                font_size: tb.font_size,
122                                color: tb.color,
123                                background: tb.background,
124                                typst_prelude: tb.typst_prelude.clone(),
125                            })
126                            .collect(),
127                    )
128                })
129                .collect(),
130        }
131    }
132
133    fn into_metadata(self) -> PresentationMetadata {
134        PresentationMetadata {
135            title: self.title,
136            end_slide: self.end_slide.and_then(to_zero_based),
137            last_minutes: self.last_minutes,
138            groups: self
139                .groups
140                .into_iter()
141                .filter_map(|g| {
142                    Some(SlideGroupMeta {
143                        start_page: to_zero_based(g.start_page)?,
144                        end_page: to_zero_based(g.end_page)?,
145                    })
146                })
147                .collect(),
148            notes: self
149                .notes
150                .into_iter()
151                .filter_map(|(k, v)| parse_one_based_key(&k).map(|idx| (idx, v)))
152                .collect(),
153            slide_timings: self
154                .slide_timings
155                .into_iter()
156                .filter_map(|(k, v)| parse_one_based_key(&k).map(|idx| (idx, v)))
157                .collect(),
158            slide_target_durations: self
159                .slide_target_durations
160                .into_iter()
161                .filter_map(|(k, v)| parse_one_based_key(&k).map(|idx| (idx, v)))
162                .collect(),
163            slide_annotations: self
164                .slide_annotations
165                .into_iter()
166                .filter_map(|(k, v)| {
167                    parse_one_based_key(&k).map(|idx| {
168                        (
169                            idx,
170                            v.into_iter()
171                                .map(|s| InkStrokeMeta {
172                                    points: s.points,
173                                    color: s.color,
174                                    width: s.width,
175                                })
176                                .collect(),
177                        )
178                    })
179                })
180                .collect(),
181            whiteboard_annotations: self
182                .whiteboard_annotations
183                .into_iter()
184                .map(|s| InkStrokeMeta { points: s.points, color: s.color, width: s.width })
185                .collect(),
186            slide_text_boxes: self
187                .slide_text_boxes
188                .into_iter()
189                .filter_map(|(k, v)| {
190                    parse_one_based_key(&k).map(|idx| {
191                        (
192                            idx,
193                            v.into_iter()
194                                .map(|tb| TextBoxMeta {
195                                    id: tb.id,
196                                    rect: tb.rect,
197                                    content: tb.content,
198                                    font_size: tb.font_size,
199                                    color: tb.color,
200                                    background: tb.background,
201                                    typst_prelude: tb.typst_prelude,
202                                })
203                                .collect(),
204                        )
205                    })
206                })
207                .collect(),
208        }
209    }
210}
211
212fn to_one_based(index: usize) -> usize {
213    index + 1
214}
215
216fn to_zero_based(index: usize) -> Option<usize> {
217    index.checked_sub(1)
218}
219
220fn parse_one_based_key(key: &str) -> Option<usize> {
221    key.parse::<usize>().ok().and_then(to_zero_based)
222}
223
224fn one_based_map(map: &HashMap<usize, String>) -> HashMap<String, String> {
225    map.iter().map(|(k, v)| (to_one_based(*k).to_string(), v.clone())).collect()
226}
227
228fn one_based_f64_map(map: &HashMap<usize, f64>) -> HashMap<String, f64> {
229    map.iter().map(|(k, v)| (to_one_based(*k).to_string(), *v)).collect()
230}
231
232impl SidecarFormat for DaisFormat {
233    fn read(&self, path: &Path) -> Result<PresentationMetadata, SidecarError> {
234        let content = std::fs::read_to_string(path)?;
235        let file: DaisFile = eon::from_str(&content)
236            .map_err(|err| SidecarError::Parse { line: 0, message: err.to_string() })?;
237        Ok(file.into_metadata())
238    }
239
240    fn write(&self, path: &Path, metadata: &PresentationMetadata) -> Result<(), SidecarError> {
241        let file = DaisFile::from_metadata(metadata);
242        let options = eon::FormatOptions::default();
243        let content = eon::to_string(&file, &options)
244            .map_err(|err| SidecarError::Parse { line: 0, message: err.to_string() })?;
245        std::fs::write(path, content)?;
246        Ok(())
247    }
248
249    fn file_extension(&self) -> &'static str {
250        "dais"
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    fn test_dir() -> std::path::PathBuf {
259        let dir = std::env::temp_dir().join("dais_test_dais_format");
260        let _ = std::fs::create_dir_all(&dir);
261        dir
262    }
263
264    #[test]
265    fn roundtrip_empty_metadata() {
266        let dir = test_dir();
267        let path = dir.join("empty.dais");
268        let format = DaisFormat;
269
270        let original = PresentationMetadata::default();
271        format.write(&path, &original).unwrap();
272        let loaded = format.read(&path).unwrap();
273
274        assert!(loaded.title.is_none());
275        assert!(loaded.groups.is_empty());
276        assert!(loaded.notes.is_empty());
277        assert!(loaded.end_slide.is_none());
278        assert!(loaded.last_minutes.is_none());
279        assert!(loaded.slide_timings.is_empty());
280        assert!(loaded.slide_target_durations.is_empty());
281
282        let _ = std::fs::remove_file(&path);
283    }
284
285    #[test]
286    fn roundtrip_with_all_fields() {
287        let dir = test_dir();
288        let path = dir.join("full.dais");
289        let format = DaisFormat;
290
291        let original = PresentationMetadata {
292            title: Some("My Presentation".to_string()),
293            end_slide: Some(25),
294            last_minutes: Some(20),
295            groups: vec![
296                SlideGroupMeta { start_page: 0, end_page: 2 },
297                SlideGroupMeta { start_page: 3, end_page: 3 },
298            ],
299            notes: {
300                let mut n = HashMap::new();
301                n.insert(0, "Welcome everyone".to_string());
302                n.insert(5, "Key point here".to_string());
303                n
304            },
305            slide_timings: {
306                let mut t = HashMap::new();
307                t.insert(0, 12.5);
308                t.insert(1, 45.0);
309                t
310            },
311            slide_target_durations: {
312                let mut t = HashMap::new();
313                t.insert(0, 60.0);
314                t.insert(1, 90.0);
315                t
316            },
317            slide_annotations: HashMap::new(),
318            whiteboard_annotations: Vec::new(),
319            slide_text_boxes: HashMap::new(),
320        };
321
322        format.write(&path, &original).unwrap();
323        let loaded = format.read(&path).unwrap();
324
325        assert_eq!(loaded.title.as_deref(), Some("My Presentation"));
326        assert_eq!(loaded.end_slide, Some(25));
327        assert_eq!(loaded.last_minutes, Some(20));
328        assert_eq!(loaded.groups.len(), 2);
329        assert_eq!(loaded.groups[0].start_page, 0);
330        assert_eq!(loaded.groups[0].end_page, 2);
331        assert_eq!(loaded.groups[1].start_page, 3);
332        assert_eq!(loaded.groups[1].end_page, 3);
333        assert_eq!(loaded.notes.len(), 2);
334        assert_eq!(loaded.notes[&0], "Welcome everyone");
335        assert_eq!(loaded.notes[&5], "Key point here");
336        assert_eq!(loaded.slide_timings.len(), 2);
337        assert!((loaded.slide_timings[&0] - 12.5).abs() < f64::EPSILON);
338        assert!((loaded.slide_timings[&1] - 45.0).abs() < f64::EPSILON);
339        assert_eq!(loaded.slide_target_durations.len(), 2);
340        assert!((loaded.slide_target_durations[&0] - 60.0).abs() < f64::EPSILON);
341        assert!((loaded.slide_target_durations[&1] - 90.0).abs() < f64::EPSILON);
342
343        let _ = std::fs::remove_file(&path);
344    }
345
346    #[test]
347    fn written_indexes_are_one_based() {
348        let dir = test_dir();
349        let path = dir.join("one_based.dais");
350        let format = DaisFormat;
351
352        let mut notes = HashMap::new();
353        notes.insert(0, "First".to_string());
354        let mut slide_timings = HashMap::new();
355        slide_timings.insert(0, 12.0);
356        let mut slide_target_durations = HashMap::new();
357        slide_target_durations.insert(0, 30.0);
358        let mut slide_annotations = HashMap::new();
359        slide_annotations.insert(
360            0,
361            vec![InkStrokeMeta { points: vec![(0.1, 0.2)], color: [0, 0, 0, 255], width: 2.0 }],
362        );
363        let mut slide_text_boxes = HashMap::new();
364        slide_text_boxes.insert(
365            0,
366            vec![TextBoxMeta {
367                id: 1,
368                rect: (0.0, 0.0, 1.0, 1.0),
369                content: "Box".to_string(),
370                font_size: 12.0,
371                color: [0, 0, 0, 255],
372                background: None,
373                typst_prelude: String::new(),
374            }],
375        );
376
377        let original = PresentationMetadata {
378            end_slide: Some(0),
379            groups: vec![SlideGroupMeta { start_page: 0, end_page: 0 }],
380            notes,
381            slide_timings,
382            slide_target_durations,
383            slide_annotations,
384            slide_text_boxes,
385            ..Default::default()
386        };
387
388        format.write(&path, &original).unwrap();
389        let content = std::fs::read_to_string(&path).unwrap();
390
391        assert!(content.contains("end_slide: 1"));
392        assert!(content.contains("start_page: 1"));
393        assert!(content.contains("end_page: 1"));
394        assert!(content.contains("\"1\": \"First\""));
395        assert!(content.contains("slide_timings: {\n\t\"1\": 12.0"));
396        assert!(content.contains("slide_target_durations: {\n\t\"1\": 30.0"));
397        assert!(content.contains("slide_annotations: {\n\t\"1\": ["));
398        assert!(content.contains("slide_text_boxes: {\n\t\"1\": ["));
399
400        let _ = std::fs::remove_file(&path);
401    }
402
403    #[test]
404    fn version_field_is_present() {
405        let dir = test_dir();
406        let path = dir.join("version_check.dais");
407        let format = DaisFormat;
408
409        format.write(&path, &PresentationMetadata::default()).unwrap();
410        let content = std::fs::read_to_string(&path).unwrap();
411        assert!(content.contains("version: 1"));
412
413        let _ = std::fs::remove_file(&path);
414    }
415
416    #[test]
417    fn unknown_version_still_parses() {
418        let dir = test_dir();
419        let path = dir.join("future_version.dais");
420
421        let content = "version: 2\ntitle: \"Future talk\"\n";
422        std::fs::write(&path, content).unwrap();
423
424        let format = DaisFormat;
425        let loaded = format.read(&path).unwrap();
426        assert_eq!(loaded.title.as_deref(), Some("Future talk"));
427
428        let _ = std::fs::remove_file(&path);
429    }
430
431    #[test]
432    fn roundtrip_slide_annotations() {
433        let dir = test_dir();
434        let path = dir.join("annotations.dais");
435        let format = DaisFormat;
436
437        let mut slide_annotations = HashMap::new();
438        slide_annotations.insert(
439            0,
440            vec![InkStrokeMeta {
441                points: vec![(0.1, 0.2), (0.3, 0.4)],
442                color: [255, 0, 0, 255],
443                width: 3.0,
444            }],
445        );
446        slide_annotations.insert(
447            5,
448            vec![
449                InkStrokeMeta { points: vec![(0.5, 0.5)], color: [0, 255, 0, 255], width: 2.0 },
450                InkStrokeMeta {
451                    points: vec![(0.7, 0.8), (0.9, 0.1)],
452                    color: [0, 0, 255, 128],
453                    width: 5.0,
454                },
455            ],
456        );
457
458        let original = PresentationMetadata { slide_annotations, ..Default::default() };
459
460        format.write(&path, &original).unwrap();
461        let loaded = format.read(&path).unwrap();
462
463        assert_eq!(loaded.slide_annotations.len(), 2);
464        assert_eq!(loaded.slide_annotations[&0].len(), 1);
465        assert_eq!(loaded.slide_annotations[&0][0].points, vec![(0.1, 0.2), (0.3, 0.4)]);
466        assert_eq!(loaded.slide_annotations[&0][0].color, [255, 0, 0, 255]);
467        assert!((loaded.slide_annotations[&0][0].width - 3.0).abs() < f32::EPSILON);
468        assert_eq!(loaded.slide_annotations[&5].len(), 2);
469
470        let _ = std::fs::remove_file(&path);
471    }
472
473    #[test]
474    fn roundtrip_whiteboard_annotations() {
475        let dir = test_dir();
476        let path = dir.join("whiteboard.dais");
477        let format = DaisFormat;
478
479        let original = PresentationMetadata {
480            whiteboard_annotations: vec![InkStrokeMeta {
481                points: vec![(0.1, 0.1), (0.9, 0.9)],
482                color: [0, 0, 0, 255],
483                width: 4.0,
484            }],
485            ..Default::default()
486        };
487
488        format.write(&path, &original).unwrap();
489        let loaded = format.read(&path).unwrap();
490
491        assert_eq!(loaded.whiteboard_annotations.len(), 1);
492        assert_eq!(loaded.whiteboard_annotations[0].points, vec![(0.1, 0.1), (0.9, 0.9)]);
493        assert_eq!(loaded.whiteboard_annotations[0].color, [0, 0, 0, 255]);
494        assert!((loaded.whiteboard_annotations[0].width - 4.0).abs() < f32::EPSILON);
495
496        let _ = std::fs::remove_file(&path);
497    }
498
499    #[test]
500    fn alpha_roundtrips_for_slide_annotations() {
501        let dir = test_dir();
502        let path = dir.join("alpha_slide.dais");
503        let format = DaisFormat;
504
505        let mut slide_annotations = HashMap::new();
506        slide_annotations.insert(
507            0,
508            vec![InkStrokeMeta {
509                points: vec![(0.1, 0.2)],
510                color: [255, 128, 0, 77], // non-opaque alpha
511                width: 3.0,
512            }],
513        );
514        let original = PresentationMetadata { slide_annotations, ..Default::default() };
515
516        format.write(&path, &original).unwrap();
517        let loaded = format.read(&path).unwrap();
518
519        let stroke = &loaded.slide_annotations[&0][0];
520        assert_eq!(stroke.color, [255, 128, 0, 77], "RGBA including alpha must roundtrip exactly");
521
522        let _ = std::fs::remove_file(&path);
523    }
524
525    #[test]
526    fn alpha_roundtrips_for_whiteboard_annotations() {
527        let dir = test_dir();
528        let path = dir.join("alpha_whiteboard.dais");
529        let format = DaisFormat;
530
531        let original = PresentationMetadata {
532            whiteboard_annotations: vec![InkStrokeMeta {
533                points: vec![(0.5, 0.5)],
534                color: [0, 200, 255, 51], // 20% alpha — highlighter-like
535                width: 8.0,
536            }],
537            ..Default::default()
538        };
539
540        format.write(&path, &original).unwrap();
541        let loaded = format.read(&path).unwrap();
542
543        let stroke = &loaded.whiteboard_annotations[0];
544        assert_eq!(stroke.color, [0, 200, 255, 51], "Whiteboard RGBA + alpha must roundtrip");
545        assert!((stroke.width - 8.0).abs() < f32::EPSILON, "Whiteboard width must roundtrip");
546
547        let _ = std::fs::remove_file(&path);
548    }
549
550    #[test]
551    fn width_roundtrips_for_non_default_values() {
552        let dir = test_dir();
553        let path = dir.join("width.dais");
554        let format = DaisFormat;
555
556        let mut slide_annotations = HashMap::new();
557        slide_annotations.insert(
558            2,
559            vec![InkStrokeMeta { points: vec![(0.0, 0.0)], color: [0, 0, 0, 255], width: 12.5 }],
560        );
561        let original = PresentationMetadata { slide_annotations, ..Default::default() };
562
563        format.write(&path, &original).unwrap();
564        let loaded = format.read(&path).unwrap();
565
566        let stroke = &loaded.slide_annotations[&2][0];
567        assert!((stroke.width - 12.5).abs() < f32::EPSILON, "Non-default width must roundtrip");
568
569        let _ = std::fs::remove_file(&path);
570    }
571
572    #[test]
573    fn missing_annotation_fields_parse_cleanly() {
574        let dir = test_dir();
575        let path = dir.join("no_annotations.dais");
576
577        // A .dais file with no annotation fields — older format
578        let content = "version: 1\ntitle: \"No annotations\"\n";
579        std::fs::write(&path, content).unwrap();
580
581        let format = DaisFormat;
582        let loaded = format.read(&path).unwrap();
583        assert_eq!(loaded.title.as_deref(), Some("No annotations"));
584        assert!(loaded.slide_annotations.is_empty());
585        assert!(loaded.whiteboard_annotations.is_empty());
586
587        let _ = std::fs::remove_file(&path);
588    }
589
590    #[test]
591    fn text_box_typst_prelude_roundtrips() {
592        let dir = test_dir();
593        let path = dir.join("text_box_prelude.dais");
594        let format = DaisFormat;
595
596        let mut slide_text_boxes = HashMap::new();
597        slide_text_boxes.insert(
598            0,
599            vec![TextBoxMeta {
600                id: 7,
601                rect: (0.1, 0.2, 0.3, 0.4),
602                content: "$pi r^2$".to_string(),
603                font_size: 24.0,
604                color: [0, 0, 0, 255],
605                background: None,
606                typst_prelude: "#set align(horizon)".to_string(),
607            }],
608        );
609        let original = PresentationMetadata { slide_text_boxes, ..Default::default() };
610
611        format.write(&path, &original).unwrap();
612        let content = std::fs::read_to_string(&path).unwrap();
613        assert!(content.contains("typst_prelude"));
614
615        let loaded = format.read(&path).unwrap();
616        assert_eq!(loaded.slide_text_boxes[&0][0].typst_prelude, "#set align(horizon)");
617
618        let _ = std::fs::remove_file(&path);
619    }
620
621    #[test]
622    fn text_box_without_typst_prelude_parses_as_empty() {
623        let dir = test_dir();
624        let path = dir.join("text_box_no_prelude.dais");
625        let format = DaisFormat;
626
627        let mut slide_text_boxes = HashMap::new();
628        slide_text_boxes.insert(
629            0,
630            vec![TextBoxMeta {
631                id: 1,
632                rect: (0.1, 0.2, 0.3, 0.4),
633                content: "Hello".to_string(),
634                font_size: 20.0,
635                color: [0, 0, 0, 255],
636                background: None,
637                typst_prelude: String::new(),
638            }],
639        );
640        let original = PresentationMetadata { slide_text_boxes, ..Default::default() };
641        format.write(&path, &original).unwrap();
642
643        let content = std::fs::read_to_string(&path).unwrap();
644        assert!(!content.contains("typst_prelude"));
645
646        let loaded = format.read(&path).unwrap();
647        assert_eq!(loaded.slide_text_boxes[&0][0].typst_prelude, "");
648
649        let _ = std::fs::remove_file(&path);
650    }
651
652    #[test]
653    fn existing_fields_roundtrip_with_annotations() {
654        let dir = test_dir();
655        let path = dir.join("full_with_annotations.dais");
656        let format = DaisFormat;
657
658        let mut slide_annotations = HashMap::new();
659        slide_annotations.insert(
660            0,
661            vec![InkStrokeMeta { points: vec![(0.1, 0.2)], color: [255, 0, 0, 255], width: 3.0 }],
662        );
663
664        let original = PresentationMetadata {
665            title: Some("With Annotations".to_string()),
666            end_slide: Some(10),
667            last_minutes: Some(15),
668            groups: vec![SlideGroupMeta { start_page: 0, end_page: 2 }],
669            notes: {
670                let mut n = HashMap::new();
671                n.insert(0, "Note".to_string());
672                n
673            },
674            slide_timings: {
675                let mut t = HashMap::new();
676                t.insert(0, 5.0);
677                t
678            },
679            slide_target_durations: {
680                let mut t = HashMap::new();
681                t.insert(0, 30.0);
682                t
683            },
684            slide_annotations,
685            whiteboard_annotations: vec![InkStrokeMeta {
686                points: vec![(0.5, 0.5)],
687                color: [0, 0, 255, 255],
688                width: 2.0,
689            }],
690            slide_text_boxes: HashMap::new(),
691        };
692
693        format.write(&path, &original).unwrap();
694        let loaded = format.read(&path).unwrap();
695
696        // Existing fields preserved
697        assert_eq!(loaded.title.as_deref(), Some("With Annotations"));
698        assert_eq!(loaded.end_slide, Some(10));
699        assert_eq!(loaded.last_minutes, Some(15));
700        assert_eq!(loaded.groups.len(), 1);
701        assert_eq!(loaded.notes.len(), 1);
702        assert_eq!(loaded.slide_timings.len(), 1);
703        assert_eq!(loaded.slide_target_durations.len(), 1);
704
705        // Annotations also preserved
706        assert_eq!(loaded.slide_annotations.len(), 1);
707        assert_eq!(loaded.whiteboard_annotations.len(), 1);
708
709        let _ = std::fs::remove_file(&path);
710    }
711}