mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
* 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.
225 lines
9.8 KiB
Rust
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);
|
|
}
|
|
}
|