Skip to main content

pptx_edit/
batch.rs

1//! Version-checked, all-or-nothing host edit batches.
2//!
3//! A batch resolves every step against one captured state and returns policy failures as data.
4//! The plan then runs on a private stage that shares this session's client id; its update is
5//! rehearsed against an untouched copy of the base and adopted as one transaction.
6
7use 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;
36/// Largest request a read, search or batch accepts, measured as JSON.
37pub const MAX_REQUEST_BYTES: usize = 16 * 1024 * 1024;
38
39/// Charges serialized JSON sizes against a byte allowance.
40pub(crate) struct ByteBudget {
41    remaining: usize,
42}
43
44impl ByteBudget {
45    pub fn new(limit: usize) -> Self {
46        Self { remaining: limit }
47    }
48
49    /// Takes `value`'s JSON size from the allowance; false once it would run out.
50    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/// An opaque, session-scoped optimistic-concurrency token. Compare for equality only.
74#[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/// Who asked for a batch. Provenance only: it selects neither permissions nor history.
139#[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/// How an applied batch enters local undo history.
148#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
149#[serde(rename_all = "camelCase")]
150pub enum EditHistory {
151    /// Exactly one undo step, whatever the capture mode.
152    #[default]
153    Separate,
154    /// Outside undo history; existing undo and redo entries stay.
155    None,
156}
157
158/// Which end of a target an insertion goes to.
159#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
160#[serde(rename_all = "camelCase")]
161pub enum TargetEdge {
162    Start,
163    End,
164}
165
166/// A required field that may be `null`.
167fn nullable<'de, D: Deserializer<'de>, T: Deserialize<'de>>(
168    deserializer: D,
169) -> Result<Option<T>, D::Error> {
170    Option::deserialize(deserializer)
171}
172
173/// Refuses a step unless its target currently reads exactly `text`.
174#[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/// Refuses a step unless the shape's rectangle is exactly `rect`.
181#[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/// Refuses a step unless the shape's authored fill is exactly `fill`; `None` is no fill of its
188/// own.
189#[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/// Refuses a step unless the shape's authored outline is exactly `outline`.
197#[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/// Refuses a step unless the slide's effective background is exactly
205/// `background`; `null` is no background of its own.
206#[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/// One batch step. Text steps stay inside one paragraph, except `formatText` and
220/// `setParagraphAlignment`, which may span paragraphs of one story.
221#[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    /// `None` restores the inherited alignment.
255    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    /// Replaces the slide's speaker notes; empty text clears them.
263    SetSlideNotes {
264        target: SlideTarget,
265        text: String,
266        #[serde(default, skip_serializing_if = "Option::is_none")]
267        expect: Option<TextGuard>,
268    },
269    /// Replaces the slide's background; `null` clears the declaration.
270    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    /// A solid `#RRGGBB` fill; `None` removes the fill.
284    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    /// UTF-16 units the step writes.
309    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    /// The version the targets were read at.
323    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/// What a failure, preview or receipt refers to.
346#[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/// A policy refusal; the deck is untouched at `version`.
390#[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
397/// Characters a failure message keeps.
398const MAX_MESSAGE_CHARS: usize = 512;
399/// JSON bytes of a target a failure still echoes.
400const MAX_ECHOED_TARGET_BYTES: usize = 64 * 1024;
401
402/// A failure small enough to return whatever text caused it: long messages are cut, and a
403/// target too large to echo is left out.
404pub(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
426/// `text` quoted for a diagnostic, cut to its first 64 characters so a refusal stays small.
427pub(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
438/// The refusal for a JSON request longer than [`MAX_REQUEST_BYTES`], before decoding it.
439pub 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/// What one step did. Text targets describe the final state; a deletion reports its collapsed
452/// boundary.
453#[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/// What one step would do at its resolved pre-batch target. It reserves nothing.
462#[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    /// False when every step was a no-op: no history, update or version change.
484    pub applied: bool,
485    pub source: EditSource,
486    pub changed_slides: Vec<String>,
487    pub changed_stories: Vec<String>,
488    /// One per request step, in request order.
489    pub receipts: Vec<EditReceipt>,
490}
491
492pub type ValidationOutcome = Result<EditValidation, EditRefusal>;
493pub type EditOutcome = Result<EditApplication, EditRefusal>;
494
495/// JSON of a policy outcome: the success body or the refusal, tagged with `ok`.
496pub 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/// A text range or boundary a step writes, in the captured state.
510#[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/// A slide or shape property a step replaces whole.
529#[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    /// Where the effect starts and how much it grows its story, for effects that move text.
575    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
587/// Where a receipt finds a step's result in the final state.
588enum Location {
589    /// `len` units from `start`, which moves with earlier edits.
590    Grown { start: u32, len: u32 },
591    /// The resolved range, both ends moving with earlier edits.
592    Kept,
593    /// The step's slide or shape.
594    Target,
595}
596
597struct Planned {
598    index: u32,
599    /// The resolved pre-batch target.
600    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    /// Conflicts with every other write to its paragraphs.
607    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
735/// A plan executed on a private stage, with the update that adopts it.
736struct 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(&paragraph.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
867/// The formatting new text at `at` takes, when the deck can store it.
868fn 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
892/// Whether applying `patch` changes any text in `start..end`.
893fn 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 &paragraph.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
930/// Insertion, replacement and deletion: plain text inside one paragraph.
931fn 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
1186/// A shape that geometry, fill and outline steps may address.
1187fn 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
1413/// Runs the plan on `stage`: effects that keep offsets first, then text changes from the end
1414/// of each story backwards, so every captured offset stays valid when its step runs.
1415fn 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
1488/// Receipts in request order; unapplied receipts keep their pre-batch targets.
1489fn 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    /// Executes a plan on a private stage and rehearses its adoption. `None` when the staged
1588    /// deck equals the captured one.
1589    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    /// Resolves, stages and rehearses a batch like [`DeckSession::apply_edits`], then discards
1670    /// it: nothing changes and no ids are reserved. `Err` means an internal failure.
1671    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    /// Applies every step or none, resolving all targets against `expect_version`. Policy
1697    /// failures come back as an [`EditRefusal`] with the deck, history and id allocation
1698    /// untouched; `Err` means an internal failure. An applied batch commits one transaction.
1699    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}