headless_lms_utils/
email_processor.rs

1use std::collections::HashMap;
2
3use once_cell::sync::Lazy;
4use regex::{Captures, Regex};
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8static LI_START_TAG_REGEX: Lazy<Regex> =
9    Lazy::new(|| Regex::new(r"<li>").expect("invalid li_start regex"));
10static LI_END_TAG_REGEX: Lazy<Regex> =
11    Lazy::new(|| Regex::new(r"</li>").expect("invalid li_end regex"));
12static ALL_TAG_REGEX: Lazy<Regex> =
13    Lazy::new(|| Regex::new(r"<.+?>").expect("invalid all_tags regex"));
14static DOUBLE_QUOTE_REGEX: Lazy<Regex> =
15    Lazy::new(|| Regex::new(r#"""#).expect("invalid double_quote regex"));
16
17#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
18#[serde(tag = "name", content = "attributes")]
19pub enum BlockAttributes {
20    #[serde(rename = "core/paragraph")]
21    Paragraph {
22        content: String,
23        drop_cap: bool,
24        #[serde(flatten)]
25        rest: HashMap<String, serde_json::Value>,
26    },
27    #[serde(rename = "core/image")]
28    Image {
29        alt: String,
30        url: String,
31        #[serde(flatten)]
32        rest: HashMap<String, serde_json::Value>,
33    },
34    #[serde(rename = "core/heading")]
35    Heading {
36        content: String,
37        anchor: String,
38        level: i64,
39        #[serde(flatten)]
40        rest: HashMap<String, serde_json::Value>,
41    },
42    #[serde(rename = "core/list")]
43    List {
44        ordered: bool,
45        values: String,
46        #[serde(flatten)]
47        rest: HashMap<String, serde_json::Value>,
48    },
49}
50
51#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
52pub struct EmailGutenbergBlock {
53    #[serde(rename = "clientId")]
54    pub client_id: Uuid,
55    #[serde(rename = "isValid")]
56    pub is_valid: bool,
57    #[serde(flatten)]
58    pub attributes: BlockAttributes,
59    #[serde(rename = "innerBlocks")]
60    pub inner_blocks: Vec<EmailGutenbergBlock>,
61}
62
63pub fn process_content_to_plaintext(blocks: &[EmailGutenbergBlock]) -> String {
64    let contents: Vec<String> = blocks
65        .iter()
66        .map(|block| match &block.attributes {
67            BlockAttributes::Paragraph { content, .. } => {
68                let res = ALL_TAG_REGEX.replace_all(content, "").to_string();
69                format!("{}\n\n", res)
70            }
71            BlockAttributes::Image { alt, url, .. } => {
72                let result = DOUBLE_QUOTE_REGEX.replace_all(alt, "").to_string();
73                format!("\"{}\", <{}>", result, url)
74            }
75            BlockAttributes::Heading { content, .. } => format!("{}\n\n\n", content),
76            BlockAttributes::List {
77                values, ordered, ..
78            } => {
79                if *ordered {
80                    let mut counter = 0;
81                    let first_tags = LI_START_TAG_REGEX
82                        .replace_all(values, |_caps: &Captures| {
83                            counter += 1;
84                            format!("{}. ", counter)
85                        })
86                        .to_string();
87                    let snd_tags = LI_END_TAG_REGEX.replace_all(&first_tags, "\n").to_string();
88                    ALL_TAG_REGEX.replace_all(&snd_tags, "").to_string()
89                } else {
90                    let first_tags = LI_START_TAG_REGEX.replace_all(values, "* ").to_string();
91                    let snd_tags = LI_END_TAG_REGEX.replace_all(&first_tags, "\n").to_string();
92                    ALL_TAG_REGEX.replace_all(&snd_tags, "").to_string()
93                }
94            }
95        })
96        .collect();
97    contents.join("\n")
98}
99
100pub fn process_content_to_html(blocks: &[EmailGutenbergBlock]) -> String {
101    let contents: Vec<String> = blocks
102        .iter()
103        .map(|block| match &block.attributes {
104            BlockAttributes::Paragraph {
105                content,
106                drop_cap: _,
107                ..
108            } => {
109                format!("<p>{}</p>", content)
110            }
111            BlockAttributes::Image { alt, url, .. } => {
112                format!(r#"<img src="{}" alt="{}"></img>"#, url, alt)
113            }
114            BlockAttributes::Heading { content, level, .. } => {
115                format!("<h{}>{}</h{}>", level, content, level)
116            }
117            BlockAttributes::List {
118                values, ordered, ..
119            } => {
120                if *ordered {
121                    format!("<ol>{}</ol>", values)
122                } else {
123                    format!("<ul>{}</ul>", values)
124                }
125            }
126        })
127        .collect();
128    contents.join("")
129}
130
131#[cfg(test)]
132mod email_processor_tests {
133    use pretty_assertions::assert_eq;
134    use uuid::Uuid;
135
136    use super::*;
137
138    #[test]
139    fn it_converts_paragraph_correctly_to_plain_text() {
140        let input = vec![EmailGutenbergBlock {
141            client_id: Uuid::new_v4(),
142            is_valid: true,
143            attributes: BlockAttributes::Paragraph {
144                content: String::from("testi paragraph."),
145                drop_cap: false,
146                rest: HashMap::new(),
147            },
148            inner_blocks: vec![],
149        }];
150
151        let result = process_content_to_plaintext(&input);
152
153        assert_eq!(String::from("testi paragraph.\n\n"), result);
154    }
155
156    #[test]
157    fn it_converts_paragraph_wrapped_in_tags_correctly_to_plain_text() {
158        let input = vec![EmailGutenbergBlock {
159            client_id: Uuid::new_v4(),
160            is_valid: true,
161            attributes: BlockAttributes::Paragraph {
162                content: String::from("<strong><em>testi paragraph.</em></strong>"),
163                drop_cap: false,
164                rest: HashMap::new(),
165            },
166            inner_blocks: vec![],
167        }];
168
169        let result = process_content_to_plaintext(&input);
170
171        assert_eq!(String::from("testi paragraph.\n\n"), result);
172    }
173
174    #[test]
175    fn it_converts_heading_correctly_to_plain_text() {
176        let input = vec![EmailGutenbergBlock {
177            client_id: Uuid::new_v4(),
178            is_valid: true,
179            attributes: BlockAttributes::Heading {
180                content: String::from("Email heading"),
181                anchor: String::from("email-heading"),
182                level: 2,
183                rest: HashMap::new(),
184            },
185            inner_blocks: vec![],
186        }];
187
188        let result = process_content_to_plaintext(&input);
189
190        assert_eq!(String::from("Email heading\n\n\n"), result);
191    }
192
193    #[test]
194    fn it_converts_image_correctly_to_plain_text() {
195        let input = vec![EmailGutenbergBlock {
196            client_id: Uuid::new_v4(),
197            is_valid: true,
198            attributes: BlockAttributes::Image {
199                alt: String::from("Alternative title"),
200                url: String::from("URL -of an image"),
201                rest: HashMap::new(),
202            },
203            inner_blocks: vec![],
204        }];
205
206        let result = process_content_to_plaintext(&input);
207
208        assert_eq!(
209            String::from("\"Alternative title\", <URL -of an image>"),
210            result
211        );
212    }
213    #[test]
214    fn it_converts_image_containing_double_quotes_correctly_to_plain_text() {
215        let input = vec![EmailGutenbergBlock {
216            client_id: Uuid::new_v4(),
217            is_valid: true,
218            attributes: BlockAttributes::Image {
219                alt: String::from(r#""Alternative title""#),
220                url: String::from("URL -of an image"),
221                rest: HashMap::new(),
222            },
223            inner_blocks: vec![],
224        }];
225
226        let result = process_content_to_plaintext(&input);
227
228        assert_eq!(
229            String::from("\"Alternative title\", <URL -of an image>"),
230            result
231        );
232    }
233
234    #[test]
235    fn it_converts_unordered_list_correctly_to_plain_text() {
236        let input = vec![EmailGutenbergBlock {
237            client_id: Uuid::new_v4(),
238            is_valid: true,
239            attributes: BlockAttributes::List {
240                values: String::from("<li>1</li><li>2</li><li>3</li><li>4</li>"),
241                ordered: false,
242                rest: HashMap::new(),
243            },
244            inner_blocks: vec![],
245        }];
246
247        let result = process_content_to_plaintext(&input);
248
249        assert_eq!(String::from("* 1\n* 2\n* 3\n* 4\n"), result);
250    }
251
252    #[test]
253    fn it_converts_unordered_list_containing_other_tags_correctly_to_plain_text() {
254        let input = vec![EmailGutenbergBlock {
255            client_id: Uuid::new_v4(),
256            is_valid: true,
257            attributes: BlockAttributes::List {
258                values: String::from(
259                    "<li><code>1</code></li><li><kbd>2</kbd></li><li>3</li><li>4</li>",
260                ),
261                ordered: false,
262                rest: HashMap::new(),
263            },
264            inner_blocks: vec![],
265        }];
266
267        let result = process_content_to_plaintext(&input);
268
269        assert_eq!(String::from("* 1\n* 2\n* 3\n* 4\n"), result);
270    }
271
272    #[test]
273    fn it_converts_ordered_list_correctly_to_plain_text() {
274        let input = vec![EmailGutenbergBlock {
275            client_id: Uuid::new_v4(),
276            is_valid: true,
277            attributes: BlockAttributes::List {
278                values: String::from("<li>first</li><li>second</li><li>third</li><li>fourth</li>"),
279                ordered: true,
280                rest: HashMap::new(),
281            },
282            inner_blocks: vec![],
283        }];
284
285        let result = process_content_to_plaintext(&input);
286
287        assert_eq!(
288            String::from("1. first\n2. second\n3. third\n4. fourth\n"),
289            result
290        );
291    }
292
293    #[test]
294    fn it_converts_ordered_list_containing_other_tags_correctly_to_plain_text() {
295        let input = vec![EmailGutenbergBlock {
296            client_id: Uuid::new_v4(),
297            is_valid: true,
298            attributes: BlockAttributes::List {
299                values: String::from(
300                    "<li><code>first</code></li><li><kbd>second</kbd></li><li>third</li><li>fourth</li>",
301                ),
302                ordered: true,
303                rest: HashMap::new(),
304            },
305            inner_blocks: vec![],
306        }];
307
308        let result = process_content_to_plaintext(&input);
309
310        assert_eq!(
311            String::from("1. first\n2. second\n3. third\n4. fourth\n"),
312            result
313        );
314    }
315
316    #[test]
317    fn it_converts_paragraph_correctly_to_html() {
318        let input = vec![EmailGutenbergBlock {
319            client_id: Uuid::new_v4(),
320            is_valid: true,
321            attributes: BlockAttributes::Paragraph {
322                content: String::from("testi paragraph."),
323                drop_cap: false,
324                rest: HashMap::new(),
325            },
326            inner_blocks: vec![],
327        }];
328
329        let result = process_content_to_html(&input);
330
331        assert_eq!(String::from("<p>testi paragraph.</p>"), result);
332    }
333
334    #[test]
335    fn it_converts_heading_correctly_to_html() {
336        let input = vec![EmailGutenbergBlock {
337            client_id: Uuid::new_v4(),
338            is_valid: true,
339            attributes: BlockAttributes::Heading {
340                content: String::from("Email heading"),
341                anchor: String::from("email-heading"),
342                level: 2,
343                rest: HashMap::new(),
344            },
345            inner_blocks: vec![],
346        }];
347
348        let result = process_content_to_html(&input);
349
350        assert_eq!(String::from("<h2>Email heading</h2>"), result);
351    }
352
353    #[test]
354    fn it_converts_image_correctly_to_html() {
355        let input = vec![EmailGutenbergBlock {
356            client_id: Uuid::new_v4(),
357            is_valid: true,
358            attributes: BlockAttributes::Image {
359                alt: String::from("Alternative title"),
360                url: String::from("URL -of an image"),
361                rest: HashMap::new(),
362            },
363            inner_blocks: vec![],
364        }];
365
366        let result = process_content_to_html(&input);
367
368        assert_eq!(
369            String::from(r#"<img src="URL -of an image" alt="Alternative title"></img>"#),
370            result
371        );
372    }
373
374    #[test]
375    fn it_converts_unordered_list_correctly_to_html() {
376        let input = vec![EmailGutenbergBlock {
377            client_id: Uuid::new_v4(),
378            is_valid: true,
379            attributes: BlockAttributes::List {
380                values: String::from("<li>1</li><li>2</li><li>3</li><li>4</li>"),
381                ordered: false,
382                rest: HashMap::new(),
383            },
384            inner_blocks: vec![],
385        }];
386
387        let result = process_content_to_html(&input);
388
389        assert_eq!(
390            String::from("<ul><li>1</li><li>2</li><li>3</li><li>4</li></ul>"),
391            result
392        );
393    }
394
395    #[test]
396    fn it_converts_unordered_list_containing_other_tags_correctly_to_html() {
397        let input = vec![EmailGutenbergBlock {
398            client_id: Uuid::new_v4(),
399            is_valid: true,
400            attributes: BlockAttributes::List {
401                values: String::from(
402                    "<li><code>1</code></li><li><kbd>2</kbd></li><li>3</li><li>4</li>",
403                ),
404                ordered: false,
405                rest: HashMap::new(),
406            },
407            inner_blocks: vec![],
408        }];
409
410        let result = process_content_to_html(&input);
411
412        assert_eq!(
413            String::from(
414                "<ul><li><code>1</code></li><li><kbd>2</kbd></li><li>3</li><li>4</li></ul>"
415            ),
416            result
417        );
418    }
419
420    #[test]
421    fn it_converts_ordered_list_correctly_to_html() {
422        let input = vec![EmailGutenbergBlock {
423            client_id: Uuid::new_v4(),
424            is_valid: true,
425            attributes: BlockAttributes::List {
426                values: String::from("<li>first</li><li>second</li><li>third</li><li>fourth</li>"),
427                ordered: true,
428                rest: HashMap::new(),
429            },
430            inner_blocks: vec![],
431        }];
432
433        let result = process_content_to_html(&input);
434
435        assert_eq!(
436            String::from("<ol><li>first</li><li>second</li><li>third</li><li>fourth</li></ol>"),
437            result
438        );
439    }
440
441    #[test]
442    fn it_converts_ordered_list_containing_other_tags_correctly_to_html() {
443        let input = vec![EmailGutenbergBlock {
444            client_id: Uuid::new_v4(),
445            is_valid: true,
446            attributes: BlockAttributes::List {
447                values: String::from(
448                    "<li><code>first</code></li><li><kbd>second</kbd></li><li>third</li><li>fourth</li>",
449                ),
450                ordered: true,
451                rest: HashMap::new(),
452            },
453            inner_blocks: vec![],
454        }];
455
456        let result = process_content_to_html(&input);
457
458        assert_eq!(
459            String::from(
460                "<ol><li><code>first</code></li><li><kbd>second</kbd></li><li>third</li><li>fourth</li></ol>"
461            ),
462            result
463        );
464    }
465}