Skip to main content

pptx_edit/
proposals.rs

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    /// Memoized previews of pending proposals, keyed on proposal id and the
138    /// doc's epoch; a preview is a pure function of the proposal's edits and
139    /// the doc state.
140    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    /// Applies `edits` to a hydrated clone of the live doc — never the live
326    /// doc itself, so a preview produces no observable update events and
327    /// leaves `encode_state_as_update_v1` byte-identical — validates it, and
328    /// returns the scratch session together with the validated snapshot.
329    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 &paragraph.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}