From 19fbf43d40cf848e2e2b953f5c4990c42e25d825 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sat, 10 Oct 2026 21:52:48 -0400 Subject: [PATCH] feat(dates): photo date filters on the true instant (#152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit date_from / date_to on /photos, /photos/exif and unified search ran on the legacy mixed-convention date_taken, so a photo could display inside a range yet be filtered out of it by up to its UTC offset (or by years, for rows the filename class re-dated). The filter lives in the shared ExifDao::query_by_exif, so it's fixed once there: - query_by_exif takes Option (bounds + display and server zones) instead of raw date_from / date_to. - SQL pre-filter: converted rows on date_utc exactly; unconverted rows (drain pending, permanent floor) on date_taken widened by LEGACY_INSTANT_BAND_SECS (±15 h, now shared with /memories). Converted rows are deliberately not bounded by date_taken: a re-dated row's legacy value can sit years from its instant. - Exact pass in Rust via InstantRange::contains → row_true_instant, so every returned row's true instant is inside the range. Calendar days now mean the owner's days: nl_query::iso_to_unix builds day bounds in the display zone (it used UTC midnights, which only lined up with the legacy wall-as-UTC column), and unified search's "today" is the owner's today rather than UTC's. The midnight-with-DST-gap fallback moves from memories_anchor into date_view::day_start_instant. Compatibility: clients already sending real instants (Apollo's /photos/exif windows) get correct edges with no change. Clients sending UTC-midnight day bounds see ranges shift by up to the UTC offset until they send display-zone bounds. No index on date_utc; the OR clause scans, which is milliseconds at personal-library scale (same as /memories today). dev/e2e check gains /photos/exif wall_clock + true_instant assertions and range-edge checks ([t, t] in, [.., t-1] and [t+1, ..] out). Not run here: the harness needs the WSL + Windows toolchain. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 + dev/e2e/README.md | 2 +- .../check_wall_clock.cpython-312.pyc | Bin 0 -> 5837 bytes dev/e2e/check_wall_clock.py | 38 ++- dev/e2e/e2e.sh | 3 +- src/ai/nl_query.rs | 105 +++++-- src/data/mod.rs | 7 +- src/database/mod.rs | 282 ++++++++++++++++-- src/date_view.rs | 68 +++++ src/files.rs | 17 +- src/memories.rs | 25 +- src/unified_search.rs | 17 +- 12 files changed, 478 insertions(+), 90 deletions(-) create mode 100644 dev/e2e/__pycache__/check_wall_clock.cpython-312.pyc diff --git a/CLAUDE.md b/CLAUDE.md index fe1b612..d77ea8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -520,6 +520,10 @@ GET /image/metadata?path=... // Advanced search with filters GET /photos?path=...&recursive=true&sort=DateTakenDesc&camera_make=Canon&gps_lat=...&gps_lon=...&gps_radius_km=10&date_from=...&date_to=...&tag_ids=1,2,3&media_type=Photo +// date_from / date_to are inclusive TRUE INSTANTS, matched against each row's +// true instant (date_utc, else date_view::row_true_instant's fallback) in +// ExifDao::query_by_exif — shared by /photos, /photos/exif and unified search. +// A calendar-day range = that day's bounds in the owner's display zone. // Video streaming (HLS) POST /video/generate (creates .m3u8 playlist + .ts segments) diff --git a/dev/e2e/README.md b/dev/e2e/README.md index f53f2ff..6432f27 100644 --- a/dev/e2e/README.md +++ b/dev/e2e/README.md @@ -7,7 +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 +dev/e2e/e2e.sh check # every fixture's wall_clock + true_instant vs the manifest, and /photos/exif range edges ``` Log in from the app with **server** `http://10.0.2.2:8090` (Android diff --git a/dev/e2e/__pycache__/check_wall_clock.cpython-312.pyc b/dev/e2e/__pycache__/check_wall_clock.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d322a9240433330cde897bd941da6d24734b2738 GIT binary patch literal 5837 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 (run via `e2e.sh check`) """ @@ -32,6 +35,14 @@ def main() -> int: 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"]} + exif = { + e["file_path"]: e + for e in api(f"photos/exif?date_from=0&date_to={2**62}")["photos"] + } + + def in_exif_range(path: str, lo: int, hi: int) -> bool: + rows = api(f"photos/exif?date_from={lo}&date_to={hi}")["photos"] + return any(r["file_path"] == path for r in rows) failures = 0 for item in items: @@ -42,16 +53,29 @@ def main() -> int: got["memories"] = memories[path] if path in gps: got["gps-summary"] = gps[path] + if path in exif: + got["photos/exif"] = exif[path].get("wall_clock") 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"]) + t = item["true_instant"] + if meta.get("true_instant") != t: + bad["metadata true_instant"] = (meta.get("true_instant"), "expected", t) + if path in exif: + if exif[path].get("true_instant") != t: + bad["photos/exif true_instant"] = (exif[path].get("true_instant"), "expected", t) + # Range edges on the true instant (#152). + if not in_exif_range(path, t, t): + bad["photos/exif [t, t]"] = "excluded" + if in_exif_range(path, t - 86_400, t - 1): + bad["photos/exif [.., t-1]"] = "included" + if in_exif_range(path, t + 1, t + 86_400): + bad["photos/exif [t+1, ..]"] = "included" 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 + instant " - f"({len(memories)} in memories, {len(gps)} in gps-summary)") + f"({len(memories)} in memories, {len(gps)} in gps-summary, {len(exif)} in photos/exif)") return 1 if failures else 0 diff --git a/dev/e2e/e2e.sh b/dev/e2e/e2e.sh index 617c58d..74eb330 100644 --- a/dev/e2e/e2e.sh +++ b/dev/e2e/e2e.sh @@ -232,7 +232,8 @@ Usage: dev/e2e/e2e.sh 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 + on metadata, memories, gps-summary and photos/exif against + the manifest, plus photos/exif date-range edges (#152) token print a fresh JWT for the e2e user api METHOD PATH [JSON] authenticated request, e.g. api GET 'photos?path=&recursive=true' diff --git a/src/ai/nl_query.rs b/src/ai/nl_query.rs index d709322..e86e4e6 100644 --- a/src/ai/nl_query.rs +++ b/src/ai/nl_query.rs @@ -88,18 +88,20 @@ pub struct StructuredQuery { pub media_type: Option, } -/// Convert an ISO `YYYY-MM-DD` date to a unix timestamp (seconds). With -/// `end_of_day`, returns 23:59:59 of that day so a `date_to` filter is -/// inclusive of the whole day; otherwise 00:00:00. Returns `None` for any -/// unparseable input (the filter is simply omitted rather than erroring). -pub fn iso_to_unix(date: &str, end_of_day: bool) -> Option { +/// Convert an ISO `YYYY-MM-DD` date to the true instant (unix seconds) that +/// day starts at in the owner's display `zone`. With `end_of_day`, returns +/// the last second of that day instead, so a `date_to` filter is inclusive +/// of the whole day. The photo filters compare true instants (ADR-0001, +/// #152), so "June" means the owner's June — the same days `/memories` +/// groups by. Returns `None` for any unparseable input (the filter is +/// simply omitted rather than erroring). +pub fn iso_to_unix(date: &str, end_of_day: bool, zone: chrono_tz::Tz) -> Option { let d = chrono::NaiveDate::parse_from_str(date.trim(), "%Y-%m-%d").ok()?; - let time = if end_of_day { - chrono::NaiveTime::from_hms_opt(23, 59, 59)? + if end_of_day { + Some(crate::date_view::day_start_instant(d.succ_opt()?, zone) - 1) } else { - chrono::NaiveTime::from_hms_opt(0, 0, 0)? - }; - Some(d.and_time(time).and_utc().timestamp()) + Some(crate::date_view::day_start_instant(d, zone)) + } } /// Normalize a free-form media-type string to the engine's vocabulary. @@ -122,7 +124,11 @@ fn normalize_media_type(raw: &str) -> Option { /// `tag_vocab` is `(tag_id, tag_name)` pairs (the shape `TagDao::get_all_tags` /// yields once the count is dropped). Matching is case-insensitive and exact /// on the trimmed name. -pub fn resolve_raw_query(raw: RawNlQuery, tag_vocab: &[(i32, String)]) -> StructuredQuery { +pub fn resolve_raw_query( + raw: RawNlQuery, + tag_vocab: &[(i32, String)], + display_zone: chrono_tz::Tz, +) -> StructuredQuery { // Case-insensitive name → id lookup. Built once per call. let lookup: std::collections::HashMap = tag_vocab .iter() @@ -173,8 +179,14 @@ pub fn resolve_raw_query(raw: RawNlQuery, tag_vocab: &[(i32, String)]) -> Struct camera_model: clean(raw.camera_model), lens_model: clean(raw.lens_model), place: clean(raw.place), - date_from: raw.date_from.as_deref().and_then(|d| iso_to_unix(d, false)), - date_to: raw.date_to.as_deref().and_then(|d| iso_to_unix(d, true)), + date_from: raw + .date_from + .as_deref() + .and_then(|d| iso_to_unix(d, false, display_zone)), + date_to: raw + .date_to + .as_deref() + .and_then(|d| iso_to_unix(d, true, display_zone)), media_type: raw.media_type.as_deref().and_then(normalize_media_type), } } @@ -243,27 +255,34 @@ fn extract_json(raw: &str) -> String { /// Parse a model response string into a [`StructuredQuery`], resolving names /// against the vocab. Separated from the LLM call so it's unit-testable. -pub fn parse_response(response: &str, tag_vocab: &[(i32, String)]) -> Result { +pub fn parse_response( + response: &str, + tag_vocab: &[(i32, String)], + display_zone: chrono_tz::Tz, +) -> Result { let json = extract_json(response); let raw: RawNlQuery = serde_json::from_str(&json) .map_err(|e| anyhow!("failed to parse NL query JSON: {e}; raw response: {response:?}"))?; - Ok(resolve_raw_query(raw, tag_vocab)) + Ok(resolve_raw_query(raw, tag_vocab, display_zone)) } /// Translate a natural-language query into a [`StructuredQuery`] via one /// grounded LLM call. The `client` is any configured backend (the unified /// endpoint passes the resolved chat backend); `tag_vocab` grounds the tag -/// mapping; `today` anchors relative-date resolution. +/// mapping; `today` (the owner's day, in `display_zone`) anchors +/// relative-date resolution, and `display_zone` turns the model's ISO +/// dates into day-bound instants. pub async fn translate_nl_query( client: &dyn LlmClient, nl: &str, tag_vocab: &[(i32, String)], today: chrono::NaiveDate, + display_zone: chrono_tz::Tz, ) -> Result { let system = build_system_prompt(tag_vocab, today); let messages = vec![ChatMessage::system(system), ChatMessage::user(nl)]; let (msg, _, _) = client.chat_with_tools(messages, Vec::::new()).await?; - parse_response(&msg.content, tag_vocab) + parse_response(&msg.content, tag_vocab, display_zone) } #[cfg(test)] @@ -281,19 +300,33 @@ mod tests { #[test] fn iso_to_unix_start_and_end_of_day() { // 2023-01-01 UTC midnight = 1672531200. - assert_eq!(iso_to_unix("2023-01-01", false), Some(1_672_531_200)); + assert_eq!( + iso_to_unix("2023-01-01", false, chrono_tz::UTC), + Some(1_672_531_200) + ); // End of that day is 86399 seconds later. assert_eq!( - iso_to_unix("2023-01-01", true), + iso_to_unix("2023-01-01", true, chrono_tz::UTC), Some(1_672_531_200 + 86_399) ); } + /// #152: ISO dates become the owner's day bounds in the display zone, + /// not UTC days, so "June" lines up with the true-instant filters. + #[test] + fn iso_to_unix_uses_display_zone_day_bounds() { + let ny = chrono_tz::America::New_York; + // 2023-06-01 00:00 EDT = 04:00Z. + assert_eq!(iso_to_unix("2023-06-01", false, ny), Some(1_685_592_000)); + // Last second of 2023-06-30 EDT = 2023-07-01 03:59:59Z. + assert_eq!(iso_to_unix("2023-06-30", true, ny), Some(1_688_183_999)); + } + #[test] fn iso_to_unix_rejects_garbage() { - assert_eq!(iso_to_unix("last summer", false), None); - assert_eq!(iso_to_unix("2023-13-99", false), None); - assert_eq!(iso_to_unix("", false), None); + assert_eq!(iso_to_unix("last summer", false, chrono_tz::UTC), None); + assert_eq!(iso_to_unix("2023-13-99", false, chrono_tz::UTC), None); + assert_eq!(iso_to_unix("", false, chrono_tz::UTC), None); } #[test] @@ -302,7 +335,7 @@ mod tests { tags: vec!["BEACH".to_string(), "sunset".to_string()], ..Default::default() }; - let q = resolve_raw_query(raw, &vocab()); + let q = resolve_raw_query(raw, &vocab(), chrono_tz::UTC); assert_eq!(q.tag_ids, vec![1, 2]); assert!(q.unmatched_tags.is_empty()); } @@ -315,7 +348,7 @@ mod tests { tags: vec!["beach".to_string(), "golden hour".to_string()], ..Default::default() }; - let q = resolve_raw_query(raw, &vocab()); + let q = resolve_raw_query(raw, &vocab(), chrono_tz::UTC); assert_eq!(q.tag_ids, vec![1]); assert_eq!(q.unmatched_tags, vec!["golden hour".to_string()]); } @@ -330,7 +363,7 @@ mod tests { ], ..Default::default() }; - let q = resolve_raw_query(raw, &vocab()); + let q = resolve_raw_query(raw, &vocab(), chrono_tz::UTC); assert_eq!(q.tag_ids, vec![1]); } @@ -342,10 +375,13 @@ mod tests { date_to: Some("2023-06-30".to_string()), ..Default::default() }; - let q = resolve_raw_query(raw, &vocab()); + let q = resolve_raw_query(raw, &vocab(), chrono_tz::UTC); assert_eq!(q.media_type.as_deref(), Some("video")); - assert_eq!(q.date_from, iso_to_unix("2023-06-01", false)); - assert_eq!(q.date_to, iso_to_unix("2023-06-30", true)); + assert_eq!( + q.date_from, + iso_to_unix("2023-06-01", false, chrono_tz::UTC) + ); + assert_eq!(q.date_to, iso_to_unix("2023-06-30", true, chrono_tz::UTC)); } #[test] @@ -354,7 +390,10 @@ mod tests { media_type: Some("all".to_string()), ..Default::default() }; - assert_eq!(resolve_raw_query(raw, &vocab()).media_type, None); + assert_eq!( + resolve_raw_query(raw, &vocab(), chrono_tz::UTC).media_type, + None + ); } #[test] @@ -365,7 +404,7 @@ mod tests { place: Some("".to_string()), ..Default::default() }; - let q = resolve_raw_query(raw, &vocab()); + let q = resolve_raw_query(raw, &vocab(), chrono_tz::UTC); assert_eq!(q.semantic, None); assert_eq!(q.camera_make.as_deref(), Some("Fujifilm")); assert_eq!(q.place, None); @@ -374,7 +413,7 @@ mod tests { #[test] fn parse_response_handles_code_fences_and_prose() { let resp = "Here is the filter:\n```json\n{\"semantic\":\"sunset\",\"tags\":[\"beach\"]}\n```\nDone."; - let q = parse_response(resp, &vocab()).expect("parse"); + let q = parse_response(resp, &vocab(), chrono_tz::UTC).expect("parse"); assert_eq!(q.semantic.as_deref(), Some("sunset")); assert_eq!(q.tag_ids, vec![1]); } @@ -382,13 +421,13 @@ mod tests { #[test] fn parse_response_handles_think_block_then_json() { let resp = "user wants beach sunsets{\"tags\":[\"beach\",\"sunset\"]}"; - let q = parse_response(resp, &vocab()).expect("parse"); + let q = parse_response(resp, &vocab(), chrono_tz::UTC).expect("parse"); assert_eq!(q.tag_ids, vec![1, 2]); } #[test] fn parse_response_errors_on_non_json() { - assert!(parse_response("I cannot help with that.", &vocab()).is_err()); + assert!(parse_response("I cannot help with that.", &vocab(), chrono_tz::UTC).is_err()); } #[test] diff --git a/src/data/mod.rs b/src/data/mod.rs index b208e3e..34a9424 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -151,7 +151,9 @@ pub struct FilesRequest { pub gps_lon: Option, pub gps_radius_km: Option, - // Date range filtering (Unix timestamps) + // Date range filtering: inclusive true instants (unix seconds), compared + // against each row's true instant (ADR-0001, #152). For calendar days, + // send the day's bounds in the owner's display zone. pub date_from: Option, pub date_to: Option, @@ -530,7 +532,8 @@ pub struct ExifBatchResponse { #[derive(Debug, Deserialize)] pub struct ExifBatchRequest { - /// Lower bound (inclusive) for `image_exif.date_taken`, unix seconds. + /// Lower bound (inclusive) on the row's true instant (`true_instant`), + /// unix seconds. Same semantics as `date_from` on `/photos`. pub date_from: Option, /// Upper bound (inclusive). Same semantics as `date_to` on `/photos`. pub date_to: Option, diff --git a/src/database/mod.rs b/src/database/mod.rs index b58e12a..06948f5 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -369,6 +369,12 @@ pub trait ExifDao: Sync + Send { /// `(library_id, date_taken)` index added in the multi_library /// migration depends on `library_id` being part of the WHERE clause — /// callers that have a library context must pass it. + /// + /// `date_range` filters on the **true instant** (ADR-0001, ticket + /// #152): converted rows on `date_utc`, unconverted rows (drain pending + /// or the permanent floor) through `date_view::row_true_instant`'s + /// per-source fallback. Every row returned satisfies + /// [`InstantRange::contains`](crate::date_view::InstantRange::contains). fn query_by_exif( &mut self, library_id: Option, @@ -376,8 +382,7 @@ pub trait ExifDao: Sync + Send { camera_model: Option<&str>, lens_model: Option<&str>, gps_bounds: Option<(f64, f64, f64, f64)>, // (min_lat, max_lat, min_lon, max_lon) - date_from: Option, - date_to: Option, + date_range: Option, ) -> Result, DbError>; /// Get distinct camera makes with counts @@ -1052,8 +1057,7 @@ impl ExifDao for SqliteExifDao { camera_model_filter: Option<&str>, lens_model_filter: Option<&str>, gps_bounds: Option<(f64, f64, f64, f64)>, - date_from: Option, - date_to: Option, + date_range: Option, ) -> Result, DbError> { trace_db_call("query", "query_by_exif", |_span| { use schema::image_exif::dsl::*; @@ -1087,20 +1091,34 @@ impl ExifDao for SqliteExifDao { .filter(gps_longitude.is_not_null()); } - // Date range - if let Some(from) = date_from { - query = query.filter(date_taken.ge(from)); - } - if let Some(to) = date_to { - query = query.filter(date_taken.le(to)); - } - if date_from.is_some() || date_to.is_some() { - query = query.filter(date_taken.is_not_null()); + // Date range, on the true instant (ADR-0001). SQL is a coarse + // pre-filter: converted rows are tested on `date_utc` exactly; + // unconverted rows on legacy `date_taken` widened by the + // per-source band. Converted rows are deliberately NOT bounded + // by `date_taken` — a re-dated row's legacy value can sit years + // from its instant (filename class outranking EXIF). + if let Some(range) = date_range { + let band = crate::date_view::LEGACY_INSTANT_BAND_SECS; + let lo = range.from.unwrap_or(i64::MIN); + let hi = range.to.unwrap_or(i64::MAX); + query = query.filter(date_taken.is_not_null()).filter( + date_utc.ge(lo).and(date_utc.le(hi)).or(date_utc + .is_null() + .and(date_taken.ge(lo.saturating_sub(band))) + .and(date_taken.le(hi.saturating_add(band)))), + ); } - query + let rows = query .load::(connection.deref_mut()) - .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; + + // Exact pass: the band admits unconverted rows up to 15 h + // outside the range; `row_true_instant` decides. + Ok(match date_range { + Some(range) => rows.into_iter().filter(|r| range.contains(r)).collect(), + None => rows, + }) }) .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } @@ -2553,19 +2571,249 @@ mod exif_dao_tests { // Union: both rows. let all = dao - .query_by_exif(None, None, None, None, None, None, None) + .query_by_exif(None, None, None, None, None, None) .unwrap(); assert_eq!(all.len(), 2); // Scoped to lib 2: only archive row. let lib2 = dao - .query_by_exif(Some(2), None, None, None, None, None, None) + .query_by_exif(Some(2), None, None, None, None, None) .unwrap(); assert_eq!(lib2.len(), 1); assert_eq!(lib2[0].file_path, "archive/a.jpg"); assert_eq!(lib2[0].library_id, 2); } + /// Row with the full date triple, for the true-instant filter tests. + #[allow(clippy::too_many_arguments)] + fn insert_dated_row( + dao: &mut SqliteExifDao, + rel: &str, + legacy: i64, + source: &str, + date_utc: Option, + offset: Option, + tz_source: Option<&str>, + ) { + dao.store_exif(InsertImageExif { + library_id: 1, + file_path: rel.to_string(), + camera_make: None, + camera_model: None, + lens_model: None, + width: None, + height: None, + orientation: None, + gps_latitude: None, + gps_longitude: None, + gps_altitude: None, + focal_length: None, + aperture: None, + shutter_speed: None, + iso: None, + date_taken: Some(legacy), + created_time: 0, + last_modified: 0, + content_hash: None, + size_bytes: None, + phash_64: None, + dhash_64: None, + date_taken_source: Some(source.to_string()), + date_utc, + camera_offset_minutes: offset, + date_taken_tz_source: tz_source.map(str::to_string), + }) + .expect("insert dated exif row"); + } + + /// 2024-07-05 in America/New_York, as true instants. + const JUL5_NY_START: i64 = 1_720_152_000; + const JUL5_NY_END: i64 = 1_720_238_399; + + fn jul5_ny(from: Option, to: Option) -> Option { + crate::date_view::InstantRange::new( + from, + to, + chrono_tz::America::New_York, + Some(chrono_tz::America::New_York), + ) + } + + fn paths(rows: Vec) -> Vec { + let mut v: Vec = rows.into_iter().map(|r| r.file_path).collect(); + v.sort(); + v + } + + /// #152: converted rows filter on `date_utc`, never on the legacy + /// column — not even as an outer bound, since a re-dated row's legacy + /// value can be years away from its instant. + #[test] + fn query_by_exif_date_range_uses_date_utc_for_converted_rows() { + let mut dao = setup_two_libraries(); + // Re-dated: legacy 2014, true instant 2024-07-05 12:00 EDT. + insert_dated_row( + &mut dao, + "redated.jpg", + 1_415_409_780, + "filename", + Some(1_720_195_200), + Some(-240), + Some("utc_direct"), + ); + // Legacy inside the window, true instant (Jul 4 22:00 EDT) outside. + insert_dated_row( + &mut dao, + "legacy_inside.jpg", + 1_720_195_200, + "exif", + Some(1_720_144_800), + Some(-240), + Some("assumed_server"), + ); + + let rows = dao + .query_by_exif( + None, + None, + None, + None, + None, + jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)), + ) + .unwrap(); + assert_eq!(paths(rows), vec!["redated.jpg"]); + } + + /// Unconverted rows (drain pending / permanent floor) are decided by + /// `row_true_instant`: the SQL band admits them, the exact pass rules. + #[test] + fn query_by_exif_date_range_reads_unconverted_rows_by_true_instant() { + let mut dao = setup_two_libraries(); + // exif legacy = wall-as-UTC. Jul 4 22:00 EDT wall: its legacy value + // sits inside the ±15 h band but the instant is the day before. + insert_dated_row( + &mut dao, + "jul4_late.jpg", + 1_720_130_400, + "exif", + None, + None, + None, + ); + // Jul 5 01:00 EDT wall: legacy (01:00 "UTC") is BEFORE the window's + // 04:00Z start — the legacy-column filter used to drop it. + insert_dated_row( + &mut dao, + "jul5_early.jpg", + 1_720_141_200, + "exif", + None, + None, + None, + ); + + let rows = dao + .query_by_exif( + None, + None, + None, + None, + None, + jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)), + ) + .unwrap(); + assert_eq!(paths(rows), vec!["jul5_early.jpg"]); + } + + /// #152 acceptance: a travel-zone photo is in or out of a range + /// exactly as its owner-day grouping says. Tokyo 23:30 on Jul 5 is + /// 10:30 EDT on Jul 5 — the owner's Jul 5 — while its wall-as-UTC + /// legacy value (Jul 5 23:30 "UTC") would mean something else. + #[test] + fn query_by_exif_date_range_follows_owner_day_for_travel_photo() { + let mut dao = setup_two_libraries(); + insert_dated_row( + &mut dao, + "tokyo.jpg", + 1_720_222_200, + "exif", + Some(1_720_189_800), + Some(540), + Some("gps"), + ); + + let in_day = dao + .query_by_exif( + None, + None, + None, + None, + None, + jul5_ny(Some(JUL5_NY_START), Some(JUL5_NY_END)), + ) + .unwrap(); + assert_eq!(paths(in_day), vec!["tokyo.jpg"]); + // Its legacy value falls in the owner's evening; a 12:00–23:59 EDT + // range must still exclude it (the instant is 10:30). + let afternoon = dao + .query_by_exif( + None, + None, + None, + None, + None, + jul5_ny(Some(1_720_195_200), Some(JUL5_NY_END)), + ) + .unwrap(); + assert!(afternoon.is_empty()); + } + + /// Open-ended bounds work, and no range means no date filter at all — + /// rows with no date at all stay eligible (unchanged behavior). + #[test] + fn query_by_exif_date_range_open_bounds_and_absent_range() { + let mut dao = setup_two_libraries(); + insert_dated_row( + &mut dao, + "a.jpg", + 0, + "fs_time", + Some(1_720_195_200), + Some(-240), + Some("utc_direct"), + ); + insert_row(&mut dao, 1, "undated.jpg", None); + + let from_only = dao + .query_by_exif( + None, + None, + None, + None, + None, + jul5_ny(Some(JUL5_NY_START), None), + ) + .unwrap(); + assert_eq!(paths(from_only), vec!["a.jpg"]); + let to_only = dao + .query_by_exif( + None, + None, + None, + None, + None, + jul5_ny(None, Some(JUL5_NY_START)), + ) + .unwrap(); + assert!(to_only.is_empty()); + assert!(jul5_ny(None, None).is_none()); + let all = dao + .query_by_exif(None, None, None, None, None, None) + .unwrap(); + assert_eq!(paths(all), vec!["a.jpg", "undated.jpg"]); + } + #[test] fn get_exif_batch_scopes_by_library_id() { let mut dao = setup_two_libraries(); diff --git a/src/date_view.rs b/src/date_view.rs index 40eb9ab..b49f75b 100644 --- a/src/date_view.rs +++ b/src/date_view.rs @@ -96,6 +96,74 @@ pub fn row_true_instant(row: &ImageExif, display_zone: Tz, server_zone: Option i64 { + [0, 1] + .iter() + .find_map(|h| { + zone.from_local_datetime(&day.and_hms_opt(*h, 0, 0).expect("valid hour")) + .earliest() + }) + .map(|dt| dt.timestamp()) + .unwrap_or_else(|| { + day.and_hms_opt(0, 0, 0) + .expect("midnight") + .and_utc() + .timestamp() + }) +} + +/// How far an unconverted row's legacy `date_taken` can sit from its true +/// instant under the per-source conventions: wall↔instant confusion ≤ 12 h, +/// zone range ≤ 2 h, DST margin 1 h. SQL pre-filters on the legacy column +/// widen their bounds by this much so [`row_true_instant`] can make the +/// exact call in Rust without a row being dropped first. +pub const LEGACY_INSTANT_BAND_SECS: i64 = 15 * 3600; + +/// An inclusive true-instant range (`date_from` / `date_to` on the photo +/// listing endpoints), plus the zones [`row_true_instant`] needs to read +/// unconverted rows. Either bound may be open. +/// +/// Bounds are instants, never wall clocks: a client filtering by calendar +/// day sends that day's midnights in the owner's display zone (see +/// `nl_query::iso_to_unix`), so the range agrees with +/// [`row_display_day`] grouping by construction. +#[derive(Clone, Copy, Debug)] +pub struct InstantRange { + pub from: Option, + pub to: Option, + pub display_zone: Tz, + pub server_zone: Option, +} + +impl InstantRange { + /// `None` when neither bound is set — no date filter at all, so rows + /// with no date stay eligible. + pub fn new( + from: Option, + to: Option, + display_zone: Tz, + server_zone: Option, + ) -> Option { + (from.is_some() || to.is_some()).then_some(InstantRange { + from, + to, + display_zone, + server_zone, + }) + } + + /// Exact test: the row's true instant lies inside the range. A row with + /// no date at all is never inside a date range. + pub fn contains(&self, row: &ImageExif) -> bool { + match row_true_instant(row, self.display_zone, self.server_zone) { + Some(t) => self.from.is_none_or(|f| t >= f) && self.to.is_none_or(|x| t <= x), + None => false, + } + } +} + /// The wall clock the owner sees for this row — camera wall when the row /// carries an offset (a Japan photo shows the Japan wall), display-zone /// rendering when it doesn't. Unconverted rows use their per-source diff --git a/src/files.rs b/src/files.rs index 774c656..3be4815 100644 --- a/src/files.rs +++ b/src/files.rs @@ -317,6 +317,14 @@ pub async fn list_photos( // filter must be scoped to it — otherwise camera/date/GPS hits // from other libraries would pollute the result set even though // downstream filesystem walks would never visit those files. + // True-instant date range (ADR-0001, #152). Zones resolved before + // the DAO mutex so the prefs lock isn't nested inside it. + let date_range = crate::date_view::InstantRange::new( + req.date_from, + req.date_to, + crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao), + crate::camera_tz::server_zone(), + ); let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao"); let exif_results = exif_dao_guard .query_by_exif( @@ -325,8 +333,7 @@ pub async fn list_photos( req.camera_model.as_deref(), req.lens_model.as_deref(), gps_bounds, - req.date_from, - req.date_to, + date_range, ) .unwrap_or_else(|e| { warn!("EXIF query failed: {:?}", e); @@ -1228,8 +1235,7 @@ pub async fn list_exif_summary( None, None, None, - req.date_from, - req.date_to, + crate::date_view::InstantRange::new(req.date_from, req.date_to, display_zone, server_zone), ) { Ok(rows) => { let photos: Vec = rows @@ -1599,8 +1605,7 @@ mod tests { _: Option<&str>, _: Option<&str>, _: Option<(f64, f64, f64, f64)>, - _: Option, - _: Option, + _: Option, ) -> Result, DbError> { Ok(Vec::new()) } diff --git a/src/memories.rs b/src/memories.rs index 2b5f335..3f1c3eb 100644 --- a/src/memories.rs +++ b/src/memories.rs @@ -195,23 +195,9 @@ pub fn memories_anchor( .date_naive(); return (now.timestamp(), today); }; - // End of D = start of D+1 minus a second. Midnight can fall in a DST - // gap in a few zones; fall back to 01:00, then to a UTC reading. + // End of D = start of D+1 minus a second. let next = d.succ_opt().unwrap_or(d); - let start_of_next = [0, 1] - .iter() - .find_map(|h| { - display_zone - .from_local_datetime(&next.and_hms_opt(*h, 0, 0).expect("valid hour")) - .earliest() - }) - .map(|dt| dt.timestamp()) - .unwrap_or_else(|| { - next.and_hms_opt(0, 0, 0) - .expect("midnight") - .and_utc() - .timestamp() - }); + let start_of_next = crate::date_view::day_start_instant(next, display_zone); (start_of_next - 1, d) } @@ -233,13 +219,14 @@ pub fn parse_reference_date(raw: Option<&str>) -> Result, Stri /// or past-year rows are dropped before [`day_in_span`] gets to match /// them. Converted rows are tested on `date_utc` (the true instant); /// unconverted rows on legacy `date_taken`, which sits up to ~15 h off -/// the instant under the per-source conventions (wall↔instant confusion -/// ≤ 12 h, zone range ≤ 2 h, DST margin 1 h) — hence the ±15 h band. +/// the instant under the per-source conventions — hence the ±15 h band +/// ([`crate::date_view::LEGACY_INSTANT_BAND_SECS`]). /// By construction the band subsumes the converted window, so the DAO's /// single outer bound on `date_taken` never drops a converted row. pub fn memories_span_windows(now_ts: i64, years_back: i64) -> (i64, i64, i64, i64) { let years_lo = now_ts - years_back * 365 * 86_400; - (years_lo, now_ts, years_lo - 15 * 3600, now_ts + 15 * 3600) + let band = crate::date_view::LEGACY_INSTANT_BAND_SECS; + (years_lo, now_ts, years_lo - band, now_ts + band) } /// True if `display_day` falls in the span (day/week/month) containing diff --git a/src/unified_search.rs b/src/unified_search.rs index 2e874ee..3b881fb 100644 --- a/src/unified_search.rs +++ b/src/unified_search.rs @@ -208,8 +208,13 @@ pub async fn unified_search( }; log::info!("unified_search: translating with model={}", backend.model()); - let today = chrono::Utc::now().date_naive(); - let sq = match translate_nl_query(backend.chat(), &nl, &tag_vocab, today).await { + // The owner's day and zone, not UTC's: relative dates ("last week") + // anchor on the owner's today, and ISO dates become display-zone day + // bounds that the true-instant filters compare against (#152). + let display_zone = crate::date_view::owner_display_zone(&state.user_ai_prefs_dao); + let server_zone = crate::camera_tz::server_zone(); + let today = chrono::Utc::now().with_timezone(&display_zone).date_naive(); + let sq = match translate_nl_query(backend.chat(), &nl, &tag_vocab, today, display_zone).await { Ok(sq) => sq, Err(e) => { log::warn!("unified_search: translate_nl_query failed: {e:?}"); @@ -297,8 +302,12 @@ pub async fn unified_search( sq.camera_model.as_deref(), sq.lens_model.as_deref(), gps_bounds, - sq.date_from, - sq.date_to, + crate::date_view::InstantRange::new( + sq.date_from, + sq.date_to, + display_zone, + server_zone, + ), ) .unwrap_or_else(|e| { log::warn!("unified_search: query_by_exif failed: {e:?}"); -- 2.54.0