1use pptx_parse::{CommentFlavor, PptxPackage};
2use sha2::{Digest, Sha256};
3use yrs::types::AsPrelim;
4use yrs::{Map, MapPrelim, MapRef, ReadTxn, Transact, TransactionMut, WriteTxn};
5
6use crate::deck::{SourceImport, map_bool, map_number, map_string, required_map, slide_ref};
7use crate::{
8 BOOTSTRAP_CLIENT_ID, COMMENTS, CommentReceipt, CommentSnapshot, DeckSession, EditCtx,
9 EditError, EditResult, META, MIGRATE_ORIGIN, doc_with_client_id, hydrate_doc,
10};
11
12pub(crate) fn seeded_comment_id(index: usize, source_id: &str) -> String {
13 format!("comment:{index}:{source_id}")
14}
15
16pub(crate) fn seed_comments(
17 txn: &mut TransactionMut<'_>,
18 package: &PptxPackage,
19 slide_id_by_part: &dyn Fn(&str) -> Option<String>,
20) -> EditResult<()> {
21 let comments = txn.get_or_insert_map(COMMENTS);
22 for (index, comment) in package.comments.iter().enumerate() {
23 let Some(slide_id) = slide_id_by_part(&comment.slide_part_path) else {
24 continue;
25 };
26 let author = package
27 .comment_authors
28 .iter()
29 .find(|author| author.id == comment.author_id);
30 let id = seeded_comment_id(index, &comment.id);
31 let entry = comments.insert(txn, id.as_str(), MapPrelim::default());
32 entry.insert(txn, "id", id.as_str());
33 entry.insert(txn, "slideId", slide_id.as_str());
34 entry.insert(
35 txn,
36 "author",
37 author.map(|author| author.name.as_str()).unwrap_or(""),
38 );
39 entry.insert(
40 txn,
41 "initials",
42 author.map(|author| author.initials.as_str()).unwrap_or(""),
43 );
44 entry.insert(txn, "text", comment.text.as_str());
45 if let Some(created) = &comment.created {
46 entry.insert(txn, "created", created.as_str());
47 }
48 entry.insert(txn, "x", comment.x_emu as f64);
49 entry.insert(txn, "y", comment.y_emu as f64);
50 if let Some(parent) = &comment.parent_id {
51 let parent_index = package.comments.iter().position(|candidate| {
52 &candidate.id == parent && candidate.slide_part_path == comment.slide_part_path
53 });
54 if let Some(parent_index) = parent_index {
55 entry.insert(
56 txn,
57 "parentId",
58 seeded_comment_id(parent_index, parent).as_str(),
59 );
60 }
61 }
62 entry.insert(
63 txn,
64 "resolved",
65 comment.status.as_deref() == Some("resolved"),
66 );
67 }
68 Ok(())
69}
70
71pub(crate) fn import_source_comments(
72 session: &DeckSession,
73 import: &mut SourceImport<'_>,
74) -> EditResult<()> {
75 {
76 let txn = session.doc.transact();
77 let meta = required_map(&txn, META)?;
78 if map_bool(&meta, &txn, "commentsPendingSource") != Some(true) {
79 return Ok(());
80 }
81 }
82 let bootstrap = doc_with_client_id(BOOTSTRAP_CLIENT_ID);
83 hydrate_doc(&bootstrap, &session.encode_state_as_update_v1())?;
84 let slide_ids: std::collections::HashMap<_, _> = import
85 .source
86 .presentation
87 .slides
88 .iter()
89 .enumerate()
90 .map(|(index, slide)| {
91 (
92 slide.part_path.as_str(),
93 format!("slide:{index}:{}", slide.id),
94 )
95 })
96 .collect();
97 seed_comments(
98 &mut bootstrap.transact_mut_with(MIGRATE_ORIGIN),
99 import.source,
100 &|part| slide_ids.get(part).cloned(),
101 )?;
102 let update = bootstrap
103 .transact()
104 .encode_diff_v1(&session.doc.transact().state_vector());
105 hydrate_doc(&session.doc, &update)?;
106 import.package.comments.clone_from(&import.source.comments);
107 import
108 .package
109 .comment_authors
110 .clone_from(&import.source.comment_authors);
111 import.package.comment_flavor = import.source.comment_flavor;
112 let mut txn = session.doc.transact_mut_with(MIGRATE_ORIGIN);
113 let meta = required_map(&txn, META)?;
114 meta.insert(
115 &mut txn,
116 "commentFlavor",
117 flavor_key(import.source.comment_flavor.unwrap_or_default()),
118 );
119 meta.insert(&mut txn, "commentsPendingSource", false);
120 Ok(())
121}
122
123pub(crate) fn snapshot_comments<T: ReadTxn>(txn: &T) -> EditResult<Vec<CommentSnapshot>> {
124 let comments = required_map(txn, COMMENTS)?;
125 comment_keys(txn)?
126 .iter()
127 .map(|key| snapshot_comment(&comments, txn, &key.id))
128 .collect()
129}
130
131pub(crate) struct CommentKey {
133 pub id: String,
134 pub slide_id: String,
135}
136
137pub(crate) fn comment_keys<T: ReadTxn>(txn: &T) -> EditResult<Vec<CommentKey>> {
139 let comments = required_map(txn, COMMENTS)?;
140 let mut keys = Vec::new();
141 for (id, value) in comments.iter(txn) {
142 let Ok(entry) = value.cast::<MapRef>() else {
143 return Err(EditError::InvalidState(format!(
144 "comment {id} is not a map"
145 )));
146 };
147 let slide_id = map_string(&entry, txn, "slideId").unwrap_or_default();
148 if slide_ref(txn, &slide_id).is_err() {
149 continue;
150 }
151 keys.push((map_string(&entry, txn, "created"), id.to_owned(), slide_id));
152 }
153 keys.sort();
154 Ok(keys
155 .into_iter()
156 .map(|(_, id, slide_id)| CommentKey { id, slide_id })
157 .collect())
158}
159
160pub(crate) fn snapshot_comment<T: ReadTxn>(
161 comments: &MapRef,
162 txn: &T,
163 id: &str,
164) -> EditResult<CommentSnapshot> {
165 let entry = comments
166 .get(txn, id)
167 .and_then(|value| value.cast::<MapRef>().ok())
168 .ok_or_else(|| EditError::InvalidState(format!("comment {id} is not a map")))?;
169 Ok(CommentSnapshot {
170 id: id.to_owned(),
171 slide_id: map_string(&entry, txn, "slideId").unwrap_or_default(),
172 author: map_string(&entry, txn, "author").unwrap_or_default(),
173 initials: map_string(&entry, txn, "initials").unwrap_or_default(),
174 text: map_string(&entry, txn, "text").unwrap_or_default(),
175 created: map_string(&entry, txn, "created"),
176 x_emu: map_number(&entry, txn, "x").unwrap_or(0.0) as i64,
177 y_emu: map_number(&entry, txn, "y").unwrap_or(0.0) as i64,
178 parent_id: live_parent(comments, &entry, txn),
179 resolved: map_bool(&entry, txn, "resolved").unwrap_or(false),
180 })
181}
182
183fn live_parent<T: ReadTxn>(comments: &MapRef, entry: &MapRef, txn: &T) -> Option<String> {
185 let parent_id = map_string(entry, txn, "parentId")?;
186 let mut next = Some(parent_id.clone());
187 let mut seen = std::collections::HashSet::new();
188 let mut depth = 0;
189 while let Some(id) = next {
190 if !seen.insert(id.clone()) || depth == 128 {
191 return None;
192 }
193 let Some(parent) = comments
194 .get(txn, &id)
195 .and_then(|value| value.cast::<MapRef>().ok())
196 else {
197 break;
198 };
199 depth += 1;
200 next = map_string(&parent, txn, "parentId");
201 }
202 (depth % 2 == 1).then_some(parent_id)
203}
204
205pub(crate) fn baseline_comments(
208 package: &PptxPackage,
209 slide_id_by_part: &dyn Fn(&str) -> Option<String>,
210) -> Vec<CommentSnapshot> {
211 let mut parents: std::collections::HashMap<String, Option<String>> =
212 std::collections::HashMap::new();
213 let mut comments = Vec::new();
214 for (index, comment) in package.comments.iter().enumerate() {
215 let Some(slide_id) = slide_id_by_part(&comment.slide_part_path) else {
216 continue;
217 };
218 let author = package
219 .comment_authors
220 .iter()
221 .find(|author| author.id == comment.author_id);
222 let id = seeded_comment_id(index, &comment.id);
223 let parent_id = comment.parent_id.as_ref().and_then(|parent| {
224 package
225 .comments
226 .iter()
227 .position(|candidate| {
228 &candidate.id == parent && candidate.slide_part_path == comment.slide_part_path
229 })
230 .map(|parent_index| seeded_comment_id(parent_index, parent))
231 });
232 parents.insert(id.clone(), parent_id.clone());
233 comments.push(CommentSnapshot {
234 id,
235 slide_id,
236 author: author.map(|author| author.name.clone()).unwrap_or_default(),
237 initials: author
238 .map(|author| author.initials.clone())
239 .unwrap_or_default(),
240 text: comment.text.clone(),
241 created: comment.created.clone(),
242 x_emu: comment.x_emu as f64 as i64,
243 y_emu: comment.y_emu as f64 as i64,
244 parent_id,
245 resolved: comment.status.as_deref() == Some("resolved"),
246 });
247 }
248 for comment in &mut comments {
249 if let Some(parent_id) = comment.parent_id.take() {
250 comment.parent_id = baseline_parent(&parents, parent_id);
251 }
252 }
253 comments.sort_by(|left, right| {
254 left.created
255 .cmp(&right.created)
256 .then_with(|| left.id.cmp(&right.id))
257 });
258 comments
259}
260
261fn baseline_parent(
264 parents: &std::collections::HashMap<String, Option<String>>,
265 parent_id: String,
266) -> Option<String> {
267 let mut next = Some(parent_id.clone());
268 let mut seen = std::collections::HashSet::new();
269 let mut depth = 0;
270 while let Some(id) = next {
271 if !seen.insert(id.clone()) || depth == 128 {
272 return None;
273 }
274 let Some(parent) = parents.get(&id) else {
275 break;
276 };
277 depth += 1;
278 next = parent.clone();
279 }
280 (depth % 2 == 1).then_some(parent_id)
281}
282
283pub(crate) fn duplicate_comments<T: ReadTxn>(
284 txn: &T,
285 slide_id: &str,
286 new_slide_id: &str,
287) -> EditResult<Vec<(String, MapPrelim)>> {
288 let comments = required_map(txn, COMMENTS)?;
289 let rename = |id: &str| {
290 format!(
291 "comment:copy:{}",
292 derived_guid(&format!("{new_slide_id}:{id}"))
293 )
294 };
295 let mut copies = Vec::new();
296 for comment in snapshot_comments(txn)?
297 .into_iter()
298 .filter(|comment| comment.slide_id == slide_id)
299 {
300 let entry = comment_ref(&comments, txn, &comment.id)?;
301 let id = rename(&comment.id);
302 let mut copy = entry.as_prelim(txn);
303 copy.insert("id".into(), id.clone().into());
304 copy.insert("slideId".into(), new_slide_id.to_owned().into());
305 if let Some(parent) = comment.parent_id {
306 copy.insert("parentId".into(), rename(&parent).into());
307 } else {
308 copy.remove("parentId");
309 }
310 copies.push((id, copy));
311 }
312 Ok(copies)
313}
314
315pub(crate) fn snapshot_flavor<T: ReadTxn>(txn: &T) -> EditResult<CommentFlavor> {
316 let meta = required_map(txn, META)?;
317 Ok(match map_string(&meta, txn, "commentFlavor").as_deref() {
318 Some("modern") => CommentFlavor::Modern,
319 _ => CommentFlavor::Legacy,
320 })
321}
322
323pub(crate) fn flavor_key(flavor: CommentFlavor) -> &'static str {
324 match flavor {
325 CommentFlavor::Legacy => "legacy",
326 CommentFlavor::Modern => "modern",
327 }
328}
329
330pub(crate) fn derived_guid(seed: &str) -> String {
331 let digest = Sha256::digest(seed.as_bytes());
332 let hex: String = digest
333 .iter()
334 .take(16)
335 .map(|byte| format!("{byte:02X}"))
336 .collect();
337 format!(
338 "{{{}-{}-{}-{}-{}}}",
339 &hex[0..8],
340 &hex[8..12],
341 &hex[12..16],
342 &hex[16..20],
343 &hex[20..32]
344 )
345}
346
347impl DeckSession {
348 #[allow(clippy::too_many_arguments)]
349 pub fn add_comment(
350 &self,
351 context: &EditCtx,
352 slide_id: &str,
353 author: &str,
354 initials: &str,
355 text: &str,
356 created: &str,
357 x_emu: i64,
358 y_emu: i64,
359 ) -> EditResult<CommentReceipt> {
360 validate_fields(author, initials, text, created)?;
361 if text.is_empty() {
362 return Err(EditError::InvalidComment("comment text is empty".into()));
363 }
364 let comments = {
365 let txn = self.doc.transact();
366 crate::deck::slide_ref(&txn, slide_id)?;
367 required_map(&txn, COMMENTS)?
368 };
369 self.automatic_undo_barrier();
370 let comment_id = self.next_id("comment");
371 let mut txn = self.transact_for(context);
372 let entry = comments.insert(&mut txn, comment_id.as_str(), MapPrelim::default());
373 entry.insert(&mut txn, "id", comment_id.as_str());
374 entry.insert(&mut txn, "slideId", slide_id);
375 entry.insert(&mut txn, "author", author);
376 entry.insert(&mut txn, "initials", initials);
377 entry.insert(&mut txn, "text", text);
378 entry.insert(&mut txn, "created", created);
379 entry.insert(&mut txn, "x", x_emu as f64);
380 entry.insert(&mut txn, "y", y_emu as f64);
381 entry.insert(&mut txn, "resolved", false);
382 drop(txn);
383 self.automatic_undo_barrier();
384 Ok(CommentReceipt {
385 comment_id,
386 slide_id: slide_id.to_owned(),
387 parent_id: None,
388 resolved: false,
389 })
390 }
391
392 pub fn reply_to_comment(
393 &self,
394 context: &EditCtx,
395 comment_id: &str,
396 author: &str,
397 initials: &str,
398 text: &str,
399 created: &str,
400 ) -> EditResult<CommentReceipt> {
401 validate_fields(author, initials, text, created)?;
402 if text.is_empty() {
403 return Err(EditError::InvalidComment("reply text is empty".into()));
404 }
405 let (comments, slide_id) = {
406 let txn = self.doc.transact();
407 let comments = required_map(&txn, COMMENTS)?;
408 let parent = comment_ref(&comments, &txn, comment_id)?;
409 if live_parent(&comments, &parent, &txn).is_some() {
410 return Err(EditError::InvalidComment(
411 "replies cannot be nested below a reply".into(),
412 ));
413 }
414 let slide_id = map_string(&parent, &txn, "slideId").unwrap_or_default();
415 (comments, slide_id)
416 };
417 self.automatic_undo_barrier();
418 let reply_id = self.next_id("comment");
419 let mut txn = self.transact_for(context);
420 let entry = comments.insert(&mut txn, reply_id.as_str(), MapPrelim::default());
421 entry.insert(&mut txn, "id", reply_id.as_str());
422 entry.insert(&mut txn, "slideId", slide_id.as_str());
423 entry.insert(&mut txn, "author", author);
424 entry.insert(&mut txn, "initials", initials);
425 entry.insert(&mut txn, "text", text);
426 entry.insert(&mut txn, "created", created);
427 entry.insert(&mut txn, "x", 0.0);
428 entry.insert(&mut txn, "y", 0.0);
429 entry.insert(&mut txn, "parentId", comment_id);
430 entry.insert(&mut txn, "resolved", false);
431 drop(txn);
432 self.automatic_undo_barrier();
433 Ok(CommentReceipt {
434 comment_id: reply_id,
435 slide_id,
436 parent_id: Some(comment_id.to_owned()),
437 resolved: false,
438 })
439 }
440
441 pub fn set_comment_position(
442 &self,
443 context: &EditCtx,
444 comment_id: &str,
445 x_emu: i64,
446 y_emu: i64,
447 ) -> EditResult<CommentReceipt> {
448 const MAX_COORDINATE: u64 = (1_u64 << 53) - 1;
449 if x_emu.unsigned_abs() > MAX_COORDINATE || y_emu.unsigned_abs() > MAX_COORDINATE {
450 return Err(EditError::InvalidComment(
451 "coordinates exceed safe integer range".into(),
452 ));
453 }
454 let (entry, slide_id, resolved) = {
455 let txn = self.doc.transact();
456 let comments = required_map(&txn, COMMENTS)?;
457 let entry = comment_ref(&comments, &txn, comment_id)?;
458 if live_parent(&comments, &entry, &txn).is_some() {
459 return Err(EditError::InvalidComment(
460 "replies share their root comment position".into(),
461 ));
462 }
463 let slide_id = map_string(&entry, &txn, "slideId").unwrap_or_default();
464 let resolved = map_bool(&entry, &txn, "resolved").unwrap_or(false);
465 (entry, slide_id, resolved)
466 };
467 self.automatic_undo_barrier();
468 let mut txn = self.transact_for(context);
469 entry.insert(&mut txn, "x", x_emu as f64);
470 entry.insert(&mut txn, "y", y_emu as f64);
471 drop(txn);
472 self.automatic_undo_barrier();
473 Ok(CommentReceipt {
474 comment_id: comment_id.to_owned(),
475 slide_id,
476 parent_id: None,
477 resolved,
478 })
479 }
480
481 pub fn set_comment_status(
482 &self,
483 context: &EditCtx,
484 comment_id: &str,
485 resolved: bool,
486 ) -> EditResult<CommentReceipt> {
487 let (entry, slide_id, parent_id) = {
488 let txn = self.doc.transact();
489 require_modern(&txn)?;
490 let comments = required_map(&txn, COMMENTS)?;
491 let entry = comment_ref(&comments, &txn, comment_id)?;
492 let slide_id = map_string(&entry, &txn, "slideId").unwrap_or_default();
493 let parent_id = live_parent(&comments, &entry, &txn);
494 (entry, slide_id, parent_id)
495 };
496 self.automatic_undo_barrier();
497 let mut txn = self.transact_for(context);
498 entry.insert(&mut txn, "resolved", resolved);
499 drop(txn);
500 self.automatic_undo_barrier();
501 Ok(CommentReceipt {
502 comment_id: comment_id.to_owned(),
503 slide_id,
504 parent_id,
505 resolved,
506 })
507 }
508
509 pub fn remove_comment(
510 &self,
511 context: &EditCtx,
512 comment_id: &str,
513 ) -> EditResult<CommentReceipt> {
514 let txn = self.doc.transact();
515 let comments = required_map(&txn, COMMENTS)?;
516 let entry = comment_ref(&comments, &txn, comment_id)?;
517 let slide_id = map_string(&entry, &txn, "slideId").unwrap_or_default();
518 let parent_id = live_parent(&comments, &entry, &txn);
519 let resolved = map_bool(&entry, &txn, "resolved").unwrap_or(false);
520 let mut replies: Vec<String> = comments
521 .iter(&txn)
522 .filter_map(|(id, value)| {
523 let entry = value.cast::<MapRef>().ok()?;
524 (map_string(&entry, &txn, "parentId").as_deref() == Some(comment_id))
525 .then(|| id.to_owned())
526 })
527 .collect();
528 replies.sort();
529 drop(txn);
530 self.automatic_undo_barrier();
531 let mut txn = self.transact_for(context);
532 for reply in replies {
533 comments.remove(&mut txn, &reply);
534 }
535 comments.remove(&mut txn, comment_id);
536 drop(txn);
537 self.automatic_undo_barrier();
538 Ok(CommentReceipt {
539 comment_id: comment_id.to_owned(),
540 slide_id,
541 parent_id,
542 resolved,
543 })
544 }
545
546 pub fn set_comment_flavor(
547 &self,
548 context: &EditCtx,
549 flavor: CommentFlavor,
550 ) -> EditResult<CommentFlavor> {
551 let meta = {
552 let txn = self.doc.transact();
553 let comments = required_map(&txn, COMMENTS)?;
554 if comments.len(&txn) > 0 {
555 return Err(EditError::InvalidComment(
556 "the comment flavour is fixed once a deck has comments".into(),
557 ));
558 }
559 required_map(&txn, META)?
560 };
561 self.automatic_undo_barrier();
562 let mut txn = self.transact_for(context);
563 meta.insert(&mut txn, "commentFlavor", flavor_key(flavor));
564 drop(txn);
565 self.automatic_undo_barrier();
566 Ok(flavor)
567 }
568
569 pub fn comments(&self) -> EditResult<Vec<CommentSnapshot>> {
570 snapshot_comments(&self.doc.transact())
571 }
572
573 pub fn comment_flavor(&self) -> EditResult<CommentFlavor> {
574 snapshot_flavor(&self.doc.transact())
575 }
576}
577
578fn validate_fields(author: &str, initials: &str, text: &str, created: &str) -> EditResult<()> {
579 for value in [author, initials, text, created] {
580 crate::model::validate_xml_text(value)?;
581 }
582 Ok(())
583}
584
585fn require_modern<T: ReadTxn>(txn: &T) -> EditResult<()> {
586 match snapshot_flavor(txn)? {
587 CommentFlavor::Modern => Ok(()),
588 CommentFlavor::Legacy => Err(EditError::InvalidComment(
589 "resolved: legacy comments cannot represent status; use a deck with modern comments"
590 .into(),
591 )),
592 }
593}
594
595fn comment_ref<T: ReadTxn>(comments: &MapRef, txn: &T, comment_id: &str) -> EditResult<MapRef> {
596 comments
597 .get(txn, comment_id)
598 .and_then(|value| value.cast::<MapRef>().ok())
599 .ok_or_else(|| EditError::CommentNotFound(comment_id.to_owned()))
600}