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_annotations: HashMap<String, Vec<DaisInkStroke>>,
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    whiteboard_annotations: Vec<DaisInkStroke>,
31    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
32    slide_text_boxes: HashMap<String, Vec<DaisTextBox>>,
33}
34
35#[derive(Serialize, Deserialize)]
36struct DaisGroup {
37    start_page: usize,
38    end_page: usize,
39}
40
41#[derive(Serialize, Deserialize)]
42struct DaisInkStroke {
43    points: Vec<(f32, f32)>,
44    color: [u8; 4],
45    width: f32,
46}
47
48#[derive(Serialize, Deserialize)]
49struct DaisTextBox {
50    id: u64,
51    rect: (f32, f32, f32, f32),
52    content: String,
53    font_size: f32,
54    color: [u8; 4],
55    #[serde(skip_serializing_if = "Option::is_none")]
56    background: Option<[u8; 4]>,
57}
58
59impl DaisFile {
60    fn from_metadata(meta: &PresentationMetadata) -> Self {
61        Self {
62            version: 1,
63            title: meta.title.clone(),
64            end_slide: meta.end_slide,
65            last_minutes: meta.last_minutes,
66            groups: meta
67                .groups
68                .iter()
69                .map(|g| DaisGroup { start_page: g.start_page, end_page: g.end_page })
70                .collect(),
71            notes: meta.notes.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(),
72            slide_timings: meta.slide_timings.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
73            slide_annotations: meta
74                .slide_annotations
75                .iter()
76                .filter(|(_, strokes)| !strokes.is_empty())
77                .map(|(k, v)| {
78                    (
79                        k.to_string(),
80                        v.iter()
81                            .map(|s| DaisInkStroke {
82                                points: s.points.clone(),
83                                color: s.color,
84                                width: s.width,
85                            })
86                            .collect(),
87                    )
88                })
89                .collect(),
90            whiteboard_annotations: meta
91                .whiteboard_annotations
92                .iter()
93                .map(|s| DaisInkStroke { points: s.points.clone(), color: s.color, width: s.width })
94                .collect(),
95            slide_text_boxes: meta
96                .slide_text_boxes
97                .iter()
98                .filter(|(_, boxes)| !boxes.is_empty())
99                .map(|(k, v)| {
100                    (
101                        k.to_string(),
102                        v.iter()
103                            .map(|tb| DaisTextBox {
104                                id: tb.id,
105                                rect: tb.rect,
106                                content: tb.content.clone(),
107                                font_size: tb.font_size,
108                                color: tb.color,
109                                background: tb.background,
110                            })
111                            .collect(),
112                    )
113                })
114                .collect(),
115        }
116    }
117
118    fn into_metadata(self) -> PresentationMetadata {
119        PresentationMetadata {
120            title: self.title,
121            end_slide: self.end_slide,
122            last_minutes: self.last_minutes,
123            groups: self
124                .groups
125                .into_iter()
126                .map(|g| SlideGroupMeta { start_page: g.start_page, end_page: g.end_page })
127                .collect(),
128            notes: self
129                .notes
130                .into_iter()
131                .filter_map(|(k, v)| k.parse::<usize>().ok().map(|idx| (idx, v)))
132                .collect(),
133            slide_timings: self
134                .slide_timings
135                .into_iter()
136                .filter_map(|(k, v)| k.parse::<usize>().ok().map(|idx| (idx, v)))
137                .collect(),
138            slide_annotations: self
139                .slide_annotations
140                .into_iter()
141                .filter_map(|(k, v)| {
142                    k.parse::<usize>().ok().map(|idx| {
143                        (
144                            idx,
145                            v.into_iter()
146                                .map(|s| InkStrokeMeta {
147                                    points: s.points,
148                                    color: s.color,
149                                    width: s.width,
150                                })
151                                .collect(),
152                        )
153                    })
154                })
155                .collect(),
156            whiteboard_annotations: self
157                .whiteboard_annotations
158                .into_iter()
159                .map(|s| InkStrokeMeta { points: s.points, color: s.color, width: s.width })
160                .collect(),
161            slide_text_boxes: self
162                .slide_text_boxes
163                .into_iter()
164                .filter_map(|(k, v)| {
165                    k.parse::<usize>().ok().map(|idx| {
166                        (
167                            idx,
168                            v.into_iter()
169                                .map(|tb| TextBoxMeta {
170                                    id: tb.id,
171                                    rect: tb.rect,
172                                    content: tb.content,
173                                    font_size: tb.font_size,
174                                    color: tb.color,
175                                    background: tb.background,
176                                })
177                                .collect(),
178                        )
179                    })
180                })
181                .collect(),
182        }
183    }
184}
185
186impl SidecarFormat for DaisFormat {
187    fn read(&self, path: &Path) -> Result<PresentationMetadata, SidecarError> {
188        let content = std::fs::read_to_string(path)?;
189        let file: DaisFile = eon::from_str(&content)
190            .map_err(|err| SidecarError::Parse { line: 0, message: err.to_string() })?;
191        Ok(file.into_metadata())
192    }
193
194    fn write(&self, path: &Path, metadata: &PresentationMetadata) -> Result<(), SidecarError> {
195        let file = DaisFile::from_metadata(metadata);
196        let options = eon::FormatOptions::default();
197        let content = eon::to_string(&file, &options)
198            .map_err(|err| SidecarError::Parse { line: 0, message: err.to_string() })?;
199        std::fs::write(path, content)?;
200        Ok(())
201    }
202
203    fn file_extension(&self) -> &'static str {
204        "dais"
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    fn test_dir() -> std::path::PathBuf {
213        let dir = std::env::temp_dir().join("dais_test_dais_format");
214        let _ = std::fs::create_dir_all(&dir);
215        dir
216    }
217
218    #[test]
219    fn roundtrip_empty_metadata() {
220        let dir = test_dir();
221        let path = dir.join("empty.dais");
222        let format = DaisFormat;
223
224        let original = PresentationMetadata::default();
225        format.write(&path, &original).unwrap();
226        let loaded = format.read(&path).unwrap();
227
228        assert!(loaded.title.is_none());
229        assert!(loaded.groups.is_empty());
230        assert!(loaded.notes.is_empty());
231        assert!(loaded.end_slide.is_none());
232        assert!(loaded.last_minutes.is_none());
233        assert!(loaded.slide_timings.is_empty());
234
235        let _ = std::fs::remove_file(&path);
236    }
237
238    #[test]
239    fn roundtrip_with_all_fields() {
240        let dir = test_dir();
241        let path = dir.join("full.dais");
242        let format = DaisFormat;
243
244        let original = PresentationMetadata {
245            title: Some("My Presentation".to_string()),
246            end_slide: Some(25),
247            last_minutes: Some(20),
248            groups: vec![
249                SlideGroupMeta { start_page: 0, end_page: 2 },
250                SlideGroupMeta { start_page: 3, end_page: 3 },
251            ],
252            notes: {
253                let mut n = HashMap::new();
254                n.insert(0, "Welcome everyone".to_string());
255                n.insert(5, "Key point here".to_string());
256                n
257            },
258            slide_timings: {
259                let mut t = HashMap::new();
260                t.insert(0, 12.5);
261                t.insert(1, 45.0);
262                t
263            },
264            slide_annotations: HashMap::new(),
265            whiteboard_annotations: Vec::new(),
266            slide_text_boxes: HashMap::new(),
267        };
268
269        format.write(&path, &original).unwrap();
270        let loaded = format.read(&path).unwrap();
271
272        assert_eq!(loaded.title.as_deref(), Some("My Presentation"));
273        assert_eq!(loaded.end_slide, Some(25));
274        assert_eq!(loaded.last_minutes, Some(20));
275        assert_eq!(loaded.groups.len(), 2);
276        assert_eq!(loaded.groups[0].start_page, 0);
277        assert_eq!(loaded.groups[0].end_page, 2);
278        assert_eq!(loaded.groups[1].start_page, 3);
279        assert_eq!(loaded.groups[1].end_page, 3);
280        assert_eq!(loaded.notes.len(), 2);
281        assert_eq!(loaded.notes[&0], "Welcome everyone");
282        assert_eq!(loaded.notes[&5], "Key point here");
283        assert_eq!(loaded.slide_timings.len(), 2);
284        assert!((loaded.slide_timings[&0] - 12.5).abs() < f64::EPSILON);
285        assert!((loaded.slide_timings[&1] - 45.0).abs() < f64::EPSILON);
286
287        let _ = std::fs::remove_file(&path);
288    }
289
290    #[test]
291    fn version_field_is_present() {
292        let dir = test_dir();
293        let path = dir.join("version_check.dais");
294        let format = DaisFormat;
295
296        format.write(&path, &PresentationMetadata::default()).unwrap();
297        let content = std::fs::read_to_string(&path).unwrap();
298        assert!(content.contains("version: 1"));
299
300        let _ = std::fs::remove_file(&path);
301    }
302
303    #[test]
304    fn unknown_version_still_parses() {
305        let dir = test_dir();
306        let path = dir.join("future_version.dais");
307
308        let content = "version: 2\ntitle: \"Future talk\"\n";
309        std::fs::write(&path, content).unwrap();
310
311        let format = DaisFormat;
312        let loaded = format.read(&path).unwrap();
313        assert_eq!(loaded.title.as_deref(), Some("Future talk"));
314
315        let _ = std::fs::remove_file(&path);
316    }
317
318    #[test]
319    fn roundtrip_slide_annotations() {
320        let dir = test_dir();
321        let path = dir.join("annotations.dais");
322        let format = DaisFormat;
323
324        let mut slide_annotations = HashMap::new();
325        slide_annotations.insert(
326            0,
327            vec![InkStrokeMeta {
328                points: vec![(0.1, 0.2), (0.3, 0.4)],
329                color: [255, 0, 0, 255],
330                width: 3.0,
331            }],
332        );
333        slide_annotations.insert(
334            5,
335            vec![
336                InkStrokeMeta { points: vec![(0.5, 0.5)], color: [0, 255, 0, 255], width: 2.0 },
337                InkStrokeMeta {
338                    points: vec![(0.7, 0.8), (0.9, 0.1)],
339                    color: [0, 0, 255, 128],
340                    width: 5.0,
341                },
342            ],
343        );
344
345        let original = PresentationMetadata { slide_annotations, ..Default::default() };
346
347        format.write(&path, &original).unwrap();
348        let loaded = format.read(&path).unwrap();
349
350        assert_eq!(loaded.slide_annotations.len(), 2);
351        assert_eq!(loaded.slide_annotations[&0].len(), 1);
352        assert_eq!(loaded.slide_annotations[&0][0].points, vec![(0.1, 0.2), (0.3, 0.4)]);
353        assert_eq!(loaded.slide_annotations[&0][0].color, [255, 0, 0, 255]);
354        assert!((loaded.slide_annotations[&0][0].width - 3.0).abs() < f32::EPSILON);
355        assert_eq!(loaded.slide_annotations[&5].len(), 2);
356
357        let _ = std::fs::remove_file(&path);
358    }
359
360    #[test]
361    fn roundtrip_whiteboard_annotations() {
362        let dir = test_dir();
363        let path = dir.join("whiteboard.dais");
364        let format = DaisFormat;
365
366        let original = PresentationMetadata {
367            whiteboard_annotations: vec![InkStrokeMeta {
368                points: vec![(0.1, 0.1), (0.9, 0.9)],
369                color: [0, 0, 0, 255],
370                width: 4.0,
371            }],
372            ..Default::default()
373        };
374
375        format.write(&path, &original).unwrap();
376        let loaded = format.read(&path).unwrap();
377
378        assert_eq!(loaded.whiteboard_annotations.len(), 1);
379        assert_eq!(loaded.whiteboard_annotations[0].points, vec![(0.1, 0.1), (0.9, 0.9)]);
380        assert_eq!(loaded.whiteboard_annotations[0].color, [0, 0, 0, 255]);
381        assert!((loaded.whiteboard_annotations[0].width - 4.0).abs() < f32::EPSILON);
382
383        let _ = std::fs::remove_file(&path);
384    }
385
386    #[test]
387    fn alpha_roundtrips_for_slide_annotations() {
388        let dir = test_dir();
389        let path = dir.join("alpha_slide.dais");
390        let format = DaisFormat;
391
392        let mut slide_annotations = HashMap::new();
393        slide_annotations.insert(
394            0,
395            vec![InkStrokeMeta {
396                points: vec![(0.1, 0.2)],
397                color: [255, 128, 0, 77], // non-opaque alpha
398                width: 3.0,
399            }],
400        );
401        let original = PresentationMetadata { slide_annotations, ..Default::default() };
402
403        format.write(&path, &original).unwrap();
404        let loaded = format.read(&path).unwrap();
405
406        let stroke = &loaded.slide_annotations[&0][0];
407        assert_eq!(stroke.color, [255, 128, 0, 77], "RGBA including alpha must roundtrip exactly");
408
409        let _ = std::fs::remove_file(&path);
410    }
411
412    #[test]
413    fn alpha_roundtrips_for_whiteboard_annotations() {
414        let dir = test_dir();
415        let path = dir.join("alpha_whiteboard.dais");
416        let format = DaisFormat;
417
418        let original = PresentationMetadata {
419            whiteboard_annotations: vec![InkStrokeMeta {
420                points: vec![(0.5, 0.5)],
421                color: [0, 200, 255, 51], // 20% alpha — highlighter-like
422                width: 8.0,
423            }],
424            ..Default::default()
425        };
426
427        format.write(&path, &original).unwrap();
428        let loaded = format.read(&path).unwrap();
429
430        let stroke = &loaded.whiteboard_annotations[0];
431        assert_eq!(stroke.color, [0, 200, 255, 51], "Whiteboard RGBA + alpha must roundtrip");
432        assert!((stroke.width - 8.0).abs() < f32::EPSILON, "Whiteboard width must roundtrip");
433
434        let _ = std::fs::remove_file(&path);
435    }
436
437    #[test]
438    fn width_roundtrips_for_non_default_values() {
439        let dir = test_dir();
440        let path = dir.join("width.dais");
441        let format = DaisFormat;
442
443        let mut slide_annotations = HashMap::new();
444        slide_annotations.insert(
445            2,
446            vec![InkStrokeMeta { points: vec![(0.0, 0.0)], color: [0, 0, 0, 255], width: 12.5 }],
447        );
448        let original = PresentationMetadata { slide_annotations, ..Default::default() };
449
450        format.write(&path, &original).unwrap();
451        let loaded = format.read(&path).unwrap();
452
453        let stroke = &loaded.slide_annotations[&2][0];
454        assert!((stroke.width - 12.5).abs() < f32::EPSILON, "Non-default width must roundtrip");
455
456        let _ = std::fs::remove_file(&path);
457    }
458
459    #[test]
460    fn missing_annotation_fields_parse_cleanly() {
461        let dir = test_dir();
462        let path = dir.join("no_annotations.dais");
463
464        // A .dais file with no annotation fields — older format
465        let content = "version: 1\ntitle: \"No annotations\"\n";
466        std::fs::write(&path, content).unwrap();
467
468        let format = DaisFormat;
469        let loaded = format.read(&path).unwrap();
470        assert_eq!(loaded.title.as_deref(), Some("No annotations"));
471        assert!(loaded.slide_annotations.is_empty());
472        assert!(loaded.whiteboard_annotations.is_empty());
473
474        let _ = std::fs::remove_file(&path);
475    }
476
477    #[test]
478    fn existing_fields_roundtrip_with_annotations() {
479        let dir = test_dir();
480        let path = dir.join("full_with_annotations.dais");
481        let format = DaisFormat;
482
483        let mut slide_annotations = HashMap::new();
484        slide_annotations.insert(
485            0,
486            vec![InkStrokeMeta { points: vec![(0.1, 0.2)], color: [255, 0, 0, 255], width: 3.0 }],
487        );
488
489        let original = PresentationMetadata {
490            title: Some("With Annotations".to_string()),
491            end_slide: Some(10),
492            last_minutes: Some(15),
493            groups: vec![SlideGroupMeta { start_page: 0, end_page: 2 }],
494            notes: {
495                let mut n = HashMap::new();
496                n.insert(0, "Note".to_string());
497                n
498            },
499            slide_timings: {
500                let mut t = HashMap::new();
501                t.insert(0, 5.0);
502                t
503            },
504            slide_annotations,
505            whiteboard_annotations: vec![InkStrokeMeta {
506                points: vec![(0.5, 0.5)],
507                color: [0, 0, 255, 255],
508                width: 2.0,
509            }],
510            slide_text_boxes: HashMap::new(),
511        };
512
513        format.write(&path, &original).unwrap();
514        let loaded = format.read(&path).unwrap();
515
516        // Existing fields preserved
517        assert_eq!(loaded.title.as_deref(), Some("With Annotations"));
518        assert_eq!(loaded.end_slide, Some(10));
519        assert_eq!(loaded.last_minutes, Some(15));
520        assert_eq!(loaded.groups.len(), 1);
521        assert_eq!(loaded.notes.len(), 1);
522        assert_eq!(loaded.slide_timings.len(), 1);
523
524        // Annotations also preserved
525        assert_eq!(loaded.slide_annotations.len(), 1);
526        assert_eq!(loaded.whiteboard_annotations.len(), 1);
527
528        let _ = std::fs::remove_file(&path);
529    }
530}