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, Local, 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/// Return the Unix timestamp offset for the Messages epoch.
24///
25/// Messages stores dates relative to `2001-01-01 00:00:00` UTC.
26///
27/// # Example
28///
29/// ```
30/// use imessage_database::util::dates::get_offset;
31///
32/// let current_epoch = get_offset();
33/// ```
34#[must_use]
35pub fn get_offset() -> i64 {
36    Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0)
37        .unwrap()
38        .timestamp()
39}
40
41/// Convert a raw Messages timestamp into local time.
42///
43/// `offset` is usually [`get_offset`].
44///
45/// # Example
46///
47/// ```
48/// use imessage_database::util::dates::{get_local_time, get_offset};
49///
50/// let current_offset = get_offset();
51/// let local = get_local_time(674526582885055488, current_offset).unwrap();
52/// ```
53pub fn get_local_time(date_stamp: i64, offset: i64) -> Result<DateTime<Local>, MessageError> {
54    // Newer databases store timestamps as nanoseconds since 2001-01-01,
55    // while older ones store plain seconds since 2001-01-01.
56    let seconds_since_2001 = if date_stamp >= 1_000_000_000_000 {
57        date_stamp / TIMESTAMP_FACTOR
58    } else {
59        date_stamp
60    };
61
62    let utc_stamp = DateTime::from_timestamp(seconds_since_2001 + offset, 0)
63        .ok_or(MessageError::InvalidTimestamp(date_stamp))?
64        .naive_utc();
65    Ok(Local.from_utc_datetime(&utc_stamp))
66}
67
68/// Format a local timestamp for export output.
69///
70/// # Example:
71///
72/// ```
73/// use chrono::offset::Local;
74/// use imessage_database::util::dates::format;
75///
76/// let date = format(&Local::now());
77/// println!("{date}");
78/// ```
79#[must_use]
80pub fn format(date: &DateTime<Local>) -> String {
81    // Equivalent to `date.format("%b %d, %Y %l:%M:%S %p").to_string()` but
82    // written directly into one pre-sized buffer
83    const MONTHS: [&str; 12] = [
84        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
85    ];
86    let (hour12, meridiem) = match date.hour() {
87        0 => (12, "AM"),
88        12 => (12, "PM"),
89        h if h < 12 => (h, "AM"),
90        h => (h - 12, "PM"),
91    };
92
93    let mut out = String::with_capacity(24);
94    let _ = write!(
95        out,
96        "{} {:02}, {} {hour12:2}:{:02}:{:02} {meridiem}",
97        MONTHS[(date.month() - 1) as usize],
98        date.day(),
99        date.year(),
100        date.minute(),
101        date.second(),
102    );
103    out
104}
105
106/// Format the elapsed time between two local timestamps.
107///
108/// # Example:
109///
110/// ```
111/// use chrono::prelude::*;
112/// use imessage_database::util::dates::readable_diff;
113///
114/// let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
115/// let end = Local.with_ymd_and_hms(2020, 5, 20, 9, 15, 13).unwrap();
116/// println!("{}", readable_diff(&start, &end).unwrap()) // "5 minutes, 2 seconds"
117/// ```
118#[must_use]
119pub fn readable_diff(start: &DateTime<Local>, end: &DateTime<Local>) -> Option<String> {
120    let seconds = end.timestamp() - start.timestamp();
121
122    if seconds < 0 {
123        return None;
124    }
125
126    let (years, remaining_seconds) = years_and_remainder(start, end)
127        .unwrap_or((seconds / SECONDS_PER_YEAR, seconds % SECONDS_PER_YEAR));
128
129    // Enough room for each component when values are two digits.
130    let mut out_s = String::with_capacity(51);
131
132    let days = remaining_seconds / SECONDS_PER_DAY;
133    let hours = (remaining_seconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR;
134    let minutes = (remaining_seconds % SECONDS_PER_DAY % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE;
135    let secs = remaining_seconds % SECONDS_PER_DAY % SECONDS_PER_HOUR % SECONDS_PER_MINUTE;
136
137    append_component(&mut out_s, years, "year", "years");
138    append_component(&mut out_s, days, "day", "days");
139    append_component(&mut out_s, hours, "hour", "hours");
140    append_component(&mut out_s, minutes, "minute", "minutes");
141    append_component(&mut out_s, secs, "second", "seconds");
142
143    Some(out_s)
144}
145
146/// Calculate whole years and the remaining seconds between two dates.
147fn years_and_remainder(start: &DateTime<Local>, end: &DateTime<Local>) -> Option<(i64, i64)> {
148    let mut years = end.year() - start.year();
149
150    if years <= 0 {
151        return Some((0, end.timestamp() - start.timestamp()));
152    }
153
154    let mut remainder_start =
155        start.checked_add_months(Months::new(u32::try_from(years).ok()?.checked_mul(12)?))?;
156
157    if remainder_start > *end {
158        years -= 1;
159        remainder_start =
160            start.checked_add_months(Months::new(u32::try_from(years).ok()?.checked_mul(12)?))?;
161    }
162
163    Some((
164        i64::from(years),
165        end.timestamp() - remainder_start.timestamp(),
166    ))
167}
168
169/// Append a time component to the output string if the value is greater than 0, with correct singular/plural formatting.
170fn append_component(out_s: &mut String, value: i64, singular: &str, plural: &str) {
171    if value == 0 {
172        return;
173    }
174
175    if !out_s.is_empty() {
176        out_s.push_str(SEPARATOR);
177    }
178
179    let metric = if value == 1 { singular } else { plural };
180    let _ = write!(out_s, "{value} {metric}");
181}
182
183#[cfg(test)]
184mod tests {
185    use crate::util::dates::{TIMESTAMP_FACTOR, format, get_local_time, get_offset, readable_diff};
186    use chrono::prelude::*;
187
188    #[test]
189    fn can_format_date_single_digit() {
190        let date = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
191        assert_eq!(format(&date), "May 20, 2020  9:10:11 AM");
192    }
193
194    #[test]
195    fn can_format_date_double_digit() {
196        let date = Local.with_ymd_and_hms(2020, 5, 20, 10, 10, 11).unwrap();
197        assert_eq!(format(&date), "May 20, 2020 10:10:11 AM");
198    }
199
200    #[test]
201    fn can_format_date_midnight() {
202        // Hour 0 renders as 12 AM (and single-digit minute/second are zero-padded).
203        let date = Local.with_ymd_and_hms(2020, 5, 20, 0, 5, 9).unwrap();
204        assert_eq!(format(&date), "May 20, 2020 12:05:09 AM");
205    }
206
207    #[test]
208    fn can_format_date_noon() {
209        // Hour 12 renders as 12 PM, not 0 PM.
210        let date = Local.with_ymd_and_hms(2020, 5, 20, 12, 0, 0).unwrap();
211        assert_eq!(format(&date), "May 20, 2020 12:00:00 PM");
212    }
213
214    #[test]
215    fn can_format_date_afternoon() {
216        // Hour 15 maps to a space-padded 12-hour `3` (note the double space) PM.
217        let date = Local.with_ymd_and_hms(2020, 5, 20, 15, 4, 5).unwrap();
218        assert_eq!(format(&date), "May 20, 2020  3:04:05 PM");
219    }
220
221    #[test]
222    fn cant_format_diff_backwards() {
223        let end = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
224        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 30).unwrap();
225        assert_eq!(readable_diff(&start, &end), None);
226    }
227
228    #[test]
229    fn can_format_diff_all_singular() {
230        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
231        let end = Local.with_ymd_and_hms(2020, 5, 21, 10, 11, 12).unwrap();
232        assert_eq!(
233            readable_diff(&start, &end),
234            Some("1 day, 1 hour, 1 minute, 1 second".to_owned())
235        );
236    }
237
238    #[test]
239    fn can_format_diff_mixed_singular() {
240        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
241        let end = Local.with_ymd_and_hms(2020, 5, 22, 10, 20, 12).unwrap();
242        assert_eq!(
243            readable_diff(&start, &end),
244            Some("2 days, 1 hour, 10 minutes, 1 second".to_owned())
245        );
246    }
247
248    #[test]
249    fn can_format_diff_seconds() {
250        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
251        let end = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 30).unwrap();
252        assert_eq!(readable_diff(&start, &end), Some("19 seconds".to_owned()));
253    }
254
255    #[test]
256    fn can_format_diff_minutes() {
257        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
258        let end = Local.with_ymd_and_hms(2020, 5, 20, 9, 15, 11).unwrap();
259        assert_eq!(readable_diff(&start, &end), Some("5 minutes".to_owned()));
260    }
261
262    #[test]
263    fn can_format_diff_hours() {
264        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
265        let end = Local.with_ymd_and_hms(2020, 5, 20, 12, 10, 11).unwrap();
266        assert_eq!(readable_diff(&start, &end), Some("3 hours".to_owned()));
267    }
268
269    #[test]
270    fn can_format_diff_days() {
271        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
272        let end = Local.with_ymd_and_hms(2020, 5, 30, 9, 10, 11).unwrap();
273        assert_eq!(readable_diff(&start, &end), Some("10 days".to_owned()));
274    }
275
276    #[test]
277    fn can_format_diff_minutes_seconds() {
278        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
279        let end = Local.with_ymd_and_hms(2020, 5, 20, 9, 15, 30).unwrap();
280        assert_eq!(
281            readable_diff(&start, &end),
282            Some("5 minutes, 19 seconds".to_owned())
283        );
284    }
285
286    #[test]
287    fn can_format_diff_days_minutes() {
288        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
289        let end = Local.with_ymd_and_hms(2020, 5, 22, 9, 30, 11).unwrap();
290        assert_eq!(
291            readable_diff(&start, &end),
292            Some("2 days, 20 minutes".to_owned())
293        );
294    }
295
296    #[test]
297    fn can_format_diff_month() {
298        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
299        let end = Local.with_ymd_and_hms(2020, 7, 20, 9, 10, 11).unwrap();
300        assert_eq!(readable_diff(&start, &end), Some("61 days".to_owned()));
301    }
302
303    #[test]
304    fn can_format_diff_single_year() {
305        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
306        let end = Local.with_ymd_and_hms(2021, 5, 20, 9, 10, 11).unwrap();
307        assert_eq!(readable_diff(&start, &end), Some("1 year".to_owned()));
308    }
309
310    #[test]
311    fn can_format_diff_years_days() {
312        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
313        let end = Local.with_ymd_and_hms(2022, 7, 20, 9, 10, 11).unwrap();
314        assert_eq!(
315            readable_diff(&start, &end),
316            Some("2 years, 61 days".to_owned())
317        );
318    }
319
320    #[test]
321    fn can_format_diff_leap_day_anniversary_as_year() {
322        let start = Local.with_ymd_and_hms(2020, 2, 29, 9, 10, 11).unwrap();
323        let end = Local.with_ymd_and_hms(2021, 2, 28, 9, 10, 11).unwrap();
324        assert_eq!(readable_diff(&start, &end), Some("1 year".to_owned()));
325    }
326
327    #[test]
328    fn can_format_diff_all() {
329        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
330        let end = Local.with_ymd_and_hms(2020, 5, 22, 14, 32, 45).unwrap();
331        assert_eq!(
332            readable_diff(&start, &end),
333            Some("2 days, 5 hours, 22 minutes, 34 seconds".to_owned())
334        );
335    }
336
337    #[test]
338    fn can_format_no_diff() {
339        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
340        let end = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
341        assert_eq!(readable_diff(&start, &end), Some(String::new()));
342    }
343
344    #[test]
345    fn can_get_local_time_from_seconds_timestamp() {
346        let offset = get_offset();
347        let expected_utc = Utc
348            .with_ymd_and_hms(2020, 5, 20, 9, 10, 11)
349            .single()
350            .unwrap();
351
352        // Older databases store seconds since 2001-01-01 00:00:00
353        let stamp_secs = expected_utc.timestamp() - offset;
354
355        let local = get_local_time(stamp_secs, offset).unwrap();
356        let expected_local = expected_utc.with_timezone(&Local);
357
358        assert_eq!(local, expected_local);
359    }
360
361    #[test]
362    fn can_get_local_time_from_nanoseconds_timestamp() {
363        let offset = get_offset();
364        let expected_utc = Utc
365            .with_ymd_and_hms(2020, 5, 20, 9, 10, 11)
366            .single()
367            .unwrap();
368
369        // Newer databases store nanoseconds since 2001-01-01 00:00:00
370        let stamp_ns = (expected_utc.timestamp() - offset) * TIMESTAMP_FACTOR;
371
372        let local = get_local_time(stamp_ns, offset).unwrap();
373        let expected_local = expected_utc.with_timezone(&Local);
374
375        assert_eq!(local, expected_local);
376    }
377
378    #[test]
379    fn can_get_local_time_from_hardcoded_seconds_timestamp() {
380        let offset = get_offset();
381
382        // Legacy-style seconds timestamp
383        let stamp_secs: i64 = 347_670_404;
384
385        let expected_utc = Utc.timestamp_opt(stamp_secs + offset, 0).single().unwrap();
386
387        let local = get_local_time(stamp_secs, offset).unwrap();
388        let expected_local = expected_utc.with_timezone(&Local);
389
390        assert_eq!(local, expected_local);
391    }
392
393    #[test]
394    fn can_get_local_time_from_hardcoded_nanoseconds_timestamp() {
395        let offset = get_offset();
396
397        // Nanosecond-style timestamp
398        let stamp_ns: i64 = 549_948_395_013_559_360;
399
400        let seconds_since_2001 = stamp_ns / TIMESTAMP_FACTOR;
401
402        let expected_utc = Utc
403            .timestamp_opt(seconds_since_2001 + offset, 0)
404            .single()
405            .unwrap();
406
407        let local = get_local_time(stamp_ns, offset).unwrap();
408        let expected_local = expected_utc.with_timezone(&Local);
409
410        assert_eq!(local, expected_local);
411    }
412}