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 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
266pub 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
280pub 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); block.inner_blocks =
301 replace_duplicate_client_ids_inner(block.inner_blocks.clone(), seen_ids.clone());
302 }
303 input
304}
305
306pub 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); 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}