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

+19 -3
View File
@@ -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() {