feat(dates): photo date filters on the true instant (#152) #162
No files matched your search
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -3,10 +3,13 @@
|
||||
|
||||
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`;
|
||||
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.
|
||||
`/image/metadata`, `/memories` (today's day span), `/photos/gps-summary` and
|
||||
`/photos/exif`; and `/image/metadata` / `/photos/exif`'s `true_instant` must
|
||||
equal the expected instant. The `/photos/exif` date filter must also treat
|
||||
that instant as a range edge (#152): `[t, t]` includes the file, and a range
|
||||
ending at `t - 1` or starting at `t + 1` excludes it. One disagreement
|
||||
anywhere is exactly the class of bug these fields exist to end, so any
|
||||
mismatch fails the run.
|
||||
|
||||
Usage: check_wall_clock.py <manifest.json> (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
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -232,7 +232,8 @@ Usage: dev/e2e/e2e.sh <command>
|
||||
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'
|
||||
|
||||
+72
-33
@@ -88,18 +88,20 @@ pub struct StructuredQuery {
|
||||
pub media_type: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<i64> {
|
||||
/// 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<i64> {
|
||||
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<String> {
|
||||
/// `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<String, i32> = 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<StructuredQuery> {
|
||||
pub fn parse_response(
|
||||
response: &str,
|
||||
tag_vocab: &[(i32, String)],
|
||||
display_zone: chrono_tz::Tz,
|
||||
) -> Result<StructuredQuery> {
|
||||
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<StructuredQuery> {
|
||||
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::<Tool>::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 = "<think>user wants beach sunsets</think>{\"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]
|
||||
|
||||
+5
-2
@@ -151,7 +151,9 @@ pub struct FilesRequest {
|
||||
pub gps_lon: Option<f64>,
|
||||
pub gps_radius_km: Option<f64>,
|
||||
|
||||
// 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<i64>,
|
||||
pub date_to: Option<i64>,
|
||||
|
||||
@@ -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<i64>,
|
||||
/// Upper bound (inclusive). Same semantics as `date_to` on `/photos`.
|
||||
pub date_to: Option<i64>,
|
||||
|
||||
+265
-17
@@ -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<i32>,
|
||||
@@ -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<i64>,
|
||||
date_to: Option<i64>,
|
||||
date_range: Option<crate::date_view::InstantRange>,
|
||||
) -> Result<Vec<ImageExif>, 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<i64>,
|
||||
date_to: Option<i64>,
|
||||
date_range: Option<crate::date_view::InstantRange>,
|
||||
) -> Result<Vec<ImageExif>, 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::<ImageExif>(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<i64>,
|
||||
offset: Option<i32>,
|
||||
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<i64>, to: Option<i64>) -> Option<crate::date_view::InstantRange> {
|
||||
crate::date_view::InstantRange::new(
|
||||
from,
|
||||
to,
|
||||
chrono_tz::America::New_York,
|
||||
Some(chrono_tz::America::New_York),
|
||||
)
|
||||
}
|
||||
|
||||
fn paths(rows: Vec<ImageExif>) -> Vec<String> {
|
||||
let mut v: Vec<String> = 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();
|
||||
|
||||
@@ -96,6 +96,74 @@ pub fn row_true_instant(row: &ImageExif, display_zone: Tz, server_zone: Option<T
|
||||
}
|
||||
}
|
||||
|
||||
/// The true instant a calendar day starts at in `zone`. Midnight can fall
|
||||
/// in a DST gap in a few zones; fall back to 01:00, then to a UTC reading.
|
||||
pub fn day_start_instant(day: NaiveDate, zone: Tz) -> 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<i64>,
|
||||
pub to: Option<i64>,
|
||||
pub display_zone: Tz,
|
||||
pub server_zone: Option<Tz>,
|
||||
}
|
||||
|
||||
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<i64>,
|
||||
to: Option<i64>,
|
||||
display_zone: Tz,
|
||||
server_zone: Option<Tz>,
|
||||
) -> Option<Self> {
|
||||
(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
|
||||
|
||||
+11
-6
@@ -317,6 +317,14 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
// 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<TagD: TagDao, FS: FileSystemAccess>(
|
||||
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<ExifSummary> = rows
|
||||
@@ -1599,8 +1605,7 @@ mod tests {
|
||||
_: Option<&str>,
|
||||
_: Option<&str>,
|
||||
_: Option<(f64, f64, f64, f64)>,
|
||||
_: Option<i64>,
|
||||
_: Option<i64>,
|
||||
_: Option<crate::date_view::InstantRange>,
|
||||
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
+6
-19
@@ -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<Option<NaiveDate>, 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
|
||||
|
||||
+13
-4
@@ -208,8 +208,13 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
};
|
||||
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<TagD: TagDao>(
|
||||
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:?}");
|
||||
|
||||
Reference in new issue
Block a user