diff --git a/Cargo.lock b/Cargo.lock index 1b3a415..8dc6f91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -779,6 +779,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1552,6 +1562,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "geometry-rs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "794fbaf8ce24464265e001a7a1dc819e401a303127c0cb714538e21ebe077c77" +dependencies = [ + "rtree_rs", +] + [[package]] name = "getopts" version = "0.2.24" @@ -2070,6 +2089,7 @@ dependencies = [ "blake3", "bytes", "chrono", + "chrono-tz", "clap", "diesel", "diesel_migrations", @@ -2103,6 +2123,7 @@ dependencies = [ "tempfile", "tokio", "tokio-util", + "tzf-rs", "urlencoding", "uuid", "walkdir", @@ -2886,6 +2907,15 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf_codegen" version = "0.10.0" @@ -2957,6 +2987,15 @@ dependencies = [ "siphasher 1.0.3", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher 1.0.3", +] + [[package]] name = "pin-project" version = "1.1.12" @@ -3041,6 +3080,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pqueue" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2145d14f09d5fc7fe7134b556146599c2929af5b1f1e3a0ecc9d582a9b85e4d" + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -3460,6 +3505,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "rtree_rs" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7787960e978c1c675fd6b8eb7d56b9c7162aec55c41d368add6f3ff39251b96e" +dependencies = [ + "pqueue", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -4314,6 +4368,23 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "tzf-dist" +version = "0.0.2026-d-fix1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2523c6471fd5827173b5b9d51d4444e1033a1f19339da61603cee9300e3f44be" + +[[package]] +name = "tzf-rs" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bd4bd76dc7816db8a12043dbac00254158c17cd2c731aa951fd85f1a9d1f1ae" +dependencies = [ + "clap", + "geometry-rs", + "tzf-dist", +] + [[package]] name = "unicase" version = "2.9.0" diff --git a/Cargo.toml b/Cargo.toml index 270e9e4..2a65287 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ futures = "0.3.5" jsonwebtoken = "9.3.0" serde = "1" serde_json = "1" -diesel = { version = "2.2.10", features = ["sqlite"] } +diesel = { version = "2.2.10", features = ["sqlite", "64-column-tables"] } libsqlite3-sys = "0.35" diesel_migrations = "2.2.0" chrono = "0.4" @@ -67,6 +67,8 @@ bk-tree = "0.5" async-trait = "0.1" indicatif = "0.17" uuid = { version = "1.10", features = ["v4", "serde"] } +chrono-tz = "0.10" +tzf-rs = "2.1.2" # Windows lacks system sqlite3, so re-enable the bundled C build there. # Linux/macOS use the system library (faster builds, smaller binary). diff --git a/migrations/2026-09-24-000000_add_true_utc_dates/down.sql b/migrations/2026-09-24-000000_add_true_utc_dates/down.sql new file mode 100644 index 0000000..e8d5992 --- /dev/null +++ b/migrations/2026-09-24-000000_add_true_utc_dates/down.sql @@ -0,0 +1,4 @@ +ALTER TABLE user_ai_prefs DROP COLUMN display_tz; +ALTER TABLE image_exif DROP COLUMN date_taken_tz_source; +ALTER TABLE image_exif DROP COLUMN camera_offset_minutes; +ALTER TABLE image_exif DROP COLUMN date_utc; diff --git a/migrations/2026-09-24-000000_add_true_utc_dates/up.sql b/migrations/2026-09-24-000000_add_true_utc_dates/up.sql new file mode 100644 index 0000000..30662d7 --- /dev/null +++ b/migrations/2026-09-24-000000_add_true_utc_dates/up.sql @@ -0,0 +1,19 @@ +-- True-instant date contract (ADR-0001 / spec #113, ticket #114). +-- +-- image_exif gains the new-contract columns alongside the untouched legacy +-- `date_taken`: +-- date_utc — true UTC instant (unix seconds) under the new +-- contract; NULL while the row is still on the +-- legacy convention (drain pending). +-- camera_offset_minutes — camera local − UTC in minutes that produced +-- date_utc (NULL for direct-UTC branches). +-- date_taken_tz_source — why the offset is what it is: +-- offset_tag | gps | assumed_server | utc_direct +-- | manual. +-- user_ai_prefs gains the DST-proof display zone (IANA name); the legacy +-- tz_offset_minutes column stays frozen for rollback. + +ALTER TABLE image_exif ADD COLUMN date_utc BIGINT; +ALTER TABLE image_exif ADD COLUMN camera_offset_minutes INTEGER; +ALTER TABLE image_exif ADD COLUMN date_taken_tz_source TEXT; +ALTER TABLE user_ai_prefs ADD COLUMN display_tz TEXT; diff --git a/src/ai/insight_chat.rs b/src/ai/insight_chat.rs index bc070f2..b34fa8f 100644 --- a/src/ai/insight_chat.rs +++ b/src/ai/insight_chat.rs @@ -3378,6 +3378,9 @@ mod tests { original_date_taken_source: None, clip_embedding: None, clip_model_version: None, + date_utc: Some(1_629_028_800), + camera_offset_minutes: None, + date_taken_tz_source: Some("utc_direct".to_string()), }); let out = resolve_date_taken_for_context(&exif, "Screenshot_2014-06-01.png"); assert_eq!(out.as_deref(), Some("2021-08-15")); diff --git a/src/backfill.rs b/src/backfill.rs index ec25fbb..c87c789 100644 --- a/src/backfill.rs +++ b/src/backfill.rs @@ -192,6 +192,9 @@ pub fn backfill_missing_date_taken( rel_path, rd.timestamp, rd.source.as_str(), + rd.date_utc, + rd.camera_offset_minutes, + rd.tz_source.map(|t| t.as_str()), ) { Ok(()) => { backfilled += 1; @@ -616,6 +619,9 @@ mod tests { phash_64: None, dhash_64: None, date_taken_source: None, + date_utc: None, + camera_offset_minutes: None, + date_taken_tz_source: None, }, ) .expect("insert"); diff --git a/src/camera_tz.rs b/src/camera_tz.rs new file mode 100644 index 0000000..1a06672 --- /dev/null +++ b/src/camera_tz.rs @@ -0,0 +1,421 @@ +//! 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. + // Written by the manual-edit rework (#118); nothing constructs it yet. + #[allow(dead_code)] + 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>) -> &str { + match stored.map(str::trim).filter(|s| !s.is_empty()) { + Some(name) if Tz::from_str(name).is_ok() => name, + _ => DEFAULT_DISPLAY_TZ, + } +} + +/// 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), DEFAULT_DISPLAY_TZ); + assert_eq!(display_zone_for(Some("")), DEFAULT_DISPLAY_TZ); + assert_eq!(display_zone_for(Some(" ")), DEFAULT_DISPLAY_TZ); + assert_eq!(display_zone_for(Some("Not/A_Zone")), 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 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(); + } +} diff --git a/src/data/mod.rs b/src/data/mod.rs index 931b3c6..44cc640 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -292,6 +292,15 @@ pub struct ExifMetadata { /// `"exif" | "exiftool" | "filename" | "fs_time" | "manual"`. NULL when /// `date_taken` itself is NULL. pub date_taken_source: Option, + /// True UTC instant under the new date contract (ADR-0001). NULL while + /// the row is still on the legacy convention (drain pending, or the + /// unclassified filename branch). + pub date_utc: Option, + /// Camera offset (local − UTC, minutes) that produced `date_utc`. + pub camera_offset_minutes: Option, + /// Why the offset is what it is: `offset_tag` | `gps` | + /// `assumed_server` | `utc_direct` | `manual`. + pub date_taken_tz_source: Option, /// When `date_taken_source = "manual"`, the prior `date_taken` snapshot. /// Used by the UI's revert affordance and to label "manually overridden; /// originally X" in the details modal. @@ -383,6 +392,9 @@ impl From for ExifMetadata { }, date_taken: exif.date_taken, date_taken_source: exif.date_taken_source, + date_utc: exif.date_utc, + camera_offset_minutes: exif.camera_offset_minutes, + date_taken_tz_source: exif.date_taken_tz_source, original_date_taken: exif.original_date_taken, original_date_taken_source: exif.original_date_taken_source, } @@ -428,6 +440,13 @@ pub struct ExifSummary { pub gps_latitude: Option, pub gps_longitude: Option, pub date_taken: Option, + /// True UTC instant under the new date contract (ADR-0001). NULL while + /// the row is still on the legacy convention. + pub date_utc: Option, + /// Camera offset (local − UTC, minutes) that produced `date_utc`. + pub camera_offset_minutes: Option, + /// How the offset was determined. See `camera_tz::TzSource`. + pub date_taken_tz_source: Option, } #[derive(Debug, Serialize)] @@ -564,3 +583,83 @@ mod tests { assert!(previews[1].get("preview_url").is_none()); } } + +#[cfg(test)] +mod date_contract_tests { + use super::*; + + fn sample_row() -> crate::database::models::ImageExif { + crate::database::models::ImageExif { + id: 1, + library_id: 1, + file_path: "2024/vacation/IMG_0001.jpg".to_string(), + camera_make: None, + camera_model: Some("NIKON Z 8".to_string()), + lens_model: None, + width: None, + height: None, + orientation: None, + gps_latitude: None, + gps_longitude: None, + gps_altitude: None, + focal_length: None, + aperture: None, + shutter_speed: None, + iso: None, + // Wall 2024-07-04 12:00:00 stored legacy-style (as-UTC). + date_taken: Some(1_720_094_400), + created_time: 0, + last_modified: 0, + content_hash: None, + size_bytes: None, + phash_64: None, + dhash_64: None, + duplicate_of_hash: None, + duplicate_decided_at: None, + date_taken_source: Some("exif".to_string()), + original_date_taken: None, + original_date_taken_source: None, + clip_embedding: None, + clip_model_version: None, + date_utc: Some(1_720_108_800), + camera_offset_minutes: Some(-240), + date_taken_tz_source: Some("offset_tag".to_string()), + } + } + + /// /image/metadata contract: the new-contract date fields ride along on + /// the EXIF metadata payload alongside the legacy `date_taken`. + #[test] + fn exif_metadata_response_includes_date_contract_fields() { + let meta = ExifMetadata::from(sample_row()); + assert_eq!(meta.date_taken, Some(1_720_094_400)); + assert_eq!(meta.date_utc, Some(1_720_108_800)); + assert_eq!(meta.camera_offset_minutes, Some(-240)); + assert_eq!(meta.date_taken_tz_source.as_deref(), Some("offset_tag")); + } + + /// /photos/exif contract: Apollo's per-photo summary carries the same + /// fields so the viewer flip (spec #113) can read one shape. + #[test] + fn exif_summary_shape_includes_date_contract_fields() { + let summary = ExifSummary { + file_path: "2024/vacation/IMG_0001.jpg".to_string(), + library_id: 1, + library_name: Some("main".to_string()), + camera_model: Some("NIKON Z 8".to_string()), + width: None, + height: None, + gps_latitude: None, + gps_longitude: None, + date_taken: Some(1_720_094_400), + date_utc: Some(1_720_108_800), + camera_offset_minutes: Some(-240), + date_taken_tz_source: Some("offset_tag".to_string()), + }; + // Verify the JSON wire names clients (Apollo, FVR) will consume. + let json = serde_json::to_value(&summary).unwrap(); + assert_eq!(json["date_utc"], 1_720_108_800); + assert_eq!(json["camera_offset_minutes"], -240); + assert_eq!(json["date_taken_tz_source"], "offset_tag"); + } +} diff --git a/src/database/mod.rs b/src/database/mod.rs index 981f6a4..536c641 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -495,10 +495,12 @@ pub trait ExifDao: Sync + Send { limit: i64, ) -> Result, DbError>; - /// Persist a resolver result for an existing row. Touches `date_taken` - /// and `date_taken_source` only — leaves all other columns alone so - /// the drain doesn't accidentally clobber EXIF/hash/perceptual data - /// the watcher / GPS-write path may have already written. + /// Persist a resolver result for an existing row. Touches `date_taken`, + /// `date_taken_source`, and the new-contract columns (`date_utc`, + /// `camera_offset_minutes`, `date_taken_tz_source`) only — leaves all + /// other columns alone so the drain doesn't accidentally clobber + /// EXIF/hash/perceptual data the watcher / GPS-write path may have + /// already written. fn backfill_date_taken( &mut self, context: &opentelemetry::Context, @@ -506,6 +508,9 @@ pub trait ExifDao: Sync + Send { rel_path: &str, date_taken: i64, source: &str, + date_utc: Option, + camera_offset_minutes: Option, + date_taken_tz_source: Option<&str>, ) -> Result<(), DbError>; /// Find image_exif rows needing a CLIP embedding for semantic search: @@ -1426,6 +1431,9 @@ impl ExifDao for SqliteExifDao { rel_path_val: &str, date_taken_val: i64, source: &str, + date_utc_val: Option, + camera_offset_minutes_val: Option, + date_taken_tz_source_val: Option<&str>, ) -> Result<(), DbError> { trace_db_call(context, "update", "backfill_date_taken", |_span| { use schema::image_exif::dsl::*; @@ -1437,7 +1445,13 @@ impl ExifDao for SqliteExifDao { .filter(library_id.eq(library_id_val)) .filter(rel_path.eq(rel_path_val)), ) - .set((date_taken.eq(date_taken_val), date_taken_source.eq(source))) + .set(( + date_taken.eq(date_taken_val), + date_taken_source.eq(source), + date_utc.eq(date_utc_val), + camera_offset_minutes.eq(camera_offset_minutes_val), + date_taken_tz_source.eq(date_taken_tz_source_val), + )) .execute(connection.deref_mut()); match result { @@ -2547,6 +2561,9 @@ mod exif_dao_tests { phash_64: None, dhash_64: None, date_taken_source: None, + date_utc: None, + camera_offset_minutes: None, + date_taken_tz_source: None, }, ) .expect("insert exif row"); @@ -2695,6 +2712,9 @@ mod exif_dao_tests { phash_64: None, dhash_64: None, date_taken_source: source.map(|s| s.to_string()), + date_utc: None, + camera_offset_minutes: None, + date_taken_tz_source: None, }, ) .expect("insert exif row"); @@ -2729,12 +2749,25 @@ mod exif_dao_tests { dao.backfill_content_hash(&ctx(), 1, "main/x.jpg", "deadbeef", 1024) .unwrap(); - dao.backfill_date_taken(&ctx(), 1, "main/x.jpg", 1700000000, "exiftool") - .unwrap(); + dao.backfill_date_taken( + &ctx(), + 1, + "main/x.jpg", + 1700000000, + "exiftool", + Some(1700000000), + None, + Some("utc_direct"), + ) + .unwrap(); let row = dao.get_exif(&ctx(), "main/x.jpg").unwrap().unwrap(); assert_eq!(row.date_taken, Some(1700000000)); assert_eq!(row.date_taken_source, Some("exiftool".to_string())); + // New-contract columns written alongside the legacy pair. + assert_eq!(row.date_utc, Some(1700000000)); + assert_eq!(row.camera_offset_minutes, None); + assert_eq!(row.date_taken_tz_source, Some("utc_direct".to_string())); // Untouched columns survive. assert_eq!(row.content_hash, Some("deadbeef".to_string())); assert_eq!(row.size_bytes, Some(1024)); diff --git a/src/database/models.rs b/src/database/models.rs index 2102511..455ec5a 100644 --- a/src/database/models.rs +++ b/src/database/models.rs @@ -136,6 +136,17 @@ pub struct InsertImageExif { /// drain re-resolves rows whose source is `"fs_time"` once exiftool /// has had a chance to run. pub date_taken_source: Option, + /// True UTC instant (unix seconds) under the new date contract + /// (ADR-0001). NULL while the row is still on the legacy convention — + /// either unclassified (filename branch, ticket #116) or pending the + /// backfill drain (#118). + pub date_utc: Option, + /// Camera offset (local − UTC, minutes) that produced `date_utc`. + /// NULL for direct-true-UTC branches and unclassified rows. + pub camera_offset_minutes: Option, + /// Why the offset is what it is: `offset_tag` | `gps` | + /// `assumed_server` | `utc_direct` | `manual`. See `camera_tz::TzSource`. + pub date_taken_tz_source: Option, } // Field order matches the post-migration column order in `image_exif`. @@ -190,6 +201,13 @@ pub struct ImageExif { /// swap of `APOLLO_CLIP_MODEL` re-eligibilizes rows whose stored /// version differs so the drain rebuilds them. pub clip_model_version: Option, + /// True UTC instant (unix seconds) under the new date contract + /// (ADR-0001). NULL while the row is still on the legacy convention. + pub date_utc: Option, + /// Camera offset (local − UTC, minutes) that produced `date_utc`. + pub camera_offset_minutes: Option, + /// How the offset was determined. See `camera_tz::TzSource`. + pub date_taken_tz_source: Option, } #[derive(Insertable, Clone)] @@ -549,6 +567,10 @@ pub struct UserAiPrefs { pub tz_offset_minutes: Option, pub library: Option, pub updated_at: i64, + /// User's display time zone as an IANA name (DST-proof). NULL = app + /// default (`camera_tz::DEFAULT_DISPLAY_TZ`). The legacy + /// `tz_offset_minutes` column stays frozen for rollback. + pub display_tz: Option, } #[derive(Insertable, Debug, Clone, serde::Deserialize, serde::Serialize)] @@ -558,4 +580,5 @@ pub struct UpsertUserAiPrefs { pub tz_offset_minutes: Option, pub library: Option, pub updated_at: i64, + pub display_tz: Option, } diff --git a/src/database/schema.rs b/src/database/schema.rs index a0d053e..5518eef 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -140,6 +140,9 @@ diesel::table! { original_date_taken_source -> Nullable, clip_embedding -> Nullable, clip_model_version -> Nullable, + date_utc -> Nullable, + camera_offset_minutes -> Nullable, + date_taken_tz_source -> Nullable, } } @@ -286,6 +289,7 @@ diesel::table! { tz_offset_minutes -> Nullable, library -> Nullable, updated_at -> BigInt, + display_tz -> Nullable, } } diff --git a/src/database/user_ai_prefs_dao.rs b/src/database/user_ai_prefs_dao.rs index 129ef0c..772e49c 100644 --- a/src/database/user_ai_prefs_dao.rs +++ b/src/database/user_ai_prefs_dao.rs @@ -99,6 +99,7 @@ impl UserAiPrefsDao for SqliteUserAiPrefsDao { dsl::voice.eq(&prefs.voice), dsl::tz_offset_minutes.eq(&prefs.tz_offset_minutes), dsl::library.eq(&prefs.library), + dsl::display_tz.eq(&prefs.display_tz), dsl::updated_at.eq(&prefs.updated_at), )) .execute(connection.deref_mut()) @@ -145,6 +146,7 @@ mod tests { tz_offset_minutes: Some(-480), library: Some("1".to_string()), updated_at: now, + display_tz: Some("America/New_York".to_string()), }; dao.upsert_prefs(&ctx(), &prefs).unwrap(); @@ -153,6 +155,7 @@ mod tests { assert_eq!(row.voice, Some("grandma".to_string())); assert_eq!(row.tz_offset_minutes, Some(-480)); assert_eq!(row.library, Some("1".to_string())); + assert_eq!(row.display_tz, Some("America/New_York".to_string())); assert_eq!(row.updated_at, now); } @@ -167,6 +170,7 @@ mod tests { tz_offset_minutes: Some(-480), library: Some("1".to_string()), updated_at: now1, + display_tz: Some("America/New_York".to_string()), }; dao.upsert_prefs(&ctx(), &prefs1).unwrap(); @@ -175,6 +179,7 @@ mod tests { tz_offset_minutes: Some(-300), library: None, updated_at: now2, + display_tz: None, }; dao.upsert_prefs(&ctx(), &prefs2).unwrap(); @@ -182,6 +187,7 @@ mod tests { assert_eq!(row.voice, Some("dad".to_string())); assert_eq!(row.tz_offset_minutes, Some(-300)); assert!(row.library.is_none()); + assert!(row.display_tz.is_none()); assert_eq!(row.updated_at, now2); } @@ -195,6 +201,7 @@ mod tests { tz_offset_minutes: Some(-480), library: None, updated_at: now, + display_tz: Some("Europe/Berlin".to_string()), }; dao.upsert_prefs(&ctx(), &prefs).unwrap(); @@ -202,5 +209,6 @@ mod tests { assert_eq!(row.tz_offset_minutes, Some(-480)); assert!(row.voice.is_none()); assert!(row.library.is_none()); + assert_eq!(row.display_tz, Some("Europe/Berlin".to_string())); } } diff --git a/src/date_resolver.rs b/src/date_resolver.rs index f8b156c..69c957d 100644 --- a/src/date_resolver.rs +++ b/src/date_resolver.rs @@ -32,10 +32,11 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::OnceLock; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, NaiveDateTime, Utc}; use log::{debug, trace, warn}; use serde::Deserialize; +use crate::camera_tz; use crate::utils::earliest_fs_time; #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -61,27 +62,101 @@ impl DateSource { } } +/// What the ingest path already knows from kamadak-exif for one file: the +/// camera wall-clock reading plus the offset evidence for the cascade. +#[derive(Copy, Clone, Debug)] +pub struct ExifPrior { + /// Camera wall clock, decoded the same way the legacy column stores it + /// (naive-as-UTC from `parse_exif_datetime`). The legacy `date_taken` + /// value stays byte-identical. + pub wall: NaiveDateTime, + /// Offset evidence (EXIF `OffsetTime*` tag, GPS coordinates). + pub evidence: camera_tz::OffsetEvidence, +} + #[derive(Copy, Clone, Debug)] pub struct ResolvedDate { + /// Legacy `date_taken` value. Kept byte-identical to the pre-migration + /// behavior of each branch so rollback stays trivial (ADR-0001). pub timestamp: i64, pub source: DateSource, + /// True UTC instant under the new contract (ADR-0001). `None` for + /// filename-sourced rows: their convention (wall-clock vs UTC) is a + /// per-pattern decision made by the filename date-class work (#116). + pub date_utc: Option, + /// Camera offset (minutes) that produced `date_utc`; `None` when the + /// branch is a direct true instant or unclassified. + pub camera_offset_minutes: Option, + /// Why `date_utc` has the offset it does. `None` when unclassified. + pub tz_source: Option, +} + +/// Build an [`ExifPrior`] from a kamadak-exif [`crate::exif::ExifData`]: +/// the wall clock (naive-as-UTC, matching the legacy column) plus the +/// offset evidence (stamped offset tag + GPS). `None` when the file had no +/// EXIF date. +pub fn exif_prior_from(data: &crate::exif::ExifData) -> Option { + let wall = DateTime::from_timestamp(data.date_taken?, 0)?.naive_utc(); + Some(ExifPrior { + wall, + evidence: camera_tz::OffsetEvidence { + offset_tag_minutes: data.offset_time_minutes, + gps: match (data.gps_latitude, data.gps_longitude) { + (Some(lat), Some(lon)) => Some((lat, lon)), + _ => None, + }, + }, + }) +} + +/// EXIF-branch resolution: run the offset cascade over the wall clock and +/// offset evidence kamadak-exif already extracted. Legacy timestamp is the +/// wall clock as-UTC — unchanged from the pre-migration behavior. +fn exif_prior_resolved(prior: &ExifPrior) -> ResolvedDate { + let resolution = camera_tz::resolve_camera_offset( + prior.wall, + &prior.evidence, + server_zone_cached(), + gps_lookup(), + ); + ResolvedDate { + timestamp: prior.wall.and_utc().timestamp(), + source: DateSource::Exif, + date_utc: Some(camera_tz::wall_to_instant(prior.wall, resolution.minutes)), + camera_offset_minutes: Some(resolution.minutes), + tz_source: Some(resolution.source), + } +} + +/// Host zone, resolved once per process. +fn server_zone_cached() -> Option { + static ZONE: OnceLock> = OnceLock::new(); + *ZONE.get_or_init(camera_tz::server_zone) +} + +/// Shared GPS → zone adapter (bundled TZF geometry, loaded on first use). +fn gps_lookup() -> &'static camera_tz::TzfGpsZoneLookup { + static LOOKUP: OnceLock = OnceLock::new(); + LOOKUP.get_or_init(camera_tz::TzfGpsZoneLookup::new) } /// Resolve the canonical date for a single file, given an already-extracted -/// kamadak-exif date if available. Returns `None` only if every step in the -/// waterfall fails — for files that exist on disk this should be vanishingly -/// rare (the fs-time fallback alone almost always succeeds). -pub fn resolve_date_taken(path: &Path, prior_exif_date: Option) -> Option { - if let Some(ts) = prior_exif_date { - return Some(ResolvedDate { - timestamp: ts, - source: DateSource::Exif, - }); +/// kamadak-exif reading if available. Returns `None` only if every step in +/// the waterfall fails — for files that exist on disk this should be +/// vanishingly rare (the fs-time fallback alone almost always succeeds). +pub fn resolve_date_taken(path: &Path, prior_exif: Option<&ExifPrior>) -> Option { + if let Some(prior) = prior_exif { + return Some(exif_prior_resolved(prior)); } if let Some(ts) = exiftool_date_single(path) { return Some(ResolvedDate { timestamp: ts, source: DateSource::Exiftool, + // exiftool's `-d %s` conversion interprets the container date + // in the server's local zone — already a true UTC instant. + date_utc: Some(ts), + camera_offset_minutes: None, + tz_source: Some(camera_tz::TzSource::UtcDirect), }); } if let Some(dt) = path @@ -92,6 +167,12 @@ pub fn resolve_date_taken(path: &Path, prior_exif_date: Option) -> Option) -> Option) -> Option, + prior_exif_priors: &HashMap, ) -> HashMap { let mut out: HashMap = HashMap::new(); let mut needs_exiftool: Vec<&Path> = Vec::with_capacity(paths.len()); for path in paths { - if let Some(&ts) = prior_exif_dates.get(path) { - out.insert( - path.clone(), - ResolvedDate { - timestamp: ts, - source: DateSource::Exif, - }, - ); + if let Some(prior) = prior_exif_priors.get(path) { + out.insert(path.clone(), exif_prior_resolved(prior)); } else { needs_exiftool.push(path.as_path()); } @@ -144,6 +222,9 @@ pub fn resolve_dates_batch( ResolvedDate { timestamp: ts, source: DateSource::Exiftool, + date_utc: Some(ts), + camera_offset_minutes: None, + tz_source: Some(camera_tz::TzSource::UtcDirect), }, ); } @@ -164,6 +245,9 @@ pub fn resolve_dates_batch( ResolvedDate { timestamp: dt.timestamp(), source: DateSource::Filename, + date_utc: None, + camera_offset_minutes: None, + tz_source: None, }, ); continue; @@ -177,6 +261,9 @@ pub fn resolve_dates_batch( ResolvedDate { timestamp: dt.timestamp(), source: DateSource::FsTime, + date_utc: Some(dt.timestamp()), + camera_offset_minutes: None, + tz_source: Some(camera_tz::TzSource::UtcDirect), }, ); } @@ -474,23 +561,85 @@ mod tests { #[test] fn resolve_uses_prior_exif_when_present() { - // Path doesn't need to exist when prior_exif_date short-circuits. + // Path doesn't need to exist when a prior EXIF reading + // short-circuits. Legacy timestamp stays the wall-clock-as-UTC + // value; the new-contract fields come from the cascade. + let prior = ExifPrior { + wall: chrono::DateTime::from_timestamp(1_700_000_000, 0) + .unwrap() + .naive_utc(), + evidence: camera_tz::OffsetEvidence::default(), + }; let resolved = - resolve_date_taken(Path::new("/nonexistent/file.jpg"), Some(1700000000)).unwrap(); - assert_eq!(resolved.timestamp, 1700000000); + resolve_date_taken(Path::new("/nonexistent/file.jpg"), Some(&prior)).unwrap(); + assert_eq!(resolved.timestamp, 1_700_000_000); assert_eq!(resolved.source, DateSource::Exif); + // No offset evidence in the stub-less test env: cascade lands on + // assumed_server (or the local-offset fallback when the host zone + // doesn't parse), and date_utc is always offset-correct. + assert!(resolved.camera_offset_minutes.is_some()); + assert!(resolved.date_utc.is_some()); + assert_eq!( + resolved.date_utc.unwrap(), + resolved.timestamp - (resolved.camera_offset_minutes.unwrap() as i64) * 60 + ); + } + + #[test] + fn resolve_exif_offset_tag_gives_true_instant_and_source() { + // Wall 2024-07-04 12:00:00 with a camera-stamped EDT offset. + // Deterministic: the offset tag short-circuits the cascade before + // GPS / server-zone lookup. + let prior = ExifPrior { + wall: NaiveDateTime::parse_from_str("2024-07-04 12:00:00", "%Y-%m-%d %H:%M:%S") + .unwrap(), + evidence: camera_tz::OffsetEvidence { + offset_tag_minutes: Some(-240), + gps: None, + }, + }; + let resolved = + resolve_date_taken(Path::new("/nonexistent/IMG_0001.jpg"), Some(&prior)).unwrap(); + assert_eq!(resolved.timestamp, 1_720_094_400); // legacy wall-as-UTC + assert_eq!(resolved.date_utc, Some(1_720_108_800)); // 16:00:00Z + assert_eq!(resolved.camera_offset_minutes, Some(-240)); + assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::OffsetTag)); + } + + #[test] + fn resolve_exif_gps_evidence_uses_capture_zone() { + // No offset tag; GPS points at Manhattan. The bundled TZF geometry + // resolves America/New_York, whose offset for the July wall time + // is EDT (-240) — DST-correct for the capture date, not "now". + let prior = ExifPrior { + wall: NaiveDateTime::parse_from_str("2024-07-04 12:00:00", "%Y-%m-%d %H:%M:%S") + .unwrap(), + evidence: camera_tz::OffsetEvidence { + offset_tag_minutes: None, + gps: Some((40.7128, -74.0060)), + }, + }; + let resolved = + resolve_date_taken(Path::new("/nonexistent/IMG_0001.jpg"), Some(&prior)).unwrap(); + assert_eq!(resolved.camera_offset_minutes, Some(-240)); + assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::Gps)); + assert_eq!(resolved.date_utc, Some(1_720_108_800)); } #[test] fn resolve_filename_when_no_exif_and_file_missing() { // No prior EXIF, no exiftool match (file missing), but the filename - // pattern still matches so the resolver lands on Filename. + // pattern still matches so the resolver lands on Filename. The + // filename branch stays unclassified (#116): date_utc is NULL. let resolved = resolve_date_taken( Path::new("/nonexistent/Screenshot_2014-06-01-20-44-50.png"), None, ) .unwrap(); assert_eq!(resolved.source, DateSource::Filename); + assert_eq!(resolved.date_utc, None); + assert_eq!(resolved.camera_offset_minutes, None); + assert_eq!(resolved.tz_source, None); } #[test] @@ -503,5 +652,9 @@ mod tests { // way the file has no EXIF and no filename date, so we should // fall to fs_time. assert_eq!(resolved.source, DateSource::FsTime); + // fs_time is already a true UTC instant — recorded as utc_direct. + assert_eq!(resolved.date_utc, Some(resolved.timestamp)); + assert_eq!(resolved.camera_offset_minutes, None); + assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::UtcDirect)); } } diff --git a/src/exif.rs b/src/exif.rs index 8061a8f..b52c857 100644 --- a/src/exif.rs +++ b/src/exif.rs @@ -25,6 +25,20 @@ pub struct ExifData { pub shutter_speed: Option, pub iso: Option, pub date_taken: Option, + /// Camera offset (local − UTC, minutes) from the EXIF `OffsetTime*` + /// tag paired with the winning `DateTime*` tag, when the camera + /// stamped one. Feeds the offset cascade in `camera_tz`. + pub offset_time_minutes: Option, + // Raw per-tag offsets, collected in the field loop and paired with the + // winning date tag after it. Not part of the wire format. + #[serde(skip)] + offset_time: Option, + #[serde(skip)] + offset_time_original: Option, + #[serde(skip)] + offset_time_digitized: Option, + #[serde(skip)] + date_tag: Option, } /// TIFF-based RAW formats where `JPEGInterchangeFormat` offsets are @@ -331,12 +345,41 @@ pub fn extract_exif_from_path(path: &Path) -> Result { Tag::DateTime | Tag::DateTimeOriginal => { if data.date_taken.is_none() { data.date_taken = parse_exif_datetime(field); + data.date_tag = Some(field.tag); + } + } + Tag::OffsetTime => { + if data.offset_time.is_none() { + data.offset_time = parse_offset_time(field); + } + } + Tag::OffsetTimeOriginal => { + if data.offset_time_original.is_none() { + data.offset_time_original = parse_offset_time(field); + } + } + Tag::OffsetTimeDigitized => { + if data.offset_time_digitized.is_none() { + data.offset_time_digitized = parse_offset_time(field); } } _ => {} } } + // Pair the offset with the date tag that won (DateTimeOriginal pairs + // with OffsetTimeOriginal, etc.); fall back to any stamped offset. + data.offset_time_minutes = match data.date_tag { + Some(Tag::DateTimeOriginal) => data + .offset_time_original + .or(data.offset_time) + .or(data.offset_time_digitized), + _ => data + .offset_time + .or(data.offset_time_original) + .or(data.offset_time_digitized), + }; + // Extract GPS coordinates if let Some(lat) = extract_gps_coordinate(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef) { data.gps_latitude = Some(lat); @@ -465,6 +508,27 @@ fn parse_exif_datetime(field: &exif::Field) -> Option { } } +/// Parse an EXIF `OffsetTime*` value into signed minutes from UTC. +/// +/// EXIF stores `+HH:MM` or `-HH:MM` (seconds are optional and ignored); the +/// special value `Z` means UTC (zero minutes). Returns `None` for anything +/// that isn't a plausible offset (hour > 14, minute > 59, unparseable). +fn parse_offset_time(field: &exif::Field) -> Option { + let s = get_string_value(field)?.trim().to_string(); + if s == "Z" { + return Some(0); + } + let sign: i32 = if s.starts_with('-') { -1 } else { 1 }; + let digits = s.trim_start_matches(['+', '-']); + let mut parts = digits.split(':'); + let hh: i32 = parts.next()?.parse().ok()?; + let mm: i32 = parts.next()?.parse().ok()?; + if hh > 14 || mm > 59 { + return None; + } + Some(sign * (hh * 60 + mm)) +} + fn extract_gps_coordinate(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option { let coord_field = exif.get_field(coord_tag, In::PRIMARY)?; let ref_field = exif.get_field(ref_tag, In::PRIMARY)?; @@ -576,4 +640,43 @@ mod tests { fn test_supports_exif_no_extension() { assert!(!supports_exif(Path::new("test"))); } + + fn offset_field(s: &[u8]) -> exif::Field { + exif::Field { + tag: Tag::OffsetTimeOriginal, + ifd_num: In::PRIMARY, + value: Value::Ascii(vec![s.to_vec()]), + } + } + + #[test] + fn test_parse_offset_time_positive_and_negative() { + assert_eq!(parse_offset_time(&offset_field(b"-04:00")), Some(-240)); + assert_eq!(parse_offset_time(&offset_field(b"+05:30")), Some(330)); + assert_eq!(parse_offset_time(&offset_field(b"+00:00")), Some(0)); + } + + #[test] + fn test_parse_offset_time_with_seconds_and_z() { + assert_eq!(parse_offset_time(&offset_field(b"-04:00:00")), Some(-240)); + assert_eq!(parse_offset_time(&offset_field(b"Z")), Some(0)); + } + + #[test] + fn test_parse_offset_time_rejects_garbage() { + assert_eq!(parse_offset_time(&offset_field(b"garbage")), None); + assert_eq!(parse_offset_time(&offset_field(b"+15:00")), None); + assert_eq!(parse_offset_time(&offset_field(b"+04:60")), None); + assert_eq!(parse_offset_time(&offset_field(b"")), None); + } + + #[test] + fn test_parse_offset_time_missing_tag_is_none() { + let empty = exif::Field { + tag: Tag::OffsetTimeOriginal, + ifd_num: In::PRIMARY, + value: Value::Ascii(Vec::new()), + }; + assert_eq!(parse_offset_time(&empty), None); + } } diff --git a/src/files.rs b/src/files.rs index 920540e..dd74a79 100644 --- a/src/files.rs +++ b/src/files.rs @@ -1281,6 +1281,9 @@ pub async fn list_exif_summary( gps_latitude: r.gps_latitude.map(f64::from), gps_longitude: r.gps_longitude.map(f64::from), date_taken: r.date_taken, + date_utc: r.date_utc, + camera_offset_minutes: r.camera_offset_minutes, + date_taken_tz_source: r.date_taken_tz_source, }) .collect(); @@ -1513,6 +1516,9 @@ mod tests { original_date_taken_source: None, clip_embedding: None, clip_model_version: None, + date_utc: None, + camera_offset_minutes: None, + date_taken_tz_source: None, } } @@ -1554,6 +1560,9 @@ mod tests { original_date_taken_source: None, clip_embedding: None, clip_model_version: None, + date_utc: data.date_utc, + camera_offset_minutes: data.camera_offset_minutes, + date_taken_tz_source: data.date_taken_tz_source.clone(), }) } @@ -1602,6 +1611,9 @@ mod tests { original_date_taken_source: None, clip_embedding: None, clip_model_version: None, + date_utc: data.date_utc, + camera_offset_minutes: data.camera_offset_minutes, + date_taken_tz_source: data.date_taken_tz_source.clone(), }) } @@ -1726,6 +1738,9 @@ mod tests { _rel_path: &str, _date_taken: i64, _source: &str, + _date_utc: Option, + _camera_offset_minutes: Option, + _date_taken_tz_source: Option<&str>, ) -> Result<(), DbError> { Ok(()) } diff --git a/src/handlers/image.rs b/src/handlers/image.rs index 923fff3..b8801f3 100644 --- a/src/handlers/image.rs +++ b/src/handlers/image.rs @@ -642,7 +642,10 @@ pub async fn set_image_gps( // writing GPS doesn't change the capture date, but if the row was // previously sourced from `fs_time` the re-read may have given us a // real EXIF date this time, and we want to upgrade the source. - let resolved_date = date_resolver::resolve_date_taken(&full_path, extracted.date_taken); + let resolved_date = date_resolver::resolve_date_taken( + &full_path, + date_resolver::exif_prior_from(&extracted).as_ref(), + ); let insert_exif = InsertImageExif { library_id: resolved_library.id, file_path: normalized_path.clone(), @@ -678,6 +681,11 @@ pub async fn set_image_gps( phash_64: perceptual_hash::compute(&full_path).map(|h| h.phash_64), dhash_64: perceptual_hash::compute(&full_path).map(|h| h.dhash_64), date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()), + date_utc: resolved_date.and_then(|r| r.date_utc), + camera_offset_minutes: resolved_date.and_then(|r| r.camera_offset_minutes), + date_taken_tz_source: resolved_date + .and_then(|r| r.tz_source) + .map(|t| t.as_str().to_string()), }; let updated = { @@ -1143,8 +1151,10 @@ pub async fn upload_image( } }; let perceptual = perceptual_hash::compute(&uploaded_path); - let resolved_date = - date_resolver::resolve_date_taken(&uploaded_path, exif_data.date_taken); + let resolved_date = date_resolver::resolve_date_taken( + &uploaded_path, + date_resolver::exif_prior_from(&exif_data).as_ref(), + ); let insert_exif = InsertImageExif { library_id: target_library.id, file_path: relative_path.clone(), @@ -1169,6 +1179,12 @@ pub async fn upload_image( phash_64: perceptual.map(|h| h.phash_64), dhash_64: perceptual.map(|h| h.dhash_64), date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()), + date_utc: resolved_date.and_then(|r| r.date_utc), + camera_offset_minutes: resolved_date + .and_then(|r| r.camera_offset_minutes), + date_taken_tz_source: resolved_date + .and_then(|r| r.tz_source) + .map(|t| t.as_str().to_string()), }; if let Ok(mut dao) = exif_dao.lock() { diff --git a/src/lib.rs b/src/lib.rs index a228472..cc717de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ extern crate diesel; pub mod ai; pub mod auth; pub mod bin_progress; +pub mod camera_tz; pub mod cleanup; pub mod clip_search; pub mod clip_watch; diff --git a/src/main.rs b/src/main.rs index 4ad8714..b3aee61 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,10 @@ use log::{error, info}; mod ai; mod auth; mod backfill; +// Display-zone helpers land in the read-side cutover (#119/#120); the +// binary's private copy of this module doesn't use them yet. +#[allow(dead_code)] +mod camera_tz; mod clip_search; mod clip_watch; mod content_hash; diff --git a/src/reels/mod.rs b/src/reels/mod.rs index afe2ced..2d20510 100644 --- a/src/reels/mod.rs +++ b/src/reels/mod.rs @@ -103,6 +103,7 @@ fn capture_prefs( ), library: Some(library), updated_at: now as i64, + display_tz: req.display_tz.clone().filter(|s| !s.is_empty()), }, ) .map_err(|e| anyhow::anyhow!("failed to upsert user_ai_prefs: {e}")) @@ -351,6 +352,12 @@ pub struct CreateReelRequest { pub span: Option, #[serde(default)] pub timezone_offset_minutes: Option, + /// User's display time zone as an IANA name (DST-proof; e.g. + /// "America/New_York"). Mirrored into `user_ai_prefs.display_tz`. + /// When omitted, the stored display zone is unchanged (NULL = app + /// default). The legacy offset above is still mirrored for rollback. + #[serde(default)] + pub display_tz: Option, #[serde(default)] pub library: Option, /// Cloned TTS voice for the narration; server default when omitted. diff --git a/src/watcher.rs b/src/watcher.rs index 6d2a727..28d8390 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -787,10 +787,16 @@ pub fn process_new_files( // computed above) → exiftool fallback for videos / MakerNote / // QuickTime → filename regex → earliest_fs_time. Source is // recorded so the per-tick backfill drain can re-run weak - // resolutions later. + // resolutions later. The EXIF branch additionally runs the + // camera-offset cascade to produce the new-contract columns + // (date_utc / camera_offset_minutes / date_taken_tz_source, + // ADR-0001) while the legacy date_taken stays byte-identical. let resolved_date = date_resolver::resolve_date_taken( &file_path, - exif_fields.as_ref().and_then(|e| e.date_taken), + exif_fields + .as_ref() + .and_then(date_resolver::exif_prior_from) + .as_ref(), ); let insert_exif = InsertImageExif { @@ -827,6 +833,11 @@ pub fn process_new_files( phash_64: perceptual.map(|h| h.phash_64), dhash_64: perceptual.map(|h| h.dhash_64), date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()), + date_utc: resolved_date.and_then(|r| r.date_utc), + camera_offset_minutes: resolved_date.and_then(|r| r.camera_offset_minutes), + date_taken_tz_source: resolved_date + .and_then(|r| r.tz_source) + .map(|t| t.as_str().to_string()), }; let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");