Files
lightcraft/crates/meta/src/datetime.rs
T
Dylan M. Taylor bfb23db2ca Use chrono instead of hand-rolled UTC conversion (#2457)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the hand-rolled Howard Hinnant civil/days-from-civil copies and the
leap-year/days-in-month table with chrono as a workspace dependency:
- apps/lightcraft/src/logging.rs: timestamp (log line time)
- crates/catalog/src/dates.rs: civil + weekday
- crates/catalog/src/stacks.rs: iso_seconds (date -> seconds)
- crates/engine/src/cmd/face_recognize.rs: capture_secs (native only)
- crates/meta/src/datetime.rs: DateTime::unix_seconds and the valid() day check
Output formats and semantics are unchanged (pre-epoch clamp, day rollover that
normalize_iso relies on, leap-second tolerance, EXIF/XMP parsing). chrono uses
only std, so wasm stays safe. AGENTS.md/CLAUDE.md now say time goes through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed

chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked
inside the logger. timestamp now uses the fallible DateTime::from_timestamp
and reads such a clock as the epoch, keeping the old pre-epoch clamp.

timestamp and catalog's civil write years past 9999 with {:04} like the old
{y:04} did, because %Y would add a '+'. Tests cover the out-of-range and
5-digit-year cases.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* time: preserve old out-of-range date conversion (no epoch fallback)

`DateTime::from_timestamp(...).unwrap_or_default()` silently reset any instant
past chrono's year range to 1970-01-01, changing output the old hand-rolled
Howard-Hinnant code converted exactly (e.g. 10_000_000_000_000 s was
318857-05-20 17:46:40, not the epoch). Restore the old conversion domain with a
cycle-based helper that routes the calendar through chrono (the calendar
repeats every 400 years = 146_097 days, so month/day/time come from chrono and
only the year is carried in i128, keeping every i64 exact).

- apps/lightcraft/src/logging.rs: `timestamp` via `utc_parts`; keep the
  pre-1970 epoch clamp; u64 secs past i64::MAX saturate, not epoch.
- crates/catalog/src/dates.rs: `civil` cycle-based (the callers stay inside
  0000-9999, but a far clock no longer resets to the epoch).

Pinned old outputs in tests:
  logging u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch),
          10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z
  civil 10_000_000_000_000 -> 318857-05-20T17:46:40,
        -10_000_000_000_000 -> -314918-08-13T06:13:20, i64 extremes

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-21 19:36:36 -04:00

225 lines
9.8 KiB
Rust

//! 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<i16>,
}
fn num(s: &str) -> Option<u32> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
impl DateTime {
fn valid(self) -> Option<Self> {
// 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<DateTime> {
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<i16> {
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<DateTime> {
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);
}
}