From f1a7fb4ecc664c564d08616353cc99fa0ef63c7a Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sat, 10 Oct 2026 21:15:41 -0400 Subject: [PATCH] feat(dates): true_instant + wall_clock on /photos/exif (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /photos/exif is Apollo's photo-to-track matching feed, but unlike /image/metadata and /photos/gps-summary it only carried the raw new-contract triple. date_utc is NULL for unconverted rows and, by design, forever for the drain's edited/exported floor (#120), so a client reading date_utc alone would have to re-implement the per-source legacy conventions to cover those rows: the re-anchoring ADR-0001 says consumers delete, not migrate. ExifSummary now carries: - true_instant: date_view::row_true_instant (date_utc when converted, else the per-source fallback), for instant-to-instant matching. - wall_clock: date_view::row_wall_clock, rendered verbatim. The row → wire mapping moves to ExifSummary::from_row so the tests cover the real projection, including an unconverted row falling back to the same instant and wall as its converted twin. Additive: legacy fields are unchanged, so current clients see the same values. The date_from/date_to filter still runs on the legacy column; that's #152, which now explicitly covers /photos/exif too. Unblocks Apollo #37. Co-Authored-By: Claude Opus 5.5 --- src/data/mod.rs | 91 +++++++++++++++++++++++++++++++++++++++++-------- src/files.rs | 24 +++++-------- 2 files changed, 84 insertions(+), 31 deletions(-) diff --git a/src/data/mod.rs b/src/data/mod.rs index 5d138ba..b208e3e 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -479,6 +479,47 @@ pub struct ExifSummary { pub camera_offset_minutes: Option, /// How the offset was determined. See `camera_tz::TzSource`. pub date_taken_tz_source: Option, + /// True instant of capture (unix seconds) — `date_view::row_true_instant`: + /// `date_utc` when converted, else the legacy value under its per-source + /// convention, so unconverted and permanent-floor rows still match + /// instant-to-instant. For matching (location track, SMS windows); never + /// render it, render `wall_clock`. + pub true_instant: Option, + /// Capture wall clock to display (naive ISO-8601; see + /// `MetadataResponse::wall_clock`). + pub wall_clock: Option, +} + +impl ExifSummary { + /// Project an `image_exif` row onto the `/photos/exif` wire shape. + /// `true_instant` / `wall_clock` go through the same `date_view` reads + /// as `/image/metadata` and `/photos/gps-summary`, so all three agree. + pub fn from_row( + r: ImageExif, + library_name: Option, + display_zone: chrono_tz::Tz, + server_zone: Option, + ) -> Self { + ExifSummary { + true_instant: crate::date_view::row_true_instant(&r, display_zone, server_zone), + wall_clock: crate::date_view::row_wall_clock(&r, display_zone, server_zone), + library_name, + file_path: r.file_path, + library_id: r.library_id, + camera_model: r.camera_model, + width: r.width, + height: r.height, + // image_exif stores GPS as f32 to keep row size small; widen + // for the JSON shape so clients don't need to know about the + // on-disk precision. + gps_latitude: r.gps_latitude.map(f64::from), + gps_longitude: r.gps_longitude.map(f64::from), + date_taken: r.date_taken, + date_utc: r.date_utc, + camera_offset_minutes: r.camera_offset_minutes, + date_taken_tz_source: r.date_taken_tz_source, + } + } } #[derive(Debug, Serialize)] @@ -674,27 +715,47 @@ mod date_contract_tests { } /// /photos/exif contract: Apollo's per-photo summary carries the same - /// fields so the viewer flip (spec #113) can read one shape. + /// fields so the viewer flip (spec #113) can read one shape, plus the + /// server-computed `true_instant` / `wall_clock` it matches and renders. #[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()), - }; + let summary = ExifSummary::from_row( + sample_row(), + Some("main".to_string()), + chrono_tz::America::New_York, + Some(chrono_tz::America::New_York), + ); // Verify the JSON wire names clients (Apollo, FVR) will consume. let json = serde_json::to_value(&summary).unwrap(); + assert_eq!(json["date_taken"], 1_720_094_400); 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"); + assert_eq!(json["true_instant"], 1_720_108_800); + assert_eq!(json["wall_clock"], "2024-07-04T12:00:00"); + assert_eq!(json["library_name"], "main"); + } + + /// A row the drain hasn't converted (or never will — the edited/exported + /// floor) has no `date_utc`, but `/photos/exif` must still hand clients + /// a true instant and wall clock via the per-source fallback, so they + /// never re-derive the legacy convention themselves (ADR-0001). + #[test] + fn exif_summary_unconverted_row_falls_back_to_legacy_convention() { + let mut row = sample_row(); + row.date_utc = None; + row.camera_offset_minutes = None; + row.date_taken_tz_source = None; + let summary = ExifSummary::from_row( + row, + None, + chrono_tz::America::New_York, + Some(chrono_tz::America::New_York), + ); + assert_eq!(summary.date_utc, None); + // exif legacy = 12:00 wall as-UTC; read in the server zone (EDT) + // that's 16:00 UTC — the same instant the converted row names. + assert_eq!(summary.true_instant, Some(1_720_108_800)); + assert_eq!(summary.wall_clock.as_deref(), Some("2024-07-04T12:00:00")); } } diff --git a/src/files.rs b/src/files.rs index 52be2ce..774c656 100644 --- a/src/files.rs +++ b/src/files.rs @@ -1216,6 +1216,11 @@ pub async fn list_exif_summary( .map(|lib| (lib.id, lib.name.clone())) .collect(); + // Resolved before taking the DAO mutex so the prefs lock isn't held + // nested inside it. + let display_zone = crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao); + let server_zone = crate::camera_tz::server_zone(); + let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao"); match exif_dao_guard.query_by_exif( library_filter, @@ -1229,22 +1234,9 @@ pub async fn list_exif_summary( Ok(rows) => { let photos: Vec = rows .into_iter() - .map(|r| ExifSummary { - library_name: library_names.get(&r.library_id).cloned(), - file_path: r.file_path, - library_id: r.library_id, - camera_model: r.camera_model, - width: r.width, - height: r.height, - // image_exif stores GPS as f32 to keep row size small; - // widen for the JSON shape so clients don't need to - // know about the on-disk precision. - gps_latitude: r.gps_latitude.map(f64::from), - gps_longitude: r.gps_longitude.map(f64::from), - date_taken: r.date_taken, - date_utc: r.date_utc, - camera_offset_minutes: r.camera_offset_minutes, - date_taken_tz_source: r.date_taken_tz_source, + .map(|r| { + let name = library_names.get(&r.library_id).cloned(); + ExifSummary::from_row(r, name, display_zone, server_zone) }) .collect(); -- 2.54.0