1use 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
18enum PageAddress {
20 List(Option<(Uuid, Vec<usize>)>),
22 Block(Uuid, Vec<usize>),
23 Name(Vec<usize>),
24 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
77pub 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
157pub(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 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(), ¤t, 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}