diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index b24c3fa..717e434 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -11,6 +11,7 @@ use std::ops::Range; pub mod colour; pub mod selector; pub mod settings; +pub mod time; pub use colour::{Chromaticities, Transfer}; pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier}; @@ -18,6 +19,7 @@ pub use settings::{ CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings, ExportTarget, OutputSharpening, Settings, SizingMode, }; +pub use time::{civil_from_unix, civil_from_unix_at, format_date, Civil}; /// Identifies a granted library location — a directory on Linux, a persisted /// document tree on Android. diff --git a/core/dr-types/src/time.rs b/core/dr-types/src/time.rs new file mode 100644 index 0000000..87c3120 --- /dev/null +++ b/core/dr-types/src/time.rs @@ -0,0 +1,135 @@ +//! Capture instants as civil dates. +//! +//! Hand-rolled rather than pulling in chrono for four fields — the same +//! reasoning as the connector's HTTP date parsing, and the same algorithm the +//! timeline has always used. +//! +//! # Why this is shared vocabulary rather than a helper where it is needed +//! +//! Three places convert a capture instant to a date, and they must agree: +//! the timeline's month headings, the exporter's `{date}` token (FR-EXP-6), +//! and the folder an import writes into (FR-CAT-10, FR-NC-7a). A photograph +//! filed under `2026-08-21` while the timeline shows it on the 22nd is not a +//! cosmetic disagreement — it is a file the user cannot find where the app +//! told them to look. + +/// A civil date and hour, as the calendar shows it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Civil { + pub year: i64, + /// 1–12. + pub month: i64, + /// 1–31. + pub day: i64, + /// 0–23. + pub hour: i64, +} + +/// Unix seconds to a civil date, via the usual era-based algorithm. +/// +/// The reading is UTC. A capture instant carries its own zone separately +/// (`Metadata::captured_offset`), because a photograph's date is local to +/// where it was taken: see [`civil_from_unix_at`]. +pub fn civil_from_unix(t: i64) -> Civil { + let days = t.div_euclid(86_400); + let secs = t.rem_euclid(86_400); + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + Civil { + year: if m <= 2 { y + 1 } else { y }, + month: m, + day: d, + hour: secs / 3600, + } +} + +/// The same instant read in the zone the camera recorded. +/// +/// `offset` is minutes east of UTC, as EXIF `OffsetTimeOriginal` gives it. +/// Absent on most bodies before ~2018, in which case the stored reading is +/// already the local wall clock (see `Metadata::captured_at`) and passing +/// `None` is correct rather than a fallback. +/// +/// This is the reading an *import* must use. A shot taken at 23:30 in Tokyo +/// belongs in Tokyo's day: filing it by UTC would put a night's photographs in +/// two different folders, split at whatever hour the offset happens to be. +pub fn civil_from_unix_at(t: i64, offset: Option) -> Civil { + civil_from_unix(t + offset.unwrap_or(0) as i64 * 60) +} + +/// A capture instant as `YYYY-MM-DD`. +pub fn format_date(t: i64) -> String { + let c = civil_from_unix(t); + format!("{}-{:02}-{:02}", c.year, c.month, c.day) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_epoch_is_the_first_of_january_1970() { + let c = civil_from_unix(0); + assert_eq!((c.year, c.month, c.day, c.hour), (1970, 1, 1, 0)); + } + + #[test] + fn a_leap_day_is_the_twenty_ninth_of_february() { + // 2024-02-29T12:00:00Z. A year-400 rule mistake shows up here first. + let c = civil_from_unix(1_709_208_000); + assert_eq!((c.year, c.month, c.day, c.hour), (2024, 2, 29, 12)); + } + + #[test] + fn the_year_2000_is_a_leap_year_and_1900_was_not() { + // 2000-02-29T00:00:00Z exists... + let c = civil_from_unix(951_782_400); + assert_eq!((c.year, c.month, c.day), (2000, 2, 29)); + // ...and 1900-03-01 is the day after 1900-02-28, with no 29th between. + let feb28 = civil_from_unix(-2_203_977_600); + assert_eq!((feb28.year, feb28.month, feb28.day), (1900, 2, 28)); + let next = civil_from_unix(-2_203_977_600 + 86_400); + assert_eq!((next.year, next.month, next.day), (1900, 3, 1)); + } + + #[test] + fn an_instant_before_the_epoch_does_not_wrap() { + // Euclidean division rather than truncating: a 1969 date must not + // round toward zero into 1970. + let c = civil_from_unix(-1); + assert_eq!((c.year, c.month, c.day, c.hour), (1969, 12, 31, 23)); + } + + #[test] + fn a_late_evening_shot_keeps_its_own_days_date() { + // 2026-08-21T22:30:00Z is already the 22nd in Tokyo (+540 minutes). + // Filing it by UTC would put it a day earlier than the photographer + // remembers taking it. + let utc = civil_from_unix_at(1_787_351_400, None); + assert_eq!((utc.year, utc.month, utc.day), (2026, 8, 21)); + let tokyo = civil_from_unix_at(1_787_351_400, Some(540)); + assert_eq!((tokyo.year, tokyo.month, tokyo.day), (2026, 8, 22)); + } + + #[test] + fn a_western_offset_can_move_the_date_back() { + // The mirror case: 2026-08-22T02:00:00Z is still the 21st in New York. + let ny = civil_from_unix_at(1_787_364_000, Some(-240)); + assert_eq!((ny.year, ny.month, ny.day), (2026, 8, 21)); + } + + #[test] + fn a_formatted_date_is_zero_padded() { + assert_eq!(format_date(1_709_208_000), "2024-02-29"); + // Single-digit months and days pad, so names sort lexicographically — + // which is the whole reason a folder template is worth having. + assert_eq!(format_date(0), "1970-01-01"); + } +} diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index 38a6451..b48d3cf 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -3428,27 +3428,18 @@ fn format_bucket(t: i64, g: dr_catalog::Granularity) -> String { /// reading so a filename and the timeline cannot disagree about what day a /// photograph was taken. pub fn format_date(t: i64) -> String { - let (y, m, d, _) = civil_from_unix(t); - format!("{y}-{m:02}-{d:02}") + dr_types::format_date(t) } -/// Unix seconds to a civil date, via the usual era-based algorithm. +/// Unix seconds to a civil date, in the tuple shape this module reads it in. /// -/// Hand-rolled rather than pulling in chrono for four fields — the same -/// reasoning as the connector's HTTP date parsing. +/// The algorithm moved to `dr_types::time` when import folder templates +/// (FR-CAT-10) became a third caller. Three readings of the same instant that +/// could drift apart is one too many: an image filed under a date the timeline +/// does not show it on is a file the user cannot find. fn civil_from_unix(t: i64) -> (i64, i64, i64, i64) { - let days = t.div_euclid(86_400); - let secs = t.rem_euclid(86_400); - let z = days + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - (if m <= 2 { y + 1 } else { y }, m, d, secs / 3600) + let c = dr_types::civil_from_unix(t); + (c.year, c.month, c.day, c.hour) } /// Copy decoded RGBA into a Slint image.