//! The operations the drive surface applies (spec §7.2), as one batch in //! one transaction. //! //! # Atomicity //! //! A yrs `TransactionMut` cannot be rolled back — dropping it commits //! whatever was written. So "a failure applies nothing" has to be true //! before the first write, and it is arranged in two passes: //! //! 1. Every op is parsed (shape, vocabularies, required fields) and then //! checked against a *model* of the deck — just the slide ids, their //! block ids and types, and the asset ids — that each op updates as //! it is checked, so a reference that only becomes wrong halfway //! through the batch (`remove_block` on a block an earlier op removed) //! is caught here too. //! 2. Only then does the transaction open and the ops apply. By the //! model's construction every lookup in this pass succeeds; the //! `Err`s it can still return exist so a bug here surfaces as a //! message rather than a trap, not because they are expected. //! //! # Ids //! //! The core never draws entropy (`lib.rs`). Every new slide or block id //! arrives in the op (`id`, `new_id`, `new_block_ids`) from the Go host, //! and an op that needs one and lacks it is a parse error. //! //! # The update //! //! The batch's update is the transaction's own `encode_update_v1()`, //! taken after an explicit `commit()` (which squashes the delete set and //! fires observers) and before the guard drops. yrs 0.23.5's //! `TransactionMut::encode_update` reads `before_state`, the store and //! the transaction's delete set, all of which are intact after commit — //! so no observer, no captured buffer, and nothing that could see a //! different transaction's changes. use crate::layout; use crate::markdown; use crate::schema::{self, Paragraph, Roots}; use crate::CoreError; use serde::{Deserialize, Deserializer}; use serde_json::{Map as JsonMap, Value}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use yrs::types::xml::XmlFragmentPrelim; use yrs::types::AsPrelim; use yrs::{Any, Array, ArrayRef, Doc, In, Map, MapRef, ReadTxn, Transact, TransactionMut}; type Props = JsonMap; /// `Option>` so an absent key (`None`) and an explicit `null` /// (`Some(None)`) can mean different things — `after` absent appends, /// `after: null` puts first; `accent: null` clears. fn nullable<'de, D, T>(d: D) -> Result>, D::Error> where D: Deserializer<'de>, T: Deserialize<'de>, { Option::::deserialize(d).map(Some) } #[derive(Deserialize)] struct Fonts { heading: String, body: String, } #[derive(Deserialize, Clone, Copy)] struct BoxSpec { x: f64, y: f64, w: f64, h: f64, } impl BoxSpec { fn json(&self) -> Value { schema::box_json(self.x, self.y, self.w, self.h) } } #[derive(Deserialize)] struct Background { kind: String, #[serde(default)] value: String, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct PhoneOverride { hidden: Option, order: Option, size: Option, #[serde(rename = "box")] bx: Option, } #[derive(Deserialize)] #[serde(tag = "op", rename_all = "snake_case")] enum Op { SetMeta { title: Option, theme: Option, #[serde(default, deserialize_with = "nullable")] accent: Option>, #[serde(default, deserialize_with = "nullable")] fonts: Option>, }, AddSlide { id: String, #[serde(default, deserialize_with = "nullable")] after: Option>, layout: String, template: Option, }, RemoveSlide { id: String, }, MoveSlide { id: String, after: Option, }, DuplicateSlide { id: String, new_id: String, new_block_ids: Vec, }, SetSlide { id: String, layout: Option, template: Option, background: Option, phone: Option, transition: Option, }, SetNotes { id: String, markdown: String, }, AddBlock { slide: String, id: String, #[serde(rename = "type")] kind: String, #[serde(default)] props: Props, region: Option, #[serde(rename = "box")] bx: Option, z: Option, #[serde(default, deserialize_with = "nullable")] after: Option>, markdown: Option, }, RemoveBlock { slide: String, id: String, }, MoveBlock { slide: String, id: String, after: Option, }, SetBlock { slide: String, id: String, props: Option, #[serde(default, deserialize_with = "nullable")] region: Option>, #[serde(default, deserialize_with = "nullable", rename = "box")] bx: Option>, z: Option, }, SetText { slide: String, id: String, markdown: String, }, SetPhone { slide: String, id: String, phone: PhoneOverride, }, RegisterAsset { id: String, #[serde(rename = "ref")] reference: String, #[serde(default)] name: String, #[serde(default)] width: i64, #[serde(default)] height: i64, #[serde(default)] alt: String, }, RemoveAsset { id: String, }, } fn fail(msg: impl Into) -> Result { Err(CoreError::Ops(msg.into())) } fn in_vocab(what: &str, value: &str, vocab: &[&str]) -> Result<(), CoreError> { if vocab.contains(&value) { Ok(()) } else { fail(format!( "{what} {value:?} is not one of {}", vocab.join(", ") )) } } fn non_empty(what: &str, value: &str) -> Result<(), CoreError> { if value.is_empty() { fail(format!("{what} is empty")) } else { Ok(()) } } /// `rows`/`cols` from props when present: whole numbers within /// `1..=MAX_TABLE_SIDE`. fn table_side(props: &Props, key: &str) -> Result, CoreError> { let Some(v) = props.get(key) else { return Ok(None); }; match v.as_i64() { Some(n) if (1..=schema::MAX_TABLE_SIDE).contains(&n) => Ok(Some(n)), _ => fail(format!( "table {key} must be a whole number from 1 to {}", schema::MAX_TABLE_SIDE )), } } // --- the model, and the checking pass --- struct BlockModel { id: String, kind: String, /// `Some(children)` for a `columns` block: the block ids its /// `props.children` currently names, kept in step through the batch /// so `remove_block` can drop a removed id from it and a later /// `set_block` on the same columns block sees the drop (spec §2.4). /// `None` for every other kind. children: Option>, } struct SlideModel { id: String, blocks: Vec, } struct Model { slides: Vec, assets: HashSet, } impl Model { fn read(txn: &T, roots: &Roots) -> Model { // Reading order at both levels (schema::ordered_maps): `after` // is positional, and the position that means anything is the // one a reader of the view sees. let slides = schema::ordered_maps(txn, &roots.slides) .into_iter() .map(|(_, m)| { let blocks = schema::get_array(txn, &m, "blocks") .map(|a| { schema::ordered_maps(txn, &a) .into_iter() .map(|(_, b)| { let kind = schema::get_string(txn, &b, "type").unwrap_or_default(); let children = (kind == "columns").then(|| { schema::get_object(txn, &b, "props") .and_then(|p| p.get("children").cloned()) .and_then(|v| v.as_array().cloned()) .map(|arr| { arr.iter() .filter_map(|c| c.as_str().map(str::to_string)) .collect() }) .unwrap_or_default() }); BlockModel { id: schema::get_string(txn, &b, "id").unwrap_or_default(), kind, children, } }) .collect() }) .unwrap_or_default(); SlideModel { id: schema::get_string(txn, &m, "id").unwrap_or_default(), blocks, } }) .collect(); let assets = roots.assets.keys(txn).map(|k| k.to_string()).collect(); Model { slides, assets } } fn slide_index(&self, id: &str) -> Result { self.slides .iter() .position(|s| s.id == id) .ok_or_else(|| CoreError::Ops(format!("no slide {id:?}"))) } fn slide_mut(&mut self, id: &str) -> Result<&mut SlideModel, CoreError> { let i = self.slide_index(id)?; Ok(&mut self.slides[i]) } fn check(&mut self, op: &Op) -> Result<(), CoreError> { match op { Op::SetMeta { .. } => Ok(()), Op::AddSlide { id, after, layout, template, } => { non_empty("id", id)?; if self.slide_index(id).is_ok() { return fail(format!("slide {id:?} already exists")); } in_vocab("layout", layout, schema::LAYOUTS)?; if let Some(t) = template { in_vocab("template", t, schema::TEMPLATES)?; } let at = match after { None => self.slides.len(), Some(None) => 0, Some(Some(a)) => self.slide_index(a)? + 1, }; self.slides.insert( at, SlideModel { id: id.clone(), blocks: Vec::new(), }, ); Ok(()) } Op::RemoveSlide { id } => { let i = self.slide_index(id)?; self.slides.remove(i); Ok(()) } Op::MoveSlide { id, after } => { self.slide_index(id)?; if let Some(a) = after { if a == id { return fail("move_slide: after is the slide itself"); } self.slide_index(a)?; } let i = self.slide_index(id)?; let s = self.slides.remove(i); let at = match after { None => 0, Some(a) => self.slide_index(a)? + 1, }; self.slides.insert(at, s); Ok(()) } Op::DuplicateSlide { id, new_id, new_block_ids, } => { non_empty("new_id", new_id)?; let i = self.slide_index(id)?; if self.slide_index(new_id).is_ok() { return fail(format!("slide {new_id:?} already exists")); } let n = self.slides[i].blocks.len(); if new_block_ids.len() != n { return fail(format!( "duplicate_slide: {n} blocks but {} new_block_ids", new_block_ids.len() )); } let distinct: HashSet<&String> = new_block_ids.iter().collect(); if distinct.len() != n || new_block_ids.iter().any(String::is_empty) { return fail("duplicate_slide: new_block_ids must be distinct and non-empty"); } // Old block id -> new, so a duplicated `columns` block's // children (already validated to name blocks on this // slide) follow the copy exactly like `rewrite_ids` // remaps them at apply time. let id_map: HashMap<&str, &str> = self.slides[i] .blocks .iter() .map(|b| b.id.as_str()) .zip(new_block_ids.iter().map(String::as_str)) .collect(); let blocks = self.slides[i] .blocks .iter() .zip(new_block_ids) .map(|(b, nid)| BlockModel { id: nid.clone(), kind: b.kind.clone(), children: b.children.as_ref().map(|cs| { cs.iter() .map(|c| { id_map .get(c.as_str()) .map(|n| n.to_string()) .unwrap_or_else(|| c.clone()) }) .collect() }), }) .collect(); self.slides.insert( i + 1, SlideModel { id: new_id.clone(), blocks, }, ); Ok(()) } Op::SetSlide { id, layout, template, background, phone, transition, } => { self.slide_index(id)?; if let Some(l) = layout { in_vocab("layout", l, schema::LAYOUTS)?; } if let Some(t) = template { in_vocab("template", t, schema::TEMPLATES)?; } if let Some(b) = background { in_vocab("background.kind", &b.kind, schema::BACKGROUND_KINDS)?; } if let Some(p) = phone { in_vocab("phone", p, schema::SLIDE_PHONES)?; } if let Some(t) = transition { in_vocab("transition", t, schema::TRANSITIONS)?; } Ok(()) } Op::SetNotes { id, .. } => self.slide_index(id).map(|_| ()), Op::AddBlock { slide, id, kind, props, region, after, .. } => { non_empty("id", id)?; in_vocab("type", kind, schema::BLOCK_TYPES)?; if let Some(r) = region { in_vocab("region", r, schema::REGIONS)?; } if kind == "table" { table_side(props, "rows")?; table_side(props, "cols")?; } let s = self.slide_mut(slide)?; if s.blocks.iter().any(|b| &b.id == id) { return fail(format!("block {id:?} already exists in slide {slide:?}")); } let children = if kind == "columns" { Some(match props.get("children") { Some(v) => validate_children(s, id, v)?, None => Vec::new(), }) } else { None }; let at = match after { None => s.blocks.len(), Some(None) => 0, Some(Some(a)) => block_index(s, a)? + 1, }; s.blocks.insert( at, BlockModel { id: id.clone(), kind: kind.clone(), children, }, ); Ok(()) } Op::RemoveBlock { slide, id } => { let s = self.slide_mut(slide)?; let i = block_index(s, id)?; s.blocks.remove(i); // Spec §2.4: a `columns` block's children may not name a // block that no longer exists — drop it rather than // reject the removal. for b in s.blocks.iter_mut() { if let Some(children) = &mut b.children { children.retain(|c| c != id); } } Ok(()) } Op::MoveBlock { slide, id, after } => { let s = self.slide_mut(slide)?; let i = block_index(s, id)?; if let Some(a) = after { if a == id { return fail("move_block: after is the block itself"); } block_index(s, a)?; } let b = s.blocks.remove(i); let at = match after { None => 0, Some(a) => block_index(s, a)? + 1, }; s.blocks.insert(at, b); Ok(()) } Op::SetBlock { slide, id, props, region, .. } => { let s = self.slide_mut(slide)?; let i = block_index(s, id)?; if let Some(Some(r)) = region { in_vocab("region", r, schema::REGIONS)?; } if s.blocks[i].kind == "table" { if let Some(p) = props { table_side(p, "rows")?; table_side(p, "cols")?; } } if s.blocks[i].kind == "columns" { if let Some(children_value) = props.as_ref().and_then(|p| p.get("children")) { let children = validate_children(s, id, children_value)?; s.blocks[i].children = Some(children); } } Ok(()) } Op::SetText { slide, id, .. } => { let s = self.slide_mut(slide)?; let i = block_index(s, id)?; if !schema::is_text_type(&s.blocks[i].kind) { return fail(format!( "block {id:?} is a {} and carries no text", s.blocks[i].kind )); } Ok(()) } Op::SetPhone { slide, id, phone } => { let s = self.slide_mut(slide)?; block_index(s, id)?; if let Some(size) = &phone.size { in_vocab("phone.size", size, schema::SIZES)?; } Ok(()) } Op::RegisterAsset { id, reference, .. } => { non_empty("id", id)?; non_empty("ref", reference)?; if !self.assets.insert(id.clone()) { return fail(format!("asset {id:?} is already registered")); } Ok(()) } Op::RemoveAsset { id } => { if !self.assets.remove(id) { return fail(format!("no asset {id:?}")); } Ok(()) } } } } fn block_index(s: &SlideModel, id: &str) -> Result { s.blocks .iter() .position(|b| b.id == id) .ok_or_else(|| CoreError::Ops(format!("no block {id:?} in slide {:?}", s.id))) } /// A `columns` block's `props.children` (spec §2.4): an array of block /// ids, each naming a block on `s` other than `self_id`, each named /// once, none of them already a child of another columns block on the /// slide, and none of them holding `self_id` underneath it — the /// children of a slide are a forest, every block with at most one /// parent and no way back up (codex 7). `null` clears (an /// absent-props-key removal), so it validates to no children. /// /// The messages are word for word the ones fixtures/ops-model.mjs /// throws, so the inspector and the drive API say the same thing. fn validate_children( s: &SlideModel, self_id: &str, value: &Value, ) -> Result, CoreError> { if value.is_null() { return Ok(Vec::new()); } let items = value .as_array() .ok_or_else(|| CoreError::Ops("columns children must be an array of block ids".into()))?; let mut out: Vec = Vec::with_capacity(items.len()); for item in items { let cid = item.as_str().ok_or_else(|| { CoreError::Ops("columns children must be an array of block ids".into()) })?; if cid == self_id { return fail(format!( "columns {self_id:?} cannot name itself in children" )); } if !s.blocks.iter().any(|b| b.id == cid) { return fail(format!( "columns children names {cid:?}, not a block on slide {:?}", s.id )); } if out.iter().any(|c| c == cid) { return fail(format!("columns children names {cid:?} twice")); } if let Some(other) = s.blocks.iter().find(|b| { b.id != self_id && b.children .as_ref() .is_some_and(|cs| cs.iter().any(|c| c == cid)) }) { return fail(format!( "columns children names {cid:?}, already a child of columns {:?}", other.id )); } if contains(s, cid, self_id) { return fail(format!( "columns children names {cid:?}, which would put columns {self_id:?} inside itself" )); } out.push(cid.to_string()); } Ok(out) } /// Whether `target` is `root` or sits anywhere under it, following the /// slide's columns children as the model holds them now. Every block /// has at most one parent by construction, so the walk is a tree walk; /// the visited set is there so a document a peer put a cycle into by /// hand still terminates. fn contains(s: &SlideModel, root: &str, target: &str) -> bool { let mut stack = vec![root]; let mut seen: HashSet<&str> = HashSet::new(); while let Some(id) = stack.pop() { if id == target { return true; } if !seen.insert(id) { continue; } if let Some(b) = s.blocks.iter().find(|b| b.id == id) { if let Some(cs) = &b.children { stack.extend(cs.iter().map(String::as_str)); } } } false } // --- the applying pass --- fn slide_map(txn: &TransactionMut, roots: &Roots, id: &str) -> Result<(u32, MapRef), CoreError> { schema::find_by_id(txn, &roots.slides, id) .ok_or_else(|| CoreError::Ops(format!("no slide {id:?}"))) } /// The slide's block array, created if a peer wrote a slide without one. pub(crate) fn blocks_of(txn: &mut TransactionMut, slide: &MapRef) -> ArrayRef { match schema::get_array(txn, slide, "blocks") { Some(a) => a, None => slide.insert(txn, "blocks", yrs::ArrayPrelim::default()), } } fn block_map( txn: &TransactionMut, blocks: &ArrayRef, id: &str, ) -> Result<(u32, MapRef), CoreError> { schema::find_by_id(txn, blocks, id).ok_or_else(|| CoreError::Ops(format!("no block {id:?}"))) } /// Drops `removed_id` from every `columns` block's `props.children` in /// `blocks` (spec §2.4: children may not name a block that no longer /// exists — `remove_block` prunes rather than rejects). fn remove_from_columns_children(txn: &mut TransactionMut, blocks: &ArrayRef, removed_id: &str) { for (_, m) in schema::maps_of(txn, blocks) { if schema::get_string(txn, &m, "type").as_deref() != Some("columns") { continue; } let Some(pm) = schema::get_map(txn, &m, "props") else { continue; }; let Some(Value::Array(children)) = schema::map_to_json(txn, &pm).get("children").cloned() else { continue; }; if !children.iter().any(|c| c.as_str() == Some(removed_id)) { continue; } let filtered: Vec = children .into_iter() .filter(|c| c.as_str() != Some(removed_id)) .collect(); pm.insert( txn, "children", schema::json_to_any(&Value::Array(filtered)), ); } } fn fragment_of(txn: &mut TransactionMut, map: &MapRef, key: &str) -> yrs::XmlFragmentRef { match schema::get_fragment(txn, map, key) { Some(f) => f, None => map.insert(txn, key, XmlFragmentPrelim::default()), } } fn map_of(txn: &mut TransactionMut, map: &MapRef, key: &str) -> MapRef { match schema::get_map(txn, map, key) { Some(m) => m, None => map.insert(txn, key, yrs::MapPrelim::default()), } } /// Where an `after` puts a new entry: absent appends, null goes first, /// an id goes right after it. fn insert_index( txn: &TransactionMut, array: &ArrayRef, after: &Option>, ) -> Result { Ok(match after { None => array.len(txn), Some(None) => 0, Some(Some(a)) => { schema::find_by_id(txn, array, a) .ok_or_else(|| CoreError::Ops(format!("no entry {a:?}")))? .0 + 1 } }) } /// Puts the entry `id` of an ordered array — a slide of the deck, a /// block of a slide — at the reading position `after` asks for: absent /// means first, an id means right after it. It does that by renumbering /// `order` on every entry (schema.rs, "reading order"). The array is /// not touched: the maps stay the shared types they are, so a peer's /// edit in flight against any of them still lands (codex 4). fn place_entry( txn: &mut TransactionMut, array: &ArrayRef, what: &str, id: &str, after: &Option, ) -> Result<(), CoreError> { let mut ordered: Vec<(String, MapRef)> = schema::ordered_maps(txn, array) .into_iter() .map(|(_, m)| (schema::get_string(txn, &m, "id").unwrap_or_default(), m)) .collect(); let i = ordered .iter() .position(|(bid, _)| bid == id) .ok_or_else(|| CoreError::Ops(format!("no {what} {id:?}")))?; let moved = ordered.remove(i); let at = match after { None => 0, Some(a) => { ordered .iter() .position(|(bid, _)| bid == a) .ok_or_else(|| CoreError::Ops(format!("no entry {a:?}")))? + 1 } }; ordered.insert(at, moved); let maps: Vec = ordered.into_iter().map(|(_, m)| m).collect(); schema::renumber(txn, &maps); Ok(()) } /// `place_entry` for an entry that was just inserted, whose `after` is /// the three-valued kind (absent appends, `null` first, an id after it). /// Only called when the array already carries explicit `order` keys. fn place_new( txn: &mut TransactionMut, array: &ArrayRef, what: &str, id: &str, after: &Option>, ) -> Result<(), CoreError> { let after_id = match after { None => schema::ordered_maps(txn, array) .iter() .map(|(_, m)| schema::get_string(txn, m, "id").unwrap_or_default()) .rev() .find(|other| other != id), Some(None) => None, Some(Some(a)) => Some(a.clone()), }; place_entry(txn, array, what, id, &after_id) } /// Text for a block of `kind` from its Markdown: `code` is plain lines, /// everything else the subset. fn text_for(kind: &str, markdown: &str) -> Vec { if kind == "code" { markdown::parse_lines(markdown) } else { markdown::parse(markdown) } } // --- duplicate --- /// Gives a copied slide its new ids: the slide's own, each block's, and /// any `columns.children` list that named the old block ids. /// /// `reading` is the source slide's block ids in reading order, and the /// k-th new id goes to the k-th block in THAT order — the order the /// model paired them in — whatever position the block has in the /// array. The copied `order` keys come along untouched, so the copy /// reads in the order the original did. fn rewrite_ids( copy: &mut yrs::MapPrelim, new_id: &str, new_block_ids: &[String], reading: &[String], ) { copy.insert(Arc::from("id"), schema::string_in(new_id)); let Some(In::Array(blocks)) = copy.get_mut("blocks") else { return; }; let mut mapping: HashMap = HashMap::new(); for b in blocks.iter_mut() { let In::Map(bm) = b else { continue }; let Some(In::Any(Any::String(old))) = bm.get("id") else { continue; }; let Some(nid) = reading .iter() .position(|r| r.as_str() == old.as_ref()) .and_then(|k| new_block_ids.get(k)) else { continue; }; mapping.insert(old.to_string(), nid.clone()); bm.insert(Arc::from("id"), schema::string_in(nid)); } for b in blocks.iter_mut() { let In::Map(bm) = b else { continue }; let Some(In::Map(props)) = bm.get_mut("props") else { continue; }; let Some(In::Any(Any::Array(children))) = props.get("children") else { continue; }; let remapped: Vec = children .iter() .map(|c| match c { Any::String(s) => match mapping.get(s.as_ref()) { Some(n) => Any::from(n.as_str()), None => c.clone(), }, other => other.clone(), }) .collect(); props.insert(Arc::from("children"), In::Any(Any::Array(remapped.into()))); } } // --- apply --- fn apply_one(txn: &mut TransactionMut, roots: &Roots, op: &Op) -> Result<(), CoreError> { match op { Op::SetMeta { title, theme, accent, fonts, } => { if let Some(t) = title { roots.meta.insert(txn, "title", Any::from(t.as_str())); } if let Some(t) = theme { roots.meta.insert(txn, "theme", Any::from(t.as_str())); } match accent { None => {} Some(None) => { roots.meta.remove(txn, "accent"); } Some(Some(a)) => { roots.meta.insert(txn, "accent", Any::from(a.as_str())); } } match fonts { None => {} Some(None) => { roots.meta.remove(txn, "fonts"); } Some(Some(f)) => { let v = serde_json::json!({"heading": f.heading, "body": f.body}); roots.meta.insert(txn, "fonts", schema::json_to_any(&v)); } } } Op::AddSlide { id, after, layout, template, } => { let at = insert_index(txn, &roots.slides, after)?; let template = template.as_deref().unwrap_or(schema::DEFAULT_TEMPLATE); let explicit = schema::has_explicit_order(txn, &roots.slides); roots .slides .insert(txn, at, schema::slide_prelim(id, layout, template)); // On a deck nobody has reordered the array IS the reading // order and the insert above already put the slide where // `after` asked. Once the deck carries `order` keys the // array position means nothing, so the new slide is placed // by renumbering instead (schema.rs, "reading order"). if explicit { place_new(txn, &roots.slides, "slide", id, after)?; } } Op::RemoveSlide { id } => { let (i, _) = slide_map(txn, roots, id)?; roots.slides.remove(txn, i); } Op::MoveSlide { id, after } => { slide_map(txn, roots, id)?; place_entry(txn, &roots.slides, "slide", id, after)?; } Op::DuplicateSlide { id, new_id, new_block_ids, } => { let (i, m) = slide_map(txn, roots, id)?; let reading: Vec = schema::get_array(txn, &m, "blocks") .map(|a| { schema::ordered_maps(txn, &a) .iter() .map(|(_, b)| schema::get_string(txn, b, "id").unwrap_or_default()) .collect() }) .unwrap_or_default(); let mut copy = m.as_prelim(txn); rewrite_ids(&mut copy, new_id, new_block_ids, &reading); let explicit = schema::has_explicit_order(txn, &roots.slides); roots.slides.insert(txn, i + 1, copy); // The copy carries the original's `order` too, so on a // reordered deck it would tie with it; renumber so the // duplicate is unambiguously the next slide. if explicit { place_entry(txn, &roots.slides, "slide", new_id, &Some(id.clone()))?; } } Op::SetSlide { id, layout, template, background, phone, transition, } => { let (_, m) = slide_map(txn, roots, id)?; if let Some(l) = layout { let current = schema::get_string(txn, &m, "layout").unwrap_or_else(|| "flow".into()); if *l != current { match l.as_str() { "canvas" => layout::to_canvas(txn, &m), _ => layout::to_flow(txn, &m), } m.insert(txn, "layout", Any::from(l.as_str())); } } if let Some(t) = template { m.insert(txn, "template", Any::from(t.as_str())); } if let Some(b) = background { m.insert( txn, "background", schema::background_prelim(&b.kind, &b.value), ); } if let Some(p) = phone { m.insert(txn, "phone", Any::from(p.as_str())); } if let Some(t) = transition { m.insert(txn, "transition", Any::from(t.as_str())); } } Op::SetNotes { id, markdown } => { let (_, m) = slide_map(txn, roots, id)?; let notes = fragment_of(txn, &m, "notes"); schema::replace_text(txn, ¬es, &markdown::parse(markdown)); } Op::AddBlock { slide, id, kind, props, region, bx, z, after, markdown, } => { let (_, s) = slide_map(txn, roots, slide)?; let blocks = blocks_of(txn, &s); let at = insert_index(txn, &blocks, after)?; let canvas = schema::get_string(txn, &s, "layout").as_deref() == Some("canvas"); let mut props = props.clone(); let cells = if kind == "table" { let rows = table_side(&props, "rows")?.unwrap_or(schema::DEFAULT_TABLE_ROWS); let cols = table_side(&props, "cols")?.unwrap_or(schema::DEFAULT_TABLE_COLS); props.insert("rows".into(), Value::from(rows)); props.insert("cols".into(), Value::from(cols)); Some((rows * cols) as usize) } else { None }; let text: Option> = if schema::is_text_type(kind) { Some( markdown .as_deref() .map(|md| text_for(kind, md)) .unwrap_or_default(), ) } else { None }; let region = region .as_deref() .unwrap_or_else(|| layout::default_region(kind)); let (region_key, box_value, z_value) = if canvas { let bx = bx.map(|b| b.json()).unwrap_or_else(|| { let (x, y, w, h) = layout::region_rect(region, false); schema::box_json(x, y, w, h) }); (None, Some(bx), Some(z.unwrap_or(at as f64))) } else { (Some(region), None, None) }; let prelim = schema::block_prelim( id, kind, &props, region_key, box_value.as_ref(), z_value, text.as_deref(), cells, ); let explicit = schema::has_explicit_order(txn, &blocks); blocks.insert(txn, at, prelim); // On a slide nobody has reordered the array IS the reading // order and the insert above already put the block where // `after` asked. Once the slide carries `order` keys the // array position means nothing, so the new block is placed // by renumbering instead (schema.rs, "reading order"). if explicit { place_new(txn, &blocks, "block", id, after)?; } } Op::RemoveBlock { slide, id } => { let (_, s) = slide_map(txn, roots, slide)?; let blocks = blocks_of(txn, &s); let (i, _) = block_map(txn, &blocks, id)?; blocks.remove(txn, i); remove_from_columns_children(txn, &blocks, id); } Op::MoveBlock { slide, id, after } => { let (_, s) = slide_map(txn, roots, slide)?; let blocks = blocks_of(txn, &s); block_map(txn, &blocks, id)?; place_entry(txn, &blocks, "block", id, after)?; } Op::SetBlock { slide, id, props, region, bx, z, } => { let (_, s) = slide_map(txn, roots, slide)?; let blocks = blocks_of(txn, &s); let (_, m) = block_map(txn, &blocks, id)?; if let Some(p) = props { let pm = map_of(txn, &m, "props"); for (k, v) in p { if v.is_null() { pm.remove(txn, k); } else { pm.insert(txn, k.as_str(), schema::json_to_any(v)); } } let is_table = schema::get_string(txn, &m, "type").as_deref() == Some("table"); // `cells` is row-major and flat, so growing/shrinking // only appends to or truncates the end of it -- a // cols-only change (rows unchanged) reflows every row's // cells rather than inserting/removing per row, which // shuffles existing cell text across the grid. Spec // §2.4 sanctions this as a v1 limitation; a person // resizing columns should expect to recheck cell // contents. if is_table && (p.contains_key("rows") || p.contains_key("cols")) { let rows = schema::get_number(txn, &pm, "rows") .unwrap_or(schema::DEFAULT_TABLE_ROWS as f64) as i64; let cols = schema::get_number(txn, &pm, "cols") .unwrap_or(schema::DEFAULT_TABLE_COLS as f64) as i64; let want = (rows.max(1) * cols.max(1)) as u32; let cells = match schema::get_array(txn, &m, "cells") { Some(c) => c, None => m.insert(txn, "cells", yrs::ArrayPrelim::default()), }; let have = cells.len(txn); if have > want { cells.remove_range(txn, want, have - want); } for _ in have..want { cells.push_back(txn, XmlFragmentPrelim::default()); } } } match region { None => {} Some(None) => { m.remove(txn, "region"); } Some(Some(r)) => { m.insert(txn, "region", Any::from(r.as_str())); } } match bx { None => {} Some(None) => { m.remove(txn, "box"); } Some(Some(b)) => { m.insert(txn, "box", schema::json_to_any(&b.json())); } } if let Some(z) = z { m.insert(txn, "z", Any::Number(*z)); } } Op::SetText { slide, id, markdown, } => { let (_, s) = slide_map(txn, roots, slide)?; let blocks = blocks_of(txn, &s); let (_, m) = block_map(txn, &blocks, id)?; let kind = schema::get_string(txn, &m, "type").unwrap_or_default(); let text = fragment_of(txn, &m, "text"); schema::replace_text(txn, &text, &text_for(&kind, markdown)); } Op::SetPhone { slide, id, phone } => { let (_, s) = slide_map(txn, roots, slide)?; let blocks = blocks_of(txn, &s); let (_, m) = block_map(txn, &blocks, id)?; let pm = map_of(txn, &m, "phone"); pm.clear(txn); if let Some(h) = phone.hidden { pm.insert(txn, "hidden", Any::Bool(h)); } if let Some(o) = phone.order { pm.insert(txn, "order", Any::Number(o as f64)); } if let Some(sz) = &phone.size { pm.insert(txn, "size", Any::from(sz.as_str())); } if let Some(b) = &phone.bx { pm.insert(txn, "box", schema::json_to_any(&b.json())); } } Op::RegisterAsset { id, reference, name, width, height, alt, } => { let v = serde_json::json!({ "ref": reference, "name": name, "width": width, "height": height, "alt": alt, }); roots .assets .insert(txn, id.as_str(), schema::json_to_any(&v)); } Op::RemoveAsset { id } => { roots.assets.remove(txn, id); } } Ok(()) } /// Applies `ops` (a JSON array of operations) to `doc` in one /// transaction with origin `"ops"`, returning the v1 update that /// transaction produced. On `Err` nothing was written — see the module /// comment for how that is arranged. pub fn apply_ops(doc: &Doc, ops: &str) -> Result, CoreError> { let raw: Vec = serde_json::from_str(ops) .map_err(|e| CoreError::Ops(format!("ops must be a JSON array: {e}")))?; let mut parsed = Vec::with_capacity(raw.len()); for (i, v) in raw.into_iter().enumerate() { let op: Op = serde_json::from_value(v).map_err(|e| CoreError::Ops(format!("op {i}: {e}")))?; parsed.push(op); } // Roots first: get_or_insert_* takes its own write transaction. let roots = schema::roots(doc); { let txn = doc.transact(); let mut model = Model::read(&txn, &roots); for (i, op) in parsed.iter().enumerate() { model.check(op).map_err(|e| match e { CoreError::Ops(m) => CoreError::Ops(format!("op {i}: {m}")), other => other, })?; } } let mut txn = doc.transact_mut_with("ops"); for (i, op) in parsed.iter().enumerate() { apply_one(&mut txn, &roots, op).map_err(|e| match e { CoreError::Ops(m) => CoreError::Ops(format!("op {i} (applying): {m}")), other => other, })?; } txn.commit(); Ok(txn.encode_update_v1()) }