diff --git a/dev/e2e/check_wall_clock.py b/dev/e2e/check_wall_clock.py index 1efd5ca..989406e 100644 --- a/dev/e2e/check_wall_clock.py +++ b/dev/e2e/check_wall_clock.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -"""Regression check for the displayed capture time (ADR-0001 `wall_clock`). +"""Regression check for capture dates (ADR-0001 `wall_clock` / `true_instant`). For every indexed fixture, the manifest's expected wall clock must equal the `wall_clock` the server reports on each endpoint that includes the file: -`/image/metadata`, `/memories` (today's day span) and `/photos/gps-summary`. +`/image/metadata`, `/memories` (today's day span) and `/photos/gps-summary`; +and `/image/metadata`'s `true_instant` must equal the expected instant. One disagreement anywhere is exactly the class of bug this field exists to end, so any mismatch fails the run. @@ -35,17 +36,21 @@ def main() -> int: failures = 0 for item in items: path, want = item["path"], item["wall_clock"] - got = {"metadata": api("image/metadata?path=" + urllib.parse.quote(path)).get("wall_clock")} + meta = api("image/metadata?path=" + urllib.parse.quote(path)) + got = {"metadata": meta.get("wall_clock")} if path in memories: got["memories"] = memories[path] if path in gps: got["gps-summary"] = gps[path] bad = {k: v for k, v in got.items() if v != want} + # The matching anchor (SMS / location windows) must be the true instant. + if meta.get("true_instant") != item["true_instant"]: + bad["metadata true_instant"] = (meta.get("true_instant"), "expected", item["true_instant"]) if bad: failures += 1 print(f"MISMATCH {path}: expected {want}, got {bad}") - print(f"{len(items) - failures}/{len(items)} fixtures report the expected wall clock " + print(f"{len(items) - failures}/{len(items)} fixtures report the expected wall clock + instant " f"({len(memories)} in memories, {len(gps)} in gps-summary)") return 1 if failures else 0 diff --git a/dev/e2e/gen_fixtures.py b/dev/e2e/gen_fixtures.py index 765a0d6..bb2b624 100644 --- a/dev/e2e/gen_fixtures.py +++ b/dev/e2e/gen_fixtures.py @@ -47,6 +47,15 @@ PLACES = { "tokyo": (35.6595, 139.7005), } +# IANA zone of each place — what the server's GPS rung of the offset cascade +# resolves, so the expected true instant is the wall read in this zone. +PLACE_ZONES = { + "santa_monica": "America/Los_Angeles", + "paris": "Europe/Paris", + "yosemite": "America/Los_Angeles", + "tokyo": "Asia/Tokyo", +} + PALETTE = [ "0x1f4e79", "0x7a3e9d", "0x2e7d32", "0xb71c1c", "0xe65100", "0x006064", "0x4e342e", "0x283593", "0x827717", "0xad1457", @@ -182,6 +191,15 @@ def expected_wall_clock(it: Item) -> str: return wall.strftime("%Y-%m-%dT%H:%M:%S") +def expected_true_instant(it: Item) -> int: + """The true instant the server should report: the expected wall read in + the zone the camera was in (GPS place, else HOME_TZ — the server's + assumed zone). Filename walls outrank EXIF, but still take GPS evidence.""" + wall = dt.datetime.fromisoformat(expected_wall_clock(it)) + zone = ZoneInfo(PLACE_ZONES[it.gps]) if it.gps else HOME_TZ + return int(wall.replace(tzinfo=zone).timestamp()) + + def _same_day(today: dt.date, year: int, hour: int) -> dt.datetime: # Feb 29 in a non-leap year would be invalid; fall back to Feb 28. try: @@ -332,6 +350,7 @@ def main() -> int: "tags": it.tags, "duplicate_of": it.copy_of, # A duplicate carries its source's bytes, EXIF included. "wall_clock": expected_wall_clock(by_path.get(it.copy_of, it)), + "true_instant": expected_true_instant(by_path.get(it.copy_of, it)), } for it in items ], diff --git a/src/data/mod.rs b/src/data/mod.rs index 79ccb7c..5d138ba 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -263,6 +263,10 @@ pub struct MetadataResponse { /// (`2018-09-29T14:00:00`) — `date_view::file_wall_clock`. Render it /// verbatim; never zone-convert it (ADR-0001). pub wall_clock: Option, + /// True instant of capture (unix seconds) — `date_view::file_true_instant`. + /// For instant-to-instant matching (SMS / location windows); never + /// render it, render `wall_clock`. + pub true_instant: Option, } impl From for MetadataResponse { @@ -281,7 +285,8 @@ impl From for MetadataResponse { filename_date: None, // Will be set in endpoint handler library_id: None, library_name: None, - wall_clock: None, // Set in the endpoint handler + wall_clock: None, // Set in the endpoint handler + true_instant: None, // Set in the endpoint handler } } } diff --git a/src/date_view.rs b/src/date_view.rs index 537bd0d..40eb9ab 100644 --- a/src/date_view.rs +++ b/src/date_view.rs @@ -242,6 +242,26 @@ pub fn file_wall_clock( wall.map(format_wall_clock) } +/// True instant for a file that may have no `image_exif` row yet — the +/// [`file_wall_clock`] precedence (row, then filename-class date, then +/// earliest fs time). The anchor for instant-to-instant matching (the +/// "messages around this photo" window), never for display. +pub fn file_true_instant( + row: Option<&ImageExif>, + rel_path: &str, + fs_earliest: Option, + display_zone: Tz, + server_zone: Option, +) -> Option { + if let Some(instant) = row.and_then(|r| row_true_instant(r, display_zone, server_zone)) { + return Some(instant); + } + match crate::memories::extract_date_from_filename(rel_path) { + Some(fd) => filename_true_instant(&fd, server_zone), + None => fs_earliest, + } +} + /// Render a unix instant as a naive wall in `zone`. fn render_in_zone_opt(instant: i64, zone: Tz) -> Option { zone.timestamp_opt(instant, 0) @@ -720,4 +740,43 @@ mod tests { None ); } + + #[test] + fn file_true_instant_uses_the_row_then_filename_then_fs_time() { + // Row: the converted instant, not the wall (Tokyo 14:00 = 05:00Z). + let r = row( + "otd.jpg", + Some(utc_epoch(2018, 9, 29, 14, 0)), + Some("exif"), + Some(utc_epoch(2018, 9, 29, 5, 0)), + Some(540), + ); + assert_eq!( + file_true_instant(Some(&r), "otd.jpg", Some(0), ny(), Some(ny())), + Some(utc_epoch(2018, 9, 29, 5, 0)) + ); + // No row: a wall-pattern filename is read in the server zone + // (10:15 EDT = 14:15Z). + assert_eq!( + file_true_instant( + None, + "Screenshots/Screenshot_20220315-101500.png", + Some(0), + ny(), + Some(ny()) + ), + Some(utc_epoch(2022, 3, 15, 14, 15)) + ); + // No row, no filename date: fs time is already an instant. + assert_eq!( + file_true_instant( + None, + "Undated/scan.jpg", + Some(1_425_229_200), + ny(), + Some(ny()) + ), + Some(1_425_229_200) + ); + } } diff --git a/src/handlers/image.rs b/src/handlers/image.rs index f3f7b72..a33f96d 100644 --- a/src/handlers/image.rs +++ b/src/handlers/image.rs @@ -528,12 +528,11 @@ pub async fn get_file_metadata( .lock() .ok() .and_then(|mut dao| dao.get_exif(&path.path).ok().flatten()); - response.wall_clock = crate::date_view::file_wall_clock( + set_capture_dates( + &mut response, row.as_ref(), &path.path, - earliest_fs_time(&response), crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao), - crate::camera_tz::server_zone(), ); response.exif = row.map(Into::into); @@ -714,14 +713,14 @@ pub async fn set_image_gps( library_id: None, library_name: None, wall_clock: None, + true_instant: None, }, }; - response.wall_clock = crate::date_view::file_wall_clock( + set_capture_dates( + &mut response, Some(&row), &normalized_path, - earliest_fs_time(&response), crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao), - crate::camera_tz::server_zone(), ); response.exif = Some(row.into()); response.library_id = Some(resolved_library.id); @@ -881,15 +880,10 @@ fn build_metadata_response_for_date_mutation( library_id: None, library_name: None, wall_clock: None, + true_instant: None, }, }; - response.wall_clock = crate::date_view::file_wall_clock( - Some(&exif), - rel_path, - earliest_fs_time(&response), - display_zone, - crate::camera_tz::server_zone(), - ); + set_capture_dates(&mut response, Some(&exif), rel_path, display_zone); response.exif = Some(exif.into()); response.library_id = Some(library.id); response.library_name = Some(library.name.clone()); @@ -898,6 +892,23 @@ fn build_metadata_response_for_date_mutation( response } +/// Fill the capture-date fields every metadata response carries (ADR-0001): +/// `wall_clock` to display and `true_instant` to match against. Needs the +/// fs times already on the response (the unindexed-file fallback). +fn set_capture_dates( + response: &mut MetadataResponse, + row: Option<&ImageExif>, + rel_path: &str, + display_zone: chrono_tz::Tz, +) { + let server_zone = crate::camera_tz::server_zone(); + let fs_earliest = earliest_fs_time(response); + response.wall_clock = + crate::date_view::file_wall_clock(row, rel_path, fs_earliest, display_zone, server_zone); + response.true_instant = + crate::date_view::file_true_instant(row, rel_path, fs_earliest, display_zone, server_zone); +} + /// Earlier of the file's created / modified times — the ingest waterfall's /// `fs_time` rule — for the wall-clock fallback of unindexed files. fn earliest_fs_time(response: &MetadataResponse) -> Option {