feat(memories): optional reference_date to browse relative to another day #159
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
|
/// Optional library filter. Accepts a library id (e.g. "1") or name
|
||||||
/// (e.g. "main"). When omitted, results span all libraries.
|
/// (e.g. "main"). When omitted, results span all libraries.
|
||||||
pub library: Option<String>,
|
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)]
|
#[derive(Debug, Serialize, Clone)]
|
||||||
@@ -175,6 +179,52 @@ pub struct MemoriesResponse {
|
|||||||
pub items: Vec<MemoryItem>,
|
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).
|
/// Epoch windows for the memories SQL pre-filter (ADR-0001).
|
||||||
///
|
///
|
||||||
/// The span feature matches the same calendar position (month-day /
|
/// The span feature matches the same calendar position (month-day /
|
||||||
@@ -519,12 +569,21 @@ pub async fn list_memories(
|
|||||||
let display_zone =
|
let display_zone =
|
||||||
chrono_tz::Tz::from_str(&display_zone_name).unwrap_or(chrono_tz::Tz::America__New_York);
|
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(
|
let items = match gather_memory_items(
|
||||||
&app_state,
|
&app_state,
|
||||||
&exif_dao,
|
&exif_dao,
|
||||||
span_mode,
|
span_mode,
|
||||||
display_zone,
|
display_zone,
|
||||||
q.library.as_deref(),
|
q.library.as_deref(),
|
||||||
|
reference_date,
|
||||||
) {
|
) {
|
||||||
Ok(items) => items,
|
Ok(items) => items,
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
@@ -540,6 +599,10 @@ pub async fn list_memories(
|
|||||||
KeyValue::new("years_back", DEFAULT_YEARS_BACK.to_string()),
|
KeyValue::new("years_back", DEFAULT_YEARS_BACK.to_string()),
|
||||||
KeyValue::new("result_count", items.len().to_string()),
|
KeyValue::new("result_count", items.len().to_string()),
|
||||||
KeyValue::new("display_tz", display_zone_name),
|
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)),
|
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
|
/// 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
|
/// locally did. The SQL pass is an interval pre-filter; exact span-day
|
||||||
/// matching happens here in Rust via [`date_view`].
|
/// 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(
|
pub fn gather_memory_items(
|
||||||
app_state: &AppState,
|
app_state: &AppState,
|
||||||
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
||||||
span_mode: MemoriesSpan,
|
span_mode: MemoriesSpan,
|
||||||
display_zone: chrono_tz::Tz,
|
display_zone: chrono_tz::Tz,
|
||||||
library_param: Option<&str>,
|
library_param: Option<&str>,
|
||||||
|
reference_date: Option<NaiveDate>,
|
||||||
) -> Result<Vec<MemoryItem>, String> {
|
) -> Result<Vec<MemoryItem>, String> {
|
||||||
let years_back: i64 = DEFAULT_YEARS_BACK as i64;
|
let years_back: i64 = DEFAULT_YEARS_BACK as i64;
|
||||||
|
|
||||||
let now = Utc::now();
|
let (now_ts, today_d) = memories_anchor(reference_date, display_zone, Utc::now());
|
||||||
let now_ts = now.timestamp();
|
|
||||||
let today_d = display_zone
|
|
||||||
.from_utc_datetime(&now.naive_utc())
|
|
||||||
.date_naive();
|
|
||||||
// The SQL pass is a coarse pre-filter over the FULL years-back window
|
// 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
|
// (not the current span window) because this feature matches the same
|
||||||
// month-day / week / month across every past year; [`day_in_span`] does
|
// 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();
|
let server_zone = crate::camera_tz::server_zone();
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
"gather_memory_items: span={:?} display_zone={:?} years_back={}",
|
"gather_memory_items: span={:?} display_zone={:?} years_back={} today={}",
|
||||||
span_mode, display_zone, years_back
|
span_mode, display_zone, years_back, today_d
|
||||||
);
|
);
|
||||||
|
|
||||||
let library = crate::libraries::resolve_library_param_state(app_state, library_param)?;
|
let library = crate::libraries::resolve_library_param_state(app_state, library_param)?;
|
||||||
@@ -1241,6 +1304,60 @@ mod tests {
|
|||||||
assert!(ulo <= clo && chi <= uhi);
|
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]
|
#[test]
|
||||||
fn day_in_span_day_and_month() {
|
fn day_in_span_day_and_month() {
|
||||||
let today = NaiveDate::from_ymd_opt(2024, 7, 4).unwrap();
|
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");
|
seed_row(&mut dao, "unconv-vid.mp4", vid_legacy, "exiftool");
|
||||||
|
|
||||||
let exif_mutex: Mutex<Box<dyn ExifDao>> = Mutex::new(Box::new(dao));
|
let exif_mutex: Mutex<Box<dyn ExifDao>> = Mutex::new(Box::new(dao));
|
||||||
let items = gather_memory_items(&state, &exif_mutex, MemoriesSpan::Month, zone, None)
|
let items =
|
||||||
.expect("gather");
|
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);
|
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).
|
// 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));
|
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
|
/// AC-1 (response shape): the serialized `/memories` item carries
|
||||||
/// the new-contract triple as explicit fields — null while the row
|
/// the new-contract triple as explicit fields — null while the row
|
||||||
/// is unconverted.
|
/// is unconverted.
|
||||||
|
|||||||
@@ -296,6 +296,8 @@ pub fn resolve(
|
|||||||
*span,
|
*span,
|
||||||
zone,
|
zone,
|
||||||
library.as_deref(),
|
library.as_deref(),
|
||||||
|
// Reels are always "now" (MVP): no reference date.
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Split into photos and video clips; anything that's neither is
|
// Split into photos and video clips; anything that's neither is
|
||||||
|
|||||||
Reference in new issue
Block a user