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 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}