1mod 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
26const MAX_BLOCK_DEPTH: usize = 20;
28
29#[derive(Debug, Clone, PartialEq)]
31pub struct PageContentResult {
32 pub page: PageDocument,
33 pub other_pages: Vec<PageDocument>,
35 pub created: Vec<CreatedIds>,
36 pub labels: BTreeMap<String, Uuid>,
37 pub warnings: Vec<Warning>,
38 pub moved_between_pages: Vec<BlockPageMove>,
41 pub exercise_copies: Vec<ExerciseCopy>,
43}
44
45#[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
53fn 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
77pub 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
98pub 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 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 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 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 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 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 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 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 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 &"),
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 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
898const 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;