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
+103
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user