1use pptx_parse::{CommentFlavor, PptxPackage};
2use sha2::{Digest, Sha256};
3use yrs::{Map, MapPrelim, MapRef, ReadTxn, Transact, TransactionMut, WriteTxn};
4
5use crate::deck::{SourceImport, map_bool, map_number, map_string, required_map};
6use crate::{
7 BOOTSTRAP_CLIENT_ID, COMMENTS, CommentReceipt, CommentSnapshot, DeckSession, EditCtx,
8 EditError, EditResult, META, MIGRATE_ORIGIN, SLIDES, doc_with_client_id, hydrate_doc,
9};
10
11pub(crate) fn seeded_comment_id(index: usize, source_id: &str) -> String {
12 format!("comment:{index}:{source_id}")
13}
14
15pub(crate) fn seed_comments(
16 txn: &mut TransactionMut<'_>,
17 package: &PptxPackage,
18 slide_id_by_part: &dyn Fn(&str) -> Option<String>,
19) -> EditResult<()> {
20 let comments = txn.get_or_insert_map(COMMENTS);
21 for (index, comment) in package.comments.iter().enumerate() {
22 let Some(slide_id) = slide_id_by_part(&comment.slide_part_path) else {
23 continue;
24 };
25 let author = package
26 .comment_authors
27 .iter()
28 .find(|author| author.id == comment.author_id);
29 let id = seeded_comment_id(index, &comment.id);
30 let entry = comments.insert(txn, id.as_str(), MapPrelim::default());
31 entry.insert(txn, "id", id.as_str());
32 entry.insert(txn, "slideId", slide_id.as_str());
33 entry.insert(
34 txn,
35 "author",
36 author.map(|author| author.name.as_str()).unwrap_or(""),
37 );
38 entry.insert(
39 txn,
40 "initials",
41 author.map(|author| author.initials.as_str()).unwrap_or(""),
42 );
43 entry.insert(txn, "text", comment.text.as_str());
44 if let Some(created) = &comment.created {
45 entry.insert(txn, "created", created.as_str());
46 }
47 entry.insert(txn, "x", comment.x_emu as f64);
48 entry.insert(txn, "y", comment.y_emu as f64);
49 if let Some(parent) = &comment.parent_id {
50 let parent_index = package
51 .comments
52 .iter()
53 .position(|candidate| &candidate.id == parent);
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 slides = required_map(txn, SLIDES)?;
141 let mut keys = Vec::new();
142 for (id, value) in comments.iter(txn) {
143 let Ok(entry) = value.cast::<MapRef>() else {
144 return Err(EditError::InvalidState(format!(
145 "comment {id} is not a map"
146 )));
147 };
148 let slide_id = map_string(&entry, txn, "slideId").unwrap_or_default();
149 if !slides.contains_key(txn, &slide_id) {
150 continue;
151 }
152 keys.push((map_string(&entry, txn, "created"), id.to_owned(), slide_id));
153 }
154 keys.sort();
155 Ok(keys
156 .into_iter()
157 .map(|(_, id, slide_id)| CommentKey { id, slide_id })
158 .collect())
159}
160
161pub(crate) fn snapshot_comment<T: ReadTxn>(
162 comments: &MapRef,
163 txn: &T,
164 id: &str,
165) -> EditResult<CommentSnapshot> {
166 let entry = comments
167 .get(txn, id)
168 .and_then(|value| value.cast::<MapRef>().ok())
169 .ok_or_else(|| EditError::InvalidState(format!("comment {id} is not a map")))?;
170 Ok(CommentSnapshot {
171 id: id.to_owned(),
172 slide_id: map_string(&entry, txn, "slideId").unwrap_or_default(),
173 author: map_string(&entry, txn, "author").unwrap_or_default(),
174 initials: map_string(&entry, txn, "initials").unwrap_or_default(),
175 text: map_string(&entry, txn, "text").unwrap_or_default(),
176 created: map_string(&entry, txn, "created"),
177 x_emu: map_number(&entry, txn, "x").unwrap_or(0.0) as i64,
178 y_emu: map_number(&entry, txn, "y").unwrap_or(0.0) as i64,
179 parent_id: live_parent(comments, &entry, txn),
180 resolved: map_bool(&entry, txn, "resolved").unwrap_or(false),
181 })
182}
183
184fn live_parent<T: ReadTxn>(comments: &MapRef, entry: &MapRef, txn: &T) -> Option<String> {
186 let parent_id = map_string(entry, txn, "parentId")?;
187 let mut next = Some(parent_id.clone());
188 let mut seen = std::collections::HashSet::new();
189 let mut depth = 0;
190 while let Some(id) = next {
191 if !seen.insert(id.clone()) || depth == 128 {
192 return None;
193 }
194 let Some(parent) = comments
195 .get(txn, &id)
196 .and_then(|value| value.cast::<MapRef>().ok())
197 else {
198 break;
199 };
200 depth += 1;
201 next = map_string(&parent, txn, "parentId");
202 }
203 (depth % 2 == 1).then_some(parent_id)
204}
205
206pub(crate) fn baseline_comments(
209 package: &PptxPackage,
210 slide_id_by_part: &dyn Fn(&str) -> Option<String>,
211) -> Vec<CommentSnapshot> {
212 let mut parents: std::collections::HashMap<String, Option<String>> =
213 std::collections::HashMap::new();
214 let mut comments = Vec::new();
215 for (index, comment) in package.comments.iter().enumerate() {
216 let Some(slide_id) = slide_id_by_part(&comment.slide_part_path) else {
217 continue;
218 };
219 let author = package
220 .comment_authors
221 .iter()
222 .find(|author| author.id == comment.author_id);
223 let id = seeded_comment_id(index, &comment.id);
224 let parent_id = comment.parent_id.as_ref().and_then(|parent| {
225 package
226 .comments
227 .iter()
228 .position(|candidate| &candidate.id == parent)
229 .map(|parent_index| seeded_comment_id(parent_index, parent))
230 });
231 parents.insert(id.clone(), parent_id.clone());
232 comments.push(CommentSnapshot {
233 id,
234 slide_id,
235 author: author.map(|author| author.name.clone()).unwrap_or_default(),
236 initials: author
237 .map(|author| author.initials.clone())
238 .unwrap_or_default(),
239 text: comment.text.clone(),
240 created: comment.created.clone(),
241 x_emu: comment.x_emu as f64 as i64,
242 y_emu: comment.y_emu as f64 as i64,
243 parent_id,
244 resolved: comment.status.as_deref() == Some("resolved"),
245 });
246 }
247 for comment in &mut comments {
248 if let Some(parent_id) = comment.parent_id.take() {
249 comment.parent_id = baseline_parent(&parents, parent_id);
250 }
251 }
252 comments.sort_by(|left, right| {
253 left.created
254 .cmp(&right.created)
255 .then_with(|| left.id.cmp(&right.id))
256 });
257 comments
258}
259
260fn baseline_parent(
263 parents: &std::collections::HashMap<String, Option<String>>,
264 parent_id: String,
265) -> Option<String> {
266 let mut next = Some(parent_id.clone());
267 let mut seen = std::collections::HashSet::new();
268 let mut depth = 0;
269 while let Some(id) = next {
270 if !seen.insert(id.clone()) || depth == 128 {
271 return None;
272 }
273 let Some(parent) = parents.get(&id) else {
274 break;
275 };
276 depth += 1;
277 next = parent.clone();
278 }
279 (depth % 2 == 1).then_some(parent_id)
280}
281
282pub(crate) fn snapshot_flavor<T: ReadTxn>(txn: &T) -> EditResult<CommentFlavor> {
283 let meta = required_map(txn, META)?;
284 Ok(match map_string(&meta, txn, "commentFlavor").as_deref() {
285 Some("modern") => CommentFlavor::Modern,
286 _ => CommentFlavor::Legacy,
287 })
288}
289
290pub(crate) fn flavor_key(flavor: CommentFlavor) -> &'static str {
291 match flavor {
292 CommentFlavor::Legacy => "legacy",
293 CommentFlavor::Modern => "modern",
294 }
295}
296
297pub(crate) fn derived_guid(seed: &str) -> String {
298 let digest = Sha256::digest(seed.as_bytes());
299 let hex: String = digest
300 .iter()
301 .take(16)
302 .map(|byte| format!("{byte:02X}"))
303 .collect();
304 format!(
305 "{{{}-{}-{}-{}-{}}}",
306 &hex[0..8],
307 &hex[8..12],
308 &hex[12..16],
309 &hex[16..20],
310 &hex[20..32]
311 )
312}
313
314impl DeckSession {
315 #[allow(clippy::too_many_arguments)]
316 pub fn add_comment(
317 &self,
318 context: &EditCtx,
319 slide_id: &str,
320 author: &str,
321 initials: &str,
322 text: &str,
323 created: &str,
324 x_emu: i64,
325 y_emu: i64,
326 ) -> EditResult<CommentReceipt> {
327 validate_fields(author, initials, text, created)?;
328 if text.is_empty() {
329 return Err(EditError::InvalidComment("comment text is empty".into()));
330 }
331 self.automatic_undo_barrier();
332 let comment_id = self.next_id("comment");
333 let mut txn = self.transact_for(context);
334 crate::deck::slide_ref(&txn, slide_id)?;
335 let comments = required_map(&txn, COMMENTS)?;
336 let entry = comments.insert(&mut txn, comment_id.as_str(), MapPrelim::default());
337 entry.insert(&mut txn, "id", comment_id.as_str());
338 entry.insert(&mut txn, "slideId", slide_id);
339 entry.insert(&mut txn, "author", author);
340 entry.insert(&mut txn, "initials", initials);
341 entry.insert(&mut txn, "text", text);
342 entry.insert(&mut txn, "created", created);
343 entry.insert(&mut txn, "x", x_emu as f64);
344 entry.insert(&mut txn, "y", y_emu as f64);
345 entry.insert(&mut txn, "resolved", false);
346 drop(txn);
347 self.automatic_undo_barrier();
348 Ok(CommentReceipt {
349 comment_id,
350 slide_id: slide_id.to_owned(),
351 parent_id: None,
352 resolved: false,
353 })
354 }
355
356 pub fn reply_to_comment(
357 &self,
358 context: &EditCtx,
359 comment_id: &str,
360 author: &str,
361 initials: &str,
362 text: &str,
363 created: &str,
364 ) -> EditResult<CommentReceipt> {
365 validate_fields(author, initials, text, created)?;
366 if text.is_empty() {
367 return Err(EditError::InvalidComment("reply text is empty".into()));
368 }
369 self.automatic_undo_barrier();
370 let reply_id = self.next_id("comment");
371 let mut txn = self.transact_for(context);
372 require_modern(&txn)?;
373 let comments = required_map(&txn, COMMENTS)?;
374 let parent = comment_ref(&comments, &txn, comment_id)?;
375 if live_parent(&comments, &parent, &txn).is_some() {
376 return Err(EditError::InvalidComment(
377 "replies cannot be nested below a reply".into(),
378 ));
379 }
380 let slide_id = map_string(&parent, &txn, "slideId").unwrap_or_default();
381 let entry = comments.insert(&mut txn, reply_id.as_str(), MapPrelim::default());
382 entry.insert(&mut txn, "id", reply_id.as_str());
383 entry.insert(&mut txn, "slideId", slide_id.as_str());
384 entry.insert(&mut txn, "author", author);
385 entry.insert(&mut txn, "initials", initials);
386 entry.insert(&mut txn, "text", text);
387 entry.insert(&mut txn, "created", created);
388 entry.insert(&mut txn, "x", 0.0);
389 entry.insert(&mut txn, "y", 0.0);
390 entry.insert(&mut txn, "parentId", comment_id);
391 entry.insert(&mut txn, "resolved", false);
392 drop(txn);
393 self.automatic_undo_barrier();
394 Ok(CommentReceipt {
395 comment_id: reply_id,
396 slide_id,
397 parent_id: Some(comment_id.to_owned()),
398 resolved: false,
399 })
400 }
401
402 pub fn set_comment_position(
403 &self,
404 context: &EditCtx,
405 comment_id: &str,
406 x_emu: i64,
407 y_emu: i64,
408 ) -> EditResult<CommentReceipt> {
409 const MAX_COORDINATE: u64 = (1_u64 << 53) - 1;
410 if x_emu.unsigned_abs() > MAX_COORDINATE || y_emu.unsigned_abs() > MAX_COORDINATE {
411 return Err(EditError::InvalidComment(
412 "coordinates exceed safe integer range".into(),
413 ));
414 }
415 {
416 let txn = self.doc.transact();
417 let comments = required_map(&txn, COMMENTS)?;
418 let entry = comment_ref(&comments, &txn, comment_id)?;
419 if live_parent(&comments, &entry, &txn).is_some() {
420 return Err(EditError::InvalidComment(
421 "replies share their root comment position".into(),
422 ));
423 }
424 }
425 self.automatic_undo_barrier();
426 let mut txn = self.transact_for(context);
427 let comments = required_map(&txn, COMMENTS)?;
428 let entry = comment_ref(&comments, &txn, comment_id)?;
429 let slide_id = map_string(&entry, &txn, "slideId").unwrap_or_default();
430 let resolved = map_bool(&entry, &txn, "resolved").unwrap_or(false);
431 entry.insert(&mut txn, "x", x_emu as f64);
432 entry.insert(&mut txn, "y", y_emu as f64);
433 drop(txn);
434 self.automatic_undo_barrier();
435 Ok(CommentReceipt {
436 comment_id: comment_id.to_owned(),
437 slide_id,
438 parent_id: None,
439 resolved,
440 })
441 }
442
443 pub fn set_comment_status(
444 &self,
445 context: &EditCtx,
446 comment_id: &str,
447 resolved: bool,
448 ) -> EditResult<CommentReceipt> {
449 self.automatic_undo_barrier();
450 let mut txn = self.transact_for(context);
451 require_modern(&txn)?;
452 let comments = required_map(&txn, COMMENTS)?;
453 let entry = comment_ref(&comments, &txn, comment_id)?;
454 let slide_id = map_string(&entry, &txn, "slideId").unwrap_or_default();
455 let parent_id = live_parent(&comments, &entry, &txn);
456 entry.insert(&mut txn, "resolved", resolved);
457 drop(txn);
458 self.automatic_undo_barrier();
459 Ok(CommentReceipt {
460 comment_id: comment_id.to_owned(),
461 slide_id,
462 parent_id,
463 resolved,
464 })
465 }
466
467 pub fn remove_comment(
468 &self,
469 context: &EditCtx,
470 comment_id: &str,
471 ) -> EditResult<CommentReceipt> {
472 self.automatic_undo_barrier();
473 let mut txn = self.transact_for(context);
474 let comments = required_map(&txn, COMMENTS)?;
475 let entry = comment_ref(&comments, &txn, comment_id)?;
476 let slide_id = map_string(&entry, &txn, "slideId").unwrap_or_default();
477 let parent_id = live_parent(&comments, &entry, &txn);
478 let resolved = map_bool(&entry, &txn, "resolved").unwrap_or(false);
479 let replies: Vec<String> = comments
480 .iter(&txn)
481 .filter_map(|(id, value)| {
482 let entry = value.cast::<MapRef>().ok()?;
483 (map_string(&entry, &txn, "parentId").as_deref() == Some(comment_id))
484 .then(|| id.to_owned())
485 })
486 .collect();
487 for reply in replies {
488 comments.remove(&mut txn, &reply);
489 }
490 comments.remove(&mut txn, comment_id);
491 drop(txn);
492 self.automatic_undo_barrier();
493 Ok(CommentReceipt {
494 comment_id: comment_id.to_owned(),
495 slide_id,
496 parent_id,
497 resolved,
498 })
499 }
500
501 pub fn set_comment_flavor(
502 &self,
503 context: &EditCtx,
504 flavor: CommentFlavor,
505 ) -> EditResult<CommentFlavor> {
506 self.automatic_undo_barrier();
507 let mut txn = self.transact_for(context);
508 let comments = required_map(&txn, COMMENTS)?;
509 if comments.len(&txn) > 0 {
510 return Err(EditError::InvalidComment(
511 "the comment flavour is fixed once a deck has comments".into(),
512 ));
513 }
514 let meta = required_map(&txn, META)?;
515 meta.insert(&mut txn, "commentFlavor", flavor_key(flavor));
516 drop(txn);
517 self.automatic_undo_barrier();
518 Ok(flavor)
519 }
520
521 pub fn comments(&self) -> EditResult<Vec<CommentSnapshot>> {
522 snapshot_comments(&self.doc.transact())
523 }
524
525 pub fn comment_flavor(&self) -> EditResult<CommentFlavor> {
526 snapshot_flavor(&self.doc.transact())
527 }
528}
529
530fn validate_fields(author: &str, initials: &str, text: &str, created: &str) -> EditResult<()> {
531 for value in [author, initials, text, created] {
532 crate::model::validate_xml_text(value)?;
533 }
534 Ok(())
535}
536
537fn require_modern<T: ReadTxn>(txn: &T) -> EditResult<()> {
538 match snapshot_flavor(txn)? {
539 CommentFlavor::Modern => Ok(()),
540 CommentFlavor::Legacy => Err(EditError::InvalidComment(
541 "legacy comments carry no replies or status; switch the deck to modern comments".into(),
542 )),
543 }
544}
545
546fn comment_ref<T: ReadTxn>(comments: &MapRef, txn: &T, comment_id: &str) -> EditResult<MapRef> {
547 comments
548 .get(txn, comment_id)
549 .and_then(|value| value.cast::<MapRef>().ok())
550 .ok_or_else(|| EditError::CommentNotFound(comment_id.to_owned()))
551}