feat(dates): true_instant + wall_clock on /photos/exif (#113) #161

Merged
cameron merged 1 commits from feature/exif-summary-instant-fields into master 2026-10-11 01:42:47 +00:00
2 changed files with 84 additions and 31 deletions

No files matched your search

+76 -15
View File
@@ -479,6 +479,47 @@ pub struct ExifSummary {
pub camera_offset_minutes: Option<i32>,
/// How the offset was determined. See `camera_tz::TzSource`.
pub date_taken_tz_source: Option<String>,
/// 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<i64>,
/// Capture wall clock to display (naive ISO-8601; see
/// `MetadataResponse::wall_clock`).
pub wall_clock: Option<String>,
}
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<String>,
display_zone: chrono_tz::Tz,
server_zone: Option<chrono_tz::Tz>,
) -> 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"));
}
}
+8 -16
View File
@@ -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<ExifSummary> = 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();