Merge pull request 'feat(memories): optional reference_date to browse relative to another day' (#159) from feature/memories-reference-date into master
Reviewed-on: #159
This commit was merged in pull request #159.
This commit is contained in:
commit
991622af2a
2 files changed
+180
-9
No files matched your search
+178
-9
@@ -144,6 +144,10 @@ pub struct MemoriesRequest {
|
||||
/// Optional library filter. Accepts a library id (e.g. "1") or name
|
||||
/// (e.g. "main"). When omitted, results span all libraries.
|
||||
pub library: Option<String>,
|
||||
/// Optional anchor date (`YYYY-MM-DD`, a calendar day in the owner's
|
||||
/// display zone) to browse "on this day/week/month" relative to
|
||||
/// instead of today. Omitted → today, unchanged behaviour.
|
||||
pub reference_date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
@@ -175,6 +179,52 @@ pub struct MemoriesResponse {
|
||||
pub items: Vec<MemoryItem>,
|
||||
}
|
||||
|
||||
/// Resolve the memories anchor: the `now_ts` that bounds the pre-filter
|
||||
/// window and the `today` that [`day_in_span`] matches against, both in
|
||||
/// the owner's display zone. With a reference date `D`, the window ends
|
||||
/// at the end of `D` (so a future `D` surfaces everything already taken,
|
||||
/// and the years-back lookback is anchored on `D`, not on now).
|
||||
pub fn memories_anchor(
|
||||
reference_date: Option<NaiveDate>,
|
||||
display_zone: chrono_tz::Tz,
|
||||
now: DateTime<Utc>,
|
||||
) -> (i64, NaiveDate) {
|
||||
let Some(d) = reference_date else {
|
||||
let today = display_zone
|
||||
.from_utc_datetime(&now.naive_utc())
|
||||
.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.
|
||||
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()
|
||||
});
|
||||
(start_of_next - 1, d)
|
||||
}
|
||||
|
||||
/// Parse the `/memories` `reference_date` param (`YYYY-MM-DD`).
|
||||
pub fn parse_reference_date(raw: Option<&str>) -> Result<Option<NaiveDate>, String> {
|
||||
match raw.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => Ok(None),
|
||||
Some(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||
.map(Some)
|
||||
.map_err(|_| format!("invalid reference_date {s:?} (expected YYYY-MM-DD)")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Epoch windows for the memories SQL pre-filter (ADR-0001).
|
||||
///
|
||||
/// The span feature matches the same calendar position (month-day /
|
||||
@@ -519,12 +569,21 @@ pub async fn list_memories(
|
||||
let display_zone =
|
||||
chrono_tz::Tz::from_str(&display_zone_name).unwrap_or(chrono_tz::Tz::America__New_York);
|
||||
|
||||
let reference_date = match parse_reference_date(q.reference_date.as_deref()) {
|
||||
Ok(d) => d,
|
||||
Err(msg) => {
|
||||
warn!("Rejecting /memories request: {}", msg);
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
}
|
||||
};
|
||||
|
||||
let items = match gather_memory_items(
|
||||
&app_state,
|
||||
&exif_dao,
|
||||
span_mode,
|
||||
display_zone,
|
||||
q.library.as_deref(),
|
||||
reference_date,
|
||||
) {
|
||||
Ok(items) => items,
|
||||
Err(msg) => {
|
||||
@@ -540,6 +599,10 @@ pub async fn list_memories(
|
||||
KeyValue::new("years_back", DEFAULT_YEARS_BACK.to_string()),
|
||||
KeyValue::new("result_count", items.len().to_string()),
|
||||
KeyValue::new("display_tz", display_zone_name),
|
||||
KeyValue::new(
|
||||
"reference_date",
|
||||
reference_date.map_or_else(|| "today".to_string(), |d| d.to_string()),
|
||||
),
|
||||
KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)),
|
||||
],
|
||||
);
|
||||
@@ -561,20 +624,20 @@ pub async fn list_memories(
|
||||
/// unconverted EXIF wall clock both land in the same day a photo taken
|
||||
/// locally did. The SQL pass is an interval pre-filter; exact span-day
|
||||
/// matching happens here in Rust via [`date_view`].
|
||||
///
|
||||
/// `reference_date` shifts "today" to another day in the display zone
|
||||
/// (see [`memories_anchor`]); `None` anchors on the real now.
|
||||
pub fn gather_memory_items(
|
||||
app_state: &AppState,
|
||||
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
||||
span_mode: MemoriesSpan,
|
||||
display_zone: chrono_tz::Tz,
|
||||
library_param: Option<&str>,
|
||||
reference_date: Option<NaiveDate>,
|
||||
) -> Result<Vec<MemoryItem>, String> {
|
||||
let years_back: i64 = DEFAULT_YEARS_BACK as i64;
|
||||
|
||||
let now = Utc::now();
|
||||
let now_ts = now.timestamp();
|
||||
let today_d = display_zone
|
||||
.from_utc_datetime(&now.naive_utc())
|
||||
.date_naive();
|
||||
let (now_ts, today_d) = memories_anchor(reference_date, display_zone, Utc::now());
|
||||
// The SQL pass is a coarse pre-filter over the FULL years-back window
|
||||
// (not the current span window) because this feature matches the same
|
||||
// month-day / week / month across every past year; [`day_in_span`] does
|
||||
@@ -583,8 +646,8 @@ pub fn gather_memory_items(
|
||||
let server_zone = crate::camera_tz::server_zone();
|
||||
|
||||
debug!(
|
||||
"gather_memory_items: span={:?} display_zone={:?} years_back={}",
|
||||
span_mode, display_zone, years_back
|
||||
"gather_memory_items: span={:?} display_zone={:?} years_back={} today={}",
|
||||
span_mode, display_zone, years_back, today_d
|
||||
);
|
||||
|
||||
let library = crate::libraries::resolve_library_param_state(app_state, library_param)?;
|
||||
@@ -1241,6 +1304,60 @@ mod tests {
|
||||
assert!(ulo <= clo && chi <= uhi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memories_anchor_defaults_to_now_in_display_zone() {
|
||||
let zone = chrono_tz::Tz::America__New_York;
|
||||
// 2024-07-05 02:00 UTC is still Jul 4 in New York.
|
||||
let now = Utc.with_ymd_and_hms(2024, 7, 5, 2, 0, 0).unwrap();
|
||||
let (ts, today) = memories_anchor(None, zone, now);
|
||||
assert_eq!(ts, now.timestamp());
|
||||
assert_eq!(today, NaiveDate::from_ymd_opt(2024, 7, 4).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memories_anchor_reference_date_ends_at_end_of_that_day() {
|
||||
let zone = chrono_tz::Tz::America__New_York;
|
||||
let d = NaiveDate::from_ymd_opt(2015, 3, 10).unwrap();
|
||||
let (ts, today) = memories_anchor(Some(d), zone, Utc::now());
|
||||
assert_eq!(today, d);
|
||||
// 2015-03-10 23:59:59 EDT (DST began Mar 8) = 03:59:59 UTC next day.
|
||||
assert_eq!(
|
||||
ts,
|
||||
Utc.with_ymd_and_hms(2015, 3, 11, 3, 59, 59)
|
||||
.unwrap()
|
||||
.timestamp()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memories_anchor_survives_midnight_dst_gap() {
|
||||
// America/Santiago springs forward at 00:00 → 01:00 (2023-09-03).
|
||||
let zone = chrono_tz::Tz::America__Santiago;
|
||||
let d = NaiveDate::from_ymd_opt(2023, 9, 2).unwrap();
|
||||
let (ts, today) = memories_anchor(Some(d), zone, Utc::now());
|
||||
assert_eq!(today, d);
|
||||
// Sep 3 01:00 -03 = 04:00 UTC; minus one second.
|
||||
assert_eq!(
|
||||
ts,
|
||||
Utc.with_ymd_and_hms(2023, 9, 3, 3, 59, 59)
|
||||
.unwrap()
|
||||
.timestamp()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_reference_date_accepts_absent_empty_and_iso() {
|
||||
assert_eq!(parse_reference_date(None), Ok(None));
|
||||
assert_eq!(parse_reference_date(Some("")), Ok(None));
|
||||
assert_eq!(
|
||||
parse_reference_date(Some("2015-03-10")),
|
||||
Ok(NaiveDate::from_ymd_opt(2015, 3, 10))
|
||||
);
|
||||
assert!(parse_reference_date(Some("2015-13-01")).is_err());
|
||||
assert!(parse_reference_date(Some("03/10/2015")).is_err());
|
||||
assert!(parse_reference_date(Some("2015-02-30")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_in_span_day_and_month() {
|
||||
let today = NaiveDate::from_ymd_opt(2024, 7, 4).unwrap();
|
||||
@@ -1442,8 +1559,9 @@ mod tests {
|
||||
seed_row(&mut dao, "unconv-vid.mp4", vid_legacy, "exiftool");
|
||||
|
||||
let exif_mutex: Mutex<Box<dyn ExifDao>> = Mutex::new(Box::new(dao));
|
||||
let items = gather_memory_items(&state, &exif_mutex, MemoriesSpan::Month, zone, None)
|
||||
.expect("gather");
|
||||
let items =
|
||||
gather_memory_items(&state, &exif_mutex, MemoriesSpan::Month, zone, None, None)
|
||||
.expect("gather");
|
||||
let by_path = |p: &str| items.iter().find(|it| it.path == p);
|
||||
|
||||
// All three rows group into the same owner month (AC-2/AC-3).
|
||||
@@ -1501,6 +1619,57 @@ mod tests {
|
||||
assert_eq!(vid.created, Some(vid_expected));
|
||||
}
|
||||
|
||||
/// Seed a converted row (true instant known), so grouping is
|
||||
/// independent of the server zone's legacy convention.
|
||||
fn seed_converted(dao: &mut SqliteExifDao, rel: &str, instant: NaiveDateTime) {
|
||||
let ts = instant.and_utc().timestamp();
|
||||
seed_row(dao, rel, ts, "exif");
|
||||
dao.backfill_date_taken(1, rel, ts, "exif", Some(ts), None, Some("utc_direct"))
|
||||
.expect("convert row");
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn memories_reference_date_anchors_day_and_lookback() {
|
||||
let state = AppState::test_state();
|
||||
let zone = chrono_tz::Tz::America__New_York;
|
||||
let noon = |y: i32, m: u32, d: u32| {
|
||||
NaiveDate::from_ymd_opt(y, m, d)
|
||||
.unwrap()
|
||||
.and_hms_opt(12, 0, 0)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let mut dao = SqliteExifDao::from_connection(in_memory_db_connection());
|
||||
seed_converted(&mut dao, "2010-same-day.jpg", noon(2010, 3, 10));
|
||||
seed_converted(&mut dao, "1996-same-day.jpg", noon(1996, 3, 10));
|
||||
seed_converted(&mut dao, "1994-too-old.jpg", noon(1994, 3, 10));
|
||||
seed_converted(&mut dao, "2016-after-ref.jpg", noon(2016, 3, 10));
|
||||
seed_converted(&mut dao, "2010-next-day.jpg", noon(2010, 3, 11));
|
||||
let exif_mutex: Mutex<Box<dyn ExifDao>> = Mutex::new(Box::new(dao));
|
||||
|
||||
let paths = |d: NaiveDate| -> Vec<String> {
|
||||
gather_memory_items(&state, &exif_mutex, MemoriesSpan::Day, zone, None, Some(d))
|
||||
.expect("gather")
|
||||
.into_iter()
|
||||
.map(|m| m.path)
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Past reference date: same month-day, within 20 years
|
||||
// *before D*, nothing after D.
|
||||
assert_eq!(
|
||||
paths(NaiveDate::from_ymd_opt(2015, 3, 10).unwrap()),
|
||||
vec!["1996-same-day.jpg", "2010-same-day.jpg"]
|
||||
);
|
||||
|
||||
// Future reference date: lookback anchored on D, so 2016 is
|
||||
// in range and 1996/2010 have aged out of the 20-year window.
|
||||
assert_eq!(
|
||||
paths(NaiveDate::from_ymd_opt(2030, 3, 10).unwrap()),
|
||||
vec!["2016-after-ref.jpg"]
|
||||
);
|
||||
}
|
||||
|
||||
/// AC-1 (response shape): the serialized `/memories` item carries
|
||||
/// the new-contract triple as explicit fields — null while the row
|
||||
/// is unconverted.
|
||||
|
||||
@@ -296,6 +296,8 @@ pub fn resolve(
|
||||
*span,
|
||||
zone,
|
||||
library.as_deref(),
|
||||
// Reels are always "now" (MVP): no reference date.
|
||||
None,
|
||||
)?;
|
||||
|
||||
// Split into photos and video clips; anything that's neither is
|
||||
|
||||
Reference in new issue
Block a user