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
13static 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
249pub 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 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
270pub 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
284pub 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 block.inner_blocks = vec![];
298 } else {
299 block.inner_blocks = filter_lock_chapter_blocks(block.inner_blocks, is_locked);
301 }
302 } else {
303 block.inner_blocks = filter_lock_chapter_blocks(block.inner_blocks, is_locked);
305 }
306 block
307 })
308 .collect()
309}
310
311pub 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); block.inner_blocks =
332 replace_duplicate_client_ids_inner(block.inner_blocks.clone(), seen_ids.clone());
333 }
334 input
335}
336
337pub 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); validate_unique_client_ids_inner(block.inner_blocks.clone(), seen_ids.clone())?;
363 }
364 Ok(input)
365}
366
367fn 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
387pub 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 assert_eq!(result.len(), 2);
500 assert_eq!(result[0].name, "moocfi/lock-chapter");
501 assert_eq!(result[0].inner_blocks.len(), 0);
502 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 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 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 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 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}