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