1pub 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
13pub(crate) const RENDERED_BLOCK_KEYS: [&str; 5] =
16 ["path", "pending", "children", "preview", "exercise_id"];
17
18pub(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
38const EXERCISE_INTERNAL_BLOCKS: [&str; 4] = [
40 "moocfi/exercise-settings",
41 "moocfi/exercise-slides",
42 "moocfi/exercise-slide",
43 "moocfi/exercise-task",
44];
45
46pub fn is_exercise(block: &GutenbergBlock) -> bool {
48 block.name == EXERCISE_BLOCK
49}
50
51pub fn is_exercise_part(name: &str) -> bool {
53 name == EXERCISE_BLOCK || EXERCISE_INTERNAL_BLOCKS.contains(&name)
54}
55
56pub 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
70pub 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
84pub 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
100pub 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
112pub 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
124pub(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
138pub 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
145pub 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
165pub 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
188pub 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
202pub 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
210pub(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#[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 pub page_id: Uuid,
228}
229
230pub 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
255pub 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
271pub 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}