feat(dates): photo date filters on the true instant (#152)
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<date_view::InstantRange> (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 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e780774d9
commit
19fbf43d40
12 files changed
+478
-90
No files matched your search
+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();
|
||||
|
||||
Reference in new issue
Block a user