Skip to main content

headless_lms_mcp/engine/read/
page.rs

1//! Read selectors over a page's blocks.
2
3use std::collections::HashMap;
4
5use headless_lms_utils::document_schema_processor::GutenbergBlock;
6use serde_json::{Map, Value, json};
7use uuid::Uuid;
8
9use super::window::{HasId, list_read, reject_window, sibling_window, single_unit, window};
10use super::{ReadMode, ReadOptions, ReadResult, Selector, check_selector, field_result};
11use crate::diff::ChangeKind;
12use crate::engine::PageDocument;
13use crate::engine::blocks;
14use crate::engine::error::{EditError, EditErrorCode};
15use crate::engine::path::{BlockPart, BlockPath, parse_block_path, parse_uuid};
16use crate::text::{plain_text, preview};
17
18/// What a read path names, with the index path of the block it starts from.
19enum PageAddress {
20    /// The top level, or the children of the block.
21    List(Option<(Uuid, Vec<usize>)>),
22    Block(Uuid, Vec<usize>),
23    Name(Vec<usize>),
24    /// The block's attributes and a JSON Pointer into them, `""` for all of them.
25    Attribute(Vec<usize>, String),
26}
27
28fn page_address(page: &PageDocument, path: &str) -> Result<PageAddress, EditError> {
29    let parsed = parse_block_path(path).ok_or_else(|| {
30        EditError::new(
31            EditErrorCode::InvalidPath,
32            format!("path \"{path}\" must start with /"),
33        )
34    })?;
35    let (root, part) = match parsed {
36        BlockPath::TopLevel => return Ok(PageAddress::List(None)),
37        BlockPath::Block { root, part } => (root, part),
38    };
39    if root.starts_with('$') {
40        return Err(EditError::new(
41            EditErrorCode::LabelUndefined,
42            "labels only exist inside the call that created them; use the returned ids",
43        ));
44    }
45    let id = parse_uuid(&root, path)?;
46    let location = blocks::find_path(&page.blocks, id).ok_or_else(|| {
47        EditError::new(
48            EditErrorCode::NotFound,
49            format!("no block {id} on this page"),
50        )
51        .with_fix("read the outline (no path) to get current ids")
52    })?;
53    match part {
54        BlockPart::Whole => Ok(PageAddress::Block(id, location)),
55        BlockPart::Children => Ok(PageAddress::List(Some((id, location)))),
56        BlockPart::ChildIndex(_) => Err(EditError::new(
57            EditErrorCode::NumericIndex,
58            "children are addressed by id; use offset and limit for windows",
59        )),
60        BlockPart::Attributes(pointer) => Ok(PageAddress::Attribute(location, pointer.to_string())),
61        BlockPart::Name => Ok(PageAddress::Name(location)),
62        BlockPart::Other {
63            field,
64            is_deeper: true,
65        } if field == "name" => Err(EditError::new(
66            EditErrorCode::InvalidPath,
67            format!("a block's name is a string; read /{id}/name"),
68        )),
69        BlockPart::Other { field, .. } => Err(EditError::new(
70            EditErrorCode::InvalidPath,
71            format!("blocks have no \"{field}\""),
72        )
73        .with_fix(format!("/{id}/attributes/{field}"))),
74    }
75}
76
77/// Runs one selector against a page.
78pub fn read_page(
79    page: &PageDocument,
80    selector: &Selector,
81    options: &ReadOptions,
82) -> Result<ReadResult, EditError> {
83    check_selector(selector)?;
84    let mode = selector.mode.unwrap_or_default();
85    let path = selector.path.as_deref().unwrap_or("");
86    let render = |block: &GutenbergBlock| render_block(block, mode, options.pending);
87    match page_address(page, path)? {
88        PageAddress::Name(location) => {
89            reject_window(selector, "a field")?;
90            let name = blocks::get(&page.blocks, &location)?.name.clone();
91            Ok(field_result(selector, Value::String(name)))
92        }
93        PageAddress::Attribute(location, pointer) => {
94            reject_window(selector, "a field")?;
95            let attributes = &blocks::get(&page.blocks, &location)?.attributes;
96            let value = if pointer.is_empty() {
97                Value::Object(attributes.clone())
98            } else {
99                blocks::attribute(attributes, &pointer)
100                    .cloned()
101                    .ok_or_else(|| {
102                        EditError::new(EditErrorCode::NotFound, format!("{path} isn't set"))
103                    })?
104            };
105            Ok(field_result(selector, value))
106        }
107        PageAddress::List(parent) => {
108            let parent_path = parent
109                .as_ref()
110                .map(|(_, path)| path.as_slice())
111                .unwrap_or(&[]);
112            list_read(
113                selector,
114                blocks::list(&page.blocks, parent_path)?,
115                parent.map(|(id, _)| format!("/{id}/innerBlocks")),
116                options,
117                &render,
118                "a block",
119            )
120        }
121        PageAddress::Block(id, location) => {
122            if selector.offset.is_some() {
123                return Err(EditError::new(EditErrorCode::InvalidValue, "offset works on a children list; with a block path use limit to read on from that block")
124                    .with_fix(format!("{{\"path\":\"/{id}\",\"limit\":20}}")));
125            }
126            let (&index, parent_path) = location.split_last().ok_or_else(blocks::stale_location)?;
127            let list = blocks::list(&page.blocks, parent_path)?;
128            let parent = if parent_path.is_empty() {
129                None
130            } else {
131                Some(format!(
132                    "/{}/innerBlocks",
133                    blocks::get(&page.blocks, parent_path)?.client_id
134                ))
135            };
136            let sibling_ids: Vec<Uuid> = list.iter().map(|b| b.client_id).collect();
137            match sibling_window(selector, index, &sibling_ids)? {
138                Some((start, end)) => {
139                    Ok(window(selector, list, start, end, parent, options, &render))
140                }
141                None => Ok(single_unit(
142                    selector,
143                    render(blocks::get(&page.blocks, &location)?),
144                    options,
145                )),
146            }
147        }
148    }
149}
150
151impl HasId for GutenbergBlock {
152    fn id(&self) -> Uuid {
153        self.client_id
154    }
155}
156
157/// The 40-60 character preview of a block's visible text.
158pub(crate) fn block_preview(block: &GutenbergBlock) -> Option<String> {
159    blocks::TEXT_ATTRIBUTES
160        .iter()
161        .filter_map(|key| block.attributes.get(*key).and_then(Value::as_str))
162        .map(plain_text)
163        .find(|text| !text.is_empty())
164        .map(|text| preview(&text, 60))
165}
166
167fn render_block(
168    block: &GutenbergBlock,
169    mode: ReadMode,
170    pending: Option<&HashMap<Uuid, ChangeKind>>,
171) -> Value {
172    let mut object = Map::new();
173    object.insert("clientId".into(), json!(block.client_id));
174    object.insert("path".into(), json!(format!("/{}", block.client_id)));
175    object.insert("name".into(), json!(block.name));
176    let children: Vec<Value> = block
177        .inner_blocks
178        .iter()
179        .map(|b| render_block(b, mode, pending))
180        .collect();
181    match mode {
182        ReadMode::Outline => {
183            if let Some(preview) = block_preview(block) {
184                object.insert("preview".into(), json!(preview));
185            }
186            if let Some(exercise_id) = block.exercise_id() {
187                object.insert("exercise_id".into(), json!(exercise_id));
188            }
189            if !children.is_empty() {
190                object.insert("children".into(), json!(children.len()));
191                object.insert("innerBlocks".into(), Value::Array(children));
192            }
193        }
194        ReadMode::Raw => {
195            object.insert("isValid".into(), json!(block.is_valid));
196            object.insert("attributes".into(), Value::Object(block.attributes.clone()));
197            object.insert("innerBlocks".into(), Value::Array(children));
198        }
199    }
200    if let Some(kind) = pending.and_then(|p| p.get(&block.client_id)) {
201        object.insert("pending".into(), json!(kind));
202    }
203    Value::Object(object)
204}
205
206#[cfg(test)]
207mod tests {
208    use pretty_assertions::assert_eq;
209
210    use super::*;
211    use crate::engine::PageType;
212    use crate::engine::read::StopReason;
213    use crate::test_fixtures::{exercise, heading, id, infobox, p, page, para, read_options};
214
215    fn sample() -> PageDocument {
216        let mut blocks = vec![
217            heading("h1", "Recursion"),
218            infobox(
219                "box1",
220                vec![
221                    para(
222                        "c1",
223                        "A function that calls itself is called recursive and this text goes on",
224                    ),
225                    para("c2", "Two"),
226                ],
227            ),
228        ];
229        for n in 0..30 {
230            blocks.push(para(&format!("p{n}"), &format!("Paragraph number {n}")));
231        }
232        blocks.push(exercise("ex1", "e1"));
233        page("pg1", PageType::CoursePage, blocks)
234    }
235
236    fn read(selector: Selector) -> Result<ReadResult, EditError> {
237        read_page(&sample(), &selector, &read_options(24_000))
238    }
239
240    #[test]
241    fn outline_of_whole_page() {
242        let result = read(Selector::default()).unwrap();
243        assert_eq!(result.total, Some(33));
244        assert!(!result.has_more);
245        let boxed = &result.items[1];
246        assert_eq!(boxed["children"], json!(2));
247        let preview = boxed["innerBlocks"][0]["preview"].as_str().unwrap();
248        assert!(preview.chars().count() <= 61 && preview.ends_with('…'));
249        assert_eq!(result.items[32]["exercise_id"], json!(id("e1")));
250        assert_eq!(result.items[0]["path"], json!(p("h1")));
251    }
252
253    #[test]
254    fn raw_block_and_attribute() {
255        let block = read(Selector {
256            path: Some(p("box1")),
257            mode: Some(ReadMode::Raw),
258            ..Default::default()
259        })
260        .unwrap();
261        assert_eq!(
262            block.items[0]["innerBlocks"][1]["attributes"]["content"],
263            json!("Two")
264        );
265        assert_eq!(block.items[0]["innerBlocks"][1]["path"], json!(p("c2")));
266        let attribute = read(Selector {
267            path: Some(format!("{}/attributes/content", p("h1"))),
268            ..Default::default()
269        })
270        .unwrap();
271        assert_eq!(attribute.value, Some(json!("Recursion")));
272    }
273
274    #[test]
275    fn children_list_range_and_around() {
276        let children = read(Selector {
277            path: Some(format!("{}/innerBlocks", p("box1"))),
278            ..Default::default()
279        })
280        .unwrap();
281        assert_eq!(children.items.len(), 2);
282        assert_eq!(children.parent, Some(format!("{}/innerBlocks", p("box1"))));
283        let range = read(Selector {
284            path: Some(p("p2")),
285            through: Some(p("p4")),
286            ..Default::default()
287        })
288        .unwrap();
289        assert_eq!(range.items.len(), 3);
290        let around = read(Selector {
291            path: Some(p("p0")),
292            around: Some(1),
293            ..Default::default()
294        })
295        .unwrap();
296        let ids: Vec<_> = around.items.iter().map(|i| i["clientId"].clone()).collect();
297        assert_eq!(
298            ids,
299            vec![json!(id("box1")), json!(id("p0")), json!(id("p1"))]
300        );
301        let reversed = read(Selector {
302            path: Some(p("p4")),
303            through: Some(p("p2")),
304            ..Default::default()
305        })
306        .unwrap_err();
307        assert_eq!(reversed.code, EditErrorCode::InvalidRange);
308    }
309
310    #[test]
311    fn offset_window_continues_by_id() {
312        let first = read(Selector {
313            path: Some(String::new()),
314            offset: Some(5),
315            limit: Some(10),
316            ..Default::default()
317        })
318        .unwrap();
319        assert_eq!(first.items.len(), 10);
320        assert_eq!(first.stopped_by, Some(StopReason::Limit));
321        let next = first.next.clone().unwrap();
322        assert_eq!(next.path, Some(p("p13")));
323        assert_eq!(next.offset, None);
324        let second = read(next).unwrap();
325        assert_eq!(second.items[0]["clientId"], json!(id("p13")));
326        assert!(
327            serde_json::to_string(&first)
328                .unwrap()
329                .find("\"offset\":5")
330                .is_some(),
331            "only the echoed request has offsets"
332        );
333    }
334
335    #[test]
336    fn budget_stops_at_whole_units() {
337        let options = read_options(600);
338        let result = read_page(
339            &sample(),
340            &Selector {
341                mode: Some(ReadMode::Raw),
342                ..Default::default()
343            },
344            &options,
345        )
346        .unwrap();
347        assert!(result.has_more);
348        assert_eq!(result.stopped_by, Some(StopReason::Budget));
349        let next = result.next.unwrap();
350        let continued = read_page(&sample(), &next, &options).unwrap();
351        assert_eq!(continued.items[0]["path"], json!(next.path.unwrap()));
352    }
353
354    /// Follows `next` from `first` to the end and returns every item's path in order.
355    fn read_to_end(first: Selector, options: &ReadOptions) -> Vec<String> {
356        let mut paths = Vec::new();
357        let mut selector = Some(first);
358        while let Some(current) = selector {
359            let result = read_page(&sample(), &current, options).unwrap();
360            paths.extend(
361                result
362                    .items
363                    .iter()
364                    .map(|item| item["path"].as_str().unwrap().to_string()),
365            );
366            assert_eq!(result.has_more, result.next.is_some());
367            selector = result.next;
368        }
369        paths
370    }
371
372    #[test]
373    fn budget_stopped_reads_continue_to_the_end_of_their_window() {
374        let options = read_options(600);
375        let all: Vec<String> = sample()
376            .blocks
377            .iter()
378            .map(|b| format!("/{}", b.client_id))
379            .collect();
380        let raw = |selector: Selector| Selector {
381            mode: Some(ReadMode::Raw),
382            ..selector
383        };
384        assert_eq!(read_to_end(raw(Selector::default()), &options), all);
385        assert_eq!(
386            read_to_end(
387                raw(Selector {
388                    offset: Some(5),
389                    ..Default::default()
390                }),
391                &options
392            ),
393            all[5..].to_vec()
394        );
395        assert_eq!(
396            read_to_end(
397                raw(Selector {
398                    path: Some(p("p10")),
399                    around: Some(8),
400                    ..Default::default()
401                }),
402                &options
403            ),
404            all[4..21].to_vec()
405        );
406    }
407
408    #[test]
409    fn an_oversized_first_unit_of_a_window_collapses_children() {
410        let options = read_options(50);
411        let result = read_page(
412            &sample(),
413            &Selector {
414                offset: Some(1),
415                mode: Some(ReadMode::Raw),
416                ..Default::default()
417            },
418            &options,
419        )
420        .unwrap();
421        assert_eq!(result.items.len(), 1);
422        assert_eq!(result.items[0]["innerBlocks"][0]["collapsed"], json!(2));
423    }
424
425    #[test]
426    fn a_block_name_has_no_fields() {
427        assert!(
428            read(Selector {
429                path: Some(format!("{}/name/foo", p("h1"))),
430                ..Default::default()
431            })
432            .is_err()
433        );
434        let name = read(Selector {
435            path: Some(format!("{}/name", p("h1"))),
436            ..Default::default()
437        })
438        .unwrap();
439        assert_eq!(name.value, Some(json!("core/heading")));
440    }
441
442    #[test]
443    fn oversized_single_block_collapses_children() {
444        let options = read_options(50);
445        let result = read_page(
446            &sample(),
447            &Selector {
448                path: Some(p("box1")),
449                mode: Some(ReadMode::Raw),
450                ..Default::default()
451            },
452            &options,
453        )
454        .unwrap();
455        assert_eq!(result.items[0]["innerBlocks"][0]["collapsed"], json!(2));
456    }
457
458    #[test]
459    fn pending_marks_and_bad_selectors() {
460        let pending = HashMap::from([(id("h1"), ChangeKind::Changed)]);
461        let options = ReadOptions {
462            pending: Some(&pending),
463            ..read_options(24_000)
464        };
465        let result = read_page(&sample(), &Selector::default(), &options).unwrap();
466        assert_eq!(result.items[0]["pending"], json!("changed"));
467        assert!(
468            read(Selector {
469                path: Some(p("h1")),
470                offset: Some(2),
471                ..Default::default()
472            })
473            .is_err()
474        );
475        assert!(
476            read(Selector {
477                path: Some("/$box".into()),
478                ..Default::default()
479            })
480            .is_err()
481        );
482        assert!(
483            read(Selector {
484                through: Some(p("p1")),
485                around: Some(1),
486                ..Default::default()
487            })
488            .is_err()
489        );
490    }
491}