The exiftool branch of the date waterfall now pulls raw strings with group prefixes (one `exiftool -j -q -G1` subprocess per batch, no `-d %s`) and classifies each candidate: - zoned strings (own offset / Z) are true instants; - naive strings from container groups (QuickTime/Track*/Apple) are UTC-rendered creation_time fields -> true instants (utc_direct). Running them through the camera wall-clock cascade was the "videos off by 4-5 hours" bug: it reinterpreted a UTC instant in the server zone; - naive strings from photo-EXIF groups are camera wall clocks and run the offset cascade (OffsetTime* pairing + GPS evidence pulled in the same exiftool pass). The legacy date_taken column stays byte-identical to the old server-local interpretation (reproduced in Rust, DST-aware); the new-contract date_utc / camera_offset_minutes / date_taken_tz_source columns carry the true instant. Manual date edit (POST /image/exif/date) reinterprets the naive-as-UTC epoch the client sent as a wall clock in the user's display zone (camera_tz::manual_date_resolution, tz_source = 'manual'), snapshots the pre-override new-contract triple next to the existing legacy snapshot pair (3 new columns, migration 2026-09-25-000000), and revert restores both pairs. ADR-0001 records the container-date finding; CLAUDE.md date-pipeline section updated.
483 lines
18 KiB
Rust
483 lines
18 KiB
Rust
//! 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<i32>,
|
||
/// `(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<Tz>;
|
||
}
|
||
|
||
/// Production [`GpsZoneLookup`]: bundled lite TZF geometry. No network,
|
||
/// ~4 MB, loaded once per process on first use.
|
||
pub struct TzfGpsZoneLookup {
|
||
finder: OnceLock<tzf_rs::DefaultFinder>,
|
||
}
|
||
|
||
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<Tz> {
|
||
// 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<Tz>,
|
||
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<Tz> {
|
||
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<i32> {
|
||
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<Tz>);
|
||
|
||
impl GpsZoneLookup for StubLookup {
|
||
fn zone_at(&self, _lat: f64, _lon: f64) -> Option<Tz> {
|
||
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();
|
||
}
|
||
}
|