1#![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#[derive(Debug)]
82pub struct ProductOSContentOptions {
83 options: Value
84}
85
86impl ProductOSContentOptions {
87 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#[derive(Debug)]
105enum PropertyError {
106 NoMatchingProperty,
107 NotAnObject,
108 InvalidProperty
109}
110
111pub enum TransformError {
113 ValueInvalid,
115 FormatInvalid,
117 ExpressionError
119}
120
121pub 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 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 return Err(TransformError::FormatInvalid)
217 },
218 ProductOSContentFormat::Binary => return Err(TransformError::FormatInvalid)
219 }
220
221 },
230 Err(_e) => { }
231 }
232 }
233 }
234 }
235 }
236
237 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 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 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 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 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 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 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 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 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 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 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 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}