1
/*
2
 * This file is part of mailpot
3
 *
4
 * Copyright 2020 - Manos Pitsidianakis
5
 *
6
 * This program is free software: you can redistribute it and/or modify
7
 * it under the terms of the GNU Affero General Public License as
8
 * published by the Free Software Foundation, either version 3 of the
9
 * License, or (at your option) any later version.
10
 *
11
 * This program is distributed in the hope that it will be useful,
12
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
 * GNU Affero General Public License for more details.
15
 *
16
 * You should have received a copy of the GNU Affero General Public License
17
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18
 */
19

            
20
//! Utils for templates with the [`minijinja`] crate.
21

            
22
use std::fmt::Write;
23

            
24
use mailpot::models::ListOwner;
25
pub use mailpot::StripCarets;
26

            
27
use super::*;
28

            
29
mod compressed;
30

            
31
lazy_static::lazy_static! {
32
    pub static ref TEMPLATES: Environment<'static> = {
33
        let mut env = Environment::new();
34
        macro_rules! add {
35
            (function $($id:ident),*$(,)?) => {
36
                $(env.add_function(stringify!($id), $id);)*
37
            };
38
            (filter $($id:ident),*$(,)?) => {
39
                $(env.add_filter(stringify!($id), $id);)*
40
            }
41
        }
42
        add!(function calendarize,
43
            strip_carets,
44
            urlize,
45
            heading,
46
            topics,
47
            login_path,
48
            logout_path,
49
            settings_path,
50
            help_path,
51
            list_path,
52
            list_settings_path,
53
            list_edit_path,
54
            list_subscribers_path,
55
            list_candidates_path,
56
            list_post_path,
57
            post_raw_path,
58
            post_eml_path
59
        );
60
        add!(filter pluralize);
61
        // Load compressed templates. They are constructed in build.rs. See
62
        // [ref:embed_templates]
63
        let mut source = minijinja::Source::new();
64
        for (name, bytes) in compressed::COMPRESSED {
65
            let mut de_bytes = vec![];
66
            zstd::stream::copy_decode(*bytes,&mut de_bytes).unwrap();
67
            source.add_template(*name, String::from_utf8(de_bytes).unwrap()).unwrap();
68
        }
69
        env.set_source(source);
70

            
71
        env.add_global("root_url_prefix", Value::from_safe_string( std::env::var("ROOT_URL_PREFIX").unwrap_or_default()));
72
        env.add_global("public_url",Value::from_safe_string(std::env::var("PUBLIC_URL").unwrap_or_default()));
73
1
        env.add_global("site_title", Value::from_safe_string(std::env::var("SITE_TITLE").unwrap_or_else(|_| "mailing list archive".to_string())));
74
        env.add_global("site_subtitle", std::env::var("SITE_SUBTITLE").ok().map(Value::from_safe_string).unwrap_or_default());
75

            
76
        env
77
    };
78
}
79

            
80
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
81
pub struct MailingList {
82
    pub pk: i64,
83
    pub name: String,
84
    pub id: String,
85
    pub address: String,
86
    pub description: Option<String>,
87
    pub topics: Vec<String>,
88
    #[serde(serialize_with = "super::utils::to_safe_string_opt")]
89
    pub archive_url: Option<String>,
90
    pub inner: DbVal<mailpot::models::MailingList>,
91
    #[serde(default)]
92
    pub is_description_html_safe: bool,
93
}
94

            
95
impl MailingList {
96
    /// Set whether it's safe to not escape the list's description field.
97
    ///
98
    /// If anyone can display arbitrary html in the server, that's bad.
99
    ///
100
    /// Note: uses `Borrow` so that it can use both `DbVal<ListOwner>` and
101
    /// `ListOwner` slices.
102
3
    pub fn set_safety<O: std::borrow::Borrow<ListOwner>>(
103
        &mut self,
104
        owners: &[O],
105
        administrators: &[String],
106
    ) {
107
3
        if owners.is_empty() || administrators.is_empty() {
108
            return;
109
        }
110
4
        self.is_description_html_safe = owners
111
            .iter()
112
4
            .any(|o| administrators.contains(&o.borrow().address));
113
3
    }
114
}
115

            
116
impl From<DbVal<mailpot::models::MailingList>> for MailingList {
117
5
    fn from(val: DbVal<mailpot::models::MailingList>) -> Self {
118
        let DbVal(
119
            mailpot::models::MailingList {
120
5
                pk,
121
5
                name,
122
5
                id,
123
5
                address,
124
5
                description,
125
5
                topics,
126
5
                archive_url,
127
            },
128
            _,
129
5
        ) = val.clone();
130

            
131
5
        Self {
132
            pk,
133
            name,
134
            id,
135
            address,
136
            description,
137
            topics,
138
            archive_url,
139
5
            inner: val,
140
            is_description_html_safe: false,
141
        }
142
5
    }
143
}
144

            
145
impl std::fmt::Display for MailingList {
146
    fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
147
        self.id.fmt(fmt)
148
    }
149
}
150

            
151
impl Object for MailingList {
152
36
    fn kind(&self) -> minijinja::value::ObjectKind {
153
36
        minijinja::value::ObjectKind::Struct(self)
154
72
    }
155

            
156
4
    fn call_method(
157
        &self,
158
        _state: &minijinja::State,
159
        name: &str,
160
        _args: &[Value],
161
    ) -> std::result::Result<Value, Error> {
162
        match name {
163
4
            "subscription_mailto" => {
164
2
                Ok(Value::from_serializable(&self.inner.subscription_mailto()))
165
2
            }
166
2
            "unsubscription_mailto" => Ok(Value::from_serializable(
167
2
                &self.inner.unsubscription_mailto(),
168
2
            )),
169
            "topics" => topics_common(&self.topics),
170
            _ => Err(Error::new(
171
                minijinja::ErrorKind::UnknownMethod,
172
                format!("object has no method named {name}"),
173
            )),
174
        }
175
4
    }
176
}
177

            
178
impl minijinja::value::StructObject for MailingList {
179
36
    fn get_field(&self, name: &str) -> Option<Value> {
180
        match name {
181
36
            "pk" => Some(Value::from_serializable(&self.pk)),
182
36
            "name" => Some(Value::from_serializable(&self.name)),
183
33
            "id" => Some(Value::from_serializable(&self.id)),
184
20
            "address" => Some(Value::from_serializable(&self.address)),
185
13
            "description" if self.is_description_html_safe => {
186
                self.description.as_ref().map_or_else(
187
                    || Some(Value::from_serializable(&self.description)),
188
                    |d| Some(Value::from_safe_string(d.clone())),
189
                )
190
            }
191
6
            "description" => Some(Value::from_serializable(&self.description)),
192
7
            "topics" => Some(Value::from_serializable(&self.topics)),
193
4
            "archive_url" => Some(Value::from_serializable(&self.archive_url)),
194
2
            "is_description_html_safe" => {
195
                Some(Value::from_serializable(&self.is_description_html_safe))
196
            }
197
2
            _ => None,
198
        }
199
36
    }
200

            
201
    fn static_fields(&self) -> Option<&'static [&'static str]> {
202
        Some(
203
            &[
204
                "pk",
205
                "name",
206
                "id",
207
                "address",
208
                "description",
209
                "topics",
210
                "archive_url",
211
                "is_description_html_safe",
212
            ][..],
213
        )
214
    }
215
}
216

            
217
/// Return a vector of weeks, with each week being a vector of 7 days and
218
/// corresponding sum of posts per day.
219
3
pub fn calendarize(
220
    _state: &minijinja::State,
221
    args: Value,
222
    hists: Value,
223
) -> std::result::Result<Value, Error> {
224
    use chrono::Month;
225

            
226
    macro_rules! month {
227
        ($int:expr) => {{
228
            let int = $int;
229
            match int {
230
                1 => Month::January.name(),
231
                2 => Month::February.name(),
232
                3 => Month::March.name(),
233
                4 => Month::April.name(),
234
                5 => Month::May.name(),
235
                6 => Month::June.name(),
236
                7 => Month::July.name(),
237
                8 => Month::August.name(),
238
                9 => Month::September.name(),
239
                10 => Month::October.name(),
240
                11 => Month::November.name(),
241
                12 => Month::December.name(),
242
                _ => unreachable!(),
243
            }
244
        }};
245
    }
246
3
    let month = args.as_str().unwrap();
247
3
    let hist = hists
248
3
        .get_item(&Value::from(month))?
249
        .as_seq()
250
        .unwrap()
251
        .iter()
252
93
        .map(|v| usize::try_from(v).unwrap())
253
3
        .collect::<Vec<usize>>();
254
3
    let sum: usize = hists
255
3
        .get_item(&Value::from(month))?
256
        .as_seq()
257
        .unwrap()
258
        .iter()
259
93
        .map(|v| usize::try_from(v).unwrap())
260
3
        .sum();
261
3
    let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%F").unwrap();
262
    // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
263
3
    Ok(minijinja::context! {
264
3
        month_name => month!(date.month()),
265
        month => month,
266
        month_int => date.month() as usize,
267
        year => date.year(),
268
        weeks => cal::calendarize_with_offset(date, 1),
269
        hist => hist,
270
        sum,
271
    })
272
3
}
273

            
274
/// `pluralize` filter for [`minijinja`].
275
///
276
/// Returns a plural suffix if the value is not `1`, `"1"`, or an object of
277
/// length `1`. By default, the plural suffix is 's' and the singular suffix is
278
/// empty (''). You can specify a singular suffix as the first argument (or
279
/// `None`, for the default). You can specify a plural suffix as the second
280
/// argument (or `None`, for the default).
281
///
282
/// See the examples for the correct usage.
283
///
284
/// # Examples
285
///
286
/// ```rust,no_run
287
/// # use mailpot_web::pluralize;
288
/// # use minijinja::Environment;
289
///
290
/// let mut env = Environment::new();
291
/// env.add_filter("pluralize", pluralize);
292
/// for (num, s) in [
293
///     (0, "You have 0 messages."),
294
///     (1, "You have 1 message."),
295
///     (10, "You have 10 messages."),
296
/// ] {
297
///     assert_eq!(
298
///         &env.render_str(
299
///             "You have {{ num_messages }} message{{ num_messages|pluralize }}.",
300
///             minijinja::context! {
301
///                 num_messages => num,
302
///             }
303
///         )
304
///         .unwrap(),
305
///         s
306
///     );
307
/// }
308
///
309
/// for (num, s) in [
310
///     (0, "You have 0 walruses."),
311
///     (1, "You have 1 walrus."),
312
///     (10, "You have 10 walruses."),
313
/// ] {
314
///     assert_eq!(
315
///         &env.render_str(
316
///             r#"You have {{ num_walruses }} walrus{{ num_walruses|pluralize(None, "es") }}."#,
317
///             minijinja::context! {
318
///                 num_walruses => num,
319
///             }
320
///         )
321
///         .unwrap(),
322
///         s
323
///     );
324
/// }
325
///
326
/// for (num, s) in [
327
///     (0, "You have 0 cherries."),
328
///     (1, "You have 1 cherry."),
329
///     (10, "You have 10 cherries."),
330
/// ] {
331
///     assert_eq!(
332
///         &env.render_str(
333
///             r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
334
///             minijinja::context! {
335
///                 num_cherries => num,
336
///             }
337
///         )
338
///         .unwrap(),
339
///         s
340
///     );
341
/// }
342
///
343
/// assert_eq!(
344
///     &env.render_str(
345
///         r#"You have {{ num_cherries|length }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
346
///         minijinja::context! {
347
///             num_cherries => vec![(); 5],
348
///         }
349
///     )
350
///     .unwrap(),
351
///     "You have 5 cherries."
352
/// );
353
///
354
/// assert_eq!(
355
///     &env.render_str(
356
///         r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
357
///         minijinja::context! {
358
///             num_cherries => "5",
359
///         }
360
///     )
361
///     .unwrap(),
362
///     "You have 5 cherries."
363
/// );
364
/// assert_eq!(
365
///     &env.render_str(
366
///         r#"You have 1 cherr{{ num_cherries|pluralize("y", "ies") }}."#,
367
///         minijinja::context! {
368
///             num_cherries => true,
369
///         }
370
///     )
371
///     .unwrap()
372
///     .to_string(),
373
///     "You have 1 cherry.",
374
/// );
375
/// assert_eq!(
376
///     &env.render_str(
377
///         r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
378
///         minijinja::context! {
379
///             num_cherries => 0.5f32,
380
///         }
381
///     )
382
///     .unwrap_err()
383
///     .to_string(),
384
///     "invalid operation: Pluralize argument is not an integer, or a sequence / object with a \
385
///      length but of type number (in <string>:1)",
386
/// );
387
/// ```
388
21
pub fn pluralize(
389
    v: Value,
390
    singular: Option<String>,
391
    plural: Option<String>,
392
) -> Result<Value, minijinja::Error> {
393
    macro_rules! int_try_from {
394
         ($ty:ty) => {
395
18
             <$ty>::try_from(v.clone()).ok().map(|v| v != 1)
396
         };
397
         ($fty:ty, $($ty:ty),*) => {
398
10
             int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
399
         }
400
     }
401
84
    let is_plural: bool = v
402
        .as_str()
403
1
        .and_then(|s| s.parse::<i128>().ok())
404
1
        .map(|l| l != 1)
405
42
        .or_else(|| v.len().map(|l| l != 1))
406
40
        .or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
407
22
        .ok_or_else(|| {
408
1
            minijinja::Error::new(
409
1
                minijinja::ErrorKind::InvalidOperation,
410
1
                format!(
411
                    "Pluralize argument is not an integer, or a sequence / object with a length \
412
                     but of type {}",
413
2
                    v.kind()
414
                ),
415
            )
416
2
        })?;
417
40
    Ok(match (is_plural, singular, plural) {
418
6
        (false, None, _) => "".into(),
419
3
        (false, Some(suffix), _) => suffix.into(),
420
3
        (true, _, None) => "s".into(),
421
8
        (true, _, Some(suffix)) => suffix.into(),
422
    })
423
21
}
424

            
425
/// `strip_carets` filter for [`minijinja`].
426
///
427
/// Removes `[<>]` from message ids.
428
4
pub fn strip_carets(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
429
4
    Ok(Value::from(
430
8
        arg.as_str()
431
4
            .ok_or_else(|| {
432
                minijinja::Error::new(
433
                    minijinja::ErrorKind::InvalidOperation,
434
                    format!("argument to strip_carets() is of type {}", arg.kind()),
435
                )
436
            })?
437
            .strip_carets(),
438
    ))
439
4
}
440

            
441
/// `urlize` filter for [`minijinja`].
442
///
443
/// Returns a safe string for use in `<a href=..` attributes.
444
///
445
/// # Examples
446
///
447
/// ```rust,no_run
448
/// # use mailpot_web::urlize;
449
/// # use minijinja::Environment;
450
/// # use minijinja::value::Value;
451
///
452
/// let mut env = Environment::new();
453
/// env.add_function("urlize", urlize);
454
/// env.add_global(
455
///     "root_url_prefix",
456
///     Value::from_safe_string("/lists/prefix/".to_string()),
457
/// );
458
/// assert_eq!(
459
///     &env.render_str(
460
///         "<a href=\"{{ urlize(\"path/index.html\") }}\">link</a>",
461
///         minijinja::context! {}
462
///     )
463
///     .unwrap(),
464
///     "<a href=\"/lists/prefix/path/index.html\">link</a>",
465
/// );
466
/// ```
467
54
pub fn urlize(state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
468
54
    let Some(prefix) = state.lookup("root_url_prefix") else {
469
        return Ok(arg);
470
    };
471
54
    Ok(Value::from_safe_string(format!("{prefix}{arg}")))
472
54
}
473

            
474
/// Make an html heading: `h1, h2, h3` etc.
475
///
476
/// # Example
477
/// ```rust,no_run
478
/// use mailpot_web::minijinja_utils::heading;
479
/// use minijinja::value::Value;
480
///
481
/// assert_eq!(
482
///   "<h1 id=\"bl-bfa-b-ah-b-asdb-hadas-d\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" href=\"#bl-bfa-b-ah-b-asdb-hadas-d\"></a></h1>",
483
///   &heading(1.into(), "bl bfa B AH bAsdb hadas d".into(), None).unwrap().to_string()
484
/// );
485
/// assert_eq!(
486
///     "<h2 id=\"short\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" href=\"#short\"></a></h2>",
487
///     &heading(2.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap().to_string()
488
/// );
489
/// assert_eq!(
490
///     r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
491
///     &heading(0.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
492
/// );
493
/// assert_eq!(
494
///     r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
495
///     &heading(8.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
496
/// );
497
/// assert_eq!(
498
///     r#"invalid operation: first heading() argument is not an integer < 7 but of type sequence"#,
499
///     &heading(Value::from(vec![Value::from(1)]), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
500
/// );
501
/// ```
502
23
pub fn heading(level: Value, text: Value, id: Option<Value>) -> std::result::Result<Value, Error> {
503
    use convert_case::{Case, Casing};
504
    macro_rules! test {
505
        () => {
506
42
            |n| *n > 0 && *n < 7
507
        };
508
    }
509

            
510
    macro_rules! int_try_from {
511
         ($ty:ty) => {
512
20
             <$ty>::try_from(level.clone()).ok().filter(test!{}).map(|n| n as u8)
513
         };
514
         ($fty:ty, $($ty:ty),*) => {
515
30
             int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
516
         }
517
     }
518
69
    let level: u8 = level
519
        .as_str()
520
        .and_then(|s| s.parse::<i128>().ok())
521
        .filter(test! {})
522
        .map(|n| n as u8)
523
46
        .or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
524
26
        .ok_or_else(|| {
525
3
            if matches!(level.kind(), minijinja::value::ValueKind::Number) {
526
4
                minijinja::Error::new(
527
2
                    minijinja::ErrorKind::InvalidOperation,
528
                    "first heading() argument must be an unsigned integer less than 7 and positive",
529
                )
530
            } else {
531
1
                minijinja::Error::new(
532
1
                    minijinja::ErrorKind::InvalidOperation,
533
1
                    format!(
534
                        "first heading() argument is not an integer < 7 but of type {}",
535
2
                        level.kind()
536
                    ),
537
                )
538
            }
539
6
        })?;
540
20
    let text = text.as_str().ok_or_else(|| {
541
        minijinja::Error::new(
542
            minijinja::ErrorKind::InvalidOperation,
543
            format!(
544
                "second heading() argument is not a string but of type {}",
545
                text.kind()
546
            ),
547
        )
548
    })?;
549
20
    if let Some(v) = id {
550
3
        let kebab = v.as_str().ok_or_else(|| {
551
            minijinja::Error::new(
552
                minijinja::ErrorKind::InvalidOperation,
553
                format!(
554
                    "third heading() argument is not a string but of type {}",
555
                    v.kind()
556
                ),
557
            )
558
3
        })?;
559
3
        Ok(Value::from_safe_string(format!(
560
            "<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
561
             href=\"#{kebab}\"></a></h{level}>"
562
        )))
563
3
    } else {
564
17
        let kebab_v = text.to_case(Case::Kebab);
565
        let kebab =
566
17
            percent_encoding::utf8_percent_encode(&kebab_v, crate::typed_paths::PATH_SEGMENT);
567
17
        Ok(Value::from_safe_string(format!(
568
            "<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
569
             href=\"#{kebab}\"></a></h{level}>"
570
        )))
571
17
    }
572
23
}
573

            
574
/// Make an array of topic strings into html badges.
575
///
576
/// # Example
577
/// ```rust
578
/// use mailpot_web::minijinja_utils::topics;
579
/// use minijinja::value::Value;
580
///
581
/// let v: Value = topics(Value::from_serializable(&vec![
582
///     "a".to_string(),
583
///     "aab".to_string(),
584
///     "aaab".to_string(),
585
/// ]))
586
/// .unwrap();
587
/// assert_eq!(
588
///     "<ul class=\"tags\"><li class=\"tag\" style=\"--red:110;--green:120;--blue:180;\"><span \
589
///      class=\"tag-name\"><a href=\"/topics/?query=a\">a</a></span></li><li class=\"tag\" \
590
///      style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
591
///      href=\"/topics/?query=aab\">aab</a></span></li><li class=\"tag\" \
592
///      style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
593
///      href=\"/topics/?query=aaab\">aaab</a></span></li></ul>",
594
///     &v.to_string()
595
/// );
596
/// ```
597
1
pub fn topics(topics: Value) -> std::result::Result<Value, Error> {
598
1
    topics.try_iter()?;
599
1
    let topics: Vec<String> = topics
600
1
        .try_iter()?
601
3
        .map(|v| v.to_string())
602
        .collect::<Vec<String>>();
603
1
    topics_common(&topics)
604
1
}
605

            
606
1
pub(crate) fn topics_common(topics: &[String]) -> std::result::Result<Value, Error> {
607
1
    let mut ul = String::new();
608
1
    write!(&mut ul, r#"<ul class="tags">"#)?;
609
4
    for topic in topics {
610
3
        write!(
611
            &mut ul,
612
            r#"<li class="tag" style="--red:110;--green:120;--blue:180;"><span class="tag-name"><a href=""#
613
        )?;
614
3
        write!(&mut ul, "{}", TopicsPath)?;
615
3
        write!(&mut ul, r#"?query="#)?;
616
3
        write!(
617
            &mut ul,
618
            "{}",
619
3
            utf8_percent_encode(topic, crate::typed_paths::PATH_SEGMENT)
620
        )?;
621
3
        write!(&mut ul, r#"">"#)?;
622
3
        write!(&mut ul, "{}", topic)?;
623
3
        write!(&mut ul, r#"</a></span></li>"#)?;
624
    }
625
2
    write!(&mut ul, r#"</ul>"#)?;
626
1
    Ok(Value::from_safe_string(ul))
627
1
}
628

            
629
#[cfg(test)]
630
mod tests {
631
    use super::*;
632

            
633
    #[test]
634
2
    fn test_pluralize() {
635
1
        let mut env = Environment::new();
636
1
        env.add_filter("pluralize", pluralize);
637
4
        for (num, s) in [
638
1
            (0, "You have 0 messages."),
639
1
            (1, "You have 1 message."),
640
1
            (10, "You have 10 messages."),
641
        ] {
642
3
            assert_eq!(
643
3
                &env.render_str(
644
                    "You have {{ num_messages }} message{{ num_messages|pluralize }}.",
645
6
                    minijinja::context! {
646
                        num_messages => num,
647
                    }
648
                )
649
                .unwrap(),
650
                s
651
            );
652
        }
653

            
654
4
        for (num, s) in [
655
1
            (0, "You have 0 walruses."),
656
1
            (1, "You have 1 walrus."),
657
1
            (10, "You have 10 walruses."),
658
        ] {
659
3
            assert_eq!(
660
3
        &env.render_str(
661
            r#"You have {{ num_walruses }} walrus{{ num_walruses|pluralize(None, "es") }}."#,
662
6
            minijinja::context! {
663
                num_walruses => num,
664
            }
665
        )
666
        .unwrap(),
667
        s
668
    );
669
        }
670

            
671
4
        for (num, s) in [
672
1
            (0, "You have 0 cherries."),
673
1
            (1, "You have 1 cherry."),
674
1
            (10, "You have 10 cherries."),
675
        ] {
676
3
            assert_eq!(
677
3
                &env.render_str(
678
                    r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
679
6
                    minijinja::context! {
680
                        num_cherries => num,
681
                    }
682
                )
683
                .unwrap(),
684
                s
685
            );
686
        }
687

            
688
1
        assert_eq!(
689
1
    &env.render_str(
690
        r#"You have {{ num_cherries|length }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
691
1
        minijinja::context! {
692
            num_cherries => vec![(); 5],
693
        }
694
    )
695
    .unwrap(),
696
    "You have 5 cherries."
697
);
698

            
699
1
        assert_eq!(
700
1
            &env.render_str(
701
                r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
702
1
                minijinja::context! {
703
                    num_cherries => "5",
704
                }
705
            )
706
            .unwrap(),
707
            "You have 5 cherries."
708
        );
709
1
        assert_eq!(
710
1
            &env.render_str(
711
                r#"You have 1 cherr{{ num_cherries|pluralize("y", "ies") }}."#,
712
1
                minijinja::context! {
713
                    num_cherries => true,
714
                }
715
            )
716
            .unwrap(),
717
            "You have 1 cherry.",
718
        );
719
1
        assert_eq!(
720
1
            &env.render_str(
721
                r#"You have {{ num_cherries }} cherr{{ num_cherries|pluralize("y", "ies") }}."#,
722
1
                minijinja::context! {
723
                    num_cherries => 0.5f32,
724
                }
725
            )
726
            .unwrap_err()
727
            .to_string(),
728
            "invalid operation: Pluralize argument is not an integer, or a sequence / object with \
729
             a length but of type number (in <string>:1)",
730
        );
731
2
    }
732

            
733
    #[test]
734
2
    fn test_urlize() {
735
1
        let mut env = Environment::new();
736
1
        env.add_function("urlize", urlize);
737
1
        env.add_global(
738
            "root_url_prefix",
739
1
            Value::from_safe_string("/lists/prefix/".to_string()),
740
        );
741
1
        assert_eq!(
742
1
            &env.render_str(
743
                "<a href=\"{{ urlize(\"path/index.html\") }}\">link</a>",
744
1
                minijinja::context! {}
745
            )
746
            .unwrap(),
747
            "<a href=\"/lists/prefix/path/index.html\">link</a>",
748
        );
749
2
    }
750

            
751
    #[test]
752
2
    fn test_heading() {
753
1
        assert_eq!(
754
            "<h1 id=\"bl-bfa-b-ah-b-asdb-hadas-d\">bl bfa B AH bAsdb hadas d<a \
755
             class=\"self-link\" href=\"#bl-bfa-b-ah-b-asdb-hadas-d\"></a></h1>",
756
1
            &heading(1.into(), "bl bfa B AH bAsdb hadas d".into(), None)
757
                .unwrap()
758
                .to_string()
759
        );
760
1
        assert_eq!(
761
            "<h2 id=\"short\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" \
762
             href=\"#short\"></a></h2>",
763
1
            &heading(
764
1
                2.into(),
765
1
                "bl bfa B AH bAsdb hadas d".into(),
766
1
                Some("short".into())
767
            )
768
            .unwrap()
769
            .to_string()
770
        );
771
1
        assert_eq!(
772
            r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
773
1
            &heading(
774
1
                0.into(),
775
1
                "bl bfa B AH bAsdb hadas d".into(),
776
1
                Some("short".into())
777
            )
778
            .unwrap_err()
779
            .to_string()
780
        );
781
1
        assert_eq!(
782
            r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
783
1
            &heading(
784
1
                8.into(),
785
1
                "bl bfa B AH bAsdb hadas d".into(),
786
1
                Some("short".into())
787
            )
788
            .unwrap_err()
789
            .to_string()
790
        );
791
1
        assert_eq!(
792
            r#"invalid operation: first heading() argument is not an integer < 7 but of type sequence"#,
793
1
            &heading(
794
1
                Value::from(vec![Value::from(1)]),
795
1
                "bl bfa B AH bAsdb hadas d".into(),
796
1
                Some("short".into())
797
            )
798
            .unwrap_err()
799
            .to_string()
800
        );
801
2
    }
802

            
803
    #[test]
804
2
    fn test_strip_carets() {
805
1
        let mut env = Environment::new();
806
1
        env.add_filter("strip_carets", strip_carets);
807
1
        assert_eq!(
808
1
            &env.render_str(
809
                "{{ msg_id | strip_carets }}",
810
1
                minijinja::context! {
811
                    msg_id => "<hello1@example.com>",
812
                }
813
            )
814
            .unwrap(),
815
            "hello1@example.com",
816
        );
817
2
    }
818

            
819
    #[test]
820
2
    fn test_calendarize() {
821
        use std::collections::HashMap;
822

            
823
1
        let mut env = Environment::new();
824
1
        env.add_function("calendarize", calendarize);
825

            
826
2
        let month = "2001-09";
827
1
        let mut hist = [0usize; 31];
828
1
        hist[15] = 5;
829
1
        hist[1] = 1;
830
1
        hist[0] = 512;
831
1
        hist[30] = 30;
832
1
        assert_eq!(
833
1
    &env.render_str(
834
        "{% set c=calendarize(month, hists) %}Month: {{ c.month }} Month Name: {{ \
835
         c.month_name }} Month Int: {{ c.month_int }} Year: {{ c.year }} Sum: {{ c.sum }} {% \
836
         for week in c.weeks %}{% for day in week %}{% set num = c.hist[day-1] %}({{ day }}, \
837
         {{ num }}){% endfor %}{% endfor %}",
838
1
        minijinja::context! {
839
        month,
840
        hists => vec![(month.to_string(), hist)].into_iter().collect::<HashMap<String, [usize;
841
        31]>>(),
842
        }
843
    )
844
    .unwrap(),
845
    "Month: 2001-09 Month Name: September Month Int: 9 Year: 2001 Sum: 548 (0, 30)(0, 30)(0, \
846
     30)(0, 30)(0, 30)(1, 512)(2, 1)(3, 0)(4, 0)(5, 0)(6, 0)(7, 0)(8, 0)(9, 0)(10, 0)(11, \
847
     0)(12, 0)(13, 0)(14, 0)(15, 0)(16, 5)(17, 0)(18, 0)(19, 0)(20, 0)(21, 0)(22, 0)(23, \
848
     0)(24, 0)(25, 0)(26, 0)(27, 0)(28, 0)(29, 0)(30, 0)"
849
);
850
2
    }
851

            
852
    #[test]
853
2
    fn test_list_html_safe() {
854
1
        let mut list = MailingList {
855
            pk: 0,
856
1
            name: String::new(),
857
1
            id: String::new(),
858
1
            address: String::new(),
859
1
            description: None,
860
1
            topics: vec![],
861
1
            archive_url: None,
862
1
            inner: DbVal(
863
1
                mailpot::models::MailingList {
864
                    pk: 0,
865
1
                    name: String::new(),
866
1
                    id: String::new(),
867
1
                    address: String::new(),
868
1
                    description: None,
869
1
                    topics: vec![],
870
1
                    archive_url: None,
871
                },
872
                0,
873
            ),
874
            is_description_html_safe: false,
875
        };
876

            
877
2
        let mut list_owners = vec![ListOwner {
878
            pk: 0,
879
            list: 0,
880
1
            address: "admin@example.com".to_string(),
881
1
            name: None,
882
        }];
883
1
        let administrators = vec!["admin@example.com".to_string()];
884
1
        list.set_safety(&list_owners, &administrators);
885
1
        assert!(list.is_description_html_safe);
886
1
        list.set_safety::<ListOwner>(&[], &[]);
887
1
        assert!(list.is_description_html_safe);
888
1
        list.is_description_html_safe = false;
889
2
        list_owners[0].address = "user@example.com".to_string();
890
1
        list.set_safety(&list_owners, &administrators);
891
1
        assert!(!list.is_description_html_safe);
892
2
    }
893
}