Skip to main content

headless_lms_mcp/engine/page_content/
replace_list.rs

1//! Replacing a children list with stubs, existing blocks and new blocks, and parsing new blocks.
2
3use std::collections::{HashMap, HashSet};
4
5use headless_lms_utils::document_schema_processor::GutenbergBlock;
6use serde_json::{Map, Value, json};
7use uuid::Uuid;
8
9use super::address::ListRef;
10use super::{Editor, check_creatable, exercise_read_only};
11use crate::engine::blocks;
12use crate::engine::blocks::RENDERED_BLOCK_KEYS;
13use crate::engine::error::{EditError, EditErrorCode, Warning, WarningCode};
14use crate::engine::labels::label_field;
15use crate::engine::path::parse_uuid;
16use crate::engine::reject_unknown_keys;
17
18/// The stored fields of a block.
19const BLOCK_FIELDS: [&str; 5] = ["clientId", "name", "attributes", "innerBlocks", "isValid"];
20
21/// What a new block may have; a `clientId` is replaced by a new one.
22const NEW_BLOCK_KEYS: [&str; 6] = [
23    "name",
24    "attributes",
25    "innerBlocks",
26    "label",
27    "clientId",
28    "isValid",
29];
30
31/// A parsed entry of a list-replace value, or of a new block's `innerBlocks`.
32pub(super) enum ListEntry {
33    /// `{"clientId": …}` alone: the existing block as it is.
34    Stub(Uuid),
35    /// An existing block with the fields given replaced.
36    Existing {
37        id: Uuid,
38        name: Option<String>,
39        attributes: Option<Map<String, Value>>,
40        inner: Option<Vec<ListEntry>>,
41    },
42    New {
43        name: String,
44        attributes: Map<String, Value>,
45        label: Option<String>,
46        /// The `clientId` an added block was sent with; it gets a new one.
47        ignored_client_id: Option<Value>,
48        inner: Vec<ListEntry>,
49    },
50}
51
52impl Editor<'_> {
53    /// Replaces the children of `list` with `entries`. Existing blocks they name move into the list;
54    /// every block the list held must be named, so nothing is deleted by omission.
55    pub(super) fn replace_list(
56        &mut self,
57        list: ListRef,
58        entries: Vec<ListEntry>,
59    ) -> Result<(), EditError> {
60        let parent_path = self.list_parent_path(list)?;
61        let doc = &self.pages[list.page].blocks;
62        let locations = check_list_entries(doc, &parent_path, &entries)?;
63        let existing: HashMap<Uuid, GutenbergBlock> = locations
64            .iter()
65            .map(|(id, path)| Ok((*id, blocks::get(doc, path)?.clone())))
66            .collect::<Result<_, EditError>>()?;
67        let new_list = self.build_entries(entries, &existing)?;
68
69        let from_elsewhere: Vec<Uuid> = locations
70            .iter()
71            .filter(|(_, path)| !(path.len() > parent_path.len() && path.starts_with(&parent_path)))
72            .map(|(id, _)| *id)
73            .collect();
74        let doc = &mut self.pages[list.page].blocks;
75        for id in from_elsewhere {
76            if let Some(path) = blocks::find_path(doc, id)
77                && let Some((index, parent_path)) = path.split_last()
78            {
79                blocks::list_mut(doc, parent_path)?.remove(*index);
80            }
81        }
82        // Taking blocks from before the list shifts its index path.
83        let parent_path = self.list_parent_path(list)?;
84        *blocks::list_mut(&mut self.pages[list.page].blocks, &parent_path)? = new_list;
85        Ok(())
86    }
87
88    fn build_entries(
89        &mut self,
90        entries: Vec<ListEntry>,
91        existing: &HashMap<Uuid, GutenbergBlock>,
92    ) -> Result<Vec<GutenbergBlock>, EditError> {
93        entries
94            .into_iter()
95            .map(|entry| self.build_entry(entry, existing))
96            .collect()
97    }
98
99    /// The block `entry` describes; `existing` holds the blocks its stubs and ids name.
100    pub(super) fn build_entry(
101        &mut self,
102        entry: ListEntry,
103        existing: &HashMap<Uuid, GutenbergBlock>,
104    ) -> Result<GutenbergBlock, EditError> {
105        match entry {
106            ListEntry::Stub(id) => existing
107                .get(&id)
108                .cloned()
109                .ok_or_else(blocks::stale_location),
110            ListEntry::Existing {
111                id,
112                name,
113                attributes,
114                inner,
115            } => {
116                let block = existing.get(&id).ok_or_else(blocks::stale_location)?;
117                let name = name.unwrap_or_else(|| block.name.clone());
118                check_creatable(&name)?;
119                let attributes = attributes.unwrap_or_else(|| block.attributes.clone());
120                let inner_blocks = match inner {
121                    Some(inner) => self.build_entries(inner, existing)?,
122                    None => block.inner_blocks.clone(),
123                };
124                Ok(GutenbergBlock {
125                    client_id: id,
126                    name,
127                    is_valid: true,
128                    attributes,
129                    inner_blocks,
130                })
131            }
132            ListEntry::New {
133                name,
134                attributes,
135                label,
136                ignored_client_id,
137                inner,
138            } => {
139                let id = self.ids.new_id();
140                if let Some(sent) = ignored_client_id {
141                    self.warnings.push(Warning::new(
142                        WarningCode::IgnoredClientId,
143                        format!(
144                            "clientId {sent} on a new block was replaced by {id}; the server assigns ids, and existing blocks are relocated with move"
145                        ),
146                    ));
147                }
148                if let Some(label) = label {
149                    self.labels.define(&label, id)?;
150                }
151                let inner_blocks = self.build_entries(inner, existing)?;
152                Ok(GutenbergBlock {
153                    client_id: id,
154                    name,
155                    is_valid: true,
156                    attributes,
157                    inner_blocks,
158                })
159            }
160        }
161    }
162}
163
164/// Checks a list-replace value against the page and returns where each existing block it names
165/// is now. The list is the children of the block at `parent_path`, or the top level.
166fn check_list_entries(
167    doc: &[GutenbergBlock],
168    parent_path: &[usize],
169    entries: &[ListEntry],
170) -> Result<HashMap<Uuid, Vec<usize>>, EditError> {
171    let mut referenced: Vec<(Uuid, bool)> = Vec::new();
172    collect_references(entries, &mut referenced);
173    let mut seen = HashSet::new();
174    let duplicates: Vec<String> = referenced
175        .iter()
176        .filter(|(id, _)| !seen.insert(*id))
177        .map(|(id, _)| id.to_string())
178        .collect();
179    if !duplicates.is_empty() {
180        return Err(EditError::new(
181            EditErrorCode::DuplicateIds,
182            "each existing block may appear once in the value",
183        )
184        .with_details(json!({"duplicate_ids": duplicates})));
185    }
186
187    let mut locations: HashMap<Uuid, Vec<usize>> = HashMap::new();
188    let mut unknown = Vec::new();
189    for (id, _) in &referenced {
190        match blocks::find_path(doc, *id) {
191            Some(path) => {
192                locations.insert(*id, path);
193            }
194            None => unknown.push(id.to_string()),
195        }
196    }
197    if !unknown.is_empty() {
198        return Err(EditError::new(
199            EditErrorCode::UnknownIds,
200            format!("these ids aren't on this page: {}", unknown.join(", ")),
201        )
202        .with_fix("leave clientId out of new blocks; blocks on other pages are brought over with move and to_page")
203        .with_details(json!({"unknown_ids": unknown})));
204    }
205
206    for depth in 1..=parent_path.len() {
207        let ancestor = blocks::get(doc, &parent_path[..depth])?.client_id;
208        if locations.contains_key(&ancestor) {
209            return Err(EditError::new(
210                EditErrorCode::MoveIntoItself,
211                format!(
212                    "/{ancestor} contains the list being replaced, so it can't be placed inside it"
213                ),
214            ));
215        }
216    }
217
218    let stubs: HashSet<Uuid> = referenced
219        .iter()
220        .filter(|(_, is_stub)| *is_stub)
221        .map(|(id, _)| *id)
222        .collect();
223    for (id, path) in &locations {
224        if let Some(holder) = blocks::ancestor_ids(doc, path)
225            .into_iter()
226            .find(|a| stubs.contains(a))
227        {
228            return Err(EditError::new(
229                EditErrorCode::DuplicateIds,
230                format!("/{id} is inside /{holder}, whose stub already keeps it"),
231            )
232            .with_fix(format!(
233                "give /{holder} as a full block with innerBlocks, or drop the separate /{id}"
234            )));
235        }
236    }
237
238    let mut required: Vec<Uuid> = blocks::list(doc, parent_path)?
239        .iter()
240        .map(|b| b.client_id)
241        .collect();
242    collect_required_children(entries, doc, &locations, &mut required)?;
243    let missing: Vec<String> = required
244        .iter()
245        .filter(|id| !locations.contains_key(id))
246        .map(|id| id.to_string())
247        .collect();
248    if !missing.is_empty() {
249        let stubs = missing
250            .iter()
251            .map(|id| format!("{{\"clientId\":\"{id}\"}}"))
252            .collect::<Vec<_>>()
253            .join(",");
254        return Err(EditError::new(
255            EditErrorCode::MissingIds,
256            format!(
257                "the value leaves out existing blocks: {}; deletions need an explicit remove",
258                missing.join(", ")
259            ),
260        )
261        .with_fix(format!(
262            "keep them with stubs ({stubs}), or remove them with separate remove ops"
263        ))
264        .with_details(json!({"missing_ids": missing})));
265    }
266
267    for (id, path) in &locations {
268        let is_given_in_full = !stubs.contains(id);
269        if blocks::is_in_exercise(doc, path, is_given_in_full)? {
270            return Err(exercise_read_only("replace it"));
271        }
272    }
273    Ok(locations)
274}
275
276/// The existing blocks `entries` name, with whether each is named by a stub.
277fn collect_references(entries: &[ListEntry], out: &mut Vec<(Uuid, bool)>) {
278    for entry in entries {
279        match entry {
280            ListEntry::Stub(id) => out.push((*id, true)),
281            ListEntry::Existing { id, inner, .. } => {
282                out.push((*id, false));
283                if let Some(inner) = inner {
284                    collect_references(inner, out);
285                }
286            }
287            ListEntry::New { inner, .. } => collect_references(inner, out),
288        }
289    }
290}
291
292/// Existing children of fully replaced blocks must be accounted for, like the replaced list's own.
293fn collect_required_children(
294    entries: &[ListEntry],
295    doc: &[GutenbergBlock],
296    locations: &HashMap<Uuid, Vec<usize>>,
297    required: &mut Vec<Uuid>,
298) -> Result<(), EditError> {
299    for entry in entries {
300        match entry {
301            ListEntry::Stub(_) => {}
302            ListEntry::Existing { id, inner, .. } => {
303                let path = locations.get(id).ok_or_else(blocks::stale_location)?;
304                let existing = blocks::get(doc, path)?;
305                match inner {
306                    Some(inner) => {
307                        required.extend(existing.inner_blocks.iter().map(|b| b.client_id));
308                        collect_required_children(inner, doc, locations, required)?;
309                    }
310                    None if !existing.inner_blocks.is_empty() => {
311                        let stubs = existing
312                            .inner_blocks
313                            .iter()
314                            .map(|b| format!("{{\"clientId\":\"{}\"}}", b.client_id))
315                            .collect::<Vec<_>>()
316                            .join(",");
317                        return Err(EditError::new(
318                            EditErrorCode::MissingField,
319                            format!("/{id} has inner blocks, so its replacement needs innerBlocks"),
320                        )
321                        .with_fix(format!("pass \"innerBlocks\":[{stubs}] to keep them")));
322                    }
323                    None => {}
324                }
325            }
326            ListEntry::New { inner, .. } => {
327                collect_required_children(inner, doc, locations, required)?
328            }
329        }
330    }
331    Ok(())
332}
333
334pub(super) fn parse_list_entries(value: &Value, what: &str) -> Result<Vec<ListEntry>, EditError> {
335    let Value::Array(items) = value else {
336        return Err(EditError::new(
337            EditErrorCode::InvalidValue,
338            format!(
339                "replacing {what} takes an array of blocks; to replace one block use its own path"
340            ),
341        ));
342    };
343    items.iter().map(parse_list_entry).collect()
344}
345
346/// One entry of a list-replace value: a stub, an existing block by `clientId`, or a new block.
347pub(super) fn parse_list_entry(value: &Value) -> Result<ListEntry, EditError> {
348    let object = value.as_object().ok_or_else(|| {
349        EditError::new(
350            EditErrorCode::InvalidValue,
351            "each item is a block object or a {\"clientId\":\"…\"} stub",
352        )
353    })?;
354    let Some(raw) = object.get("clientId") else {
355        return parse_new_entry(object, &parse_list_entry);
356    };
357    let id = raw
358        .as_str()
359        .ok_or_else(|| EditError::new(EditErrorCode::InvalidValue, "clientId is a string"))?;
360    let id = parse_uuid(id, id)?;
361    let is_stub = object
362        .keys()
363        .all(|k| k == "clientId" || RENDERED_BLOCK_KEYS.contains(&k.as_str()));
364    if is_stub {
365        return Ok(ListEntry::Stub(id));
366    }
367    let allowed: Vec<&str> = BLOCK_FIELDS
368        .iter()
369        .chain(&RENDERED_BLOCK_KEYS)
370        .copied()
371        .collect();
372    check_block_keys(object, &allowed)?;
373    let name = match object.get("name") {
374        None => None,
375        Some(_) => Some(block_name(object)?),
376    };
377    Ok(ListEntry::Existing {
378        id,
379        name,
380        attributes: block_attributes(object)?,
381        inner: parse_inner_blocks(object, &parse_list_entry)?,
382    })
383}
384
385/// A block an `add` or `wrap` creates. Its inner blocks are new too, whatever ids they carry.
386pub(super) fn parse_new_block(value: &Value) -> Result<ListEntry, EditError> {
387    let object = value.as_object().ok_or_else(|| {
388        EditError::new(
389            EditErrorCode::InvalidValue,
390            "a block is an object like {\"name\":\"core/paragraph\",\"attributes\":{\"content\":\"…\"}}",
391        )
392    })?;
393    parse_new_entry(object, &parse_new_block)
394}
395
396/// A new block; `parse_child` parses each of its inner blocks.
397fn parse_new_entry(
398    object: &Map<String, Value>,
399    parse_child: &dyn Fn(&Value) -> Result<ListEntry, EditError>,
400) -> Result<ListEntry, EditError> {
401    check_block_keys(object, &NEW_BLOCK_KEYS)?;
402    let name = block_name(object)?;
403    check_creatable(&name)?;
404    Ok(ListEntry::New {
405        name,
406        attributes: block_attributes(object)?.unwrap_or_default(),
407        label: label_field(object, "$box")?,
408        ignored_client_id: object.get("clientId").cloned(),
409        inner: parse_inner_blocks(object, parse_child)?.unwrap_or_default(),
410    })
411}
412
413/// The block's `innerBlocks`, `None` when it has none.
414fn parse_inner_blocks(
415    object: &Map<String, Value>,
416    parse_child: &dyn Fn(&Value) -> Result<ListEntry, EditError>,
417) -> Result<Option<Vec<ListEntry>>, EditError> {
418    match object.get("innerBlocks") {
419        None | Some(Value::Null) => Ok(None),
420        Some(Value::Array(items)) => items
421            .iter()
422            .map(parse_child)
423            .collect::<Result<_, _>>()
424            .map(Some),
425        Some(_) => Err(EditError::new(
426            EditErrorCode::InvalidValue,
427            "innerBlocks must be an array of blocks",
428        )),
429    }
430}
431
432fn check_block_keys(object: &Map<String, Value>, allowed: &[&str]) -> Result<(), EditError> {
433    reject_unknown_keys(object, allowed, "a block").map_err(|error| {
434        error.with_fix(format!(
435            "block fields are: {}; block-specific values go inside attributes",
436            allowed.join(", ")
437        ))
438    })
439}
440
441fn block_name(object: &Map<String, Value>) -> Result<String, EditError> {
442    match object.get("name") {
443        Some(Value::String(name)) if name.contains('/') => Ok(name.clone()),
444        Some(Value::String(name)) => Err(EditError::new(
445            EditErrorCode::InvalidValue,
446            format!("block name \"{name}\" needs its namespace"),
447        )
448        .with_fix(format!("core/{name} or moocfi/{name}"))),
449        Some(_) => Err(EditError::new(
450            EditErrorCode::InvalidValue,
451            "name is a string",
452        )),
453        None => Err(EditError::new(
454            EditErrorCode::MissingField,
455            "a new block needs \"name\", e.g. \"core/paragraph\"",
456        )),
457    }
458}
459
460fn block_attributes(object: &Map<String, Value>) -> Result<Option<Map<String, Value>>, EditError> {
461    match object.get("attributes") {
462        None | Some(Value::Null) => Ok(None),
463        Some(Value::Object(attributes)) => Ok(Some(attributes.clone())),
464        Some(_) => Err(EditError::new(
465            EditErrorCode::InvalidValue,
466            "attributes is an object",
467        )),
468    }
469}