Skip to main content

imessage_database/util/
dates.rs

1/*!
2 Contains date parsing functions for iMessage dates.
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, 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 between nanosecond-precision timestamps and seconds
19///
20/// The iMessage database stores timestamps as nanoseconds, so this factor is used
21/// to convert between the database format and standard Unix timestamps.
22pub const TIMESTAMP_FACTOR: i64 = 1_000_000_000;
23
24/// Get the date offset for the iMessage Database
25///
26/// This offset is used to adjust the unix timestamps stored in the iMessage database
27/// with a non-standard epoch of `2001-01-01 00:00:00` in UTC.
28///
29/// # Example
30///
31/// ```
32/// use imessage_database::util::dates::get_offset;
33///
34/// let current_epoch = get_offset();
35/// ```
36#[must_use]
37pub fn get_offset() -> i64 {
38    Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0)
39        .unwrap()
40        .timestamp()
41}
42
43/// Create a `DateTime<Local>` from an arbitrary date and offset
44///
45/// This is used to create date data for anywhere dates are stored in the table, including
46/// `PLIST` payloads or [`typedstream`](crate::util::typedstream) data.
47///
48/// # Example
49///
50/// ```
51/// use imessage_database::util::dates::{get_local_time, get_offset};
52///
53/// let current_offset = get_offset();
54/// let local = get_local_time(674526582885055488, current_offset).unwrap();
55/// ```
56pub fn get_local_time(date_stamp: i64, offset: i64) -> Result<DateTime<Local>, MessageError> {
57    // Newer databases store timestamps as nanoseconds since 2001-01-01,
58    // while older ones store plain seconds since 2001-01-01.
59    let seconds_since_2001 = if date_stamp >= 1_000_000_000_000 {
60        date_stamp / TIMESTAMP_FACTOR
61    } else {
62        date_stamp
63    };
64
65    let utc_stamp = DateTime::from_timestamp(seconds_since_2001 + offset, 0)
66        .ok_or(MessageError::InvalidTimestamp(date_stamp))?
67        .naive_utc();
68    Ok(Local.from_utc_datetime(&utc_stamp))
69}
70
71/// Format a date from the iMessage table for reading
72///
73/// # Example:
74///
75/// ```
76/// use chrono::offset::Local;
77/// use imessage_database::util::dates::format;
78///
79/// let date = format(&Local::now());
80/// println!("{date}");
81/// ```
82#[must_use]
83pub fn format(date: &DateTime<Local>) -> String {
84    DateTime::format(date, "%b %d, %Y %l:%M:%S %p").to_string()
85}
86
87/// Generate a readable diff from two local timestamps.
88///
89/// # Example:
90///
91/// ```
92/// use chrono::prelude::*;
93/// use imessage_database::util::dates::readable_diff;
94///
95/// let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
96/// let end = Local.with_ymd_and_hms(2020, 5, 20, 9, 15, 13).unwrap();
97/// println!("{}", readable_diff(&start, &end).unwrap()) // "5 minutes, 2 seconds"
98/// ```
99#[must_use]
100pub fn readable_diff(start: &DateTime<Local>, end: &DateTime<Local>) -> Option<String> {
101    // Calculate diff
102    let seconds = end.timestamp() - start.timestamp();
103
104    // Early escape for invalid date diff
105    if seconds < 0 {
106        return None;
107    }
108
109    let (years, remaining_seconds) = years_and_remainder(start, end)
110        .unwrap_or((seconds / SECONDS_PER_YEAR, seconds % SECONDS_PER_YEAR));
111
112    // 51 is the length of a diff string that has all components with 2 digits each.
113    // This represented a performance increase of ~20% over a string that starts empty and grows with each component.
114    let mut out_s = String::with_capacity(51);
115
116    let days = remaining_seconds / SECONDS_PER_DAY;
117    let hours = (remaining_seconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR;
118    let minutes = (remaining_seconds % SECONDS_PER_DAY % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE;
119    let secs = remaining_seconds % SECONDS_PER_DAY % SECONDS_PER_HOUR % SECONDS_PER_MINUTE;
120
121    append_component(&mut out_s, years, "year", "years");
122    append_component(&mut out_s, days, "day", "days");
123    append_component(&mut out_s, hours, "hour", "hours");
124    append_component(&mut out_s, minutes, "minute", "minutes");
125    append_component(&mut out_s, secs, "second", "seconds");
126
127    Some(out_s)
128}
129
130/// Calculate the number of whole years between two dates, and the remaining seconds after accounting for those years.
131fn years_and_remainder(start: &DateTime<Local>, end: &DateTime<Local>) -> Option<(i64, i64)> {
132    let mut years = end.year() - start.year();
133
134    if years <= 0 {
135        return Some((0, end.timestamp() - start.timestamp()));
136    }
137
138    let mut remainder_start =
139        start.checked_add_months(Months::new(u32::try_from(years).ok()?.checked_mul(12)?))?;
140
141    if remainder_start > *end {
142        years -= 1;
143        remainder_start =
144            start.checked_add_months(Months::new(u32::try_from(years).ok()?.checked_mul(12)?))?;
145    }
146
147    Some((
148        i64::from(years),
149        end.timestamp() - remainder_start.timestamp(),
150    ))
151}
152
153/// Append a time component to the output string if the value is greater than 0, with correct singular/plural formatting.
154fn append_component(out_s: &mut String, value: i64, singular: &str, plural: &str) {
155    if value == 0 {
156        return;
157    }
158
159    if !out_s.is_empty() {
160        out_s.push_str(SEPARATOR);
161    }
162
163    let metric = if value == 1 { singular } else { plural };
164    let _ = write!(out_s, "{value} {metric}");
165}
166
167#[cfg(test)]
168mod tests {
169    use crate::util::dates::{TIMESTAMP_FACTOR, format, get_local_time, get_offset, readable_diff};
170    use chrono::prelude::*;
171
172    #[test]
173    fn can_format_date_single_digit() {
174        let date = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
175        assert_eq!(format(&date), "May 20, 2020  9:10:11 AM");
176    }
177
178    #[test]
179    fn can_format_date_double_digit() {
180        let date = Local.with_ymd_and_hms(2020, 5, 20, 10, 10, 11).unwrap();
181        assert_eq!(format(&date), "May 20, 2020 10:10:11 AM");
182    }
183
184    #[test]
185    fn cant_format_diff_backwards() {
186        let end = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
187        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 30).unwrap();
188        assert_eq!(readable_diff(&start, &end), None);
189    }
190
191    #[test]
192    fn can_format_diff_all_singular() {
193        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
194        let end = Local.with_ymd_and_hms(2020, 5, 21, 10, 11, 12).unwrap();
195        assert_eq!(
196            readable_diff(&start, &end),
197            Some("1 day, 1 hour, 1 minute, 1 second".to_owned())
198        );
199    }
200
201    #[test]
202    fn can_format_diff_mixed_singular() {
203        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
204        let end = Local.with_ymd_and_hms(2020, 5, 22, 10, 20, 12).unwrap();
205        assert_eq!(
206            readable_diff(&start, &end),
207            Some("2 days, 1 hour, 10 minutes, 1 second".to_owned())
208        );
209    }
210
211    #[test]
212    fn can_format_diff_seconds() {
213        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
214        let end = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 30).unwrap();
215        assert_eq!(readable_diff(&start, &end), Some("19 seconds".to_owned()));
216    }
217
218    #[test]
219    fn can_format_diff_minutes() {
220        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
221        let end = Local.with_ymd_and_hms(2020, 5, 20, 9, 15, 11).unwrap();
222        assert_eq!(readable_diff(&start, &end), Some("5 minutes".to_owned()));
223    }
224
225    #[test]
226    fn can_format_diff_hours() {
227        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
228        let end = Local.with_ymd_and_hms(2020, 5, 20, 12, 10, 11).unwrap();
229        assert_eq!(readable_diff(&start, &end), Some("3 hours".to_owned()));
230    }
231
232    #[test]
233    fn can_format_diff_days() {
234        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
235        let end = Local.with_ymd_and_hms(2020, 5, 30, 9, 10, 11).unwrap();
236        assert_eq!(readable_diff(&start, &end), Some("10 days".to_owned()));
237    }
238
239    #[test]
240    fn can_format_diff_minutes_seconds() {
241        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
242        let end = Local.with_ymd_and_hms(2020, 5, 20, 9, 15, 30).unwrap();
243        assert_eq!(
244            readable_diff(&start, &end),
245            Some("5 minutes, 19 seconds".to_owned())
246        );
247    }
248
249    #[test]
250    fn can_format_diff_days_minutes() {
251        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
252        let end = Local.with_ymd_and_hms(2020, 5, 22, 9, 30, 11).unwrap();
253        assert_eq!(
254            readable_diff(&start, &end),
255            Some("2 days, 20 minutes".to_owned())
256        );
257    }
258
259    #[test]
260    fn can_format_diff_month() {
261        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
262        let end = Local.with_ymd_and_hms(2020, 7, 20, 9, 10, 11).unwrap();
263        assert_eq!(readable_diff(&start, &end), Some("61 days".to_owned()));
264    }
265
266    #[test]
267    fn can_format_diff_single_year() {
268        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
269        let end = Local.with_ymd_and_hms(2021, 5, 20, 9, 10, 11).unwrap();
270        assert_eq!(readable_diff(&start, &end), Some("1 year".to_owned()));
271    }
272
273    #[test]
274    fn can_format_diff_years_days() {
275        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
276        let end = Local.with_ymd_and_hms(2022, 7, 20, 9, 10, 11).unwrap();
277        assert_eq!(
278            readable_diff(&start, &end),
279            Some("2 years, 61 days".to_owned())
280        );
281    }
282
283    #[test]
284    fn can_format_diff_leap_day_anniversary_as_year() {
285        let start = Local.with_ymd_and_hms(2020, 2, 29, 9, 10, 11).unwrap();
286        let end = Local.with_ymd_and_hms(2021, 2, 28, 9, 10, 11).unwrap();
287        assert_eq!(readable_diff(&start, &end), Some("1 year".to_owned()));
288    }
289
290    #[test]
291    fn can_format_diff_all() {
292        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
293        let end = Local.with_ymd_and_hms(2020, 5, 22, 14, 32, 45).unwrap();
294        assert_eq!(
295            readable_diff(&start, &end),
296            Some("2 days, 5 hours, 22 minutes, 34 seconds".to_owned())
297        );
298    }
299
300    #[test]
301    fn can_format_no_diff() {
302        let start = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
303        let end = Local.with_ymd_and_hms(2020, 5, 20, 9, 10, 11).unwrap();
304        assert_eq!(readable_diff(&start, &end), Some(String::new()));
305    }
306
307    #[test]
308    fn can_get_local_time_from_seconds_timestamp() {
309        let offset = get_offset();
310        let expected_utc = Utc
311            .with_ymd_and_hms(2020, 5, 20, 9, 10, 11)
312            .single()
313            .unwrap();
314
315        // Older databases store seconds since 2001-01-01 00:00:00
316        let stamp_secs = expected_utc.timestamp() - offset;
317
318        let local = get_local_time(stamp_secs, offset).unwrap();
319        let expected_local = expected_utc.with_timezone(&Local);
320
321        assert_eq!(local, expected_local);
322    }
323
324    #[test]
325    fn can_get_local_time_from_nanoseconds_timestamp() {
326        let offset = get_offset();
327        let expected_utc = Utc
328            .with_ymd_and_hms(2020, 5, 20, 9, 10, 11)
329            .single()
330            .unwrap();
331
332        // Newer databases store nanoseconds since 2001-01-01 00:00:00
333        let stamp_ns = (expected_utc.timestamp() - offset) * TIMESTAMP_FACTOR;
334
335        let local = get_local_time(stamp_ns, offset).unwrap();
336        let expected_local = expected_utc.with_timezone(&Local);
337
338        assert_eq!(local, expected_local);
339    }
340
341    #[test]
342    fn can_get_local_time_from_hardcoded_seconds_timestamp() {
343        let offset = get_offset();
344
345        // Legacy-style seconds timestamp
346        let stamp_secs: i64 = 347_670_404;
347
348        let expected_utc = Utc.timestamp_opt(stamp_secs + offset, 0).single().unwrap();
349
350        let local = get_local_time(stamp_secs, offset).unwrap();
351        let expected_local = expected_utc.with_timezone(&Local);
352
353        assert_eq!(local, expected_local);
354    }
355
356    #[test]
357    fn can_get_local_time_from_hardcoded_nanoseconds_timestamp() {
358        let offset = get_offset();
359
360        // Nanosecond-style timestamp
361        let stamp_ns: i64 = 549_948_395_013_559_360;
362
363        let seconds_since_2001 = stamp_ns / TIMESTAMP_FACTOR;
364
365        let expected_utc = Utc
366            .timestamp_opt(seconds_since_2001 + offset, 0)
367            .single()
368            .unwrap();
369
370        let local = get_local_time(stamp_ns, offset).unwrap();
371        let expected_local = expected_utc.with_timezone(&Local);
372
373        assert_eq!(local, expected_local);
374    }
375}