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] = &[
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
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
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 assert_eq!(result.len(), 2);
462 assert_eq!(result[0].name, "moocfi/lock-chapter");
463 assert_eq!(result[0].inner_blocks.len(), 0);
464 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 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 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 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 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}