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