Skip to main content

pptx_edit/
comments.rs

1use pptx_parse::{CommentFlavor, PptxPackage};
2use sha2::{Digest, Sha256};
3use yrs::{Any, Map, MapPrelim, MapRef, ReadTxn, Transact, TransactionMut, WriteTxn};
4
5use crate::deck::{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    source: &PptxPackage,
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<_, _> = source
85        .presentation
86        .slides
87        .iter()
88        .enumerate()
89        .map(|(index, slide)| {
90            (
91                slide.part_path.as_str(),
92                format!("slide:{index}:{}", slide.id),
93            )
94        })
95        .collect();
96    seed_comments(
97        &mut bootstrap.transact_mut_with(MIGRATE_ORIGIN),
98        source,
99        &|part| slide_ids.get(part).cloned(),
100    )?;
101    let update = bootstrap
102        .transact()
103        .encode_diff_v1(&session.doc.transact().state_vector());
104    hydrate_doc(&session.doc, &update)?;
105    let mut package = session.package().clone();
106    package.comments = source.comments.clone();
107    package.comment_authors = source.comment_authors.clone();
108    package.comment_flavor = source.comment_flavor;
109    let mut txn = session.doc.transact_mut_with(MIGRATE_ORIGIN);
110    let meta = required_map(&txn, META)?;
111    meta.insert(
112        &mut txn,
113        "packageJson",
114        Any::Buffer(std::sync::Arc::from(
115            serde_json::to_vec(&package).map_err(|error| EditError::Json(error.to_string()))?,
116        )),
117    );
118    meta.insert(
119        &mut txn,
120        "commentFlavor",
121        flavor_key(source.comment_flavor.unwrap_or_default()),
122    );
123    meta.insert(&mut txn, "commentsPendingSource", false);
124    Ok(())
125}
126
127pub(crate) fn snapshot_comments<T: ReadTxn>(txn: &T) -> EditResult<Vec<CommentSnapshot>> {
128    let comments = required_map(txn, COMMENTS)?;
129    let slides = required_map(txn, SLIDES)?;
130    let mut output = Vec::new();
131    for (id, value) in comments.iter(txn) {
132        let Ok(entry) = value.cast::<MapRef>() else {
133            return Err(EditError::InvalidState(format!(
134                "comment {id} is not a map"
135            )));
136        };
137        let slide_id = map_string(&entry, txn, "slideId").unwrap_or_default();
138        if !slides.contains_key(txn, &slide_id) {
139            continue;
140        }
141        output.push(CommentSnapshot {
142            id: id.to_owned(),
143            slide_id,
144            author: map_string(&entry, txn, "author").unwrap_or_default(),
145            initials: map_string(&entry, txn, "initials").unwrap_or_default(),
146            text: map_string(&entry, txn, "text").unwrap_or_default(),
147            created: map_string(&entry, txn, "created"),
148            x_emu: map_number(&entry, txn, "x").unwrap_or(0.0) as i64,
149            y_emu: map_number(&entry, txn, "y").unwrap_or(0.0) as i64,
150            parent_id: live_parent(&comments, &entry, txn),
151            resolved: map_bool(&entry, txn, "resolved").unwrap_or(false),
152        });
153    }
154    output.sort_by(|left, right| {
155        left.created
156            .cmp(&right.created)
157            .then_with(|| left.id.cmp(&right.id))
158    });
159    Ok(output)
160}
161
162/// Promotes alternating levels so undo cannot create nested replies.
163fn live_parent<T: ReadTxn>(comments: &MapRef, entry: &MapRef, txn: &T) -> Option<String> {
164    let parent_id = map_string(entry, txn, "parentId")?;
165    let mut next = Some(parent_id.clone());
166    let mut seen = std::collections::HashSet::new();
167    let mut depth = 0;
168    while let Some(id) = next {
169        if !seen.insert(id.clone()) || depth == 128 {
170            return None;
171        }
172        let Some(parent) = comments
173            .get(txn, &id)
174            .and_then(|value| value.cast::<MapRef>().ok())
175        else {
176            break;
177        };
178        depth += 1;
179        next = map_string(&parent, txn, "parentId");
180    }
181    (depth % 2 == 1).then_some(parent_id)
182}
183
184pub(crate) fn snapshot_flavor<T: ReadTxn>(txn: &T) -> EditResult<CommentFlavor> {
185    let meta = required_map(txn, META)?;
186    Ok(match map_string(&meta, txn, "commentFlavor").as_deref() {
187        Some("modern") => CommentFlavor::Modern,
188        _ => CommentFlavor::Legacy,
189    })
190}
191
192pub(crate) fn flavor_key(flavor: CommentFlavor) -> &'static str {
193    match flavor {
194        CommentFlavor::Legacy => "legacy",
195        CommentFlavor::Modern => "modern",
196    }
197}
198
199pub(crate) fn derived_guid(seed: &str) -> String {
200    let digest = Sha256::digest(seed.as_bytes());
201    let hex: String = digest
202        .iter()
203        .take(16)
204        .map(|byte| format!("{byte:02X}"))
205        .collect();
206    format!(
207        "{{{}-{}-{}-{}-{}}}",
208        &hex[0..8],
209        &hex[8..12],
210        &hex[12..16],
211        &hex[16..20],
212        &hex[20..32]
213    )
214}
215
216impl DeckSession {
217    #[allow(clippy::too_many_arguments)]
218    pub fn add_comment(
219        &self,
220        context: &EditCtx,
221        slide_id: &str,
222        author: &str,
223        initials: &str,
224        text: &str,
225        created: &str,
226        x_emu: i64,
227        y_emu: i64,
228    ) -> EditResult<CommentReceipt> {
229        validate_fields(author, initials, text, created)?;
230        if text.is_empty() {
231            return Err(EditError::InvalidComment("comment text is empty".into()));
232        }
233        self.add_undo_barrier();
234        let comment_id = self.next_id("comment");
235        let mut txn = self.transact_for(context);
236        crate::deck::slide_ref(&txn, slide_id)?;
237        let comments = required_map(&txn, COMMENTS)?;
238        let entry = comments.insert(&mut txn, comment_id.as_str(), MapPrelim::default());
239        entry.insert(&mut txn, "id", comment_id.as_str());
240        entry.insert(&mut txn, "slideId", slide_id);
241        entry.insert(&mut txn, "author", author);
242        entry.insert(&mut txn, "initials", initials);
243        entry.insert(&mut txn, "text", text);
244        entry.insert(&mut txn, "created", created);
245        entry.insert(&mut txn, "x", x_emu as f64);
246        entry.insert(&mut txn, "y", y_emu as f64);
247        entry.insert(&mut txn, "resolved", false);
248        drop(txn);
249        self.add_undo_barrier();
250        Ok(CommentReceipt {
251            comment_id,
252            slide_id: slide_id.to_owned(),
253            parent_id: None,
254            resolved: false,
255        })
256    }
257
258    pub fn reply_to_comment(
259        &self,
260        context: &EditCtx,
261        comment_id: &str,
262        author: &str,
263        initials: &str,
264        text: &str,
265        created: &str,
266    ) -> EditResult<CommentReceipt> {
267        validate_fields(author, initials, text, created)?;
268        if text.is_empty() {
269            return Err(EditError::InvalidComment("reply text is empty".into()));
270        }
271        self.add_undo_barrier();
272        let reply_id = self.next_id("comment");
273        let mut txn = self.transact_for(context);
274        require_modern(&txn)?;
275        let comments = required_map(&txn, COMMENTS)?;
276        let parent = comment_ref(&comments, &txn, comment_id)?;
277        if live_parent(&comments, &parent, &txn).is_some() {
278            return Err(EditError::InvalidComment(
279                "replies cannot be nested below a reply".into(),
280            ));
281        }
282        let slide_id = map_string(&parent, &txn, "slideId").unwrap_or_default();
283        let entry = comments.insert(&mut txn, reply_id.as_str(), MapPrelim::default());
284        entry.insert(&mut txn, "id", reply_id.as_str());
285        entry.insert(&mut txn, "slideId", slide_id.as_str());
286        entry.insert(&mut txn, "author", author);
287        entry.insert(&mut txn, "initials", initials);
288        entry.insert(&mut txn, "text", text);
289        entry.insert(&mut txn, "created", created);
290        entry.insert(&mut txn, "x", 0.0);
291        entry.insert(&mut txn, "y", 0.0);
292        entry.insert(&mut txn, "parentId", comment_id);
293        entry.insert(&mut txn, "resolved", false);
294        drop(txn);
295        self.add_undo_barrier();
296        Ok(CommentReceipt {
297            comment_id: reply_id,
298            slide_id,
299            parent_id: Some(comment_id.to_owned()),
300            resolved: false,
301        })
302    }
303
304    pub fn set_comment_status(
305        &self,
306        context: &EditCtx,
307        comment_id: &str,
308        resolved: bool,
309    ) -> EditResult<CommentReceipt> {
310        self.add_undo_barrier();
311        let mut txn = self.transact_for(context);
312        require_modern(&txn)?;
313        let comments = required_map(&txn, COMMENTS)?;
314        let entry = comment_ref(&comments, &txn, comment_id)?;
315        let slide_id = map_string(&entry, &txn, "slideId").unwrap_or_default();
316        let parent_id = live_parent(&comments, &entry, &txn);
317        entry.insert(&mut txn, "resolved", resolved);
318        drop(txn);
319        self.add_undo_barrier();
320        Ok(CommentReceipt {
321            comment_id: comment_id.to_owned(),
322            slide_id,
323            parent_id,
324            resolved,
325        })
326    }
327
328    pub fn remove_comment(
329        &self,
330        context: &EditCtx,
331        comment_id: &str,
332    ) -> EditResult<CommentReceipt> {
333        self.add_undo_barrier();
334        let mut txn = self.transact_for(context);
335        let comments = required_map(&txn, COMMENTS)?;
336        let entry = comment_ref(&comments, &txn, comment_id)?;
337        let slide_id = map_string(&entry, &txn, "slideId").unwrap_or_default();
338        let parent_id = live_parent(&comments, &entry, &txn);
339        let resolved = map_bool(&entry, &txn, "resolved").unwrap_or(false);
340        let replies: Vec<String> = comments
341            .iter(&txn)
342            .filter_map(|(id, value)| {
343                let entry = value.cast::<MapRef>().ok()?;
344                (map_string(&entry, &txn, "parentId").as_deref() == Some(comment_id))
345                    .then(|| id.to_owned())
346            })
347            .collect();
348        for reply in replies {
349            comments.remove(&mut txn, &reply);
350        }
351        comments.remove(&mut txn, comment_id);
352        drop(txn);
353        self.add_undo_barrier();
354        Ok(CommentReceipt {
355            comment_id: comment_id.to_owned(),
356            slide_id,
357            parent_id,
358            resolved,
359        })
360    }
361
362    pub fn set_comment_flavor(
363        &self,
364        context: &EditCtx,
365        flavor: CommentFlavor,
366    ) -> EditResult<CommentFlavor> {
367        self.add_undo_barrier();
368        let mut txn = self.transact_for(context);
369        let comments = required_map(&txn, COMMENTS)?;
370        if comments.len(&txn) > 0 {
371            return Err(EditError::InvalidComment(
372                "the comment flavour is fixed once a deck has comments".into(),
373            ));
374        }
375        let meta = required_map(&txn, META)?;
376        meta.insert(&mut txn, "commentFlavor", flavor_key(flavor));
377        drop(txn);
378        self.add_undo_barrier();
379        Ok(flavor)
380    }
381
382    pub fn comments(&self) -> EditResult<Vec<CommentSnapshot>> {
383        snapshot_comments(&self.doc.transact())
384    }
385
386    pub fn comment_flavor(&self) -> EditResult<CommentFlavor> {
387        snapshot_flavor(&self.doc.transact())
388    }
389}
390
391fn validate_fields(author: &str, initials: &str, text: &str, created: &str) -> EditResult<()> {
392    for value in [author, initials, text, created] {
393        crate::model::validate_xml_text(value)?;
394    }
395    Ok(())
396}
397
398fn require_modern<T: ReadTxn>(txn: &T) -> EditResult<()> {
399    match snapshot_flavor(txn)? {
400        CommentFlavor::Modern => Ok(()),
401        CommentFlavor::Legacy => Err(EditError::InvalidComment(
402            "legacy comments carry no replies or status; switch the deck to modern comments".into(),
403        )),
404    }
405}
406
407fn comment_ref<T: ReadTxn>(comments: &MapRef, txn: &T, comment_id: &str) -> EditResult<MapRef> {
408    comments
409        .get(txn, comment_id)
410        .and_then(|value| value.cast::<MapRef>().ok())
411        .ok_or_else(|| EditError::CommentNotFound(comment_id.to_owned()))
412}