use actix_web::web::Data; use actix_web::{HttpRequest, HttpResponse, Responder, get, web}; use chrono::{DateTime, FixedOffset, Local, NaiveDate, TimeZone, Utc}; use log::{debug, trace, warn}; use opentelemetry::KeyValue; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use serde::{Deserialize, Serialize}; use std::path::Path; use std::path::PathBuf; use std::sync::Mutex; use crate::data::Claims; use crate::database::ExifDao; use crate::otel::{extract_context_from_request, global_tracer}; use crate::state::AppState; // Helper that encapsulates path-exclusion semantics #[derive(Debug)] pub struct PathExcluder { base: PathBuf, excluded_dirs: Vec, excluded_patterns: Vec, } impl PathExcluder { /// Build from a `base` path and the raw exclusion entries. /// /// Rules: /// - Entries starting with '/' are interpreted as "absolute under base" /// (e.g. "/photos/private" -> base/photos/private). /// - Entries without '/' are treated as path-component patterns that /// match a directory or file name *under* `base`. The base prefix is /// stripped before matching so a system-level component (e.g. the /// `tmp` in `/tmp/...` when running tests) doesn't masquerade as a /// user-defined exclude. pub fn new(base: &Path, raw_excluded: &[String]) -> Self { let mut excluded_dirs = Vec::new(); let mut excluded_patterns = Vec::new(); for dir in raw_excluded { if let Some(rel) = dir.strip_prefix('/') { // Absolute under base if !rel.is_empty() { excluded_dirs.push(base.join(rel)); } } else { // Pattern anywhere under base excluded_patterns.push(dir.clone()); } } debug!( "PathExcluder created. base={:?}, dirs={:?}, patterns={:?}", base, excluded_dirs, excluded_patterns ); Self { base: base.to_path_buf(), excluded_dirs, excluded_patterns, } } /// Returns true if `path` should be excluded. pub fn is_excluded(&self, path: &Path) -> bool { // Directory-based exclusions for excluded in &self.excluded_dirs { if path.starts_with(excluded) { trace!( "PathExcluder: excluded by dir: {:?} (rule: {:?})", path, excluded ); return true; } } if self.excluded_patterns.is_empty() { return false; } // Strip the base prefix before scanning components. Without this, // every path component above `base` (e.g. `tmp` in `/tmp/test123` // under tempdir, or the user's `home` in `/home/user/Pictures`) // would match user-defined patterns and produce false positives. let scan_root = path.strip_prefix(&self.base).unwrap_or(path); for component in scan_root.components() { if let Some(comp_str) = component.as_os_str().to_str() && self.excluded_patterns.iter().any(|pat| pat == comp_str) { trace!( "PathExcluder: excluded by component pattern: {:?} (component: {:?}, patterns: {:?})", path, comp_str, self.excluded_patterns ); return true; } } false } } #[derive(Copy, Clone, Deserialize, PartialEq, Debug)] #[serde(rename_all = "lowercase")] pub enum MemoriesSpan { Day, Week, Month, } #[derive(Deserialize)] pub struct MemoriesRequest { pub span: Option, /// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for CET) pub timezone_offset_minutes: Option, /// Optional library filter. Accepts a library id (e.g. "1") or name /// (e.g. "main"). When omitted, results span all libraries. pub library: Option, } #[derive(Debug, Serialize, Clone)] pub struct MemoryItem { pub path: String, pub created: Option, pub modified: Option, /// Id of the library this memory belongs to. Allows clients to show a /// per-item source badge in union mode. pub library_id: i32, } #[derive(Debug, Serialize)] pub struct MemoriesResponse { pub items: Vec, } pub fn extract_date_from_filename(filename: &str) -> Option> { // Filenames carry only digits — no timezone. We deliberately interpret // them as UTC so `.timestamp()` returns the wall-clock-as-UTC unix // seconds, matching the "naive local reinterpreted as UTC" convention // image_exif.date_taken uses for kamadak-exif DateTimeOriginal (which // is also naive). Anything else (Local::from_local_datetime, the // previous behavior) shifted filename-sourced dates by the SERVER's // TZ offset relative to UTC, making them disagree with EXIF-sourced // dates by hours and double-shifting through Apollo's photo matcher // (which re-anchors naive-as-UTC via the browser TZ). let build_date_from_ymd_capture = |captures: ®ex::Captures| -> Option> { let year = captures.get(1)?.as_str().parse::().ok()?; let month = captures.get(2)?.as_str().parse::().ok()?; let day = captures.get(3)?.as_str().parse::().ok()?; let hour = captures.get(4)?.as_str().parse::().ok()?; let min = captures.get(5)?.as_str().parse::().ok()?; let sec = captures.get(6)?.as_str().parse::().ok()?; let naive = NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, min, sec)?; Some(Utc.from_utc_datetime(&naive).fixed_offset()) }; // 1. Screenshot format: Screenshot_2014-06-01-20-44-50.png if let Some(captures) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})") .ok()? .captures(filename) .and_then(|c| build_date_from_ymd_capture(&c)) { return Some(captures); } // Screenshot format: Screenshot_20140601[_-]204450.png if let Some(captures) = regex::Regex::new(r"(\d{4})(\d{2})(\d{2})[_-](\d{2})(\d{2})(\d{2})") .ok()? .captures(filename) .and_then(|c| build_date_from_ymd_capture(&c)) { return Some(captures); } // 2. Dash format: 2015-01-09_02-15-15.jpg if let Some(captures) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})") .ok()? .captures(filename) .and_then(|c| build_date_from_ymd_capture(&c)) { return Some(captures); } // Dash with compact time format: 2015-01-09-021515.jpg if let Some(captures) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})") .ok()? .captures(filename) .and_then(|c| build_date_from_ymd_capture(&c)) { return Some(captures); } // 3. Compact format: 20140927101712.jpg if let Some(captures) = regex::Regex::new(r"(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})") .ok()? .captures(filename) .and_then(|c| build_date_from_ymd_capture(&c)) { return Some(captures); } // 4. Timestamp format: 1401638400.jpeg, att_1422489664680106.jpeg, att_142248967186928.jpeg // Matches timestamps with 10-16 digits (seconds, milliseconds, microseconds) if let Some(captures) = regex::Regex::new(r"(\d{10,16})\.").ok()?.captures(filename) { let timestamp_str = captures.get(1)?.as_str(); let len = timestamp_str.len(); // Skip autogenerated filenames that start with "10000" (e.g., 1000004178.jpg) // These are not timestamps but auto-generated file IDs if timestamp_str.starts_with("10000") { return None; } // Try milliseconds first (13 digits exactly) if len == 13 && let Some(date_time) = timestamp_str .parse::() .ok() .and_then(DateTime::from_timestamp_millis) .map(|naive_dt| naive_dt.fixed_offset()) { return Some(date_time); } // For 14-16 digits, treat first 10 digits as seconds to avoid far future dates // Examples: att_1422489664680106 (16 digits), att_142248967186928 (15 digits) if (14..=16).contains(&len) && let Some(date_time) = timestamp_str[0..10] .parse::() .ok() .and_then(|timestamp_secs| DateTime::from_timestamp(timestamp_secs, 0)) .map(|naive_dt| naive_dt.fixed_offset()) { return Some(date_time); } // Exactly 10 digits - seconds since epoch if len == 10 && let Some(date_time) = timestamp_str .parse::() .ok() .and_then(|timestamp_secs| DateTime::from_timestamp(timestamp_secs, 0)) .map(|naive_dt| naive_dt.fixed_offset()) { return Some(date_time); } // 11-12 digits: try as milliseconds (might be partial millisecond timestamp) if (len == 11 || len == 12) && let Some(date_time) = timestamp_str .parse::() .ok() .and_then(DateTime::from_timestamp_millis) .map(|naive_dt| naive_dt.fixed_offset()) { return Some(date_time); } } None } /// Convert a `date_taken` Unix-seconds value to a `NaiveDate` in the /// client's local time. Falls back to server-local when the client didn't /// send a tz hint. fn date_in_client_tz(timestamp: i64, client_timezone: Option) -> Option { let dt = DateTime::from_timestamp(timestamp, 0)?; Some(match client_timezone { Some(tz) => dt.with_timezone(&tz).date_naive(), None => dt.with_timezone(&Local).date_naive(), }) } /// Default lookback for `/memories`. The original 15-year cap pre-dated /// most of the imported libraries; bumped to 20 so users with deeper /// archives see those photos surface on the matching anniversary too. pub const DEFAULT_YEARS_BACK: i32 = 20; #[get("/memories")] pub async fn list_memories( _claims: Claims, request: HttpRequest, q: web::Query, app_state: Data, exif_dao: Data>>, ) -> impl Responder { let tracer = global_tracer(); let parent_context = extract_context_from_request(&request); let mut span = tracer.start_with_context("list_memories", &parent_context); let span_context = opentelemetry::Context::new().with_remote_span_context(span.span_context().clone()); let span_mode = q.span.unwrap_or(MemoriesSpan::Day); let span_token = match span_mode { MemoriesSpan::Day => "day", MemoriesSpan::Week => "week", MemoriesSpan::Month => "month", }; let years_back: i32 = DEFAULT_YEARS_BACK; // The SQL filter expects a signed offset in minutes from UTC; default // 0 (UTC) when the client didn't send a hint. We also keep a chrono // `FixedOffset` for sorting/secondary-key date math in Rust below — // anchoring both sides on the same value keeps "what SQL matched" and // "what we sort by" consistent. let tz_offset_minutes = q.timezone_offset_minutes.unwrap_or(0); let client_timezone = q .timezone_offset_minutes .and_then(|offset_mins| FixedOffset::east_opt(offset_mins * 60)); debug!( "list_memories: span={:?} tz_offset_min={} years_back={}", span_mode, tz_offset_minutes, years_back ); let library = match crate::libraries::resolve_library_param(&app_state, q.library.as_deref()) { Ok(lib) => lib, Err(msg) => { warn!("Rejecting /memories request: {}", msg); return HttpResponse::BadRequest().body(msg); } }; let libraries_to_scan: Vec<&crate::libraries::Library> = match library { Some(lib) => vec![lib], None => app_state.libraries.iter().collect(), }; // (item, date) tuples — `date` is the canonical NaiveDate of the // memory in the client's tz, used as the primary sort key. let mut memories_with_dates: Vec<(MemoryItem, NaiveDate)> = Vec::new(); for lib in &libraries_to_scan { let base = Path::new(&lib.root_path); let effective = lib.effective_excluded_dirs(&app_state.excluded_dirs); let path_excluder = PathExcluder::new(base, &effective); let rows = match exif_dao.lock() { Ok(mut dao) => match dao.get_memories_in_window( &span_context, lib.id, span_token, years_back, tz_offset_minutes, ) { Ok(rows) => rows, Err(e) => { warn!( "Failed to query memories for library '{}': {:?}", lib.name, e ); continue; } }, Err(e) => { warn!("Failed to lock EXIF DAO: {:?}", e); continue; } }; for (rel_path, date_taken_ts, last_modified_ts) in rows { // Apply per-library exclusions in Rust — they're a small // set and pushing them into the SQL WHERE adds bind-param // gymnastics with no measurable win at this scale. let full_path = base.join(&rel_path); if path_excluder.is_excluded(&full_path) { trace!("Memory excluded by PathExcluder: {:?}", full_path); continue; } let Some(file_date) = date_in_client_tz(date_taken_ts, client_timezone) else { continue; }; memories_with_dates.push(( MemoryItem { path: rel_path, created: Some(date_taken_ts), modified: Some(last_modified_ts), library_id: lib.id, }, file_date, )); } } // Sort once over the merged result set. The SQL filter handles the // matching; sort order is purely UI concern. match span_mode { // Month: chronological — gives an "overview" feel. MemoriesSpan::Month => memories_with_dates.sort_by(|a, b| a.1.cmp(&b.1)), // Week: full date then timestamp (oldest → newest). MemoriesSpan::Week => { memories_with_dates.sort_by(|a, b| { a.1.cmp(&b.1) .then_with(|| match (a.0.created, b.0.created) { (Some(at), Some(bt)) => at.cmp(&bt), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => std::cmp::Ordering::Equal, }) }); } // Day: same calendar day across years, sub-sorted by timestamp. MemoriesSpan::Day => { memories_with_dates.sort_by(|a, b| match (a.0.created, b.0.created) { (Some(at), Some(bt)) => at.cmp(&bt), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => std::cmp::Ordering::Equal, }); } } let items: Vec = memories_with_dates.into_iter().map(|(m, _)| m).collect(); span.add_event( "memories_scanned", vec![ KeyValue::new("span", format!("{:?}", span_mode)), KeyValue::new("years_back", years_back.to_string()), KeyValue::new("result_count", items.len().to_string()), KeyValue::new("tz_offset_minutes", tz_offset_minutes.to_string()), KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)), ], ); span.set_status(Status::Ok); HttpResponse::Ok().json(MemoriesResponse { items }) } #[cfg(test)] mod tests { use super::*; use chrono::{Datelike, Timelike}; use std::fs::{self, File}; use tempfile::tempdir; #[test] fn test_extract_date_from_filename_screenshot_format() { let filename = "Screenshot_2014-06-01-20-44-50.png"; let date_time = extract_date_from_filename(filename).unwrap(); assert_eq!(date_time.year(), 2014); assert_eq!(date_time.month(), 6); assert_eq!(date_time.day(), 1); assert_eq!(date_time.hour(), 20); assert_eq!(date_time.minute(), 44); assert_eq!(date_time.second(), 50); } #[test] fn test_extract_date_from_filename_screenshot_less_dashes_format() { let filename = "Screenshot_20140601-204450.png"; let date_time = extract_date_from_filename(filename).unwrap(); assert_eq!(date_time.year(), 2014); assert_eq!(date_time.month(), 6); assert_eq!(date_time.day(), 1); assert_eq!(date_time.hour(), 20); assert_eq!(date_time.minute(), 44); assert_eq!(date_time.second(), 50); } #[test] fn test_extract_date_from_filename_screenshot_underscores_format() { let filename = "20140601_204450.png"; let date_time = extract_date_from_filename(filename).unwrap(); assert_eq!(date_time.year(), 2014); assert_eq!(date_time.month(), 6); assert_eq!(date_time.day(), 1); assert_eq!(date_time.hour(), 20); assert_eq!(date_time.minute(), 44); assert_eq!(date_time.second(), 50); } #[test] fn test_extract_date_from_filename_dash_format() { let filename = "2015-01-09_02-15-15.jpg"; let date_time = extract_date_from_filename(filename).unwrap(); assert_eq!(date_time.year(), 2015); assert_eq!(date_time.month(), 1); assert_eq!(date_time.day(), 9); assert_eq!(date_time.hour(), 2); assert_eq!(date_time.minute(), 15); assert_eq!(date_time.second(), 15); } #[test] fn test_extract_date_from_filename_dash_compact_time_format() { let filename = "2015-01-09-021515.jpg"; let date_time = extract_date_from_filename(filename).unwrap(); assert_eq!(date_time.year(), 2015); assert_eq!(date_time.month(), 1); assert_eq!(date_time.day(), 9); assert_eq!(date_time.hour(), 2); assert_eq!(date_time.minute(), 15); assert_eq!(date_time.second(), 15); } #[test] fn test_extract_date_from_filename_compact_format() { let filename = "20140927101712.jpg"; let date_time = extract_date_from_filename(filename).unwrap(); assert_eq!(date_time.year(), 2014); assert_eq!(date_time.month(), 9); assert_eq!(date_time.day(), 27); assert_eq!(date_time.hour(), 10); assert_eq!(date_time.minute(), 17); assert_eq!(date_time.second(), 12); } #[test] fn test_extract_date_from_filename_timestamp_format() { let filename = "xyz_1401638400.jpeg"; // Unix timestamp for 2014-06-01 16:00:00 UTC // Timestamps are already in UTC, so timezone doesn't matter for this test let date_time = extract_date_from_filename(filename).unwrap(); assert_eq!(date_time.year(), 2014); assert_eq!(date_time.month(), 6); assert_eq!(date_time.day(), 1); assert_eq!(date_time.hour(), 16); assert_eq!(date_time.minute(), 0); assert_eq!(date_time.second(), 0); } #[test] fn test_extract_date_from_filename_timestamp_millis_format() { let filename = "xyz_1401638400000.jpeg"; // Unix timestamp in milliseconds let date_time = extract_date_from_filename(filename).unwrap(); assert_eq!(date_time.year(), 2014); assert_eq!(date_time.month(), 6); assert_eq!(date_time.day(), 1); assert_eq!(date_time.hour(), 16); assert_eq!(date_time.minute(), 0); assert_eq!(date_time.second(), 0); } #[test] fn test_extract_date_from_filename_attachment_15_digits() { // att_142248967186928.jpeg - 15 digits, should parse first 10 as seconds // 1422489671 = 2015-01-28 23:07:51 UTC (converts to local timezone) let filename = "att_142248967186928.jpeg"; let date_time = extract_date_from_filename(filename).unwrap(); // Verify year and month are correct (2015-01) assert_eq!(date_time.year(), 2015); assert_eq!(date_time.month(), 1); // Day may be 28 or 29 depending on timezone assert!(date_time.day() >= 28 && date_time.day() <= 29); // Verify timestamp is within expected range (should be around 1422489671) let timestamp = date_time.timestamp(); assert!((1422480000..=1422576000).contains(×tamp)); // Jan 28-29, 2015 } #[test] fn test_extract_date_from_filename_attachment_16_digits() { // att_1422489664680106.jpeg - 16 digits, should parse first 10 as seconds // 1422489664 = 2015-01-28 23:07:44 UTC (converts to local timezone) let filename = "att_1422489664680106.jpeg"; let date_time = extract_date_from_filename(filename).unwrap(); // Verify year and month are correct (2015-01) assert_eq!(date_time.year(), 2015); assert_eq!(date_time.month(), 1); // Day may be 28 or 29 depending on timezone assert!(date_time.day() >= 28 && date_time.day() <= 29); // Verify timestamp is within expected range (should be around 1422489664) let timestamp = date_time.timestamp(); assert!((1422480000..=1422576000).contains(×tamp)); // Jan 28-29, 2015 } #[test] fn test_extract_date_from_filename_autogenerated_should_not_match() { // Autogenerated filenames like 1000004178.jpg should NOT be parsed as timestamps // These start with "10000" which would be Sept 2001 if parsed literally let filename = "1000004178.jpg"; let date_time = extract_date_from_filename(filename); assert!( date_time.is_none(), "Autogenerated filenames starting with 10000 should not be parsed as dates" ); } // The obsolete `test_memory_date_priority_*` tests covered the old // request-time waterfall in `get_memory_date_with_priority`. Their // replacement lives in `crate::date_resolver::tests` (resolver // waterfall) and the SQL surface is exercised by integration tests // that hit `get_memories_in_window` directly. #[test] fn test_path_excluder_absolute_under_base() { let tmp = tempdir().unwrap(); let base = tmp.path(); // Simulate structure: // base/photos/private/secret.jpg // base/photos/public/ok.jpg // base/screenshots/img.png let photos_private = base.join("photos/private"); let photos_public = base.join("photos/public"); let screenshots = base.join("screenshots"); fs::create_dir_all(&photos_private).unwrap(); fs::create_dir_all(&photos_public).unwrap(); fs::create_dir_all(&screenshots).unwrap(); let secret = photos_private.join("secret.jpg"); let ok = photos_public.join("ok.jpg"); let shot = screenshots.join("img.png"); File::create(&secret).unwrap(); File::create(&ok).unwrap(); File::create(&shot).unwrap(); // Exclude "/photos/private" and "/screenshots" under base let excluded = vec![ String::from("/photos/private"), String::from("/screenshots"), ]; let excluder = PathExcluder::new(base, &excluded); assert!(excluder.is_excluded(&secret), "secret should be excluded"); assert!( excluder.is_excluded(&shot), "screenshots should be excluded" ); assert!( !excluder.is_excluded(&ok), "public photo should NOT be excluded" ); } #[test] fn test_path_excluder_pattern_anywhere_under_base() { let tmp = tempdir().unwrap(); let base = tmp.path(); // Simulate: // base/a/tmp_file.jpg // base/b/normal.jpg // base/c/sometmpdir/file.jpg let a = base.join("a"); let b = base.join("b"); let c = base.join("c/tmp"); fs::create_dir_all(&a).unwrap(); fs::create_dir_all(&b).unwrap(); fs::create_dir_all(&c).unwrap(); let tmp_file = a.join("tmp_file.jpg"); let normal = b.join("normal.jpg"); let tmp_dir_file = c.join("file.jpg"); File::create(&tmp_file).unwrap(); File::create(&normal).unwrap(); File::create(&tmp_dir_file).unwrap(); // Exclude any path containing "tmp" let excluded = vec![String::from("tmp")]; let excluder = PathExcluder::new(base, &excluded); assert!( !excluder.is_excluded(&tmp_file), "file with 'tmp' in name should NOT be excluded" ); assert!( excluder.is_excluded(&tmp_dir_file), "file in directory with 'tmp' in path should be excluded" ); assert!( !excluder.is_excluded(&normal), "file without 'tmp' in its path should NOT be excluded" ); } #[test] fn test_path_excluder_mixed_absolute_and_pattern() { let tmp = tempdir().unwrap(); let base = tmp.path(); // Simulate: // base/photos/private/secret_tmp.jpg -> excluded by absolute dir rule // base/photos/private/secret.jpg -> excluded by absolute dir rule // base/photos/tmp/public.jpg -> excluded by pattern "tmp" (dir name) // base/photos/public/tmp_public.jpg -> NOT excluded (file name contains "tmp" but not equal) // base/other/keep.jpg -> NOT excluded let photos_private = base.join("photos/private"); let photos_tmp = base.join("photos/tmp"); let photos_public = base.join("photos/public"); let other = base.join("other"); fs::create_dir_all(&photos_private).unwrap(); fs::create_dir_all(&photos_tmp).unwrap(); fs::create_dir_all(&photos_public).unwrap(); fs::create_dir_all(&other).unwrap(); let secret_tmp = photos_private.join("secret_tmp.jpg"); let secret = photos_private.join("secret.jpg"); let tmp_dir_file = photos_tmp.join("public.jpg"); let tmp_in_name = photos_public.join("tmp_public.jpg"); let keep = other.join("keep.jpg"); File::create(&secret_tmp).unwrap(); File::create(&secret).unwrap(); File::create(&tmp_dir_file).unwrap(); File::create(&tmp_in_name).unwrap(); File::create(&keep).unwrap(); // Mixed: exclude "/photos/private" (dir) and any component equal to "tmp" let excluded = vec![String::from("/photos/private"), String::from("tmp")]; let excluder = PathExcluder::new(base, &excluded); // Entire private tree is excluded by dir rule assert!(excluder.is_excluded(&secret_tmp)); assert!(excluder.is_excluded(&secret)); // Dir 'tmp' under photos excluded by pattern assert!(excluder.is_excluded(&tmp_dir_file)); // File name containing 'tmp' but not equal should NOT be excluded assert!(!excluder.is_excluded(&tmp_in_name)); // keep.jpg doesn't match any rule assert!(!excluder.is_excluded(&keep)); } #[test] fn test_week_span_sorting_chronological_by_day() { // Test that Week span sorts by: // 1. Day of month (ascending) // 2. Full timestamp oldest to newest (year + time combined) // Create test data: // - Jan 15, 2024 at 9:00 AM // - Jan 15, 2020 at 10:00 AM // - Jan 16, 2021 at 8:00 AM let jan_15_2024_9am = NaiveDate::from_ymd_opt(2024, 1, 15) .unwrap() .and_hms_opt(9, 0, 0) .unwrap() .and_utc() .timestamp(); let jan_15_2020_10am = NaiveDate::from_ymd_opt(2020, 1, 15) .unwrap() .and_hms_opt(10, 0, 0) .unwrap() .and_utc() .timestamp(); let jan_16_2021_8am = NaiveDate::from_ymd_opt(2021, 1, 16) .unwrap() .and_hms_opt(8, 0, 0) .unwrap() .and_utc() .timestamp(); let mut memories_with_dates = [ ( MemoryItem { path: "photo1.jpg".to_string(), created: Some(jan_15_2024_9am), modified: Some(jan_15_2024_9am), library_id: 1, }, NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(), ), ( MemoryItem { path: "photo2.jpg".to_string(), created: Some(jan_15_2020_10am), modified: Some(jan_15_2020_10am), library_id: 1, }, NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(), ), ( MemoryItem { path: "photo3.jpg".to_string(), created: Some(jan_16_2021_8am), modified: Some(jan_16_2021_8am), library_id: 1, }, NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(), ), ]; // Sort using Week span logic memories_with_dates.sort_by(|a, b| { // First, sort by day of month let day_cmp = a.1.day().cmp(&b.1.day()); if day_cmp != std::cmp::Ordering::Equal { return day_cmp; } // Then sort by full created timestamp (oldest to newest) match (a.0.created, b.0.created) { (Some(a_time), Some(b_time)) => a_time.cmp(&b_time), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => std::cmp::Ordering::Equal, } }); // Expected order: // 1. Jan 15, 2020 at 10:00 AM (oldest Jan 15 photo) // 2. Jan 15, 2024 at 9:00 AM (newer Jan 15 photo) // 3. Jan 16, 2021 at 8:00 AM (all Jan 16 photos after Jan 15) assert_eq!(memories_with_dates[0].0.created.unwrap(), jan_15_2020_10am); assert_eq!(memories_with_dates[1].0.created.unwrap(), jan_15_2024_9am); assert_eq!(memories_with_dates[2].0.created.unwrap(), jan_16_2021_8am); } }