1use serde_json::{Map, Value, json};
4use uuid::Uuid;
5
6use super::window::{HasId, list_read, reject_window, sibling_window, single_unit, window};
7use super::{ReadMode, ReadOptions, ReadResult, Selector, check_selector, field_result};
8use crate::engine::course_structure::{
9 CourseStructure, EntityRef, ListRef, Loc, RENDERED_KEYS, parse_structure_path,
10};
11use crate::engine::error::{EditError, EditErrorCode};
12use crate::engine::path::parse_uuid;
13
14impl HasId for EntityRef<'_> {
15 fn id(&self) -> Uuid {
16 EntityRef::id(*self)
17 }
18}
19
20struct Renderer<'a> {
21 doc: &'a CourseStructure,
22 mode: ReadMode,
23 options: &'a ReadOptions<'a>,
24}
25
26impl Renderer<'_> {
27 fn entity(&self, entity: EntityRef) -> Value {
28 let mut value = serde_json::to_value(entity).unwrap_or_else(|error| {
29 tracing::error!(%error, "a course structure entity didn't serialize");
30 Value::Null
31 });
32 if let Value::Object(object) = &mut value {
33 self.add_read_keys(object, entity);
34 }
35 value
36 }
37
38 fn add_read_keys(&self, object: &mut Map<String, Value>, entity: EntityRef) {
41 let id = entity.id();
42 object.insert("path".into(), json!(format!("/{id}")));
43 object.insert("kind".into(), json!(entity.kind().noun()));
44 if matches!(entity, EntityRef::Page(_)) && self.doc.is_chapter_front_page(id) {
45 object.insert("is_chapter_front_page".into(), json!(true));
46 }
47 if let Some(pending) = self.options.pending.and_then(|p| p.get(&id)) {
48 object.insert("pending".into(), json!(pending));
49 }
50 let (key, children): (&str, Vec<EntityRef>) = match entity {
51 EntityRef::Module(module) => (
52 "chapters",
53 module.chapters.iter().map(EntityRef::Chapter).collect(),
54 ),
55 EntityRef::Chapter(chapter) => {
56 if self.mode == ReadMode::Outline {
57 object.remove("color");
58 }
59 ("pages", chapter.pages.iter().map(EntityRef::Page).collect())
60 }
61 EntityRef::Page(_) => return,
62 };
63 if let Some(Value::Array(rendered)) = object.get_mut(key) {
64 for (child, child_entity) in rendered.iter_mut().zip(children) {
65 if let Value::Object(child) = child {
66 self.add_read_keys(child, child_entity);
67 }
68 }
69 }
70 }
71
72 fn course(&self, doc: &CourseStructure) -> Value {
73 json!({
74 "id": doc.id,
75 "path": "/course",
76 "kind": "course",
77 "pages": doc.pages.iter().map(|p| self.entity(EntityRef::Page(p))).collect::<Vec<_>>(),
78 "modules": doc.modules.iter().map(|m| self.entity(EntityRef::Module(m))).collect::<Vec<_>>(),
79 })
80 }
81}
82
83pub fn read_structure(
85 doc: &CourseStructure,
86 selector: &Selector,
87 options: &ReadOptions,
88) -> Result<ReadResult, EditError> {
89 check_selector(selector)?;
90 let mode = selector.mode.unwrap_or_default();
91 let renderer = Renderer { doc, mode, options };
92 let render = |entity: &EntityRef| renderer.entity(*entity);
93 let whole_course = || -> Result<ReadResult, EditError> {
94 reject_window(selector, "the whole course")?;
95 Ok(single_unit(selector, renderer.course(doc), options))
96 };
97 let path = selector.path.as_deref().unwrap_or("");
98 if path.is_empty() || path == "/" {
99 return whole_course();
100 }
101 let parsed = parse_structure_path(path).ok_or_else(|| {
102 EditError::new(
103 EditErrorCode::InvalidPath,
104 format!("path \"{path}\" must start with /"),
105 )
106 })?;
107 if parsed.is_course(doc.id) {
108 let Some(child) = parsed.child.as_deref() else {
109 return whole_course();
110 };
111 let list = ListRef::of_course(child)
112 .filter(|_| !parsed.is_deeper)
113 .ok_or_else(|| {
114 EditError::new(
115 EditErrorCode::InvalidPath,
116 "the course's lists are /course/pages and /course/modules",
117 )
118 })?;
119 return read_list(doc, selector, list, options, &render);
120 }
121 let id = parse_uuid(&parsed.root, path)?;
122 let Loc { list, index } = doc.locate(id).ok_or_else(|| {
123 EditError::new(
124 EditErrorCode::NotFound,
125 format!("no module, chapter or page {id}"),
126 )
127 })?;
128 let siblings = doc.list(list);
129 match (parsed.child.as_deref(), parsed.is_deeper) {
130 (None, _) => {
131 if selector.offset.is_some() {
132 return Err(EditError::new(
133 EditErrorCode::InvalidValue,
134 "offset works on a list path such as /<chapter id>/pages",
135 ));
136 }
137 let sibling_ids: Vec<Uuid> = siblings.iter().map(|e| e.id()).collect();
138 let Some((start, end)) = sibling_window(selector, index, &sibling_ids)? else {
139 return Ok(single_unit(
140 selector,
141 renderer.entity(siblings[index]),
142 options,
143 ));
144 };
145 Ok(window(
146 selector,
147 &siblings,
148 start,
149 end,
150 Some(list.path()),
151 options,
152 &render,
153 ))
154 }
155 (Some(key @ ("pages" | "modules" | "chapters")), false) => {
156 let child_list = ListRef::of_entity(list.kind(), id, key).ok_or_else(|| {
157 EditError::new(
158 EditErrorCode::InvalidPath,
159 format!(
160 "/{id}/{key} isn't a list; lists are /<module id>/chapters and /<chapter id>/pages"
161 ),
162 )
163 })?;
164 read_list(doc, selector, child_list, options, &render)
165 }
166 (Some(field), false) => {
167 reject_window(selector, "a field")?;
168 let full = Renderer {
169 doc,
170 mode: ReadMode::Raw,
171 options,
172 }
173 .entity(siblings[index]);
174 let value = full
175 .get(field)
176 .filter(|_| field != "id" && !RENDERED_KEYS.contains(&field))
177 .cloned()
178 .ok_or_else(|| {
179 EditError::new(EditErrorCode::NotFound, format!("{path} isn't a field"))
180 })?;
181 Ok(field_result(selector, value))
182 }
183 (Some(_), true) => Err(EditError::new(
184 EditErrorCode::InvalidPath,
185 format!("\"{path}\" goes deeper than an entity field"),
186 )),
187 }
188}
189
190fn read_list(
192 doc: &CourseStructure,
193 selector: &Selector,
194 list: ListRef,
195 options: &ReadOptions,
196 render: &dyn Fn(&EntityRef) -> Value,
197) -> Result<ReadResult, EditError> {
198 list_read(
199 selector,
200 &doc.list(list),
201 Some(list.path()),
202 options,
203 render,
204 "an entity",
205 )
206}
207
208#[cfg(test)]
209mod tests {
210 use pretty_assertions::assert_eq;
211
212 use super::*;
213 use crate::engine::course_structure::StructureModule;
214 use crate::engine::read::StopReason;
215 use crate::test_fixtures::{id, p, read_options, structure_chapter, structure_page};
216
217 fn sample() -> CourseStructure {
218 let page = |name: &str, path: &str| structure_page(name, &name.to_uppercase(), path);
219 let chapters = (1..=15)
220 .map(|n| {
221 structure_chapter(
222 &format!("ch{n}"),
223 n,
224 &format!("Chapter {n}"),
225 (0..10)
226 .map(|k| page(&format!("ch{n}pg{k}"), &format!("/chapter-{n}/p{k}")))
227 .collect(),
228 )
229 })
230 .collect();
231 CourseStructure {
232 id: id("c1"),
233 pages: vec![page("pg0", "/")],
234 modules: vec![StructureModule {
235 id: id("m1"),
236 default: true,
237 name: None,
238 chapters,
239 }],
240 }
241 }
242
243 #[test]
244 fn whole_course_collapses_when_over_budget() {
245 let options = read_options(2_000);
246 let result = read_structure(&sample(), &Selector::default(), &options).unwrap();
247 assert_eq!(result.items[0]["modules"][0]["collapsed"], json!(1));
248 assert_eq!(
249 result.items[0]["modules"][0]["path"],
250 json!("/course/modules")
251 );
252 }
253
254 #[test]
255 fn chapter_list_window_and_continuation() {
256 let selector = Selector {
257 path: Some(format!("{}/chapters", p("m1"))),
258 limit: Some(4),
259 ..Default::default()
260 };
261 let result = read_structure(&sample(), &selector, &read_options(24_000)).unwrap();
262 assert_eq!(result.items.len(), 4);
263 assert_eq!(result.total, Some(15));
264 assert_eq!(result.stopped_by, Some(StopReason::Limit));
265 assert_eq!(result.next.unwrap().path, Some(p("ch5")));
266 }
267
268 #[test]
269 fn entity_range_and_field() {
270 let range = Selector {
271 path: Some(p("ch2pg3")),
272 through: Some(p("ch2pg5")),
273 ..Default::default()
274 };
275 let result = read_structure(&sample(), &range, &read_options(24_000)).unwrap();
276 assert_eq!(result.items.len(), 3);
277 assert_eq!(result.items[0]["kind"], json!("page"));
278 let field = Selector {
279 path: Some(format!("{}/deadline", p("ch3"))),
280 ..Default::default()
281 };
282 assert_eq!(
283 read_structure(&sample(), &field, &read_options(24_000))
284 .unwrap()
285 .value,
286 Some(Value::Null)
287 );
288 let chapter = Selector {
289 path: Some(p("ch3")),
290 ..Default::default()
291 };
292 let result = read_structure(&sample(), &chapter, &read_options(24_000)).unwrap();
293 assert_eq!(result.items[0]["pages"].as_array().unwrap().len(), 10);
294 for rendered_only in RENDERED_KEYS {
295 let selector = Selector {
296 path: Some(format!("{}/{rendered_only}", p("ch3"))),
297 ..Default::default()
298 };
299 assert!(read_structure(&sample(), &selector, &read_options(24_000)).is_err());
300 }
301 }
302
303 #[test]
304 fn a_large_only_module_collapses_when_its_list_is_read() {
305 let options = read_options(2_000);
306 let selector = Selector {
307 path: Some("/course/modules".into()),
308 ..Default::default()
309 };
310 let result = read_structure(&sample(), &selector, &options).unwrap();
311 assert_eq!(result.items.len(), 1);
312 assert_eq!(result.items[0]["chapters"][0]["collapsed"], json!(15));
313 }
314}