Files
deckcraft/crates/text/src/datetime.rs
T
Dylan M. Taylor 0f4fffcf6c Use chrono instead of hand-rolled UTC conversion (#1116)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the hand-rolled civil_from_days / civil copies and the hand-written
weekday / leap-year / days-in-month helpers with chrono. chrono is already a
workspace dependency (used by crates/text), so this reuses that entry and just
adds `chrono = { workspace = true }` to the engine and desktop app rather than
a second version.

- apps/deckcraft logging `timestamp` now formats through chrono (private
  civil_from_days deleted).
- engine cmd::design `civil` (days since 1970 -> date, used for comment
  timestamps in review.rs) uses chrono::NaiveDate.
- text datetime `DateTime::is_valid` / `weekday` use chrono::NaiveDate instead
  of a hand-rolled leap-year table and days-since-1970 weekday.

Output is byte-identical and pinned by the existing logging, design and
datetime-field tests; each converted helper keeps a proleptic-Gregorian
fallback for dates outside chrono's NaiveDate range.

Follow-up to storytold/cadcraft#1785

* text: make chrono available on wasm32

is_valid/weekday in crates/text/src/datetime.rs use chrono unconditionally,
but chrono was only declared under the non-wasm target, so the crate (and
engine/pdf/render/ui-egui depending on it) failed the wasm32-unknown-unknown
check in `cargo xtask ci`.

- crates/text: declare chrono unconditionally (wasm-safe `std` baseline),
  and keep `clock` in a separate cfg(not(target_arch="wasm32")) entry for
  now_local()'s local clock, which is already wasm-gated to return None.
- workspace: drop `clock` from the shared chrono entry so it is no longer
  forced onto wasm builds (engine uses chrono unconditionally too); `clock`
  is now requested per-crate only where the local clock is read.

* Never panic on a clock outside chrono's range; harden the civil fallback

logging.rs timestamp(): chrono's From<SystemTime> unwraps, so a clock past
year 262143 panicked inside the logger. Use the fallible DateTime::from_timestamp
and read an out-of-range or pre-epoch clock as the epoch, as the old saturating
code did. Years past 9999 are written with {:04} like before, because %Y adds a
'+'. Tests cover both cases.

cmd/design.rs civil_fallback(): make the hand-rolled fallback's year arithmetic
saturating so civil() cannot overflow or panic at i64::MAX/MIN (the engine rule
forbids crashes and unchecked arithmetic). Behaviour is unchanged for every real
input; a test exercises both extremes. The output year is formatted with {y:04}
by review.rs, so no %Y prefix issue.

crates/text/src/datetime.rs: checked after review; is_valid already limited the
year to 1..=9999 before this PR, weekday() is only reached for valid dates, and
all its arithmetic is checked/euclidean, so no change is needed.

* 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.

* Make civil() exact for every i64 instead of saturating

The out-of-range fallback saturated z + 719_468, so civil(i64::MAX)
returned a date 719,468 days early. civil() now has chrono convert the day
within its 400-year Gregorian cycle and adds whole cycles back as years
(i128 for the extremes), so the hand-rolled fallback is gone. Exact results
for i64::MAX/MIN and both sides of chrono's range are tested against Hinnant's formula.

* time: preserve out-of-range years in the log timestamp instead of the epoch

`DateTime::from_timestamp(..).unwrap_or_default()` changed the existing output: a
clock set far ahead (an input the old hand-rolled civil-from-days converted)
collapsed to `1970-01-01`. Restore the old conversion domain with a cycle-based
helper: reduce by whole 400-year Gregorian cycles (146_097 days), convert the
in-range remainder with chrono, and carry the year back as i128. Only the year
leaves chrono's range; month/day/time stay exact. Pre-1970 still clamps to the
epoch as before. chrono stays the only calendar logic (no new features).

apps/deckcraft logging.rs `timestamp` (SystemTime, u64 secs), pinned via a
platform-safe `UNIX_EPOCH.checked_add`:
  u64::MAX/2 s → "292277026596-12-04T15:30:07.000Z" (was "1970-01-01..."),
  10e12 s → "318857-05-20T17:46:40.000Z", 253_402_300_800 s → "10000-01-01...".

* 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-23 11:20:42 -04:00

195 lines
8.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Date and time fields (`a:fld type="datetime…"`): the text each field type shows for a moment,
//! and the local clock.
//!
//! The field types and their formats follow ECMA-376 Part 1, §21.1.2.2.4 (`fld`): `datetime1`
//! to `datetime13`, written as in PowerPoint's English (United States) Date and Time list.
//! `datetime` and `datetimeFigureOut` show the language's short date. Numeric dates follow the
//! run's language; formats with month or day names, or AM/PM, are only computed for English, so
//! other languages keep the text the file was saved with.
/// A local date and time: what the clock read, or a fixed moment (tests, deterministic output).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DateTime {
pub year: i32,
/// 1–12.
pub month: u32,
/// 1–31.
pub day: u32,
/// 0–23.
pub hour: u32,
pub minute: u32,
pub second: u32,
}
impl DateTime {
pub fn new(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> Self {
DateTime { year, month, day, hour, minute, second }
}
/// The local date and time now (the user's time zone); `None` where there is no clock (wasm).
pub fn now_local() -> Option<DateTime> {
#[cfg(not(target_arch = "wasm32"))]
{
use chrono::{Datelike, Timelike};
let n = chrono::Local::now();
Some(DateTime::new(n.year(), n.month(), n.day(), n.hour(), n.minute(), n.second()))
}
#[cfg(target_arch = "wasm32")]
{
None
}
}
/// A real calendar date and time of day.
fn is_valid(&self) -> bool {
let date_ok = (1..=9999).contains(&self.year) && chrono::NaiveDate::from_ymd_opt(self.year, self.month, self.day).is_some();
date_ok && self.hour < 24 && self.minute < 60 && self.second < 61
}
/// Day of the week, 0 = Sunday.
fn weekday(&self) -> usize {
match chrono::NaiveDate::from_ymd_opt(self.year, self.month, self.day) {
// `chrono::Weekday::num_days_from_sunday` is 0 = Sunday.
Some(d) => chrono::Datelike::weekday(&d).num_days_from_sunday() as usize,
None => 0,
}
}
}
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
/// Is field `kind` a date/time field?
pub fn is_date_field(kind: &str) -> bool {
kind.starts_with("datetime")
}
/// The text date/time field `kind` shows at `now` in a run of language `lang` (BCP 47, e.g.
/// `en-US`). `None` when `kind` isn't a date/time type this knows, `now` isn't a real date, or
/// the format needs names in a language other than English: the field keeps its saved text.
pub fn field_text(kind: &str, lang: Option<&str>, now: DateTime) -> Option<String> {
if !now.is_valid() {
return None;
}
let lang = lang.map(str::trim).filter(|l| !l.is_empty()).unwrap_or("en-US").to_ascii_lowercase();
let english = lang == "en" || lang.starts_with("en-");
let month = MONTHS.get(now.month.checked_sub(1)? as usize)?;
let mon = month.get(..3)?;
let day = DAYS.get(now.weekday())?;
let (d, y) = (now.day, now.year);
let yy = y.rem_euclid(100);
let h12 = match now.hour % 12 {
0 => 12,
h => h,
};
let ampm = if now.hour < 12 { "AM" } else { "PM" };
let (mi, s) = (now.minute, now.second);
let short = short_date(&lang, now);
let named = |text: String| english.then_some(text);
match kind {
"datetime" | "datetimeFigureOut" | "datetime1" => Some(short),
"datetime2" => named(format!("{day}, {month} {d}, {y}")),
"datetime3" => named(format!("{d} {month} {y}")),
"datetime4" => named(format!("{month} {d}, {y}")),
"datetime5" => named(format!("{d}-{mon}-{yy:02}")),
"datetime6" => named(format!("{month} {yy:02}")),
"datetime7" => named(format!("{mon}-{yy:02}")),
"datetime8" => named(format!("{short} {h12}:{mi:02} {ampm}")),
"datetime9" => named(format!("{short} {h12}:{mi:02}:{s:02} {ampm}")),
"datetime10" => Some(format!("{}:{mi:02}", now.hour)),
"datetime11" => Some(format!("{}:{mi:02}:{s:02}", now.hour)),
"datetime12" => named(format!("{h12}:{mi:02} {ampm}")),
"datetime13" => named(format!("{h12}:{mi:02}:{s:02} {ampm}")),
_ => None,
}
}
/// The short (numeric) date of language `lang` (lower case), US order unless the language is
/// known to write its dates another way.
fn short_date(lang: &str, t: DateTime) -> String {
let (y, m, d) = (t.year, t.month, t.day);
let primary = lang.split(['-', '_']).next().unwrap_or("");
match lang {
"en-gb" | "en-au" | "en-nz" | "en-ie" | "en-in" => return format!("{d:02}/{m:02}/{y}"),
"en-ca" | "fr-ca" | "en-za" => return format!("{y}-{m:02}-{d:02}"),
_ => {}
}
match primary {
"fr" | "es" | "it" | "pt" | "el" | "ca" | "vi" => format!("{d:02}/{m:02}/{y}"),
"de" | "ru" | "pl" | "cs" | "sk" | "tr" | "fi" | "nb" | "nn" | "no" | "da" | "uk" | "ro" => format!("{d:02}.{m:02}.{y}"),
"nl" => format!("{d}-{m}-{y}"),
"ja" | "zh" => format!("{y}/{m}/{d}"),
"ko" | "sv" | "lt" => format!("{y}-{m:02}-{d:02}"),
_ => format!("{m}/{d}/{y}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
const FRI: DateTime = DateTime { year: 2026, month: 10, day: 9, hour: 20, minute: 5, second: 7 };
#[test]
fn english_formats_of_every_type() {
let f = |k: &str| field_text(k, Some("en-US"), FRI);
assert_eq!(f("datetime1").as_deref(), Some("10/9/2026"));
assert_eq!(f("datetime2").as_deref(), Some("Friday, October 9, 2026"));
assert_eq!(f("datetime3").as_deref(), Some("9 October 2026"));
assert_eq!(f("datetime4").as_deref(), Some("October 9, 2026"));
assert_eq!(f("datetime5").as_deref(), Some("9-Oct-26"));
assert_eq!(f("datetime6").as_deref(), Some("October 26"));
assert_eq!(f("datetime7").as_deref(), Some("Oct-26"));
assert_eq!(f("datetime8").as_deref(), Some("10/9/2026 8:05 PM"));
assert_eq!(f("datetime9").as_deref(), Some("10/9/2026 8:05:07 PM"));
assert_eq!(f("datetime10").as_deref(), Some("20:05"));
assert_eq!(f("datetime11").as_deref(), Some("20:05:07"));
assert_eq!(f("datetime12").as_deref(), Some("8:05 PM"));
assert_eq!(f("datetime13").as_deref(), Some("8:05:07 PM"));
assert_eq!(f("datetimeFigureOut").as_deref(), Some("10/9/2026"));
assert_eq!(f("datetime").as_deref(), Some("10/9/2026"));
// No language: US English. Midnight and noon are 12 on the clock.
assert_eq!(field_text("datetime12", None, DateTime::new(2024, 2, 29, 0, 0, 0)).as_deref(), Some("12:00 AM"));
assert_eq!(field_text("datetime12", None, DateTime::new(2024, 2, 29, 12, 30, 0)).as_deref(), Some("12:30 PM"));
assert_eq!(field_text("datetime2", None, DateTime::new(2000, 1, 1, 0, 0, 0)).as_deref(), Some("Saturday, January 1, 2000"));
}
#[test]
fn language_orders_numeric_dates_and_keeps_names_english_only() {
assert_eq!(field_text("datetime1", Some("en-GB"), FRI).as_deref(), Some("09/10/2026"));
assert_eq!(field_text("datetimeFigureOut", Some("de-DE"), FRI).as_deref(), Some("09.10.2026"));
assert_eq!(field_text("datetime1", Some("ja-JP"), FRI).as_deref(), Some("2026/10/9"));
assert_eq!(field_text("datetime1", Some("sv-SE"), FRI).as_deref(), Some("2026-10-09"));
assert_eq!(field_text("datetime10", Some("fr-FR"), FRI).as_deref(), Some("20:05"));
// Month names in another language: not computed (the field keeps its saved text).
assert_eq!(field_text("datetime2", Some("de-DE"), FRI), None);
assert_eq!(field_text("datetime12", Some("fr-FR"), FRI), None);
assert_eq!(field_text("datetime4", Some("en-GB"), FRI).as_deref(), Some("October 9, 2026"));
}
#[test]
fn unknown_types_and_impossible_dates_give_nothing() {
assert_eq!(field_text("slidenum", None, FRI), None);
assert_eq!(field_text("datetime14", None, FRI), None);
for bad in [
DateTime::new(2026, 0, 9, 0, 0, 0),
DateTime::new(2026, 13, 9, 0, 0, 0),
DateTime::new(2026, 2, 29, 0, 0, 0),
DateTime::new(2026, 4, 31, 0, 0, 0),
DateTime::new(2026, 1, 1, 24, 0, 0),
DateTime::new(i32::MIN, 1, 1, 0, 0, 0),
DateTime::new(i32::MAX, u32::MAX, u32::MAX, u32::MAX, u32::MAX, u32::MAX),
] {
assert_eq!(field_text("datetime2", None, bad), None, "{bad:?}");
}
assert!(is_date_field("datetimeFigureOut") && !is_date_field("slidenum"));
}
#[test]
fn the_clock_reads_a_real_date() {
if let Some(now) = DateTime::now_local() {
assert!(field_text("datetime1", None, now).is_some(), "{now:?}");
}
}
}