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
13static DISALLOWED_BLOCKS_IN_TOP_LEVEL_PAGES: &[&str] = &[
14    "moocfi/exercise",
15    "moocfi/exercise-task",
16    "moocfi/exercises-in-chapter",
17    "moocfi/pages-in-chapter",
18    "moocfi/exercises-in-chapter",
19    "moocfi/chapter-progress",
20];
21
22pub use crate::attributes;
23use crate::prelude::*;
24
25#[macro_export]
26macro_rules! attributes {
27    () => {{
28        serde_json::Map::<String, serde_json::Value>::new()
29    }};
30    ($($name: tt: $value: expr_2021),+ $(,)*) => {{
31        let mut map = serde_json::Map::<String, serde_json::Value>::new();
32        $(map.insert($name.into(), serde_json::json!($value));)*
33        map
34    }};
35}
36
37#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
38#[cfg_attr(feature = "ts_rs", derive(TS))]
39pub struct GutenbergBlock {
40    #[serde(rename = "clientId")]
41    pub client_id: Uuid,
42    pub name: String,
43    #[serde(rename = "isValid")]
44    pub is_valid: bool,
45    #[cfg_attr(feature = "ts_rs", ts(type = "Record<string, unknown>"))]
46    pub attributes: Map<String, Value>,
47    #[serde(rename = "innerBlocks")]
48    pub inner_blocks: Vec<GutenbergBlock>,
49}
50
51impl GutenbergBlock {
52    pub fn paragraph(paragraph: &str) -> Self {
53        Self::block_with_name_and_attributes(
54            "core/paragraph",
55            attributes! {
56              "content": paragraph.to_string(),
57              "dropCap": false
58            },
59        )
60    }
61
62    pub fn empty_block_from_name(name: String) -> Self {
63        GutenbergBlock {
64            client_id: Uuid::new_v4(),
65            name,
66            is_valid: true,
67            attributes: Map::new(),
68            inner_blocks: vec![],
69        }
70    }
71    pub fn block_with_name_and_attributes(name: &str, attributes: Map<String, Value>) -> Self {
72        GutenbergBlock {
73            client_id: Uuid::new_v4(),
74            name: name.to_string(),
75            is_valid: true,
76            attributes,
77            inner_blocks: vec![],
78        }
79    }
80    pub fn block_with_name_attributes_and_inner_blocks(
81        name: &str,
82        attributes: Map<String, Value>,
83        inner_blocks: Vec<GutenbergBlock>,
84    ) -> Self {
85        GutenbergBlock {
86            client_id: Uuid::new_v4(),
87            name: name.to_string(),
88            is_valid: true,
89            attributes,
90            inner_blocks,
91        }
92    }
93    pub fn hero_section(title: &str, sub_title: &str) -> Self {
94        GutenbergBlock::block_with_name_and_attributes(
95            "moocfi/hero-section",
96            attributes! {
97                "title": title,
98                "subtitle": sub_title
99            },
100        )
101    }
102    pub fn landing_page_hero_section(title: &str, sub_title: &str) -> Self {
103        GutenbergBlock::block_with_name_attributes_and_inner_blocks(
104            "moocfi/landing-page-hero-section",
105            attributes! {"title": title},
106            vec![GutenbergBlock::block_with_name_and_attributes(
107                "core/paragraph",
108                attributes! {
109                    "align": "center",
110                    "content": sub_title,
111                    "dropCap": false,
112                    "placeholder": "Insert short description of course..."
113                },
114            )],
115        )
116    }
117    pub fn course_objective_section() -> Self {
118        GutenbergBlock::block_with_name_attributes_and_inner_blocks(
119            "moocfi/course-objective-section",
120            attributes! {
121                "title": "In this course you'll..."
122            },
123            vec![GutenbergBlock::block_with_name_attributes_and_inner_blocks(
124                "core/columns",
125                attributes! {
126                    "isStackedOnMobile": true
127                },
128                vec![
129                    GutenbergBlock::block_with_name_attributes_and_inner_blocks(
130                        "core/column",
131                        attributes! {},
132                        vec![
133                            GutenbergBlock::block_with_name_and_attributes(
134                                "core/heading",
135                                attributes! {
136                                    "textAlign": "center",
137                                    "level": 3,
138                                    "content": "Objective #1",
139                                    "anchor": "objective-1",
140                                },
141                            ),
142                            GutenbergBlock::block_with_name_and_attributes(
143                                "core/paragraph",
144                                attributes! {
145                                    "align": "center",
146                                    "dropCap": false,
147                                    "content": "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit..."
148                                },
149                            ),
150                        ],
151                    ),
152                    GutenbergBlock::block_with_name_attributes_and_inner_blocks(
153                        "core/column",
154                        attributes! {},
155                        vec![
156                            GutenbergBlock::block_with_name_and_attributes(
157                                "core/heading",
158                                attributes! {
159                                    "textAlign": "center",
160                                    "level": 3,
161                                    "content": "Objective #2",
162                                    "anchor": "objective-2",
163                                },
164                            ),
165                            GutenbergBlock::block_with_name_and_attributes(
166                                "core/paragraph",
167                                attributes! {
168                                    "align": "center",
169                                    "dropCap": false,
170                                    "content": "There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain..."
171                                },
172                            ),
173                        ],
174                    ),
175                    GutenbergBlock::block_with_name_attributes_and_inner_blocks(
176                        "core/column",
177                        attributes! {},
178                        vec![
179                            GutenbergBlock::block_with_name_and_attributes(
180                                "core/heading",
181                                attributes! {
182                                    "textAlign": "center",
183                                    "level": 3,
184                                    "content": "Objective #3",
185                                    "anchor": "objective-3",
186                                },
187                            ),
188                            GutenbergBlock::block_with_name_and_attributes(
189                                "core/paragraph",
190                                attributes! {
191                                    "align": "center",
192                                    "dropCap": false,
193                                    "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas a tempor risus. Morbi at sapien."
194                                },
195                            ),
196                        ],
197                    ),
198                ],
199            )],
200        )
201    }
202
203    pub fn landing_page_copy_text(heading: &str, content: &str) -> Self {
204        GutenbergBlock::block_with_name_attributes_and_inner_blocks(
205            "moocfi/landing-page-copy-text",
206            attributes! {},
207            vec![GutenbergBlock::block_with_name_attributes_and_inner_blocks(
208                "core/columns",
209                attributes! {
210                    "isStackedOnMobile": true
211                },
212                vec![GutenbergBlock::block_with_name_attributes_and_inner_blocks(
213                    "core/column",
214                    attributes! {},
215                    vec![
216                        GutenbergBlock::block_with_name_and_attributes(
217                            "core/heading",
218                            attributes! {
219                                "content": heading,
220                                "level": 2,
221                                "placeholder": heading,
222                                "anchor": heading,
223                                "textAlign": "left"
224                            },
225                        ),
226                        GutenbergBlock::block_with_name_and_attributes(
227                            "core/paragraph",
228                            attributes! {
229                                "content": content,
230                                "dropCap": false
231                            },
232                        ),
233                    ],
234                )],
235            )],
236        )
237    }
238
239    pub fn with_id(self, id: Uuid) -> Self {
240        Self {
241            client_id: id,
242            ..self
243        }
244    }
245}
246
247pub fn contains_blocks_not_allowed_in_top_level_pages(input: &[GutenbergBlock]) -> bool {
248    input
249        .iter()
250        .any(|block| DISALLOWED_BLOCKS_IN_TOP_LEVEL_PAGES.contains(&block.name.as_str()))
251}
252
253pub fn remap_ids_in_content(
254    content: &serde_json::Value,
255    chaged_ids: HashMap<Uuid, Uuid>,
256) -> UtilResult<serde_json::Value> {
257    // naive implementation for now because the structure of the content was not decided at the time of writing this.
258    // In the future we could only edit the necessary fields.
259    let mut content_str = serde_json::to_string(content)?;
260    for (k, v) in chaged_ids.into_iter() {
261        content_str = content_str.replace(&k.to_string(), &v.to_string());
262    }
263    Ok(serde_json::from_str(&content_str)?)
264}
265
266/** Removes the private spec from exercise tasks. */
267pub fn remove_sensitive_attributes(input: Vec<GutenbergBlock>) -> Vec<GutenbergBlock> {
268    input
269        .into_iter()
270        .map(|mut block| {
271            if block.name == "moocfi/exercise-task" {
272                block.attributes = Map::new();
273            }
274            block.inner_blocks = remove_sensitive_attributes(block.inner_blocks);
275            block
276        })
277        .collect()
278}
279
280/// Replaces duplicate client IDs with new unique IDs in Gutenberg blocks.
281pub fn replace_duplicate_client_ids(input: Vec<GutenbergBlock>) -> Vec<GutenbergBlock> {
282    let seen_ids = Rc::new(RefCell::new(HashSet::new()));
283
284    replace_duplicate_client_ids_inner(input, seen_ids)
285}
286
287fn replace_duplicate_client_ids_inner(
288    mut input: Vec<GutenbergBlock>,
289    seen_ids: Rc<RefCell<HashSet<Uuid>>>,
290) -> Vec<GutenbergBlock> {
291    for block in input.iter_mut() {
292        let mut seen_ids_borrow = seen_ids.borrow_mut();
293        if seen_ids_borrow.contains(&block.client_id) {
294            block.client_id = Uuid::new_v4();
295        } else {
296            seen_ids_borrow.insert(block.client_id);
297        }
298        drop(seen_ids_borrow); // Release the borrow before recursive call
299
300        block.inner_blocks =
301            replace_duplicate_client_ids_inner(block.inner_blocks.clone(), seen_ids.clone());
302    }
303    input
304}
305
306/// Validates that all client IDs in the Gutenberg blocks are unique.
307/// Returns an error if duplicate client IDs are found.
308pub fn validate_unique_client_ids(input: Vec<GutenbergBlock>) -> UtilResult<Vec<GutenbergBlock>> {
309    let seen_ids = Rc::new(RefCell::new(HashSet::new()));
310
311    validate_unique_client_ids_inner(input, seen_ids)
312}
313
314fn validate_unique_client_ids_inner(
315    input: Vec<GutenbergBlock>,
316    seen_ids: Rc<RefCell<HashSet<Uuid>>>,
317) -> UtilResult<Vec<GutenbergBlock>> {
318    for block in input.iter() {
319        let mut seen_ids_borrow = seen_ids.borrow_mut();
320        if seen_ids_borrow.contains(&block.client_id) {
321            return Err(UtilError::new(
322                UtilErrorType::Other,
323                format!("Duplicate client ID found: {}", block.client_id),
324                None,
325            ));
326        } else {
327            seen_ids_borrow.insert(block.client_id);
328        }
329        drop(seen_ids_borrow); // Release the borrow before recursive call
330
331        validate_unique_client_ids_inner(block.inner_blocks.clone(), seen_ids.clone())?;
332    }
333    Ok(input)
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    fn collect_ids(blocks: &Vec<GutenbergBlock>, ids: &mut Vec<Uuid>) {
341        for block in blocks {
342            ids.push(block.client_id);
343            collect_ids(&block.inner_blocks, ids);
344        }
345    }
346
347    #[test]
348    fn replace_duplicate_client_ids_makes_all_ids_unique_flat_and_nested() {
349        let dup_id = Uuid::new_v4();
350        let nested_dup_id = Uuid::new_v4();
351
352        let block_a = GutenbergBlock::empty_block_from_name("a".into()).with_id(dup_id);
353        let block_b_child_1 =
354            GutenbergBlock::empty_block_from_name("b1".into()).with_id(nested_dup_id);
355        let block_b_child_2 =
356            GutenbergBlock::empty_block_from_name("b2".into()).with_id(nested_dup_id);
357        let block_b = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
358            "b",
359            attributes! {},
360            vec![block_b_child_1, block_b_child_2],
361        )
362        .with_id(dup_id);
363
364        let input = vec![block_a, block_b];
365        let output = replace_duplicate_client_ids(input);
366
367        let mut ids: Vec<Uuid> = Vec::new();
368        collect_ids(&output, &mut ids);
369        let unique: HashSet<Uuid> = ids.iter().cloned().collect();
370        assert_eq!(
371            unique.len(),
372            ids.len(),
373            "all ids should be unique after replacement"
374        );
375    }
376
377    #[test]
378    fn validate_unique_client_ids_ok_on_unique() {
379        let a = GutenbergBlock::empty_block_from_name("a".into());
380        let b = GutenbergBlock::empty_block_from_name("b".into());
381        let c = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
382            "c",
383            attributes! {},
384            vec![GutenbergBlock::empty_block_from_name("c1".into())],
385        );
386        let input = vec![a, b, c];
387        let result = validate_unique_client_ids(input);
388        assert!(result.is_ok());
389    }
390
391    #[test]
392    fn validate_unique_client_ids_err_on_duplicate_nested() {
393        let dup_id = Uuid::new_v4();
394        let a = GutenbergBlock::empty_block_from_name("a".into()).with_id(dup_id);
395        let b_child = GutenbergBlock::empty_block_from_name("b1".into()).with_id(dup_id);
396        let b = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
397            "b",
398            attributes! {},
399            vec![b_child],
400        );
401        let input = vec![a, b];
402        let result = validate_unique_client_ids(input);
403        assert!(result.is_err());
404    }
405}