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
15static 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
251pub 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 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
272pub 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
286pub 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 block.inner_blocks = vec![];
300 } else {
301 block.inner_blocks = filter_lock_chapter_blocks(block.inner_blocks, is_locked);
303 }
304 } else {
305 block.inner_blocks = filter_lock_chapter_blocks(block.inner_blocks, is_locked);
307 }
308 block
309 })
310 .collect()
311}
312
313pub 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); block.inner_blocks =
334 replace_duplicate_client_ids_inner(block.inner_blocks.clone(), seen_ids.clone());
335 }
336 input
337}
338
339pub 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); validate_unique_client_ids_inner(block.inner_blocks.clone(), seen_ids.clone())?;
365 }
366 Ok(input)
367}
368
369fn 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
387pub 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 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 #[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 #[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 assert_eq!(result.len(), 2);
560 assert_eq!(result[0].name, "moocfi/lock-chapter");
561 assert_eq!(result[0].inner_blocks.len(), 0);
562 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 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 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 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 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}