From e2f33684c8290987c0d0941d832e4532d58eeee4 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Tue, 29 Sep 2026 13:50:23 -0400 Subject: [PATCH 1/3] Dates: serve the display wall clock (`wall_clock`) to clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every payload a client renders a capture time from now carries `wall_clock`, a naive ISO-8601 string computed by the existing `date_view::row_display_wall`: the camera's wall clock (true instant + camera offset; the owner's display zone without an offset), with the per-source fallback for unconverted rows. Clients render it verbatim and do no zone math, which is the FileViewer-React #33 / Apollo #37 flip with the rule kept server-side: the drain's permanent `date_utc IS NULL` floor (#120: edited/exported files) would otherwise force every client to carry the per-source legacy conventions forever, and insight prompts already use the same function, so prompt and UI agree by construction. - `/image/metadata` (+ the GPS / date / date-clear mutation responses): `file_wall_clock` — the row's wall, else the filename-class date, else the earliest fs time in the display zone (unindexed files). - `/memories` items and `/photos/gps-summary` photos: `row_wall_clock`. - `get_all_with_gps` returns full rows instead of a 7-tuple that dropped `date_taken_source`, which unconverted rows need. - `date_view::owner_display_zone` resolves prefs -> app default per request. Legacy fields are untouched, so current clients see byte-identical responses (additive; safe to deploy ahead of the client flip, per #120). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 12 ++++ src/data/mod.rs | 8 +++ src/database/mod.rs | 67 +++--------------- src/date_view.rs | 156 ++++++++++++++++++++++++++++++++++++++++++ src/files.rs | 48 +++++-------- src/handlers/image.rs | 45 ++++++++++-- src/memories.rs | 10 +++ src/reels/selector.rs | 4 ++ 8 files changed, 257 insertions(+), 93 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 32424cc..1c2bb30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -467,6 +467,18 @@ are SMS-derived and unaffected. Progress: Prometheus `date_backfill_remaining` per library on `GET /libraries` (live count of `date_utc IS NULL`). +**Displaying a capture time: `wall_clock`.** Every payload a client renders a +capture time from (`/image/metadata` + the date/GPS mutation responses, +`/memories` items, `/photos/gps-summary`) carries `wall_clock`, a naive +ISO-8601 string (`2018-09-29T14:00:00`) computed by +`date_view::row_display_wall` — the camera's wall clock (instant + camera +offset; the owner's display zone when there's no offset), with the per-source +fallback for rows the drain can't convert (its permanent floor). +`file_wall_clock` covers files with no row yet (filename date, then fs time). +Clients render it verbatim and do no zone math (ADR-0001; FileViewer-React #33, +Apollo #37). Insight prompts use the same function, so prompt and UI agree. +`dev/e2e/e2e.sh check` verifies it for every fixture on all three endpoints. + `/memories` is a single SQL query against this column (`get_memories_in_window` in `src/database/mod.rs`), using `strftime('%m-%d' | '%W' | '%m', date_taken, 'unixepoch', tz)` for diff --git a/src/data/mod.rs b/src/data/mod.rs index d30f448..79ccb7c 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -259,6 +259,10 @@ pub struct MetadataResponse { pub filename_date: Option, // Date extracted from filename pub library_id: Option, pub library_name: Option, + /// The capture wall clock to display, naive ISO-8601 + /// (`2018-09-29T14:00:00`) — `date_view::file_wall_clock`. Render it + /// verbatim; never zone-convert it (ADR-0001). + pub wall_clock: Option, } impl From for MetadataResponse { @@ -277,6 +281,7 @@ 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 } } } @@ -437,6 +442,9 @@ pub struct GpsPhotoSummary { /// Which evidence pinned the instant (`offset_tag`, `gps`, /// `assumed_server`, `utc_direct`, `manual`). pub date_taken_tz_source: Option, + /// Capture wall clock to display (naive ISO-8601; see + /// `MetadataResponse::wall_clock`). + pub wall_clock: Option, } #[derive(Debug, Serialize)] diff --git a/src/database/mod.rs b/src/database/mod.rs index 04d5248..b58e12a 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -389,25 +389,15 @@ pub trait ExifDao: Sync + Send { /// Get all file paths from EXIF database fn get_all_file_paths(&mut self) -> Result, DbError>; - /// Get all photos with GPS coordinates - /// Returns Vec<(file_path, latitude, longitude, date_taken)> - #[allow(clippy::type_complexity)] + /// Get all rows with GPS coordinates (both latitude and longitude set) + /// under `base_path`. Full rows, so callers can apply the date contract + /// (`date_view`) — which needs the date source and path, not just the + /// new-contract triple. fn get_all_with_gps( &mut self, base_path: &str, recursive: bool, - ) -> Result< - Vec<( - String, - f64, - f64, - Option, - Option, - Option, - Option, - )>, - DbError, - >; + ) -> Result, DbError>; /// Return rows that still lack a `content_hash`, oldest first. Used by /// the `backfill_hashes` binary to batch through the historical @@ -1168,23 +1158,11 @@ impl ExifDao for SqliteExifDao { .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } - #[allow(clippy::type_complexity)] fn get_all_with_gps( &mut self, base_path: &str, recursive: bool, - ) -> Result< - Vec<( - String, - f64, - f64, - Option, - Option, - Option, - Option, - )>, - DbError, - > { + ) -> Result, DbError> { trace_db_call("query", "get_all_with_gps", |span| { use schema::image_exif::dsl::*; @@ -1216,36 +1194,11 @@ impl ExifDao for SqliteExifDao { .load::(connection) .map_err(|e| anyhow::anyhow!("GPS query error: {}", e))?; - // Convert to tuple format (path, lat, lon, legacy date_taken, - // date_utc, camera_offset_minutes, date_taken_tz_source). - // Filter out any rows where GPS is still None (shouldn't happen due to filter) - // Cast f32 GPS values to f64 for API compatibility - let filtered: Vec<( - String, - f64, - f64, - Option, - Option, - Option, - Option, - )> = results + // The filter already requires both coordinates; keep the guard so + // callers can rely on lat/lon being present. + let filtered: Vec = results .into_iter() - .filter_map(|exif| { - if let (Some(lat_val), Some(lon_val)) = (exif.gps_latitude, exif.gps_longitude) - { - Some(( - exif.file_path, - lat_val as f64, - lon_val as f64, - exif.date_taken, - exif.date_utc, - exif.camera_offset_minutes, - exif.date_taken_tz_source, - )) - } else { - None - } - }) + .filter(|exif| exif.gps_latitude.is_some() && exif.gps_longitude.is_some()) .collect(); span.set_attribute("result_count", filtered.len() as i64); diff --git a/src/date_view.rs b/src/date_view.rs index d84fd77..537bd0d 100644 --- a/src/date_view.rs +++ b/src/date_view.rs @@ -184,6 +184,64 @@ pub fn row_display_day( ) } +/// Wire format for a wall clock: naive ISO-8601, no zone designator +/// (`2018-09-29T14:00:00`). Clients render it verbatim — no zone math — +/// so the UI shows exactly the wall [`row_display_wall`] computed and +/// insight prompts use (ADR-0001: consumers never re-anchor). +pub fn format_wall_clock(wall: NaiveDateTime) -> String { + wall.format("%Y-%m-%dT%H:%M:%S").to_string() +} + +/// The owner's display zone for a request: prefs `display_tz`, else the +/// app default (never fails — an unreadable prefs row is the default). +pub fn owner_display_zone( + prefs_dao: &std::sync::Mutex>, +) -> Tz { + use std::str::FromStr; + let stored = prefs_dao + .lock() + .ok() + .and_then(|mut dao| dao.get_prefs().ok().flatten()) + .and_then(|p| p.display_tz); + Tz::from_str(&camera_tz::display_zone_for(stored.as_deref())) + .unwrap_or(chrono_tz::Tz::America__New_York) +} + +/// [`row_display_wall`] in wire format. +pub fn row_wall_clock( + row: &ImageExif, + display_zone: Tz, + server_zone: Option, +) -> Option { + row_display_wall(row, display_zone, server_zone).map(format_wall_clock) +} + +/// Wall clock for a file that may have no `image_exif` row yet (not yet +/// ingested, or excluded): the row's wall when it has one, else the +/// filename-class date (a wall pattern *is* the wall; a unix-timestamp +/// pattern renders in the display zone), else the file's earliest +/// filesystem time rendered in the display zone. The same precedence the +/// ingest waterfall applies, so the value doesn't jump once the row lands. +pub fn file_wall_clock( + row: Option<&ImageExif>, + rel_path: &str, + fs_earliest: Option, + display_zone: Tz, + server_zone: Option, +) -> Option { + if let Some(wall) = row.and_then(|r| row_display_wall(r, display_zone, server_zone)) { + return Some(format_wall_clock(wall)); + } + let wall = match crate::memories::extract_date_from_filename(rel_path) { + Some(crate::memories::FilenameDate::Wall(wall)) => Some(wall), + Some(crate::memories::FilenameDate::Utc(dt)) => { + render_in_zone_opt(dt.timestamp(), display_zone) + } + None => fs_earliest.and_then(|t| render_in_zone_opt(t, display_zone)), + }; + wall.map(format_wall_clock) +} + /// 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) @@ -564,4 +622,102 @@ mod tests { ); assert_eq!(row_true_instant(&fs, tokyo, Some(ny())), Some(instant)); } + + #[test] + fn wall_clock_is_the_camera_wall_for_a_travel_zone_photo() { + // 14:00 in Tokyo (+09:00) = 05:00Z. The owner is in New York, but + // the wall clock is the camera's: 14:00, not 01:00. + 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!( + row_wall_clock(&r, ny(), Some(ny())), + Some("2018-09-29T14:00:00".to_string()) + ); + } + + #[test] + fn wall_clock_without_offset_renders_the_instant_in_the_display_zone() { + // Container date (utc_direct): 12:30Z is 08:30 in New York (EDT). + let r = row( + "summit.mp4", + None, + Some("exiftool"), + Some(utc_epoch(2022, 8, 20, 12, 30)), + None, + ); + assert_eq!( + row_wall_clock(&r, ny(), Some(ny())), + Some("2022-08-20T08:30:00".to_string()) + ); + } + + #[test] + fn wall_clock_serves_unconverted_rows_by_their_legacy_convention() { + // The drain's permanent floor never gets date_utc; an `exif` legacy + // value is the wall stored as UTC and must come back unshifted. + let r = row( + "edited.jpg", + Some(utc_epoch(2021, 5, 12, 10, 30)), + Some("exif"), + None, + None, + ); + assert_eq!( + row_wall_clock(&r, ny(), Some(ny())), + Some("2021-05-12T10:30:00".to_string()) + ); + } + + #[test] + fn file_wall_clock_prefers_the_row() { + let r = row( + "Screenshots/Screenshot_20220315-101500.png", + Some(utc_epoch(2022, 3, 15, 10, 15)), + Some("filename"), + Some(utc_epoch(2022, 3, 15, 14, 15)), + Some(-240), + ); + assert_eq!( + file_wall_clock(Some(&r), &r.file_path, Some(0), ny(), Some(ny())), + Some("2022-03-15T10:15:00".to_string()) + ); + } + + #[test] + fn file_wall_clock_without_a_row_uses_the_filename_wall() { + assert_eq!( + file_wall_clock( + None, + "Screenshots/Screenshot_20220315-101500.png", + Some(0), + ny(), + Some(ny()) + ), + Some("2022-03-15T10:15:00".to_string()) + ); + } + + #[test] + fn file_wall_clock_without_a_row_or_filename_date_uses_fs_time_in_display_zone() { + // 2015-03-01 17:00Z = 12:00 in New York (EST). + assert_eq!( + file_wall_clock( + None, + "Undated/scan_01.jpg", + Some(utc_epoch(2015, 3, 1, 17, 0)), + ny(), + Some(ny()) + ), + Some("2015-03-01T12:00:00".to_string()) + ); + assert_eq!( + file_wall_clock(None, "Undated/scan_01.jpg", None, ny(), Some(ny())), + None + ); + } } diff --git a/src/files.rs b/src/files.rs index 5b375cf..52be2ce 100644 --- a/src/files.rs +++ b/src/files.rs @@ -1100,27 +1100,24 @@ pub async fn get_gps_summary( let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao"); match exif_dao_guard.get_all_with_gps(&requested_path, recursive) { Ok(gps_data) => { + 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 photos: Vec = gps_data .into_iter() - .map( - |( - path, - lat, - lon, - date_taken, - date_utc, - camera_offset_minutes, - date_taken_tz_source, - )| GpsPhotoSummary { - path, - lat, - lon, - date_taken, - date_utc, - camera_offset_minutes, - date_taken_tz_source, - }, - ) + .filter_map(|row| { + let wall_clock = + crate::date_view::row_wall_clock(&row, display_zone, server_zone); + Some(GpsPhotoSummary { + lat: row.gps_latitude? as f64, + lon: row.gps_longitude? as f64, + path: row.file_path, + date_taken: row.date_taken, + date_utc: row.date_utc, + camera_offset_minutes: row.camera_offset_minutes, + date_taken_tz_source: row.date_taken_tz_source, + wall_clock, + }) + }) .collect(); // Sort by date_taken based on request, defaulting to ascending (oldest to newest) @@ -1632,18 +1629,7 @@ mod tests { &mut self, _base_path: &str, _recursive: bool, - ) -> Result< - Vec<( - String, - f64, - f64, - Option, - Option, - Option, - Option, - )>, - DbError, - > { + ) -> Result, DbError> { todo!() } diff --git a/src/handlers/image.rs b/src/handlers/image.rs index 4d2a0cc..f3f7b72 100644 --- a/src/handlers/image.rs +++ b/src/handlers/image.rs @@ -524,11 +524,18 @@ pub async fn get_file_metadata( .map(memories::FilenameDate::legacy_timestamp); // Query EXIF data if available - if let Ok(mut dao) = exif_dao.lock() - && let Ok(Some(exif)) = dao.get_exif(&path.path) - { - response.exif = Some(exif.into()); - } + let row = exif_dao + .lock() + .ok() + .and_then(|mut dao| dao.get_exif(&path.path).ok().flatten()); + response.wall_clock = crate::date_view::file_wall_clock( + 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); span.add_event( "Metadata fetched", @@ -706,8 +713,16 @@ pub async fn set_image_gps( filename_date: None, library_id: None, library_name: None, + wall_clock: None, }, }; + response.wall_clock = crate::date_view::file_wall_clock( + 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); response.library_name = Some(resolved_library.name.clone()); @@ -848,6 +863,7 @@ fn build_metadata_response_for_date_mutation( library: &libraries::Library, rel_path: &str, exif: ImageExif, + display_zone: chrono_tz::Tz, ) -> MetadataResponse { let full_path = is_valid_full_path(&library.root_path, &rel_path.to_string(), false); let fs_meta = full_path @@ -864,8 +880,16 @@ fn build_metadata_response_for_date_mutation( filename_date: None, library_id: None, library_name: None, + wall_clock: 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(), + ); response.exif = Some(exif.into()); response.library_id = Some(library.id); response.library_name = Some(library.name.clone()); @@ -874,6 +898,15 @@ fn build_metadata_response_for_date_mutation( response } +/// 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 { + match (response.created, response.modified) { + (Some(c), Some(m)) => Some(c.min(m)), + (c, m) => c.or(m), + } +} + #[post("/image/exif/date")] #[tracing::instrument(name = "set_image_date", skip_all)] pub async fn set_image_date( @@ -924,6 +957,7 @@ pub async fn set_image_date( library, &normalized_path, row, + crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao), )) } Err(e) => { @@ -972,6 +1006,7 @@ pub async fn clear_image_date( library, &normalized_path, row, + crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao), )) } Err(e) => { diff --git a/src/memories.rs b/src/memories.rs index b254afb..becd537 100644 --- a/src/memories.rs +++ b/src/memories.rs @@ -164,6 +164,10 @@ pub struct MemoryItem { pub date_utc: Option, pub camera_offset_minutes: Option, pub date_taken_tz_source: Option, + /// Capture wall clock to display, naive ISO-8601 (`row_display_wall`): + /// the camera's clock, which can differ from the owner's day that + /// `created` groups under. Render verbatim (ADR-0001). + pub wall_clock: Option, } #[derive(Debug, Serialize)] @@ -637,6 +641,7 @@ pub fn gather_memory_items( } let created = crate::date_view::row_true_instant(&row, display_zone, server_zone); + let wall_clock = crate::date_view::row_wall_clock(&row, display_zone, server_zone); memories_with_dates.push(( MemoryItem { path: row.file_path, @@ -646,6 +651,7 @@ pub fn gather_memory_items( date_utc: row.date_utc, camera_offset_minutes: row.camera_offset_minutes, date_taken_tz_source: row.date_taken_tz_source, + wall_clock, }, display_day, )); @@ -1130,6 +1136,7 @@ mod tests { date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, + wall_clock: None, }, NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(), ), @@ -1142,6 +1149,7 @@ mod tests { date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, + wall_clock: None, }, NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(), ), @@ -1154,6 +1162,7 @@ mod tests { date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, + wall_clock: None, }, NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(), ), @@ -1484,6 +1493,7 @@ mod tests { date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, + wall_clock: None, }; let v: serde_json::Value = serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap(); diff --git a/src/reels/selector.rs b/src/reels/selector.rs index 86109c4..bcf3691 100644 --- a/src/reels/selector.rs +++ b/src/reels/selector.rs @@ -430,6 +430,7 @@ mod tests { date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, + wall_clock: None, }, memories::MemoryItem { path: "b".into(), @@ -439,6 +440,7 @@ mod tests { date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, + wall_clock: None, }, memories::MemoryItem { path: "c".into(), @@ -448,6 +450,7 @@ mod tests { date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, + wall_clock: None, }, ]; assert_eq!( @@ -466,6 +469,7 @@ mod tests { date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, + wall_clock: None, } } fn item_at(ts: i64, name: &str) -> memories::MemoryItem { -- 2.54.0 From bc41c737e5cc4aba6c35d3e84f7008f1cb8536a6 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Tue, 29 Sep 2026 13:50:24 -0400 Subject: [PATCH 2/3] dev/e2e: wall_clock regression check + deterministic fixture dates `e2e.sh check` compares every indexed fixture's manifest wall clock with the `wall_clock` reported by /image/metadata, /memories and /photos/gps-summary; any disagreement fails. - Fixture videos are stamped like phone containers: the America/New_York wall as a UTC creation_time (the label previously showed the UTC time). - mtimes are pinned in America/New_York instead of the host's zone. - The e2e env sets TZ=America/New_York (the #120 preflight): the Windows exe has no /etc/timezone, so server_zone() was otherwise None. Co-Authored-By: Claude Opus 5.5 --- dev/e2e/README.md | 8 ++++-- dev/e2e/check_wall_clock.py | 55 +++++++++++++++++++++++++++++++++++++ dev/e2e/e2e.sh | 7 +++++ dev/e2e/gen_fixtures.py | 32 +++++++++++++++++++-- 4 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 dev/e2e/check_wall_clock.py diff --git a/dev/e2e/README.md b/dev/e2e/README.md index 709bd62..f53f2ff 100644 --- a/dev/e2e/README.md +++ b/dev/e2e/README.md @@ -7,6 +7,7 @@ end-to-end and agent-driven UI testing of the SynologyFileViewer app. dev/e2e/e2e.sh up # build + fixtures + reset + start; blocks until ingest is done dev/e2e/e2e.sh reset # back to the pristine baseline (after tests mutate things) dev/e2e/e2e.sh stop +dev/e2e/e2e.sh check # every fixture's displayed capture time (wall_clock) vs the manifest ``` Log in from the app with **server** `http://10.0.2.2:8090` (Android @@ -32,8 +33,11 @@ server. Everything under `.work/` is gitignored and safe to delete. `gen_fixtures.py` renders every image with its relative path, date and scenario burned into the pixels — a screenshot tells you exactly which fixture is on screen. `.work/state/manifest.json` lists every item with its -expected date, GPS, camera, tags and duplicate relationship, plus expected -counts; assert against it rather than hardcoding. +expected date, GPS, camera, tags, duplicate relationship and **expected +`wall_clock`** (the capture time the UI must show), plus expected counts; +assert against it rather than hardcoding. Naive fixture times are walls in +America/New_York (the e2e display and server zone); videos are stamped the +way phones write containers (that wall, as a UTC `creation_time`). | Folder | Covers | |---|---| diff --git a/dev/e2e/check_wall_clock.py b/dev/e2e/check_wall_clock.py new file mode 100644 index 0000000..1efd5ca --- /dev/null +++ b/dev/e2e/check_wall_clock.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Regression check for the displayed capture time (ADR-0001 `wall_clock`). + +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`. +One disagreement anywhere is exactly the class of bug this field exists to +end, so any mismatch fails the run. + +Usage: check_wall_clock.py (run via `e2e.sh check`) +""" + +import json +import os +import subprocess +import sys +import urllib.parse +from pathlib import Path + +E2E = Path(__file__).with_name("e2e.sh") + + +def api(path: str) -> dict: + return json.loads(subprocess.check_output([str(E2E), "api", "GET", path])) + + +def main() -> int: + manifest = json.load(open(sys.argv[1])) + excluded = tuple(d.strip("/") + "/" for d in manifest["excluded_dirs"]) + items = [i for i in manifest["items"] if not i["path"].startswith(excluded)] + + memories = {m["path"]: m.get("wall_clock") for m in api("memories?span=day")["items"]} + gps = {g["path"]: g.get("wall_clock") for g in api("photos/gps-summary?path=")["photos"]} + + 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")} + 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} + 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 " + f"({len(memories)} in memories, {len(gps)} in gps-summary)") + return 1 if failures else 0 + + +if __name__ == "__main__": + os.environ.setdefault("E2E_TOKEN", subprocess.check_output([str(E2E), "token"], text=True).strip()) + sys.exit(main()) diff --git a/dev/e2e/e2e.sh b/dev/e2e/e2e.sh index 25ccc1a..617c58d 100644 --- a/dev/e2e/e2e.sh +++ b/dev/e2e/e2e.sh @@ -87,6 +87,10 @@ LOCAL_BIND_URL= CORS_ALLOWED_ORIGINS=http://localhost:8081,http://localhost:19006 SECRET_KEY=e2e-dev-only-secret-not-for-production RUST_LOG=info +# Pin the server zone (ImageApi #120 preflight): the Windows exe has no +# /etc/timezone, so without this server_zone() is None and assumed_server +# rows fall back to the process-local offset. +TZ=America/New_York # Fast ingest so a fresh reset is browsable within seconds. WATCH_QUICK_INTERVAL_SECONDS=3 WATCH_FULL_INTERVAL_SECONDS=60 @@ -227,6 +231,8 @@ Usage: dev/e2e/e2e.sh real folder in as media/imported/; the source is read-only) reset stop, wipe db/thumbs/hls, re-copy media, recreate user start | stop | restart | status + check verify every fixture's displayed capture time (wall_clock) + on metadata, memories and gps-summary against the manifest token print a fresh JWT for the e2e user api METHOD PATH [JSON] authenticated request, e.g. api GET 'photos?path=&recursive=true' @@ -248,6 +254,7 @@ case "${1:-help}" in restart) cmd_stop; cmd_start ;; status) cmd_status ;; token) login ;; + check) E2E_TOKEN="$(login)" python3 "$HERE/check_wall_clock.py" "$STATE/manifest.json" ;; api) shift; api "$@" ;; logs) tail -f "$LOG" ;; info) cmd_info ;; diff --git a/dev/e2e/gen_fixtures.py b/dev/e2e/gen_fixtures.py index e92c090..765a0d6 100644 --- a/dev/e2e/gen_fixtures.py +++ b/dev/e2e/gen_fixtures.py @@ -28,6 +28,7 @@ import sys from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from pathlib import Path +from zoneinfo import ZoneInfo FFMPEG = shutil.which("ffmpeg.exe") or shutil.which("ffmpeg") EXIFTOOL = shutil.which("exiftool.exe") or shutil.which("exiftool") @@ -35,6 +36,10 @@ EXIFTOOL = shutil.which("exiftool.exe") or shutil.which("exiftool") # the drive letter must be escaped inside a filter argument. FONT = "C\\:/Windows/Fonts/consola.ttf" +# The owner's display zone and the server zone the e2e env pins (TZ=...). +# Naive fixture datetimes without GPS are walls in this zone. +HOME_TZ = ZoneInfo("America/New_York") + PLACES = { "santa_monica": (34.0100, -118.4960), "paris": (48.8584, 2.2945), @@ -53,7 +58,9 @@ class Item: rel: str # path relative to the library root, forward slashes kind: str # "image" | "video" size: tuple[int, int] = (1600, 1200) - taken: dt.datetime | None = None # written to EXIF / video creation_time + # Capture wall clock: EXIF DateTimeOriginal as-is; for videos, the wall in + # HOME_TZ, stamped as UTC creation_time the way phones write containers. + taken: dt.datetime | None = None gps: str | None = None # key into PLACES camera: tuple[str, str] | None = None # (Make, Model) mtime: dt.datetime | None = None # pinned file mtime (undated fallback) @@ -160,6 +167,21 @@ def build_items(today: dt.date) -> list[Item]: return items +def expected_wall_clock(it: Item) -> str: + """The wall clock the server should report (`wall_clock`): the filename + date for the filename class (it outranks EXIF), else the capture wall, + else the pinned mtime rendered in HOME_TZ.""" + import re + name = it.rel.rsplit("/", 1)[-1] + m = (re.search(r"(\d{4})(\d{2})(\d{2})[_-](\d{2})(\d{2})(\d{2})", name) + or re.search(r"(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})", name)) + if m: + wall = dt.datetime(*map(int, m.groups())) + else: + wall = it.taken or it.mtime or dt.datetime(2024, 1, 1) + return wall.strftime("%Y-%m-%dT%H:%M:%S") + + 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: @@ -200,7 +222,8 @@ def render(item: Item, idx: int, media: Path) -> None: "-c:a", "aac", "-shortest", ] if item.taken: - cmd += ["-metadata", f"creation_time={item.taken.isoformat()}Z"] + utc = item.taken.replace(tzinfo=HOME_TZ).astimezone(dt.timezone.utc) + cmd += ["-metadata", f"creation_time={utc.strftime('%Y-%m-%dT%H:%M:%S')}Z"] subprocess.run(cmd + [win(out)], check=True) @@ -271,7 +294,7 @@ def main() -> int: # Pin mtimes last: exiftool rewrites (and so touches) the files. for it in items: when = it.mtime or it.taken or dt.datetime(2024, 1, 1) - ts = when.timestamp() + ts = when.replace(tzinfo=HOME_TZ).timestamp() # not WSL's local zone os.utime(media / it.rel, (ts, ts)) imported = 0 @@ -280,6 +303,7 @@ def main() -> int: shutil.copytree(a.import_dir, dest) # copy: the source is never touched imported = sum(1 for p in dest.rglob("*") if p.is_file()) + by_path = {it.rel: it for it in items} tags: dict[str, list[str]] = {} for it in items: for t in it.tags: @@ -306,6 +330,8 @@ def main() -> int: "taken": it.taken.isoformat() if it.taken else None, "gps": it.gps, "camera": it.camera[1] if it.camera else None, "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)), } for it in items ], -- 2.54.0 From 9269b5c9fdb85ed3c7977d65342ac1818509ab73 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Tue, 29 Sep 2026 14:14:28 -0400 Subject: [PATCH 3/3] Dates: `true_instant` on metadata responses for instant matching The mobile viewer anchored its "messages around this photo" window on the legacy date_taken (a wall clock stored as UTC for EXIF rows), while SMS-API dates are true instants: the window was off by the photo's UTC offset (4-5 h for photos taken at home; ImageApi #113 user story 3). Verified SMS-API side: importers store backup epochs as UTC, and 40/40 sampled rows equal their source epochs exactly. `/image/metadata` (+ GPS / date mutation responses) now carries `true_instant` from `date_view::file_true_instant`: `row_true_instant`, else the filename-class instant, else the earliest fs time. Server-side for the same reason as `wall_clock`: the drain's permanent floor rows have no `date_utc`. `set_capture_dates` fills both fields at all four response sites. dev/e2e: the manifest records each fixture's expected instant (the wall read in its GPS zone, else America/New_York) and `e2e.sh check` asserts it (97/97). Co-Authored-By: Claude Opus 5.5 --- dev/e2e/check_wall_clock.py | 13 +++++--- dev/e2e/gen_fixtures.py | 19 ++++++++++++ src/data/mod.rs | 7 ++++- src/date_view.rs | 59 +++++++++++++++++++++++++++++++++++++ src/handlers/image.rs | 37 +++++++++++++++-------- 5 files changed, 117 insertions(+), 18 deletions(-) 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 { -- 2.54.0