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 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    /// Applies `edits` to a hydrated clone of the live doc — never the live
268    /// doc itself, so a preview produces no observable update events and
269    /// leaves `encode_state_as_update_v1` byte-identical — validates it, and
270    /// returns the scratch session together with the validated snapshot.
271    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 &paragraph.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}