feat(dates): true_instant + wall_clock on /photos/exif (#113) #161
No files matched your search
+76
-15
@@ -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
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user