//! Camera-offset cascade and display-zone helpers for the true-instant date //! contract (see `docs/adr/0001-true-utc-dates.md`). //! //! The legacy `image_exif.date_taken` column mixes conventions: EXIF-sourced //! rows hold camera wall-clock *as UTC*, exiftool / filesystem rows hold true //! UTC instants. The new columns — `date_utc`, `camera_offset_minutes`, //! `date_taken_tz_source` — are populated at ingest by the offset cascade: //! //! 1. **offset tag** — the EXIF `OffsetTime*` tag, when the camera stamped //! it (explicit, most accurate). //! 2. **gps** — the capture location resolved to an IANA zone (bundled //! offline TZF geometry) and that zone's offset for the capture *date* //! (DST-correct via chrono-tz). //! 3. **assumed_server** — the server's own IANA zone, offset at the //! capture date. A camera without offset or GPS was probably carried //! around by its owner, i.e. in the operator's home zone. //! //! The cascade always returns `(offset_minutes, source)` so the caller can //! compute `date_utc = wall − offset` and audit why the row has the offset //! it does. Branches that already produce true UTC instants (exiftool, //! filesystem time) skip the cascade entirely and are recorded as //! [`TzSource::UtcDirect`]. use std::str::FromStr; use std::sync::OnceLock; use chrono::{Local, LocalResult, NaiveDateTime, Offset, TimeZone}; use chrono_tz::Tz; /// Why a `date_utc` row has the offset it does — or that it is a direct /// true instant. Written verbatim to `image_exif.date_taken_tz_source`. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum TzSource { /// An EXIF `OffsetTime*` tag supplied the camera offset directly. OffsetTag, /// GPS → IANA-zone lookup resolved the offset for the capture date. Gps, /// No offset tag and no usable GPS: assumed the server's zone. AssumedServer, /// The resolver branch already produced a true UTC instant (exiftool, /// filesystem time, unix-timestamp filename). No offset is involved. UtcDirect, /// An operator set the date manually (POST /image/exif/date, ticket /// #115): the wall the user picked, interpreted in their display zone. Manual, } impl TzSource { pub fn as_str(self) -> &'static str { match self { TzSource::OffsetTag => "offset_tag", TzSource::Gps => "gps", TzSource::AssumedServer => "assumed_server", TzSource::UtcDirect => "utc_direct", TzSource::Manual => "manual", } } } /// Evidence available to the offset cascade for one file. #[derive(Copy, Clone, Debug, Default)] pub struct OffsetEvidence { /// Signed minutes from the EXIF `OffsetTime*` tag (e.g. EDT = `-240`), /// when the camera stamped one. pub offset_tag_minutes: Option, /// `(latitude, longitude)` from the EXIF GPS tags, when present. pub gps: Option<(f64, f64)>, } /// A resolved camera offset for one capture. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct OffsetResolution { /// Camera local time minus UTC, in minutes (negative = west of UTC). pub minutes: i32, /// Which rung of the cascade produced it. pub source: TzSource, } /// GPS → IANA-zone seam. The production adapter wraps the bundled offline /// TZF geometry; tests inject a stub. pub trait GpsZoneLookup { fn zone_at(&self, lat: f64, lon: f64) -> Option; } /// Production [`GpsZoneLookup`]: bundled lite TZF geometry. No network, /// ~4 MB, loaded once per process on first use. pub struct TzfGpsZoneLookup { finder: OnceLock, } impl TzfGpsZoneLookup { pub fn new() -> Self { Self { finder: OnceLock::new(), } } fn finder(&self) -> &tzf_rs::DefaultFinder { self.finder.get_or_init(tzf_rs::DefaultFinder::new) } } impl Default for TzfGpsZoneLookup { fn default() -> Self { Self::new() } } impl GpsZoneLookup for TzfGpsZoneLookup { fn zone_at(&self, lat: f64, lon: f64) -> Option { // tzf-rs takes (lng, lat) — the reverse of our (lat, lon) convention. let name = self.finder().get_tz_name(lon, lat); if name.is_empty() { return None; } Tz::from_str(name).ok() } } /// The zone offset, in minutes, for a naive wall-clock reading. /// /// DST-correct: resolves the zone's offset for the wall-clock *date itself* /// (the same zone rule the camera's own clock followed). Ambiguous /// fall-back wall times resolve to the earlier (pre-transition) offset; /// nonexistent spring-forward wall times to the later one — each within one /// hour of the true capture instant, never off by a full day. pub fn offset_minutes_for_zone(zone: Tz, wall: NaiveDateTime) -> i32 { let dt = match zone.from_local_datetime(&wall) { LocalResult::Single(dt) => dt, LocalResult::Ambiguous(early, _) => early, // Nonexistent spring-forward wall time: use the zone's offset at // that wall reading interpreted as UTC — within an hour of truth. LocalResult::None => zone.from_utc_datetime(&wall), }; dt.offset().fix().local_minus_utc() / 60 } /// True UTC instant (unix seconds) for a camera wall-clock reading and its /// resolved offset: `instant = wall − offset`. /// /// e.g. wall `2024-07-04 12:00:00` at EDT (`-240`) → `16:00:00Z`. pub fn wall_to_instant(wall: NaiveDateTime, offset_minutes: i32) -> i64 { wall.and_utc().timestamp() - (offset_minutes as i64) * 60 } /// The offset cascade: offset tag → GPS zone → server zone. /// /// `server_zone` is the operator's home zone (see [`server_zone`]); when it /// is `None` (unresolvable on this host) the cascade falls back to the /// process-local offset, which is only exact for fixed-offset zones. pub fn resolve_camera_offset( wall: NaiveDateTime, evidence: &OffsetEvidence, server_zone: Option, zone_lookup: &dyn GpsZoneLookup, ) -> OffsetResolution { if let Some(minutes) = evidence.offset_tag_minutes { return OffsetResolution { minutes, source: TzSource::OffsetTag, }; } if let Some((lat, lon)) = evidence.gps && let Some(zone) = zone_lookup.zone_at(lat, lon) { return OffsetResolution { minutes: offset_minutes_for_zone(zone, wall), source: TzSource::Gps, }; } let minutes = match server_zone { Some(zone) => offset_minutes_for_zone(zone, wall), None => Local::now().offset().fix().local_minus_utc() / 60, }; OffsetResolution { minutes, source: TzSource::AssumedServer, } } /// Resolve the server's IANA zone from the host environment, in order: /// `TZ` env var (zone-name form), `/etc/timezone`, then the /// `/etc/localtime` symlink target. Returns `None` when nothing parses — /// callers then fall back to the process-local offset. pub fn server_zone() -> Option { if let Ok(tz) = std::env::var("TZ") && let Some(zone) = Tz::from_str(tz.trim()).ok() { return Some(zone); } if let Ok(contents) = std::fs::read_to_string("/etc/timezone") && let Some(zone) = Tz::from_str(contents.trim()).ok() { return Some(zone); } if let Ok(target) = std::fs::read_link("/etc/localtime") { let target = target.to_string_lossy().into_owned(); let zone_name = target .trim_start_matches("/usr/share/zoneinfo/") .trim_start_matches("/etc/zoneinfo/") .trim_start_matches("/var/db/timezone/zoneinfo/"); if !zone_name.is_empty() && zone_name != "localtime" && let Ok(zone) = Tz::from_str(zone_name) { return Some(zone); } } None } /// App default display zone when the user hasn't stored one /// (`user_ai_prefs.display_tz` NULL). pub const DEFAULT_DISPLAY_TZ: &str = "America/New_York"; /// Resolve the user's stored display zone to a usable IANA name. /// NULL / empty / unparseable prefs fall back to the app default, so /// read paths (tickets #119/#120) never have to special-case the /// unset state themselves. pub fn display_zone_for(stored: Option<&str>) -> String { match stored.map(str::trim).filter(|s| !s.is_empty()) { Some(name) if Tz::from_str(name).is_ok() => name.to_string(), _ => DEFAULT_DISPLAY_TZ.to_string(), } } /// Pure seam for the manual date-edit endpoints (ADR-0001, ticket #115): /// reinterpret the naive-as-UTC epoch the UI sent as a wall clock in the /// user's display zone, and produce the new-contract /// `(date_utc, camera_offset_minutes)` pair. The caller writes /// `date_taken_tz_source = 'manual'` itself and stores the epoch verbatim /// in the legacy `date_taken` column (byte-identical to the pre-rework /// behavior). /// /// `stored_zone` is `user_ai_prefs.display_tz` (NULL / unparseable → app /// default). The offset follows the wall's own date through the zone's DST /// rules — ambiguous fall-back walls take the earlier (pre-transition) /// offset; nonexistent spring-forward walls take the zone's offset at the /// wall-as-UTC reading, within an hour of truth (see /// [`offset_minutes_for_zone`]). `None` only when the zone name is /// unparseable, which [`display_zone_for`] already rules out — the caller /// then leaves the new-contract triple untouched. pub fn manual_date_resolution( stored_zone: Option<&str>, wall_naive_epoch: i64, ) -> Option<(i64, i32)> { let zone = Tz::from_str(&display_zone_for(stored_zone)).ok()?; let wall = chrono::DateTime::from_timestamp(wall_naive_epoch, 0).map(|dt| dt.naive_utc())?; let minutes = offset_minutes_for_zone(zone, wall); Some((wall_to_instant(wall, minutes), minutes)) } /// Display-zone offset, in minutes, for a true UTC instant — DST-aware. /// `None` when the zone name doesn't parse. #[allow(dead_code)] // consumed by the read-side cutover (#119/#120) pub fn display_zone_offset_minutes(zone_name: &str, instant: i64) -> Option { let zone = Tz::from_str(zone_name).ok()?; let dt = zone.timestamp_opt(instant, 0).single()?; Some(dt.offset().fix().local_minus_utc() / 60) } #[cfg(test)] mod tests { use super::*; #[test] fn display_zone_for_falls_back_to_default() { assert_eq!(display_zone_for(None).as_str(), DEFAULT_DISPLAY_TZ); assert_eq!(display_zone_for(Some("")).as_str(), DEFAULT_DISPLAY_TZ); assert_eq!(display_zone_for(Some(" ")).as_str(), DEFAULT_DISPLAY_TZ); assert_eq!( display_zone_for(Some("Not/A_Zone")).as_str(), DEFAULT_DISPLAY_TZ ); } #[test] fn display_zone_for_passes_through_valid_names() { assert_eq!(display_zone_for(Some("Europe/Berlin")), "Europe/Berlin"); assert_eq!(display_zone_for(Some(" Asia/Tokyo ")), "Asia/Tokyo"); } /// Stub lookup for cascade tests: one fixed zone or none. struct StubLookup(Option); impl GpsZoneLookup for StubLookup { fn zone_at(&self, _lat: f64, _lon: f64) -> Option { self.0 } } fn ny() -> Tz { chrono_tz::America::New_York } fn wall_july() -> NaiveDateTime { NaiveDateTime::parse_from_str("2024-07-04 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap() } fn wall_january() -> NaiveDateTime { NaiveDateTime::parse_from_str("2024-01-15 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap() } #[test] fn offset_tag_wins_over_everything() { let evidence = OffsetEvidence { offset_tag_minutes: Some(330), // +05:30 gps: Some((40.71, -74.00)), }; let res = resolve_camera_offset(wall_july(), &evidence, Some(ny()), &StubLookup(Some(ny()))); assert_eq!(res.minutes, 330); assert_eq!(res.source, TzSource::OffsetTag); } #[test] fn gps_zone_wins_when_no_tag() { let evidence = OffsetEvidence { offset_tag_minutes: None, gps: Some((40.71, -74.00)), }; let res = resolve_camera_offset(wall_july(), &evidence, Some(ny()), &StubLookup(Some(ny()))); assert_eq!(res.minutes, -240, "July wall time in NY is EDT (UTC-4)"); assert_eq!(res.source, TzSource::Gps); } #[test] fn server_zone_is_last_resort() { let evidence = OffsetEvidence::default(); let res = resolve_camera_offset( wall_january(), &evidence, Some(ny()), &StubLookup(Some(ny())), // stub is ignored: no GPS evidence ); assert_eq!(res.minutes, -300, "January wall time in NY is EST (UTC-5)"); assert_eq!(res.source, TzSource::AssumedServer); } #[test] fn gps_miss_falls_through_to_server_zone() { let evidence = OffsetEvidence { offset_tag_minutes: None, gps: Some((30.0, -40.0)), // open ocean: lookup returns None }; let res = resolve_camera_offset(wall_january(), &evidence, Some(ny()), &StubLookup(None)); assert_eq!(res.source, TzSource::AssumedServer); } #[test] fn zone_offset_is_dst_correct_for_capture_date() { assert_eq!(offset_minutes_for_zone(ny(), wall_july()), -240); assert_eq!(offset_minutes_for_zone(ny(), wall_january()), -300); // Berlin: CEST +120 in summer, CET +60 in winter. let berlin = chrono_tz::Europe::Berlin; assert_eq!(offset_minutes_for_zone(berlin, wall_july()), 120); assert_eq!(offset_minutes_for_zone(berlin, wall_january()), 60); } #[test] fn wall_to_instant_edt_example() { // Wall 2024-07-04 12:00:00 at EDT (-240) is 16:00:00 UTC // = 1720108800. This is the exact pair the mixed-convention audit // used to prove the legacy EXIF branch was off by 4 hours. assert_eq!(wall_to_instant(wall_july(), -240), 1_720_108_800); } #[test] fn wall_to_instant_positive_offset() { // Wall 2024-07-04 12:00:00 at +05:30 (330) is 06:30:00 UTC. let expected = wall_july().and_utc().timestamp() - 330 * 60; assert_eq!(wall_to_instant(wall_july(), 330), expected); } #[test] fn display_zone_offset_minutes_is_dst_aware() { // 2024-07-04 16:00:00Z — New York is on EDT. assert_eq!( display_zone_offset_minutes("America/New_York", 1_720_108_800), Some(-240) ); // 2024-01-15 17:00:00Z — New York is on EST. assert_eq!( display_zone_offset_minutes("America/New_York", 1_705_335_600), Some(-300) ); } #[test] fn manual_date_resolution_display_zone_edt() { // The UI sends the wall rendered in the display zone, as a // naive-as-UTC epoch: 1720080000 = "2024-07-04 04:00:00". July in // New York is EDT (-240): the instant is 08:00:00Z. let (utc, off) = manual_date_resolution(Some("America/New_York"), 1_720_080_000).unwrap(); assert_eq!(off, -240); assert_eq!(utc, 1_720_080_000 + 4 * 3600); } #[test] fn manual_date_resolution_display_zone_est() { // Same function in January: EST (-300). Wall 2024-01-15 04:00:00 // (naive epoch 1705291200) → 09:00:00Z. let (utc, off) = manual_date_resolution(Some("America/New_York"), 1_705_291_200).unwrap(); assert_eq!(off, -300); assert_eq!(utc, 1_705_291_200 + 5 * 3600); } #[test] fn manual_date_resolution_positive_zone_and_default() { // Tokyo is +09:00 year-round: wall 04:00 → previous day 19:00Z. let (utc, off) = manual_date_resolution(Some("Asia/Tokyo"), 1_720_080_000).unwrap(); assert_eq!(off, 540); assert_eq!(utc, 1_720_080_000 - 540 * 60); // NULL stored zone → app default (America/New_York), July → EDT. let (_, off) = manual_date_resolution(None, 1_720_080_000).unwrap(); assert_eq!(off, -240); // Unparseable stored zone also falls back to the default. let (_, off) = manual_date_resolution(Some("Not/A_Zone"), 1_720_080_000).unwrap(); assert_eq!(off, -240); } #[test] fn display_zone_offset_minutes_unknown_zone_is_none() { assert_eq!( display_zone_offset_minutes("Not/A_Zone", 1_700_000_000), None ); } #[test] fn tz_source_as_str_round_trip() { let pairs = [ (TzSource::OffsetTag, "offset_tag"), (TzSource::Gps, "gps"), (TzSource::AssumedServer, "assumed_server"), (TzSource::UtcDirect, "utc_direct"), (TzSource::Manual, "manual"), ]; for (src, expected) in pairs { assert_eq!(src.as_str(), expected); } } #[test] fn tzf_lookup_resolves_new_york() { let lookup = TzfGpsZoneLookup::new(); assert_eq!( lookup.zone_at(40.7128, -74.0060), Some(chrono_tz::America::New_York) ); } #[test] fn tzf_lookup_resolves_berlin() { let lookup = TzfGpsZoneLookup::new(); assert_eq!( lookup.zone_at(52.5200, 13.4050), Some(chrono_tz::Europe::Berlin) ); } #[test] fn tzf_lookup_open_ocean_resolves_etc_zone() { // The bundled lite data covers open ocean with fixed-offset // Etc/GMT* zones — a mid-Atlantic photo gets a sane local offset // rather than falling through to the server zone. let lookup = TzfGpsZoneLookup::new(); let zone = lookup .zone_at(30.0, -40.0) .expect("ocean resolves to Etc/GMT*"); assert!(zone.name().starts_with("Etc/GMT"), "got {}", zone.name()); } #[test] fn server_zone_resolves_on_this_host_or_none() { // The host's zone is whatever it is — the contract is that a // parseable result is a real Tz and a missing config is None, not a // panic. Both outcomes are valid; assert only the no-panic shape. let _ = server_zone(); } }