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 ProposalStore {
144 pub(crate) fn counters(&self) -> (u64, usize) {
145 (self.next_id, self.pending.len())
146 }
147
148 pub(crate) fn adopt_counter(&mut self, counter: u64) {
149 self.next_id = counter;
150 }
151}
152
153#[derive(Serialize)]
154#[serde(rename_all = "camelCase")]
155pub(crate) struct CompactProposalAcceptance {
156 pub proposal_id: String,
157 pub applied: bool,
158 pub changed_targets: Vec<String>,
159}
160
161impl DeckSession {
162 pub fn propose(&self, request: ProposalRequest) -> ProposalResult<Proposal> {
163 if request.agent_id.trim().is_empty()
164 || request.edits.is_empty()
165 || request.edits.len() > 256
166 {
167 return Err(ProposalError::Invalid(
168 "an agent and 1 to 256 edits are required".into(),
169 ));
170 }
171 if self.proposals.borrow().pending.len() >= 64 {
172 return Err(ProposalError::Invalid(
173 "64 proposals are already pending".into(),
174 ));
175 }
176 let before = self.snapshot()?;
177 let (_, snapshot) = self.preview_edits(&before, &request.edits)?;
178 let changes = changes_for(&before, &snapshot, &request.edits)?;
179 let mut store = self.proposals.borrow_mut();
180 store.next_id += 1;
181 let proposal = Proposal {
182 id: format!("p{}", store.next_id),
183 agent_id: request.agent_id,
184 note: request.note,
185 edits: request.edits,
186 changes,
187 stale_targets: Vec::new(),
188 };
189 store.pending.push(proposal.clone());
190 let epoch = self.epoch();
191 store.previews.insert(
192 proposal.id.clone(),
193 (
194 epoch,
195 ProposalPreview {
196 proposal: proposal.clone(),
197 snapshot,
198 },
199 ),
200 );
201 Ok(proposal)
202 }
203
204 pub fn proposals(&self) -> ProposalResult<Vec<Proposal>> {
205 let snapshot = self.snapshot()?;
206 Ok(self
207 .proposals
208 .borrow()
209 .pending
210 .iter()
211 .map(|proposal| {
212 let mut proposal = proposal.clone();
213 proposal.stale_targets = stale_targets(&snapshot, &proposal);
214 proposal
215 })
216 .collect())
217 }
218
219 pub fn preview_proposal(&self, id: &str) -> ProposalResult<ProposalPreview> {
220 let mut proposal = self.pending_proposal(id)?;
221 let epoch = self.epoch();
222 if let Some((cached_epoch, preview)) = self.proposals.borrow().previews.get(id)
223 && *cached_epoch == epoch
224 {
225 return Ok(preview.clone());
226 }
227 self.proposals.borrow_mut().previews.remove(id);
228 let before = self.snapshot()?;
229 let (_, snapshot) = self.preview_edits(&before, &proposal.edits)?;
230 proposal.stale_targets = stale_targets(&before, &proposal);
231 proposal.changes = changes_for(&before, &snapshot, &proposal.edits)?;
232 let preview = ProposalPreview { proposal, snapshot };
233 self.proposals
234 .borrow_mut()
235 .previews
236 .insert(id.to_owned(), (epoch, preview.clone()));
237 Ok(preview)
238 }
239
240 pub fn proposal_preview_session(&self, id: &str) -> ProposalResult<DeckSession> {
241 let proposal = self.pending_proposal(id)?;
242 let before = self.snapshot()?;
243 Ok(self.preview_edits(&before, &proposal.edits)?.0)
244 }
245
246 pub fn accept_proposal(&self, id: &str, force: bool) -> ProposalResult<ProposalAcceptance> {
247 let (outcome, snapshot) = self.accept_proposal_inner(id, force)?;
248 Ok(ProposalAcceptance {
249 proposal_id: outcome.proposal_id,
250 applied: outcome.applied,
251 snapshot,
252 })
253 }
254
255 pub(crate) fn accept_proposal_compact(
256 &self,
257 id: &str,
258 force: bool,
259 ) -> ProposalResult<CompactProposalAcceptance> {
260 self.accept_proposal_inner(id, force)
261 .map(|(outcome, _)| outcome)
262 }
263
264 fn accept_proposal_inner(
265 &self,
266 id: &str,
267 force: bool,
268 ) -> ProposalResult<(CompactProposalAcceptance, DeckSnapshot)> {
269 let proposal = self.pending_proposal(id)?;
270 let before = self.snapshot()?;
271 let stale = stale_targets(&before, &proposal);
272 if !force && !stale.is_empty() {
273 return Err(ProposalError::Stale(stale));
274 }
275 let (preview, snapshot) = self.preview_edits(&before, &proposal.edits)?;
276 let applied = before != snapshot;
277 if applied {
278 let (_, update) = preview.staged_update(&self.doc.transact().state_vector())?;
279 self.adopt(&preview, update, Adoption::Proposal)?;
280 }
281 self.reject_proposal(id);
282 let changed_targets = proposal
283 .changes
284 .iter()
285 .filter(|change| {
286 capture(&snapshot, &change.slide_id, change.shape_id.as_deref()).is_ok_and(
287 |after| {
288 capture(&before, &change.slide_id, change.shape_id.as_deref())
289 .is_ok_and(|before| after != before)
290 },
291 )
292 })
293 .map(ProposalChange::key)
294 .collect::<BTreeSet<_>>()
295 .into_iter()
296 .collect();
297 Ok((
298 CompactProposalAcceptance {
299 proposal_id: id.to_owned(),
300 applied,
301 changed_targets,
302 },
303 snapshot,
304 ))
305 }
306
307 pub fn reject_proposal(&self, id: &str) -> bool {
308 let mut store = self.proposals.borrow_mut();
309 store.previews.remove(id);
310 let before = store.pending.len();
311 store.pending.retain(|proposal| proposal.id != id);
312 before != store.pending.len()
313 }
314
315 pub(crate) fn pending_proposal(&self, id: &str) -> ProposalResult<Proposal> {
316 self.proposals
317 .borrow()
318 .pending
319 .iter()
320 .find(|proposal| proposal.id == id)
321 .cloned()
322 .ok_or_else(|| ProposalError::NotFound(id.to_owned()))
323 }
324
325 fn preview_edits(
330 &self,
331 before: &DeckSnapshot,
332 edits: &[ProposalEdit],
333 ) -> ProposalResult<(DeckSession, DeckSnapshot)> {
334 for edit in edits {
335 let (slide_id, shape_id) = target(before, edit)?;
336 check_target(before, &slide_id, shape_id.as_deref())?;
337 }
338 let preview = self.stage()?;
339 for edit in edits {
340 apply_edit(&preview, edit)?;
341 }
342 let snapshot = preview.validated_snapshot()?;
343 Ok((preview, snapshot))
344 }
345}
346
347pub(crate) fn apply_edit(session: &DeckSession, edit: &ProposalEdit) -> Result<(), EditError> {
348 let context = EditCtx::local("proposal");
349 match edit {
350 ProposalEdit::ReplaceText {
351 story_id,
352 start,
353 end,
354 text,
355 style,
356 } => {
357 let story = session.story(story_id)?;
358 let inherited = inherited_style(&story, *start);
359 session.delete_text(&context, story_id, *start, *end)?;
360 session.insert_text(
361 &context,
362 story_id,
363 *start,
364 text,
365 style.as_ref().unwrap_or(&inherited),
366 )?;
367 }
368 ProposalEdit::FormatText {
369 story_id,
370 start,
371 end,
372 patch,
373 } => {
374 session.format_text(&context, story_id, *start, *end, patch)?;
375 }
376 ProposalEdit::SetParagraphAlignment {
377 story_id,
378 start,
379 end,
380 alignment,
381 } => {
382 session.set_paragraph_alignment(
383 &context,
384 story_id,
385 *start,
386 *end,
387 alignment.as_deref(),
388 )?;
389 }
390 ProposalEdit::SetShapeRect {
391 slide_id,
392 shape_id,
393 rect,
394 } => {
395 session.set_shape_rect(&context, slide_id, shape_id, *rect)?;
396 }
397 ProposalEdit::SetShapeFill {
398 slide_id,
399 shape_id,
400 color,
401 } => {
402 session.set_shape_fill(&context, slide_id, shape_id, color.as_deref())?;
403 }
404 ProposalEdit::SetShapeStroke {
405 slide_id,
406 shape_id,
407 stroke,
408 } => {
409 session.set_shape_stroke(&context, slide_id, shape_id, stroke)?;
410 }
411 ProposalEdit::SetShapeAdjust {
412 slide_id,
413 shape_id,
414 adjustments,
415 } => {
416 session.set_shape_adjust(&context, slide_id, shape_id, adjustments)?;
417 }
418 ProposalEdit::SetSlideNotes { slide_id, text } => {
419 session.set_slide_notes(&context, slide_id, text)?
420 }
421 }
422 Ok(())
423}
424
425pub(crate) fn inherited_style(story: &crate::StorySnapshot, at: u32) -> TextStyle {
426 let mut offset = 0;
427 let mut previous = TextStyle::default();
428 for paragraph in &story.paragraphs {
429 for run in ¶graph.runs {
430 offset += run.text.encode_utf16().count() as u32;
431 if at < offset {
432 return run.style.clone();
433 }
434 previous = run.style.clone();
435 }
436 if at == offset {
437 return previous;
438 }
439 offset += 1;
440 }
441 previous
442}
443
444fn find_shape<'a>(shapes: &'a [ShapeSnapshot], id: &str) -> Option<&'a ShapeSnapshot> {
445 shapes.iter().find_map(|shape| {
446 if shape.id == id {
447 Some(shape)
448 } else {
449 find_shape(&shape.children, id)
450 }
451 })
452}
453
454fn find_story_shape<'a>(shapes: &'a [ShapeSnapshot], id: &str) -> Option<&'a ShapeSnapshot> {
455 shapes.iter().find_map(|shape| {
456 if shape.text_stories.iter().any(|story| story.id == id) {
457 Some(shape)
458 } else {
459 find_story_shape(&shape.children, id)
460 }
461 })
462}
463
464pub(crate) fn shape_text(shape: &ShapeSnapshot) -> String {
465 shape
466 .text_stories
467 .iter()
468 .map(|story| story.plain_text())
469 .collect::<Vec<_>>()
470 .join("\n")
471}
472
473fn target(
474 snapshot: &DeckSnapshot,
475 edit: &ProposalEdit,
476) -> Result<(String, Option<String>), EditError> {
477 match edit {
478 ProposalEdit::ReplaceText { story_id, .. }
479 | ProposalEdit::FormatText { story_id, .. }
480 | ProposalEdit::SetParagraphAlignment { story_id, .. } => snapshot
481 .slides
482 .iter()
483 .find_map(|slide| {
484 find_story_shape(&slide.shapes, story_id)
485 .map(|shape| (slide.id.clone(), Some(shape.id.clone())))
486 })
487 .ok_or_else(|| EditError::StoryNotFound(story_id.clone())),
488 ProposalEdit::SetShapeRect {
489 slide_id, shape_id, ..
490 }
491 | ProposalEdit::SetShapeFill {
492 slide_id, shape_id, ..
493 }
494 | ProposalEdit::SetShapeStroke {
495 slide_id, shape_id, ..
496 }
497 | ProposalEdit::SetShapeAdjust {
498 slide_id, shape_id, ..
499 } => Ok((slide_id.clone(), Some(shape_id.clone()))),
500 ProposalEdit::SetSlideNotes { slide_id, .. } => Ok((slide_id.clone(), None)),
501 }
502}
503
504fn check_target(
505 snapshot: &DeckSnapshot,
506 slide_id: &str,
507 shape_id: Option<&str>,
508) -> Result<(), EditError> {
509 let slide = snapshot
510 .slides
511 .iter()
512 .find(|slide| slide.id == slide_id)
513 .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
514 if let Some(id) = shape_id {
515 find_shape(&slide.shapes, id).ok_or_else(|| EditError::ShapeNotFound(id.to_owned()))?;
516 }
517 Ok(())
518}
519
520fn capture(
521 snapshot: &DeckSnapshot,
522 slide_id: &str,
523 shape_id: Option<&str>,
524) -> Result<(Option<ShapeSnapshot>, String), EditError> {
525 let slide = snapshot
526 .slides
527 .iter()
528 .find(|slide| slide.id == slide_id)
529 .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
530 match shape_id {
531 Some(id) => {
532 let shape = find_shape(&slide.shapes, id)
533 .ok_or_else(|| EditError::ShapeNotFound(id.to_owned()))?;
534 Ok((Some(shape.clone()), shape_text(shape)))
535 }
536 None => Ok((None, slide.notes.clone())),
537 }
538}
539
540fn changes_for(
541 before: &DeckSnapshot,
542 after: &DeckSnapshot,
543 edits: &[ProposalEdit],
544) -> Result<Vec<ProposalChange>, EditError> {
545 let targets = edits
546 .iter()
547 .map(|edit| target(before, edit))
548 .collect::<Result<BTreeSet<_>, _>>()?;
549 targets
550 .into_iter()
551 .map(|(slide_id, shape_id)| {
552 let (before, old_text) = capture(before, &slide_id, shape_id.as_deref())?;
553 let (after, new_text) = capture(after, &slide_id, shape_id.as_deref())?;
554 Ok(ProposalChange {
555 slide_id,
556 shape_id,
557 before,
558 after,
559 old_text,
560 new_text,
561 })
562 })
563 .collect()
564}
565
566fn stale_targets(snapshot: &DeckSnapshot, proposal: &Proposal) -> Vec<String> {
567 proposal
568 .changes
569 .iter()
570 .filter(|change| {
571 capture(snapshot, &change.slide_id, change.shape_id.as_deref())
572 .map_or(true, |(shape, text)| {
573 shape != change.before || text != change.old_text
574 })
575 })
576 .map(ProposalChange::key)
577 .collect()
578}
579
580#[cfg(test)]
581mod tests {
582 use crate::{DeckSession, EditCtx, ProposalEdit, ProposalError, ProposalRequest};
583
584 const DECK: &[u8] = include_bytes!("../../../apps/demo/public/betteroffice-demo.pptx");
585
586 fn request(edit: ProposalEdit) -> ProposalRequest {
587 ProposalRequest {
588 agent_id: "agent".into(),
589 note: None,
590 edits: vec![edit],
591 }
592 }
593
594 fn notes(slide_id: &str, text: &str) -> ProposalRequest {
595 request(ProposalEdit::SetSlideNotes {
596 slide_id: slide_id.into(),
597 text: text.into(),
598 })
599 }
600
601 #[test]
602 fn previews_leave_the_cache_with_their_proposal() {
603 let session = DeckSession::open(DECK, 97).unwrap();
604 let slide = session.snapshot().unwrap().slides[0].clone();
605 let cached = || session.proposals.borrow().previews.len();
606 let missing = |id: &str| {
607 matches!(
608 session.preview_proposal(id),
609 Err(ProposalError::NotFound(_))
610 )
611 };
612 let accept = |id: &str| session.accept_proposal(id, false).unwrap().applied;
613
614 let rejected = session.propose(notes(&slide.id, "Rejected")).unwrap();
615 session.preview_proposal(&rejected.id).unwrap();
616 assert!(session.reject_proposal(&rejected.id));
617 assert!(missing(&rejected.id));
618 let unchanged = session.propose(notes(&slide.id, &slide.notes)).unwrap();
619 assert!(!accept(&unchanged.id));
620 assert!(missing(&unchanged.id));
621 let accepted = session.propose(notes(&slide.id, "Accepted")).unwrap();
622 assert!(accept(&accepted.id));
623 assert!(missing(&accepted.id));
624 assert_eq!(cached(), 0);
625
626 for cycle in 0..50 {
627 let proposal = session
628 .propose(notes(&slide.id, &format!("Cycle {cycle}")))
629 .unwrap();
630 session.preview_proposal(&proposal.id).unwrap();
631 assert!(session.reject_proposal(&proposal.id));
632 }
633 assert_eq!(cached(), 0);
634
635 let shape = &slide.shapes[0];
636 let removed = session
637 .propose(request(ProposalEdit::SetShapeFill {
638 slide_id: slide.id.clone(),
639 shape_id: shape.id.clone(),
640 color: Some("#FF0000".into()),
641 }))
642 .unwrap();
643 assert_eq!(cached(), 1);
644 session
645 .remove_shape(&EditCtx::local("human"), &slide.id, &shape.id)
646 .unwrap();
647 assert!(session.preview_proposal(&removed.id).is_err());
648 assert_eq!(session.proposals().unwrap().len(), 1);
649 assert_eq!(cached(), 0);
650 }
651}