Skip to main content

pptx_edit/
comments.rs

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
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 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
184/// Promotes alternating levels so undo cannot create nested replies.
185fn 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
206/// The comments `snapshot_comments` returns for a doc `seed_comments` wrote,
207/// computed without materializing a scratch document.
208pub(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
260/// `live_parent` over the seeded map: a reply keeps its parent only when the
261/// chain depth above it is odd.
262fn 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}