1use std::collections::{BTreeMap, HashMap, HashSet};
4
5use headless_lms_utils::document_schema_processor::GutenbergBlock;
6use serde_json::{Map, Value, json};
7use uuid::Uuid;
8
9use super::fields::{
10 COUNT_FIELDS, DERIVED_FIELDS, check_field, field_error, not_nullable, optional_color,
11 optional_date, required_text, unknown_field,
12};
13use super::model::{
14 CourseStructure, Entity, EntityRef, Kind, ListRef, Loc, RENDERED_KEYS, StructureChapter,
15 StructureModule, StructurePage, entity_ids, parse_structure_path,
16};
17use super::paths::{PathRequests, assign_paths, path_changes};
18use super::{ChapterRenumber, PageCopy, StructureResult};
19use crate::engine::blocks::content_counts;
20use crate::engine::error::{EditError, EditErrorCode, Warning, WarningCode};
21use crate::engine::labels::{Labels, label_field};
22use crate::engine::op::{Op, PatchTarget, Position, Transfer};
23use crate::engine::page_content;
24use crate::engine::{CreatedIdLog, PageDocument, PageType, non_empty_string, value_items};
25use crate::ids::IdGenerator;
26
27#[derive(Debug, Clone)]
28enum Address {
29 Course,
30 List(ListRef),
31 Entity(Uuid),
32 Field(Uuid, String),
33}
34
35type Anchor = crate::engine::Anchor<ListRef>;
36
37pub(super) struct StructureEditor<'c> {
38 doc: CourseStructure,
39 labels: Labels,
40 removed: HashMap<Uuid, usize>,
41 pub(super) ids: CreatedIdLog<'c>,
42 pub(super) warnings: Vec<Warning>,
43 paths: PathRequests,
44 new_pages: HashMap<Uuid, Vec<GutenbergBlock>>,
45 page_copies: Vec<PageCopy>,
46 page_pairings: BTreeMap<Uuid, Uuid>,
47 front_page: Option<Uuid>,
49 course_is_live: bool,
50}
51
52impl<'c> StructureEditor<'c> {
53 pub(super) fn new(
54 doc: &CourseStructure,
55 ops: &[Op],
56 course_is_live: bool,
57 ids: &'c dyn IdGenerator,
58 ) -> Self {
59 Self {
60 front_page: doc.pages.iter().find(|p| p.url_path == "/").map(|p| p.id),
61 doc: doc.clone(),
62 labels: Labels::prescan(ops),
63 removed: HashMap::new(),
64 ids: CreatedIdLog::new(ids),
65 warnings: Vec::new(),
66 paths: PathRequests::default(),
67 new_pages: HashMap::new(),
68 page_copies: Vec::new(),
69 page_pairings: BTreeMap::new(),
70 course_is_live,
71 }
72 }
73
74 pub(super) fn apply_op(&mut self, op: &Op) -> Result<(), EditError> {
75 match op {
76 Op::Add {
77 path,
78 value,
79 position,
80 } => self.add(path, value, *position),
81 Op::Remove { path, through } => self.remove(path, through.as_deref()),
82 Op::Replace { path, value } => self.replace(path, value),
83 Op::Move(transfer) => self.move_entities(transfer),
84 Op::Copy { transfer, label } => self.copy(transfer, label.as_deref()),
85 Op::Reorder { path, value } => self.reorder(path, value),
86 _ => Err(PatchTarget::CourseStructure.rejected_op(op.kind())),
87 }
88 }
89
90 fn require(&self, id: Uuid) -> Result<Loc, EditError> {
91 self.doc.locate(id).ok_or_else(|| self.not_found(id))
92 }
93
94 fn not_found(&self, id: Uuid) -> EditError {
95 match self.removed.get(&id) {
96 Some(op) => EditError::new(
97 EditErrorCode::NotFound,
98 format!("{id} was removed by op {op} of this call"),
99 ),
100 None => EditError::new(
101 EditErrorCode::NotFound,
102 format!("no module, chapter or page {id} in this course"),
103 )
104 .with_fix("get_course_structure lists the current ids"),
105 }
106 }
107
108 fn address(&self, path: &str) -> Result<Address, EditError> {
109 let parsed = parse_structure_path(path).ok_or_else(|| {
110 EditError::new(
111 EditErrorCode::InvalidPath,
112 format!(
113 "path \"{path}\" must start with /, e.g. /course/modules, /<chapter id>/pages or /<page id>/title"
114 ),
115 )
116 })?;
117 if parsed.is_course(self.doc.id) {
118 let Some(child) = parsed.child.as_deref() else {
119 return Ok(Address::Course);
120 };
121 return match ListRef::of_course(child) {
122 Some(list) if !parsed.is_deeper => Ok(Address::List(list)),
123 Some(_) => Err(EditError::numeric_index(path, "modules and pages").with_fix("use /<id>, or the list with a position, e.g. {\"path\":\"/course/pages\",\"position\":\"end\"}")),
124 None => Err(EditError::new(
125 EditErrorCode::WrongTool,
126 format!("course field \"{child}\" is edited with patch_course_settings"),
127 )),
128 };
129 }
130 let id = self
131 .labels
132 .resolve_segment(&parsed.root, path, self.ids.op())?;
133 let kind = self.require(id)?.kind();
134 let Some(child) = parsed.child.as_deref() else {
135 return Ok(Address::Entity(id));
136 };
137 match (ListRef::of_entity(kind, id, child), parsed.is_deeper) {
138 (Some(_), true) => Err(EditError::numeric_index(path, "chapters and pages")),
139 (Some(list), false) => Ok(Address::List(list)),
140 (None, false) => {
141 check_field(kind, child)?;
142 Ok(Address::Field(id, child.to_string()))
143 }
144 (None, true) => Err(EditError::new(
145 EditErrorCode::InvalidPath,
146 format!("\"{path}\" goes deeper than a {} field", kind.noun()),
147 )),
148 }
149 }
150
151 fn list_ids(&self, list: ListRef) -> Vec<Uuid> {
152 self.doc.list(list).into_iter().map(EntityRef::id).collect()
153 }
154
155 fn take(&mut self, list: ListRef, start: usize, end: usize) -> Vec<Entity> {
156 let range = start..=end;
157 match list {
158 ListRef::CoursePages => self.doc.pages.drain(range).map(Entity::Page).collect(),
159 ListRef::CourseModules => self.doc.modules.drain(range).map(Entity::Module).collect(),
160 ListRef::ModuleChapters(id) => self
161 .doc
162 .module_mut(id)
163 .map(|m| m.chapters.drain(range).map(Entity::Chapter).collect())
164 .unwrap_or_default(),
165 ListRef::ChapterPages(id) => self
166 .doc
167 .chapter_mut(id)
168 .map(|c| c.pages.drain(range).map(Entity::Page).collect())
169 .unwrap_or_default(),
170 }
171 }
172
173 fn put(&mut self, list: ListRef, index: usize, entities: Vec<Entity>) -> Result<(), EditError> {
174 for (offset, entity) in entities.into_iter().enumerate() {
175 let at = index + offset;
176 match (list, entity) {
177 (ListRef::CoursePages, Entity::Page(page)) => self.doc.pages.insert(at, page),
178 (ListRef::CourseModules, Entity::Module(module)) => {
179 self.doc.modules.insert(at, module)
180 }
181 (ListRef::ModuleChapters(id), Entity::Chapter(chapter)) => {
182 if let Some(module) = self.doc.module_mut(id) {
183 module.chapters.insert(at, chapter);
184 }
185 }
186 (ListRef::ChapterPages(id), Entity::Page(page)) => {
187 if let Some(chapter) = self.doc.chapter_mut(id) {
188 chapter.pages.insert(at, page);
189 }
190 }
191 (list, entity) => return Err(wrong_list(entity.kind(), list)),
192 }
193 }
194 Ok(())
195 }
196
197 fn anchor(&self, address: &Address, position: Option<Position>) -> Result<Anchor, EditError> {
198 let Some(position) = position else {
199 return Err(EditError::position_required(
200 "position is before/after a sibling (\"path\":\"/<id>\") or start/end of a list (\"path\":\"/<chapter id>/pages\")",
201 ));
202 };
203 match (address, position) {
204 (Address::List(list), Position::Start) => Ok(Anchor::Start(*list)),
205 (Address::List(list), Position::End) => Ok(Anchor::End(*list)),
206 (Address::Entity(id), Position::Before) => Ok(Anchor::Before(*id)),
207 (Address::Entity(id), Position::After) => Ok(Anchor::After(*id)),
208 (Address::Entity(id), Position::Start | Position::End) => {
209 let loc = self.require(*id)?;
210 let fix = match loc.kind() {
211 Kind::Module => format!("\"path\":\"/{id}/chapters\""),
212 Kind::Chapter => format!("\"path\":\"/{id}/pages\""),
213 Kind::Page => {
214 "before/after this page, or start/end of its chapter's pages".to_string()
215 }
216 };
217 Err(EditError::new(
218 EditErrorCode::PositionNeedsChildren,
219 "start/end need a list, not an entity",
220 )
221 .with_fix(fix))
222 }
223 (Address::List(list), Position::Before | Position::After) => Err(EditError::new(
224 EditErrorCode::PositionNeedsSibling,
225 format!("{} is a list; before/after need a sibling", list.path()),
226 )
227 .with_fix(format!(
228 "use position start/end with {}, or before/after with a sibling's path",
229 list.path()
230 ))),
231 (Address::Course, _) => Err(EditError::new(
232 EditErrorCode::PositionNeedsChildren,
233 "/course holds two lists",
234 )
235 .with_fix("use /course/pages or /course/modules")),
236 (Address::Field(..), _) => Err(EditError::new(
237 EditErrorCode::InvalidPath,
238 "items are placed next to an entity or into a list, not at a field",
239 )),
240 }
241 }
242
243 fn resolve_anchor(&self, anchor: Anchor, kind: Kind) -> Result<(ListRef, usize), EditError> {
245 let (list, index) = anchor.insertion_point(
246 |id| self.require(id).map(|loc| (loc.list, loc.index)),
247 |list| Ok((list, self.doc.list(list).len())),
248 )?;
249 if list.kind() != kind {
250 return Err(wrong_list(kind, list));
251 }
252 if index == 0
253 && let Some((code, pinned)) = self.pinned_first(list)
254 {
255 return Err(
256 pinned_error(code, &format!("stays first in {}", list.path()))
257 .with_fix(format!("{{\"path\":\"/{pinned}\",\"position\":\"after\"}}")),
258 );
259 }
260 Ok((list, index))
261 }
262
263 fn pinned_first(&self, list: ListRef) -> Option<(EditErrorCode, Uuid)> {
266 match list {
267 ListRef::CoursePages => self
268 .front_page
269 .map(|id| (EditErrorCode::PinnedFrontPage, id)),
270 ListRef::CourseModules => self
271 .doc
272 .modules
273 .first()
274 .map(|m| (EditErrorCode::DefaultModule, m.id)),
275 ListRef::ModuleChapters(_) | ListRef::ChapterPages(_) => None,
276 }
277 }
278
279 fn range(&self, from: Uuid, through: Option<Uuid>) -> Result<(Loc, usize), EditError> {
280 let start = self.require(from)?;
281 let Some(last) = through else {
282 return Ok((start, start.index));
283 };
284 let end = self.require(last)?;
285 if end.list != start.list {
286 return Err(EditError::new(
287 EditErrorCode::InvalidRange,
288 format!("a range must be siblings in one list; /{from} is in {} and /{last} in {}", start.list.path(), end.list.path()),
289 )
290 .with_details(json!({"from_siblings": self.sibling_paths(start.list), "through_siblings": self.sibling_paths(end.list)})));
291 }
292 if end.index < start.index {
293 return Err(EditError::reversed_range(
294 from,
295 last,
296 self.sibling_paths(start.list),
297 ));
298 }
299 Ok((start, end.index))
300 }
301
302 fn sibling_paths(&self, list: ListRef) -> Vec<String> {
303 self.list_ids(list)
304 .into_iter()
305 .map(|id| format!("/{id}"))
306 .collect()
307 }
308
309 fn check_not_pinned(&self, list: ListRef, start: usize, action: &str) -> Result<(), EditError> {
310 match self.pinned_first(list) {
311 Some((code, _)) if start == 0 => Err(pinned_error(code, &format!("can't be {action}"))),
312 _ => Ok(()),
313 }
314 }
315
316 fn new_entities(&mut self, kind: Kind, value: &Value) -> Result<Vec<Entity>, EditError> {
317 value_items(value, &format!("a {}", kind.noun()))?
318 .into_iter()
319 .map(|item| self.new_entity(kind, item))
320 .collect()
321 }
322
323 fn new_entity(&mut self, kind: Kind, value: &Value) -> Result<Entity, EditError> {
324 let object = value.as_object().ok_or_else(|| {
325 EditError::new(
326 EditErrorCode::InvalidValue,
327 format!("a new {} is an object", kind.noun()),
328 )
329 })?;
330 let contents: &[&str] = match kind {
331 Kind::Module => &[],
332 Kind::Chapter => &["pages"],
333 Kind::Page => &["blocks", "pair_with"],
334 };
335 if let Some(key) = object.keys().find(|key| {
336 key.as_str() != "label"
337 && !kind.editable_fields().contains(&key.as_str())
338 && !contents.contains(&key.as_str())
339 }) {
340 let error = field_error(kind, key);
341 if error.code != EditErrorCode::UnknownField {
342 return Err(error);
343 }
344 let accepted = [&["label"], kind.editable_fields(), contents].concat();
345 return Err(error.with_fix(format!(
346 "a new {} takes: {}",
347 kind.noun(),
348 accepted.join(", ")
349 )));
350 }
351 let label = label_field(object, "$ch")?;
352 let entity = match kind {
353 Kind::Page => Entity::Page(self.new_page(object)?),
354 Kind::Module => Entity::Module(StructureModule {
355 id: self.ids.new_id(),
356 default: false,
357 name: Some(non_empty_string(object.get("name"), "a new module's name")?),
358 chapters: Vec::new(),
359 }),
360 Kind::Chapter => {
361 let id = self.ids.new_id();
362 let name = non_empty_string(object.get("name"), "a new chapter's name")?;
363 let mut chapter = StructureChapter {
364 id,
365 chapter_number: 0,
366 name: name.clone(),
367 color: optional_color(object.get("color"))?,
368 opens_at: optional_date(object.get("opens_at"), "opens_at")?,
369 deadline: optional_date(object.get("deadline"), "deadline")?,
370 pages: Vec::new(),
371 };
372 match object.get("pages") {
373 Some(Value::Array(pages)) if !pages.is_empty() => {
374 for page in pages {
375 if let Entity::Page(page) = self.new_entity(Kind::Page, page)? {
376 chapter.pages.push(page);
377 }
378 }
379 }
380 Some(Value::Array(_)) | None | Some(Value::Null) => {
381 let front = self.new_page(&Map::from_iter([(
382 "title".to_string(),
383 Value::String(name),
384 )]))?;
385 chapter.pages.push(front);
386 }
387 Some(_) => {
388 return Err(EditError::new(
389 EditErrorCode::InvalidValue,
390 "pages is a list of page objects",
391 ));
392 }
393 }
394 Entity::Chapter(chapter)
395 }
396 };
397 if let Some(label) = &label {
398 self.labels.define(label, entity.id())?;
399 }
400 Ok(entity)
401 }
402
403 fn new_page(&mut self, object: &Map<String, Value>) -> Result<StructurePage, EditError> {
404 let id = self.ids.new_id();
405 let title = non_empty_string(object.get("title"), "a new page's title")?;
406 if let Some(url_path) = object.get("url_path") {
407 let url_path = url_path.as_str().ok_or_else(|| {
408 EditError::new(EditErrorCode::InvalidValue, "url_path is a string")
409 })?;
410 self.paths.set_explicit(id, url_path.to_string());
411 }
412 let blocks_value = object.get("blocks").filter(|v| !v.is_null());
413 let has_content = blocks_value
414 .and_then(Value::as_array)
415 .is_some_and(|blocks| !blocks.is_empty());
416 let hidden = match object.get("hidden") {
417 Some(Value::Bool(hidden)) => *hidden,
418 Some(_) => {
419 return Err(EditError::new(
420 EditErrorCode::InvalidValue,
421 "hidden is true or false",
422 ));
423 }
424 None if self.course_is_live && !has_content => {
425 self.warnings.push(Warning::new(
426 WarningCode::NewPageHidden,
427 format!("page {id} has no content yet, so it starts hidden; set hidden to false when it is ready"),
428 ));
429 true
430 }
431 None => false,
432 };
433 let blocks = match blocks_value {
434 Some(value @ Value::Array(items)) if !items.is_empty() => {
435 self.materialize_blocks(id, value)?
436 }
437 Some(Value::Array(_)) | None => Vec::new(),
438 Some(_) => {
439 return Err(EditError::new(
440 EditErrorCode::InvalidValue,
441 "blocks is a list of blocks",
442 ));
443 }
444 };
445 if let Some(source) = object.get("pair_with") {
446 let source = source
447 .as_str()
448 .and_then(|source| Uuid::parse_str(source.trim_start_matches('/')).ok())
449 .ok_or_else(|| {
450 EditError::new(
451 EditErrorCode::InvalidValue,
452 "pair_with is the id of the page in another language version of the course that this page translates",
453 )
454 })?;
455 self.page_pairings.insert(id, source);
456 }
457 let (block_count, exercise_count) = content_counts(&blocks);
458 self.new_pages.insert(id, blocks);
459 Ok(StructurePage {
460 id,
461 title,
462 url_path: String::new(),
463 hidden,
464 block_count,
465 exercise_count,
466 has_submissions: false,
467 page_language_group_id: None,
468 })
469 }
470
471 fn materialize_blocks(
473 &mut self,
474 page_id: Uuid,
475 value: &Value,
476 ) -> Result<Vec<GutenbergBlock>, EditError> {
477 let empty = PageDocument {
478 page_id,
479 page_type: PageType::CoursePage,
480 blocks: Vec::new(),
481 };
482 let add = Op::Add {
483 path: String::new(),
484 value: value.clone(),
485 position: Some(Position::End),
486 };
487 let result = page_content::apply(
488 empty,
489 &|_| None,
490 std::slice::from_ref(&add),
491 self.ids.generator(),
492 )
493 .map_err(|mut e| {
494 e.message = format!("in the blocks of new page {page_id}: {}", e.message);
495 e.op = None;
496 e
497 })?;
498 for id in result.created.into_iter().flat_map(|created| created.ids) {
499 self.ids.record(id);
500 }
501 for (label, id) in &result.labels {
502 self.labels.define(label, *id)?;
503 }
504 self.warnings.extend(result.warnings);
505 Ok(result.page.blocks)
506 }
507
508 fn add(
509 &mut self,
510 path: &str,
511 value: &Value,
512 position: Option<Position>,
513 ) -> Result<(), EditError> {
514 let address = self.address(path)?;
515 let anchor = self.anchor(&address, position)?;
516 let kind = match anchor {
517 Anchor::Start(list) | Anchor::End(list) => list.kind(),
518 Anchor::Before(id) | Anchor::After(id) => self.require(id)?.kind(),
519 };
520 let (list, index) = self.resolve_anchor(anchor, kind)?;
521 let entities = self.new_entities(kind, value)?;
522 self.put(list, index, entities)?;
523 Ok(())
524 }
525
526 fn entity_at(&self, path: &str, message: &str) -> Result<Uuid, EditError> {
528 match self.address(path)? {
529 Address::Entity(id) => Ok(id),
530 _ => Err(EditError::new(EditErrorCode::InvalidPath, message)),
531 }
532 }
533
534 fn remove(&mut self, path: &str, through: Option<&str>) -> Result<(), EditError> {
535 match self.address(path)? {
536 Address::Entity(id) => {
537 let through = self
538 .labels
539 .resolve_optional_item_ref(through, self.ids.op())?;
540 let (start, end) = self.range(id, through)?;
541 self.check_not_pinned(start.list, start.index, "removed")?;
542 if start.kind() == Kind::Module {
543 let not_empty: Vec<String> = self.doc.modules[start.index..=end]
544 .iter()
545 .filter(|module| !module.chapters.is_empty())
546 .map(|module| {
547 let chapters: Vec<String> = module
548 .chapters
549 .iter()
550 .map(|c| format!("/{} ({})", c.id, c.name))
551 .collect();
552 format!("module {} has {}", module.id, chapters.join(", "))
553 })
554 .collect();
555 if !not_empty.is_empty() {
556 return Err(EditError::new(
557 EditErrorCode::ModuleNotEmpty,
558 format!("modules still have chapters: {}", not_empty.join("; ")),
559 )
560 .with_fix(
561 "move or remove their chapters first, in the same call if you like",
562 ));
563 }
564 }
565 let removed = self.take(start.list, start.index, end);
566 for entity in &removed {
567 for id in entity_ids(entity) {
568 self.removed.insert(id, self.ids.op());
569 }
570 }
571 Ok(())
572 }
573 Address::Field(id, field) => self.set_field(id, &field, Value::Null),
574 Address::Course | Address::List(_) => Err(EditError::new(
575 EditErrorCode::InvalidPath,
576 "remove takes a module, chapter or page (or a range of them), not a list",
577 )),
578 }
579 }
580
581 fn replace(&mut self, path: &str, value: &Value) -> Result<(), EditError> {
582 match self.address(path)? {
583 Address::Field(id, field) => self.set_field(id, &field, value.clone()),
584 Address::Entity(id) => {
585 let Value::Object(fields) = value else {
586 return Err(EditError::new(
587 EditErrorCode::InvalidValue,
588 "replace on an entity takes an object of the fields to set",
589 ));
590 };
591 let kind = self.require(id)?.kind();
592 for (field, value) in fields {
593 if RENDERED_KEYS.contains(&field.as_str()) {
594 continue;
595 }
596 if matches!(field.as_str(), "pages" | "chapters") {
597 return Err(EditError::new(
598 EditErrorCode::FieldNotAllowed,
599 format!("{field} changes through move, copy, remove and reorder"),
600 ));
601 }
602 if COUNT_FIELDS.contains(&field.as_str())
603 || (DERIVED_FIELDS.contains(&field.as_str())
604 && self.field_value(id, kind, field).as_ref() == Some(value))
605 {
606 continue;
607 }
608 check_field(kind, field)?;
609 self.set_field(id, field, value.clone())?;
610 }
611 Ok(())
612 }
613 Address::List(list) => Err(EditError::new(
614 EditErrorCode::InvalidPath,
615 format!(
616 "{} changes through add, move, remove and reorder",
617 list.path()
618 ),
619 )
620 .with_fix(format!(
621 "{{\"op\":\"reorder\",\"path\":\"{}\",\"value\":[ids in the new order]}}",
622 list.path()
623 ))),
624 Address::Course => Err(EditError::new(
625 EditErrorCode::WrongTool,
626 "course fields are edited with patch_course_settings",
627 )),
628 }
629 }
630
631 fn field_value(&self, id: Uuid, kind: Kind, field: &str) -> Option<Value> {
632 match (kind, field) {
633 (_, "id") => Some(json!(id)),
634 (Kind::Chapter, "chapter_number") => {
635 self.doc.chapter(id).map(|c| json!(c.chapter_number))
636 }
637 (Kind::Module, "default") => self.doc.module(id).map(|m| json!(m.default)),
638 _ => None,
639 }
640 }
641
642 fn set_field(&mut self, id: Uuid, field: &str, value: Value) -> Result<(), EditError> {
644 let loc = self.require(id)?;
645 match loc.kind() {
646 Kind::Module => {
647 let Some(module) = self.doc.modules.get_mut(loc.index) else {
648 return Err(self.not_found(id));
649 };
650 if module.default {
651 return Err(EditError::new(
652 EditErrorCode::DefaultModule,
653 "the default module has no name and can't be renamed",
654 ));
655 }
656 module.name = Some(required_text(value, "name", "a module name")?);
657 }
658 Kind::Chapter => {
659 let Some(chapter) = self.doc.chapter_mut(id) else {
660 return Err(self.not_found(id));
661 };
662 match field {
663 "name" => chapter.name = required_text(value, "name", "a chapter name")?,
664 "color" => chapter.color = optional_color(Some(&value))?,
665 "opens_at" => chapter.opens_at = optional_date(Some(&value), "opens_at")?,
666 "deadline" => chapter.deadline = optional_date(Some(&value), "deadline")?,
667 _ => return Err(unknown_field(Kind::Chapter, field)),
668 }
669 }
670 Kind::Page => {
671 let Some(page) = self.doc.page_mut(id) else {
672 return Err(self.not_found(id));
673 };
674 match (field, value) {
675 ("title", value) => page.title = required_text(value, "title", "a title")?,
676 ("hidden", Value::Bool(hidden)) => page.hidden = hidden,
677 ("url_path", Value::String(path)) => {
678 if page.url_path == "/" {
679 return Err(EditError::new(
680 EditErrorCode::PinnedFrontPage,
681 "the course front page's path is always /",
682 ));
683 }
684 self.paths.set_explicit(id, path);
685 }
686 (field, Value::Null) => return Err(not_nullable(field)),
687 ("hidden", _) => {
688 return Err(EditError::new(
689 EditErrorCode::InvalidValue,
690 "hidden is true or false",
691 ));
692 }
693 ("url_path", _) => {
694 return Err(EditError::new(
695 EditErrorCode::InvalidValue,
696 "url_path is a string such as \"/faq\"",
697 ));
698 }
699 (field, _) => return Err(unknown_field(Kind::Page, field)),
700 }
701 }
702 }
703 Ok(())
704 }
705
706 fn move_entities(&mut self, transfer: &Transfer) -> Result<(), EditError> {
707 let from = self.entity_at(
708 &transfer.from,
709 &format!(
710 "from must name a module, chapter or page; got \"{}\"",
711 transfer.from
712 ),
713 )?;
714 let through = self
715 .labels
716 .resolve_optional_item_ref(transfer.through.as_deref(), self.ids.op())?;
717 let (start, end) = self.range(from, through)?;
718 self.check_not_pinned(start.list, start.index, "moved")?;
719 let address = self.address(&transfer.path)?;
720 let anchor = self.anchor(&address, Some(transfer.position))?;
721
722 let moved_ids: HashSet<Uuid> = self.list_ids(start.list)[start.index..=end]
723 .iter()
724 .copied()
725 .collect();
726 if let Some(anchor_id) = anchor.target_id(ListRef::owner)
727 && moved_ids.contains(&anchor_id)
728 {
729 return Err(EditError::new(
730 EditErrorCode::MoveIntoItself,
731 format!("{anchor_id} is part of what is being moved"),
732 )
733 .with_fix("choose a target outside the moved range"));
734 }
735 let entities = self.take(start.list, start.index, end);
736 let (list, index) = self.resolve_anchor(anchor, start.kind())?;
737 self.put(list, index, entities)?;
738 Ok(())
739 }
740
741 fn copy(&mut self, transfer: &Transfer, label: Option<&str>) -> Result<(), EditError> {
742 let from = self.entity_at(&transfer.from, "from must name a chapter or a page")?;
743 let loc = self.require(from)?;
744 let address = self.address(&transfer.path)?;
745 let anchor = self.anchor(&address, Some(transfer.position))?;
746 let (list, index) = self.resolve_anchor(anchor, loc.kind())?;
747 let copy = match loc.kind() {
748 Kind::Module => {
749 return Err(EditError::new(
750 EditErrorCode::InvalidOp,
751 "modules can't be copied; copy their chapters into a new module",
752 ));
753 }
754 Kind::Page => {
755 let Some(source) = self.doc.page(from).cloned() else {
756 return Err(self.not_found(from));
757 };
758 Entity::Page(self.copy_page(&source))
759 }
760 Kind::Chapter => {
761 let Some(source) = self.doc.chapter(from).cloned() else {
762 return Err(self.not_found(from));
763 };
764 let id = self.ids.new_id();
765 let pages = source.pages.iter().map(|p| self.copy_page(p)).collect();
766 Entity::Chapter(StructureChapter {
767 id,
768 name: format!("{}-copy", source.name),
769 pages,
770 chapter_number: 0,
771 ..source
772 })
773 }
774 };
775 if let Some(label) = label {
776 self.labels.define(label, copy.id())?;
777 }
778 self.put(list, index, vec![copy])?;
779 Ok(())
780 }
781
782 fn copy_page(&mut self, source: &StructurePage) -> StructurePage {
783 let id = self.ids.new_id();
784 self.page_copies.push(PageCopy {
785 source_page_id: source.id,
786 new_page_id: id,
787 });
788 self.paths.add_copy(id, source);
789 StructurePage {
790 id,
791 title: format!("{}-copy", source.title),
792 url_path: String::new(),
793 hidden: source.hidden,
794 block_count: source.block_count,
795 exercise_count: source.exercise_count,
796 has_submissions: false,
797 page_language_group_id: None,
798 }
799 }
800
801 fn reorder(&mut self, path: &str, value: &Value) -> Result<(), EditError> {
802 let list = match self.address(path)? {
803 Address::List(list) => list,
804 Address::Entity(id) => {
805 return Err(EditError::new(
806 EditErrorCode::InvalidPath,
807 "reorder takes a list path",
808 )
809 .with_fix(format!(
810 "/{id}/pages, /{id}/chapters, /course/pages or /course/modules"
811 )));
812 }
813 _ => {
814 return Err(EditError::new(
815 EditErrorCode::InvalidPath,
816 "reorder takes a list path such as /<chapter id>/pages",
817 ));
818 }
819 };
820 let Value::Array(values) = value else {
821 return Err(EditError::new(
822 EditErrorCode::InvalidValue,
823 "reorder's value is the list's ids in the new order",
824 ));
825 };
826 let mut order = Vec::with_capacity(values.len());
827 for value in values {
828 let raw = value.as_str().ok_or_else(|| {
829 EditError::new(
830 EditErrorCode::InvalidValue,
831 "reorder's value is a list of id strings",
832 )
833 })?;
834 order.push(self.labels.resolve_item_ref(raw, self.ids.op())?);
835 }
836 let current = self.list_ids(list);
837 let current_set: HashSet<Uuid> = current.iter().copied().collect();
838 let given: HashSet<Uuid> = order.iter().copied().collect();
839 if given != current_set || order.len() != current.len() {
840 return Err(EditError::new(
841 EditErrorCode::ReorderMismatch,
842 format!(
843 "reorder's value must list exactly the current children of {}",
844 list.path()
845 ),
846 )
847 .with_details(
848 json!({"current_ids": current.iter().map(Uuid::to_string).collect::<Vec<_>>()}),
849 ));
850 }
851 if let Some((code, pinned)) = self.pinned_first(list)
852 && order.first() != Some(&pinned)
853 {
854 return Err(pinned_error(
855 code,
856 &format!("stays first in {}", list.path()),
857 ));
858 }
859 if current.is_empty() {
860 return Ok(());
861 }
862 let mut by_id: HashMap<Uuid, Entity> = self
863 .take(list, 0, current.len() - 1)
864 .into_iter()
865 .map(|e| (e.id(), e))
866 .collect();
867 let reordered: Vec<Entity> = order.iter().filter_map(|id| by_id.remove(id)).collect();
868 self.put(list, 0, reordered)?;
869 Ok(())
870 }
871
872 pub(super) fn finish(mut self, before: &CourseStructure) -> Result<StructureResult, EditError> {
873 self.check_final_invariants()?;
874 let renumbered_chapters = self.renumber_chapters(before);
875 let before_paths: HashMap<Uuid, String> = before
876 .pages()
877 .map(|(_, p)| (p.id, p.url_path.clone()))
878 .collect();
879 let path_warnings =
880 assign_paths(&mut self.doc, &self.paths, &before_paths, self.front_page)?;
881 self.warnings.extend(path_warnings);
882 let changed_paths = path_changes(&self.doc, &before_paths);
883 self.new_pages
884 .retain(|page_id, _| self.doc.page(*page_id).is_some());
885 self.page_pairings
886 .retain(|page_id, _| self.doc.page(*page_id).is_some());
887 Ok(StructureResult {
888 new_pages: self.new_pages,
889 page_pairings: self.page_pairings,
890 page_copies: self
891 .page_copies
892 .into_iter()
893 .filter(|c| self.doc.page(c.new_page_id).is_some())
894 .collect(),
895 structure: self.doc,
896 created: self.ids.into_created(),
897 labels: self.labels.into_map(),
898 warnings: self.warnings,
899 changed_paths,
900 renumbered_chapters,
901 })
902 }
903
904 fn check_final_invariants(&self) -> Result<(), EditError> {
907 let modules = &self.doc.modules;
908 if !modules.first().is_some_and(|first| first.default) {
909 return Err(pinned_error(
910 EditErrorCode::DefaultModule,
911 "must stay first",
912 ));
913 }
914 if let Some(unnamed) = modules
915 .iter()
916 .skip(1)
917 .find(|m| m.name.as_deref().is_none_or(|n| n.trim().is_empty()))
918 {
919 return Err(EditError::new(
920 EditErrorCode::InvalidValue,
921 format!("module {} needs a name", unnamed.id),
922 ));
923 }
924 if let Some(empty) = self.doc.chapters().find(|c| c.pages.is_empty()) {
925 return Err(EditError::new(
926 EditErrorCode::ChapterNeedsPage,
927 format!(
928 "chapter {} ({}) would have no pages; a chapter always has a front page",
929 empty.id, empty.name
930 ),
931 )
932 .with_fix(format!(
933 "remove the chapter instead: {{\"op\":\"remove\",\"path\":\"/{}\"}}",
934 empty.id
935 )));
936 }
937 if let Some(front) = self.front_page
938 && self.doc.pages.first().map(|p| p.id) != Some(front)
939 {
940 return Err(pinned_error(
941 EditErrorCode::PinnedFrontPage,
942 "must stay first in /course/pages",
943 ));
944 }
945 Ok(())
946 }
947
948 fn renumber_chapters(&mut self, before: &CourseStructure) -> Vec<ChapterRenumber> {
950 let mut renumbered = Vec::new();
951 for (number, chapter) in (1..).zip(self.doc.chapters_mut()) {
952 let previous = before
953 .chapters()
954 .find(|c| c.id == chapter.id)
955 .map(|c| c.chapter_number);
956 if let Some(previous) = previous
957 && previous != number
958 {
959 renumbered.push(ChapterRenumber {
960 chapter_id: chapter.id,
961 from: previous,
962 to: number,
963 });
964 }
965 chapter.chapter_number = number;
966 }
967 renumbered
968 }
969}
970
971pub(super) fn wrong_list(kind: Kind, list: ListRef) -> EditError {
972 EditError::new(
973 EditErrorCode::InvalidPath,
974 format!("a {} can't go into {}", kind.noun(), list.path()),
975 )
976 .with_fix(match kind {
977 Kind::Page => "pages go into /<chapter id>/pages or /course/pages",
978 Kind::Chapter => "chapters go into /<module id>/chapters",
979 Kind::Module => "modules go into /course/modules",
980 })
981}
982
983pub(super) fn pinned_error(code: EditErrorCode, problem: &str) -> EditError {
985 let item = match code {
986 EditErrorCode::DefaultModule => "the default module",
987 _ => "the course front page (/)",
988 };
989 EditError::new(code, format!("{item} {problem}"))
990}