use actix_web::web::Data; use actix_web::{HttpRequest, HttpResponse, Responder, get, web}; use chrono::LocalResult::{Ambiguous, Single}; use chrono::{DateTime, Datelike, FixedOffset, Local, LocalResult, NaiveDate, TimeZone, Utc}; use log::{debug, trace, warn}; use opentelemetry::KeyValue; use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer}; use rayon::prelude::*; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; use std::sync::Mutex; use walkdir::WalkDir; use crate::data::Claims; use crate::database::ExifDao; use crate::files::is_image_or_video; use crate::otel::{extract_context_from_request, global_tracer}; use crate::state::AppState; // Helper that encapsulates path-exclusion semantics #[derive(Debug)] struct PathExcluder { 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 substring patterns that match /// anywhere in the full path string (still scoped under base). 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. dirs={:?}, patterns={:?}", excluded_dirs, excluded_patterns ); Self { excluded_dirs, excluded_patterns, } } /// Returns true if `path` should be excluded. 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; } } // Pattern-based exclusions: match whole path components (dir or file name), // not substrings. if !self.excluded_patterns.is_empty() { for component in path.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, } #[derive(Debug, Serialize, Clone)] pub struct MemoryItem { pub path: String, pub created: Option, pub modified: Option, } #[derive(Debug, Serialize)] pub struct MemoriesResponse { pub items: Vec, } /// Convert Unix timestamp to NaiveDate in client timezone fn timestamp_to_naive_date( timestamp: i64, client_timezone: &Option, ) -> Option { let dt_utc = DateTime::::from_timestamp(timestamp, 0)?; let date = if let Some(tz) = client_timezone { dt_utc.with_timezone(tz).date_naive() } else { dt_utc.with_timezone(&Local).date_naive() }; Some(date) } pub fn extract_date_from_filename(filename: &str) -> Option> { 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()?; match Local.from_local_datetime( &NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, min, sec)?, ) { Single(dt) => Some(dt.fixed_offset()), Ambiguous(early_dt, _) => Some(early_dt.fixed_offset()), LocalResult::None => { warn!("Weird local date: {:?}", filename); None } } }; // 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 } /// Get the canonical date for a memory with priority: filename → EXIF → metadata /// Returns (NaiveDate for matching, timestamp for display, modified timestamp) fn get_memory_date_with_priority( path: &Path, exif_date_taken: Option, client_timezone: &Option, ) -> Option<(NaiveDate, Option, Option)> { // Read file metadata once let meta = std::fs::metadata(path).ok()?; // Priority 1: Try to extract date from filename if let Some(filename_date) = path .file_name() .and_then(|f| f.to_str()) .and_then(extract_date_from_filename) { // Convert to client timezone if specified let date_in_timezone = if let Some(tz) = client_timezone { filename_date.with_timezone(tz) } else { filename_date.with_timezone(&Local).fixed_offset() }; let timestamp = if let Some(tz) = client_timezone { filename_date.with_timezone(tz).timestamp() } else { filename_date.timestamp() }; let modified = meta.modified().ok().map(|t| { let utc: DateTime = t.into(); if let Some(tz) = client_timezone { utc.with_timezone(tz).timestamp() } else { utc.timestamp() } }); debug!( "Memory date from filename {:?} > {:?} = {:?}", path.file_name(), filename_date, date_in_timezone ); return Some((date_in_timezone.date_naive(), Some(timestamp), modified)); } // Priority 2: Use EXIF date_taken if available if let Some(exif_timestamp) = exif_date_taken { let date = timestamp_to_naive_date(exif_timestamp, client_timezone)?; let modified = meta.modified().ok().map(|t| { let utc: DateTime = t.into(); if let Some(tz) = client_timezone { utc.with_timezone(tz).timestamp() } else { utc.timestamp() } }); debug!("Memory date from EXIF {:?} = {:?}", path.file_name(), date); return Some((date, Some(exif_timestamp), modified)); } // Priority 3: Fall back to metadata let system_time = meta.created().ok().or_else(|| meta.modified().ok())?; let dt_utc: DateTime = system_time.into(); let date_in_timezone = if let Some(tz) = client_timezone { dt_utc.with_timezone(tz).date_naive() } else { dt_utc.with_timezone(&Local).date_naive() }; let created_timestamp = if let Some(tz) = client_timezone { dt_utc.with_timezone(tz).timestamp() } else { dt_utc.timestamp() }; let modified = meta.modified().ok().map(|t| { let utc: DateTime = t.into(); if let Some(tz) = client_timezone { utc.with_timezone(tz).timestamp() } else { utc.timestamp() } }); trace!("Fallback metadata create date = {:?}", date_in_timezone); Some((date_in_timezone, Some(created_timestamp), modified)) } /// Collect memories from EXIF database fn collect_exif_memories( exif_dao: &Data>>, context: &opentelemetry::Context, base_path: &str, now: NaiveDate, span_mode: MemoriesSpan, years_back: u32, client_timezone: &Option, path_excluder: &PathExcluder, ) -> Vec<(MemoryItem, NaiveDate)> { // Query database for all files with date_taken let exif_records = match exif_dao.lock() { Ok(mut dao) => match dao.get_all_with_date_taken(context) { Ok(records) => records, Err(e) => { warn!("Failed to query EXIF database: {:?}", e); return Vec::new(); // Graceful fallback } }, Err(e) => { warn!("Failed to lock EXIF DAO: {:?}", e); return Vec::new(); } }; // Parallel processing with Rayon exif_records .par_iter() .filter_map(|(file_path, date_taken_ts)| { // Build full path let full_path = Path::new(base_path).join(file_path); // Check exclusions if path_excluder.is_excluded(&full_path) { return None; } // Verify file exists if !full_path.exists() || !full_path.is_file() { warn!("EXIF record exists but file not found: {:?}", full_path); return None; } // Get date with priority: filename → EXIF → metadata // This ensures sorting and display use the same date source let (file_date, created, modified) = get_memory_date_with_priority(&full_path, Some(*date_taken_ts), client_timezone)?; // Check if matches memory criteria if !is_memories_match(file_path, file_date, now, span_mode, years_back) { return None; } Some(( MemoryItem { path: file_path.clone(), created, modified, }, file_date, )) }) .collect() } /// Collect memories from file system scan (for files not in EXIF DB) fn collect_filesystem_memories( base_path: &str, path_excluder: &PathExcluder, skip_paths: &HashSet, now: NaiveDate, span_mode: MemoriesSpan, years_back: u32, client_timezone: &Option, ) -> Vec<(MemoryItem, NaiveDate)> { let base = Path::new(base_path); let entries: Vec<_> = WalkDir::new(base) .into_iter() .filter_map(|e| e.ok()) .filter(|e| { let path = e.path(); // Skip if already processed by EXIF query if skip_paths.contains(path) { return false; } // Check exclusions if path_excluder.is_excluded(path) { return false; } // Only process image/video files e.file_type().is_file() && is_image_or_video(path) }) .collect(); entries .par_iter() .filter_map(|entry| { // Use unified date priority function (no EXIF for filesystem scan) let (file_date, created, modified) = get_memory_date_with_priority(entry.path(), None, client_timezone)?; if is_memories_match( entry.path().to_str().unwrap_or("Unknown"), file_date, now, span_mode, years_back, ) { let path_relative = entry.path().strip_prefix(base).ok()?.to_str()?.to_string(); Some(( MemoryItem { path: path_relative, created, modified, }, file_date, )) } else { None } }) .collect() } #[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 years_back: u32 = 15; // Create timezone from client offset, default to local timezone if not provided let client_timezone = match q.timezone_offset_minutes { Some(offset_mins) => { let offset_secs = offset_mins * 60; Some( FixedOffset::east_opt(offset_secs) .unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()), ) } None => None, }; let now = if let Some(tz) = client_timezone { debug!("Client timezone: {:?}", tz); Utc::now().with_timezone(&tz).date_naive() } else { Local::now().date_naive() }; debug!("Now: {:?}", now); let base = Path::new(&app_state.base_path); // Build the path excluder from base and env-configured exclusions let path_excluder = PathExcluder::new(base, &app_state.excluded_dirs); // Phase 1: Query EXIF database let exif_memories = collect_exif_memories( &exif_dao, &span_context, &app_state.base_path, now, span_mode, years_back, &client_timezone, &path_excluder, ); // Build HashSet for deduplication let exif_paths: HashSet = exif_memories .iter() .map(|(item, _)| PathBuf::from(&app_state.base_path).join(&item.path)) .collect(); // Phase 2: File system scan (skip EXIF files) let fs_memories = collect_filesystem_memories( &app_state.base_path, &path_excluder, &exif_paths, now, span_mode, years_back, &client_timezone, ); // Phase 3: Merge and sort let mut memories_with_dates = exif_memories; memories_with_dates.extend(fs_memories); match span_mode { // Sort by absolute time for a more 'overview' MemoriesSpan::Month => memories_with_dates.sort_by(|a, b| a.1.cmp(&b.1)), // For week span, sort by day of month, then by full timestamp (oldest first) MemoriesSpan::Week => { 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, } }); } // For day span, sort by day of month then by time MemoriesSpan::Day => { memories_with_dates.sort_by(|a, b| { let day_comparison = a.1.day().cmp(&b.1.day()); if day_comparison == std::cmp::Ordering::Equal { 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, } } else { day_comparison } }); } } // Sort by day of the month and time (using the created timestamp) 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( "client_timezone", format!( "{:?}", client_timezone.unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()) ), ), KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)), ], ); span.set_status(Status::Ok); HttpResponse::Ok().json(MemoriesResponse { items }) } fn is_memories_match( file_path: &str, file_date: NaiveDate, today: NaiveDate, span: MemoriesSpan, years_back: u32, ) -> bool { if file_date > today { return false; } let years_diff = (today.year() - file_date.year()).unsigned_abs(); if years_diff > years_back { warn!( "File ({}) date is too far in the past: {:?} vs {:?}", file_path, file_date, today ); return false; } match span { MemoriesSpan::Day => same_month_day_any_year(file_date, today), MemoriesSpan::Week => same_week_any_year(file_date, today), MemoriesSpan::Month => same_month_any_year(file_date, today), } } fn same_month_day_any_year(a: NaiveDate, b: NaiveDate) -> bool { a.month() == b.month() && a.day() == b.day() } // Match same ISO week number and same weekday (ignoring year) fn same_week_any_year(a: NaiveDate, b: NaiveDate) -> bool { a.iso_week().week().eq(&b.iso_week().week()) } // Match same month (ignoring day and year) fn same_month_any_year(a: NaiveDate, b: NaiveDate) -> bool { a.month() == b.month() } #[cfg(test)] mod tests { use super::*; use chrono::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!(timestamp >= 1422480000 && timestamp <= 1422576000); // 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!(timestamp >= 1422480000 && timestamp <= 1422576000); // 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" ); } #[test] fn test_memory_date_priority_filename() { let temp_dir = tempdir().unwrap(); let temp_file = temp_dir.path().join("Screenshot_2014-06-01-20-44-50.png"); File::create(&temp_file).unwrap(); // Test that filename takes priority (even with EXIF data available) let exif_date = DateTime::::from_timestamp(1609459200, 0) // 2021-01-01 .unwrap() .timestamp(); let (date, created, _) = get_memory_date_with_priority( &temp_file, Some(exif_date), &Some(*Local::now().fixed_offset().offset()), ) .unwrap(); // Check that date is from filename (2014), NOT EXIF (2021) assert_eq!(date.year(), 2014); assert_eq!(date.month(), 6); assert_eq!(date.day(), 1); // Check that created timestamp matches the date from filename assert!(created.is_some()); let ts = created.unwrap(); // The timestamp should be for 2014-06-01 20:44:50 in the LOCAL timezone let dt_from_ts = Local.timestamp_opt(ts, 0).unwrap(); assert_eq!(dt_from_ts.year(), 2014); assert_eq!(dt_from_ts.month(), 6); assert_eq!(dt_from_ts.day(), 1); assert_eq!(dt_from_ts.hour(), 20); assert_eq!(dt_from_ts.minute(), 44); assert_eq!(dt_from_ts.second(), 50); } #[test] fn test_memory_date_priority_metadata_fallback() { let temp_dir = tempdir().unwrap(); let temp_file = temp_dir.path().join("regular_image.jpg"); File::create(&temp_file).unwrap(); // Test metadata fallback when no filename date or EXIF let (date, created, modified) = get_memory_date_with_priority(&temp_file, None, &None).unwrap(); // Both date and timestamps should be from metadata (recent) let today = Local::now().date_naive(); assert_eq!(date.year(), today.year()); assert_eq!(date.month(), today.month()); // Both timestamps should be valid assert!(created.is_some()); assert!(modified.is_some()); // Check that timestamps are recent let dt_created = DateTime::::from_timestamp(created.unwrap(), 0).unwrap(); assert_eq!(dt_created.year(), today.year()); let dt_modified = DateTime::::from_timestamp(modified.unwrap(), 0).unwrap(); assert_eq!(dt_modified.year(), today.year()); } #[test] fn test_memory_date_priority_exif_over_metadata() { let temp_dir = tempdir().unwrap(); let temp_file = temp_dir.path().join("regular_image.jpg"); File::create(&temp_file).unwrap(); // Test that EXIF takes priority over metadata (but not filename) // EXIF date: June 15, 2020 12:00:00 UTC (safe from timezone edge cases) let exif_date = DateTime::::from_timestamp(1592222400, 0) // 2020-06-15 12:00:00 UTC .unwrap() .timestamp(); let (date, created, modified) = get_memory_date_with_priority(&temp_file, Some(exif_date), &None).unwrap(); // Date should be from EXIF (2020), not metadata (today) assert_eq!(date.year(), 2020); assert_eq!(date.month(), 6); assert_eq!(date.day(), 15); // Created timestamp should also be from EXIF assert!(created.is_some()); assert_eq!(created.unwrap(), exif_date); // Modified should still be from metadata assert!(modified.is_some()); let today = Local::now().date_naive(); let dt_modified = DateTime::::from_timestamp(modified.unwrap(), 0).unwrap(); assert_eq!(dt_modified.year(), today.year()); } #[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 = vec![ ( MemoryItem { path: "photo1.jpg".to_string(), created: Some(jan_15_2024_9am), modified: Some(jan_15_2024_9am), }, 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), }, 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), }, 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); } }