Skip to main content

ocre/
helpers.rs

1//! View helpers: numbers, dates and text formatted like Rails' `number_to_currency`, `time_ago_in_words`, `excerpt`...
2//!
3//! Plain functions returning `String`, usable anywhere (a JSON field, an
4//! email, a log line). Templates use them as askama filters through
5//! [`filters`](crate::filters) (`{{ post.price|number_to_currency("$") }}`,
6//! feature `html`).
7//!
8//! Number helpers take any value whose text is a number (`i64`, `f64`, a
9//! `String` from D1...) and return any other text unchanged. Time helpers
10//! take Unix seconds (`i64`, as [`now`](crate::now) returns) or the text D1
11//! stores (`2026-09-29 14:05:00`, `2026-09-29T14:05:00Z`, `2026-09-29`,
12//! with an optional `+02:00` offset), read as UTC when there is no offset,
13//! and return other text unchanged. All are plain string work: microseconds
14//! of CPU, no binding call. English only; [`I18n`](crate::i18n::I18n) has
15//! the localized versions (`l`, `number`, `currency`, `time_ago_in_words`...).
16
17use std::fmt::{Display, Write as _};
18
19/// Groups the integer part by thousands: `1234567.891` becomes `1,234,567.891` (Rails' `number_with_delimiter`).
20///
21/// # Examples
22///
23/// ```
24/// use ocre::helpers::number_with_delimiter;
25///
26/// assert_eq!(number_with_delimiter(-1234567), "-1,234,567");
27/// assert_eq!(number_with_delimiter("1234.5"), "1,234.5");
28/// assert_eq!(number_with_delimiter("n/a"), "n/a");
29/// ```
30pub fn number_with_delimiter(value: impl Display) -> String {
31    number_with_delimiter_text(value.to_string())
32}
33
34fn number_with_delimiter_text(text: String) -> String {
35    with_delimiter(&text)
36}
37
38/// Rounds to `precision` decimals: `3.14159` with 2 becomes `3.14` (Rails' `number_with_precision`).
39///
40/// # Examples
41///
42/// ```
43/// assert_eq!(ocre::helpers::number_with_precision(2.46, 1), "2.5");
44/// ```
45pub fn number_with_precision(value: impl Display, precision: usize) -> String {
46    number_with_precision_text(value.to_string(), precision)
47}
48
49fn number_with_precision_text(text: String, precision: usize) -> String {
50    number(&text).map_or(text, |n| format!("{n:.precision$}"))
51}
52
53/// Two decimals, thousands grouped, `unit` first: `1234.5` with `"$"` becomes `$1,234.50`, `-3` becomes `-$3.00` (Rails' `number_to_currency`).
54///
55/// For amounts stored in cents, divide first: `number_to_currency(cents as f64 / 100.0, "€")`.
56///
57/// # Examples
58///
59/// ```
60/// assert_eq!(ocre::helpers::number_to_currency(1234.5, "€"), "€1,234.50");
61/// ```
62pub fn number_to_currency(value: impl Display, unit: &str) -> String {
63    number_to_currency_text(value.to_string(), unit)
64}
65
66fn number_to_currency_text(text: String, unit: &str) -> String {
67    match number(&text) {
68        Some(n) => {
69            let sign = if n < 0.0 && (n * 100.0).round() != 0.0 { "-" } else { "" };
70            format!("{sign}{unit}{}", with_delimiter(&format!("{:.2}", n.abs())))
71        }
72        None => text,
73    }
74}
75
76/// Rounds to `precision` decimals and adds `%`: `12.345` with 1 becomes `12.3%` (Rails' `number_to_percentage`).
77///
78/// # Examples
79///
80/// ```
81/// assert_eq!(ocre::helpers::number_to_percentage(99.6, 0), "100%");
82/// ```
83pub fn number_to_percentage(value: impl Display, precision: usize) -> String {
84    number_to_percentage_text(value.to_string(), precision)
85}
86
87fn number_to_percentage_text(text: String, precision: usize) -> String {
88    number(&text).map_or(text, |n| format!("{n:.precision$}%"))
89}
90
91/// A byte count in 1024 steps: `1536` becomes `1.5 KB` (Rails' `number_to_human_size`, as [`storage::human_size`](crate::storage::human_size)).
92///
93/// # Examples
94///
95/// ```
96/// assert_eq!(ocre::helpers::number_to_human_size(3 * 1024 * 1024), "3 MB");
97/// ```
98pub fn number_to_human_size(value: impl Display) -> String {
99    number_to_human_size_text(value.to_string())
100}
101
102fn number_to_human_size_text(text: String) -> String {
103    match number(&text) {
104        Some(n) if n >= 0.0 => crate::storage::human_size(n as u64),
105        _ => text,
106    }
107}
108
109/// Three significant digits and a word, Thousand to Quadrillion: `1234567` becomes `1.23 Million` (Rails' `number_to_human`).
110///
111/// # Examples
112///
113/// ```
114/// assert_eq!(ocre::helpers::number_to_human(489_939), "490 Thousand");
115/// assert_eq!(ocre::helpers::number_to_human(123), "123");
116/// ```
117pub fn number_to_human(value: impl Display) -> String {
118    number_to_human_text(value.to_string())
119}
120
121fn number_to_human_text(text: String) -> String {
122    number(&text).map_or(text, human)
123}
124
125/// The time from `value` to [`now`](crate::now), in words: `about 3 hours` (Rails' `time_ago_in_words`).
126///
127/// Add "ago" yourself: `{{ post.created_at|time_ago_in_words }} ago`. The
128/// wording is Rails': `less than a minute`, `1 minute`, `N minutes`,
129/// `about 1 hour`, `about N hours`, `1 day`, `N days`, `about 1 month`,
130/// `about 2 months`, `N months`, then `about N years`, `over N years` and
131/// `almost N years`.
132///
133/// # Examples
134///
135/// ```
136/// let three_hours_ago = ocre::now() - 3 * 3600;
137/// assert_eq!(ocre::helpers::time_ago_in_words(three_hours_ago), "about 3 hours");
138/// ```
139pub fn time_ago_in_words(value: impl Display) -> String {
140    time_ago_in_words_text(value.to_string())
141}
142
143fn time_ago_in_words_text(text: String) -> String {
144    parse_time(&text).map_or(text, |time| distance_in_words(time, crate::now()))
145}
146
147/// The time between two times, in words: `5 minutes`, `2 days` (Rails' `distance_of_time_in_words`).
148///
149/// The order does not matter. See [`time_ago_in_words`] for the wording.
150///
151/// # Examples
152///
153/// ```
154/// use ocre::helpers::distance_of_time_in_words;
155///
156/// assert_eq!(distance_of_time_in_words("2026-01-01 10:00:00", "2026-01-03 09:00:00"), "2 days");
157/// ```
158pub fn distance_of_time_in_words(from: impl Display, to: impl Display) -> String {
159    distance_text(from.to_string(), &to.to_string())
160}
161
162fn distance_text(text: String, to: &str) -> String {
163    match (parse_time(&text), parse_time(to)) {
164        (Some(from), Some(to)) => distance_in_words(from, to),
165        _ => text,
166    }
167}
168
169/// Formats a time in UTC with `strftime` directives: `%b %-d, %Y` gives `Sep 29, 2026`.
170///
171/// Directives: `%Y` (2026), `%y` (26), `%m` (09), `%d` (29), `%e` (day
172/// padded with a space), `%H` (14), `%I` (02), `%M`, `%S`, `%p` (AM/PM),
173/// `%b` (Sep), `%B` (September), `%a` (Tue), `%A` (Tuesday), `%j` (day of
174/// the year), `%F` (2026-09-29), `%T` (14:05:00), `%z` (`+0000`: always
175/// UTC), `%%`; `%-d`, `%-m`, `%-H`, `%-I` drop the zero padding. Unknown
176/// directives are copied as they are. For month and day names in another
177/// language, see [`I18n::l`](crate::i18n::I18n::l).
178///
179/// # Examples
180///
181/// ```
182/// use ocre::helpers::strftime;
183///
184/// assert_eq!(strftime("2026-09-29 14:05:00", "%A %-d %B %Y, %I:%M %p"), "Tuesday 29 September 2026, 02:05 PM");
185/// ```
186pub fn strftime(value: impl Display, format: &str) -> String {
187    strftime_text(value.to_string(), format)
188}
189
190fn strftime_text(text: String, format: &str) -> String {
191    parse_time(&text).map_or(text, |time| format_time(time, format))
192}
193
194/// The first match of `phrase` (case-insensitive) with `radius` characters around it, `...` where cut (Rails' `excerpt`).
195///
196/// Empty when the phrase is not found.
197///
198/// # Examples
199///
200/// ```
201/// assert_eq!(ocre::helpers::excerpt("This is an example", "an", 5), "...s is an exam...");
202/// ```
203pub fn excerpt(value: impl Display, phrase: &str, radius: usize) -> String {
204    excerpt_text(value.to_string(), phrase, radius)
205}
206
207fn excerpt_text(text: String, phrase: &str, radius: usize) -> String {
208    let text: Vec<char> = text.chars().collect();
209    let Some(start) = find_ignore_case(&text, phrase, 0) else {
210        return String::new();
211    };
212    let end = start + phrase.chars().count();
213    let (from, to) = (start.saturating_sub(radius), (end + radius).min(text.len()));
214    let mut out = String::new();
215    if from > 0 {
216        out.push_str("...");
217    }
218    out.extend(&text[from..to]);
219    if to < text.len() {
220        out.push_str("...");
221    }
222    out
223}
224
225/// The text, HTML-escaped, with each match of `phrase` (case-insensitive) in `<mark>` (Rails' `highlight`).
226///
227/// The result is HTML: the `highlight` filter marks it safe.
228///
229/// # Examples
230///
231/// ```
232/// assert_eq!(
233///     ocre::helpers::highlight("Rust & rusty <tools>", "rust"),
234///     "<mark>Rust</mark> &amp; <mark>rust</mark>y &lt;tools&gt;"
235/// );
236/// ```
237pub fn highlight(value: impl Display, phrase: &str) -> String {
238    highlight_text(value.to_string(), phrase)
239}
240
241fn highlight_text(text: String, phrase: &str) -> String {
242    let text: Vec<char> = text.chars().collect();
243    let length = phrase.chars().count();
244    let mut out = String::with_capacity(text.len() + 16);
245    let mut at = 0;
246    while let Some(start) = (length > 0).then(|| find_ignore_case(&text, phrase, at)).flatten() {
247        escape_into(&mut out, &text[at..start]);
248        out.push_str("<mark>");
249        escape_into(&mut out, &text[start..start + length]);
250        out.push_str("</mark>");
251        at = start + length;
252    }
253    escape_into(&mut out, &text[at..]);
254    out
255}
256
257/// Breaks lines longer than `width` characters at spaces (Rails' `word_wrap`).
258///
259/// Words longer than `width` stay whole; existing line breaks are kept.
260/// In HTML, show the breaks with `|linebreaksbr` or in a `<pre>`.
261///
262/// # Examples
263///
264/// ```
265/// assert_eq!(ocre::helpers::word_wrap("Once upon a time", 8), "Once\nupon a\ntime");
266/// ```
267pub fn word_wrap(value: impl Display, width: usize) -> String {
268    word_wrap_text(value.to_string(), width)
269}
270
271fn word_wrap_text(text: String, width: usize) -> String {
272    let mut lines = Vec::new();
273    for paragraph in text.split('\n') {
274        let mut line = String::new();
275        for word in paragraph.split_whitespace() {
276            if !line.is_empty() && line.chars().count() + 1 + word.chars().count() > width {
277                lines.push(std::mem::take(&mut line));
278            }
279            if !line.is_empty() {
280                line.push(' ');
281            }
282            line.push_str(word);
283        }
284        lines.push(line);
285    }
286    lines.join("\n")
287}
288
289/// Space-separated class names whose condition is true (Rails' `class_names` / `token_list`).
290///
291/// In a template (askama passes the array by reference):
292/// `<a class="{{ ocre::helpers::class_names([("tab", true), ("active", is_current)]) }}">`.
293///
294/// # Examples
295///
296/// ```
297/// let classes = ocre::helpers::class_names(&[("btn", true), ("btn-danger", false), ("wide", true)]);
298/// assert_eq!(classes, "btn wide");
299/// ```
300pub fn class_names(classes: &[(&str, bool)]) -> String {
301    let mut out = String::new();
302    for &(name, on) in classes {
303        if on && !name.is_empty() {
304            if !out.is_empty() {
305                out.push(' ');
306            }
307            out.push_str(name);
308        }
309    }
310    out
311}
312
313/// Whether `url` names the page being shown (Rails' `current_page?`), for "you are here" links.
314///
315/// `current` is the request's URI as text (`uri.to_string()` of the `Uri`
316/// or `OriginalUri` extractor, or just its path and query); `url` is a link
317/// target: a path, a path with a query, or an absolute URL. Paths are
318/// compared without a trailing slash (`/posts/` is `/posts`) and without a
319/// `#fragment`. The query only matters when `url` has one, and the host
320/// only when both are absolute. Pure string work, no binding call.
321///
322/// # Examples
323///
324/// ```
325/// use ocre::helpers::current_page;
326///
327/// let current = "https://shop.example.com/posts?page=2";
328/// assert!(current_page(current, "/posts"));
329/// assert!(current_page(current, "/posts/?page=2"));
330/// assert!(!current_page(current, "/posts?page=3"));
331/// assert!(current_page(current, "https://shop.example.com/posts#top"));
332/// assert!(!current_page(current, "https://other.example.com/posts"));
333/// ```
334///
335/// In a template, with `current: String` set from the `Uri` extractor:
336/// `<a href="/posts" class="{{ ocre::helpers::class_names([("active", ocre::helpers::current_page(current, "/posts"))]) }}">`.
337pub fn current_page(current: &str, url: &str) -> bool {
338    let (current_host, current_path, current_query) = split_url(current);
339    let (host, path, query) = split_url(url);
340    let same_host = match (current_host, host) {
341        (Some(current_host), Some(host)) => current_host.eq_ignore_ascii_case(host),
342        _ => true,
343    };
344    same_host && current_path == path && query.is_none_or(|query| current_query == Some(query))
345}
346
347/// `https://host/path/?q#f` is `(Some("host"), "/path", Some("q"))`.
348fn split_url(url: &str) -> (Option<&str>, &str, Option<&str>) {
349    let url = url.split('#').next().unwrap_or_default();
350    let (host, rest) = match url.split_once("://") {
351        Some((_, after)) => {
352            let end = after.find(['/', '?']).unwrap_or(after.len());
353            (Some(&after[..end]), &after[end..])
354        }
355        None => (None, url),
356    };
357    let (path, query) = match rest.split_once('?') {
358        Some((path, query)) => (path, Some(query).filter(|query| !query.is_empty())),
359        None => (rest, None),
360    };
361    let path = path.trim_end_matches('/');
362    (host, if path.is_empty() { "/" } else { path }, query)
363}
364
365pub(crate) fn number(text: &str) -> Option<f64> {
366    text.trim().parse::<f64>().ok().filter(|n| n.is_finite())
367}
368
369/// `-1234567.5` -> `-1,234,567.5`; anything else unchanged.
370fn with_delimiter(text: &str) -> String {
371    delimit(text, ",", ".")
372}
373
374/// `-1234567.5` with `delimiter` between thousands and `separator` before the decimals; non-numbers unchanged.
375pub(crate) fn delimit(text: &str, delimiter: &str, separator: &str) -> String {
376    if number(text).is_none() {
377        return text.to_owned();
378    }
379    let text = text.trim();
380    let (sign, rest) = text.strip_prefix('-').map_or(("", text), |rest| ("-", rest));
381    let (int, frac) = rest.split_once('.').map_or((rest, None), |(int, frac)| (int, Some(frac)));
382    if !int.bytes().all(|b| b.is_ascii_digit()) {
383        return text.to_owned();
384    }
385    let mut out = String::from(sign);
386    for (i, digit) in int.chars().enumerate() {
387        if i > 0 && (int.len() - i) % 3 == 0 {
388            out.push_str(delimiter);
389        }
390        out.push(digit);
391    }
392    if let Some(frac) = frac {
393        out.push_str(separator);
394        out.push_str(frac);
395    }
396    out
397}
398
399fn human(n: f64) -> String {
400    const UNITS: [&str; 5] = ["Thousand", "Million", "Billion", "Trillion", "Quadrillion"];
401    let mut exponent = 0;
402    let mut value = n.abs();
403    while value >= 999.5 && exponent < UNITS.len() {
404        value /= 1000.0;
405        exponent += 1;
406    }
407    let digits = if value >= 99.95 {
408        0
409    } else if value >= 9.995 {
410        1
411    } else {
412        2
413    };
414    let rounded = format!("{value:.digits$}");
415    let rounded = if rounded.contains('.') { rounded.trim_end_matches('0').trim_end_matches('.') } else { &rounded };
416    let sign = if n < 0.0 { "-" } else { "" };
417    match exponent {
418        0 => format!("{sign}{rounded}"),
419        _ => format!("{sign}{rounded} {}", UNITS[exponent - 1]),
420    }
421}
422
423/// Unix seconds from `1727618700`, `2026-09-29`, `2026-09-29 14:05[:00[.123]]`
424/// (or with `T`), followed by nothing, `Z` or `±hh:mm`.
425pub(crate) fn parse_time(text: &str) -> Option<i64> {
426    let text = text.trim();
427    if let Ok(seconds) = text.parse::<i64>() {
428        return Some(seconds);
429    }
430    let number = |part: &str| -> Option<i64> {
431        (!part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())).then(|| part.parse().ok()).flatten()
432    };
433    let (date, rest) = text.split_at(text.len().min(10));
434    let mut ymd = date.splitn(3, '-');
435    let (year, month, day) = (number(ymd.next()?)?, number(ymd.next()?)?, number(ymd.next()?)?);
436    if date.len() != 10 || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
437        return None;
438    }
439    let mut seconds = days_from_civil(year, month, day) * 86_400;
440    let rest = rest.strip_prefix(['T', ' ']).unwrap_or(rest);
441    let zone_at = rest.find(['Z', '+', '-']).unwrap_or(rest.len());
442    let (clock, zone) = rest.split_at(zone_at);
443    if !clock.is_empty() {
444        let clock = clock.split('.').next().unwrap_or(clock);
445        let mut hms = clock.split(':');
446        let (hour, minute) = (number(hms.next()?)?, number(hms.next()?)?);
447        let second = hms.next().map_or(Some(0), number)?;
448        if hms.next().is_some() || hour > 23 || minute > 59 || second > 60 {
449            return None;
450        }
451        seconds += hour * 3600 + minute * 60 + second;
452    }
453    match zone {
454        "" | "Z" => Some(seconds),
455        _ => {
456            let (sign, offset) = zone.split_at(1);
457            let (hours, minutes) = offset.split_once(':').unwrap_or((offset, "0"));
458            let offset = number(hours)? * 3600 + number(minutes)? * 60;
459            Some(if sign == "+" { seconds - offset } else { seconds + offset })
460        }
461    }
462}
463
464/// Days since 1970-01-01 (Howard Hinnant's algorithm).
465fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
466    let year = if month <= 2 { year - 1 } else { year };
467    let era = year.div_euclid(400);
468    let year_of_era = year - era * 400;
469    let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
470    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
471    era * 146_097 + day_of_era - 719_468
472}
473
474/// (year, month, day) of a day count since 1970-01-01.
475pub(crate) fn civil_from_days(days: i64) -> (i64, i64, i64) {
476    let days = days + 719_468;
477    let era = days.div_euclid(146_097);
478    let day_of_era = days - era * 146_097;
479    let year_of_era = (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
480    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
481    let mp = (5 * day_of_year + 2) / 153;
482    let day = day_of_year - (153 * mp + 2) / 5 + 1;
483    let month = if mp < 10 { mp + 3 } else { mp - 9 };
484    let year = year_of_era + era * 400 + i64::from(month <= 2);
485    (year, month, day)
486}
487
488/// Month and day names [`format_time_with`] writes for `%B %b %A %a %p`.
489pub(crate) struct DateNames<'a> {
490    pub(crate) months: [&'a str; 12],
491    pub(crate) abbr_months: [&'a str; 12],
492    /// Sunday first, as Rails' `date.day_names`.
493    pub(crate) days: [&'a str; 7],
494    pub(crate) abbr_days: [&'a str; 7],
495    pub(crate) am: &'a str,
496    pub(crate) pm: &'a str,
497}
498
499pub(crate) const ENGLISH_NAMES: DateNames<'static> = DateNames {
500    months: [
501        "January",
502        "February",
503        "March",
504        "April",
505        "May",
506        "June",
507        "July",
508        "August",
509        "September",
510        "October",
511        "November",
512        "December",
513    ],
514    abbr_months: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
515    days: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
516    abbr_days: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
517    am: "AM",
518    pm: "PM",
519};
520
521fn format_time(time: i64, format: &str) -> String {
522    format_time_with(time, format, &ENGLISH_NAMES)
523}
524
525/// [`strftime`] with the names of a language.
526pub(crate) fn format_time_with(time: i64, format: &str, names: &DateNames<'_>) -> String {
527    let days = time.div_euclid(86_400);
528    let of_day = time.rem_euclid(86_400);
529    let (year, month, day) = civil_from_days(days);
530    let (hour, minute, second) = (of_day / 3600, of_day % 3600 / 60, of_day % 60);
531    let hour12 = if hour % 12 == 0 { 12 } else { hour % 12 };
532    let month_index = (month - 1) as usize;
533    // 1970-01-01 was a Thursday.
534    let weekday = (days + 4).rem_euclid(7) as usize;
535    let mut out = String::new();
536    let mut chars = format.chars();
537    while let Some(c) = chars.next() {
538        if c != '%' {
539            out.push(c);
540            continue;
541        }
542        let (unpadded, directive) = match chars.next() {
543            Some('-') => (true, chars.next()),
544            other => (false, other),
545        };
546        let padded = |value: i64| if unpadded { value.to_string() } else { format!("{value:02}") };
547        let written = match directive {
548            Some('Y') => write!(out, "{year}"),
549            Some('y') => write!(out, "{:02}", year.rem_euclid(100)),
550            Some('m') => write!(out, "{}", padded(month)),
551            Some('d') => write!(out, "{}", padded(day)),
552            Some('e') => write!(out, "{day:>2}"),
553            Some('H') => write!(out, "{}", padded(hour)),
554            Some('I') => write!(out, "{}", padded(hour12)),
555            Some('M') => write!(out, "{minute:02}"),
556            Some('S') => write!(out, "{second:02}"),
557            Some('p') => write!(out, "{}", if hour < 12 { names.am } else { names.pm }),
558            Some('b') => write!(out, "{}", names.abbr_months[month_index]),
559            Some('B') => write!(out, "{}", names.months[month_index]),
560            Some('a') => write!(out, "{}", names.abbr_days[weekday]),
561            Some('A') => write!(out, "{}", names.days[weekday]),
562            Some('j') => write!(out, "{:03}", days - days_from_civil(year, 1, 1) + 1),
563            Some('F') => write!(out, "{year}-{month:02}-{day:02}"),
564            Some('T') => write!(out, "{hour:02}:{minute:02}:{second:02}"),
565            Some('z') => write!(out, "+0000"),
566            Some('%') => write!(out, "%"),
567            Some(other) => write!(out, "%{}{other}", if unpadded { "-" } else { "" }),
568            None => write!(out, "%"),
569        };
570        written.expect("writing to a String");
571    }
572    out
573}
574
575/// Rails' `distance_of_time_in_words`, without seconds precision, in English.
576fn distance_in_words(from: i64, to: i64) -> String {
577    let (key, count) = distance_key(from, to);
578    crate::i18n::english_distance(key, count)
579}
580
581/// The `datetime.distance_in_words.<key>` Rails picks for the time between
582/// `from` and `to`, with its `count`.
583pub(crate) fn distance_key(from: i64, to: i64) -> (&'static str, i64) {
584    const HOUR: i64 = 60;
585    const DAY: i64 = 1440;
586    const MONTH: i64 = 43_200;
587    const YEAR: i64 = 525_600;
588    let minutes = ((from - to).abs() + 30) / 60;
589    let rounded = |unit: i64| (minutes + unit / 2) / unit;
590    match minutes {
591        0 => ("less_than_x_minutes", 1),
592        1..45 => ("x_minutes", minutes),
593        45..90 => ("about_x_hours", 1),
594        90..DAY => ("about_x_hours", rounded(HOUR)),
595        DAY..2520 => ("x_days", 1),
596        2520..MONTH => ("x_days", rounded(DAY)),
597        MONTH..86_400 => ("about_x_months", rounded(MONTH)),
598        86_400..YEAR => ("x_months", rounded(MONTH)),
599        _ => {
600            let (years, remainder) = (minutes / YEAR, minutes % YEAR);
601            if remainder < YEAR / 4 {
602                ("about_x_years", years)
603            } else if remainder < YEAR * 3 / 4 {
604                ("over_x_years", years)
605            } else {
606                ("almost_x_years", years + 1)
607            }
608        }
609    }
610}
611
612/// Index (in chars) of the first case-insensitive match of `phrase` at or after `from`.
613fn find_ignore_case(text: &[char], phrase: &str, from: usize) -> Option<usize> {
614    let phrase: Vec<char> = phrase.chars().flat_map(char::to_lowercase).collect();
615    if phrase.is_empty() || phrase.len() > text.len() {
616        return None;
617    }
618    (from..=text.len() - phrase.len()).find(|&start| {
619        text[start..start + phrase.len()].iter().flat_map(|c| c.to_lowercase()).eq(phrase.iter().copied())
620    })
621}
622
623fn escape_into(out: &mut String, text: &[char]) {
624    for c in text {
625        match c {
626            '&' => out.push_str("&amp;"),
627            '<' => out.push_str("&lt;"),
628            '>' => out.push_str("&gt;"),
629            '"' => out.push_str("&quot;"),
630            '\'' => out.push_str("&#39;"),
631            _ => out.push(*c),
632        }
633    }
634}
635
636/// A progress bar with the element id `id`: a `<progress>` at `percent`
637/// (clamped to 0-100) and a `label` (escaped), as HTML.
638///
639/// Render it in a page, then broadcast a new one with the same `id` to
640/// move it: htmx's WebSocket extension replaces the element that has the
641/// id (`ocre::realtime::broadcast`; `ocre g external_job --realtime` does it).
642/// Style it with the `progress-bar` class.
643///
644/// # Examples
645///
646/// ```
647/// assert_eq!(
648///     ocre::helpers::progress_bar("upload_1", 40, "Encoding <4K>"),
649///     "<div id=\"upload_1\" class=\"progress-bar\"><progress max=\"100\" value=\"40\">40%</progress> <span>Encoding &lt;4K&gt;</span></div>"
650/// );
651/// assert!(ocre::helpers::progress_bar("x", 140, "").contains("value=\"100\""));
652/// ```
653pub fn progress_bar(id: &str, percent: i64, label: &str) -> String {
654    let percent = percent.clamp(0, 100);
655    let mut out = String::from("<div id=\"");
656    escape_into(&mut out, &id.chars().collect::<Vec<_>>());
657    write!(out, "\" class=\"progress-bar\"><progress max=\"100\" value=\"{percent}\">{percent}%</progress> <span>")
658        .expect("writing to a String");
659    escape_into(&mut out, &label.chars().collect::<Vec<_>>());
660    out.push_str("</span></div>");
661    out
662}
663
664/// IANA time zone names, as browsers list them (`Intl.supportedValuesOf("timeZone")`): what
665/// [`time_zone_options`] offers and `ocre time-zones` prints.
666///
667/// # Examples
668///
669/// ```
670/// assert!(ocre::helpers::TIME_ZONES.contains(&"Europe/Paris"));
671/// ```
672pub const TIME_ZONES: &[&str] = &[
673    "UTC",
674    "Africa/Abidjan",
675    "Africa/Accra",
676    "Africa/Addis_Ababa",
677    "Africa/Algiers",
678    "Africa/Asmera",
679    "Africa/Bamako",
680    "Africa/Bangui",
681    "Africa/Banjul",
682    "Africa/Bissau",
683    "Africa/Blantyre",
684    "Africa/Brazzaville",
685    "Africa/Bujumbura",
686    "Africa/Cairo",
687    "Africa/Casablanca",
688    "Africa/Ceuta",
689    "Africa/Conakry",
690    "Africa/Dakar",
691    "Africa/Dar_es_Salaam",
692    "Africa/Djibouti",
693    "Africa/Douala",
694    "Africa/El_Aaiun",
695    "Africa/Freetown",
696    "Africa/Gaborone",
697    "Africa/Harare",
698    "Africa/Johannesburg",
699    "Africa/Juba",
700    "Africa/Kampala",
701    "Africa/Khartoum",
702    "Africa/Kigali",
703    "Africa/Kinshasa",
704    "Africa/Lagos",
705    "Africa/Libreville",
706    "Africa/Lome",
707    "Africa/Luanda",
708    "Africa/Lubumbashi",
709    "Africa/Lusaka",
710    "Africa/Malabo",
711    "Africa/Maputo",
712    "Africa/Maseru",
713    "Africa/Mbabane",
714    "Africa/Mogadishu",
715    "Africa/Monrovia",
716    "Africa/Nairobi",
717    "Africa/Ndjamena",
718    "Africa/Niamey",
719    "Africa/Nouakchott",
720    "Africa/Ouagadougou",
721    "Africa/Porto-Novo",
722    "Africa/Sao_Tome",
723    "Africa/Tripoli",
724    "Africa/Tunis",
725    "Africa/Windhoek",
726    "America/Adak",
727    "America/Anchorage",
728    "America/Anguilla",
729    "America/Antigua",
730    "America/Araguaina",
731    "America/Argentina/La_Rioja",
732    "America/Argentina/Rio_Gallegos",
733    "America/Argentina/Salta",
734    "America/Argentina/San_Juan",
735    "America/Argentina/San_Luis",
736    "America/Argentina/Tucuman",
737    "America/Argentina/Ushuaia",
738    "America/Aruba",
739    "America/Asuncion",
740    "America/Bahia",
741    "America/Bahia_Banderas",
742    "America/Barbados",
743    "America/Belem",
744    "America/Belize",
745    "America/Blanc-Sablon",
746    "America/Boa_Vista",
747    "America/Bogota",
748    "America/Boise",
749    "America/Buenos_Aires",
750    "America/Cambridge_Bay",
751    "America/Campo_Grande",
752    "America/Cancun",
753    "America/Caracas",
754    "America/Catamarca",
755    "America/Cayenne",
756    "America/Cayman",
757    "America/Chicago",
758    "America/Chihuahua",
759    "America/Ciudad_Juarez",
760    "America/Coral_Harbour",
761    "America/Cordoba",
762    "America/Costa_Rica",
763    "America/Coyhaique",
764    "America/Creston",
765    "America/Cuiaba",
766    "America/Curacao",
767    "America/Danmarkshavn",
768    "America/Dawson",
769    "America/Dawson_Creek",
770    "America/Denver",
771    "America/Detroit",
772    "America/Dominica",
773    "America/Edmonton",
774    "America/Eirunepe",
775    "America/El_Salvador",
776    "America/Fort_Nelson",
777    "America/Fortaleza",
778    "America/Glace_Bay",
779    "America/Godthab",
780    "America/Goose_Bay",
781    "America/Grand_Turk",
782    "America/Grenada",
783    "America/Guadeloupe",
784    "America/Guatemala",
785    "America/Guayaquil",
786    "America/Guyana",
787    "America/Halifax",
788    "America/Havana",
789    "America/Hermosillo",
790    "America/Indiana/Knox",
791    "America/Indiana/Marengo",
792    "America/Indiana/Petersburg",
793    "America/Indiana/Tell_City",
794    "America/Indiana/Vevay",
795    "America/Indiana/Vincennes",
796    "America/Indiana/Winamac",
797    "America/Indianapolis",
798    "America/Inuvik",
799    "America/Iqaluit",
800    "America/Jamaica",
801    "America/Jujuy",
802    "America/Juneau",
803    "America/Kentucky/Monticello",
804    "America/Kralendijk",
805    "America/La_Paz",
806    "America/Lima",
807    "America/Los_Angeles",
808    "America/Louisville",
809    "America/Lower_Princes",
810    "America/Maceio",
811    "America/Managua",
812    "America/Manaus",
813    "America/Marigot",
814    "America/Martinique",
815    "America/Matamoros",
816    "America/Mazatlan",
817    "America/Mendoza",
818    "America/Menominee",
819    "America/Merida",
820    "America/Metlakatla",
821    "America/Mexico_City",
822    "America/Miquelon",
823    "America/Moncton",
824    "America/Monterrey",
825    "America/Montevideo",
826    "America/Montserrat",
827    "America/Nassau",
828    "America/New_York",
829    "America/Nome",
830    "America/Noronha",
831    "America/North_Dakota/Beulah",
832    "America/North_Dakota/Center",
833    "America/North_Dakota/New_Salem",
834    "America/Ojinaga",
835    "America/Panama",
836    "America/Paramaribo",
837    "America/Phoenix",
838    "America/Port-au-Prince",
839    "America/Port_of_Spain",
840    "America/Porto_Velho",
841    "America/Puerto_Rico",
842    "America/Punta_Arenas",
843    "America/Rankin_Inlet",
844    "America/Recife",
845    "America/Regina",
846    "America/Resolute",
847    "America/Rio_Branco",
848    "America/Santarem",
849    "America/Santiago",
850    "America/Santo_Domingo",
851    "America/Sao_Paulo",
852    "America/Scoresbysund",
853    "America/Sitka",
854    "America/St_Barthelemy",
855    "America/St_Johns",
856    "America/St_Kitts",
857    "America/St_Lucia",
858    "America/St_Thomas",
859    "America/St_Vincent",
860    "America/Swift_Current",
861    "America/Tegucigalpa",
862    "America/Thule",
863    "America/Tijuana",
864    "America/Toronto",
865    "America/Tortola",
866    "America/Vancouver",
867    "America/Whitehorse",
868    "America/Winnipeg",
869    "America/Yakutat",
870    "Antarctica/Casey",
871    "Antarctica/Davis",
872    "Antarctica/DumontDUrville",
873    "Antarctica/Macquarie",
874    "Antarctica/Mawson",
875    "Antarctica/McMurdo",
876    "Antarctica/Palmer",
877    "Antarctica/Rothera",
878    "Antarctica/Syowa",
879    "Antarctica/Troll",
880    "Antarctica/Vostok",
881    "Arctic/Longyearbyen",
882    "Asia/Aden",
883    "Asia/Almaty",
884    "Asia/Amman",
885    "Asia/Anadyr",
886    "Asia/Aqtau",
887    "Asia/Aqtobe",
888    "Asia/Ashgabat",
889    "Asia/Atyrau",
890    "Asia/Baghdad",
891    "Asia/Bahrain",
892    "Asia/Baku",
893    "Asia/Bangkok",
894    "Asia/Barnaul",
895    "Asia/Beirut",
896    "Asia/Bishkek",
897    "Asia/Brunei",
898    "Asia/Calcutta",
899    "Asia/Chita",
900    "Asia/Colombo",
901    "Asia/Damascus",
902    "Asia/Dhaka",
903    "Asia/Dili",
904    "Asia/Dubai",
905    "Asia/Dushanbe",
906    "Asia/Famagusta",
907    "Asia/Gaza",
908    "Asia/Hebron",
909    "Asia/Hong_Kong",
910    "Asia/Hovd",
911    "Asia/Irkutsk",
912    "Asia/Jakarta",
913    "Asia/Jayapura",
914    "Asia/Jerusalem",
915    "Asia/Kabul",
916    "Asia/Kamchatka",
917    "Asia/Karachi",
918    "Asia/Katmandu",
919    "Asia/Khandyga",
920    "Asia/Krasnoyarsk",
921    "Asia/Kuala_Lumpur",
922    "Asia/Kuching",
923    "Asia/Kuwait",
924    "Asia/Macau",
925    "Asia/Magadan",
926    "Asia/Makassar",
927    "Asia/Manila",
928    "Asia/Muscat",
929    "Asia/Nicosia",
930    "Asia/Novokuznetsk",
931    "Asia/Novosibirsk",
932    "Asia/Omsk",
933    "Asia/Oral",
934    "Asia/Phnom_Penh",
935    "Asia/Pontianak",
936    "Asia/Pyongyang",
937    "Asia/Qatar",
938    "Asia/Qostanay",
939    "Asia/Qyzylorda",
940    "Asia/Rangoon",
941    "Asia/Riyadh",
942    "Asia/Saigon",
943    "Asia/Sakhalin",
944    "Asia/Samarkand",
945    "Asia/Seoul",
946    "Asia/Shanghai",
947    "Asia/Singapore",
948    "Asia/Srednekolymsk",
949    "Asia/Taipei",
950    "Asia/Tashkent",
951    "Asia/Tbilisi",
952    "Asia/Tehran",
953    "Asia/Thimphu",
954    "Asia/Tokyo",
955    "Asia/Tomsk",
956    "Asia/Ulaanbaatar",
957    "Asia/Urumqi",
958    "Asia/Ust-Nera",
959    "Asia/Vientiane",
960    "Asia/Vladivostok",
961    "Asia/Yakutsk",
962    "Asia/Yekaterinburg",
963    "Asia/Yerevan",
964    "Atlantic/Azores",
965    "Atlantic/Bermuda",
966    "Atlantic/Canary",
967    "Atlantic/Cape_Verde",
968    "Atlantic/Faeroe",
969    "Atlantic/Madeira",
970    "Atlantic/Reykjavik",
971    "Atlantic/South_Georgia",
972    "Atlantic/St_Helena",
973    "Atlantic/Stanley",
974    "Australia/Adelaide",
975    "Australia/Brisbane",
976    "Australia/Broken_Hill",
977    "Australia/Darwin",
978    "Australia/Eucla",
979    "Australia/Hobart",
980    "Australia/Lindeman",
981    "Australia/Lord_Howe",
982    "Australia/Melbourne",
983    "Australia/Perth",
984    "Australia/Sydney",
985    "Europe/Amsterdam",
986    "Europe/Andorra",
987    "Europe/Astrakhan",
988    "Europe/Athens",
989    "Europe/Belgrade",
990    "Europe/Berlin",
991    "Europe/Bratislava",
992    "Europe/Brussels",
993    "Europe/Bucharest",
994    "Europe/Budapest",
995    "Europe/Busingen",
996    "Europe/Chisinau",
997    "Europe/Copenhagen",
998    "Europe/Dublin",
999    "Europe/Gibraltar",
1000    "Europe/Guernsey",
1001    "Europe/Helsinki",
1002    "Europe/Isle_of_Man",
1003    "Europe/Istanbul",
1004    "Europe/Jersey",
1005    "Europe/Kaliningrad",
1006    "Europe/Kiev",
1007    "Europe/Kirov",
1008    "Europe/Lisbon",
1009    "Europe/Ljubljana",
1010    "Europe/London",
1011    "Europe/Luxembourg",
1012    "Europe/Madrid",
1013    "Europe/Malta",
1014    "Europe/Mariehamn",
1015    "Europe/Minsk",
1016    "Europe/Monaco",
1017    "Europe/Moscow",
1018    "Europe/Oslo",
1019    "Europe/Paris",
1020    "Europe/Podgorica",
1021    "Europe/Prague",
1022    "Europe/Riga",
1023    "Europe/Rome",
1024    "Europe/Samara",
1025    "Europe/San_Marino",
1026    "Europe/Sarajevo",
1027    "Europe/Saratov",
1028    "Europe/Simferopol",
1029    "Europe/Skopje",
1030    "Europe/Sofia",
1031    "Europe/Stockholm",
1032    "Europe/Tallinn",
1033    "Europe/Tirane",
1034    "Europe/Ulyanovsk",
1035    "Europe/Vaduz",
1036    "Europe/Vatican",
1037    "Europe/Vienna",
1038    "Europe/Vilnius",
1039    "Europe/Volgograd",
1040    "Europe/Warsaw",
1041    "Europe/Zagreb",
1042    "Europe/Zurich",
1043    "Indian/Antananarivo",
1044    "Indian/Chagos",
1045    "Indian/Christmas",
1046    "Indian/Cocos",
1047    "Indian/Comoro",
1048    "Indian/Kerguelen",
1049    "Indian/Mahe",
1050    "Indian/Maldives",
1051    "Indian/Mauritius",
1052    "Indian/Mayotte",
1053    "Indian/Reunion",
1054    "Pacific/Apia",
1055    "Pacific/Auckland",
1056    "Pacific/Bougainville",
1057    "Pacific/Chatham",
1058    "Pacific/Easter",
1059    "Pacific/Efate",
1060    "Pacific/Enderbury",
1061    "Pacific/Fakaofo",
1062    "Pacific/Fiji",
1063    "Pacific/Funafuti",
1064    "Pacific/Galapagos",
1065    "Pacific/Gambier",
1066    "Pacific/Guadalcanal",
1067    "Pacific/Guam",
1068    "Pacific/Honolulu",
1069    "Pacific/Kiritimati",
1070    "Pacific/Kosrae",
1071    "Pacific/Kwajalein",
1072    "Pacific/Majuro",
1073    "Pacific/Marquesas",
1074    "Pacific/Midway",
1075    "Pacific/Nauru",
1076    "Pacific/Niue",
1077    "Pacific/Norfolk",
1078    "Pacific/Noumea",
1079    "Pacific/Pago_Pago",
1080    "Pacific/Palau",
1081    "Pacific/Pitcairn",
1082    "Pacific/Ponape",
1083    "Pacific/Port_Moresby",
1084    "Pacific/Rarotonga",
1085    "Pacific/Saipan",
1086    "Pacific/Tahiti",
1087    "Pacific/Tarawa",
1088    "Pacific/Tongatapu",
1089    "Pacific/Truk",
1090    "Pacific/Wake",
1091    "Pacific/Wallis",
1092];
1093
1094/// The `<option>`s of every [`TIME_ZONES`] name, `selected` marked (Rails'
1095/// `time_zone_select`): write them in a `<select>`, and store the IANA
1096/// name the form sends. Ocre's own time helpers stay UTC; convert for
1097/// display in the browser (`Intl.DateTimeFormat` with `timeZone`).
1098///
1099/// # Examples
1100///
1101/// ```
1102/// let options = ocre::helpers::time_zone_options("Europe/Paris");
1103/// assert!(options.contains("<option selected>Europe/Paris</option>"));
1104/// assert!(options.contains("<option>Asia/Tokyo</option>"));
1105/// ```
1106///
1107/// In an askama template: `<select name="time_zone">{{ ocre::helpers::time_zone_options(user.time_zone.as_str())|safe }}</select>`.
1108pub fn time_zone_options(selected: &str) -> String {
1109    let mut out = String::with_capacity(TIME_ZONES.len() * 32);
1110    for zone in TIME_ZONES {
1111        // Zone names are letters, digits, `/`, `_`, `-` and `+`: nothing to escape.
1112        let mark = if *zone == selected { " selected" } else { "" };
1113        write!(out, "<option{mark}>{zone}</option>").expect("writing to a String");
1114    }
1115    out
1116}
1117
1118#[cfg(test)]
1119#[path = "../tests/helpers.rs"]
1120mod tests;