Skip to main content

product_os_content/
content_transform.rs

1//! Content transformation module
2//!
3//! Handles template variable substitution, expression evaluation, and markup transformation
4//! for content rendering. Supports various content formats, interactive markup elements,
5//! and dynamic content generation.
6
7// Allow clippy lints for this private module - these are stylistic and don't affect the public API
8#![allow(clippy::collapsible_match)]
9#![allow(clippy::unnecessary_to_owned)]
10#![allow(clippy::explicit_counter_loop)]
11
12use std::collections::{HashMap, hash_set::HashSet };
13use std::sync::OnceLock;
14use evalexpr::{ContextWithMutableVariables, HashMapContext};
15use serde_json::{Map, Value};
16use regex::Regex;
17use crate::ProductOSContentFormat;
18
19
20fn content_regex() -> &'static Regex {
21    static RE: OnceLock<Regex> = OnceLock::new();
22    RE.get_or_init(|| Regex::new(r"([#][{]([a-zA-Z0-9_-]+)[}])").unwrap())
23}
24
25fn content_variables_regex() -> &'static Regex {
26    static RE: OnceLock<Regex> = OnceLock::new();
27    RE.get_or_init(|| Regex::new(r"([@][{]([a-zA-Z0-9_-]+)[}])").unwrap())
28}
29
30fn form_variables_regex() -> &'static Regex {
31    static RE: OnceLock<Regex> = OnceLock::new();
32    RE.get_or_init(|| Regex::new(r"([@][@][{]([a-zA-Z0-9_-]+)[}])").unwrap())
33}
34
35fn stored_variables_regex() -> &'static Regex {
36    static RE: OnceLock<Regex> = OnceLock::new();
37    RE.get_or_init(|| Regex::new(r"([@][!][{]([a-zA-Z0-9_-]+)[}])").unwrap())
38}
39
40fn interact_markup_regex() -> &'static Regex {
41    static RE: OnceLock<Regex> = OnceLock::new();
42    RE.get_or_init(|| Regex::new(r"([@][\[]([a-zA-Z0-9_-]+)[\]])").unwrap())
43}
44
45fn interact_markup_icon_regex() -> &'static Regex {
46    static RE: OnceLock<Regex> = OnceLock::new();
47    RE.get_or_init(|| Regex::new(r"(^=(fa[srlb][ ][-a-z ]+))").unwrap())
48}
49
50fn interact_markup_image_regex() -> &'static Regex {
51    static RE: OnceLock<Regex> = OnceLock::new();
52    RE.get_or_init(|| Regex::new(r"(^=image[ ]([-a-zA-Z0-9:;\/?&%.,_#+=]+)([ ]([0-9]+)([ ]([0-9]+))?)?)").unwrap())
53}
54
55fn interact_markup_file_regex() -> &'static Regex {
56    static RE: OnceLock<Regex> = OnceLock::new();
57    RE.get_or_init(|| Regex::new(r"(^=file[ ]([-a-zA-Z0-9:;\/?&%.,_#+=]+)([ ]([-_a-zA-Z0-9.]+))?)").unwrap())
58}
59
60fn interact_markup_video_regex() -> &'static Regex {
61    static RE: OnceLock<Regex> = OnceLock::new();
62    RE.get_or_init(|| Regex::new(r"(^=video[ ]([-a-zA-Z0-9:;\/?&%.,_#+=]+)([ ]([0-9]+)([ ]([0-9]+))?)?)").unwrap())
63}
64
65fn interact_markup_audio_regex() -> &'static Regex {
66    static RE: OnceLock<Regex> = OnceLock::new();
67    RE.get_or_init(|| Regex::new(r"(^=audio[ ]([-a-zA-Z0-9:;\/?&%.,_#+=]+)?)").unwrap())
68}
69
70fn interact_markup_link_regex() -> &'static Regex {
71    static RE: OnceLock<Regex> = OnceLock::new();
72    RE.get_or_init(|| Regex::new(r"(^=link[ ]([-a-zA-Z0-9:\/?&%._#+]+)([ ]([-_a-zA-Z0-9]+))?)").unwrap())
73}
74
75fn interact_markup_newline_regex() -> &'static Regex {
76    static RE: OnceLock<Regex> = OnceLock::new();
77    RE.get_or_init(|| Regex::new(r"(^=(newline))").unwrap())
78}
79
80/// Configuration options for content transformation
81#[derive(Debug)]
82pub struct ProductOSContentOptions {
83    options: Value
84}
85
86impl ProductOSContentOptions {
87    /// Creates new content options from a JSON value
88    pub fn new(map: Value) -> Self {
89        Self {
90            options: map
91        }
92    }
93}
94
95impl Default for ProductOSContentOptions {
96    fn default() -> Self {
97        Self {
98            options: Value::Object(Map::new())
99        }
100    }
101}
102
103/// Error during property access
104#[derive(Debug)]
105enum PropertyError {
106    NoMatchingProperty,
107    NotAnObject,
108    InvalidProperty
109}
110
111/// Error during content transformation
112pub enum TransformError {
113    /// Value type is invalid for the operation
114    ValueInvalid,
115    /// Content format is invalid
116    FormatInvalid,
117    /// Expression evaluation failed
118    ExpressionError
119}
120
121/// Transforms content by applying variable substitutions, evaluations, and markup processing
122///
123/// This is the main content transformation function. It:
124/// 1. Replaces content elements (#{element})
125/// 2. Substitutes variables (@{var}, @@{form_var}, @!{stored_var})
126/// 3. Evaluates expressions (@!{expression})
127/// 4. Processes interactive markup (@[icon], =image, =video, etc.)
128/// 5. Converts Markdown to HTML if applicable
129/// 6. Purifies HTML content
130///
131/// # Arguments
132///
133/// * `content` - The content string to transform
134/// * `data` - Optional JSON data object for variable lookups
135/// * `variables` - Optional variable map for substitutions
136/// * `options_provided` - Optional configuration for transformation
137///
138/// # Returns
139///
140/// * `Ok(String)` - The transformed content
141/// * `Err(TransformError)` - Transformation failed
142///
143/// # Examples
144///
145/// ```rust,ignore
146/// // This is an internal function not exposed in the public API
147/// use serde_json::json;
148///
149/// let result = transform_content(
150///     "Hello #{name}!".to_string(),
151///     Some(json!({"name": "World"})),
152///     None,
153///     None
154/// );
155/// ```
156pub fn transform_content(content: String, data: Option<Value>, variables: Option<Map<String, Value>>, options_provided: Option<ProductOSContentOptions>) -> Result<String, TransformError> {
157    let mut result_content = content;
158
159    let data = data.unwrap_or_default();
160
161    let variables = match variables {
162        None => Map::new(),
163        Some(var) => var
164    };
165
166    let options = match options_provided {
167        None => ProductOSContentOptions::default().options.as_object().unwrap().to_owned(),
168        Some(opts) => opts.options.as_object().unwrap().to_owned()
169    };
170
171    let content = match data.get("content") {
172        Some(v) => v.to_owned(),
173        None => Value::Object(Map::new())
174    };
175
176    let content_format = match data.get("contentFormat") {
177        Some(v) => {
178            match v.to_owned().as_str() {
179                None => return Err(TransformError::FormatInvalid),
180                Some(s) => match ProductOSContentFormat::try_from_str(s) {
181                    Some(fmt) => fmt,
182                    None => return Err(TransformError::FormatInvalid),
183                }
184            }
185        },
186        None => ProductOSContentFormat::Text
187    };
188
189    let form = match data.get("form") {
190        Some(v) => v.to_owned(),
191        None => Value::Object(Map::new())
192    };
193
194    let values = match data.get("values") {
195        Some(v) => v.to_owned(),
196        None => Value::Object(Map::new())
197    };
198
199    // Perform any label / content replacements relevant to fields
200    match options.get("contentReplacements") {
201        None => {}
202        Some(replace) => {
203            if replace.as_bool().unwrap() {
204                for matches in content_regex().captures_iter(result_content.to_owned().as_str()) {
205                    match find_prop(content.clone(), matches[2].to_string()) {
206                        Ok(value) => {
207                            match content_format {
208                                ProductOSContentFormat::Text => {
209                                    match value {
210                                        serde_json::Value::String(value) => result_content = result_content.replace(&matches[1], value.as_str()),
211                                        _ => return Err(TransformError::ValueInvalid)
212                                    }
213                                }
214                                ProductOSContentFormat::Block => {
215                                    // TODO: Implement block
216                                    return Err(TransformError::FormatInvalid)
217                                },
218                                ProductOSContentFormat::Binary => return Err(TransformError::FormatInvalid)
219                            }
220
221                            /*
222                            match value {
223                                serde_json::Value::Bool(value) => result_content = result_content.replace(&matches[1], value.to_string().as_str()),
224                                serde_json::Value::Number(value) => result_content = result_content.replace(&matches[1], value.to_string().as_str()),
225                                serde_json::Value::String(value) => result_content = result_content.replace(&matches[1], value.as_str()),
226                                _ => {}
227                            }
228                            */
229                        },
230                        Err(_e) => { }
231                    }
232                }
233            }
234        }
235    }
236
237    // Perform form data variable replacements
238    match options.get("formReplacements") {
239        None => {}
240        Some(replace) => {
241            if replace.as_bool().unwrap() {
242                for matches in form_variables_regex().captures_iter(result_content.to_owned().as_str()) {
243                    match find_prop(form.clone(), matches[2].to_string()) {
244                        Ok(value) => {
245                            match value {
246                                serde_json::Value::Bool(value) => result_content = result_content.replace(&matches[1], value.to_string().as_str()),
247                                serde_json::Value::Number(value) => result_content = result_content.replace(&matches[1], value.to_string().as_str()),
248                                serde_json::Value::String(value) => result_content = result_content.replace(&matches[1], value.as_str()),
249                                _ => {}
250                            }
251                        },
252                        Err(_) => return Err(TransformError::ValueInvalid)
253                    }
254                }
255            }
256        }
257    }
258
259    // Perform stored value variable replacements
260    match options.get("valueReplacements") {
261        None => {}
262        Some(replace) => {
263            if replace.as_bool().unwrap() {
264                for matches in stored_variables_regex().captures_iter(result_content.to_owned().as_str()) {
265                    match find_prop(values.clone(), matches[2].to_string()) {
266                        Ok(value) => {
267                            match value {
268                                serde_json::Value::Bool(value) => result_content = result_content.replace(&matches[1], value.to_string().as_str()),
269                                serde_json::Value::Number(value) => result_content = result_content.replace(&matches[1], value.to_string().as_str()),
270                                serde_json::Value::String(value) => result_content = result_content.replace(&matches[1], value.as_str()),
271                                _ => {}
272                            }
273                        },
274                        Err(_) => return Err(TransformError::ValueInvalid)
275                    }
276                }
277            }
278        }
279    }
280
281    // Special URL matching
282    //    if (content.startsWith('http://localhost:3030/media/image/'))
283    //    else if (content.startsWith('http://localhost:3030/media/file/'))
284    //    else if (content.startsWith('http://localhost:3030/media/video/'))
285    //    else if (content.startsWith('http://localhost:3030/media/audio/'))
286    match options.get("urlReplacements") {
287        None => {}
288        Some(replace) => {
289            if replace.as_bool().unwrap() {
290                let url_list = options.get("urlReplacementList").unwrap().as_array().unwrap();
291
292                for url in url_list {
293                    let escaped = regex::escape(url.as_str().unwrap_or_default());
294                    for matches in Regex::new(&escaped).unwrap().captures_iter(result_content.to_owned().as_str()) {
295                        match find_prop(content.clone(), matches[2].to_string()) {
296                            Ok(value) => {
297                                match value {
298                                    serde_json::Value::Bool(value) => result_content = result_content.replace(&matches[1], value.to_string().as_str()),
299                                    serde_json::Value::Number(value) => result_content = result_content.replace(&matches[1], value.to_string().as_str()),
300                                    serde_json::Value::String(value) => result_content = result_content.replace(&matches[1], value.as_str()),
301                                    _ => {}
302                                }
303                            },
304                            Err(_) => return Err(TransformError::ValueInvalid)
305                        }
306                    }
307                }
308            }
309        }
310    }
311
312    // Perform any label / content replacements relevant to fields
313    match options.get("variableReplacements") {
314        None => {}
315        Some(replace) => {
316            if replace.as_bool().unwrap() {
317                for matches in content_variables_regex().captures_iter(result_content.to_owned().as_str()) {
318                    let value = evaluate(matches[2].to_string(), &variables);
319
320                    match value {
321                        Ok(val) => result_content = result_content.replace(&matches[1], val.to_string().as_str()),
322                        Err(_) => return Err(TransformError::ExpressionError)
323                    }
324                }
325            }
326        }
327    }
328
329
330    let content_type = options.get("contentType").map(|c| c.as_str().unwrap());
331
332    let images = match variables.get("images") {
333        Some(v) => match v {
334            Value::Object(map) => map.to_owned(),
335            _ => Map::new()
336        },
337        None => Map::new()
338    };
339
340    let files = match variables.get("files") {
341        Some(v) => match v {
342            Value::Object(map) => map.to_owned(),
343            _ => Map::new()
344        },
345        None => Map::new()
346    };
347
348    let videos = match variables.get("videos") {
349        Some(v) => match v {
350            Value::Object(map) => map.to_owned(),
351            _ => Map::new()
352        },
353        None => Map::new()
354    };
355
356    let audios = match variables.get("audios") {
357        Some(v) => match v {
358            Value::Object(map) => map.to_owned(),
359            _ => Map::new()
360        },
361        None => Map::new()
362    };
363
364    match options.get("markupReplacements") {
365        None => {}
366        Some(replace) => {
367            if replace.as_bool().unwrap() {
368                for matches in interact_markup_regex().captures_iter(result_content.to_owned().as_str()) {
369                    // Icon - Font Awesome Matching
370                    match options.get("iconReplacements") {
371                        None => {}
372                        Some(replace) => {
373                            if replace.as_bool().unwrap() {
374                                for markup_matches in interact_markup_icon_regex().captures_iter(&matches[2]) {
375                                    let value = markup_matches[2].to_string();
376                                    result_content = result_content.replace(&matches[1], format!("<i class=\"{value}\"></i>").as_str());
377                                }
378                            }
379                        }
380                    }
381
382                    // Image matching
383                    match options.get("imageReplacements") {
384                        None => {}
385                        Some(replace) => {
386                            if replace.as_bool().unwrap() {
387                                for markup_matches in interact_markup_image_regex().captures_iter(&matches[2]) {
388                                    let image = images.get(&markup_matches[2]).map(|img| img.as_object().unwrap().to_owned());
389
390                                    match content_type {
391                                        Some("image") | Some("gif") | Some("sticker") => {
392                                            match image {
393                                                None => {
394                                                    if markup_matches[2].contains("base64,") || markup_matches[2].contains("https://") {
395                                                        result_content = markup_matches[2].to_string();
396                                                    }
397                                                }
398                                                Some(img) => {
399                                                    let data = img.get("data").unwrap().as_str().unwrap();
400                                                    result_content = data.to_string();
401                                                }
402                                            }
403                                        },
404                                        _ => {
405                                            match image {
406                                                None => {
407                                                    if markup_matches[2].contains("base64,") || markup_matches[2].contains("https://") {
408                                                        result_content =  result_content.replace(&matches[1], &markup_matches[2]);
409                                                    }
410                                                }
411                                                Some(img) => {
412                                                    let data = img.get("data").unwrap().as_str().unwrap();
413                                                    result_content =  result_content.replace(&matches[1], data);
414                                                }
415                                            }
416                                        }
417                                    }
418                                }
419                            }
420                        }
421                    }
422
423
424                    // File matching
425                    match options.get("fileReplacements") {
426                        None => {}
427                        Some(replace) => {
428                            if replace.as_bool().unwrap() {
429                                for markup_matches in interact_markup_file_regex().captures_iter(&matches[2]) {
430                                    let file = files.get(&markup_matches[2]).map(|fil| fil.as_object().unwrap().to_owned());
431
432                                    match content_type {
433                                        Some("file") => {
434                                            match file {
435                                                None => {
436                                                    if markup_matches[2].contains("base64,") || markup_matches[2].contains("https://") {
437                                                        result_content = markup_matches[2].to_string();
438                                                    }
439                                                }
440                                                Some(fil) => {
441                                                    let data = fil.get("data").unwrap().as_str().unwrap();
442                                                    result_content = data.to_string();
443                                                }
444                                            }
445                                        },
446                                        _ => {
447                                            match file {
448                                                None => {
449                                                    if markup_matches[2].contains("base64,") || markup_matches[2].contains("https://") {
450                                                        result_content =  result_content.replace(&matches[1], &markup_matches[2]);
451                                                    }
452                                                }
453                                                Some(fil) => {
454                                                    let data = fil.get("data").unwrap().as_str().unwrap();
455                                                    result_content =  result_content.replace(&matches[1], data);
456                                                }
457                                            }
458                                        }
459                                    }
460                                }
461                            }
462                        }
463                    }
464
465
466                    // Video matching
467                    match options.get("videoReplacements") {
468                        None => {}
469                        Some(replace) => {
470                            if replace.as_bool().unwrap() {
471                                for markup_matches in interact_markup_video_regex().captures_iter(&matches[2]) {
472                                    let video = videos.get(&markup_matches[2]).map(|vid| vid.as_object().unwrap().to_owned());
473
474                                    match content_type {
475                                        Some("video") | Some("gif") | Some("sticker") => {
476                                            match video {
477                                                None => {
478                                                    if markup_matches[2].contains("base64,") || markup_matches[2].contains("https://") {
479                                                        result_content = markup_matches[2].to_string();
480                                                    }
481                                                }
482                                                Some(vid) => {
483                                                    let data = vid.get("data").unwrap().as_str().unwrap();
484                                                    result_content = data.to_string();
485                                                }
486                                            }
487                                        },
488                                        _ => {
489                                            match video {
490                                                None => {
491                                                    if markup_matches[2].contains("base64,") || markup_matches[2].contains("https://") {
492                                                        result_content =  result_content.replace(&matches[1], &markup_matches[2]);
493                                                    }
494                                                }
495                                                Some(vid) => {
496                                                    let data = vid.get("data").unwrap().as_str().unwrap();
497                                                    result_content =  result_content.replace(&matches[1], data);
498                                                }
499                                            }
500                                        }
501                                    }
502                                }
503                            }
504                        }
505                    }
506
507
508                    // Audio matching
509                    match options.get("audioReplacements") {
510                        None => {}
511                        Some(replace) => {
512                            if replace.as_bool().unwrap() {
513                                for markup_matches in interact_markup_audio_regex().captures_iter(&matches[2]) {
514                                    let audio = audios.get(&markup_matches[2]).map(|aud| aud.as_object().unwrap().to_owned());
515
516                                    match content_type {
517                                        Some("audio") => {
518                                            match audio {
519                                                None => {
520                                                    if markup_matches[2].contains("base64,") || markup_matches[2].contains("https://") {
521                                                        result_content = markup_matches[2].to_string();
522                                                    }
523                                                }
524                                                Some(aud) => {
525                                                    let data = aud.get("data").unwrap().as_str().unwrap();
526                                                    result_content = data.to_string();
527                                                }
528                                            }
529                                        },
530                                        _ => {
531                                            match audio {
532                                                None => {
533                                                    if markup_matches[2].contains("base64,") || markup_matches[2].contains("https://") {
534                                                        result_content =  result_content.replace(&matches[1], &markup_matches[2]);
535                                                    }
536                                                }
537                                                Some(aud) => {
538                                                    let data = aud.get("data").unwrap().as_str().unwrap();
539                                                    result_content =  result_content.replace(&matches[1], data);
540                                                }
541                                            }
542                                        }
543                                    }
544                                }
545                            }
546                        }
547                    }
548
549
550                    // Link matching
551                    match options.get("linkReplacements") {
552                        None => {}
553                        Some(replace) => {
554                            if replace.as_bool().unwrap() {
555                                for markup_matches in interact_markup_link_regex().captures_iter(&matches[2]) {
556                                    let value = if !markup_matches[4].is_empty() {
557                                        let mut text = markup_matches[4].to_string();
558                                        let link = markup_matches[2].to_string();
559                                        text.push_str(format!(" [{link}]").as_str());
560                                        text
561                                    } else {
562                                        markup_matches[2].to_string()
563                                    };
564
565                                    result_content = result_content.replace(&matches[1], value.as_str());
566                                }
567                            }
568                        }
569                    }
570
571                    // New line matching
572                    match options.get("newLineReplacements") {
573                        None => {}
574                        Some(replace) => {
575                            if replace.as_bool().unwrap() {
576                                for _ in interact_markup_newline_regex().captures_iter(&matches[2]) {
577                                    let value = "\n";
578                                    result_content = result_content.replace(&matches[1], value);
579                                }
580                            }
581                        }
582                    }
583                }
584
585                match options.get("paragraphMarkdown") {
586                    None => {}
587                    Some(replace) => {
588                        if replace.as_bool().unwrap() {
589                            result_content = generate_markdown(result_content, None);
590                        }
591                        else {
592                            // render inline
593                            result_content = generate_markdown(result_content, None);
594                        }
595                    }
596                }
597            }
598        }
599    }
600
601
602    match options.get("htmlToText") {
603        None => {}
604        Some(replace) => {
605            if replace.as_bool().unwrap() {
606                result_content = html_to_text(result_content);
607            }
608        }
609    }
610
611    match options.get("skipTrim") {
612        None => result_content = result_content.trim().to_string(),
613        Some(replace) => {
614            if !replace.as_bool().unwrap() {
615                result_content = result_content.trim().to_string();
616            }
617        }
618    }
619
620    let html_purify_options = options.get("htmlPurifyConfig").map(|map| map.to_owned());
621
622    match options.get("htmlPurify") {
623        None => {}
624        Some(replace) => {
625            if replace.as_bool().unwrap() {
626                result_content = html_purify(result_content, html_purify_options)
627            }
628        }
629    }
630
631
632    Ok(result_content)
633}
634
635
636enum EvaluateError {
637    Error
638}
639
640
641
642fn find_prop(value: Value, property: String) -> Result<Value, PropertyError> {
643    let prop: Vec<&str> = property.splitn(2, ".").collect();
644
645    match prop.len() {
646        1 => {
647            match value {
648                Value::Object(obj) => match obj.get(prop[0]) {
649                    None => Err(PropertyError::NoMatchingProperty),
650                    Some(v) => Ok(v.to_owned())
651                }
652                _ => Err(PropertyError::NotAnObject)
653            }
654        },
655        2 => {
656            let remaining_properties = prop[1];
657
658            match value {
659                Value::Object(obj) => match obj.get(prop[0]) {
660                    None => Err(PropertyError::NoMatchingProperty),
661                    Some(v) => find_prop(v.to_owned(), remaining_properties.to_string())
662                }
663                _ => Err(PropertyError::NotAnObject)
664            }
665        }
666        _ => Err(PropertyError::InvalidProperty)
667    }
668}
669
670fn build_evaluate_context(variables: &Map<String, Value>) -> evalexpr::HashMapContext {
671    let mut context = HashMapContext::new();
672
673    for (name, value) in internal_build_evaluate_context(variables).iter() {
674        if context.set_value(name.to_owned(), value.to_owned()).is_ok() {}
675    }
676
677    context
678}
679
680fn internal_build_evaluate_context(variables: &Map<String, Value>) -> HashMap<String, evalexpr::Value> {
681    let mut context = HashMap::new();
682
683    for (name, value) in variables.to_owned() {
684        match value {
685            Value::Bool(b) => { context.insert(name, evalexpr::Value::Boolean(b)); },
686            Value::Number(n) => { context.insert(name, evalexpr::Value::Int(n.as_i64().unwrap())); },
687            Value::String(s) => { context.insert(name, evalexpr::Value::String(s)); },
688            Value::Array(a) => {
689                let mut array_map = Map::new();
690
691                let mut counter = 0;
692                for element in a {
693                    let mut element_name = name.to_owned();
694                    element_name.push('.');
695                    element_name.push_str(counter.to_string().as_str());
696                    array_map.insert(element_name, element);
697                    counter += 1;
698                }
699
700                let result = internal_build_evaluate_context(&array_map);
701
702                for (name, value) in result.iter() {
703                    context.insert(name.to_owned(), value.to_owned());
704                }
705            },
706            Value::Object(o) => {
707                let mut object_map = Map::new();
708
709                for (el_name, el_value) in o {
710                    let mut element_name = name.to_owned();
711                    element_name.push('.');
712                    element_name.push_str(el_name.as_str());
713                    object_map.insert(element_name, el_value);
714                }
715
716                let result = internal_build_evaluate_context(&object_map);
717
718                for (name, value) in result.iter() {
719                    context.insert(name.to_owned(), value.to_owned());
720                }
721            }
722            Value::Null => { context.insert(name, evalexpr::Value::Empty); },
723        }
724    }
725
726    context
727}
728
729
730fn evaluate(expression: String, variables: &Map<String, Value>) -> Result<evalexpr::Value, EvaluateError> {
731    let context = build_evaluate_context(variables);
732
733    match evalexpr::eval_with_context(expression.as_str(), &context) {
734        Ok(res) => Ok(res),
735        Err(_) => Err(EvaluateError::Error)
736    }
737}
738
739fn generate_markdown(content: String, options: Option<pulldown_cmark::Options>) -> String {
740    let options = match options {
741        None => pulldown_cmark::Options::empty(),
742        Some(opt) => opt
743    };
744
745    let md_parsed = pulldown_cmark::Parser::new_ext(content.as_str(), options);
746    let mut html = String::new();
747
748    pulldown_cmark::html::push_html(&mut html, md_parsed);
749
750    html
751}
752
753fn html_to_text(content: String) -> String {
754    html_escape::decode_html_entities(html2text::from_read(content.as_bytes(), usize::MAX).as_str()).to_string()
755}
756
757fn html_purify(content: String, options: Option<Value>) -> String {
758    let mut tags = vec![String::from("!DOCTYPE"), String::from("html"), String::from("head"), String::from("title"), String::from("body")];
759
760    match options {
761        None => {}
762        Some(opts) => {
763            if let Value::Object(o) = opts {
764                match o.get("tags") {
765                    None => {}
766                    Some(t) => {
767                        if let Value::Array(ta) = t {
768                            tags = vec!();
769
770                            for t in ta {
771                                if let Value::String(s) = t {
772                                    tags.push(s.to_owned())
773                                }
774                            }
775                        }
776                    }
777                }
778            }
779        }
780    }
781
782    ammonia::Builder::default()
783        .add_tags(tags.as_slice())
784        .add_generic_attributes(HashSet::from(["target"]))
785        .url_schemes(HashSet::from(["https", "mailto", "tel", "callto", "cid", "xmpp", "data"]))
786        .clean(content.as_str())
787        .to_string()
788}