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 copied_comment_id(slide_id: &str, comment_id: &str) -> String {
284 format!(
285 "comment:copy:{}",
286 derived_guid(&format!("{slide_id}:{comment_id}"))
287 )
288}
289
290pub(crate) fn duplicate_comments<T: ReadTxn>(
291 txn: &T,
292 slide_id: &str,
293 new_slide_id: &str,
294) -> EditResult<Vec<(String, MapPrelim)>> {
295 let comments = required_map(txn, COMMENTS)?;
296 let rename = |id: &str| copied_comment_id(new_slide_id, id);
297 let mut copies = Vec::new();
298 for comment in snapshot_comments(txn)?
299 .into_iter()
300 .filter(|comment| comment.slide_id == slide_id)
301 {
302 let entry = comment_ref(&comments, txn, &comment.id)?;
303 let id = rename(&comment.id);
304 let mut copy = entry.as_prelim(txn);
305 copy.insert("id".into(), id.clone().into());
306 copy.insert("slideId".into(), new_slide_id.to_owned().into());
307 let origin = map_string(&entry, txn, "copyOrigin").unwrap_or_else(|| comment.id.clone());
308 copy.insert("copyOrigin".into(), origin.into());
309 if let Some(parent) = comment.parent_id {
310 copy.insert("parentId".into(), rename(&parent).into());
311 } else {
312 copy.remove("parentId");
313 }
314 copies.push((id, copy));
315 }
316 Ok(copies)
317}
318
319pub(crate) fn comment_copy_origins<T: ReadTxn>(
320 txn: &T,
321) -> EditResult<std::collections::HashMap<String, String>> {
322 let comments = required_map(txn, COMMENTS)?;
323 let mut origins = std::collections::HashMap::new();
324 for (id, value) in comments.iter(txn) {
325 let Ok(entry) = value.cast::<MapRef>() else {
326 return Err(EditError::InvalidState(format!(
327 "comment {id} is not a map"
328 )));
329 };
330 if let Some(origin) = map_string(&entry, txn, "copyOrigin") {
331 origins.insert(id.to_owned(), origin);
332 }
333 }
334 Ok(origins)
335}
336
337pub(crate) fn snapshot_flavor<T: ReadTxn>(txn: &T) -> EditResult<CommentFlavor> {
338 let meta = required_map(txn, META)?;
339 Ok(match map_string(&meta, txn, "commentFlavor").as_deref() {
340 Some("modern") => CommentFlavor::Modern,
341 _ => CommentFlavor::Legacy,
342 })
343}
344
345pub(crate) fn flavor_key(flavor: CommentFlavor) -> &'static str {
346 match flavor {
347 CommentFlavor::Legacy => "legacy",
348 CommentFlavor::Modern => "modern",
349 }
350}
351
352pub(crate) fn derived_guid(seed: &str) -> String {
353 let digest = Sha256::digest(seed.as_bytes());
354 let hex: String = digest
355 .iter()
356 .take(16)
357 .map(|byte| format!("{byte:02X}"))
358 .collect();
359 format!(
360 "{{{}-{}-{}-{}-{}}}",
361 &hex[0..8],
362 &hex[8..12],
363 &hex[12..16],
364 &hex[16..20],
365 &hex[20..32]
366 )
367}
368
369impl DeckSession {
370 #[allow(clippy::too_many_arguments)]
371 pub fn add_comment(
372 &self,
373 context: &EditCtx,
374 slide_id: &str,
375 author: &str,
376 initials: &str,
377 text: &str,
378 created: &str,
379 x_emu: i64,
380 y_emu: i64,
381 ) -> EditResult<CommentReceipt> {
382 validate_fields(author, initials, text, created)?;
383 if text.is_empty() {
384 return Err(EditError::InvalidComment("comment text is empty".into()));
385 }
386 let comments = {
387 let txn = self.doc.transact();
388 crate::deck::slide_ref(&txn, slide_id)?;
389 required_map(&txn, COMMENTS)?
390 };
391 self.automatic_undo_barrier();
392 let comment_id = self.next_id("comment");
393 let mut txn = self.transact_for(context);
394 let entry = comments.insert(&mut txn, comment_id.as_str(), MapPrelim::default());
395 entry.insert(&mut txn, "id", comment_id.as_str());
396 entry.insert(&mut txn, "slideId", slide_id);
397 entry.insert(&mut txn, "author", author);
398 entry.insert(&mut txn, "initials", initials);
399 entry.insert(&mut txn, "text", text);
400 entry.insert(&mut txn, "created", created);
401 entry.insert(&mut txn, "x", x_emu as f64);
402 entry.insert(&mut txn, "y", y_emu as f64);
403 entry.insert(&mut txn, "resolved", false);
404 drop(txn);
405 self.automatic_undo_barrier();
406 Ok(CommentReceipt {
407 comment_id,
408 slide_id: slide_id.to_owned(),
409 parent_id: None,
410 resolved: false,
411 })
412 }
413
414 pub fn reply_to_comment(
415 &self,
416 context: &EditCtx,
417 comment_id: &str,
418 author: &str,
419 initials: &str,
420 text: &str,
421 created: &str,
422 ) -> EditResult<CommentReceipt> {
423 validate_fields(author, initials, text, created)?;
424 if text.is_empty() {
425 return Err(EditError::InvalidComment("reply text is empty".into()));
426 }
427 let (comments, slide_id) = {
428 let txn = self.doc.transact();
429 let comments = required_map(&txn, COMMENTS)?;
430 let parent = comment_ref(&comments, &txn, comment_id)?;
431 if live_parent(&comments, &parent, &txn).is_some() {
432 return Err(EditError::InvalidComment(
433 "replies cannot be nested below a reply".into(),
434 ));
435 }
436 let slide_id = map_string(&parent, &txn, "slideId").unwrap_or_default();
437 (comments, slide_id)
438 };
439 self.automatic_undo_barrier();
440 let reply_id = self.next_id("comment");
441 let mut txn = self.transact_for(context);
442 let entry = comments.insert(&mut txn, reply_id.as_str(), MapPrelim::default());
443 entry.insert(&mut txn, "id", reply_id.as_str());
444 entry.insert(&mut txn, "slideId", slide_id.as_str());
445 entry.insert(&mut txn, "author", author);
446 entry.insert(&mut txn, "initials", initials);
447 entry.insert(&mut txn, "text", text);
448 entry.insert(&mut txn, "created", created);
449 entry.insert(&mut txn, "x", 0.0);
450 entry.insert(&mut txn, "y", 0.0);
451 entry.insert(&mut txn, "parentId", comment_id);
452 entry.insert(&mut txn, "resolved", false);
453 drop(txn);
454 self.automatic_undo_barrier();
455 Ok(CommentReceipt {
456 comment_id: reply_id,
457 slide_id,
458 parent_id: Some(comment_id.to_owned()),
459 resolved: false,
460 })
461 }
462
463 pub fn set_comment_position(
464 &self,
465 context: &EditCtx,
466 comment_id: &str,
467 x_emu: i64,
468 y_emu: i64,
469 ) -> EditResult<CommentReceipt> {
470 const MAX_COORDINATE: u64 = (1_u64 << 53) - 1;
471 if x_emu.unsigned_abs() > MAX_COORDINATE || y_emu.unsigned_abs() > MAX_COORDINATE {
472 return Err(EditError::InvalidComment(
473 "coordinates exceed safe integer range".into(),
474 ));
475 }
476 let (entry, slide_id, resolved) = {
477 let txn = self.doc.transact();
478 let comments = required_map(&txn, COMMENTS)?;
479 let entry = comment_ref(&comments, &txn, comment_id)?;
480 if live_parent(&comments, &entry, &txn).is_some() {
481 return Err(EditError::InvalidComment(
482 "replies share their root comment position".into(),
483 ));
484 }
485 let slide_id = map_string(&entry, &txn, "slideId").unwrap_or_default();
486 let resolved = map_bool(&entry, &txn, "resolved").unwrap_or(false);
487 (entry, slide_id, resolved)
488 };
489 self.automatic_undo_barrier();
490 let mut txn = self.transact_for(context);
491 entry.insert(&mut txn, "x", x_emu as f64);
492 entry.insert(&mut txn, "y", y_emu as f64);
493 drop(txn);
494 self.automatic_undo_barrier();
495 Ok(CommentReceipt {
496 comment_id: comment_id.to_owned(),
497 slide_id,
498 parent_id: None,
499 resolved,
500 })
501 }
502
503 pub fn set_comment_status(
504 &self,
505 context: &EditCtx,
506 comment_id: &str,
507 resolved: bool,
508 ) -> EditResult<CommentReceipt> {
509 let (entry, slide_id, parent_id) = {
510 let txn = self.doc.transact();
511 require_modern(&txn)?;
512 let comments = required_map(&txn, COMMENTS)?;
513 let entry = comment_ref(&comments, &txn, comment_id)?;
514 let slide_id = map_string(&entry, &txn, "slideId").unwrap_or_default();
515 let parent_id = live_parent(&comments, &entry, &txn);
516 (entry, slide_id, parent_id)
517 };
518 self.automatic_undo_barrier();
519 let mut txn = self.transact_for(context);
520 entry.insert(&mut txn, "resolved", resolved);
521 drop(txn);
522 self.automatic_undo_barrier();
523 Ok(CommentReceipt {
524 comment_id: comment_id.to_owned(),
525 slide_id,
526 parent_id,
527 resolved,
528 })
529 }
530
531 pub fn remove_comment(
532 &self,
533 context: &EditCtx,
534 comment_id: &str,
535 ) -> EditResult<CommentReceipt> {
536 let txn = self.doc.transact();
537 let comments = required_map(&txn, COMMENTS)?;
538 let entry = comment_ref(&comments, &txn, comment_id)?;
539 let slide_id = map_string(&entry, &txn, "slideId").unwrap_or_default();
540 let parent_id = live_parent(&comments, &entry, &txn);
541 let resolved = map_bool(&entry, &txn, "resolved").unwrap_or(false);
542 let mut replies: Vec<String> = comments
543 .iter(&txn)
544 .filter_map(|(id, value)| {
545 let entry = value.cast::<MapRef>().ok()?;
546 (map_string(&entry, &txn, "parentId").as_deref() == Some(comment_id))
547 .then(|| id.to_owned())
548 })
549 .collect();
550 replies.sort();
551 drop(txn);
552 self.automatic_undo_barrier();
553 let mut txn = self.transact_for(context);
554 for reply in replies {
555 comments.remove(&mut txn, &reply);
556 }
557 comments.remove(&mut txn, comment_id);
558 drop(txn);
559 self.automatic_undo_barrier();
560 Ok(CommentReceipt {
561 comment_id: comment_id.to_owned(),
562 slide_id,
563 parent_id,
564 resolved,
565 })
566 }
567
568 pub fn set_comment_flavor(
569 &self,
570 context: &EditCtx,
571 flavor: CommentFlavor,
572 ) -> EditResult<CommentFlavor> {
573 let meta = {
574 let txn = self.doc.transact();
575 let comments = required_map(&txn, COMMENTS)?;
576 if comments.len(&txn) > 0 {
577 return Err(EditError::InvalidComment(
578 "the comment flavour is fixed once a deck has comments".into(),
579 ));
580 }
581 required_map(&txn, META)?
582 };
583 self.automatic_undo_barrier();
584 let mut txn = self.transact_for(context);
585 meta.insert(&mut txn, "commentFlavor", flavor_key(flavor));
586 drop(txn);
587 self.automatic_undo_barrier();
588 Ok(flavor)
589 }
590
591 pub fn comments(&self) -> EditResult<Vec<CommentSnapshot>> {
592 snapshot_comments(&self.doc.transact())
593 }
594
595 pub fn comment_flavor(&self) -> EditResult<CommentFlavor> {
596 snapshot_flavor(&self.doc.transact())
597 }
598}
599
600fn validate_fields(author: &str, initials: &str, text: &str, created: &str) -> EditResult<()> {
601 for value in [author, initials, text, created] {
602 crate::model::validate_xml_text(value)?;
603 }
604 Ok(())
605}
606
607fn require_modern<T: ReadTxn>(txn: &T) -> EditResult<()> {
608 match snapshot_flavor(txn)? {
609 CommentFlavor::Modern => Ok(()),
610 CommentFlavor::Legacy => Err(EditError::InvalidComment(
611 "resolved: legacy comments cannot represent status; use a deck with modern comments"
612 .into(),
613 )),
614 }
615}
616
617fn comment_ref<T: ReadTxn>(comments: &MapRef, txn: &T, comment_id: &str) -> EditResult<MapRef> {
618 comments
619 .get(txn, comment_id)
620 .and_then(|value| value.cast::<MapRef>().ok())
621 .ok_or_else(|| EditError::CommentNotFound(comment_id.to_owned()))
622}