feat(dates): foundation for the true-instant date contract (#114)

Populate the new-contract date columns at ingest while keeping the
legacy date_taken column byte-identical (ADR-0001):

- camera_tz module: offset cascade (offset tag -> GPS zone -> server
  zone) as a pure, testable function; bundled offline TZF geometry for
  GPS -> IANA resolution; DST-correct offsets for the capture date;
  display-zone helpers (DEFAULT_DISPLAY_TZ, display_zone_for,
  display_zone_offset_minutes) for the read-side cutover.
- EXIF branch: kamadak-exif now collects OffsetTime* tags paired with
  the winning date tag; resolve_date_taken takes an ExifPrior and
  emits (date_utc, camera_offset_minutes, date_taken_tz_source) per
  branch: exif -> cascade, exiftool/fs_time -> utc_direct, filename
  -> unclassified (pending #116).
- Schema: migration adds image_exif.date_utc /
  camera_offset_minutes / date_taken_tz_source and
  user_ai_prefs.display_tz (all nullable; down.sql drops them).
- DAO + ingest: backfill_date_taken persists the triple alongside the
  legacy pair; watcher, upload, and set_image_gps call sites feed the
  cascade.
- Response surface: ExifMetadata and ExifSummary carry the triple so
  /image/metadata and /photos/exif expose it (null for unconverted
  rows); reel requests mirror display_tz into prefs.

cargo build + clippy clean; 625 lib + 702 bin tests pass (the one
failing doctest in utils.rs is pre-existing at the base commit).
This commit is contained in:
Cameron Cordes committed 2026-09-24 21:58:46 -04:00
1 parent ee773a34ef
commit 2f2107d4fa
20 files changed
+1039 -36

No files matched your search

+176 -23
View File
@@ -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<i64>,
/// Camera offset (minutes) that produced `date_utc`; `None` when the
/// branch is a direct true instant or unclassified.
pub camera_offset_minutes: Option<i32>,
/// Why `date_utc` has the offset it does. `None` when unclassified.
pub tz_source: Option<camera_tz::TzSource>,
}
/// 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<ExifPrior> {
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<chrono_tz::Tz> {
static ZONE: OnceLock<Option<chrono_tz::Tz>> = 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<camera_tz::TzfGpsZoneLookup> = 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<i64>) -> Option<ResolvedDate> {
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<ResolvedDate> {
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<i64>) -> Option<R
return Some(ResolvedDate {
timestamp: dt.timestamp(),
source: DateSource::Filename,
// Filename conventions (wall-clock vs UTC) are decided
// per-pattern by the filename date-class work (#116); until
// then these rows stay unclassified.
date_utc: None,
camera_offset_minutes: None,
tz_source: None,
});
}
if let Ok(meta) = std::fs::metadata(path)
@@ -101,6 +182,9 @@ pub fn resolve_date_taken(path: &Path, prior_exif_date: Option<i64>) -> Option<R
return Some(ResolvedDate {
timestamp: dt.timestamp(),
source: DateSource::FsTime,
date_utc: Some(dt.timestamp()),
camera_offset_minutes: None,
tz_source: Some(camera_tz::TzSource::UtcDirect),
});
}
None
@@ -116,20 +200,14 @@ pub fn resolve_date_taken(path: &Path, prior_exif_date: Option<i64>) -> Option<R
/// at a time and uses one exiftool subprocess to drain the lot.
pub fn resolve_dates_batch(
paths: &[PathBuf],
prior_exif_dates: &HashMap<PathBuf, i64>,
prior_exif_priors: &HashMap<PathBuf, ExifPrior>,
) -> HashMap<PathBuf, ResolvedDate> {
let mut out: HashMap<PathBuf, ResolvedDate> = 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));
}
}