Skip to main content

headless_lms_mcp/engine/course_structure/
editor.rs

1//! The editor that applies structure ops one by one to a copy of the course tree.
2
3use 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    /// The course front page, `/`, which stays first in /course/pages with its path.
48    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    /// (list, index) for an anchor in the current tree, checking the pinned positions.
244    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    /// What `list` keeps first, with the error code for disturbing it: the course front page in
264    /// /course/pages and the default module in /course/modules.
265    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    /// Builds a new page's blocks with the page tool's rules.
472    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    /// The entity `path` names; any other address fails with `message`.
527    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    /// Sets one editable field; `Value::Null` clears it. `field` passed [`check_field`].
643    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    /// What every op keeps, checked once more on the result: the pinned first items, module
905    /// names and chapter front pages.
906    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    /// Numbers the chapters 1, 2, … in order and lists the ones whose number changed from `before`.
949    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
983/// An op disturbs the item a list keeps first; `problem` says how, e.g. "can't be moved".
984pub(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}