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>,
|
pub camera_offset_minutes: Option<i32>,
|
||||||
/// How the offset was determined. See `camera_tz::TzSource`.
|
/// How the offset was determined. See `camera_tz::TzSource`.
|
||||||
pub date_taken_tz_source: Option<String>,
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -674,27 +715,47 @@ mod date_contract_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// /photos/exif contract: Apollo's per-photo summary carries the same
|
/// /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]
|
#[test]
|
||||||
fn exif_summary_shape_includes_date_contract_fields() {
|
fn exif_summary_shape_includes_date_contract_fields() {
|
||||||
let summary = ExifSummary {
|
let summary = ExifSummary::from_row(
|
||||||
file_path: "2024/vacation/IMG_0001.jpg".to_string(),
|
sample_row(),
|
||||||
library_id: 1,
|
Some("main".to_string()),
|
||||||
library_name: Some("main".to_string()),
|
chrono_tz::America::New_York,
|
||||||
camera_model: Some("NIKON Z 8".to_string()),
|
Some(chrono_tz::America::New_York),
|
||||||
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.
|
// Verify the JSON wire names clients (Apollo, FVR) will consume.
|
||||||
let json = serde_json::to_value(&summary).unwrap();
|
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["date_utc"], 1_720_108_800);
|
||||||
assert_eq!(json["camera_offset_minutes"], -240);
|
assert_eq!(json["camera_offset_minutes"], -240);
|
||||||
assert_eq!(json["date_taken_tz_source"], "offset_tag");
|
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()))
|
.map(|lib| (lib.id, lib.name.clone()))
|
||||||
.collect();
|
.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");
|
let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao");
|
||||||
match exif_dao_guard.query_by_exif(
|
match exif_dao_guard.query_by_exif(
|
||||||
library_filter,
|
library_filter,
|
||||||
@@ -1229,22 +1234,9 @@ pub async fn list_exif_summary(
|
|||||||
Ok(rows) => {
|
Ok(rows) => {
|
||||||
let photos: Vec<ExifSummary> = rows
|
let photos: Vec<ExifSummary> = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| ExifSummary {
|
.map(|r| {
|
||||||
library_name: library_names.get(&r.library_id).cloned(),
|
let name = library_names.get(&r.library_id).cloned();
|
||||||
file_path: r.file_path,
|
ExifSummary::from_row(r, name, display_zone, server_zone)
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user