Skip to main content

headless_lms_mcp/engine/page_content/
mod.rs

1//! `patch_page_content`: block-tree edits on one page, with cross-page `move` and `copy`.
2
3mod address;
4mod pointer;
5mod replace_list;
6
7use std::collections::{BTreeMap, HashMap, HashSet};
8
9use headless_lms_utils::document_schema_processor::{GutenbergBlock, all_client_ids};
10use serde::Serialize;
11use serde_json::{Value, json};
12use uuid::Uuid;
13
14use self::address::{Address, Anchor, ListRef, Loc, Range};
15use self::pointer::{PointerWrite, pointer_remove, pointer_write};
16use self::replace_list::{ListEntry, parse_list_entries, parse_list_entry, parse_new_block};
17use super::blocks::{self, ExerciseCopy, is_exercise, is_exercise_part};
18use super::error::{EditError, EditErrorCode, Warning, WarningCode};
19use super::labels::Labels;
20use super::op::{Op, PatchTarget, Position, Transfer};
21use super::path::{ItemRef, parse_item_ref};
22use super::{CreatedIdLog, CreatedIds, PageDocument, value_items};
23use crate::ids::IdGenerator;
24use crate::text;
25
26/// Deeper trees stop loading: JSON nests two levels per block, and decoding stops at 128.
27const MAX_BLOCK_DEPTH: usize = 20;
28
29/// The new state after a successful call. Nothing is stored until the caller stages it.
30#[derive(Debug, Clone, PartialEq)]
31pub struct PageContentResult {
32    pub page: PageDocument,
33    /// Other pages the call changed through `to_page`.
34    pub other_pages: Vec<PageDocument>,
35    pub created: Vec<CreatedIds>,
36    pub labels: BTreeMap<String, Uuid>,
37    pub warnings: Vec<Warning>,
38    /// Blocks (with all their descendants) that now live on another page; their feedback and
39    /// proposed edits must follow them.
40    pub moved_between_pages: Vec<BlockPageMove>,
41    /// Exercise blocks a `copy` duplicated; the caller creates the new exercise from the source.
42    pub exercise_copies: Vec<ExerciseCopy>,
43}
44
45/// A block now on another page; a moved block's descendants are listed too.
46#[derive(Debug, Clone, PartialEq, Serialize)]
47pub struct BlockPageMove {
48    pub block_id: Uuid,
49    pub from_page: Uuid,
50    pub to_page: Uuid,
51}
52
53/// Pages named by `to_page`.
54fn referenced_pages(ops: &[Op]) -> Result<Vec<Uuid>, EditError> {
55    let mut pages = Vec::new();
56    for (index, op) in ops.iter().enumerate() {
57        if let Some(raw) = op.to_page() {
58            match parse_item_ref(raw).map_err(|e| e.at_op(index))? {
59                ItemRef::Id(id) => {
60                    if !pages.contains(&id) {
61                        pages.push(id);
62                    }
63                }
64                ItemRef::Label(_) => {
65                    return Err(EditError::new(
66                        EditErrorCode::InvalidValue,
67                        "to_page takes a page id; pages are created with patch_course_structure",
68                    )
69                    .at_op(index));
70                }
71            }
72        }
73    }
74    Ok(pages)
75}
76
77/// Applies `ops` in order to `page`. `load_page` gives the page a `to_page` names, `None` when the
78/// course has no such page. Invariants are checked once, after the last op.
79pub fn apply(
80    page: PageDocument,
81    load_page: &dyn Fn(Uuid) -> Option<PageDocument>,
82    ops: &[Op],
83    ids: &dyn IdGenerator,
84) -> Result<PageContentResult, EditError> {
85    let mut editor = Editor::new(page, load_page, ops, ids)?;
86    for (index, op) in ops.iter().enumerate() {
87        editor.ids.set_op(index);
88        let first_warning = editor.warnings.len();
89        editor.apply_op(op).map_err(|e| e.at_op(index))?;
90        for warning in &mut editor.warnings[first_warning..] {
91            warning.op = Some(index);
92        }
93    }
94    editor.check_invariants()?;
95    Ok(editor.finish())
96}
97
98/// Resolves `$label` path roots in a selector path after a call, for `read_back`.
99pub fn resolve_label_path(
100    path: &str,
101    labels: &BTreeMap<String, Uuid>,
102) -> Result<String, EditError> {
103    let Some(rest) = path.strip_prefix("/$").or_else(|| path.strip_prefix('$')) else {
104        return Ok(path.to_string());
105    };
106    let (label, tail) = match rest.find('/') {
107        Some(slash) => (&rest[..slash], &rest[slash..]),
108        None => (rest, ""),
109    };
110    let label = format!("${label}");
111    labels
112        .get(&label)
113        .map(|id| format!("/{id}{tail}"))
114        .ok_or_else(|| {
115            EditError::new(
116                EditErrorCode::LabelUndefined,
117                format!("read_back uses {label}, which no op in this call defines"),
118            )
119        })
120}
121
122struct Editor<'c> {
123    /// The edited page first, then the `to_page`s.
124    pages: Vec<PageDocument>,
125    labels: Labels,
126    removed: HashMap<Uuid, usize>,
127    ids: CreatedIdLog<'c>,
128    warnings: Vec<Warning>,
129    moves: Vec<BlockPageMove>,
130    exercise_copies: Vec<ExerciseCopy>,
131    /// Whether the current op may find blocks on the `to_page`s besides the edited page.
132    can_reach_other_pages: bool,
133}
134
135impl<'c> Editor<'c> {
136    fn new(
137        page: PageDocument,
138        load_page: &dyn Fn(Uuid) -> Option<PageDocument>,
139        ops: &[Op],
140        ids: &'c dyn IdGenerator,
141    ) -> Result<Self, EditError> {
142        let mut pages = vec![page];
143        for page_id in referenced_pages(ops)? {
144            if page_id == pages[0].page_id {
145                continue;
146            }
147            let to_page = load_page(page_id).ok_or_else(|| {
148                EditError::new(
149                    EditErrorCode::NotFound,
150                    format!("to_page {page_id} isn't a page of this course"),
151                )
152                .with_fix("get_course_structure lists the course's page ids")
153            })?;
154            pages.push(to_page);
155        }
156        Ok(Self {
157            pages,
158            labels: Labels::prescan(ops),
159            removed: HashMap::new(),
160            ids: CreatedIdLog::new(ids),
161            warnings: Vec::new(),
162            moves: Vec::new(),
163            exercise_copies: Vec::new(),
164            can_reach_other_pages: false,
165        })
166    }
167
168    fn finish(mut self) -> PageContentResult {
169        let pages = &self.pages;
170        self.moves.retain(|moved| {
171            pages.iter().any(|doc| {
172                doc.page_id == moved.to_page
173                    && blocks::find_path(&doc.blocks, moved.block_id).is_some()
174            })
175        });
176        let page = self.pages.remove(0);
177        PageContentResult {
178            page,
179            // Each to_page got blocks from a move or copy, so all of them changed.
180            other_pages: self.pages,
181            created: self.ids.into_created(),
182            labels: self.labels.into_map(),
183            warnings: self.warnings,
184            moved_between_pages: self.moves,
185            exercise_copies: self.exercise_copies,
186        }
187    }
188
189    fn apply_op(&mut self, op: &Op) -> Result<(), EditError> {
190        self.can_reach_other_pages = matches!(op, Op::Move(_) | Op::Copy { .. });
191        match op {
192            Op::Add {
193                path,
194                value,
195                position,
196            } => self.add(path, value, *position),
197            Op::Remove { path, through } => self.remove(path, through.as_deref()),
198            Op::Replace { path, value } => self.replace(path, value),
199            Op::Move(transfer) => self.move_range(transfer),
200            Op::Copy { transfer, label } => self.copy_range(transfer, label.as_deref()),
201            Op::Wrap {
202                path,
203                through,
204                value,
205                label,
206            } => self.wrap(path, through.as_deref(), value, label.as_deref()),
207            Op::Unwrap { path } => self.unwrap(path),
208            Op::Split {
209                path,
210                at_text,
211                label,
212            } => self.split(path, at_text, label.as_deref()),
213            Op::Merge { path, from } => self.merge(path, from),
214            Op::Reorder { .. } => Err(PatchTarget::PageContent.rejected_op(op.kind())),
215        }
216    }
217
218    fn block(&self, loc: &Loc) -> Result<&GutenbergBlock, EditError> {
219        blocks::get(&self.pages[loc.page].blocks, &loc.path)
220    }
221
222    fn block_mut(&mut self, loc: &Loc) -> Result<&mut GutenbergBlock, EditError> {
223        blocks::get_mut(&mut self.pages[loc.page].blocks, &loc.path)
224    }
225
226    fn page_id(&self, page: usize) -> Uuid {
227        self.pages[page].page_id
228    }
229
230    fn page_index(&self, page_id: Uuid) -> usize {
231        self.pages
232            .iter()
233            .position(|p| p.page_id == page_id)
234            .unwrap_or(0)
235    }
236
237    fn siblings(&self, page: usize, parent_path: &[usize]) -> Vec<String> {
238        blocks::list(&self.pages[page].blocks, parent_path)
239            .map(|list| {
240                list.iter()
241                    .take(50)
242                    .map(|b| format!("/{}", b.client_id))
243                    .collect()
244            })
245            .unwrap_or_default()
246    }
247
248    /// Fails when `loc` is an exercise block's inside, or (with `include_self`) the exercise block.
249    fn check_not_in_exercise(
250        &self,
251        loc: &Loc,
252        include_self: bool,
253        action: &str,
254    ) -> Result<(), EditError> {
255        if blocks::is_in_exercise(&self.pages[loc.page].blocks, &loc.path, include_self)? {
256            return Err(exercise_read_only(action));
257        }
258        Ok(())
259    }
260
261    fn insert(&mut self, anchor: Anchor, new_blocks: Vec<GutenbergBlock>) -> Result<(), EditError> {
262        let (page, parent_path, index) = self.resolve_anchor(anchor)?;
263        let list = blocks::list_mut(&mut self.pages[page].blocks, &parent_path)?;
264        for (offset, block) in new_blocks.into_iter().enumerate() {
265            list.insert(index + offset, block);
266        }
267        Ok(())
268    }
269
270    fn detach(&mut self, range: &Range) -> Result<Vec<GutenbergBlock>, EditError> {
271        let list = blocks::list_mut(&mut self.pages[range.page].blocks, &range.parent_path)?;
272        if range.end >= list.len() {
273            return Err(blocks::stale_location());
274        }
275        Ok(list.drain(range.start..=range.end).collect())
276    }
277
278    fn mark_removed(&mut self, removed: &[GutenbergBlock]) {
279        for id in all_client_ids(removed) {
280            self.removed.insert(id, self.ids.op());
281        }
282    }
283
284    /// The block `path` names; any other address fails with `message`.
285    fn block_at(&self, path: &str, root_page: usize, message: &str) -> Result<Uuid, EditError> {
286        match self.address(path, root_page)? {
287            Address::Block(id) => Ok(id),
288            _ => Err(EditError::new(EditErrorCode::InvalidPath, message)),
289        }
290    }
291
292    fn through(&self, through: Option<&str>) -> Result<Option<Uuid>, EditError> {
293        self.labels
294            .resolve_optional_item_ref(through, self.ids.op())
295    }
296
297    /// A new block from an `add` or `wrap` value.
298    fn new_block(&mut self, value: &Value) -> Result<GutenbergBlock, EditError> {
299        let entry = parse_new_block(value)?;
300        self.build_entry(entry, &HashMap::new())
301    }
302
303    fn add(
304        &mut self,
305        path: &str,
306        value: &Value,
307        position: Option<Position>,
308    ) -> Result<(), EditError> {
309        let address = self.address(path, 0)?;
310        if let Address::Attribute(id, pointer) = &address {
311            if position.is_some() {
312                return Err(EditError::new(
313                    EditErrorCode::FieldNotAllowed,
314                    "position places blocks; inside attributes, add follows JSON Pointer (an array index or \"-\" to append)",
315                ));
316            }
317            return self.set_attribute(*id, pointer, value.clone(), PointerWrite::Add);
318        }
319        if matches!(address, Address::Name(_)) {
320            return Err(EditError::new(
321                EditErrorCode::InvalidPath,
322                "a block's name is changed with replace",
323            )
324            .with_fix(format!(
325                "{{\"op\":\"replace\",\"path\":\"{path}\",\"value\":\"core/heading\"}}"
326            )));
327        }
328        let anchor = self.anchor(&address, position)?;
329        let new_blocks = value_items(value, "a block")?
330            .into_iter()
331            .map(|item| self.new_block(item))
332            .collect::<Result<Vec<_>, _>>()?;
333        self.insert(anchor, new_blocks)
334    }
335
336    fn remove(&mut self, path: &str, through: Option<&str>) -> Result<(), EditError> {
337        match self.address(path, 0)? {
338            Address::Block(id) => {
339                let through = self.through(through)?;
340                let range = self.range(id, through)?;
341                self.check_not_in_exercise(&range.first(), false, "remove blocks inside it")?;
342                let removed = self.detach(&range)?;
343                self.mark_removed(&removed);
344                Ok(())
345            }
346            Address::Attribute(id, pointer) => {
347                if through.is_some() {
348                    return Err(EditError::new(
349                        EditErrorCode::FieldNotAllowed,
350                        "through names a block range; it doesn't apply to attributes",
351                    ));
352                }
353                if pointer.is_empty() {
354                    return Err(EditError::new(
355                        EditErrorCode::InvalidPath,
356                        "a block always has attributes; remove single attributes, e.g. /<id>/attributes/anchor",
357                    ));
358                }
359                let loc = self.require(id)?;
360                self.check_not_in_exercise(&loc, true, "change its attributes")?;
361                let block = self.block_mut(&loc)?;
362                pointer_remove(&mut block.attributes, &pointer)
363            }
364            Address::Name(_) => Err(EditError::new(
365                EditErrorCode::InvalidPath,
366                "a block's name can't be removed; replace it, or remove the block",
367            )),
368            address @ (Address::Root(_) | Address::Children(_)) => {
369                let (list, description): (&[GutenbergBlock], String) = match address {
370                    Address::Children(id) => {
371                        let loc = self.require(id)?;
372                        (
373                            &self.block(&loc)?.inner_blocks,
374                            format!("/{id}/innerBlocks"),
375                        )
376                    }
377                    _ => (&self.pages[0].blocks, "the page".to_string()),
378                };
379                let fix = match (list.first(), list.last()) {
380                    (Some(first), Some(last)) => format!(
381                        "{{\"op\":\"remove\",\"path\":\"/{}\",\"through\":\"/{}\"}}",
382                        first.client_id, last.client_id
383                    ),
384                    _ => "the list is already empty".to_string(),
385                };
386                Err(EditError::new(
387                    EditErrorCode::InvalidPath,
388                    format!(
389                        "remove takes a block or a range, not the children list of {description}"
390                    ),
391                )
392                .with_fix(fix))
393            }
394        }
395    }
396
397    fn replace(&mut self, path: &str, value: &Value) -> Result<(), EditError> {
398        match self.address(path, 0)? {
399            Address::Root(page) => {
400                let entries = parse_list_entries(value, "the page")?;
401                self.replace_list(ListRef { page, parent: None }, entries)
402            }
403            Address::Children(id) => {
404                let loc = self.require(id)?;
405                self.check_not_in_exercise(&loc, true, "change what is inside it")?;
406                let entries = parse_list_entries(value, &format!("/{id}/innerBlocks"))?;
407                self.replace_list(
408                    ListRef {
409                        page: loc.page,
410                        parent: Some(id),
411                    },
412                    entries,
413                )
414            }
415            Address::Block(id) => self.replace_block(id, value),
416            Address::Name(id) => {
417                let loc = self.require(id)?;
418                self.check_not_in_exercise(&loc, true, "change its type")?;
419                let Value::String(name) = value else {
420                    return Err(EditError::new(
421                        EditErrorCode::InvalidValue,
422                        "a block name is a string such as \"core/heading\"",
423                    ));
424                };
425                check_creatable(name)?;
426                self.block_mut(&loc)?.name = name.clone();
427                Ok(())
428            }
429            Address::Attribute(id, pointer) => {
430                self.set_attribute(id, &pointer, value.clone(), PointerWrite::Replace)
431            }
432        }
433    }
434
435    fn set_attribute(
436        &mut self,
437        id: Uuid,
438        pointer: &str,
439        value: Value,
440        mode: PointerWrite,
441    ) -> Result<(), EditError> {
442        let loc = self.require(id)?;
443        self.check_not_in_exercise(&loc, true, "change its attributes")?;
444        let block = self.block_mut(&loc)?;
445        if pointer.is_empty() {
446            let Value::Object(attributes) = value else {
447                return Err(EditError::new(
448                    EditErrorCode::InvalidValue,
449                    "attributes is an object",
450                ));
451            };
452            block.attributes = attributes;
453            return Ok(());
454        }
455        pointer_write(&mut block.attributes, pointer, value, mode)
456    }
457
458    fn replace_block(&mut self, id: Uuid, value: &Value) -> Result<(), EditError> {
459        let loc = self.require(id)?;
460        self.check_not_in_exercise(&loc, true, "replace it")?;
461        let object = match value {
462            Value::Object(object) => object,
463            Value::Array(_) => {
464                return Err(EditError::new(
465                    EditErrorCode::InvalidValue,
466                    "replace on a block takes one block; to replace it with several, add them after it and remove it",
467                )
468                .with_fix(format!(
469                    "[{{\"op\":\"add\",\"path\":\"/{id}\",\"position\":\"after\",\"value\":[…]}},{{\"op\":\"remove\",\"path\":\"/{id}\"}}]"
470                )));
471            }
472            _ => {
473                return Err(EditError::new(
474                    EditErrorCode::InvalidValue,
475                    "replace on a block takes a block object",
476                ));
477            }
478        };
479        if let Some(sent) = object.get("clientId")
480            && sent.as_str() != Some(id.to_string().as_str())
481        {
482            return Err(EditError::new(
483                EditErrorCode::InvalidValue,
484                format!("the value's clientId {sent} differs from the path's block {id}"),
485            )
486            .with_fix("leave clientId out of the value; replace keeps the block's id"));
487        }
488        let mut with_id = object.clone();
489        with_id.insert("clientId".to_string(), Value::String(id.to_string()));
490        let replacement = parse_list_entry(&Value::Object(with_id))?;
491        let parent = if loc.path.len() == 1 {
492            None
493        } else {
494            Some(blocks::get(&self.pages[loc.page].blocks, loc.parent_path())?.client_id)
495        };
496        let mut entries: Vec<ListEntry> =
497            blocks::list(&self.pages[loc.page].blocks, loc.parent_path())?
498                .iter()
499                .map(|b| ListEntry::Stub(b.client_id))
500                .collect();
501        let entry = entries
502            .get_mut(loc.index())
503            .ok_or_else(blocks::stale_location)?;
504        *entry = replacement;
505        self.replace_list(
506            ListRef {
507                page: loc.page,
508                parent,
509            },
510            entries,
511        )
512    }
513
514    /// The range a `move` or `copy` takes from the edited page, the index of the page it goes to,
515    /// and where there. `verb` names the op in errors.
516    fn resolve_transfer(
517        &self,
518        transfer: &Transfer,
519        verb: &str,
520    ) -> Result<(Range, usize, Anchor), EditError> {
521        let from = self.block_at(
522            &transfer.from,
523            0,
524            &format!(
525                "from must name a block, like \"/<id>\"; got \"{}\"",
526                transfer.from
527            ),
528        )?;
529        let through = self.through(transfer.through.as_deref())?;
530        let range = self.range(from, through)?;
531        if range.page != 0 {
532            return Err(EditError::new(
533                EditErrorCode::InvalidPath,
534                "from and through name blocks on the page being edited; edit the other page with its own call",
535            ));
536        }
537        let target_page = match &transfer.to_page {
538            Some(raw) => self.page_index(self.labels.resolve_item_ref(raw, self.ids.op())?),
539            None => 0,
540        };
541        let address = self.address(&transfer.path, target_page)?;
542        let anchor = self.anchor(&address, Some(transfer.position))?;
543        if self.anchor_page(anchor)? != target_page {
544            return Err(EditError::new(
545                EditErrorCode::InvalidPath,
546                format!("path {} is on another page than the target", transfer.path),
547            )
548            .with_fix("set to_page to the page that holds path, or use a path on this page"));
549        }
550        self.check_not_in_exercise(&range.first(), false, &format!("{verb} blocks out of it"))?;
551        Ok((range, target_page, anchor))
552    }
553
554    /// Fails when `anchor` would put an exercise block of `range` below the top level.
555    fn check_exercises_stay_top_level(
556        &self,
557        range: &Range,
558        anchor: Anchor,
559    ) -> Result<(), EditError> {
560        let (_, target_parent_path, _) = self.resolve_anchor(anchor)?;
561        if !target_parent_path.is_empty() && self.range_blocks(range)?.iter().any(is_exercise) {
562            return Err(exercise_must_stay_top_level());
563        }
564        Ok(())
565    }
566
567    fn move_range(&mut self, transfer: &Transfer) -> Result<(), EditError> {
568        let (range, target_page, anchor) = self.resolve_transfer(transfer, "move")?;
569        let moved_ids: HashSet<Uuid> = all_client_ids(self.range_blocks(&range)?)
570            .into_iter()
571            .collect();
572        if let Some(anchor_id) = anchor.target_id(|list| list.parent)
573            && moved_ids.contains(&anchor_id)
574        {
575            return Err(EditError::new(
576                EditErrorCode::MoveIntoItself,
577                format!(
578                    "can't move into or next to {anchor_id}: it is part of what is being moved"
579                ),
580            )
581            .with_fix("choose a target outside the moved block or range"));
582        }
583        self.check_exercises_stay_top_level(&range, anchor)?;
584        let detached = self.detach(&range)?;
585        if target_page != range.page {
586            let (from_page, to_page) = (self.page_id(range.page), self.page_id(target_page));
587            for id in all_client_ids(&detached) {
588                self.moves.push(BlockPageMove {
589                    block_id: id,
590                    from_page,
591                    to_page,
592                });
593            }
594        }
595        self.insert(anchor, detached)
596    }
597
598    fn copy_range(&mut self, transfer: &Transfer, label: Option<&str>) -> Result<(), EditError> {
599        let (range, target_page, anchor) = self.resolve_transfer(transfer, "copy")?;
600        self.check_exercises_stay_top_level(&range, anchor)?;
601        let (copies, exercise_copies) = blocks::copy_blocks(
602            self.range_blocks(&range)?,
603            self.ids.generator(),
604            self.page_id(target_page),
605        );
606        for id in all_client_ids(&copies) {
607            self.ids.record(id);
608        }
609        for copy in &exercise_copies {
610            self.warnings.push(Warning::new(
611                WarningCode::ExerciseCopied,
612                format!(
613                    "the copy of exercise {} is a new exercise {} with no submissions",
614                    copy.source_exercise_id, copy.new_exercise_id
615                ),
616            ));
617        }
618        self.exercise_copies.extend(exercise_copies);
619        if let (Some(label), Some(first)) = (label, copies.first()) {
620            self.labels.define(label, first.client_id)?;
621        }
622        self.insert(anchor, copies)
623    }
624
625    fn wrap(
626        &mut self,
627        path: &str,
628        through: Option<&str>,
629        value: &Value,
630        label: Option<&str>,
631    ) -> Result<(), EditError> {
632        let first = self.block_at(
633            path,
634            0,
635            "wrap takes a block path (and optionally through) for the blocks to wrap",
636        )?;
637        let through = self.through(through)?;
638        let range = self.range(first, through)?;
639        self.check_not_in_exercise(&range.first(), false, "wrap blocks inside it")?;
640        if self.range_blocks(&range)?.iter().any(is_exercise) {
641            return Err(exercise_must_stay_top_level());
642        }
643        let object = value.as_object().ok_or_else(|| {
644            EditError::new(
645                EditErrorCode::InvalidValue,
646                "wrap's value is the container block, e.g. {\"name\":\"moocfi/infobox\"}",
647            )
648        })?;
649        if object
650            .get("innerBlocks")
651            .and_then(Value::as_array)
652            .is_some_and(|a| !a.is_empty())
653        {
654            return Err(EditError::new(
655                EditErrorCode::InvalidValue,
656                "wrap's container gets the wrapped blocks as its children; leave innerBlocks out",
657            ));
658        }
659        if object.contains_key("label") {
660            return Err(EditError::new(
661                EditErrorCode::FieldNotAllowed,
662                "wrap names its container with the op's label, not one inside value",
663            )
664            .with_fix("{\"op\":\"wrap\",\"path\":\"/p1\",\"value\":{\"name\":\"moocfi/infobox\"},\"label\":\"$box\"}"));
665        }
666        let mut container_value = object.clone();
667        container_value.remove("innerBlocks");
668        let mut container = self.new_block(&Value::Object(container_value))?;
669        if let Some(label) = label {
670            self.labels.define(label, container.client_id)?;
671        }
672        container.inner_blocks = self.detach(&range)?;
673        blocks::list_mut(&mut self.pages[range.page].blocks, &range.parent_path)?
674            .insert(range.start, container);
675        Ok(())
676    }
677
678    fn unwrap(&mut self, path: &str) -> Result<(), EditError> {
679        let id = self.block_at(path, 0, "unwrap takes a block path")?;
680        let loc = self.require(id)?;
681        self.check_not_in_exercise(&loc, true, "unwrap it")?;
682        if self.block(&loc)?.inner_blocks.is_empty() {
683            return Err(EditError::new(
684                EditErrorCode::InvalidValue,
685                format!("/{id} has no inner blocks to unwrap"),
686            )
687            .with_fix(format!("{{\"op\":\"remove\",\"path\":\"/{id}\"}}")));
688        }
689        let list = blocks::list_mut(&mut self.pages[loc.page].blocks, loc.parent_path())?;
690        let container = list.remove(loc.index());
691        self.removed.insert(container.client_id, self.ids.op());
692        for (offset, child) in container.inner_blocks.into_iter().enumerate() {
693            list.insert(loc.index() + offset, child);
694        }
695        Ok(())
696    }
697
698    fn split(&mut self, path: &str, at_text: &str, label: Option<&str>) -> Result<(), EditError> {
699        let id = self.block_at(path, 0, "split takes a block path")?;
700        if at_text.is_empty() {
701            return Err(EditError::new(
702                EditErrorCode::InvalidValue,
703                "at_text can't be empty",
704            ));
705        }
706        let loc = self.require(id)?;
707        self.check_not_in_exercise(&loc, true, "split it")?;
708        let block = self.block(&loc)?;
709        let content = text_content(block)?.to_string();
710        if !block.inner_blocks.is_empty() {
711            return Err(EditError::new(
712                EditErrorCode::NotATextBlock,
713                format!("/{id} has inner blocks; split works on blocks without children"),
714            ));
715        }
716        let positions: Vec<usize> = content
717            .char_indices()
718            .map(|(i, _)| i)
719            .filter(|i| content[*i..].starts_with(at_text))
720            .collect();
721        let at = match positions.as_slice() {
722            [] => {
723                let in_plain_text = text::plain_text(&content).contains(at_text);
724                let mut error = EditError::new(
725                    EditErrorCode::TextNotFound,
726                    format!("\"{at_text}\" doesn't occur in /{id}"),
727                )
728                .with_details(json!({"content": content}));
729                if in_plain_text {
730                    error = error.with_fix("at_text matches the stored HTML, markup included; copy it from the content shown in details");
731                }
732                return Err(error);
733            }
734            [single] => *single,
735            many => {
736                return Err(EditError::new(
737                    EditErrorCode::TextAmbiguous,
738                    format!(
739                        "\"{at_text}\" occurs {} times in /{id}; split needs text that occurs once",
740                        many.len()
741                    ),
742                )
743                .with_fix("make at_text longer so it matches one place"));
744            }
745        };
746        if at == 0 {
747            return Err(EditError::new(
748                EditErrorCode::InvalidValue,
749                format!("\"{at_text}\" starts the block, so the first part would be empty"),
750            ));
751        }
752        if text::is_inside_tag(&content, at) {
753            return Err(EditError::new(
754                EditErrorCode::SplitInsideMarkup,
755                format!("\"{at_text}\" is inside an HTML tag"),
756            ));
757        }
758        if text::is_inside_character_reference(&content, at) {
759            return Err(EditError::new(
760                EditErrorCode::SplitInsideMarkup,
761                format!("\"{at_text}\" starts inside a character reference such as &amp;"),
762            )
763            .with_fix("start at_text at the & or after the ;"));
764        }
765        let open = text::open_tags_at(&content, at);
766        if !open.is_empty() {
767            return Err(EditError::new(
768                EditErrorCode::SplitInsideMarkup,
769                format!(
770                    "\"{at_text}\" is inside <{}>; splitting there would break the formatting",
771                    open.join(">, <")
772                ),
773            )
774            .with_fix("split outside the formatted text, or remove the formatting first"));
775        }
776        let first_part = content[..at].trim_end().to_string();
777        let second_part = content[at..].to_string();
778        let mut second = block.clone();
779        let new_id = self.ids.new_id();
780        second.client_id = new_id;
781        // An anchor is the block's HTML id, which must stay unique on the page.
782        second.attributes.remove("anchor");
783        second
784            .attributes
785            .insert("content".to_string(), Value::String(second_part));
786        if let Some(label) = label {
787            self.labels.define(label, new_id)?;
788        }
789        self.block_mut(&loc)?
790            .attributes
791            .insert("content".to_string(), Value::String(first_part));
792        let list = blocks::list_mut(&mut self.pages[loc.page].blocks, loc.parent_path())?;
793        list.insert(loc.index() + 1, second);
794        Ok(())
795    }
796
797    fn merge(&mut self, path: &str, from: &str) -> Result<(), EditError> {
798        let target = self.block_at(path, 0, "merge's path is the block that stays")?;
799        let source = self.labels.resolve_item_ref(from, self.ids.op())?;
800        let target_loc = self.require(target)?;
801        let source_loc = self.require(source)?;
802        self.check_not_in_exercise(&target_loc, true, "merge into it")?;
803        self.check_not_in_exercise(&source_loc, true, "merge it")?;
804        let adjacent = target_loc.page == source_loc.page
805            && target_loc.parent_path() == source_loc.parent_path()
806            && source_loc.index() == target_loc.index() + 1;
807        if !adjacent {
808            return Err(EditError::new(
809                EditErrorCode::MergeNotAdjacent,
810                format!("merge joins a block (from) into the block right before it (path); /{source} isn't right after /{target}"),
811            )
812            .with_details(json!({"siblings_of_path": self.siblings(target_loc.page, target_loc.parent_path())})));
813        }
814        let target_block = self.block(&target_loc)?;
815        let source_block = self.block(&source_loc)?;
816        if target_block.name != source_block.name {
817            return Err(EditError::new(
818                EditErrorCode::NotATextBlock,
819                format!(
820                    "merge joins blocks of the same type; /{target} is {} and /{source} is {}",
821                    target_block.name, source_block.name
822                ),
823            ));
824        }
825        let first = text_content(target_block)?;
826        let second = text_content(source_block)?;
827        if !source_block.inner_blocks.is_empty() {
828            return Err(EditError::new(
829                EditErrorCode::NotATextBlock,
830                format!("/{source} has inner blocks; merge works on blocks without children"),
831            ));
832        }
833        let separator = if first.ends_with(char::is_whitespace)
834            || second.starts_with(char::is_whitespace)
835            || first.is_empty()
836        {
837            ""
838        } else {
839            " "
840        };
841        let merged = format!("{first}{separator}{second}");
842        self.block_mut(&target_loc)?
843            .attributes
844            .insert("content".to_string(), Value::String(merged));
845        let list = blocks::list_mut(
846            &mut self.pages[source_loc.page].blocks,
847            source_loc.parent_path(),
848        )?;
849        let removed = list.remove(source_loc.index());
850        self.removed.insert(removed.client_id, self.ids.op());
851        Ok(())
852    }
853
854    fn check_invariants(&self) -> Result<(), EditError> {
855        let mut seen: HashMap<Uuid, Uuid> = HashMap::new();
856        for doc in &self.pages {
857            for id in all_client_ids(&doc.blocks) {
858                if let Some(other_page) = seen.insert(id, doc.page_id) {
859                    return Err(EditError::new(
860                        EditErrorCode::DuplicateIds,
861                        format!(
862                            "block id {id} would appear twice (pages {other_page} and {})",
863                            doc.page_id
864                        ),
865                    ));
866                }
867            }
868        }
869        for doc in &self.pages {
870            let mut nested_exercises = Vec::new();
871            let mut too_deep = None;
872            blocks::walk(&doc.blocks, &mut |block, path| {
873                if path.len() > MAX_BLOCK_DEPTH && too_deep.is_none() {
874                    too_deep = Some(block.client_id);
875                }
876                if is_exercise(block) && path.len() > 1 {
877                    nested_exercises.push(block.client_id.to_string());
878                }
879            });
880            if let Some(block) = too_deep {
881                return Err(EditError::new(
882                    EditErrorCode::InvariantViolated,
883                    format!(
884                        "block {block} would be nested more than {MAX_BLOCK_DEPTH} levels deep"
885                    ),
886                )
887                .with_fix("unwrap some of the containers around it"));
888            }
889            if !nested_exercises.is_empty() {
890                return Err(exercise_must_stay_top_level()
891                    .with_details(json!({"exercise_blocks": nested_exercises})));
892            }
893        }
894        Ok(())
895    }
896}
897
898/// Raw HTML, synced patterns and the editor's placeholder.
899const NOT_WRITABLE_BLOCKS: [&str; 3] = ["core/html", "core/block", "moocfi/unsupported-block-type"];
900
901fn check_creatable(name: &str) -> Result<(), EditError> {
902    if is_exercise_part(name) {
903        return Err(EditError::new(
904            EditErrorCode::ExerciseReadOnly,
905            "exercises can't be created with page edits, and exercise blocks can't be written into instructions; an existing exercise block can be moved, copied or removed",
906        )
907        .with_fix("patch_exercise edits an existing exercise's name and instructions; the teacher creates exercises in the editor"));
908    }
909    if NOT_WRITABLE_BLOCKS.contains(&name) {
910        return Err(EditError::new(
911            EditErrorCode::InvalidValue,
912            format!("agents can't write {name}; the guide tool lists the blocks a page takes"),
913        ));
914    }
915    Ok(())
916}
917
918fn text_content(block: &GutenbergBlock) -> Result<&str, EditError> {
919    block.attributes.get("content").and_then(Value::as_str).ok_or_else(|| {
920        EditError::new(
921            EditErrorCode::NotATextBlock,
922            format!("/{} ({}) has no text content attribute; split and merge work on text blocks such as core/paragraph and core/heading", block.client_id, block.name),
923        )
924    })
925}
926
927fn exercise_read_only(action: &str) -> EditError {
928    EditError::new(
929        EditErrorCode::ExerciseReadOnly,
930        format!("exercise blocks can't be edited with page edits, so you can't {action}"),
931    )
932    .with_fix("an exercise block can only be moved, copied or removed here; patch_exercise edits the exercise's name, task instructions and review instructions, and the teacher edits its other settings in the editor")
933}
934
935fn exercise_must_stay_top_level() -> EditError {
936    EditError::new(
937        EditErrorCode::InvariantViolated,
938        "exercise blocks stay at the top level of a page; they can't be wrapped or nested",
939    )
940    .with_fix("place exercise blocks with path \"\" or before/after a top-level block")
941}
942
943#[cfg(test)]
944mod tests;