Skip to main content

headless_lms_utils/
document_schema_processor.rs

1use std::{
2    cell::RefCell,
3    collections::{HashMap, HashSet},
4    rc::Rc,
5};
6
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9
10use utoipa::ToSchema;
11use uuid::Uuid;
12
13/// Blocks that are not allowed in top-level pages (pages without chapter_id).
14/// Note: This is NOT for chapter front pages. Chapter front pages can contain these blocks.
15static DISALLOWED_BLOCKS_IN_TOP_LEVEL_PAGES: &[&str] = &[
16    "moocfi/exercise",
17    "moocfi/exercise-task",
18    "moocfi/exercises-in-chapter",
19    "moocfi/pages-in-chapter",
20    "moocfi/exercises-in-chapter",
21    "moocfi/chapter-progress",
22];
23
24pub use crate::attributes;
25use crate::prelude::*;
26
27#[macro_export]
28macro_rules! attributes {
29    () => {{
30        serde_json::Map::<String, serde_json::Value>::new()
31    }};
32    ($($name: tt: $value: expr_2021),+ $(,)*) => {{
33        let mut map = serde_json::Map::<String, serde_json::Value>::new();
34        $(map.insert($name.into(), serde_json::json!($value));)*
35        map
36    }};
37}
38
39#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
40
41pub struct GutenbergBlock {
42    #[serde(rename = "clientId")]
43    pub client_id: Uuid,
44    pub name: String,
45    #[serde(rename = "isValid")]
46    pub is_valid: bool,
47    pub attributes: Map<String, Value>,
48    #[serde(rename = "innerBlocks")]
49    #[schema(no_recursion)]
50    pub inner_blocks: Vec<GutenbergBlock>,
51}
52
53impl GutenbergBlock {
54    pub fn paragraph(paragraph: &str) -> Self {
55        Self::block_with_name_and_attributes(
56            "core/paragraph",
57            attributes! {
58              "content": paragraph.to_string(),
59              "dropCap": false
60            },
61        )
62    }
63
64    pub fn empty_block_from_name(name: String) -> Self {
65        GutenbergBlock {
66            client_id: Uuid::new_v4(),
67            name,
68            is_valid: true,
69            attributes: Map::new(),
70            inner_blocks: vec![],
71        }
72    }
73    pub fn block_with_name_and_attributes(name: &str, attributes: Map<String, Value>) -> Self {
74        GutenbergBlock {
75            client_id: Uuid::new_v4(),
76            name: name.to_string(),
77            is_valid: true,
78            attributes,
79            inner_blocks: vec![],
80        }
81    }
82    pub fn block_with_name_attributes_and_inner_blocks(
83        name: &str,
84        attributes: Map<String, Value>,
85        inner_blocks: Vec<GutenbergBlock>,
86    ) -> Self {
87        GutenbergBlock {
88            client_id: Uuid::new_v4(),
89            name: name.to_string(),
90            is_valid: true,
91            attributes,
92            inner_blocks,
93        }
94    }
95    pub fn hero_section(title: &str, sub_title: &str) -> Self {
96        GutenbergBlock::block_with_name_and_attributes(
97            "moocfi/hero-section",
98            attributes! {
99                "title": title,
100                "subtitle": sub_title
101            },
102        )
103    }
104    pub fn landing_page_hero_section(title: &str, sub_title: &str) -> Self {
105        GutenbergBlock::block_with_name_attributes_and_inner_blocks(
106            "moocfi/landing-page-hero-section",
107            attributes! {"title": title},
108            vec![GutenbergBlock::block_with_name_and_attributes(
109                "core/paragraph",
110                attributes! {
111                    "align": "center",
112                    "content": sub_title,
113                    "dropCap": false,
114                    "placeholder": "Insert short description of course..."
115                },
116            )],
117        )
118    }
119    pub fn course_objective_section() -> Self {
120        GutenbergBlock::block_with_name_attributes_and_inner_blocks(
121            "moocfi/course-objective-section",
122            attributes! {
123                "title": "In this course you'll..."
124            },
125            vec![GutenbergBlock::block_with_name_attributes_and_inner_blocks(
126                "core/columns",
127                attributes! {
128                    "isStackedOnMobile": true
129                },
130                vec![
131                    GutenbergBlock::block_with_name_attributes_and_inner_blocks(
132                        "core/column",
133                        attributes! {},
134                        vec![
135                            GutenbergBlock::block_with_name_and_attributes(
136                                "core/heading",
137                                attributes! {
138                                    "textAlign": "center",
139                                    "level": 3,
140                                    "content": "Objective #1",
141                                    "anchor": "objective-1",
142                                },
143                            ),
144                            GutenbergBlock::block_with_name_and_attributes(
145                                "core/paragraph",
146                                attributes! {
147                                    "align": "center",
148                                    "dropCap": false,
149                                    "content": "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit..."
150                                },
151                            ),
152                        ],
153                    ),
154                    GutenbergBlock::block_with_name_attributes_and_inner_blocks(
155                        "core/column",
156                        attributes! {},
157                        vec![
158                            GutenbergBlock::block_with_name_and_attributes(
159                                "core/heading",
160                                attributes! {
161                                    "textAlign": "center",
162                                    "level": 3,
163                                    "content": "Objective #2",
164                                    "anchor": "objective-2",
165                                },
166                            ),
167                            GutenbergBlock::block_with_name_and_attributes(
168                                "core/paragraph",
169                                attributes! {
170                                    "align": "center",
171                                    "dropCap": false,
172                                    "content": "There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain..."
173                                },
174                            ),
175                        ],
176                    ),
177                    GutenbergBlock::block_with_name_attributes_and_inner_blocks(
178                        "core/column",
179                        attributes! {},
180                        vec![
181                            GutenbergBlock::block_with_name_and_attributes(
182                                "core/heading",
183                                attributes! {
184                                    "textAlign": "center",
185                                    "level": 3,
186                                    "content": "Objective #3",
187                                    "anchor": "objective-3",
188                                },
189                            ),
190                            GutenbergBlock::block_with_name_and_attributes(
191                                "core/paragraph",
192                                attributes! {
193                                    "align": "center",
194                                    "dropCap": false,
195                                    "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas a tempor risus. Morbi at sapien."
196                                },
197                            ),
198                        ],
199                    ),
200                ],
201            )],
202        )
203    }
204
205    pub fn landing_page_copy_text(heading: &str, content: &str) -> Self {
206        GutenbergBlock::block_with_name_attributes_and_inner_blocks(
207            "moocfi/landing-page-copy-text",
208            attributes! {},
209            vec![GutenbergBlock::block_with_name_attributes_and_inner_blocks(
210                "core/columns",
211                attributes! {
212                    "isStackedOnMobile": true
213                },
214                vec![GutenbergBlock::block_with_name_attributes_and_inner_blocks(
215                    "core/column",
216                    attributes! {},
217                    vec![
218                        GutenbergBlock::block_with_name_and_attributes(
219                            "core/heading",
220                            attributes! {
221                                "content": heading,
222                                "level": 2,
223                                "placeholder": heading,
224                                "anchor": heading,
225                                "textAlign": "left"
226                            },
227                        ),
228                        GutenbergBlock::block_with_name_and_attributes(
229                            "core/paragraph",
230                            attributes! {
231                                "content": content,
232                                "dropCap": false
233                            },
234                        ),
235                    ],
236                )],
237            )],
238        )
239    }
240
241    pub fn with_id(self, id: Uuid) -> Self {
242        Self {
243            client_id: id,
244            ..self
245        }
246    }
247}
248
249/// Checks if blocks contain any that are not allowed in top-level pages (pages without chapter_id).
250/// Note: This is NOT for chapter front pages. Chapter front pages can contain these blocks.
251pub fn contains_blocks_not_allowed_in_top_level_pages(input: &[GutenbergBlock]) -> bool {
252    input
253        .iter()
254        .any(|block| DISALLOWED_BLOCKS_IN_TOP_LEVEL_PAGES.contains(&block.name.as_str()))
255}
256
257pub fn remap_ids_in_content(
258    content: &serde_json::Value,
259    chaged_ids: HashMap<Uuid, Uuid>,
260) -> UtilResult<serde_json::Value> {
261    // naive implementation for now because the structure of the content was not decided at the time of writing this.
262    // In the future we could only edit the necessary fields.
263    let mut content_str = serde_json::to_string(content)?;
264    for (k, v) in chaged_ids.into_iter() {
265        content_str = content_str.replace(&k.to_string(), &v.to_string());
266    }
267    Ok(serde_json::from_str(&content_str)?)
268}
269
270/** Removes the private spec from exercise tasks. */
271pub fn remove_sensitive_attributes(input: Vec<GutenbergBlock>) -> Vec<GutenbergBlock> {
272    input
273        .into_iter()
274        .map(|mut block| {
275            if block.name == "moocfi/exercise-task" {
276                block.attributes = Map::new();
277            }
278            block.inner_blocks = remove_sensitive_attributes(block.inner_blocks);
279            block
280        })
281        .collect()
282}
283
284/// Filters lock-chapter blocks' inner blocks based on whether the chapter is locked.
285/// If the chapter is not locked, inner blocks are removed to prevent unauthorized access.
286/// This function recursively processes all blocks to handle nested structures.
287pub fn filter_lock_chapter_blocks(
288    input: Vec<GutenbergBlock>,
289    is_locked: bool,
290) -> Vec<GutenbergBlock> {
291    input
292        .into_iter()
293        .map(|mut block| {
294            if block.name == "moocfi/lock-chapter" {
295                if !is_locked {
296                    // Remove inner blocks if chapter is not locked
297                    block.inner_blocks = vec![];
298                } else {
299                    // Recursively process inner blocks if locked
300                    block.inner_blocks = filter_lock_chapter_blocks(block.inner_blocks, is_locked);
301                }
302            } else {
303                // Recursively process all blocks
304                block.inner_blocks = filter_lock_chapter_blocks(block.inner_blocks, is_locked);
305            }
306            block
307        })
308        .collect()
309}
310
311/// Replaces duplicate client IDs with new unique IDs in Gutenberg blocks.
312pub fn replace_duplicate_client_ids(input: Vec<GutenbergBlock>) -> Vec<GutenbergBlock> {
313    let seen_ids = Rc::new(RefCell::new(HashSet::new()));
314
315    replace_duplicate_client_ids_inner(input, seen_ids)
316}
317
318fn replace_duplicate_client_ids_inner(
319    mut input: Vec<GutenbergBlock>,
320    seen_ids: Rc<RefCell<HashSet<Uuid>>>,
321) -> Vec<GutenbergBlock> {
322    for block in input.iter_mut() {
323        let mut seen_ids_borrow = seen_ids.borrow_mut();
324        if seen_ids_borrow.contains(&block.client_id) {
325            block.client_id = Uuid::new_v4();
326        } else {
327            seen_ids_borrow.insert(block.client_id);
328        }
329        drop(seen_ids_borrow); // Release the borrow before recursive call
330
331        block.inner_blocks =
332            replace_duplicate_client_ids_inner(block.inner_blocks.clone(), seen_ids.clone());
333    }
334    input
335}
336
337/// Validates that all client IDs in the Gutenberg blocks are unique.
338/// Returns an error if duplicate client IDs are found.
339pub fn validate_unique_client_ids(input: Vec<GutenbergBlock>) -> UtilResult<Vec<GutenbergBlock>> {
340    let seen_ids = Rc::new(RefCell::new(HashSet::new()));
341
342    validate_unique_client_ids_inner(input, seen_ids)
343}
344
345fn validate_unique_client_ids_inner(
346    input: Vec<GutenbergBlock>,
347    seen_ids: Rc<RefCell<HashSet<Uuid>>>,
348) -> UtilResult<Vec<GutenbergBlock>> {
349    for block in input.iter() {
350        let mut seen_ids_borrow = seen_ids.borrow_mut();
351        if seen_ids_borrow.contains(&block.client_id) {
352            return Err(UtilError::new(
353                UtilErrorType::Other,
354                format!("Duplicate client ID found: {}", block.client_id),
355                None,
356            ));
357        } else {
358            seen_ids_borrow.insert(block.client_id);
359        }
360        drop(seen_ids_borrow); // Release the borrow before recursive call
361
362        validate_unique_client_ids_inner(block.inner_blocks.clone(), seen_ids.clone())?;
363    }
364    Ok(input)
365}
366
367/// Get the content of each block and of all its inner blocks as a String.
368fn get_blocks_content_recursive(blocks: Vec<GutenbergBlock>) -> Result<String, serde_json::Error> {
369    let content = blocks
370        .iter()
371        .map(|b| {
372            let c = b.attributes.get("content");
373            let c2 = if let Some(v) = c {
374                serde_json::to_string(v)?
375            } else {
376                "".to_string()
377            };
378            let inner = b.inner_blocks.to_owned();
379            let c3 = get_blocks_content_recursive(inner)?;
380            Ok(c2 + &c3)
381        })
382        .collect::<Result<Vec<String>, serde_json::Error>>()?;
383
384    Ok(content.join(""))
385}
386
387/// Get learning objectives from a vec of Gutenberg blocks. Works recursively and
388/// inspects the inner blocks. Returns an empty string if no objectives are found.
389/// Returns the objectives of the whole Vec argument, joined together into a String.
390pub fn get_learning_objectives(blocks: Vec<GutenbergBlock>) -> Result<String, serde_json::Error> {
391    let objectives = blocks
392        .iter()
393        .map(|x| {
394            if x.name == "moocfi/learning-objectives" || x.name == "moocfi/course-objective-section"
395            {
396                get_blocks_content_recursive(vec![x.to_owned()])
397            } else {
398                get_learning_objectives(x.inner_blocks.to_owned())
399            }
400        })
401        .collect::<Result<Vec<String>, serde_json::Error>>()?;
402    Ok(objectives.join(""))
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    fn collect_ids(blocks: &Vec<GutenbergBlock>, ids: &mut Vec<Uuid>) {
410        for block in blocks {
411            ids.push(block.client_id);
412            collect_ids(&block.inner_blocks, ids);
413        }
414    }
415
416    #[test]
417    fn replace_duplicate_client_ids_makes_all_ids_unique_flat_and_nested() {
418        let dup_id = Uuid::new_v4();
419        let nested_dup_id = Uuid::new_v4();
420
421        let block_a = GutenbergBlock::empty_block_from_name("a".into()).with_id(dup_id);
422        let block_b_child_1 =
423            GutenbergBlock::empty_block_from_name("b1".into()).with_id(nested_dup_id);
424        let block_b_child_2 =
425            GutenbergBlock::empty_block_from_name("b2".into()).with_id(nested_dup_id);
426        let block_b = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
427            "b",
428            attributes! {},
429            vec![block_b_child_1, block_b_child_2],
430        )
431        .with_id(dup_id);
432
433        let input = vec![block_a, block_b];
434        let output = replace_duplicate_client_ids(input);
435
436        let mut ids: Vec<Uuid> = Vec::new();
437        collect_ids(&output, &mut ids);
438        let unique: HashSet<Uuid> = ids.iter().cloned().collect();
439        assert_eq!(
440            unique.len(),
441            ids.len(),
442            "all ids should be unique after replacement"
443        );
444    }
445
446    #[test]
447    fn validate_unique_client_ids_ok_on_unique() {
448        let a = GutenbergBlock::empty_block_from_name("a".into());
449        let b = GutenbergBlock::empty_block_from_name("b".into());
450        let c = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
451            "c",
452            attributes! {},
453            vec![GutenbergBlock::empty_block_from_name("c1".into())],
454        );
455        let input = vec![a, b, c];
456        let result = validate_unique_client_ids(input);
457        assert!(result.is_ok());
458    }
459
460    #[test]
461    fn validate_unique_client_ids_err_on_duplicate_nested() {
462        let dup_id = Uuid::new_v4();
463        let a = GutenbergBlock::empty_block_from_name("a".into()).with_id(dup_id);
464        let b_child = GutenbergBlock::empty_block_from_name("b1".into()).with_id(dup_id);
465        let b = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
466            "b",
467            attributes! {},
468            vec![b_child],
469        );
470        let input = vec![a, b];
471        let result = validate_unique_client_ids(input);
472        assert!(result.is_err());
473    }
474
475    #[test]
476    fn filter_lock_chapter_blocks_removes_inner_blocks_when_not_locked() {
477        let inner_block = GutenbergBlock::block_with_name_and_attributes(
478            "core/paragraph",
479            attributes! {
480                "content": "This should be hidden"
481            },
482        );
483        let lock_block = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
484            "moocfi/lock-chapter",
485            attributes! {},
486            vec![inner_block.clone()],
487        );
488        let regular_block = GutenbergBlock::block_with_name_and_attributes(
489            "core/heading",
490            attributes! {
491                "content": "Regular heading"
492            },
493        );
494
495        let input = vec![lock_block, regular_block];
496        let result = filter_lock_chapter_blocks(input, false);
497
498        // Lock-chapter block should have no inner blocks
499        assert_eq!(result.len(), 2);
500        assert_eq!(result[0].name, "moocfi/lock-chapter");
501        assert_eq!(result[0].inner_blocks.len(), 0);
502        // Regular block should be unaffected
503        assert_eq!(result[1].name, "core/heading");
504    }
505
506    #[test]
507    fn filter_lock_chapter_blocks_preserves_inner_blocks_when_locked() {
508        let inner_block = GutenbergBlock::block_with_name_and_attributes(
509            "core/paragraph",
510            attributes! {
511                "content": "This should be visible"
512            },
513        );
514        let lock_block = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
515            "moocfi/lock-chapter",
516            attributes! {},
517            vec![inner_block.clone()],
518        );
519
520        let input = vec![lock_block];
521        let result = filter_lock_chapter_blocks(input, true);
522
523        // Lock-chapter block should preserve inner blocks
524        assert_eq!(result.len(), 1);
525        assert_eq!(result[0].name, "moocfi/lock-chapter");
526        assert_eq!(result[0].inner_blocks.len(), 1);
527        assert_eq!(result[0].inner_blocks[0].name, "core/paragraph");
528    }
529
530    #[test]
531    fn filter_lock_chapter_blocks_handles_nested_blocks() {
532        let nested_inner = GutenbergBlock::block_with_name_and_attributes(
533            "core/paragraph",
534            attributes! {
535                "content": "Nested content"
536            },
537        );
538        let nested_lock = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
539            "moocfi/lock-chapter",
540            attributes! {},
541            vec![nested_inner],
542        );
543        let outer_lock = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
544            "moocfi/lock-chapter",
545            attributes! {},
546            vec![nested_lock],
547        );
548
549        let input = vec![outer_lock];
550        let result = filter_lock_chapter_blocks(input, false);
551
552        // All lock-chapter blocks should have inner blocks removed
553        assert_eq!(result.len(), 1);
554        assert_eq!(result[0].name, "moocfi/lock-chapter");
555        assert_eq!(result[0].inner_blocks.len(), 0);
556    }
557
558    #[test]
559    fn filter_lock_chapter_blocks_handles_nested_blocks_when_locked() {
560        let nested_inner = GutenbergBlock::block_with_name_and_attributes(
561            "core/paragraph",
562            attributes! {
563                "content": "Nested content"
564            },
565        );
566        let nested_lock = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
567            "moocfi/lock-chapter",
568            attributes! {},
569            vec![nested_inner.clone()],
570        );
571        let outer_lock = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
572            "moocfi/lock-chapter",
573            attributes! {},
574            vec![nested_lock],
575        );
576
577        let input = vec![outer_lock];
578        let result = filter_lock_chapter_blocks(input, true);
579
580        // All lock-chapter blocks should preserve inner blocks
581        assert_eq!(result.len(), 1);
582        assert_eq!(result[0].name, "moocfi/lock-chapter");
583        assert_eq!(result[0].inner_blocks.len(), 1);
584        assert_eq!(result[0].inner_blocks[0].name, "moocfi/lock-chapter");
585        assert_eq!(result[0].inner_blocks[0].inner_blocks.len(), 1);
586        assert_eq!(
587            result[0].inner_blocks[0].inner_blocks[0].name,
588            "core/paragraph"
589        );
590    }
591
592    #[test]
593    fn filter_lock_chapter_blocks_does_not_affect_non_lock_blocks() {
594        let paragraph = GutenbergBlock::block_with_name_and_attributes(
595            "core/paragraph",
596            attributes! {
597                "content": "Regular paragraph"
598            },
599        );
600        let heading = GutenbergBlock::block_with_name_and_attributes(
601            "core/heading",
602            attributes! {
603                "content": "Regular heading"
604            },
605        );
606        let list_item = GutenbergBlock::block_with_name_and_attributes(
607            "core/list-item",
608            attributes! {
609                "content": "List item"
610            },
611        );
612        let list = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
613            "core/list",
614            attributes! {},
615            vec![list_item],
616        );
617
618        let input = vec![paragraph, heading, list];
619        let result = filter_lock_chapter_blocks(input, false);
620
621        // All blocks should be preserved
622        assert_eq!(result.len(), 3);
623        assert_eq!(result[0].name, "core/paragraph");
624        assert_eq!(result[1].name, "core/heading");
625        assert_eq!(result[2].name, "core/list");
626        assert_eq!(result[2].inner_blocks.len(), 1);
627    }
628}