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
@@ -292,6 +292,15 @@ pub struct ExifMetadata {
|
||||
/// `"exif" | "exiftool" | "filename" | "fs_time" | "manual"`. NULL when
|
||||
/// `date_taken` itself is NULL.
|
||||
pub date_taken_source: Option<String>,
|
||||
/// True UTC instant under the new date contract (ADR-0001). NULL while
|
||||
/// the row is still on the legacy convention (drain pending, or the
|
||||
/// unclassified filename branch).
|
||||
pub date_utc: Option<i64>,
|
||||
/// Camera offset (local − UTC, minutes) that produced `date_utc`.
|
||||
pub camera_offset_minutes: Option<i32>,
|
||||
/// Why the offset is what it is: `offset_tag` | `gps` |
|
||||
/// `assumed_server` | `utc_direct` | `manual`.
|
||||
pub date_taken_tz_source: Option<String>,
|
||||
/// When `date_taken_source = "manual"`, the prior `date_taken` snapshot.
|
||||
/// Used by the UI's revert affordance and to label "manually overridden;
|
||||
/// originally X" in the details modal.
|
||||
@@ -383,6 +392,9 @@ impl From<ImageExif> for ExifMetadata {
|
||||
},
|
||||
date_taken: exif.date_taken,
|
||||
date_taken_source: exif.date_taken_source,
|
||||
date_utc: exif.date_utc,
|
||||
camera_offset_minutes: exif.camera_offset_minutes,
|
||||
date_taken_tz_source: exif.date_taken_tz_source,
|
||||
original_date_taken: exif.original_date_taken,
|
||||
original_date_taken_source: exif.original_date_taken_source,
|
||||
}
|
||||
@@ -428,6 +440,13 @@ pub struct ExifSummary {
|
||||
pub gps_latitude: Option<f64>,
|
||||
pub gps_longitude: Option<f64>,
|
||||
pub date_taken: Option<i64>,
|
||||
/// True UTC instant under the new date contract (ADR-0001). NULL while
|
||||
/// the row is still on the legacy convention.
|
||||
pub date_utc: Option<i64>,
|
||||
/// Camera offset (local − UTC, minutes) that produced `date_utc`.
|
||||
pub camera_offset_minutes: Option<i32>,
|
||||
/// How the offset was determined. See `camera_tz::TzSource`.
|
||||
pub date_taken_tz_source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -564,3 +583,83 @@ mod tests {
|
||||
assert!(previews[1].get("preview_url").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod date_contract_tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_row() -> crate::database::models::ImageExif {
|
||||
crate::database::models::ImageExif {
|
||||
id: 1,
|
||||
library_id: 1,
|
||||
file_path: "2024/vacation/IMG_0001.jpg".to_string(),
|
||||
camera_make: None,
|
||||
camera_model: Some("NIKON Z 8".to_string()),
|
||||
lens_model: None,
|
||||
width: None,
|
||||
height: None,
|
||||
orientation: None,
|
||||
gps_latitude: None,
|
||||
gps_longitude: None,
|
||||
gps_altitude: None,
|
||||
focal_length: None,
|
||||
aperture: None,
|
||||
shutter_speed: None,
|
||||
iso: None,
|
||||
// Wall 2024-07-04 12:00:00 stored legacy-style (as-UTC).
|
||||
date_taken: Some(1_720_094_400),
|
||||
created_time: 0,
|
||||
last_modified: 0,
|
||||
content_hash: None,
|
||||
size_bytes: None,
|
||||
phash_64: None,
|
||||
dhash_64: None,
|
||||
duplicate_of_hash: None,
|
||||
duplicate_decided_at: None,
|
||||
date_taken_source: Some("exif".to_string()),
|
||||
original_date_taken: None,
|
||||
original_date_taken_source: None,
|
||||
clip_embedding: None,
|
||||
clip_model_version: None,
|
||||
date_utc: Some(1_720_108_800),
|
||||
camera_offset_minutes: Some(-240),
|
||||
date_taken_tz_source: Some("offset_tag".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// /image/metadata contract: the new-contract date fields ride along on
|
||||
/// the EXIF metadata payload alongside the legacy `date_taken`.
|
||||
#[test]
|
||||
fn exif_metadata_response_includes_date_contract_fields() {
|
||||
let meta = ExifMetadata::from(sample_row());
|
||||
assert_eq!(meta.date_taken, Some(1_720_094_400));
|
||||
assert_eq!(meta.date_utc, Some(1_720_108_800));
|
||||
assert_eq!(meta.camera_offset_minutes, Some(-240));
|
||||
assert_eq!(meta.date_taken_tz_source.as_deref(), Some("offset_tag"));
|
||||
}
|
||||
|
||||
/// /photos/exif contract: Apollo's per-photo summary carries the same
|
||||
/// fields so the viewer flip (spec #113) can read one shape.
|
||||
#[test]
|
||||
fn exif_summary_shape_includes_date_contract_fields() {
|
||||
let summary = ExifSummary {
|
||||
file_path: "2024/vacation/IMG_0001.jpg".to_string(),
|
||||
library_id: 1,
|
||||
library_name: Some("main".to_string()),
|
||||
camera_model: Some("NIKON Z 8".to_string()),
|
||||
width: None,
|
||||
height: None,
|
||||
gps_latitude: None,
|
||||
gps_longitude: None,
|
||||
date_taken: Some(1_720_094_400),
|
||||
date_utc: Some(1_720_108_800),
|
||||
camera_offset_minutes: Some(-240),
|
||||
date_taken_tz_source: Some("offset_tag".to_string()),
|
||||
};
|
||||
// Verify the JSON wire names clients (Apollo, FVR) will consume.
|
||||
let json = serde_json::to_value(&summary).unwrap();
|
||||
assert_eq!(json["date_utc"], 1_720_108_800);
|
||||
assert_eq!(json["camera_offset_minutes"], -240);
|
||||
assert_eq!(json["date_taken_tz_source"], "offset_tag");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user