Skip to main content

imessage_database/util/
dates.rs

1/*!
2 Date conversion and formatting helpers for Messages timestamps.
3
4 Most dates are stored as nanosecond-precision unix timestamps with an epoch of `1/1/2001 00:00:00` in UTC.
5*/
6use std::fmt::Write;
7
8use chrono::{DateTime, Datelike, Months, TimeZone, Timelike, Utc};
9
10use crate::error::message::MessageError;
11
12const SEPARATOR: &str = ", ";
13const SECONDS_PER_MINUTE: i64 = 60;
14const SECONDS_PER_HOUR: i64 = 60 * SECONDS_PER_MINUTE;
15const SECONDS_PER_DAY: i64 = 24 * SECONDS_PER_HOUR;
16const SECONDS_PER_YEAR: i64 = 365 * SECONDS_PER_DAY;
17
18/// Factor used to convert nanosecond-precision timestamps to seconds.
19///
20/// Most Messages timestamps use nanoseconds, while older rows may store seconds.
21pub const TIMESTAMP_FACTOR: i64 = 1_000_000_000;
22
23/// Unix seconds at the Messages epoch, `2001-01-01 00:00:00 UTC`.
24pub const MESSAGES_EPOCH_UNIX_SECS: i64 = 978_307_200;
25
26/// Decode a raw Messages timestamp to an instant.
27///
28/// Interpret newer timestamps as nanoseconds since the Messages epoch and
29/// older timestamps as seconds. Discard sub-second precision. Return
30/// [`MessageError::InvalidTimestamp`] for a decoded value outside chrono's
31/// supported range.
32///
33/// # Example
34///
35/// ```
36/// use imessage_database::util::dates::to_utc;
37///
38/// let instant = to_utc(674526582885055488).unwrap();
39/// assert_eq!(instant.timestamp(), 1652833782);
40/// ```
41pub fn to_utc(stamp: i64) -> Result<DateTime<Utc>, MessageError> {
42    // Interpret newer timestamps as nanoseconds since 2001-01-01 and older
43    // timestamps as seconds since the same epoch.
44    let seconds_since_2001 = if stamp >= 1_000_000_000_000 {
45        stamp / TIMESTAMP_FACTOR
46    } else {
47        stamp
48    };
49
50    DateTime::from_timestamp(seconds_since_2001 + MESSAGES_EPOCH_UNIX_SECS, 0)
51        .ok_or(MessageError::InvalidTimestamp(stamp))
52}
53
54/// Format a timestamp for export output in the wall-clock time of its zone.
55///
56/// # Example:
57///
58/// ```
59/// use chrono::prelude::*;
60/// use imessage_database::util::dates::format;
61///
62/// let date = Utc.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
63/// assert_eq!(format(&date), "May 20, 2020  9:10:11 AM");
64/// ```
65#[must_use]
66pub fn format<Tz: TimeZone>(date: &DateTime<Tz>) -> String {
67    // Equivalent to `date.format("%b %d, %Y %l:%M:%S %p").to_string()` but
68    // written directly into one pre-sized buffer
69    const MONTHS: [&str; 12] = [
70        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
71    ];
72    let (hour12, meridiem) = match date.hour() {
73        0 => (12, "AM"),
74        12 => (12, "PM"),
75        h if h < 12 => (h, "AM"),
76        h => (h - 12, "PM"),
77    };
78
79    let mut out = String::with_capacity(24);
80    let _ = write!(
81        out,
82        "{} {:02}, {} {hour12:2}:{:02}:{:02} {meridiem}",
83        MONTHS[(date.month() - 1) as usize],
84        date.day(),
85        date.year(),
86        date.minute(),
87        date.second(),
88    );
89    out
90}
91
92/// Format the elapsed time between two timestamps.
93///
94/// Pass both arguments in the same zone to count whole years on that zone's
95/// calendar. Return `None` for a negative elapsed time.
96///
97/// # Example:
98///
99/// ```
100/// use chrono::prelude::*;
101/// use imessage_database::util::dates::readable_diff;
102///
103/// let start = Utc.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
104/// let end = Utc.with_ymd_and_hms(2020, 5, 20, 9, 15, 13).unwrap();
105/// assert_eq!(readable_diff(&start, &end).unwrap(), "5 minutes, 2 seconds");
106/// ```
107#[must_use]
108pub fn readable_diff<Tz: TimeZone>(start: &DateTime<Tz>, end: &DateTime<Tz>) -> Option<String> {
109    let seconds = end.timestamp() - start.timestamp();
110
111    if seconds < 0 {
112        return None;
113    }
114
115    let (years, remaining_seconds) = years_and_remainder(start, end)
116        .unwrap_or((seconds / SECONDS_PER_YEAR, seconds % SECONDS_PER_YEAR));
117
118    // Enough room for each component when values are two digits.
119    let mut out_s = String::with_capacity(51);
120
121    let days = remaining_seconds / SECONDS_PER_DAY;
122    let hours = (remaining_seconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR;
123    let minutes = (remaining_seconds % SECONDS_PER_DAY % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE;
124    let secs = remaining_seconds % SECONDS_PER_DAY % SECONDS_PER_HOUR % SECONDS_PER_MINUTE;
125
126    append_component(&mut out_s, years, "year", "years");
127    append_component(&mut out_s, days, "day", "days");
128    append_component(&mut out_s, hours, "hour", "hours");
129    append_component(&mut out_s, minutes, "minute", "minutes");
130    append_component(&mut out_s, secs, "second", "seconds");
131
132    Some(out_s)
133}
134
135/// Calculate whole years and the remaining seconds between two dates.
136fn years_and_remainder<Tz: TimeZone>(
137    start: &DateTime<Tz>,
138    end: &DateTime<Tz>,
139) -> Option<(i64, i64)> {
140    let mut years = end.year() - start.year();
141
142    if years <= 0 {
143        return Some((0, end.timestamp() - start.timestamp()));
144    }
145
146    // Clone before adding months to pass an owned `DateTime<Tz>` without
147    // requiring `Tz::Offset: Copy`.
148    let mut remainder_start = start
149        .clone()
150        .checked_add_months(Months::new(u32::try_from(years).ok()?.checked_mul(12)?))?;
151
152    if remainder_start > *end {
153        years -= 1;
154        remainder_start = start
155            .clone()
156            .checked_add_months(Months::new(u32::try_from(years).ok()?.checked_mul(12)?))?;
157    }
158
159    Some((
160        i64::from(years),
161        end.timestamp() - remainder_start.timestamp(),
162    ))
163}
164
165/// Append a time component to the output string if the value is greater than 0, with correct singular/plural formatting.
166fn append_component(out_s: &mut String, value: i64, singular: &str, plural: &str) {
167    if value == 0 {
168        return;
169    }
170
171    if !out_s.is_empty() {
172        out_s.push_str(SEPARATOR);
173    }
174
175    let metric = if value == 1 { singular } else { plural };
176    let _ = write!(out_s, "{value} {metric}");
177}
178
179#[cfg(test)]
180mod tests {
181    use crate::util::dates::{
182        MESSAGES_EPOCH_UNIX_SECS, TIMESTAMP_FACTOR, format, readable_diff, to_utc,
183    };
184    use chrono::prelude::*;
185
186    #[test]
187    fn can_format_date_single_digit() {
188        let date = Utc.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
189        assert_eq!(format(&date), "May 20, 2020  9:10:11 AM");
190    }
191
192    #[test]
193    fn can_format_date_double_digit() {
194        let date = Utc.with_ymd_and_hms(2020, 5, 20, 10, 10, 11).unwrap();
195        assert_eq!(format(&date), "May 20, 2020 10:10:11 AM");
196    }
197
198    #[test]
199    fn can_format_date_midnight() {
200        // Hour 0 renders as 12 AM (and single-digit minute/second are zero-padded).
201        let date = Utc.with_ymd_and_hms(2020, 5, 20, 0, 5, 9).unwrap();
202        assert_eq!(format(&date), "May 20, 2020 12:05:09 AM");
203    }
204
205    #[test]
206    fn can_format_date_noon() {
207        // Hour 12 renders as 12 PM, not 0 PM.
208        let date = Utc.with_ymd_and_hms(2020, 5, 20, 12, 0, 0).unwrap();
209        assert_eq!(format(&date), "May 20, 2020 12:00:00 PM");
210    }
211
212    #[test]
213    fn can_format_date_afternoon() {
214        // Hour 15 maps to a space-padded 12-hour `3` (note the double space) PM.
215        let date = Utc.with_ymd_and_hms(2020, 5, 20, 15, 4, 5).unwrap();
216        assert_eq!(format(&date), "May 20, 2020  3:04:05 PM");
217    }
218
219    #[test]
220    fn can_format_date_in_fixed_offset_wall_clock() {
221        // Expect the wall-clock time in the supplied zone:
222        // 09:10:11 UTC is 14:40:11 at +05:30.
223        let instant = Utc.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
224        let zoned = instant.with_timezone(&FixedOffset::east_opt(5 * 3600 + 30 * 60).unwrap());
225        assert_eq!(format(&zoned), "May 20, 2020  2:40:11 PM");
226    }
227
228    #[test]
229    fn cant_format_diff_backwards() {
230        let end = Utc.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
231        let start = Utc.with_ymd_and_hms(2020, 5, 20, 9, 10, 30).unwrap();
232        assert_eq!(readable_diff(&start, &end), None);
233    }
234
235    #[test]
236    fn can_format_diff_all_singular() {
237        let start = Utc.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
238        let end = Utc.with_ymd_and_hms(2020, 5, 21, 10, 11, 12).unwrap();
239        assert_eq!(
240            readable_diff(&start, &end),
241            Some("1 day, 1 hour, 1 minute, 1 second".to_owned())
242        );
243    }
244
245    #[test]
246    fn can_format_diff_mixed_singular() {
247        let start = Utc.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
248        let end = Utc.with_ymd_and_hms(2020, 5, 22, 10, 20, 12).unwrap();
249        assert_eq!(
250            readable_diff(&start, &end),
251            Some("2 days, 1 hour, 10 minutes, 1 second".to_owned())
252        );
253    }
254
255    #[test]
256    fn can_format_diff_components() {
257        let start = Utc.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
258        let cases = [
259            ("seconds", (2020, 5, 20, 9, 10, 30), "19 seconds"),
260            ("minutes", (2020, 5, 20, 9, 15, 11), "5 minutes"),
261            ("hours", (2020, 5, 20, 12, 10, 11), "3 hours"),
262            ("days", (2020, 5, 30, 9, 10, 11), "10 days"),
263            (
264                "minutes and seconds",
265                (2020, 5, 20, 9, 15, 30),
266                "5 minutes, 19 seconds",
267            ),
268            (
269                "days and minutes",
270                (2020, 5, 22, 9, 30, 11),
271                "2 days, 20 minutes",
272            ),
273            (
274                "every component",
275                (2020, 5, 22, 14, 32, 45),
276                "2 days, 5 hours, 22 minutes, 34 seconds",
277            ),
278        ];
279        for (label, (year, month, day, hour, minute, second), expected) in cases {
280            let end = Utc
281                .with_ymd_and_hms(year, month, day, hour, minute, second)
282                .unwrap();
283            assert_eq!(
284                readable_diff(&start, &end),
285                Some(expected.to_owned()),
286                "{label}"
287            );
288        }
289    }
290
291    #[test]
292    fn can_format_diff_month() {
293        let start = Utc.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
294        let end = Utc.with_ymd_and_hms(2020, 7, 20, 9, 10, 11).unwrap();
295        assert_eq!(readable_diff(&start, &end), Some("61 days".to_owned()));
296    }
297
298    #[test]
299    fn can_format_diff_single_year() {
300        let start = Utc.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
301        let end = Utc.with_ymd_and_hms(2021, 5, 20, 9, 10, 11).unwrap();
302        assert_eq!(readable_diff(&start, &end), Some("1 year".to_owned()));
303    }
304
305    #[test]
306    fn can_format_diff_years_days() {
307        let start = Utc.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
308        let end = Utc.with_ymd_and_hms(2022, 7, 20, 9, 10, 11).unwrap();
309        assert_eq!(
310            readable_diff(&start, &end),
311            Some("2 years, 61 days".to_owned())
312        );
313    }
314
315    #[test]
316    fn can_format_diff_leap_day_anniversary_as_year() {
317        let start = Utc.with_ymd_and_hms(2020, 2, 29, 9, 10, 11).unwrap();
318        let end = Utc.with_ymd_and_hms(2021, 2, 28, 9, 10, 11).unwrap();
319        assert_eq!(readable_diff(&start, &end), Some("1 year".to_owned()));
320    }
321
322    #[test]
323    fn can_format_no_diff() {
324        let start = Utc.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
325        let end = Utc.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
326        assert_eq!(readable_diff(&start, &end), Some(String::new()));
327    }
328
329    #[test]
330    fn messages_epoch_is_2001_01_01_utc() {
331        assert_eq!(
332            MESSAGES_EPOCH_UNIX_SECS,
333            Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0)
334                .unwrap()
335                .timestamp()
336        );
337    }
338
339    #[test]
340    fn can_decode_seconds_timestamp() {
341        let expected = Utc.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
342
343        // Use seconds since 2001-01-01 00:00:00, as in older databases.
344        let stamp_secs = expected.timestamp() - MESSAGES_EPOCH_UNIX_SECS;
345
346        assert_eq!(to_utc(stamp_secs).unwrap(), expected);
347    }
348
349    #[test]
350    fn can_decode_nanoseconds_timestamp() {
351        let expected = Utc.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
352
353        // Newer databases store nanoseconds since 2001-01-01 00:00:00
354        let stamp_ns = (expected.timestamp() - MESSAGES_EPOCH_UNIX_SECS) * TIMESTAMP_FACTOR;
355
356        assert_eq!(to_utc(stamp_ns).unwrap(), expected);
357    }
358}