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#[cfg(feature = "ts_rs")]
10use ts_rs::TS;
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)]
40#[cfg_attr(feature = "ts_rs", derive(TS))]
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    #[cfg_attr(feature = "ts_rs", ts(type = "Record<string, unknown>"))]
48    pub attributes: Map<String, Value>,
49    #[serde(rename = "innerBlocks")]
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#[cfg(test)]
368mod tests {
369    use super::*;
370
371    fn collect_ids(blocks: &Vec<GutenbergBlock>, ids: &mut Vec<Uuid>) {
372        for block in blocks {
373            ids.push(block.client_id);
374            collect_ids(&block.inner_blocks, ids);
375        }
376    }
377
378    #[test]
379    fn replace_duplicate_client_ids_makes_all_ids_unique_flat_and_nested() {
380        let dup_id = Uuid::new_v4();
381        let nested_dup_id = Uuid::new_v4();
382
383        let block_a = GutenbergBlock::empty_block_from_name("a".into()).with_id(dup_id);
384        let block_b_child_1 =
385            GutenbergBlock::empty_block_from_name("b1".into()).with_id(nested_dup_id);
386        let block_b_child_2 =
387            GutenbergBlock::empty_block_from_name("b2".into()).with_id(nested_dup_id);
388        let block_b = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
389            "b",
390            attributes! {},
391            vec![block_b_child_1, block_b_child_2],
392        )
393        .with_id(dup_id);
394
395        let input = vec![block_a, block_b];
396        let output = replace_duplicate_client_ids(input);
397
398        let mut ids: Vec<Uuid> = Vec::new();
399        collect_ids(&output, &mut ids);
400        let unique: HashSet<Uuid> = ids.iter().cloned().collect();
401        assert_eq!(
402            unique.len(),
403            ids.len(),
404            "all ids should be unique after replacement"
405        );
406    }
407
408    #[test]
409    fn validate_unique_client_ids_ok_on_unique() {
410        let a = GutenbergBlock::empty_block_from_name("a".into());
411        let b = GutenbergBlock::empty_block_from_name("b".into());
412        let c = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
413            "c",
414            attributes! {},
415            vec![GutenbergBlock::empty_block_from_name("c1".into())],
416        );
417        let input = vec![a, b, c];
418        let result = validate_unique_client_ids(input);
419        assert!(result.is_ok());
420    }
421
422    #[test]
423    fn validate_unique_client_ids_err_on_duplicate_nested() {
424        let dup_id = Uuid::new_v4();
425        let a = GutenbergBlock::empty_block_from_name("a".into()).with_id(dup_id);
426        let b_child = GutenbergBlock::empty_block_from_name("b1".into()).with_id(dup_id);
427        let b = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
428            "b",
429            attributes! {},
430            vec![b_child],
431        );
432        let input = vec![a, b];
433        let result = validate_unique_client_ids(input);
434        assert!(result.is_err());
435    }
436
437    #[test]
438    fn filter_lock_chapter_blocks_removes_inner_blocks_when_not_locked() {
439        let inner_block = GutenbergBlock::block_with_name_and_attributes(
440            "core/paragraph",
441            attributes! {
442                "content": "This should be hidden"
443            },
444        );
445        let lock_block = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
446            "moocfi/lock-chapter",
447            attributes! {},
448            vec![inner_block.clone()],
449        );
450        let regular_block = GutenbergBlock::block_with_name_and_attributes(
451            "core/heading",
452            attributes! {
453                "content": "Regular heading"
454            },
455        );
456
457        let input = vec![lock_block, regular_block];
458        let result = filter_lock_chapter_blocks(input, false);
459
460        // Lock-chapter block should have no inner blocks
461        assert_eq!(result.len(), 2);
462        assert_eq!(result[0].name, "moocfi/lock-chapter");
463        assert_eq!(result[0].inner_blocks.len(), 0);
464        // Regular block should be unaffected
465        assert_eq!(result[1].name, "core/heading");
466    }
467
468    #[test]
469    fn filter_lock_chapter_blocks_preserves_inner_blocks_when_locked() {
470        let inner_block = GutenbergBlock::block_with_name_and_attributes(
471            "core/paragraph",
472            attributes! {
473                "content": "This should be visible"
474            },
475        );
476        let lock_block = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
477            "moocfi/lock-chapter",
478            attributes! {},
479            vec![inner_block.clone()],
480        );
481
482        let input = vec![lock_block];
483        let result = filter_lock_chapter_blocks(input, true);
484
485        // Lock-chapter block should preserve inner blocks
486        assert_eq!(result.len(), 1);
487        assert_eq!(result[0].name, "moocfi/lock-chapter");
488        assert_eq!(result[0].inner_blocks.len(), 1);
489        assert_eq!(result[0].inner_blocks[0].name, "core/paragraph");
490    }
491
492    #[test]
493    fn filter_lock_chapter_blocks_handles_nested_blocks() {
494        let nested_inner = GutenbergBlock::block_with_name_and_attributes(
495            "core/paragraph",
496            attributes! {
497                "content": "Nested content"
498            },
499        );
500        let nested_lock = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
501            "moocfi/lock-chapter",
502            attributes! {},
503            vec![nested_inner],
504        );
505        let outer_lock = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
506            "moocfi/lock-chapter",
507            attributes! {},
508            vec![nested_lock],
509        );
510
511        let input = vec![outer_lock];
512        let result = filter_lock_chapter_blocks(input, false);
513
514        // All lock-chapter blocks should have inner blocks removed
515        assert_eq!(result.len(), 1);
516        assert_eq!(result[0].name, "moocfi/lock-chapter");
517        assert_eq!(result[0].inner_blocks.len(), 0);
518    }
519
520    #[test]
521    fn filter_lock_chapter_blocks_handles_nested_blocks_when_locked() {
522        let nested_inner = GutenbergBlock::block_with_name_and_attributes(
523            "core/paragraph",
524            attributes! {
525                "content": "Nested content"
526            },
527        );
528        let nested_lock = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
529            "moocfi/lock-chapter",
530            attributes! {},
531            vec![nested_inner.clone()],
532        );
533        let outer_lock = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
534            "moocfi/lock-chapter",
535            attributes! {},
536            vec![nested_lock],
537        );
538
539        let input = vec![outer_lock];
540        let result = filter_lock_chapter_blocks(input, true);
541
542        // All lock-chapter blocks should preserve inner blocks
543        assert_eq!(result.len(), 1);
544        assert_eq!(result[0].name, "moocfi/lock-chapter");
545        assert_eq!(result[0].inner_blocks.len(), 1);
546        assert_eq!(result[0].inner_blocks[0].name, "moocfi/lock-chapter");
547        assert_eq!(result[0].inner_blocks[0].inner_blocks.len(), 1);
548        assert_eq!(
549            result[0].inner_blocks[0].inner_blocks[0].name,
550            "core/paragraph"
551        );
552    }
553
554    #[test]
555    fn filter_lock_chapter_blocks_does_not_affect_non_lock_blocks() {
556        let paragraph = GutenbergBlock::block_with_name_and_attributes(
557            "core/paragraph",
558            attributes! {
559                "content": "Regular paragraph"
560            },
561        );
562        let heading = GutenbergBlock::block_with_name_and_attributes(
563            "core/heading",
564            attributes! {
565                "content": "Regular heading"
566            },
567        );
568        let list_item = GutenbergBlock::block_with_name_and_attributes(
569            "core/list-item",
570            attributes! {
571                "content": "List item"
572            },
573        );
574        let list = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
575            "core/list",
576            attributes! {},
577            vec![list_item],
578        );
579
580        let input = vec![paragraph, heading, list];
581        let result = filter_lock_chapter_blocks(input, false);
582
583        // All blocks should be preserved
584        assert_eq!(result.len(), 3);
585        assert_eq!(result[0].name, "core/paragraph");
586        assert_eq!(result[1].name, "core/heading");
587        assert_eq!(result[2].name, "core/list");
588        assert_eq!(result[2].inner_blocks.len(), 1);
589    }
590}