1use std::cmp::Reverse;
8use std::collections::{BTreeMap, BTreeSet};
9use std::fmt;
10use std::sync::atomic::{AtomicU64, Ordering};
11
12use ooxml_drawingml::{ShapeFill, ShapeOutline};
13use serde::{Deserialize, Deserializer, Serialize};
14use serde_json::Value;
15use yrs::{ReadTxn, Transact, Update};
16
17use crate::deck::{
18 normalized_background, shape_fill, stroked_outline, validate_adjustments, validate_background,
19 validate_rect,
20};
21use crate::model::validate_xml_text;
22use crate::proposals::inherited_style;
23use crate::staging::Adoption;
24use crate::story::{validate_alignment, validate_style_values};
25use crate::target::{
26 Deck, ParagraphView, Selection, ShapeTarget, StoryTarget, TextRange, TextTarget, keeps_fields,
27};
28use crate::{
29 DeckSession, DeckSnapshot, EditCtx, EditError, EditResult, MAX_UPDATE_BYTES, ShapeKind,
30 ShapeRect, ShapeSnapshot, ShapeStroke, StorySnapshot, TextStyle, TextStylePatch,
31};
32
33const MAX_STEPS: usize = 128;
34const MAX_INSERTED_UNITS: usize = 1_048_576;
35const MAX_STAGING_BYTES: usize = 256 * 1024 * 1024;
36pub const MAX_REQUEST_BYTES: usize = 16 * 1024 * 1024;
38
39pub(crate) struct ByteBudget {
41 remaining: usize,
42}
43
44impl ByteBudget {
45 pub fn new(limit: usize) -> Self {
46 Self { remaining: limit }
47 }
48
49 pub fn charge(&mut self, value: &impl Serialize) -> bool {
51 struct Counter(usize);
52 impl std::io::Write for Counter {
53 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
54 self.0 = self.0.checked_sub(bytes.len()).ok_or_else(|| {
55 std::io::Error::new(std::io::ErrorKind::OutOfMemory, "over budget")
56 })?;
57 Ok(bytes.len())
58 }
59
60 fn flush(&mut self) -> std::io::Result<()> {
61 Ok(())
62 }
63 }
64 let mut counter = Counter(self.remaining);
65 let fits = serde_json::to_writer(&mut counter, value).is_ok();
66 if fits {
67 self.remaining = counter.0;
68 }
69 fits
70 }
71}
72
73#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
75#[serde(transparent)]
76pub struct DocumentVersion(String);
77
78impl DocumentVersion {
79 pub fn as_str(&self) -> &str {
80 &self.0
81 }
82}
83
84impl From<String> for DocumentVersion {
85 fn from(token: String) -> Self {
86 Self(token)
87 }
88}
89
90impl From<&str> for DocumentVersion {
91 fn from(token: &str) -> Self {
92 Self(token.to_owned())
93 }
94}
95
96impl fmt::Display for DocumentVersion {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 f.write_str(&self.0)
99 }
100}
101
102pub(crate) fn version_token(nonce: u64, epoch: u64) -> DocumentVersion {
103 DocumentVersion(format!("{nonce:016x}-{epoch}"))
104}
105
106static NONCE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
107
108fn clock_entropy() -> u64 {
109 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
110 {
111 std::time::SystemTime::now()
112 .duration_since(std::time::UNIX_EPOCH)
113 .map(|elapsed| elapsed.as_nanos() as u64)
114 .unwrap_or_default()
115 }
116 #[cfg(all(target_family = "wasm", target_os = "unknown"))]
117 {
118 0
119 }
120}
121
122fn splitmix64(mut value: u64) -> u64 {
123 value = value.wrapping_add(0x9e37_79b9_7f4a_7c15);
124 value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
125 value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
126 value ^ (value >> 31)
127}
128
129pub(crate) fn mint_nonce(client_id: u64, entropy: u64) -> u64 {
130 let sequence = NONCE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
131 splitmix64(
132 splitmix64(sequence ^ client_id.rotate_left(32))
133 ^ splitmix64(entropy)
134 ^ splitmix64(clock_entropy()),
135 )
136}
137
138#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
140#[serde(rename_all = "camelCase")]
141pub enum EditSource {
142 #[default]
143 Host,
144 Agent,
145}
146
147#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
149#[serde(rename_all = "camelCase")]
150pub enum EditHistory {
151 #[default]
153 Separate,
154 None,
156}
157
158#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
160#[serde(rename_all = "camelCase")]
161pub enum TargetEdge {
162 Start,
163 End,
164}
165
166fn nullable<'de, D: Deserializer<'de>, T: Deserialize<'de>>(
168 deserializer: D,
169) -> Result<Option<T>, D::Error> {
170 Option::deserialize(deserializer)
171}
172
173#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
175#[serde(rename_all = "camelCase", deny_unknown_fields)]
176pub struct TextGuard {
177 pub text: String,
178}
179
180#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
182#[serde(rename_all = "camelCase", deny_unknown_fields)]
183pub struct RectGuard {
184 pub rect: ShapeRect,
185}
186
187#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
190#[serde(rename_all = "camelCase", deny_unknown_fields)]
191pub struct FillGuard {
192 #[serde(deserialize_with = "nullable")]
193 pub fill: Option<Box<ShapeFill>>,
194}
195
196#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
198#[serde(rename_all = "camelCase", deny_unknown_fields)]
199pub struct OutlineGuard {
200 #[serde(deserialize_with = "nullable")]
201 pub outline: Option<Box<ShapeOutline>>,
202}
203
204#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
207#[serde(rename_all = "camelCase", deny_unknown_fields)]
208pub struct BackgroundGuard {
209 #[serde(deserialize_with = "nullable")]
210 pub background: Option<Box<ShapeFill>>,
211}
212
213#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
214#[serde(rename_all = "camelCase", deny_unknown_fields)]
215pub struct SlideTarget {
216 pub slide_id: String,
217}
218
219#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
222#[serde(
223 tag = "op",
224 rename_all = "camelCase",
225 rename_all_fields = "camelCase",
226 deny_unknown_fields
227)]
228#[non_exhaustive]
229pub enum EditStep {
230 InsertText {
231 target: TextTarget,
232 at: TargetEdge,
233 text: String,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 expect: Option<TextGuard>,
236 },
237 ReplaceText {
238 target: TextTarget,
239 text: String,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 expect: Option<TextGuard>,
242 },
243 DeleteText {
244 target: TextTarget,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 expect: Option<TextGuard>,
247 },
248 FormatText {
249 target: TextTarget,
250 patch: TextStylePatch,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 expect: Option<TextGuard>,
253 },
254 SetParagraphAlignment {
256 target: TextTarget,
257 #[serde(deserialize_with = "nullable")]
258 alignment: Option<String>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 expect: Option<TextGuard>,
261 },
262 SetSlideNotes {
264 target: SlideTarget,
265 text: String,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 expect: Option<TextGuard>,
268 },
269 SetSlideBackground {
271 target: SlideTarget,
272 #[serde(deserialize_with = "nullable")]
273 background: Option<Box<ShapeFill>>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 expect: Option<BackgroundGuard>,
276 },
277 SetShapeRect {
278 target: ShapeTarget,
279 rect: ShapeRect,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 expect: Option<RectGuard>,
282 },
283 SetShapeFill {
285 target: ShapeTarget,
286 #[serde(deserialize_with = "nullable")]
287 color: Option<String>,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 expect: Option<FillGuard>,
290 },
291 SetShapeStroke {
292 target: ShapeTarget,
293 stroke: ShapeStroke,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 expect: Option<OutlineGuard>,
296 },
297 SetShapeAdjust {
298 target: ShapeTarget,
299 adjustments: BTreeMap<String, f64>,
300 },
301}
302
303fn units(text: &str) -> usize {
304 text.encode_utf16().count()
305}
306
307impl EditStep {
308 fn inserted_units(&self) -> usize {
310 match self {
311 Self::InsertText { text, .. }
312 | Self::ReplaceText { text, .. }
313 | Self::SetSlideNotes { text, .. } => units(text),
314 _ => 0,
315 }
316 }
317}
318
319#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
320#[serde(rename_all = "camelCase", deny_unknown_fields)]
321pub struct EditRequest {
322 pub expect_version: DocumentVersion,
324 #[serde(default)]
325 pub source: EditSource,
326 #[serde(default)]
327 pub history: EditHistory,
328 pub steps: Vec<EditStep>,
329}
330
331#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
332#[serde(rename_all = "kebab-case")]
333#[non_exhaustive]
334pub enum EditFailureCode {
335 StaleVersion,
336 MissingTarget,
337 AmbiguousTarget,
338 ContentMismatch,
339 OverlappingSteps,
340 Unsupported,
341 InvalidStep,
342 LimitExceeded,
343}
344
345#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
347#[serde(
348 tag = "kind",
349 rename_all = "camelCase",
350 rename_all_fields = "camelCase"
351)]
352pub enum EditTarget {
353 Range(TextRange),
354 Search { within: StoryTarget, text: String },
355 Story(StoryTarget),
356 Shape(ShapeTarget),
357 Slide(SlideTarget),
358}
359
360impl From<TextTarget> for EditTarget {
361 fn from(target: TextTarget) -> Self {
362 match target {
363 TextTarget::Range(range) => Self::Range(range),
364 TextTarget::Search { within, text } => Self::Search { within, text },
365 }
366 }
367}
368
369#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
370#[serde(rename_all = "camelCase")]
371pub struct EditFailure {
372 pub code: EditFailureCode,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub step_index: Option<u32>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub conflicting_step_index: Option<u32>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub target: Option<Box<EditTarget>>,
379 pub message: String,
380}
381
382impl EditFailure {
383 fn at(mut self, step_index: u32) -> Self {
384 self.step_index = Some(step_index);
385 self
386 }
387}
388
389#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
391#[serde(rename_all = "camelCase")]
392pub struct EditRefusal {
393 pub version: DocumentVersion,
394 pub failure: EditFailure,
395}
396
397const MAX_MESSAGE_CHARS: usize = 512;
399const MAX_ECHOED_TARGET_BYTES: usize = 64 * 1024;
401
402pub(crate) fn failure(
405 code: EditFailureCode,
406 message: impl Into<String>,
407 target: Option<&EditTarget>,
408) -> EditFailure {
409 let mut message = message.into();
410 if let Some((cut, _)) = message.char_indices().nth(MAX_MESSAGE_CHARS) {
411 message.truncate(cut);
412 message.push('…');
413 }
414 EditFailure {
415 code,
416 step_index: None,
417 conflicting_step_index: None,
418 target: target
419 .filter(|target| ByteBudget::new(MAX_ECHOED_TARGET_BYTES).charge(target))
420 .cloned()
421 .map(Box::new),
422 message,
423 }
424}
425
426pub(crate) fn quoted(text: &str) -> String {
428 match text.char_indices().nth(64) {
429 Some((cut, _)) => format!("{:?}…", &text[..cut]),
430 None => format!("{text:?}"),
431 }
432}
433
434pub(crate) fn refusal(version: DocumentVersion, failure: EditFailure) -> EditRefusal {
435 EditRefusal { version, failure }
436}
437
438pub fn oversized_request(version: DocumentVersion) -> EditRefusal {
440 refusal(version, request_limit())
441}
442
443pub(crate) fn request_limit() -> EditFailure {
444 failure(
445 EditFailureCode::LimitExceeded,
446 format!("a request holds at most {MAX_REQUEST_BYTES} bytes of JSON"),
447 None,
448 )
449}
450
451#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
454#[serde(rename_all = "camelCase")]
455pub struct EditReceipt {
456 pub step_index: u32,
457 pub changed: bool,
458 pub target: EditTarget,
459}
460
461#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
463#[serde(rename_all = "camelCase")]
464pub struct EditPreview {
465 pub step_index: u32,
466 pub target: EditTarget,
467 pub would_change: bool,
468}
469
470#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
471#[serde(rename_all = "camelCase")]
472pub struct EditValidation {
473 pub base_version: DocumentVersion,
474 pub would_apply: bool,
475 pub previews: Vec<EditPreview>,
476}
477
478#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
479#[serde(rename_all = "camelCase")]
480pub struct EditApplication {
481 pub base_version: DocumentVersion,
482 pub version: DocumentVersion,
483 pub applied: bool,
485 pub source: EditSource,
486 pub changed_slides: Vec<String>,
487 pub changed_stories: Vec<String>,
488 pub receipts: Vec<EditReceipt>,
490}
491
492pub type ValidationOutcome = Result<EditValidation, EditRefusal>;
493pub type EditOutcome = Result<EditApplication, EditRefusal>;
494
495pub fn outcome_json<T: Serialize>(
497 outcome: &Result<T, EditRefusal>,
498) -> Result<String, serde_json::Error> {
499 let mut value = match outcome {
500 Ok(body) => serde_json::to_value(body)?,
501 Err(refusal) => serde_json::to_value(refusal)?,
502 };
503 if let Value::Object(object) = &mut value {
504 object.insert("ok".to_owned(), Value::Bool(outcome.is_ok()));
505 }
506 serde_json::to_string(&value)
507}
508
509#[derive(Clone, Copy)]
511enum Claim {
512 Span(u32, u32),
513 Point(u32),
514}
515
516impl Claim {
517 fn conflicts(self, other: Self) -> bool {
518 match (self, other) {
519 (Self::Span(a, b), Self::Span(c, d)) => a < d && c < b,
520 (Self::Point(p), Self::Point(q)) => p == q,
521 (Self::Point(p), Self::Span(a, b)) | (Self::Span(a, b), Self::Point(p)) => {
522 a <= p && p <= b
523 }
524 }
525 }
526}
527
528#[derive(Clone, Copy, Eq, PartialEq)]
530enum Property {
531 Notes,
532 Background,
533 Rect,
534 Fill,
535 Stroke,
536 Adjust,
537}
538
539enum Effect {
540 Insert {
541 at: u32,
542 text: String,
543 style: TextStyle,
544 },
545 Replace {
546 start: u32,
547 end: u32,
548 text: String,
549 style: TextStyle,
550 },
551 Delete {
552 start: u32,
553 end: u32,
554 },
555 Format {
556 start: u32,
557 end: u32,
558 patch: TextStylePatch,
559 },
560 Align {
561 start: u32,
562 end: u32,
563 alignment: Option<String>,
564 },
565 Notes(String),
566 Background(Option<ShapeFill>),
567 Rect(ShapeRect),
568 Fill(Option<String>),
569 Stroke(ShapeStroke),
570 Adjust(BTreeMap<String, f64>),
571}
572
573impl Effect {
574 fn shift(&self) -> Option<(u32, i64)> {
576 match self {
577 Self::Insert { at, text, .. } => Some((*at, units(text) as i64)),
578 Self::Replace {
579 start, end, text, ..
580 } => Some((*start, units(text) as i64 - i64::from(end - start))),
581 Self::Delete { start, end } => Some((*start, -i64::from(end - start))),
582 _ => None,
583 }
584 }
585}
586
587enum Location {
589 Grown { start: u32, len: u32 },
591 Kept,
593 Target,
595}
596
597struct Planned {
598 index: u32,
599 target: EditTarget,
601 slide_id: String,
602 shape_id: Option<String>,
603 story_id: Option<String>,
604 claims: Vec<Claim>,
605 paragraphs: Vec<String>,
606 exclusive: bool,
608 property: Option<Property>,
609 effect: Option<Effect>,
610 location: Location,
611}
612
613impl Planned {
614 fn text(
615 index: u32,
616 selection: &Selection<'_>,
617 claims: Vec<Claim>,
618 paragraphs: Vec<String>,
619 exclusive: bool,
620 effect: Option<Effect>,
621 location: Location,
622 ) -> Self {
623 Self {
624 index,
625 target: EditTarget::Range(selection.range()),
626 slide_id: selection.view.slide.id.clone(),
627 shape_id: Some(selection.view.shape.id.clone()),
628 story_id: Some(selection.view.story.id.clone()),
629 claims,
630 paragraphs,
631 exclusive,
632 property: None,
633 effect,
634 location,
635 }
636 }
637
638 fn property(
639 index: u32,
640 target: EditTarget,
641 slide_id: &str,
642 shape_id: Option<&str>,
643 property: Property,
644 effect: Option<Effect>,
645 ) -> Self {
646 Self {
647 index,
648 target,
649 slide_id: slide_id.to_owned(),
650 shape_id: shape_id.map(str::to_owned),
651 story_id: None,
652 claims: Vec::new(),
653 paragraphs: Vec::new(),
654 exclusive: false,
655 property: Some(property),
656 effect,
657 location: Location::Target,
658 }
659 }
660
661 fn owned_property(&self) -> Option<(&str, Property)> {
662 let property = self.property?;
663 let owner = match property {
664 Property::Notes | Property::Background => self.slide_id.as_str(),
665 _ => self.shape_id.as_deref()?,
666 };
667 Some((owner, property))
668 }
669
670 fn shift(&self) -> Option<(u32, i64)> {
671 self.effect.as_ref()?.shift()
672 }
673
674 fn conflicts(&self, other: &Self) -> bool {
675 if self.property.is_some() && self.owned_property() == other.owned_property() {
676 return true;
677 }
678 if self.story_id.is_none() || self.story_id != other.story_id {
679 return false;
680 }
681 let shared = |owner: &Self, other: &Self| {
682 owner.exclusive
683 && owner
684 .paragraphs
685 .iter()
686 .any(|id| other.paragraphs.contains(id))
687 };
688 shared(self, other)
689 || shared(other, self)
690 || self
691 .claims
692 .iter()
693 .any(|mine| other.claims.iter().any(|theirs| mine.conflicts(*theirs)))
694 }
695}
696
697struct Plan {
698 base_version: DocumentVersion,
699 nonce: u64,
700 epoch: u64,
701 id_counter: u64,
702 source: EditSource,
703 before: DeckSnapshot,
704 steps: Vec<Planned>,
705}
706
707impl Plan {
708 fn would_apply(&self) -> bool {
709 self.steps.iter().any(|planned| planned.effect.is_some())
710 }
711
712 fn application(self, applied: bool, version: DocumentVersion) -> EditApplication {
713 let changed = |id: fn(&Planned) -> Option<&String>| -> Vec<String> {
714 self.steps
715 .iter()
716 .filter(|planned| applied && planned.effect.is_some())
717 .filter_map(id)
718 .cloned()
719 .collect::<BTreeSet<_>>()
720 .into_iter()
721 .collect()
722 };
723 EditApplication {
724 changed_slides: changed(|planned| Some(&planned.slide_id)),
725 changed_stories: changed(|planned| planned.story_id.as_ref()),
726 receipts: receipts(&self.steps, applied),
727 base_version: self.base_version,
728 version,
729 applied,
730 source: self.source,
731 }
732 }
733}
734
735struct Staged {
737 stage: DeckSession,
738 update: Update,
739}
740
741fn unsupported(message: impl Into<String>, target: &EditTarget) -> EditFailure {
742 failure(EditFailureCode::Unsupported, message, Some(target))
743}
744
745fn invalid(error: impl fmt::Display, target: &EditTarget) -> EditFailure {
746 failure(
747 EditFailureCode::InvalidStep,
748 error.to_string(),
749 Some(target),
750 )
751}
752
753fn mismatch(message: impl Into<String>, target: &EditTarget) -> EditFailure {
754 failure(EditFailureCode::ContentMismatch, message, Some(target))
755}
756
757fn pending_failure() -> EditFailure {
758 failure(
759 EditFailureCode::Unsupported,
760 "the deck holds updates that are not integrated yet",
761 None,
762 )
763}
764
765fn check_budget(request: &EditRequest) -> Result<(), EditFailure> {
766 let limit = |message: String| failure(EditFailureCode::LimitExceeded, message, None);
767 if request.steps.len() > MAX_STEPS {
768 return Err(limit(format!("a batch holds at most {MAX_STEPS} steps")));
769 }
770 if !ByteBudget::new(MAX_REQUEST_BYTES).charge(request) {
771 return Err(request_limit());
772 }
773 let inserted: usize = request.steps.iter().map(EditStep::inserted_units).sum();
774 if inserted > MAX_INSERTED_UNITS {
775 return Err(limit(format!(
776 "a batch inserts at most {MAX_INSERTED_UNITS} UTF-16 units"
777 )));
778 }
779 Ok(())
780}
781
782fn check_text(
783 expect: Option<&TextGuard>,
784 actual: &str,
785 target: &EditTarget,
786) -> Result<(), EditFailure> {
787 match expect {
788 Some(expected) if expected.text != actual => Err(mismatch(
789 format!(
790 "the target reads {}, not the expected {}",
791 quoted(actual),
792 quoted(&expected.text)
793 ),
794 target,
795 )),
796 _ => Ok(()),
797 }
798}
799
800fn check_inserted(text: &str, target: &EditTarget) -> Result<(), EditFailure> {
801 if text.contains(['\n', '\r', '\u{b}', '\u{2028}', '\u{2029}']) {
802 return Err(invalid(
803 "inserted text may not contain paragraph or line breaks",
804 target,
805 ));
806 }
807 validate_xml_text(text).map_err(|error| invalid(error, target))
808}
809
810fn check_editable(paragraph: &ParagraphView, target: &EditTarget) -> Result<(), EditFailure> {
811 if paragraph.editable {
812 Ok(())
813 } else {
814 Err(unsupported(
815 format!(
816 "saving could not keep the fields of paragraph {} once it changes",
817 quoted(¶graph.id)
818 ),
819 target,
820 ))
821 }
822}
823
824fn check_point(paragraph: &ParagraphView, at: u32, target: &EditTarget) -> Result<(), EditFailure> {
825 if paragraph
826 .fields
827 .iter()
828 .any(|field| field.start < at && at < field.end)
829 {
830 return Err(unsupported(
831 "text cannot be inserted inside a field",
832 target,
833 ));
834 }
835 Ok(())
836}
837
838fn check_span(
839 paragraph: &ParagraphView,
840 start: u32,
841 end: u32,
842 target: &EditTarget,
843) -> Result<(), EditFailure> {
844 if paragraph
845 .line_breaks
846 .iter()
847 .any(|at| start <= *at && *at < end)
848 {
849 return Err(unsupported(
850 "replacing or deleting line breaks is not supported",
851 target,
852 ));
853 }
854 if paragraph
855 .fields
856 .iter()
857 .any(|field| start < field.end && field.start < end)
858 {
859 return Err(unsupported(
860 "text steps cannot change a field's text",
861 target,
862 ));
863 }
864 Ok(())
865}
866
867fn writable_style(
869 story: &StorySnapshot,
870 at: u32,
871 target: &EditTarget,
872) -> Result<TextStyle, EditFailure> {
873 let style = inherited_style(story, at);
874 validate_style_values(
875 style.font_family.as_deref(),
876 style.underline.as_deref(),
877 style.color.as_deref(),
878 style.font_size_pt,
879 style.spacing_pt,
880 style.baseline_pct,
881 style.kern_pt,
882 )
883 .map_err(|error| {
884 unsupported(
885 format!("the surrounding formatting cannot be written: {error}"),
886 target,
887 )
888 })?;
889 Ok(style)
890}
891
892fn patch_changes(story: &StorySnapshot, start: u32, end: u32, patch: &TextStylePatch) -> bool {
894 fn differs<T: PartialEq>(current: &Option<T>, patched: &Option<T>) -> bool {
895 patched
896 .as_ref()
897 .is_some_and(|value| current.as_ref() != Some(value))
898 }
899 let mut offset = 0;
900 for paragraph in &story.paragraphs {
901 for run in ¶graph.runs {
902 let (from, to) = (offset, offset + units(&run.text) as u32);
903 offset = to;
904 let style = &run.style;
905 if from < end
906 && start < to
907 && (differs(&style.bold, &patch.bold)
908 || differs(&style.italic, &patch.italic)
909 || differs(&style.font_size_pt, &patch.font_size_pt)
910 || differs(&style.color, &patch.color)
911 || differs(&style.font_family, &patch.font_family)
912 || differs(&style.underline, &patch.underline)
913 || differs(&style.spacing_pt, &patch.spacing_pt)
914 || differs(&style.baseline_pct, &patch.baseline_pct))
915 {
916 return true;
917 }
918 }
919 offset += 1;
920 }
921 false
922}
923
924enum TextWrite<'s> {
925 Insert(TargetEdge, &'s str),
926 Replace(&'s str),
927 Delete,
928}
929
930fn plan_text_write(
932 deck: &Deck<'_>,
933 index: u32,
934 target: &TextTarget,
935 expect: Option<&TextGuard>,
936 write: TextWrite<'_>,
937) -> Result<Planned, EditFailure> {
938 let requested = EditTarget::from(target.clone());
939 let selection = deck.text(target)?;
940 let selected = selection.text();
941 check_text(expect, &selected, &requested)?;
942 let Some(paragraph) = selection.paragraph() else {
943 return Err(unsupported(
944 "a text range must stay within one paragraph",
945 &requested,
946 ));
947 };
948 check_editable(paragraph, &requested)?;
949 let story = selection.view.story;
950 let (start, end) = (selection.start, selection.end);
951 let (claim, effect, location) = match write {
952 TextWrite::Insert(edge, text) => {
953 check_inserted(text, &requested)?;
954 let at = if edge == TargetEdge::Start {
955 start
956 } else {
957 end
958 };
959 check_point(paragraph, at, &requested)?;
960 let effect = if text.is_empty() {
961 None
962 } else {
963 Some(Effect::Insert {
964 at,
965 text: text.to_owned(),
966 style: writable_style(story, at, &requested)?,
967 })
968 };
969 let len = if effect.is_some() { units(text) } else { 0 };
970 (
971 Claim::Point(at),
972 effect,
973 Location::Grown {
974 start: at,
975 len: len as u32,
976 },
977 )
978 }
979 TextWrite::Replace(text) => {
980 check_inserted(text, &requested)?;
981 let claim = if start == end {
982 check_point(paragraph, start, &requested)?;
983 Claim::Point(start)
984 } else {
985 check_span(paragraph, start, end, &requested)?;
986 Claim::Span(start, end)
987 };
988 let effect = if selected == text {
989 None
990 } else {
991 Some(Effect::Replace {
992 start,
993 end,
994 text: text.to_owned(),
995 style: writable_style(story, start, &requested)?,
996 })
997 };
998 let len = if effect.is_some() {
999 units(text) as u32
1000 } else {
1001 end - start
1002 };
1003 (claim, effect, Location::Grown { start, len })
1004 }
1005 TextWrite::Delete if start == end => {
1006 (Claim::Point(start), None, Location::Grown { start, len: 0 })
1007 }
1008 TextWrite::Delete => {
1009 check_span(paragraph, start, end, &requested)?;
1010 (
1011 Claim::Span(start, end),
1012 Some(Effect::Delete { start, end }),
1013 Location::Grown { start, len: 0 },
1014 )
1015 }
1016 };
1017 let paragraphs = vec![paragraph.id.clone()];
1018 Ok(Planned::text(
1019 index,
1020 &selection,
1021 vec![claim],
1022 paragraphs,
1023 false,
1024 effect,
1025 location,
1026 ))
1027}
1028
1029fn plan_format(
1030 deck: &Deck<'_>,
1031 index: u32,
1032 target: &TextTarget,
1033 patch: &TextStylePatch,
1034 expect: Option<&TextGuard>,
1035) -> Result<Planned, EditFailure> {
1036 let requested = EditTarget::from(target.clone());
1037 let selection = deck.text(target)?;
1038 check_text(expect, &selection.text(), &requested)?;
1039 validate_style_values(
1040 patch.font_family.as_deref(),
1041 patch.underline.as_deref(),
1042 patch.color.as_deref(),
1043 patch.font_size_pt,
1044 patch.spacing_pt,
1045 patch.baseline_pct,
1046 None,
1047 )
1048 .map_err(|error| invalid(error, &requested))?;
1049 let (start, end) = (selection.start, selection.end);
1050 let selected = selection.selected();
1051 for paragraph in &selected {
1052 check_editable(paragraph, &requested)?;
1053 if paragraph.fields.iter().any(|field| {
1054 (field.start < start && start < field.end) || (field.start < end && end < field.end)
1055 }) {
1056 return Err(unsupported(
1057 "formatting part of a field would unbind it",
1058 &requested,
1059 ));
1060 }
1061 }
1062 let effect =
1063 (start < end && patch_changes(selection.view.story, start, end, patch)).then(|| {
1064 Effect::Format {
1065 start,
1066 end,
1067 patch: patch.clone(),
1068 }
1069 });
1070 let claim = if start == end {
1071 Claim::Point(start)
1072 } else {
1073 Claim::Span(start, end)
1074 };
1075 let paragraphs = selected
1076 .iter()
1077 .map(|paragraph| paragraph.id.clone())
1078 .collect();
1079 Ok(Planned::text(
1080 index,
1081 &selection,
1082 vec![claim],
1083 paragraphs,
1084 false,
1085 effect,
1086 Location::Kept,
1087 ))
1088}
1089
1090fn plan_alignment(
1091 deck: &Deck<'_>,
1092 index: u32,
1093 target: &TextTarget,
1094 alignment: &Option<String>,
1095 expect: Option<&TextGuard>,
1096) -> Result<Planned, EditFailure> {
1097 let requested = EditTarget::from(target.clone());
1098 let selection = deck.text(target)?;
1099 check_text(expect, &selection.text(), &requested)?;
1100 validate_alignment(alignment.as_deref()).map_err(|error| invalid(error, &requested))?;
1101 let selected = selection.selected();
1102 let effect = selected
1103 .iter()
1104 .any(|paragraph| paragraph.alignment != *alignment)
1105 .then(|| Effect::Align {
1106 start: selection.start,
1107 end: selection.end,
1108 alignment: alignment.clone(),
1109 });
1110 let paragraphs = selected
1111 .iter()
1112 .map(|paragraph| paragraph.id.clone())
1113 .collect();
1114 Ok(Planned::text(
1115 index,
1116 &selection,
1117 Vec::new(),
1118 paragraphs,
1119 true,
1120 effect,
1121 Location::Kept,
1122 ))
1123}
1124
1125fn plan_notes(
1126 deck: &Deck<'_>,
1127 index: u32,
1128 target: &SlideTarget,
1129 text: &str,
1130 expect: Option<&TextGuard>,
1131) -> Result<Planned, EditFailure> {
1132 let requested = EditTarget::Slide(target.clone());
1133 let slide = deck.slide(&target.slide_id, &requested)?;
1134 check_text(expect, &slide.notes, &requested)?;
1135 validate_xml_text(text).map_err(|error| invalid(error, &requested))?;
1136 let effect = (slide.notes != text).then(|| Effect::Notes(text.to_owned()));
1137 Ok(Planned::property(
1138 index,
1139 requested,
1140 &slide.id,
1141 None,
1142 Property::Notes,
1143 effect,
1144 ))
1145}
1146
1147fn plan_background(
1148 deck: &Deck<'_>,
1149 index: u32,
1150 target: &SlideTarget,
1151 background: &Option<Box<ShapeFill>>,
1152 expect: Option<&BackgroundGuard>,
1153) -> Result<Planned, EditFailure> {
1154 let requested = EditTarget::Slide(target.clone());
1155 let slide = deck.slide(&target.slide_id, &requested)?;
1156 if let Some(guard) = expect
1157 && guard.background.as_deref() != slide.background.as_ref()
1158 {
1159 return Err(mismatch(
1160 "the slide's background differs from the expected background",
1161 &requested,
1162 ));
1163 }
1164 if let Some(fill) = background {
1165 validate_background(fill).map_err(|error| invalid(error, &requested))?;
1166 }
1167 let fill = background.as_deref().map(normalized_background);
1168 let changes = match &fill {
1169 Some(_) => slide.background.as_ref().map(normalized_background) != fill,
1170 None => {
1171 slide.background.is_some()
1172 || (!slide.background_edited && deck.declares_source_background(slide))
1173 }
1174 };
1175 let effect = changes.then_some(Effect::Background(fill));
1176 Ok(Planned::property(
1177 index,
1178 requested,
1179 &slide.id,
1180 None,
1181 Property::Background,
1182 effect,
1183 ))
1184}
1185
1186fn top_level_shape<'a>(
1188 deck: &Deck<'a>,
1189 target: &ShapeTarget,
1190 requested: &EditTarget,
1191) -> Result<&'a ShapeSnapshot, EditFailure> {
1192 let (_, shape, top_level) = deck.shape(target, requested)?;
1193 if !top_level {
1194 return Err(unsupported(
1195 "geometry, fill and outline steps address shapes at the top of a slide",
1196 requested,
1197 ));
1198 }
1199 Ok(shape)
1200}
1201
1202fn require_preset(shape: &ShapeSnapshot, target: &EditTarget) -> Result<(), EditFailure> {
1203 if shape.kind == ShapeKind::Shape {
1204 Ok(())
1205 } else {
1206 Err(unsupported(
1207 "only preset shapes support shape styling",
1208 target,
1209 ))
1210 }
1211}
1212
1213fn plan_rect(
1214 deck: &Deck<'_>,
1215 index: u32,
1216 target: &ShapeTarget,
1217 rect: ShapeRect,
1218 expect: Option<&RectGuard>,
1219) -> Result<Planned, EditFailure> {
1220 let requested = EditTarget::Shape(target.clone());
1221 let shape = top_level_shape(deck, target, &requested)?;
1222 let current = ShapeRect {
1223 x: shape.x,
1224 y: shape.y,
1225 width: shape.width,
1226 height: shape.height,
1227 };
1228 if let Some(guard) = expect
1229 && guard.rect != current
1230 {
1231 return Err(mismatch(
1232 format!(
1233 "the shape's rectangle is {current:?}, not the expected {:?}",
1234 guard.rect
1235 ),
1236 &requested,
1237 ));
1238 }
1239 validate_rect(rect).map_err(|error| invalid(error, &requested))?;
1240 let effect = (current != rect).then_some(Effect::Rect(rect));
1241 Ok(Planned::property(
1242 index,
1243 requested,
1244 &target.slide_id,
1245 Some(&shape.id),
1246 Property::Rect,
1247 effect,
1248 ))
1249}
1250
1251fn plan_fill(
1252 deck: &Deck<'_>,
1253 index: u32,
1254 target: &ShapeTarget,
1255 color: &Option<String>,
1256 expect: Option<&FillGuard>,
1257) -> Result<Planned, EditFailure> {
1258 let requested = EditTarget::Shape(target.clone());
1259 let shape = top_level_shape(deck, target, &requested)?;
1260 if let Some(guard) = expect
1261 && guard.fill.as_deref() != shape.fill.as_ref()
1262 {
1263 return Err(mismatch(
1264 "the shape's fill differs from the expected fill",
1265 &requested,
1266 ));
1267 }
1268 require_preset(shape, &requested)?;
1269 let fill = shape_fill(color.as_deref()).map_err(|error| invalid(error, &requested))?;
1270 let effect = (shape.fill.as_ref() != Some(&fill)).then(|| Effect::Fill(color.clone()));
1271 Ok(Planned::property(
1272 index,
1273 requested,
1274 &target.slide_id,
1275 Some(&shape.id),
1276 Property::Fill,
1277 effect,
1278 ))
1279}
1280
1281fn plan_stroke(
1282 deck: &Deck<'_>,
1283 index: u32,
1284 target: &ShapeTarget,
1285 stroke: &ShapeStroke,
1286 expect: Option<&OutlineGuard>,
1287) -> Result<Planned, EditFailure> {
1288 let requested = EditTarget::Shape(target.clone());
1289 let shape = top_level_shape(deck, target, &requested)?;
1290 if let Some(guard) = expect
1291 && guard.outline.as_deref() != shape.outline.as_ref()
1292 {
1293 return Err(mismatch(
1294 "the shape's outline differs from the expected outline",
1295 &requested,
1296 ));
1297 }
1298 require_preset(shape, &requested)?;
1299 let outline = stroked_outline(shape.outline.clone(), stroke)
1300 .map_err(|error| invalid(error, &requested))?;
1301 let effect = (shape.outline.as_ref() != Some(&outline)).then(|| Effect::Stroke(stroke.clone()));
1302 Ok(Planned::property(
1303 index,
1304 requested,
1305 &target.slide_id,
1306 Some(&shape.id),
1307 Property::Stroke,
1308 effect,
1309 ))
1310}
1311
1312fn plan_step(deck: &Deck<'_>, index: u32, step: &EditStep) -> Result<Planned, EditFailure> {
1313 match step {
1314 EditStep::InsertText {
1315 target,
1316 at,
1317 text,
1318 expect,
1319 } => plan_text_write(
1320 deck,
1321 index,
1322 target,
1323 expect.as_ref(),
1324 TextWrite::Insert(*at, text),
1325 ),
1326 EditStep::ReplaceText {
1327 target,
1328 text,
1329 expect,
1330 } => plan_text_write(
1331 deck,
1332 index,
1333 target,
1334 expect.as_ref(),
1335 TextWrite::Replace(text),
1336 ),
1337 EditStep::DeleteText { target, expect } => {
1338 plan_text_write(deck, index, target, expect.as_ref(), TextWrite::Delete)
1339 }
1340 EditStep::FormatText {
1341 target,
1342 patch,
1343 expect,
1344 } => plan_format(deck, index, target, patch, expect.as_ref()),
1345 EditStep::SetParagraphAlignment {
1346 target,
1347 alignment,
1348 expect,
1349 } => plan_alignment(deck, index, target, alignment, expect.as_ref()),
1350 EditStep::SetSlideNotes {
1351 target,
1352 text,
1353 expect,
1354 } => plan_notes(deck, index, target, text, expect.as_ref()),
1355 EditStep::SetSlideBackground {
1356 target,
1357 background,
1358 expect,
1359 } => plan_background(deck, index, target, background, expect.as_ref()),
1360 EditStep::SetShapeRect {
1361 target,
1362 rect,
1363 expect,
1364 } => plan_rect(deck, index, target, *rect, expect.as_ref()),
1365 EditStep::SetShapeFill {
1366 target,
1367 color,
1368 expect,
1369 } => plan_fill(deck, index, target, color, expect.as_ref()),
1370 EditStep::SetShapeStroke {
1371 target,
1372 stroke,
1373 expect,
1374 } => plan_stroke(deck, index, target, stroke, expect.as_ref()),
1375 EditStep::SetShapeAdjust {
1376 target,
1377 adjustments,
1378 } => {
1379 let requested = EditTarget::Shape(target.clone());
1380 let shape = top_level_shape(deck, target, &requested)?;
1381 require_preset(shape, &requested)?;
1382 if shape.geometry == "custom" {
1383 return Err(unsupported(
1384 "custom geometry does not support adjustments",
1385 &requested,
1386 ));
1387 }
1388 validate_adjustments(adjustments).map_err(|error| invalid(error, &requested))?;
1389 let adjusted: BTreeMap<_, _> = adjustments
1390 .iter()
1391 .map(|(name, value)| {
1392 let maximum = if shape.geometry == "roundRect" && name == "adj" {
1393 0.5
1394 } else {
1395 1.0
1396 };
1397 (name.clone(), value.clamp(0.0, maximum))
1398 })
1399 .collect();
1400 let effect = (shape.adjust_values != adjusted).then_some(Effect::Adjust(adjusted));
1401 Ok(Planned::property(
1402 index,
1403 requested,
1404 &target.slide_id,
1405 Some(&shape.id),
1406 Property::Adjust,
1407 effect,
1408 ))
1409 }
1410 }
1411}
1412
1413fn execute(stage: &DeckSession, steps: &[Planned]) -> EditResult<()> {
1416 let context = EditCtx::local("batch");
1417 let run = |planned: &Planned| -> EditResult<()> {
1418 let slide = planned.slide_id.as_str();
1419 let shape = planned.shape_id.as_deref().unwrap_or_default();
1420 let story = planned.story_id.as_deref().unwrap_or_default();
1421 match &planned.effect {
1422 None => {}
1423 Some(Effect::Insert { at, text, style }) => {
1424 stage.insert_text(&context, story, *at, text, style)?;
1425 }
1426 Some(Effect::Replace {
1427 start,
1428 end,
1429 text,
1430 style,
1431 }) => {
1432 stage.delete_text(&context, story, *start, *end)?;
1433 if !text.is_empty() {
1434 stage.insert_text(&context, story, *start, text, style)?;
1435 }
1436 }
1437 Some(Effect::Delete { start, end }) => {
1438 stage.delete_text(&context, story, *start, *end)?;
1439 }
1440 Some(Effect::Format { start, end, patch }) => {
1441 stage.format_text(&context, story, *start, *end, patch)?;
1442 }
1443 Some(Effect::Align {
1444 start,
1445 end,
1446 alignment,
1447 }) => {
1448 stage.set_paragraph_alignment(
1449 &context,
1450 story,
1451 *start,
1452 *end,
1453 alignment.as_deref(),
1454 )?;
1455 }
1456 Some(Effect::Notes(text)) => stage.set_slide_notes(&context, slide, text)?,
1457 Some(Effect::Background(background)) => {
1458 stage.set_slide_background(&context, slide, background.as_ref())?;
1459 }
1460 Some(Effect::Rect(rect)) => {
1461 stage.set_shape_rect(&context, slide, shape, *rect)?;
1462 }
1463 Some(Effect::Fill(color)) => {
1464 stage.set_shape_fill(&context, slide, shape, color.as_deref())?;
1465 }
1466 Some(Effect::Stroke(stroke)) => {
1467 stage.set_shape_stroke(&context, slide, shape, stroke)?;
1468 }
1469 Some(Effect::Adjust(adjustments)) => {
1470 stage.set_shape_adjust(&context, slide, shape, adjustments)?;
1471 }
1472 }
1473 Ok(())
1474 };
1475 let (mut moving, fixed): (Vec<&Planned>, Vec<&Planned>) = steps
1476 .iter()
1477 .filter(|planned| planned.effect.is_some())
1478 .partition(|planned| planned.shift().is_some());
1479 moving.sort_by_key(|planned| Reverse(planned.shift().map(|(start, _)| start)));
1480 for planned in fixed.into_iter().chain(moving) {
1481 run(planned).map_err(|error| {
1482 EditError::InvalidState(format!("staged step {} failed: {error}", planned.index))
1483 })?;
1484 }
1485 Ok(())
1486}
1487
1488fn receipts(steps: &[Planned], applied: bool) -> Vec<EditReceipt> {
1490 let shifted = |story: Option<&str>, raw: u32| -> u32 {
1491 if !applied {
1492 return raw;
1493 }
1494 let delta: i64 = steps
1495 .iter()
1496 .filter(|planned| planned.story_id.as_deref() == story)
1497 .filter_map(Planned::shift)
1498 .filter(|(start, _)| *start < raw)
1499 .map(|(_, delta)| delta)
1500 .sum();
1501 (i64::from(raw) + delta).max(0) as u32
1502 };
1503 steps
1504 .iter()
1505 .map(|planned| {
1506 let story = planned.story_id.as_deref();
1507 let target = match (&planned.location, &planned.target) {
1508 (Location::Grown { start, len }, EditTarget::Range(range)) => {
1509 let start = shifted(story, *start);
1510 EditTarget::Range(TextRange {
1511 start,
1512 end: start + len,
1513 ..range.clone()
1514 })
1515 }
1516 (Location::Kept, EditTarget::Range(range)) => EditTarget::Range(TextRange {
1517 start: shifted(story, range.start),
1518 end: shifted(story, range.end),
1519 ..range.clone()
1520 }),
1521 _ => planned.target.clone(),
1522 };
1523 EditReceipt {
1524 step_index: planned.index,
1525 changed: applied && planned.effect.is_some(),
1526 target,
1527 }
1528 })
1529 .collect()
1530}
1531
1532impl DeckSession {
1533 fn plan(&self, request: &EditRequest) -> EditResult<Result<Plan, EditRefusal>> {
1534 let nonce = self.version_nonce.load(Ordering::Relaxed);
1535 let epoch = self.epoch();
1536 let version = version_token(nonce, epoch);
1537 let refuse = |failure: EditFailure| -> EditResult<Result<Plan, EditRefusal>> {
1538 Ok(Err(refusal(version.clone(), failure)))
1539 };
1540 if let Err(failure) = check_budget(request) {
1541 return refuse(failure);
1542 }
1543 if request.expect_version != version {
1544 return refuse(failure(
1545 EditFailureCode::StaleVersion,
1546 "the deck changed since the expected version was read",
1547 None,
1548 ));
1549 }
1550 if self.has_pending_updates() {
1551 return refuse(pending_failure());
1552 }
1553 let before = self.snapshot()?;
1554 let mut steps: Vec<Planned> = Vec::with_capacity(request.steps.len());
1555 {
1556 let deck = Deck::new(&before, &self.package);
1557 for (index, step) in request.steps.iter().enumerate() {
1558 let index = index as u32;
1559 let planned = match plan_step(&deck, index, step) {
1560 Ok(planned) => planned,
1561 Err(failure) => return refuse(failure.at(index)),
1562 };
1563 if let Some(earlier) = steps.iter().find(|earlier| earlier.conflicts(&planned)) {
1564 let mut conflict = failure(
1565 EditFailureCode::OverlappingSteps,
1566 format!("step {index} conflicts with step {}", earlier.index),
1567 Some(&planned.target),
1568 )
1569 .at(index);
1570 conflict.conflicting_step_index = Some(earlier.index);
1571 return refuse(conflict);
1572 }
1573 steps.push(planned);
1574 }
1575 }
1576 Ok(Ok(Plan {
1577 base_version: version,
1578 nonce,
1579 epoch,
1580 id_counter: self.id_counter.load(Ordering::Relaxed),
1581 source: request.source,
1582 before,
1583 steps,
1584 }))
1585 }
1586
1587 fn stage_plan(&self, plan: &Plan) -> EditResult<Result<Option<Staged>, EditRefusal>> {
1590 let refuse = |code: EditFailureCode, message: String| {
1591 Ok(Err(refusal(
1592 plan.base_version.clone(),
1593 failure(code, message, None),
1594 )))
1595 };
1596 let base = self.state_update_v1();
1597 if base.len() > MAX_STAGING_BYTES {
1598 return refuse(
1599 EditFailureCode::LimitExceeded,
1600 format!("batches stage decks of at most {MAX_STAGING_BYTES} bytes"),
1601 );
1602 }
1603 let base_state = self.doc.transact().state_vector();
1604 let stage = self.stage()?;
1605 execute(&stage, &plan.steps)?;
1606 let staged = match stage.validated_snapshot() {
1607 Ok(staged) => staged,
1608 Err(error) => {
1609 return refuse(
1610 EditFailureCode::Unsupported,
1611 format!("the batch would leave the deck invalid: {error}"),
1612 );
1613 }
1614 };
1615 if staged == plan.before {
1616 return Ok(Ok(None));
1617 }
1618 for planned in plan.steps.iter().filter(|planned| planned.effect.is_some()) {
1619 let Some(story) = &planned.story_id else {
1620 continue;
1621 };
1622 if let Some(paragraph) = planned
1623 .paragraphs
1624 .iter()
1625 .find(|paragraph| !keeps_fields(&self.package, &staged, story, paragraph))
1626 {
1627 let failure = unsupported(
1628 format!(
1629 "saving would turn a field of paragraph {} into plain text",
1630 quoted(paragraph)
1631 ),
1632 &planned.target,
1633 )
1634 .at(planned.index);
1635 return Ok(Err(refusal(plan.base_version.clone(), failure)));
1636 }
1637 }
1638 let (bytes, update) = stage.staged_update(&base_state)?;
1639 if bytes.len() > MAX_UPDATE_BYTES {
1640 return refuse(
1641 EditFailureCode::LimitExceeded,
1642 format!("a batch's update may not exceed {MAX_UPDATE_BYTES} bytes"),
1643 );
1644 }
1645 self.rehearse(&base, &bytes, &stage, &staged)?;
1646 Ok(Ok(Some(Staged { stage, update })))
1647 }
1648
1649 fn check_commit(&self, plan: &Plan) -> Result<(), EditRefusal> {
1650 if self.version_nonce.load(Ordering::Relaxed) != plan.nonce
1651 || self.epoch() != plan.epoch
1652 || self.id_counter.load(Ordering::Relaxed) != plan.id_counter
1653 {
1654 return Err(refusal(
1655 self.version(),
1656 failure(
1657 EditFailureCode::StaleVersion,
1658 "the deck changed while the batch was staged",
1659 None,
1660 ),
1661 ));
1662 }
1663 if self.has_pending_updates() {
1664 return Err(refusal(self.version(), pending_failure()));
1665 }
1666 Ok(())
1667 }
1668
1669 pub fn validate_edits(&self, request: &EditRequest) -> EditResult<ValidationOutcome> {
1672 let plan = match self.plan(request)? {
1673 Ok(plan) => plan,
1674 Err(refusal) => return Ok(Err(refusal)),
1675 };
1676 let would_apply = plan.would_apply()
1677 && match self.stage_plan(&plan)? {
1678 Ok(staged) => staged.is_some(),
1679 Err(refusal) => return Ok(Err(refusal)),
1680 };
1681 Ok(Ok(EditValidation {
1682 previews: plan
1683 .steps
1684 .iter()
1685 .map(|planned| EditPreview {
1686 step_index: planned.index,
1687 target: planned.target.clone(),
1688 would_change: would_apply && planned.effect.is_some(),
1689 })
1690 .collect(),
1691 base_version: plan.base_version,
1692 would_apply,
1693 }))
1694 }
1695
1696 pub fn apply_edits(&self, request: &EditRequest) -> EditResult<EditOutcome> {
1700 let plan = match self.plan(request)? {
1701 Ok(plan) => plan,
1702 Err(refusal) => return Ok(Err(refusal)),
1703 };
1704 let staged = if plan.would_apply() {
1705 match self.stage_plan(&plan)? {
1706 Ok(staged) => staged,
1707 Err(refusal) => return Ok(Err(refusal)),
1708 }
1709 } else {
1710 None
1711 };
1712 let Some(staged) = staged else {
1713 let version = plan.base_version.clone();
1714 return Ok(Ok(plan.application(false, version)));
1715 };
1716 if let Err(refusal) = self.check_commit(&plan) {
1717 return Ok(Err(refusal));
1718 }
1719 let adoption = match request.history {
1720 EditHistory::Separate => Adoption::Tracked,
1721 EditHistory::None => Adoption::Untracked,
1722 };
1723 self.adopt(&staged.stage, staged.update, adoption)?;
1724 Ok(Ok(plan.application(true, self.version())))
1725 }
1726}