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:
1 parent
ee773a34ef
commit
2f2107d4fa
20 files changed
+1039
-36
No files matched your search
+176
-23
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user