1use std::collections::{BTreeMap, BTreeSet, HashMap};
2
3use serde::{Deserialize, Serialize};
4use yrs::{ReadTxn, Transact};
5
6use crate::staging::Adoption;
7use crate::{
8 DeckSession, DeckSnapshot, EditCtx, EditError, ShapeRect, ShapeSnapshot, ShapeStroke,
9 TextStyle, TextStylePatch,
10};
11
12#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
13#[serde(
14 tag = "type",
15 rename_all = "camelCase",
16 rename_all_fields = "camelCase",
17 deny_unknown_fields
18)]
19pub enum ProposalEdit {
20 ReplaceText {
21 story_id: String,
22 start: u32,
23 end: u32,
24 text: String,
25 #[serde(default)]
26 style: Option<TextStyle>,
27 },
28 FormatText {
29 story_id: String,
30 start: u32,
31 end: u32,
32 patch: TextStylePatch,
33 },
34 SetParagraphAlignment {
35 story_id: String,
36 start: u32,
37 end: u32,
38 alignment: Option<String>,
39 },
40 SetShapeRect {
41 slide_id: String,
42 shape_id: String,
43 rect: ShapeRect,
44 },
45 SetShapeFill {
46 slide_id: String,
47 shape_id: String,
48 color: Option<String>,
49 },
50 SetShapeStroke {
51 slide_id: String,
52 shape_id: String,
53 stroke: ShapeStroke,
54 },
55 SetShapeAdjust {
56 slide_id: String,
57 shape_id: String,
58 adjustments: BTreeMap<String, f64>,
59 },
60 SetSlideNotes {
61 slide_id: String,
62 text: String,
63 },
64}
65
66#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase", deny_unknown_fields)]
68pub struct ProposalRequest {
69 pub agent_id: String,
70 pub note: Option<String>,
71 pub edits: Vec<ProposalEdit>,
72}
73
74#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
75#[serde(rename_all = "camelCase")]
76pub struct ProposalChange {
77 pub slide_id: String,
78 pub shape_id: Option<String>,
79 pub before: Option<ShapeSnapshot>,
80 pub after: Option<ShapeSnapshot>,
81 pub old_text: String,
82 pub new_text: String,
83}
84
85impl ProposalChange {
86 pub(crate) fn key(&self) -> String {
87 self.shape_id
88 .clone()
89 .unwrap_or_else(|| self.slide_id.clone())
90 }
91}
92
93#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct Proposal {
96 pub id: String,
97 pub agent_id: String,
98 pub note: Option<String>,
99 pub edits: Vec<ProposalEdit>,
100 pub changes: Vec<ProposalChange>,
101 pub stale_targets: Vec<String>,
102}
103
104#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase")]
106pub struct ProposalAcceptance {
107 pub proposal_id: String,
108 pub applied: bool,
109 pub snapshot: DeckSnapshot,
110}
111
112#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct ProposalPreview {
115 pub proposal: Proposal,
116 pub snapshot: DeckSnapshot,
117}
118
119#[derive(Debug, thiserror::Error)]
120pub enum ProposalError {
121 #[error("no proposal {0}")]
122 NotFound(String),
123 #[error("stale proposal targets: {0:?}")]
124 Stale(Vec<String>),
125 #[error("invalid proposal: {0}")]
126 Invalid(String),
127 #[error(transparent)]
128 Edit(#[from] EditError),
129}
130
131pub type ProposalResult<T> = Result<T, ProposalError>;
132
133#[derive(Default)]
134pub(crate) struct ProposalStore {
135 next_id: u64,
136 pending: Vec<Proposal>,
137 pub(crate) previews: HashMap<String, (u64, ProposalPreview)>,
141}
142
143impl DeckSession {
144 pub fn propose(&self, request: ProposalRequest) -> ProposalResult<Proposal> {
145 if request.agent_id.trim().is_empty()
146 || request.edits.is_empty()
147 || request.edits.len() > 256
148 {
149 return Err(ProposalError::Invalid(
150 "an agent and 1 to 256 edits are required".into(),
151 ));
152 }
153 if self.proposals.borrow().pending.len() >= 64 {
154 return Err(ProposalError::Invalid(
155 "64 proposals are already pending".into(),
156 ));
157 }
158 let before = self.snapshot()?;
159 let (_, snapshot) = self.preview_edits(&before, &request.edits)?;
160 let changes = changes_for(&before, &snapshot, &request.edits)?;
161 let mut store = self.proposals.borrow_mut();
162 store.next_id += 1;
163 let proposal = Proposal {
164 id: format!("p{}", store.next_id),
165 agent_id: request.agent_id,
166 note: request.note,
167 edits: request.edits,
168 changes,
169 stale_targets: Vec::new(),
170 };
171 store.pending.push(proposal.clone());
172 let epoch = self.epoch();
173 store.previews.insert(
174 proposal.id.clone(),
175 (
176 epoch,
177 ProposalPreview {
178 proposal: proposal.clone(),
179 snapshot,
180 },
181 ),
182 );
183 Ok(proposal)
184 }
185
186 pub fn proposals(&self) -> ProposalResult<Vec<Proposal>> {
187 let snapshot = self.snapshot()?;
188 Ok(self
189 .proposals
190 .borrow()
191 .pending
192 .iter()
193 .map(|proposal| {
194 let mut proposal = proposal.clone();
195 proposal.stale_targets = stale_targets(&snapshot, &proposal);
196 proposal
197 })
198 .collect())
199 }
200
201 pub fn preview_proposal(&self, id: &str) -> ProposalResult<ProposalPreview> {
202 let mut proposal = self.pending_proposal(id)?;
203 let epoch = self.epoch();
204 if let Some((cached_epoch, preview)) = self.proposals.borrow().previews.get(id)
205 && *cached_epoch == epoch
206 {
207 return Ok(preview.clone());
208 }
209 self.proposals.borrow_mut().previews.remove(id);
210 let before = self.snapshot()?;
211 let (_, snapshot) = self.preview_edits(&before, &proposal.edits)?;
212 proposal.stale_targets = stale_targets(&before, &proposal);
213 proposal.changes = changes_for(&before, &snapshot, &proposal.edits)?;
214 let preview = ProposalPreview { proposal, snapshot };
215 self.proposals
216 .borrow_mut()
217 .previews
218 .insert(id.to_owned(), (epoch, preview.clone()));
219 Ok(preview)
220 }
221
222 pub fn proposal_preview_session(&self, id: &str) -> ProposalResult<DeckSession> {
223 let proposal = self.pending_proposal(id)?;
224 let before = self.snapshot()?;
225 Ok(self.preview_edits(&before, &proposal.edits)?.0)
226 }
227
228 pub fn accept_proposal(&self, id: &str, force: bool) -> ProposalResult<ProposalAcceptance> {
229 let proposal = self.pending_proposal(id)?;
230 let before = self.snapshot()?;
231 let stale = stale_targets(&before, &proposal);
232 if !force && !stale.is_empty() {
233 return Err(ProposalError::Stale(stale));
234 }
235 let (preview, snapshot) = self.preview_edits(&before, &proposal.edits)?;
236 let applied = before != snapshot;
237 if applied {
238 let (_, update) = preview.staged_update(&self.doc.transact().state_vector())?;
239 self.adopt(&preview, update, Adoption::Proposal)?;
240 }
241 self.reject_proposal(id);
242 Ok(ProposalAcceptance {
243 proposal_id: id.to_owned(),
244 applied,
245 snapshot,
246 })
247 }
248
249 pub fn reject_proposal(&self, id: &str) -> bool {
250 let mut store = self.proposals.borrow_mut();
251 store.previews.remove(id);
252 let before = store.pending.len();
253 store.pending.retain(|proposal| proposal.id != id);
254 before != store.pending.len()
255 }
256
257 pub(crate) fn pending_proposal(&self, id: &str) -> ProposalResult<Proposal> {
258 self.proposals
259 .borrow()
260 .pending
261 .iter()
262 .find(|proposal| proposal.id == id)
263 .cloned()
264 .ok_or_else(|| ProposalError::NotFound(id.to_owned()))
265 }
266
267 fn preview_edits(
272 &self,
273 before: &DeckSnapshot,
274 edits: &[ProposalEdit],
275 ) -> ProposalResult<(DeckSession, DeckSnapshot)> {
276 for edit in edits {
277 let (slide_id, shape_id) = target(before, edit)?;
278 check_target(before, &slide_id, shape_id.as_deref())?;
279 }
280 let preview = self.stage()?;
281 for edit in edits {
282 apply_edit(&preview, edit)?;
283 }
284 let snapshot = preview.validated_snapshot()?;
285 Ok((preview, snapshot))
286 }
287}
288
289pub(crate) fn apply_edit(session: &DeckSession, edit: &ProposalEdit) -> Result<(), EditError> {
290 let context = EditCtx::local("proposal");
291 match edit {
292 ProposalEdit::ReplaceText {
293 story_id,
294 start,
295 end,
296 text,
297 style,
298 } => {
299 let story = session.story(story_id)?;
300 let inherited = inherited_style(&story, *start);
301 session.delete_text(&context, story_id, *start, *end)?;
302 session.insert_text(
303 &context,
304 story_id,
305 *start,
306 text,
307 style.as_ref().unwrap_or(&inherited),
308 )?;
309 }
310 ProposalEdit::FormatText {
311 story_id,
312 start,
313 end,
314 patch,
315 } => {
316 session.format_text(&context, story_id, *start, *end, patch)?;
317 }
318 ProposalEdit::SetParagraphAlignment {
319 story_id,
320 start,
321 end,
322 alignment,
323 } => {
324 session.set_paragraph_alignment(
325 &context,
326 story_id,
327 *start,
328 *end,
329 alignment.as_deref(),
330 )?;
331 }
332 ProposalEdit::SetShapeRect {
333 slide_id,
334 shape_id,
335 rect,
336 } => {
337 session.set_shape_rect(&context, slide_id, shape_id, *rect)?;
338 }
339 ProposalEdit::SetShapeFill {
340 slide_id,
341 shape_id,
342 color,
343 } => {
344 session.set_shape_fill(&context, slide_id, shape_id, color.as_deref())?;
345 }
346 ProposalEdit::SetShapeStroke {
347 slide_id,
348 shape_id,
349 stroke,
350 } => {
351 session.set_shape_stroke(&context, slide_id, shape_id, stroke)?;
352 }
353 ProposalEdit::SetShapeAdjust {
354 slide_id,
355 shape_id,
356 adjustments,
357 } => {
358 session.set_shape_adjust(&context, slide_id, shape_id, adjustments)?;
359 }
360 ProposalEdit::SetSlideNotes { slide_id, text } => {
361 session.set_slide_notes(&context, slide_id, text)?
362 }
363 }
364 Ok(())
365}
366
367pub(crate) fn inherited_style(story: &crate::StorySnapshot, at: u32) -> TextStyle {
368 let mut offset = 0;
369 let mut previous = TextStyle::default();
370 for paragraph in &story.paragraphs {
371 for run in ¶graph.runs {
372 offset += run.text.encode_utf16().count() as u32;
373 if at < offset {
374 return run.style.clone();
375 }
376 previous = run.style.clone();
377 }
378 if at == offset {
379 return previous;
380 }
381 offset += 1;
382 }
383 previous
384}
385
386fn find_shape<'a>(shapes: &'a [ShapeSnapshot], id: &str) -> Option<&'a ShapeSnapshot> {
387 shapes.iter().find_map(|shape| {
388 if shape.id == id {
389 Some(shape)
390 } else {
391 find_shape(&shape.children, id)
392 }
393 })
394}
395
396fn find_story_shape<'a>(shapes: &'a [ShapeSnapshot], id: &str) -> Option<&'a ShapeSnapshot> {
397 shapes.iter().find_map(|shape| {
398 if shape.text_stories.iter().any(|story| story.id == id) {
399 Some(shape)
400 } else {
401 find_story_shape(&shape.children, id)
402 }
403 })
404}
405
406pub(crate) fn shape_text(shape: &ShapeSnapshot) -> String {
407 shape
408 .text_stories
409 .iter()
410 .map(|story| story.plain_text())
411 .collect::<Vec<_>>()
412 .join("\n")
413}
414
415fn target(
416 snapshot: &DeckSnapshot,
417 edit: &ProposalEdit,
418) -> Result<(String, Option<String>), EditError> {
419 match edit {
420 ProposalEdit::ReplaceText { story_id, .. }
421 | ProposalEdit::FormatText { story_id, .. }
422 | ProposalEdit::SetParagraphAlignment { story_id, .. } => snapshot
423 .slides
424 .iter()
425 .find_map(|slide| {
426 find_story_shape(&slide.shapes, story_id)
427 .map(|shape| (slide.id.clone(), Some(shape.id.clone())))
428 })
429 .ok_or_else(|| EditError::StoryNotFound(story_id.clone())),
430 ProposalEdit::SetShapeRect {
431 slide_id, shape_id, ..
432 }
433 | ProposalEdit::SetShapeFill {
434 slide_id, shape_id, ..
435 }
436 | ProposalEdit::SetShapeStroke {
437 slide_id, shape_id, ..
438 }
439 | ProposalEdit::SetShapeAdjust {
440 slide_id, shape_id, ..
441 } => Ok((slide_id.clone(), Some(shape_id.clone()))),
442 ProposalEdit::SetSlideNotes { slide_id, .. } => Ok((slide_id.clone(), None)),
443 }
444}
445
446fn check_target(
447 snapshot: &DeckSnapshot,
448 slide_id: &str,
449 shape_id: Option<&str>,
450) -> Result<(), EditError> {
451 let slide = snapshot
452 .slides
453 .iter()
454 .find(|slide| slide.id == slide_id)
455 .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
456 if let Some(id) = shape_id {
457 find_shape(&slide.shapes, id).ok_or_else(|| EditError::ShapeNotFound(id.to_owned()))?;
458 }
459 Ok(())
460}
461
462fn capture(
463 snapshot: &DeckSnapshot,
464 slide_id: &str,
465 shape_id: Option<&str>,
466) -> Result<(Option<ShapeSnapshot>, String), EditError> {
467 let slide = snapshot
468 .slides
469 .iter()
470 .find(|slide| slide.id == slide_id)
471 .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
472 match shape_id {
473 Some(id) => {
474 let shape = find_shape(&slide.shapes, id)
475 .ok_or_else(|| EditError::ShapeNotFound(id.to_owned()))?;
476 Ok((Some(shape.clone()), shape_text(shape)))
477 }
478 None => Ok((None, slide.notes.clone())),
479 }
480}
481
482fn changes_for(
483 before: &DeckSnapshot,
484 after: &DeckSnapshot,
485 edits: &[ProposalEdit],
486) -> Result<Vec<ProposalChange>, EditError> {
487 let targets = edits
488 .iter()
489 .map(|edit| target(before, edit))
490 .collect::<Result<BTreeSet<_>, _>>()?;
491 targets
492 .into_iter()
493 .map(|(slide_id, shape_id)| {
494 let (before, old_text) = capture(before, &slide_id, shape_id.as_deref())?;
495 let (after, new_text) = capture(after, &slide_id, shape_id.as_deref())?;
496 Ok(ProposalChange {
497 slide_id,
498 shape_id,
499 before,
500 after,
501 old_text,
502 new_text,
503 })
504 })
505 .collect()
506}
507
508fn stale_targets(snapshot: &DeckSnapshot, proposal: &Proposal) -> Vec<String> {
509 proposal
510 .changes
511 .iter()
512 .filter(|change| {
513 capture(snapshot, &change.slide_id, change.shape_id.as_deref())
514 .map_or(true, |(shape, text)| {
515 shape != change.before || text != change.old_text
516 })
517 })
518 .map(ProposalChange::key)
519 .collect()
520}
521
522#[cfg(test)]
523mod tests {
524 use crate::{DeckSession, EditCtx, ProposalEdit, ProposalError, ProposalRequest};
525
526 const DECK: &[u8] = include_bytes!("../../../apps/demo/public/betteroffice-demo.pptx");
527
528 fn request(edit: ProposalEdit) -> ProposalRequest {
529 ProposalRequest {
530 agent_id: "agent".into(),
531 note: None,
532 edits: vec![edit],
533 }
534 }
535
536 fn notes(slide_id: &str, text: &str) -> ProposalRequest {
537 request(ProposalEdit::SetSlideNotes {
538 slide_id: slide_id.into(),
539 text: text.into(),
540 })
541 }
542
543 #[test]
544 fn previews_leave_the_cache_with_their_proposal() {
545 let session = DeckSession::open(DECK, 97).unwrap();
546 let slide = session.snapshot().unwrap().slides[0].clone();
547 let cached = || session.proposals.borrow().previews.len();
548 let missing = |id: &str| {
549 matches!(
550 session.preview_proposal(id),
551 Err(ProposalError::NotFound(_))
552 )
553 };
554 let accept = |id: &str| session.accept_proposal(id, false).unwrap().applied;
555
556 let rejected = session.propose(notes(&slide.id, "Rejected")).unwrap();
557 session.preview_proposal(&rejected.id).unwrap();
558 assert!(session.reject_proposal(&rejected.id));
559 assert!(missing(&rejected.id));
560 let unchanged = session.propose(notes(&slide.id, &slide.notes)).unwrap();
561 assert!(!accept(&unchanged.id));
562 assert!(missing(&unchanged.id));
563 let accepted = session.propose(notes(&slide.id, "Accepted")).unwrap();
564 assert!(accept(&accepted.id));
565 assert!(missing(&accepted.id));
566 assert_eq!(cached(), 0);
567
568 for cycle in 0..50 {
569 let proposal = session
570 .propose(notes(&slide.id, &format!("Cycle {cycle}")))
571 .unwrap();
572 session.preview_proposal(&proposal.id).unwrap();
573 assert!(session.reject_proposal(&proposal.id));
574 }
575 assert_eq!(cached(), 0);
576
577 let shape = &slide.shapes[0];
578 let removed = session
579 .propose(request(ProposalEdit::SetShapeFill {
580 slide_id: slide.id.clone(),
581 shape_id: shape.id.clone(),
582 color: Some("#FF0000".into()),
583 }))
584 .unwrap();
585 assert_eq!(cached(), 1);
586 session
587 .remove_shape(&EditCtx::local("human"), &slide.id, &shape.id)
588 .unwrap();
589 assert!(session.preview_proposal(&removed.id).is_err());
590 assert_eq!(session.proposals().unwrap().len(), 1);
591 assert_eq!(cached(), 0);
592 }
593}