Skip to main content

deckcraft_text/
datetime.rs

1//! Date and time fields (`a:fld type="datetime…"`): the text each field type shows for a moment,
2//! and the local clock.
3//!
4//! The field types and their formats follow ECMA-376 Part 1, §21.1.2.2.4 (`fld`): `datetime1`
5//! to `datetime13`, written as in PowerPoint's English (United States) Date and Time list.
6//! `datetime` and `datetimeFigureOut` show the language's short date. Numeric dates follow the
7//! run's language; formats with month or day names, or AM/PM, are only computed for English, so
8//! other languages keep the text the file was saved with.
9
10/// A local date and time: what the clock read, or a fixed moment (tests, deterministic output).
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub struct DateTime {
13    pub year: i32,
14    /// 1–12.
15    pub month: u32,
16    /// 1–31.
17    pub day: u32,
18    /// 0–23.
19    pub hour: u32,
20    pub minute: u32,
21    pub second: u32,
22}
23
24impl DateTime {
25    pub fn new(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> Self {
26        DateTime {
27            year,
28            month,
29            day,
30            hour,
31            minute,
32            second,
33        }
34    }
35
36    /// The local date and time now (the user's time zone); `None` where there is no clock (wasm).
37    pub fn now_local() -> Option<DateTime> {
38        #[cfg(not(target_arch = "wasm32"))]
39        {
40            use chrono::{Datelike, Timelike};
41            let n = chrono::Local::now();
42            Some(DateTime::new(
43                n.year(),
44                n.month(),
45                n.day(),
46                n.hour(),
47                n.minute(),
48                n.second(),
49            ))
50        }
51        #[cfg(target_arch = "wasm32")]
52        {
53            None
54        }
55    }
56
57    /// A real calendar date and time of day.
58    fn is_valid(&self) -> bool {
59        let leap = self.year % 4 == 0 && (self.year % 100 != 0 || self.year % 400 == 0);
60        let days = match self.month {
61            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
62            4 | 6 | 9 | 11 => 30,
63            2 if leap => 29,
64            2 => 28,
65            _ => return false,
66        };
67        (1..=9999).contains(&self.year)
68            && (1..=days).contains(&self.day)
69            && self.hour < 24
70            && self.minute < 60
71            && self.second < 61
72    }
73
74    /// Day of the week, 0 = Sunday (days since 1970-01-01, Howard Hinnant's algorithm).
75    fn weekday(&self) -> usize {
76        let (y, m, d) = (
77            i64::from(self.year),
78            i64::from(self.month),
79            i64::from(self.day),
80        );
81        let y = if m <= 2 { y - 1 } else { y };
82        let era = y.div_euclid(400);
83        let yoe = y - era * 400;
84        let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
85        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
86        let days = era * 146_097 + doe - 719_468;
87        (days + 4).rem_euclid(7) as usize
88    }
89}
90
91const MONTHS: [&str; 12] = [
92    "January",
93    "February",
94    "March",
95    "April",
96    "May",
97    "June",
98    "July",
99    "August",
100    "September",
101    "October",
102    "November",
103    "December",
104];
105const DAYS: [&str; 7] = [
106    "Sunday",
107    "Monday",
108    "Tuesday",
109    "Wednesday",
110    "Thursday",
111    "Friday",
112    "Saturday",
113];
114
115/// Is field `kind` a date/time field?
116pub fn is_date_field(kind: &str) -> bool {
117    kind.starts_with("datetime")
118}
119
120/// The text date/time field `kind` shows at `now` in a run of language `lang` (BCP 47, e.g.
121/// `en-US`). `None` when `kind` isn't a date/time type this knows, `now` isn't a real date, or
122/// the format needs names in a language other than English: the field keeps its saved text.
123pub fn field_text(kind: &str, lang: Option<&str>, now: DateTime) -> Option<String> {
124    if !now.is_valid() {
125        return None;
126    }
127    let lang = lang
128        .map(str::trim)
129        .filter(|l| !l.is_empty())
130        .unwrap_or("en-US")
131        .to_ascii_lowercase();
132    let english = lang == "en" || lang.starts_with("en-");
133    let month = MONTHS.get(now.month.checked_sub(1)? as usize)?;
134    let mon = month.get(..3)?;
135    let day = DAYS.get(now.weekday())?;
136    let (d, y) = (now.day, now.year);
137    let yy = y.rem_euclid(100);
138    let h12 = match now.hour % 12 {
139        0 => 12,
140        h => h,
141    };
142    let ampm = if now.hour < 12 { "AM" } else { "PM" };
143    let (mi, s) = (now.minute, now.second);
144    let short = short_date(&lang, now);
145    let named = |text: String| english.then_some(text);
146    match kind {
147        "datetime" | "datetimeFigureOut" | "datetime1" => Some(short),
148        "datetime2" => named(format!("{day}, {month} {d}, {y}")),
149        "datetime3" => named(format!("{d} {month} {y}")),
150        "datetime4" => named(format!("{month} {d}, {y}")),
151        "datetime5" => named(format!("{d}-{mon}-{yy:02}")),
152        "datetime6" => named(format!("{month} {yy:02}")),
153        "datetime7" => named(format!("{mon}-{yy:02}")),
154        "datetime8" => named(format!("{short} {h12}:{mi:02} {ampm}")),
155        "datetime9" => named(format!("{short} {h12}:{mi:02}:{s:02} {ampm}")),
156        "datetime10" => Some(format!("{}:{mi:02}", now.hour)),
157        "datetime11" => Some(format!("{}:{mi:02}:{s:02}", now.hour)),
158        "datetime12" => named(format!("{h12}:{mi:02} {ampm}")),
159        "datetime13" => named(format!("{h12}:{mi:02}:{s:02} {ampm}")),
160        _ => None,
161    }
162}
163
164/// The short (numeric) date of language `lang` (lower case), US order unless the language is
165/// known to write its dates another way.
166fn short_date(lang: &str, t: DateTime) -> String {
167    let (y, m, d) = (t.year, t.month, t.day);
168    let primary = lang.split(['-', '_']).next().unwrap_or("");
169    match lang {
170        "en-gb" | "en-au" | "en-nz" | "en-ie" | "en-in" => return format!("{d:02}/{m:02}/{y}"),
171        "en-ca" | "fr-ca" | "en-za" => return format!("{y}-{m:02}-{d:02}"),
172        _ => {}
173    }
174    match primary {
175        "fr" | "es" | "it" | "pt" | "el" | "ca" | "vi" => format!("{d:02}/{m:02}/{y}"),
176        "de" | "ru" | "pl" | "cs" | "sk" | "tr" | "fi" | "nb" | "nn" | "no" | "da" | "uk"
177        | "ro" => format!("{d:02}.{m:02}.{y}"),
178        "nl" => format!("{d}-{m}-{y}"),
179        "ja" | "zh" => format!("{y}/{m}/{d}"),
180        "ko" | "sv" | "lt" => format!("{y}-{m:02}-{d:02}"),
181        _ => format!("{m}/{d}/{y}"),
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    const FRI: DateTime = DateTime {
190        year: 2026,
191        month: 10,
192        day: 9,
193        hour: 20,
194        minute: 5,
195        second: 7,
196    };
197
198    #[test]
199    fn english_formats_of_every_type() {
200        let f = |k: &str| field_text(k, Some("en-US"), FRI);
201        assert_eq!(f("datetime1").as_deref(), Some("10/9/2026"));
202        assert_eq!(f("datetime2").as_deref(), Some("Friday, October 9, 2026"));
203        assert_eq!(f("datetime3").as_deref(), Some("9 October 2026"));
204        assert_eq!(f("datetime4").as_deref(), Some("October 9, 2026"));
205        assert_eq!(f("datetime5").as_deref(), Some("9-Oct-26"));
206        assert_eq!(f("datetime6").as_deref(), Some("October 26"));
207        assert_eq!(f("datetime7").as_deref(), Some("Oct-26"));
208        assert_eq!(f("datetime8").as_deref(), Some("10/9/2026 8:05 PM"));
209        assert_eq!(f("datetime9").as_deref(), Some("10/9/2026 8:05:07 PM"));
210        assert_eq!(f("datetime10").as_deref(), Some("20:05"));
211        assert_eq!(f("datetime11").as_deref(), Some("20:05:07"));
212        assert_eq!(f("datetime12").as_deref(), Some("8:05 PM"));
213        assert_eq!(f("datetime13").as_deref(), Some("8:05:07 PM"));
214        assert_eq!(f("datetimeFigureOut").as_deref(), Some("10/9/2026"));
215        assert_eq!(f("datetime").as_deref(), Some("10/9/2026"));
216        // No language: US English. Midnight and noon are 12 on the clock.
217        assert_eq!(
218            field_text("datetime12", None, DateTime::new(2024, 2, 29, 0, 0, 0)).as_deref(),
219            Some("12:00 AM")
220        );
221        assert_eq!(
222            field_text("datetime12", None, DateTime::new(2024, 2, 29, 12, 30, 0)).as_deref(),
223            Some("12:30 PM")
224        );
225        assert_eq!(
226            field_text("datetime2", None, DateTime::new(2000, 1, 1, 0, 0, 0)).as_deref(),
227            Some("Saturday, January 1, 2000")
228        );
229    }
230
231    #[test]
232    fn language_orders_numeric_dates_and_keeps_names_english_only() {
233        assert_eq!(
234            field_text("datetime1", Some("en-GB"), FRI).as_deref(),
235            Some("09/10/2026")
236        );
237        assert_eq!(
238            field_text("datetimeFigureOut", Some("de-DE"), FRI).as_deref(),
239            Some("09.10.2026")
240        );
241        assert_eq!(
242            field_text("datetime1", Some("ja-JP"), FRI).as_deref(),
243            Some("2026/10/9")
244        );
245        assert_eq!(
246            field_text("datetime1", Some("sv-SE"), FRI).as_deref(),
247            Some("2026-10-09")
248        );
249        assert_eq!(
250            field_text("datetime10", Some("fr-FR"), FRI).as_deref(),
251            Some("20:05")
252        );
253        // Month names in another language: not computed (the field keeps its saved text).
254        assert_eq!(field_text("datetime2", Some("de-DE"), FRI), None);
255        assert_eq!(field_text("datetime12", Some("fr-FR"), FRI), None);
256        assert_eq!(
257            field_text("datetime4", Some("en-GB"), FRI).as_deref(),
258            Some("October 9, 2026")
259        );
260    }
261
262    #[test]
263    fn unknown_types_and_impossible_dates_give_nothing() {
264        assert_eq!(field_text("slidenum", None, FRI), None);
265        assert_eq!(field_text("datetime14", None, FRI), None);
266        for bad in [
267            DateTime::new(2026, 0, 9, 0, 0, 0),
268            DateTime::new(2026, 13, 9, 0, 0, 0),
269            DateTime::new(2026, 2, 29, 0, 0, 0),
270            DateTime::new(2026, 4, 31, 0, 0, 0),
271            DateTime::new(2026, 1, 1, 24, 0, 0),
272            DateTime::new(i32::MIN, 1, 1, 0, 0, 0),
273            DateTime::new(i32::MAX, u32::MAX, u32::MAX, u32::MAX, u32::MAX, u32::MAX),
274        ] {
275            assert_eq!(field_text("datetime2", None, bad), None, "{bad:?}");
276        }
277        assert!(is_date_field("datetimeFigureOut") && !is_date_field("slidenum"));
278    }
279
280    #[test]
281    fn the_clock_reads_a_real_date() {
282        if let Some(now) = DateTime::now_local() {
283            assert!(field_text("datetime1", None, now).is_some(), "{now:?}");
284        }
285    }
286}