Skip to main content

pptx_edit/
comments.rs

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
131/// A live comment's place in [`snapshot_comments`] order.
132pub(crate) struct CommentKey {
133    pub id: String,
134    pub slide_id: String,
135}
136
137/// The live comments, ordered by creation time and id, without reading their text.
138pub(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
183/// Promotes alternating levels so undo cannot create nested replies.
184fn 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
205/// The comments `snapshot_comments` returns for a doc `seed_comments` wrote,
206/// computed without materializing a scratch document.
207pub(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
261/// `live_parent` over the seeded map: a reply keeps its parent only when the
262/// chain depth above it is odd.
263fn 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}