oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1//! The operations the drive surface applies (spec §7.2), as one batch in
2//! one transaction.
3//!
4//! # Atomicity
5//!
6//! A yrs `TransactionMut` cannot be rolled back — dropping it commits
7//! whatever was written. So "a failure applies nothing" has to be true
8//! before the first write, and it is arranged in two passes:
9//!
10//! 1. Every op is parsed (shape, vocabularies, required fields) and then
11//! checked against a *model* of the deck — just the slide ids, their
12//! block ids and types, and the asset ids — that each op updates as
13//! it is checked, so a reference that only becomes wrong halfway
14//! through the batch (`remove_block` on a block an earlier op removed)
15//! is caught here too.
16//! 2. Only then does the transaction open and the ops apply. By the
17//! model's construction every lookup in this pass succeeds; the
18//! `Err`s it can still return exist so a bug here surfaces as a
19//! message rather than a trap, not because they are expected.
20//!
21//! # Ids
22//!
23//! The core never draws entropy (`lib.rs`). Every new slide or block id
24//! arrives in the op (`id`, `new_id`, `new_block_ids`) from the Go host,
25//! and an op that needs one and lacks it is a parse error.
26//!
27//! # The update
28//!
29//! The batch's update is the transaction's own `encode_update_v1()`,
30//! taken after an explicit `commit()` (which squashes the delete set and
31//! fires observers) and before the guard drops. yrs 0.23.5's
32//! `TransactionMut::encode_update` reads `before_state`, the store and
33//! the transaction's delete set, all of which are intact after commit —
34//! so no observer, no captured buffer, and nothing that could see a
35//! different transaction's changes.
36
37use crate::layout;
38use crate::markdown;
39use crate::schema::{self, Paragraph, Roots};
40use crate::CoreError;
41use serde::{Deserialize, Deserializer};
42use serde_json::{Map as JsonMap, Value};
43use std::collections::{HashMap, HashSet};
44use std::sync::Arc;
45use yrs::types::xml::XmlFragmentPrelim;
46use yrs::types::AsPrelim;
47use yrs::{Any, Array, ArrayRef, Doc, In, Map, MapRef, ReadTxn, Transact, TransactionMut};
48
49type Props = JsonMap<String, Value>;
50
51/// `Option<Option<T>>` so an absent key (`None`) and an explicit `null`
52/// (`Some(None)`) can mean different things — `after` absent appends,
53/// `after: null` puts first; `accent: null` clears.
54fn nullable<'de, D, T>(d: D) -> Result<Option<Option<T>>, D::Error>
55where
56 D: Deserializer<'de>,
57 T: Deserialize<'de>,
58{
59 Option::<T>::deserialize(d).map(Some)
60}
61
62#[derive(Deserialize)]
63struct Fonts {
64 heading: String,
65 body: String,
66}
67
68#[derive(Deserialize, Clone, Copy)]
69struct BoxSpec {
70 x: f64,
71 y: f64,
72 w: f64,
73 h: f64,
74}
75
76impl BoxSpec {
77 fn json(&self) -> Value {
78 schema::box_json(self.x, self.y, self.w, self.h)
79 }
80}
81
82#[derive(Deserialize)]
83struct Background {
84 kind: String,
85 #[serde(default)]
86 value: String,
87}
88
89#[derive(Deserialize)]
90#[serde(deny_unknown_fields)]
91struct PhoneOverride {
92 hidden: Option<bool>,
93 order: Option<i64>,
94 size: Option<String>,
95 #[serde(rename = "box")]
96 bx: Option<BoxSpec>,
97}
98
99#[derive(Deserialize)]
100#[serde(tag = "op", rename_all = "snake_case")]
101enum Op {
102 SetMeta {
103 title: Option<String>,
104 theme: Option<String>,
105 #[serde(default, deserialize_with = "nullable")]
106 accent: Option<Option<String>>,
107 #[serde(default, deserialize_with = "nullable")]
108 fonts: Option<Option<Fonts>>,
109 },
110 AddSlide {
111 id: String,
112 #[serde(default, deserialize_with = "nullable")]
113 after: Option<Option<String>>,
114 layout: String,
115 template: Option<String>,
116 },
117 RemoveSlide {
118 id: String,
119 },
120 MoveSlide {
121 id: String,
122 after: Option<String>,
123 },
124 DuplicateSlide {
125 id: String,
126 new_id: String,
127 new_block_ids: Vec<String>,
128 },
129 SetSlide {
130 id: String,
131 layout: Option<String>,
132 template: Option<String>,
133 background: Option<Background>,
134 phone: Option<String>,
135 transition: Option<String>,
136 },
137 SetNotes {
138 id: String,
139 markdown: String,
140 },
141 AddBlock {
142 slide: String,
143 id: String,
144 #[serde(rename = "type")]
145 kind: String,
146 #[serde(default)]
147 props: Props,
148 region: Option<String>,
149 #[serde(rename = "box")]
150 bx: Option<BoxSpec>,
151 z: Option<f64>,
152 #[serde(default, deserialize_with = "nullable")]
153 after: Option<Option<String>>,
154 markdown: Option<String>,
155 },
156 RemoveBlock {
157 slide: String,
158 id: String,
159 },
160 MoveBlock {
161 slide: String,
162 id: String,
163 after: Option<String>,
164 },
165 SetBlock {
166 slide: String,
167 id: String,
168 props: Option<Props>,
169 #[serde(default, deserialize_with = "nullable")]
170 region: Option<Option<String>>,
171 #[serde(default, deserialize_with = "nullable", rename = "box")]
172 bx: Option<Option<BoxSpec>>,
173 z: Option<f64>,
174 },
175 SetText {
176 slide: String,
177 id: String,
178 markdown: String,
179 },
180 SetPhone {
181 slide: String,
182 id: String,
183 phone: PhoneOverride,
184 },
185 RegisterAsset {
186 id: String,
187 #[serde(rename = "ref")]
188 reference: String,
189 #[serde(default)]
190 name: String,
191 #[serde(default)]
192 width: i64,
193 #[serde(default)]
194 height: i64,
195 #[serde(default)]
196 alt: String,
197 },
198 RemoveAsset {
199 id: String,
200 },
201}
202
203fn fail<T>(msg: impl Into<String>) -> Result<T, CoreError> {
204 Err(CoreError::Ops(msg.into()))
205}
206
207fn in_vocab(what: &str, value: &str, vocab: &[&str]) -> Result<(), CoreError> {
208 if vocab.contains(&value) {
209 Ok(())
210 } else {
211 fail(format!(
212 "{what} {value:?} is not one of {}",
213 vocab.join(", ")
214 ))
215 }
216}
217
218fn non_empty(what: &str, value: &str) -> Result<(), CoreError> {
219 if value.is_empty() {
220 fail(format!("{what} is empty"))
221 } else {
222 Ok(())
223 }
224}
225
226/// `rows`/`cols` from props when present: whole numbers within
227/// `1..=MAX_TABLE_SIDE`.
228fn table_side(props: &Props, key: &str) -> Result<Option<i64>, CoreError> {
229 let Some(v) = props.get(key) else {
230 return Ok(None);
231 };
232 match v.as_i64() {
233 Some(n) if (1..=schema::MAX_TABLE_SIDE).contains(&n) => Ok(Some(n)),
234 _ => fail(format!(
235 "table {key} must be a whole number from 1 to {}",
236 schema::MAX_TABLE_SIDE
237 )),
238 }
239}
240
241// --- the model, and the checking pass ---
242
243struct BlockModel {
244 id: String,
245 kind: String,
246 /// `Some(children)` for a `columns` block: the block ids its
247 /// `props.children` currently names, kept in step through the batch
248 /// so `remove_block` can drop a removed id from it and a later
249 /// `set_block` on the same columns block sees the drop (spec §2.4).
250 /// `None` for every other kind.
251 children: Option<Vec<String>>,
252}
253
254struct SlideModel {
255 id: String,
256 blocks: Vec<BlockModel>,
257}
258
259struct Model {
260 slides: Vec<SlideModel>,
261 assets: HashSet<String>,
262}
263
264impl Model {
265 fn read<T: ReadTxn>(txn: &T, roots: &Roots) -> Model {
266 // Reading order at both levels (schema::ordered_maps): `after`
267 // is positional, and the position that means anything is the
268 // one a reader of the view sees.
269 let slides = schema::ordered_maps(txn, &roots.slides)
270 .into_iter()
271 .map(|(_, m)| {
272 let blocks = schema::get_array(txn, &m, "blocks")
273 .map(|a| {
274 schema::ordered_maps(txn, &a)
275 .into_iter()
276 .map(|(_, b)| {
277 let kind = schema::get_string(txn, &b, "type").unwrap_or_default();
278 let children = (kind == "columns").then(|| {
279 schema::get_object(txn, &b, "props")
280 .and_then(|p| p.get("children").cloned())
281 .and_then(|v| v.as_array().cloned())
282 .map(|arr| {
283 arr.iter()
284 .filter_map(|c| c.as_str().map(str::to_string))
285 .collect()
286 })
287 .unwrap_or_default()
288 });
289 BlockModel {
290 id: schema::get_string(txn, &b, "id").unwrap_or_default(),
291 kind,
292 children,
293 }
294 })
295 .collect()
296 })
297 .unwrap_or_default();
298 SlideModel {
299 id: schema::get_string(txn, &m, "id").unwrap_or_default(),
300 blocks,
301 }
302 })
303 .collect();
304 let assets = roots.assets.keys(txn).map(|k| k.to_string()).collect();
305 Model { slides, assets }
306 }
307
308 fn slide_index(&self, id: &str) -> Result<usize, CoreError> {
309 self.slides
310 .iter()
311 .position(|s| s.id == id)
312 .ok_or_else(|| CoreError::Ops(format!("no slide {id:?}")))
313 }
314
315 fn slide_mut(&mut self, id: &str) -> Result<&mut SlideModel, CoreError> {
316 let i = self.slide_index(id)?;
317 Ok(&mut self.slides[i])
318 }
319
320 fn check(&mut self, op: &Op) -> Result<(), CoreError> {
321 match op {
322 Op::SetMeta { .. } => Ok(()),
323 Op::AddSlide {
324 id,
325 after,
326 layout,
327 template,
328 } => {
329 non_empty("id", id)?;
330 if self.slide_index(id).is_ok() {
331 return fail(format!("slide {id:?} already exists"));
332 }
333 in_vocab("layout", layout, schema::LAYOUTS)?;
334 if let Some(t) = template {
335 in_vocab("template", t, schema::TEMPLATES)?;
336 }
337 let at = match after {
338 None => self.slides.len(),
339 Some(None) => 0,
340 Some(Some(a)) => self.slide_index(a)? + 1,
341 };
342 self.slides.insert(
343 at,
344 SlideModel {
345 id: id.clone(),
346 blocks: Vec::new(),
347 },
348 );
349 Ok(())
350 }
351 Op::RemoveSlide { id } => {
352 let i = self.slide_index(id)?;
353 self.slides.remove(i);
354 Ok(())
355 }
356 Op::MoveSlide { id, after } => {
357 self.slide_index(id)?;
358 if let Some(a) = after {
359 if a == id {
360 return fail("move_slide: after is the slide itself");
361 }
362 self.slide_index(a)?;
363 }
364 let i = self.slide_index(id)?;
365 let s = self.slides.remove(i);
366 let at = match after {
367 None => 0,
368 Some(a) => self.slide_index(a)? + 1,
369 };
370 self.slides.insert(at, s);
371 Ok(())
372 }
373 Op::DuplicateSlide {
374 id,
375 new_id,
376 new_block_ids,
377 } => {
378 non_empty("new_id", new_id)?;
379 let i = self.slide_index(id)?;
380 if self.slide_index(new_id).is_ok() {
381 return fail(format!("slide {new_id:?} already exists"));
382 }
383 let n = self.slides[i].blocks.len();
384 if new_block_ids.len() != n {
385 return fail(format!(
386 "duplicate_slide: {n} blocks but {} new_block_ids",
387 new_block_ids.len()
388 ));
389 }
390 let distinct: HashSet<&String> = new_block_ids.iter().collect();
391 if distinct.len() != n || new_block_ids.iter().any(String::is_empty) {
392 return fail("duplicate_slide: new_block_ids must be distinct and non-empty");
393 }
394 // Old block id -> new, so a duplicated `columns` block's
395 // children (already validated to name blocks on this
396 // slide) follow the copy exactly like `rewrite_ids`
397 // remaps them at apply time.
398 let id_map: HashMap<&str, &str> = self.slides[i]
399 .blocks
400 .iter()
401 .map(|b| b.id.as_str())
402 .zip(new_block_ids.iter().map(String::as_str))
403 .collect();
404 let blocks = self.slides[i]
405 .blocks
406 .iter()
407 .zip(new_block_ids)
408 .map(|(b, nid)| BlockModel {
409 id: nid.clone(),
410 kind: b.kind.clone(),
411 children: b.children.as_ref().map(|cs| {
412 cs.iter()
413 .map(|c| {
414 id_map
415 .get(c.as_str())
416 .map(|n| n.to_string())
417 .unwrap_or_else(|| c.clone())
418 })
419 .collect()
420 }),
421 })
422 .collect();
423 self.slides.insert(
424 i + 1,
425 SlideModel {
426 id: new_id.clone(),
427 blocks,
428 },
429 );
430 Ok(())
431 }
432 Op::SetSlide {
433 id,
434 layout,
435 template,
436 background,
437 phone,
438 transition,
439 } => {
440 self.slide_index(id)?;
441 if let Some(l) = layout {
442 in_vocab("layout", l, schema::LAYOUTS)?;
443 }
444 if let Some(t) = template {
445 in_vocab("template", t, schema::TEMPLATES)?;
446 }
447 if let Some(b) = background {
448 in_vocab("background.kind", &b.kind, schema::BACKGROUND_KINDS)?;
449 }
450 if let Some(p) = phone {
451 in_vocab("phone", p, schema::SLIDE_PHONES)?;
452 }
453 if let Some(t) = transition {
454 in_vocab("transition", t, schema::TRANSITIONS)?;
455 }
456 Ok(())
457 }
458 Op::SetNotes { id, .. } => self.slide_index(id).map(|_| ()),
459 Op::AddBlock {
460 slide,
461 id,
462 kind,
463 props,
464 region,
465 after,
466 ..
467 } => {
468 non_empty("id", id)?;
469 in_vocab("type", kind, schema::BLOCK_TYPES)?;
470 if let Some(r) = region {
471 in_vocab("region", r, schema::REGIONS)?;
472 }
473 if kind == "table" {
474 table_side(props, "rows")?;
475 table_side(props, "cols")?;
476 }
477 let s = self.slide_mut(slide)?;
478 if s.blocks.iter().any(|b| &b.id == id) {
479 return fail(format!("block {id:?} already exists in slide {slide:?}"));
480 }
481 let children = if kind == "columns" {
482 Some(match props.get("children") {
483 Some(v) => validate_children(s, id, v)?,
484 None => Vec::new(),
485 })
486 } else {
487 None
488 };
489 let at = match after {
490 None => s.blocks.len(),
491 Some(None) => 0,
492 Some(Some(a)) => block_index(s, a)? + 1,
493 };
494 s.blocks.insert(
495 at,
496 BlockModel {
497 id: id.clone(),
498 kind: kind.clone(),
499 children,
500 },
501 );
502 Ok(())
503 }
504 Op::RemoveBlock { slide, id } => {
505 let s = self.slide_mut(slide)?;
506 let i = block_index(s, id)?;
507 s.blocks.remove(i);
508 // Spec §2.4: a `columns` block's children may not name a
509 // block that no longer exists — drop it rather than
510 // reject the removal.
511 for b in s.blocks.iter_mut() {
512 if let Some(children) = &mut b.children {
513 children.retain(|c| c != id);
514 }
515 }
516 Ok(())
517 }
518 Op::MoveBlock { slide, id, after } => {
519 let s = self.slide_mut(slide)?;
520 let i = block_index(s, id)?;
521 if let Some(a) = after {
522 if a == id {
523 return fail("move_block: after is the block itself");
524 }
525 block_index(s, a)?;
526 }
527 let b = s.blocks.remove(i);
528 let at = match after {
529 None => 0,
530 Some(a) => block_index(s, a)? + 1,
531 };
532 s.blocks.insert(at, b);
533 Ok(())
534 }
535 Op::SetBlock {
536 slide,
537 id,
538 props,
539 region,
540 ..
541 } => {
542 let s = self.slide_mut(slide)?;
543 let i = block_index(s, id)?;
544 if let Some(Some(r)) = region {
545 in_vocab("region", r, schema::REGIONS)?;
546 }
547 if s.blocks[i].kind == "table" {
548 if let Some(p) = props {
549 table_side(p, "rows")?;
550 table_side(p, "cols")?;
551 }
552 }
553 if s.blocks[i].kind == "columns" {
554 if let Some(children_value) = props.as_ref().and_then(|p| p.get("children")) {
555 let children = validate_children(s, id, children_value)?;
556 s.blocks[i].children = Some(children);
557 }
558 }
559 Ok(())
560 }
561 Op::SetText { slide, id, .. } => {
562 let s = self.slide_mut(slide)?;
563 let i = block_index(s, id)?;
564 if !schema::is_text_type(&s.blocks[i].kind) {
565 return fail(format!(
566 "block {id:?} is a {} and carries no text",
567 s.blocks[i].kind
568 ));
569 }
570 Ok(())
571 }
572 Op::SetPhone { slide, id, phone } => {
573 let s = self.slide_mut(slide)?;
574 block_index(s, id)?;
575 if let Some(size) = &phone.size {
576 in_vocab("phone.size", size, schema::SIZES)?;
577 }
578 Ok(())
579 }
580 Op::RegisterAsset { id, reference, .. } => {
581 non_empty("id", id)?;
582 non_empty("ref", reference)?;
583 if !self.assets.insert(id.clone()) {
584 return fail(format!("asset {id:?} is already registered"));
585 }
586 Ok(())
587 }
588 Op::RemoveAsset { id } => {
589 if !self.assets.remove(id) {
590 return fail(format!("no asset {id:?}"));
591 }
592 Ok(())
593 }
594 }
595 }
596}
597
598fn block_index(s: &SlideModel, id: &str) -> Result<usize, CoreError> {
599 s.blocks
600 .iter()
601 .position(|b| b.id == id)
602 .ok_or_else(|| CoreError::Ops(format!("no block {id:?} in slide {:?}", s.id)))
603}
604
605/// A `columns` block's `props.children` (spec §2.4): an array of block
606/// ids, each naming a block on `s` other than `self_id`, each named
607/// once, none of them already a child of another columns block on the
608/// slide, and none of them holding `self_id` underneath it — the
609/// children of a slide are a forest, every block with at most one
610/// parent and no way back up (codex 7). `null` clears (an
611/// absent-props-key removal), so it validates to no children.
612///
613/// The messages are word for word the ones fixtures/ops-model.mjs
614/// throws, so the inspector and the drive API say the same thing.
615fn validate_children(
616 s: &SlideModel,
617 self_id: &str,
618 value: &Value,
619) -> Result<Vec<String>, CoreError> {
620 if value.is_null() {
621 return Ok(Vec::new());
622 }
623 let items = value
624 .as_array()
625 .ok_or_else(|| CoreError::Ops("columns children must be an array of block ids".into()))?;
626 let mut out: Vec<String> = Vec::with_capacity(items.len());
627 for item in items {
628 let cid = item.as_str().ok_or_else(|| {
629 CoreError::Ops("columns children must be an array of block ids".into())
630 })?;
631 if cid == self_id {
632 return fail(format!(
633 "columns {self_id:?} cannot name itself in children"
634 ));
635 }
636 if !s.blocks.iter().any(|b| b.id == cid) {
637 return fail(format!(
638 "columns children names {cid:?}, not a block on slide {:?}",
639 s.id
640 ));
641 }
642 if out.iter().any(|c| c == cid) {
643 return fail(format!("columns children names {cid:?} twice"));
644 }
645 if let Some(other) = s.blocks.iter().find(|b| {
646 b.id != self_id
647 && b.children
648 .as_ref()
649 .is_some_and(|cs| cs.iter().any(|c| c == cid))
650 }) {
651 return fail(format!(
652 "columns children names {cid:?}, already a child of columns {:?}",
653 other.id
654 ));
655 }
656 if contains(s, cid, self_id) {
657 return fail(format!(
658 "columns children names {cid:?}, which would put columns {self_id:?} inside itself"
659 ));
660 }
661 out.push(cid.to_string());
662 }
663 Ok(out)
664}
665
666/// Whether `target` is `root` or sits anywhere under it, following the
667/// slide's columns children as the model holds them now. Every block
668/// has at most one parent by construction, so the walk is a tree walk;
669/// the visited set is there so a document a peer put a cycle into by
670/// hand still terminates.
671fn contains(s: &SlideModel, root: &str, target: &str) -> bool {
672 let mut stack = vec![root];
673 let mut seen: HashSet<&str> = HashSet::new();
674 while let Some(id) = stack.pop() {
675 if id == target {
676 return true;
677 }
678 if !seen.insert(id) {
679 continue;
680 }
681 if let Some(b) = s.blocks.iter().find(|b| b.id == id) {
682 if let Some(cs) = &b.children {
683 stack.extend(cs.iter().map(String::as_str));
684 }
685 }
686 }
687 false
688}
689
690// --- the applying pass ---
691
692fn slide_map(txn: &TransactionMut, roots: &Roots, id: &str) -> Result<(u32, MapRef), CoreError> {
693 schema::find_by_id(txn, &roots.slides, id)
694 .ok_or_else(|| CoreError::Ops(format!("no slide {id:?}")))
695}
696
697/// The slide's block array, created if a peer wrote a slide without one.
698pub(crate) fn blocks_of(txn: &mut TransactionMut, slide: &MapRef) -> ArrayRef {
699 match schema::get_array(txn, slide, "blocks") {
700 Some(a) => a,
701 None => slide.insert(txn, "blocks", yrs::ArrayPrelim::default()),
702 }
703}
704
705fn block_map(
706 txn: &TransactionMut,
707 blocks: &ArrayRef,
708 id: &str,
709) -> Result<(u32, MapRef), CoreError> {
710 schema::find_by_id(txn, blocks, id).ok_or_else(|| CoreError::Ops(format!("no block {id:?}")))
711}
712
713/// Drops `removed_id` from every `columns` block's `props.children` in
714/// `blocks` (spec §2.4: children may not name a block that no longer
715/// exists — `remove_block` prunes rather than rejects).
716fn remove_from_columns_children(txn: &mut TransactionMut, blocks: &ArrayRef, removed_id: &str) {
717 for (_, m) in schema::maps_of(txn, blocks) {
718 if schema::get_string(txn, &m, "type").as_deref() != Some("columns") {
719 continue;
720 }
721 let Some(pm) = schema::get_map(txn, &m, "props") else {
722 continue;
723 };
724 let Some(Value::Array(children)) = schema::map_to_json(txn, &pm).get("children").cloned()
725 else {
726 continue;
727 };
728 if !children.iter().any(|c| c.as_str() == Some(removed_id)) {
729 continue;
730 }
731 let filtered: Vec<Value> = children
732 .into_iter()
733 .filter(|c| c.as_str() != Some(removed_id))
734 .collect();
735 pm.insert(
736 txn,
737 "children",
738 schema::json_to_any(&Value::Array(filtered)),
739 );
740 }
741}
742
743fn fragment_of(txn: &mut TransactionMut, map: &MapRef, key: &str) -> yrs::XmlFragmentRef {
744 match schema::get_fragment(txn, map, key) {
745 Some(f) => f,
746 None => map.insert(txn, key, XmlFragmentPrelim::default()),
747 }
748}
749
750fn map_of(txn: &mut TransactionMut, map: &MapRef, key: &str) -> MapRef {
751 match schema::get_map(txn, map, key) {
752 Some(m) => m,
753 None => map.insert(txn, key, yrs::MapPrelim::default()),
754 }
755}
756
757/// Where an `after` puts a new entry: absent appends, null goes first,
758/// an id goes right after it.
759fn insert_index(
760 txn: &TransactionMut,
761 array: &ArrayRef,
762 after: &Option<Option<String>>,
763) -> Result<u32, CoreError> {
764 Ok(match after {
765 None => array.len(txn),
766 Some(None) => 0,
767 Some(Some(a)) => {
768 schema::find_by_id(txn, array, a)
769 .ok_or_else(|| CoreError::Ops(format!("no entry {a:?}")))?
770 .0
771 + 1
772 }
773 })
774}
775
776/// Puts the entry `id` of an ordered array — a slide of the deck, a
777/// block of a slide — at the reading position `after` asks for: absent
778/// means first, an id means right after it. It does that by renumbering
779/// `order` on every entry (schema.rs, "reading order"). The array is
780/// not touched: the maps stay the shared types they are, so a peer's
781/// edit in flight against any of them still lands (codex 4).
782fn place_entry(
783 txn: &mut TransactionMut,
784 array: &ArrayRef,
785 what: &str,
786 id: &str,
787 after: &Option<String>,
788) -> Result<(), CoreError> {
789 let mut ordered: Vec<(String, MapRef)> = schema::ordered_maps(txn, array)
790 .into_iter()
791 .map(|(_, m)| (schema::get_string(txn, &m, "id").unwrap_or_default(), m))
792 .collect();
793 let i = ordered
794 .iter()
795 .position(|(bid, _)| bid == id)
796 .ok_or_else(|| CoreError::Ops(format!("no {what} {id:?}")))?;
797 let moved = ordered.remove(i);
798 let at = match after {
799 None => 0,
800 Some(a) => {
801 ordered
802 .iter()
803 .position(|(bid, _)| bid == a)
804 .ok_or_else(|| CoreError::Ops(format!("no entry {a:?}")))?
805 + 1
806 }
807 };
808 ordered.insert(at, moved);
809 let maps: Vec<MapRef> = ordered.into_iter().map(|(_, m)| m).collect();
810 schema::renumber(txn, &maps);
811 Ok(())
812}
813
814/// `place_entry` for an entry that was just inserted, whose `after` is
815/// the three-valued kind (absent appends, `null` first, an id after it).
816/// Only called when the array already carries explicit `order` keys.
817fn place_new(
818 txn: &mut TransactionMut,
819 array: &ArrayRef,
820 what: &str,
821 id: &str,
822 after: &Option<Option<String>>,
823) -> Result<(), CoreError> {
824 let after_id = match after {
825 None => schema::ordered_maps(txn, array)
826 .iter()
827 .map(|(_, m)| schema::get_string(txn, m, "id").unwrap_or_default())
828 .rev()
829 .find(|other| other != id),
830 Some(None) => None,
831 Some(Some(a)) => Some(a.clone()),
832 };
833 place_entry(txn, array, what, id, &after_id)
834}
835
836/// Text for a block of `kind` from its Markdown: `code` is plain lines,
837/// everything else the subset.
838fn text_for(kind: &str, markdown: &str) -> Vec<Paragraph> {
839 if kind == "code" {
840 markdown::parse_lines(markdown)
841 } else {
842 markdown::parse(markdown)
843 }
844}
845
846// --- duplicate ---
847
848/// Gives a copied slide its new ids: the slide's own, each block's, and
849/// any `columns.children` list that named the old block ids.
850///
851/// `reading` is the source slide's block ids in reading order, and the
852/// k-th new id goes to the k-th block in THAT order — the order the
853/// model paired them in — whatever position the block has in the
854/// array. The copied `order` keys come along untouched, so the copy
855/// reads in the order the original did.
856fn rewrite_ids(
857 copy: &mut yrs::MapPrelim,
858 new_id: &str,
859 new_block_ids: &[String],
860 reading: &[String],
861) {
862 copy.insert(Arc::from("id"), schema::string_in(new_id));
863 let Some(In::Array(blocks)) = copy.get_mut("blocks") else {
864 return;
865 };
866 let mut mapping: HashMap<String, String> = HashMap::new();
867 for b in blocks.iter_mut() {
868 let In::Map(bm) = b else { continue };
869 let Some(In::Any(Any::String(old))) = bm.get("id") else {
870 continue;
871 };
872 let Some(nid) = reading
873 .iter()
874 .position(|r| r.as_str() == old.as_ref())
875 .and_then(|k| new_block_ids.get(k))
876 else {
877 continue;
878 };
879 mapping.insert(old.to_string(), nid.clone());
880 bm.insert(Arc::from("id"), schema::string_in(nid));
881 }
882 for b in blocks.iter_mut() {
883 let In::Map(bm) = b else { continue };
884 let Some(In::Map(props)) = bm.get_mut("props") else {
885 continue;
886 };
887 let Some(In::Any(Any::Array(children))) = props.get("children") else {
888 continue;
889 };
890 let remapped: Vec<Any> = children
891 .iter()
892 .map(|c| match c {
893 Any::String(s) => match mapping.get(s.as_ref()) {
894 Some(n) => Any::from(n.as_str()),
895 None => c.clone(),
896 },
897 other => other.clone(),
898 })
899 .collect();
900 props.insert(Arc::from("children"), In::Any(Any::Array(remapped.into())));
901 }
902}
903
904// --- apply ---
905
906fn apply_one(txn: &mut TransactionMut, roots: &Roots, op: &Op) -> Result<(), CoreError> {
907 match op {
908 Op::SetMeta {
909 title,
910 theme,
911 accent,
912 fonts,
913 } => {
914 if let Some(t) = title {
915 roots.meta.insert(txn, "title", Any::from(t.as_str()));
916 }
917 if let Some(t) = theme {
918 roots.meta.insert(txn, "theme", Any::from(t.as_str()));
919 }
920 match accent {
921 None => {}
922 Some(None) => {
923 roots.meta.remove(txn, "accent");
924 }
925 Some(Some(a)) => {
926 roots.meta.insert(txn, "accent", Any::from(a.as_str()));
927 }
928 }
929 match fonts {
930 None => {}
931 Some(None) => {
932 roots.meta.remove(txn, "fonts");
933 }
934 Some(Some(f)) => {
935 let v = serde_json::json!({"heading": f.heading, "body": f.body});
936 roots.meta.insert(txn, "fonts", schema::json_to_any(&v));
937 }
938 }
939 }
940 Op::AddSlide {
941 id,
942 after,
943 layout,
944 template,
945 } => {
946 let at = insert_index(txn, &roots.slides, after)?;
947 let template = template.as_deref().unwrap_or(schema::DEFAULT_TEMPLATE);
948 let explicit = schema::has_explicit_order(txn, &roots.slides);
949 roots
950 .slides
951 .insert(txn, at, schema::slide_prelim(id, layout, template));
952 // On a deck nobody has reordered the array IS the reading
953 // order and the insert above already put the slide where
954 // `after` asked. Once the deck carries `order` keys the
955 // array position means nothing, so the new slide is placed
956 // by renumbering instead (schema.rs, "reading order").
957 if explicit {
958 place_new(txn, &roots.slides, "slide", id, after)?;
959 }
960 }
961 Op::RemoveSlide { id } => {
962 let (i, _) = slide_map(txn, roots, id)?;
963 roots.slides.remove(txn, i);
964 }
965 Op::MoveSlide { id, after } => {
966 slide_map(txn, roots, id)?;
967 place_entry(txn, &roots.slides, "slide", id, after)?;
968 }
969 Op::DuplicateSlide {
970 id,
971 new_id,
972 new_block_ids,
973 } => {
974 let (i, m) = slide_map(txn, roots, id)?;
975 let reading: Vec<String> = schema::get_array(txn, &m, "blocks")
976 .map(|a| {
977 schema::ordered_maps(txn, &a)
978 .iter()
979 .map(|(_, b)| schema::get_string(txn, b, "id").unwrap_or_default())
980 .collect()
981 })
982 .unwrap_or_default();
983 let mut copy = m.as_prelim(txn);
984 rewrite_ids(&mut copy, new_id, new_block_ids, &reading);
985 let explicit = schema::has_explicit_order(txn, &roots.slides);
986 roots.slides.insert(txn, i + 1, copy);
987 // The copy carries the original's `order` too, so on a
988 // reordered deck it would tie with it; renumber so the
989 // duplicate is unambiguously the next slide.
990 if explicit {
991 place_entry(txn, &roots.slides, "slide", new_id, &Some(id.clone()))?;
992 }
993 }
994 Op::SetSlide {
995 id,
996 layout,
997 template,
998 background,
999 phone,
1000 transition,
1001 } => {
1002 let (_, m) = slide_map(txn, roots, id)?;
1003 if let Some(l) = layout {
1004 let current =
1005 schema::get_string(txn, &m, "layout").unwrap_or_else(|| "flow".into());
1006 if *l != current {
1007 match l.as_str() {
1008 "canvas" => layout::to_canvas(txn, &m),
1009 _ => layout::to_flow(txn, &m),
1010 }
1011 m.insert(txn, "layout", Any::from(l.as_str()));
1012 }
1013 }
1014 if let Some(t) = template {
1015 m.insert(txn, "template", Any::from(t.as_str()));
1016 }
1017 if let Some(b) = background {
1018 m.insert(
1019 txn,
1020 "background",
1021 schema::background_prelim(&b.kind, &b.value),
1022 );
1023 }
1024 if let Some(p) = phone {
1025 m.insert(txn, "phone", Any::from(p.as_str()));
1026 }
1027 if let Some(t) = transition {
1028 m.insert(txn, "transition", Any::from(t.as_str()));
1029 }
1030 }
1031 Op::SetNotes { id, markdown } => {
1032 let (_, m) = slide_map(txn, roots, id)?;
1033 let notes = fragment_of(txn, &m, "notes");
1034 schema::replace_text(txn, &notes, &markdown::parse(markdown));
1035 }
1036 Op::AddBlock {
1037 slide,
1038 id,
1039 kind,
1040 props,
1041 region,
1042 bx,
1043 z,
1044 after,
1045 markdown,
1046 } => {
1047 let (_, s) = slide_map(txn, roots, slide)?;
1048 let blocks = blocks_of(txn, &s);
1049 let at = insert_index(txn, &blocks, after)?;
1050 let canvas = schema::get_string(txn, &s, "layout").as_deref() == Some("canvas");
1051 let mut props = props.clone();
1052 let cells = if kind == "table" {
1053 let rows = table_side(&props, "rows")?.unwrap_or(schema::DEFAULT_TABLE_ROWS);
1054 let cols = table_side(&props, "cols")?.unwrap_or(schema::DEFAULT_TABLE_COLS);
1055 props.insert("rows".into(), Value::from(rows));
1056 props.insert("cols".into(), Value::from(cols));
1057 Some((rows * cols) as usize)
1058 } else {
1059 None
1060 };
1061 let text: Option<Vec<Paragraph>> = if schema::is_text_type(kind) {
1062 Some(
1063 markdown
1064 .as_deref()
1065 .map(|md| text_for(kind, md))
1066 .unwrap_or_default(),
1067 )
1068 } else {
1069 None
1070 };
1071 let region = region
1072 .as_deref()
1073 .unwrap_or_else(|| layout::default_region(kind));
1074 let (region_key, box_value, z_value) = if canvas {
1075 let bx = bx.map(|b| b.json()).unwrap_or_else(|| {
1076 let (x, y, w, h) = layout::region_rect(region, false);
1077 schema::box_json(x, y, w, h)
1078 });
1079 (None, Some(bx), Some(z.unwrap_or(at as f64)))
1080 } else {
1081 (Some(region), None, None)
1082 };
1083 let prelim = schema::block_prelim(
1084 id,
1085 kind,
1086 &props,
1087 region_key,
1088 box_value.as_ref(),
1089 z_value,
1090 text.as_deref(),
1091 cells,
1092 );
1093 let explicit = schema::has_explicit_order(txn, &blocks);
1094 blocks.insert(txn, at, prelim);
1095 // On a slide nobody has reordered the array IS the reading
1096 // order and the insert above already put the block where
1097 // `after` asked. Once the slide carries `order` keys the
1098 // array position means nothing, so the new block is placed
1099 // by renumbering instead (schema.rs, "reading order").
1100 if explicit {
1101 place_new(txn, &blocks, "block", id, after)?;
1102 }
1103 }
1104 Op::RemoveBlock { slide, id } => {
1105 let (_, s) = slide_map(txn, roots, slide)?;
1106 let blocks = blocks_of(txn, &s);
1107 let (i, _) = block_map(txn, &blocks, id)?;
1108 blocks.remove(txn, i);
1109 remove_from_columns_children(txn, &blocks, id);
1110 }
1111 Op::MoveBlock { slide, id, after } => {
1112 let (_, s) = slide_map(txn, roots, slide)?;
1113 let blocks = blocks_of(txn, &s);
1114 block_map(txn, &blocks, id)?;
1115 place_entry(txn, &blocks, "block", id, after)?;
1116 }
1117 Op::SetBlock {
1118 slide,
1119 id,
1120 props,
1121 region,
1122 bx,
1123 z,
1124 } => {
1125 let (_, s) = slide_map(txn, roots, slide)?;
1126 let blocks = blocks_of(txn, &s);
1127 let (_, m) = block_map(txn, &blocks, id)?;
1128 if let Some(p) = props {
1129 let pm = map_of(txn, &m, "props");
1130 for (k, v) in p {
1131 if v.is_null() {
1132 pm.remove(txn, k);
1133 } else {
1134 pm.insert(txn, k.as_str(), schema::json_to_any(v));
1135 }
1136 }
1137 let is_table = schema::get_string(txn, &m, "type").as_deref() == Some("table");
1138 // `cells` is row-major and flat, so growing/shrinking
1139 // only appends to or truncates the end of it -- a
1140 // cols-only change (rows unchanged) reflows every row's
1141 // cells rather than inserting/removing per row, which
1142 // shuffles existing cell text across the grid. Spec
1143 // §2.4 sanctions this as a v1 limitation; a person
1144 // resizing columns should expect to recheck cell
1145 // contents.
1146 if is_table && (p.contains_key("rows") || p.contains_key("cols")) {
1147 let rows = schema::get_number(txn, &pm, "rows")
1148 .unwrap_or(schema::DEFAULT_TABLE_ROWS as f64)
1149 as i64;
1150 let cols = schema::get_number(txn, &pm, "cols")
1151 .unwrap_or(schema::DEFAULT_TABLE_COLS as f64)
1152 as i64;
1153 let want = (rows.max(1) * cols.max(1)) as u32;
1154 let cells = match schema::get_array(txn, &m, "cells") {
1155 Some(c) => c,
1156 None => m.insert(txn, "cells", yrs::ArrayPrelim::default()),
1157 };
1158 let have = cells.len(txn);
1159 if have > want {
1160 cells.remove_range(txn, want, have - want);
1161 }
1162 for _ in have..want {
1163 cells.push_back(txn, XmlFragmentPrelim::default());
1164 }
1165 }
1166 }
1167 match region {
1168 None => {}
1169 Some(None) => {
1170 m.remove(txn, "region");
1171 }
1172 Some(Some(r)) => {
1173 m.insert(txn, "region", Any::from(r.as_str()));
1174 }
1175 }
1176 match bx {
1177 None => {}
1178 Some(None) => {
1179 m.remove(txn, "box");
1180 }
1181 Some(Some(b)) => {
1182 m.insert(txn, "box", schema::json_to_any(&b.json()));
1183 }
1184 }
1185 if let Some(z) = z {
1186 m.insert(txn, "z", Any::Number(*z));
1187 }
1188 }
1189 Op::SetText {
1190 slide,
1191 id,
1192 markdown,
1193 } => {
1194 let (_, s) = slide_map(txn, roots, slide)?;
1195 let blocks = blocks_of(txn, &s);
1196 let (_, m) = block_map(txn, &blocks, id)?;
1197 let kind = schema::get_string(txn, &m, "type").unwrap_or_default();
1198 let text = fragment_of(txn, &m, "text");
1199 schema::replace_text(txn, &text, &text_for(&kind, markdown));
1200 }
1201 Op::SetPhone { slide, id, phone } => {
1202 let (_, s) = slide_map(txn, roots, slide)?;
1203 let blocks = blocks_of(txn, &s);
1204 let (_, m) = block_map(txn, &blocks, id)?;
1205 let pm = map_of(txn, &m, "phone");
1206 pm.clear(txn);
1207 if let Some(h) = phone.hidden {
1208 pm.insert(txn, "hidden", Any::Bool(h));
1209 }
1210 if let Some(o) = phone.order {
1211 pm.insert(txn, "order", Any::Number(o as f64));
1212 }
1213 if let Some(sz) = &phone.size {
1214 pm.insert(txn, "size", Any::from(sz.as_str()));
1215 }
1216 if let Some(b) = &phone.bx {
1217 pm.insert(txn, "box", schema::json_to_any(&b.json()));
1218 }
1219 }
1220 Op::RegisterAsset {
1221 id,
1222 reference,
1223 name,
1224 width,
1225 height,
1226 alt,
1227 } => {
1228 let v = serde_json::json!({
1229 "ref": reference,
1230 "name": name,
1231 "width": width,
1232 "height": height,
1233 "alt": alt,
1234 });
1235 roots
1236 .assets
1237 .insert(txn, id.as_str(), schema::json_to_any(&v));
1238 }
1239 Op::RemoveAsset { id } => {
1240 roots.assets.remove(txn, id);
1241 }
1242 }
1243 Ok(())
1244}
1245
1246/// Applies `ops` (a JSON array of operations) to `doc` in one
1247/// transaction with origin `"ops"`, returning the v1 update that
1248/// transaction produced. On `Err` nothing was written — see the module
1249/// comment for how that is arranged.
1250pub fn apply_ops(doc: &Doc, ops: &str) -> Result<Vec<u8>, CoreError> {
1251 let raw: Vec<Value> = serde_json::from_str(ops)
1252 .map_err(|e| CoreError::Ops(format!("ops must be a JSON array: {e}")))?;
1253 let mut parsed = Vec::with_capacity(raw.len());
1254 for (i, v) in raw.into_iter().enumerate() {
1255 let op: Op =
1256 serde_json::from_value(v).map_err(|e| CoreError::Ops(format!("op {i}: {e}")))?;
1257 parsed.push(op);
1258 }
1259
1260 // Roots first: get_or_insert_* takes its own write transaction.
1261 let roots = schema::roots(doc);
1262 {
1263 let txn = doc.transact();
1264 let mut model = Model::read(&txn, &roots);
1265 for (i, op) in parsed.iter().enumerate() {
1266 model.check(op).map_err(|e| match e {
1267 CoreError::Ops(m) => CoreError::Ops(format!("op {i}: {m}")),
1268 other => other,
1269 })?;
1270 }
1271 }
1272
1273 let mut txn = doc.transact_mut_with("ops");
1274 for (i, op) in parsed.iter().enumerate() {
1275 apply_one(&mut txn, &roots, op).map_err(|e| match e {
1276 CoreError::Ops(m) => CoreError::Ops(format!("op {i} (applying): {m}")),
1277 other => other,
1278 })?;
1279 }
1280 txn.commit();
1281 Ok(txn.encode_update_v1())
1282}
1283