//! Capture date/time with optional UTC offset (Exif `DateTimeOriginal` + `OffsetTimeOriginal`, ISO 8601 in XMP). use serde::{Deserialize, Serialize}; /// A civil date-time as recorded by the camera; `offset_minutes` is the UTC offset when known. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub struct DateTime { pub year: u16, pub month: u8, pub day: u8, pub hour: u8, pub minute: u8, pub second: u8, /// Sub-second part in milliseconds. pub millis: u16, pub offset_minutes: Option, } fn num(s: &str) -> Option { if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { return None; } s.parse().ok() } impl DateTime { fn valid(self) -> Option { // A timestamp must represent a real Gregorian calendar day, not just // numbers in the range 1..=31. Invalid dates would otherwise be // accepted from EXIF/XMP and silently normalized during time arithmetic. // chrono rejects impossible days (leap years and all); the time is checked // by hand so a leap second (`second == 60`) stays acceptable. let day_ok = self.year > 0 && chrono::NaiveDate::from_ymd_opt(i32::from(self.year), u32::from(self.month), u32::from(self.day)).is_some(); let ok = day_ok && self.hour < 24 && self.minute < 60 && self.second < 61 && self.offset_minutes.is_none_or(|o| o.abs() <= 18 * 60); ok.then_some(self) } /// Exif form `YYYY:MM:DD HH:MM:SS` (also tolerates `-` date separators and a missing time). pub fn parse_exif(s: &str) -> Option { let s = s.trim().trim_end_matches('\0'); let (d, t) = s.split_once([' ', 'T']).unwrap_or((s, "")); let dp: Vec<&str> = d.split([':', '-']).collect(); if dp.len() != 3 { return None; } let tp: Vec<&str> = if t.is_empty() { vec![] } else { t.split(':').collect() }; let g = |v: &Vec<&str>, i: usize| v.get(i).map(|x| num(x.trim())).unwrap_or(Some(0)); DateTime { year: num(dp[0])?.try_into().ok()?, month: num(dp[1])?.try_into().ok()?, day: num(dp[2])?.try_into().ok()?, hour: g(&tp, 0)?.try_into().ok()?, minute: g(&tp, 1)?.try_into().ok()?, second: g(&tp, 2)?.try_into().ok()?, millis: 0, offset_minutes: None, } .valid() } /// Exif `OffsetTime*` form `+HH:MM` / `-HH:MM` (or `Z`). pub fn parse_offset(s: &str) -> Option { let s = s.trim().trim_end_matches('\0'); if s == "Z" { return Some(0); } let (sign, rest) = match s.as_bytes().first()? { b'+' => (1, &s[1..]), b'-' => (-1, &s[1..]), _ => return None, }; let (h, m) = match rest.split_once(':') { Some((h, m)) => (num(h)?, num(m)?), None if rest.len() == 4 => (num(rest.get(..2)?)?, num(rest.get(2..)?)?), None => (num(rest)?, 0), }; if h > 18 || m > 59 { return None; } Some(sign * (h * 60 + m) as i16) } /// Set the sub-second part from an Exif `SubSecTime*` string (digits are a decimal fraction). pub fn with_subsec(mut self, s: &str) -> DateTime { let digits: String = s.trim().chars().take_while(|c| c.is_ascii_digit()).take(3).collect(); if !digits.is_empty() { let v: u16 = digits.parse().unwrap_or(0); self.millis = v * 10u16.pow(3 - digits.len() as u32); } self } /// ISO 8601 (XMP date): `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDThh:mm[:ss[.s+]][TZD]`. pub fn parse_iso(s: &str) -> Option { let s = s.trim(); let (d, t) = s.split_once('T').unwrap_or((s, "")); let dp: Vec<&str> = d.split('-').collect(); let year = num(dp.first()?)?.try_into().ok()?; let month = dp.get(1).map(|m| num(m)).unwrap_or(Some(1))?.try_into().ok()?; let day = dp.get(2).map(|m| num(m)).unwrap_or(Some(1))?.try_into().ok()?; if dp.len() > 3 { return None; } let mut dt = DateTime { year, month, day, ..Default::default() }; if !t.is_empty() { let (clock, off) = match t.find(['Z', '+', '-']) { Some(i) => (&t[..i], Some(&t[i..])), None => (t, None), }; let cp: Vec<&str> = clock.split(':').collect(); dt.hour = num(cp.first()?)?.try_into().ok()?; dt.minute = num(cp.get(1)?)?.try_into().ok()?; if let Some(sec) = cp.get(2) { let (whole, frac) = sec.split_once('.').unwrap_or((sec, "")); dt.second = num(whole)?.try_into().ok()?; dt = dt.with_subsec(frac); } if let Some(o) = off { dt.offset_minutes = Some(Self::parse_offset(o)?); } } dt.valid() } /// Exif form `YYYY:MM:DD HH:MM:SS`. pub fn to_exif(&self) -> String { format!("{:04}:{:02}:{:02} {:02}:{:02}:{:02}", self.year, self.month, self.day, self.hour, self.minute, self.second) } /// ISO 8601 with milliseconds when non-zero and the offset when known. pub fn to_iso(&self) -> String { let mut s = format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", self.year, self.month, self.day, self.hour, self.minute, self.second); if self.millis != 0 { s += &format!(".{:03}", self.millis); } if let Some(o) = self.offset_minutes { let sign = if o < 0 { '-' } else { '+' }; s += &format!("{sign}{:02}:{:02}", o.abs() / 60, o.abs() % 60); } s } /// Seconds since 1970-01-01T00:00:00 of the civil time (UTC when the offset is known, else "local" seconds). pub fn unix_seconds(&self) -> i64 { // Every `DateTime` reaching here is a real calendar day (`valid()` ran in the parsers); // an out-of-range one (built by hand) reads as the epoch rather than panicking. let days = chrono::NaiveDate::from_ymd_opt(i32::from(self.year), u32::from(self.month), u32::from(self.day)) .zip(chrono::NaiveDate::from_ymd_opt(1970, 1, 1)) .map(|(d, epoch)| (d - epoch).num_days()) .unwrap_or(0); days * 86400 + self.hour as i64 * 3600 + self.minute as i64 * 60 + self.second as i64 - self.offset_minutes.unwrap_or(0) as i64 * 60 } } #[cfg(test)] mod tests { use super::*; #[test] fn exif_forms() { let d = DateTime::parse_exif("2024:06:01 13:45:59").unwrap(); assert_eq!((d.year, d.month, d.day, d.hour, d.minute, d.second), (2024, 6, 1, 13, 45, 59)); assert_eq!(d.to_exif(), "2024:06:01 13:45:59"); assert!(DateTime::parse_exif("0000:00:00 00:00:00").is_none()); assert!(DateTime::parse_exif(" : : : : ").is_none()); assert!(DateTime::parse_exif("2024:13:01 00:00:00").is_none()); // Invalid calendar days must never enter persisted capture metadata. for value in ["2023:02:29", "2024:02:30", "2024:04:31", "1900:02:29", "2024:01:00"] { assert!(DateTime::parse_exif(value).is_none(), "{value}"); } assert!(DateTime::parse_exif("2024:02:29").is_some()); assert!(DateTime::parse_exif("2000:02:29").is_some()); for value in ["2023-02-29", "2024-02-30", "1900-02-29", "2024-06-31T10:00:00Z"] { assert!(DateTime::parse_iso(value).is_none(), "{value}"); } assert!(DateTime::parse_iso("2000-02-29T23:59:59Z").is_some()); assert_eq!(DateTime::parse_exif("2024-06-01").unwrap().hour, 0); assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("5").millis, 500); assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("0123").millis, 12); } #[test] fn offsets() { assert_eq!(DateTime::parse_offset("+02:00"), Some(120)); assert_eq!(DateTime::parse_offset("-05:30"), Some(-330)); assert_eq!(DateTime::parse_offset("Z"), Some(0)); assert_eq!(DateTime::parse_offset("+0930"), Some(570)); assert_eq!(DateTime::parse_offset(" : "), None); assert_eq!(DateTime::parse_offset("+99:00"), None); } #[test] fn unicode_offsets_are_rejected_without_panicking() { for offset in ["+1€", "-1€", "+€1", "-€1"] { assert_eq!(DateTime::parse_offset(offset), None); let date = format!("2024-06-01T13:45:59{offset}"); let metadata = crate::Metadata { capture_time: DateTime::parse_iso("2024-06-01T13:45:59Z"), ..Default::default() }; let xmp = crate::write_xmp(&metadata, None).replace("2024-06-01T13:45:59+00:00", &date); assert!(crate::parse_xmp(&xmp).unwrap().metadata.capture_time.is_none()); } } #[test] fn iso_roundtrip() { for s in ["2024-06-01T13:45:59", "2024-06-01T13:45:59.250+02:00", "1999-12-31T23:59:00-05:30", "2024-02-29T00:00:00Z"] { let d = DateTime::parse_iso(s).unwrap(); let again = DateTime::parse_iso(&d.to_iso()).unwrap(); assert_eq!(d, again, "{s}"); } assert_eq!(DateTime::parse_iso("2024-02-29T00:00:00Z").unwrap().to_iso(), "2024-02-29T00:00:00+00:00"); let d = DateTime::parse_iso("2019").unwrap(); assert_eq!((d.year, d.month, d.day), (2019, 1, 1)); assert_eq!(DateTime::parse_iso("2019-07-04T10:11").unwrap().minute, 11); assert!(DateTime::parse_iso("nonsense").is_none()); assert!(DateTime::parse_iso("2019-07-04Tab:cd").is_none()); } #[test] fn unix() { assert_eq!(DateTime::parse_iso("1970-01-01T00:00:00Z").unwrap().unix_seconds(), 0); assert_eq!(DateTime::parse_iso("2000-03-01T00:00:00Z").unwrap().unix_seconds(), 951868800); assert_eq!(DateTime::parse_iso("2000-03-01T02:00:00+02:00").unwrap().unix_seconds(), 951868800); } }