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
9pub 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], 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], 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 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 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 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}