Skip to main content

headless_lms_mcp/engine/
blocks.rs

1//! Block tree navigation by index path (`[2, 0]` = first child of the third top-level block).
2
3pub use headless_lms_utils::document_schema_processor::{EXERCISE_BLOCK, walk};
4use headless_lms_utils::document_schema_processor::{GutenbergBlock, walk_mut};
5use serde::Serialize;
6use serde_json::json;
7use uuid::Uuid;
8
9use super::error::{EditError, EditErrorCode};
10use super::path::{split_segment, unescape_segment};
11use crate::ids::IdGenerator;
12
13/// Keys a page read adds to a block besides its stored fields. Writes ignore them, so a block
14/// read back can be written again.
15pub(crate) const RENDERED_BLOCK_KEYS: [&str; 5] =
16    ["path", "pending", "children", "preview", "exercise_id"];
17
18/// Attributes that hold a block's text, most telling first: bulk edits search them unless told
19/// otherwise, and a block's preview is the first one with text.
20pub(crate) const TEXT_ATTRIBUTES: [&str; 15] = [
21    "content",
22    "title",
23    "text",
24    "caption",
25    "alt",
26    "name",
27    "citation",
28    "value",
29    "label",
30    "subtitle",
31    "fileName",
32    "downloadButtonText",
33    "head",
34    "body",
35    "foot",
36];
37
38/// Blocks that only exist inside an exercise block's editor tree.
39const EXERCISE_INTERNAL_BLOCKS: [&str; 4] = [
40    "moocfi/exercise-settings",
41    "moocfi/exercise-slides",
42    "moocfi/exercise-slide",
43    "moocfi/exercise-task",
44];
45
46/// Whether the block embeds an exercise.
47pub fn is_exercise(block: &GutenbergBlock) -> bool {
48    block.name == EXERCISE_BLOCK
49}
50
51/// An exercise block or a block of an exercise's editor tree.
52pub fn is_exercise_part(name: &str) -> bool {
53    name == EXERCISE_BLOCK || EXERCISE_INTERNAL_BLOCKS.contains(&name)
54}
55
56/// The index path of the block `id`, if the tree has it.
57pub fn find_path(blocks: &[GutenbergBlock], id: Uuid) -> Option<Vec<usize>> {
58    for (index, block) in blocks.iter().enumerate() {
59        if block.client_id == id {
60            return Some(vec![index]);
61        }
62        if let Some(mut rest) = find_path(&block.inner_blocks, id) {
63            rest.insert(0, index);
64            return Some(rest);
65        }
66    }
67    None
68}
69
70/// The block at an index path. Paths come from [`find_path`] on the same tree, so the error, for
71/// an empty path or one the tree doesn't have, means the location went stale.
72pub fn get<'a>(
73    blocks: &'a [GutenbergBlock],
74    path: &[usize],
75) -> Result<&'a GutenbergBlock, EditError> {
76    let (first, rest) = path.split_first().ok_or_else(stale_location)?;
77    let mut block = blocks.get(*first).ok_or_else(stale_location)?;
78    for index in rest {
79        block = block.inner_blocks.get(*index).ok_or_else(stale_location)?;
80    }
81    Ok(block)
82}
83
84/// [`get`], to change the block.
85pub fn get_mut<'a>(
86    blocks: &'a mut [GutenbergBlock],
87    path: &[usize],
88) -> Result<&'a mut GutenbergBlock, EditError> {
89    let (first, rest) = path.split_first().ok_or_else(stale_location)?;
90    let mut block = blocks.get_mut(*first).ok_or_else(stale_location)?;
91    for index in rest {
92        block = block
93            .inner_blocks
94            .get_mut(*index)
95            .ok_or_else(stale_location)?;
96    }
97    Ok(block)
98}
99
100/// The children list whose parent is at `parent_path`; the top level for an empty path.
101pub fn list_mut<'a>(
102    blocks: &'a mut Vec<GutenbergBlock>,
103    parent_path: &[usize],
104) -> Result<&'a mut Vec<GutenbergBlock>, EditError> {
105    if parent_path.is_empty() {
106        Ok(blocks)
107    } else {
108        Ok(&mut get_mut(blocks, parent_path)?.inner_blocks)
109    }
110}
111
112/// [`list_mut`], to read.
113pub fn list<'a>(
114    blocks: &'a [GutenbergBlock],
115    parent_path: &[usize],
116) -> Result<&'a [GutenbergBlock], EditError> {
117    if parent_path.is_empty() {
118        Ok(blocks)
119    } else {
120        Ok(&get(blocks, parent_path)?.inner_blocks)
121    }
122}
123
124/// The error for a located block or range the tree no longer has.
125pub(crate) fn stale_location() -> EditError {
126    EditError::new(
127        EditErrorCode::NotFound,
128        "a block the call located isn't where it was; read the page again and repeat the call",
129    )
130}
131
132fn count_blocks(blocks: &[GutenbergBlock]) -> usize {
133    let mut count = 0;
134    walk(blocks, &mut |_, _| count += 1);
135    count
136}
137
138/// A page's block count, nested blocks included, and its exercise count, as the course structure
139/// shows them.
140pub fn content_counts(blocks: &[GutenbergBlock]) -> (u32, u32) {
141    let exercise_count = blocks.iter().filter(|b| is_exercise(b)).count();
142    (count_blocks(blocks) as u32, exercise_count as u32)
143}
144
145/// Whether the block at `path` is inside an exercise's editor tree; with `include_self`, also
146/// whether it is part of one itself. Errors with [`stale_location`] if the tree lacks `path`.
147pub fn is_in_exercise(
148    blocks: &[GutenbergBlock],
149    path: &[usize],
150    include_self: bool,
151) -> Result<bool, EditError> {
152    let end = if include_self {
153        path.len()
154    } else {
155        path.len().saturating_sub(1)
156    };
157    for depth in 1..=end {
158        if is_exercise_part(&get(blocks, &path[..depth])?.name) {
159            return Ok(true);
160        }
161    }
162    Ok(false)
163}
164
165/// Like [`walk`], but never enters an exercise, whose blocks page edits don't touch.
166pub fn walk_outside_exercises<'a>(
167    blocks: &'a [GutenbergBlock],
168    visit: &mut dyn FnMut(&'a GutenbergBlock, &[usize]),
169) {
170    fn go<'a>(
171        blocks: &'a [GutenbergBlock],
172        prefix: &mut Vec<usize>,
173        visit: &mut dyn FnMut(&'a GutenbergBlock, &[usize]),
174    ) {
175        for (index, block) in blocks.iter().enumerate() {
176            if is_exercise_part(&block.name) {
177                continue;
178            }
179            prefix.push(index);
180            visit(block, prefix);
181            go(&block.inner_blocks, prefix, visit);
182            prefix.pop();
183        }
184    }
185    go(blocks, &mut Vec::new(), visit);
186}
187
188/// Like [`walk_mut`], but never enters an exercise, whose blocks page edits don't touch.
189pub fn walk_mut_outside_exercises(
190    blocks: &mut [GutenbergBlock],
191    visit: &mut dyn FnMut(&mut GutenbergBlock),
192) {
193    for block in blocks {
194        if is_exercise_part(&block.name) {
195            continue;
196        }
197        visit(block);
198        walk_mut_outside_exercises(&mut block.inner_blocks, visit);
199    }
200}
201
202/// The parent ids of the block at `path`, outermost first.
203pub fn ancestor_ids(blocks: &[GutenbergBlock], path: &[usize]) -> Vec<Uuid> {
204    (1..path.len())
205        .filter_map(|depth| get(blocks, &path[..depth]).ok())
206        .map(|block| block.client_id)
207        .collect()
208}
209
210/// The value at JSON Pointer `pointer` (non-empty, escaped) inside `attributes`.
211pub(crate) fn attribute<'a>(
212    attributes: &'a serde_json::Map<String, serde_json::Value>,
213    pointer: &str,
214) -> Option<&'a serde_json::Value> {
215    let (name, rest) = split_segment(pointer.strip_prefix('/')?);
216    attributes.get(&unescape_segment(name))?.pointer(rest)
217}
218
219/// An exercise block a copy duplicated. The caller creates the new exercise from the source's
220/// settings.
221#[derive(Debug, Clone, PartialEq, Serialize)]
222pub struct ExerciseCopy {
223    pub source_exercise_id: Uuid,
224    pub new_exercise_id: Uuid,
225    pub new_block_id: Uuid,
226    /// The page the copy is on.
227    pub page_id: Uuid,
228}
229
230/// Copies `source` for `page_id` with new block ids. Exercise blocks get new exercise ids, listed
231/// in the result.
232pub fn copy_blocks(
233    source: &[GutenbergBlock],
234    ids: &dyn IdGenerator,
235    page_id: Uuid,
236) -> (Vec<GutenbergBlock>, Vec<ExerciseCopy>) {
237    let mut copies = Vec::new();
238    let mut blocks = source.to_vec();
239    walk_mut(&mut blocks, &mut |block| {
240        block.client_id = ids.new_id();
241        if let Some(source_exercise_id) = block.exercise_id() {
242            let new_exercise_id = ids.new_id();
243            block.attributes.insert("id".into(), json!(new_exercise_id));
244            copies.push(ExerciseCopy {
245                source_exercise_id,
246                new_exercise_id,
247                new_block_id: block.client_id,
248                page_id,
249            });
250        }
251    });
252    (blocks, copies)
253}
254
255/// Visits every string value inside `attributes`.
256pub fn for_each_string(
257    attributes: &serde_json::Map<String, serde_json::Value>,
258    visit: &mut dyn FnMut(&str),
259) {
260    fn go(value: &serde_json::Value, visit: &mut dyn FnMut(&str)) {
261        match value {
262            serde_json::Value::String(s) => visit(s),
263            serde_json::Value::Array(items) => items.iter().for_each(|v| go(v, visit)),
264            serde_json::Value::Object(map) => map.values().for_each(|v| go(v, visit)),
265            _ => {}
266        }
267    }
268    attributes.values().for_each(|value| go(value, visit));
269}
270
271/// Visits every string value inside `attributes`, to change it in place.
272pub fn for_each_string_mut(
273    attributes: &mut serde_json::Map<String, serde_json::Value>,
274    visit: &mut dyn FnMut(&mut String),
275) {
276    fn go(value: &mut serde_json::Value, visit: &mut dyn FnMut(&mut String)) {
277        match value {
278            serde_json::Value::String(s) => visit(s),
279            serde_json::Value::Array(items) => items.iter_mut().for_each(|v| go(v, visit)),
280            serde_json::Value::Object(map) => map.values_mut().for_each(|v| go(v, visit)),
281            _ => {}
282        }
283    }
284    attributes.values_mut().for_each(|value| go(value, visit));
285}
286
287#[cfg(test)]
288mod tests {
289    use std::collections::HashSet;
290
291    use headless_lms_utils::document_schema_processor::all_client_ids;
292    use pretty_assertions::assert_eq;
293
294    use super::*;
295    use crate::ids::SequentialIds;
296    use crate::test_fixtures::{exercise, id, infobox, para};
297
298    #[test]
299    fn copied_blocks_get_new_ids_and_new_exercises() {
300        let source = vec![infobox("box", vec![para("c", "x")]), exercise("exb", "ex")];
301        let ids = SequentialIds::default();
302        let (copied, copies) = copy_blocks(&source, &ids, id("page"));
303        let source_ids: HashSet<Uuid> = all_client_ids(&source).into_iter().collect();
304        assert!(
305            all_client_ids(&copied)
306                .iter()
307                .all(|id| !source_ids.contains(id))
308        );
309        assert_eq!(copies.len(), 1);
310        assert_eq!(copies[0].source_exercise_id, id("ex"));
311        assert_eq!(copied[1].exercise_id(), Some(copies[0].new_exercise_id));
312        assert_eq!(copied[0].inner_blocks[0].attributes["content"], json!("x"));
313    }
314}