use actix_web::web::Data; use actix_web::{get, web, HttpRequest, HttpResponse, Responder}; use chrono::LocalResult::{Ambiguous, Single}; use chrono::{DateTime, Datelike, FixedOffset, Local, LocalResult, NaiveDate, TimeZone, Utc}; use log::{debug, trace, warn}; use opentelemetry::trace::{Span, Status, Tracer}; use opentelemetry::KeyValue; use rayon::prelude::*; use serde::{Deserialize, Serialize}; use std::path::Path; use std::path::PathBuf; use walkdir::WalkDir; use crate::data::Claims; use crate::files::is_image_or_video; use crate::otel::{extract_context_from_request, global_tracer}; use crate::state::AppState; #[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, } fn get_file_date_info( path: &Path, client_timezone: &Option, ) -> Option<(NaiveDate, Option, Option)> { // Read file metadata once let meta = std::fs::metadata(path).ok()?; // Extract metadata timestamps let metadata_created = meta.created().ok().map(|t| { let utc: DateTime = t.into(); if let Some(tz) = client_timezone { utc.with_timezone(tz).timestamp() } else { utc.timestamp() } }); let metadata_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() } }); // Try to get date from filename if let Some(date_time) = path .file_name() .and_then(|filename| filename.to_str()) .and_then(extract_date_from_filename) { // Convert to client timezone if specified let date_in_timezone = if let Some(tz) = client_timezone { date_time.with_timezone(tz) } else { date_time.with_timezone(&Local).fixed_offset() }; // Use the timestamp from the filename date let created_ts = date_in_timezone.timestamp(); debug!( "File date from file {:?} > {:?} = {:?}", path.file_name(), date_time, date_in_timezone ); return Some(( date_in_timezone.date_naive(), Some(created_ts), metadata_modified, )); } // Fall back to metadata if no date in filename 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() }; trace!("Fallback metadata create date = {:?}", date_in_timezone); Some((date_in_timezone, metadata_created, metadata_modified)) } 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"Screenshot_(\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); } // 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 if let Some(captures) = regex::Regex::new(r"(\d{10}|\d{13})\.") .ok()? .captures(filename) { let timestamp_str = captures.get(1)?.as_str(); // Millisecond timestamp (13 digits) if timestamp_str.len() >= 13 { if let Some(date_time) = timestamp_str[0..13] .parse::() .ok() .and_then(|timestamp_millis| DateTime::from_timestamp_millis(timestamp_millis)) .map(|naive_dt| naive_dt.fixed_offset()) { return Some(date_time); } } // Second timestamp (10 digits) if timestamp_str.len() >= 10 { if 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); } } } None } #[get("/memories")] pub async fn list_memories( _claims: Claims, request: HttpRequest, q: web::Query, app_state: Data, ) -> impl Responder { let tracer = global_tracer(); let context = extract_context_from_request(&request); let mut span = tracer.start_with_context("list_memories", &context); 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 a list of excluded directories with full paths let excluded_paths: Vec = app_state .excluded_dirs .iter() .map(|dir| base.join(dir)) .collect(); debug!("Excluded directories: {:?}", excluded_paths); let entries: Vec<_> = WalkDir::new(base) .into_iter() .filter_map(|e| e.ok()) .filter(|e| { // Skip excluded directories if !excluded_paths.is_empty() { let path = e.path(); for excluded in &excluded_paths { if path.starts_with(excluded) { debug!("Skipping excluded path: {:?}", path); return false; } } } true }) .filter(|e| e.file_type().is_file() && is_image_or_video(e.path())) .collect(); let mut memories_with_dates: Vec<(MemoryItem, NaiveDate)> = entries .par_iter() .filter_map(|entry| { let path = entry.path(); // Get file date and timestamps in one operation let (file_date, created, modified) = match get_file_date_info(path, &client_timezone) { Some(info) => info, None => { warn!("No date info found for file: {:?}", path); return None; } }; if is_memories_match(file_date, now, span_mode, years_back) { return if let Ok(rel) = path.strip_prefix(base) { Some(( MemoryItem { path: rel.to_string_lossy().to_string(), created, modified, }, file_date, )) } else { warn!("Failed to strip prefix from path: {:?}", path); None }; } None }) .collect(); 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)), _ => { 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_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_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::File; use tempfile::tempdir; // Add new tests for our date extraction functionality #[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_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_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_get_file_date_info_from_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(); let (date, created, _) = get_file_date_info(&temp_file, &Some(*Local::now().fixed_offset().offset())).unwrap(); // Check that date is from filename 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_get_file_date_info_from_metadata() { let temp_dir = tempdir().unwrap(); let temp_file = temp_dir.path().join("regular_image.jpg"); File::create(&temp_file).unwrap(); let (date, created, modified) = get_file_date_info(&temp_file, &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()); } }