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

+103
View File
@@ -25,6 +25,20 @@ pub struct ExifData {
pub shutter_speed: Option<String>,
pub iso: Option<i32>,
pub date_taken: Option<i64>,
/// Camera offset (local − UTC, minutes) from the EXIF `OffsetTime*`
/// tag paired with the winning `DateTime*` tag, when the camera
/// stamped one. Feeds the offset cascade in `camera_tz`.
pub offset_time_minutes: Option<i32>,
// Raw per-tag offsets, collected in the field loop and paired with the
// winning date tag after it. Not part of the wire format.
#[serde(skip)]
offset_time: Option<i32>,
#[serde(skip)]
offset_time_original: Option<i32>,
#[serde(skip)]
offset_time_digitized: Option<i32>,
#[serde(skip)]
date_tag: Option<Tag>,
}
/// TIFF-based RAW formats where `JPEGInterchangeFormat` offsets are
@@ -331,12 +345,41 @@ pub fn extract_exif_from_path(path: &Path) -> Result<ExifData> {
Tag::DateTime | Tag::DateTimeOriginal => {
if data.date_taken.is_none() {
data.date_taken = parse_exif_datetime(field);
data.date_tag = Some(field.tag);
}
}
Tag::OffsetTime => {
if data.offset_time.is_none() {
data.offset_time = parse_offset_time(field);
}
}
Tag::OffsetTimeOriginal => {
if data.offset_time_original.is_none() {
data.offset_time_original = parse_offset_time(field);
}
}
Tag::OffsetTimeDigitized => {
if data.offset_time_digitized.is_none() {
data.offset_time_digitized = parse_offset_time(field);
}
}
_ => {}
}
}
// Pair the offset with the date tag that won (DateTimeOriginal pairs
// with OffsetTimeOriginal, etc.); fall back to any stamped offset.
data.offset_time_minutes = match data.date_tag {
Some(Tag::DateTimeOriginal) => data
.offset_time_original
.or(data.offset_time)
.or(data.offset_time_digitized),
_ => data
.offset_time
.or(data.offset_time_original)
.or(data.offset_time_digitized),
};
// Extract GPS coordinates
if let Some(lat) = extract_gps_coordinate(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef) {
data.gps_latitude = Some(lat);
@@ -465,6 +508,27 @@ fn parse_exif_datetime(field: &exif::Field) -> Option<i64> {
}
}
/// Parse an EXIF `OffsetTime*` value into signed minutes from UTC.
///
/// EXIF stores `+HH:MM` or `-HH:MM` (seconds are optional and ignored); the
/// special value `Z` means UTC (zero minutes). Returns `None` for anything
/// that isn't a plausible offset (hour > 14, minute > 59, unparseable).
fn parse_offset_time(field: &exif::Field) -> Option<i32> {
let s = get_string_value(field)?.trim().to_string();
if s == "Z" {
return Some(0);
}
let sign: i32 = if s.starts_with('-') { -1 } else { 1 };
let digits = s.trim_start_matches(['+', '-']);
let mut parts = digits.split(':');
let hh: i32 = parts.next()?.parse().ok()?;
let mm: i32 = parts.next()?.parse().ok()?;
if hh > 14 || mm > 59 {
return None;
}
Some(sign * (hh * 60 + mm))
}
fn extract_gps_coordinate(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option<f64> {
let coord_field = exif.get_field(coord_tag, In::PRIMARY)?;
let ref_field = exif.get_field(ref_tag, In::PRIMARY)?;
@@ -576,4 +640,43 @@ mod tests {
fn test_supports_exif_no_extension() {
assert!(!supports_exif(Path::new("test")));
}
fn offset_field(s: &[u8]) -> exif::Field {
exif::Field {
tag: Tag::OffsetTimeOriginal,
ifd_num: In::PRIMARY,
value: Value::Ascii(vec![s.to_vec()]),
}
}
#[test]
fn test_parse_offset_time_positive_and_negative() {
assert_eq!(parse_offset_time(&offset_field(b"-04:00")), Some(-240));
assert_eq!(parse_offset_time(&offset_field(b"+05:30")), Some(330));
assert_eq!(parse_offset_time(&offset_field(b"+00:00")), Some(0));
}
#[test]
fn test_parse_offset_time_with_seconds_and_z() {
assert_eq!(parse_offset_time(&offset_field(b"-04:00:00")), Some(-240));
assert_eq!(parse_offset_time(&offset_field(b"Z")), Some(0));
}
#[test]
fn test_parse_offset_time_rejects_garbage() {
assert_eq!(parse_offset_time(&offset_field(b"garbage")), None);
assert_eq!(parse_offset_time(&offset_field(b"+15:00")), None);
assert_eq!(parse_offset_time(&offset_field(b"+04:60")), None);
assert_eq!(parse_offset_time(&offset_field(b"")), None);
}
#[test]
fn test_parse_offset_time_missing_tag_is_none() {
let empty = exif::Field {
tag: Tag::OffsetTimeOriginal,
ifd_num: In::PRIMARY,
value: Value::Ascii(Vec::new()),
};
assert_eq!(parse_offset_time(&empty), None);
}
}