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

+40 -7
View File
@@ -495,10 +495,12 @@ pub trait ExifDao: Sync + Send {
limit: i64,
) -> Result<Vec<(i32, String)>, 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<i64>,
camera_offset_minutes: Option<i32>,
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<i64>,
camera_offset_minutes_val: Option<i32>,
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));