Skip to main content

pptx_edit/
proposals.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::sync::atomic::Ordering;
3
4use serde::{Deserialize, Serialize};
5use yrs::Transact;
6
7use crate::{
8    DeckSession, DeckSnapshot, DeckUndoManager, EditCtx, EditError, ShapeRect, ShapeSnapshot,
9    ShapeStroke, TextStyle, TextStylePatch, decode_update_v1, doc_with_client_id, hydrate_doc,
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    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}
138
139impl DeckSession {
140    pub fn propose(&self, request: ProposalRequest) -> ProposalResult<Proposal> {
141        if request.agent_id.trim().is_empty()
142            || request.edits.is_empty()
143            || request.edits.len() > 256
144        {
145            return Err(ProposalError::Invalid(
146                "an agent and 1 to 256 edits are required".into(),
147            ));
148        }
149        if self.proposals.borrow().pending.len() >= 64 {
150            return Err(ProposalError::Invalid(
151                "64 proposals are already pending".into(),
152            ));
153        }
154        let before = self.snapshot()?;
155        let preview = self.preview_edits(&request.edits)?;
156        let changes = changes_for(&before, &preview.snapshot()?, &request.edits)?;
157        let mut store = self.proposals.borrow_mut();
158        store.next_id += 1;
159        let proposal = Proposal {
160            id: format!("p{}", store.next_id),
161            agent_id: request.agent_id,
162            note: request.note,
163            edits: request.edits,
164            changes,
165            stale_targets: Vec::new(),
166        };
167        store.pending.push(proposal.clone());
168        Ok(proposal)
169    }
170
171    pub fn proposals(&self) -> ProposalResult<Vec<Proposal>> {
172        let snapshot = self.snapshot()?;
173        Ok(self
174            .proposals
175            .borrow()
176            .pending
177            .iter()
178            .map(|proposal| {
179                let mut proposal = proposal.clone();
180                proposal.stale_targets = stale_targets(&snapshot, &proposal);
181                proposal
182            })
183            .collect())
184    }
185
186    pub fn preview_proposal(&self, id: &str) -> ProposalResult<ProposalPreview> {
187        let mut proposal = self.pending_proposal(id)?;
188        let before = self.snapshot()?;
189        let snapshot = self.preview_edits(&proposal.edits)?.snapshot()?;
190        proposal.stale_targets = stale_targets(&before, &proposal);
191        proposal.changes = changes_for(&before, &snapshot, &proposal.edits)?;
192        Ok(ProposalPreview { proposal, snapshot })
193    }
194
195    pub fn proposal_preview_session(&self, id: &str) -> ProposalResult<DeckSession> {
196        self.preview_edits(&self.pending_proposal(id)?.edits)
197    }
198
199    pub fn accept_proposal(&self, id: &str, force: bool) -> ProposalResult<ProposalAcceptance> {
200        let proposal = self.pending_proposal(id)?;
201        let before = self.snapshot()?;
202        let stale = stale_targets(&before, &proposal);
203        if !force && !stale.is_empty() {
204            return Err(ProposalError::Stale(stale));
205        }
206        let preview = self.preview_edits(&proposal.edits)?;
207        let snapshot = preview.snapshot()?;
208        let applied = before != snapshot;
209        if applied {
210            let update = preview.encode_diff_v1(&self.encode_state_vector_v1())?;
211            let update = decode_update_v1(&update).map_err(EditError::InvalidUpdate)?;
212            self.add_undo_barrier();
213            self.doc
214                .transact_mut_with(self.client_id)
215                .apply_update(update)
216                .map_err(|error| EditError::InvalidUpdate(error.to_string()))?;
217            self.id_counter.store(
218                preview.id_counter.load(Ordering::Relaxed),
219                Ordering::Relaxed,
220            );
221            self.add_undo_barrier();
222        }
223        self.reject_proposal(id);
224        Ok(ProposalAcceptance {
225            proposal_id: id.to_owned(),
226            applied,
227            snapshot,
228        })
229    }
230
231    pub fn reject_proposal(&self, id: &str) -> bool {
232        let mut store = self.proposals.borrow_mut();
233        let before = store.pending.len();
234        store.pending.retain(|proposal| proposal.id != id);
235        before != store.pending.len()
236    }
237
238    fn pending_proposal(&self, id: &str) -> ProposalResult<Proposal> {
239        self.proposals
240            .borrow()
241            .pending
242            .iter()
243            .find(|proposal| proposal.id == id)
244            .cloned()
245            .ok_or_else(|| ProposalError::NotFound(id.to_owned()))
246    }
247
248    fn preview_edits(&self, edits: &[ProposalEdit]) -> ProposalResult<DeckSession> {
249        let snapshot = self.snapshot()?;
250        for edit in edits {
251            let (slide_id, shape_id) = target(&snapshot, edit)?;
252            capture(&snapshot, &slide_id, shape_id.as_deref())?;
253        }
254        let doc = doc_with_client_id(self.client_id);
255        hydrate_doc(&doc, &self.encode_state_as_update_v1())?;
256        let undo = DeckUndoManager::new(&doc, self.client_id)?;
257        let preview = DeckSession {
258            doc,
259            client_id: self.client_id,
260            id_counter: self.id_counter.load(Ordering::Relaxed).into(),
261            package: self.package.clone(),
262            undo: std::cell::RefCell::new(undo),
263            proposals: Default::default(),
264        };
265        for edit in edits {
266            apply_edit(&preview, edit)?;
267        }
268        crate::deck::validate_doc(&preview.doc)?;
269        Ok(preview)
270    }
271}
272
273fn apply_edit(session: &DeckSession, edit: &ProposalEdit) -> Result<(), EditError> {
274    let context = EditCtx::local("proposal");
275    match edit {
276        ProposalEdit::ReplaceText {
277            story_id,
278            start,
279            end,
280            text,
281            style,
282        } => {
283            let story = session.story(story_id)?;
284            let inherited = inherited_style(&story, *start);
285            session.delete_text(&context, story_id, *start, *end)?;
286            session.insert_text(
287                &context,
288                story_id,
289                *start,
290                text,
291                style.as_ref().unwrap_or(&inherited),
292            )?;
293        }
294        ProposalEdit::FormatText {
295            story_id,
296            start,
297            end,
298            patch,
299        } => {
300            session.format_text(&context, story_id, *start, *end, patch)?;
301        }
302        ProposalEdit::SetParagraphAlignment {
303            story_id,
304            start,
305            end,
306            alignment,
307        } => {
308            session.set_paragraph_alignment(
309                &context,
310                story_id,
311                *start,
312                *end,
313                alignment.as_deref(),
314            )?;
315        }
316        ProposalEdit::SetShapeRect {
317            slide_id,
318            shape_id,
319            rect,
320        } => {
321            session.set_shape_rect(&context, slide_id, shape_id, *rect)?;
322        }
323        ProposalEdit::SetShapeFill {
324            slide_id,
325            shape_id,
326            color,
327        } => {
328            session.set_shape_fill(&context, slide_id, shape_id, color.as_deref())?;
329        }
330        ProposalEdit::SetShapeStroke {
331            slide_id,
332            shape_id,
333            stroke,
334        } => {
335            session.set_shape_stroke(&context, slide_id, shape_id, stroke)?;
336        }
337        ProposalEdit::SetShapeAdjust {
338            slide_id,
339            shape_id,
340            adjustments,
341        } => {
342            session.set_shape_adjust(&context, slide_id, shape_id, adjustments)?;
343        }
344        ProposalEdit::SetSlideNotes { slide_id, text } => {
345            session.set_slide_notes(&context, slide_id, text)?
346        }
347    }
348    Ok(())
349}
350
351fn inherited_style(story: &crate::StorySnapshot, at: u32) -> TextStyle {
352    let mut offset = 0;
353    let mut previous = TextStyle::default();
354    for paragraph in &story.paragraphs {
355        for run in &paragraph.runs {
356            offset += run.text.encode_utf16().count() as u32;
357            if at < offset {
358                return run.style.clone();
359            }
360            previous = run.style.clone();
361        }
362        if at == offset {
363            return previous;
364        }
365        offset += 1;
366    }
367    previous
368}
369
370fn find_shape<'a>(shapes: &'a [ShapeSnapshot], id: &str) -> Option<&'a ShapeSnapshot> {
371    shapes.iter().find_map(|shape| {
372        if shape.id == id {
373            Some(shape)
374        } else {
375            find_shape(&shape.children, id)
376        }
377    })
378}
379
380fn find_story_shape<'a>(shapes: &'a [ShapeSnapshot], id: &str) -> Option<&'a ShapeSnapshot> {
381    shapes.iter().find_map(|shape| {
382        if shape.text_stories.iter().any(|story| story.id == id) {
383            Some(shape)
384        } else {
385            find_story_shape(&shape.children, id)
386        }
387    })
388}
389
390fn shape_text(shape: &ShapeSnapshot) -> String {
391    shape
392        .text_stories
393        .iter()
394        .map(|story| story.plain_text())
395        .collect::<Vec<_>>()
396        .join("\n")
397}
398
399fn target(
400    snapshot: &DeckSnapshot,
401    edit: &ProposalEdit,
402) -> Result<(String, Option<String>), EditError> {
403    match edit {
404        ProposalEdit::ReplaceText { story_id, .. }
405        | ProposalEdit::FormatText { story_id, .. }
406        | ProposalEdit::SetParagraphAlignment { story_id, .. } => snapshot
407            .slides
408            .iter()
409            .find_map(|slide| {
410                find_story_shape(&slide.shapes, story_id)
411                    .map(|shape| (slide.id.clone(), Some(shape.id.clone())))
412            })
413            .ok_or_else(|| EditError::StoryNotFound(story_id.clone())),
414        ProposalEdit::SetShapeRect {
415            slide_id, shape_id, ..
416        }
417        | ProposalEdit::SetShapeFill {
418            slide_id, shape_id, ..
419        }
420        | ProposalEdit::SetShapeStroke {
421            slide_id, shape_id, ..
422        }
423        | ProposalEdit::SetShapeAdjust {
424            slide_id, shape_id, ..
425        } => Ok((slide_id.clone(), Some(shape_id.clone()))),
426        ProposalEdit::SetSlideNotes { slide_id, .. } => Ok((slide_id.clone(), None)),
427    }
428}
429
430fn capture(
431    snapshot: &DeckSnapshot,
432    slide_id: &str,
433    shape_id: Option<&str>,
434) -> Result<(Option<ShapeSnapshot>, String), EditError> {
435    let slide = snapshot
436        .slides
437        .iter()
438        .find(|slide| slide.id == slide_id)
439        .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
440    match shape_id {
441        Some(id) => {
442            let shape = find_shape(&slide.shapes, id)
443                .ok_or_else(|| EditError::ShapeNotFound(id.to_owned()))?;
444            Ok((Some(shape.clone()), shape_text(shape)))
445        }
446        None => Ok((None, slide.notes.clone())),
447    }
448}
449
450fn changes_for(
451    before: &DeckSnapshot,
452    after: &DeckSnapshot,
453    edits: &[ProposalEdit],
454) -> Result<Vec<ProposalChange>, EditError> {
455    let targets = edits
456        .iter()
457        .map(|edit| target(before, edit))
458        .collect::<Result<BTreeSet<_>, _>>()?;
459    targets
460        .into_iter()
461        .map(|(slide_id, shape_id)| {
462            let (before, old_text) = capture(before, &slide_id, shape_id.as_deref())?;
463            let (after, new_text) = capture(after, &slide_id, shape_id.as_deref())?;
464            Ok(ProposalChange {
465                slide_id,
466                shape_id,
467                before,
468                after,
469                old_text,
470                new_text,
471            })
472        })
473        .collect()
474}
475
476fn stale_targets(snapshot: &DeckSnapshot, proposal: &Proposal) -> Vec<String> {
477    proposal
478        .changes
479        .iter()
480        .filter(|change| {
481            capture(snapshot, &change.slide_id, change.shape_id.as_deref())
482                .map_or(true, |(shape, text)| {
483                    shape != change.before || text != change.old_text
484                })
485        })
486        .map(ProposalChange::key)
487        .collect()
488}