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 ¶graph.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}