From caed787c04f6298a17138d113f439c8de327b20a Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 9 Aug 2025 22:24:48 -0400 Subject: [PATCH] Add /memories endpoint --- src/main.rs | 2 + src/memories.rs | 199 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 src/memories.rs diff --git a/src/main.rs b/src/main.rs index aea239a..9604b67 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,6 +59,7 @@ mod state; mod tags; mod video; +mod memories; mod otel; mod service; #[cfg(test)] @@ -654,6 +655,7 @@ fn main() -> std::io::Result<()> { .service(put_add_favorite) .service(delete_favorite) .service(get_file_metadata) + .service(memories::list_memories) .add_feature(add_tag_services::<_, SqliteTagDao>) .app_data(app_data.clone()) .app_data::>(Data::new(RealFileSystem::new( diff --git a/src/memories.rs b/src/memories.rs new file mode 100644 index 0000000..595d7fa --- /dev/null +++ b/src/memories.rs @@ -0,0 +1,199 @@ +use actix_web::web::Data; +use actix_web::{get, web, HttpRequest, HttpResponse, Responder}; +use chrono::{DateTime, Datelike, Local, NaiveDate, Utc}; +use opentelemetry::trace::{Span, Status, Tracer}; +use opentelemetry::KeyValue; +use serde::{Deserialize, Serialize}; +use std::path::Path; +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, +} + +#[derive(Deserialize)] +pub struct MemoriesRequest { + pub span: 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, +} + +#[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; + + let now = Local::now().date_naive(); + let base = Path::new(&app_state.base_path); + + let mut memories_with_dates: Vec<(MemoryItem, NaiveDate)> = Vec::new(); + + for entry in WalkDir::new(base) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + { + let path = entry.path(); + + if !is_image_or_video(path) { + continue; + } + + // Use created date if available, otherwise modified date for matching + let file_date = match file_best_local_date(path) { + Some(d) => d, + None => continue, + }; + + if is_memories_match(file_date, now, span_mode, years_back) { + let (created, modified) = file_times_epoch_secs(path); + if let Ok(rel) = path.strip_prefix(base) { + memories_with_dates.push(( + MemoryItem { + path: rel.to_string_lossy().to_string(), + created, + modified, + }, + file_date, + )); + } + } + } + + memories_with_dates.sort_by_key(|k| k.1.ordinal()); + + 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()), + ], + ); + span.set_status(Status::Ok); + + HttpResponse::Ok().json(MemoriesResponse { items }) +} + +fn file_best_local_date(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + let system_time = meta.created().ok().or_else(|| meta.modified().ok())?; + let local_dt = chrono::DateTime::::from(system_time); + Some(local_dt.date_naive()) +} + +fn file_times_epoch_secs(path: &Path) -> (Option, Option) { + let meta = match std::fs::metadata(path) { + Ok(m) => m, + Err(_) => return (None, None), + }; + + let created = meta.created().ok().map(|t| { + let utc: DateTime = t.into(); + utc.timestamp() + }); + + let modified = meta.modified().ok().map(|t| { + let utc: DateTime = t.into(); + utc.timestamp() + }); + + (created, modified) +} + +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 { + return false; + } + + match span { + MemoriesSpan::Day => same_month_day_any_year(file_date, today), + MemoriesSpan::Week => same_week_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 { + // let (_ay, aw, _) = a.iso_week().year_week(); + // let (_by, bw, _) = b.iso_week().year_week(); + // aw == bw && a.weekday() == b.weekday() + a.iso_week().week().eq(&b.iso_week().week()) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + #[test] + fn test_same_month_day_any_year() { + let today = NaiveDate::from_ymd_opt(2025, 8, 8).unwrap(); + assert!(same_month_day_any_year( + NaiveDate::from_ymd_opt(2019, 8, 8).unwrap(), + today + )); + assert!(!same_month_day_any_year( + NaiveDate::from_ymd_opt(2019, 8, 7).unwrap(), + today + )); + } + + #[test] + fn test_same_week_any_year() { + let b = NaiveDate::from_ymd_opt(2025, 8, 8).unwrap(); // Friday + let a = NaiveDate::from_ymd_opt(2024, 8, 9).unwrap(); // Friday, same ISO week number + assert_eq!(b.weekday(), a.weekday()); + assert_eq!(b.iso_week().week(), a.iso_week().week()); + assert!(same_week_any_year(a, b)); + } + + #[test] + fn test_years_back_limit() { + let today = NaiveDate::from_ymd_opt(2025, 8, 8).unwrap(); + let file_date = NaiveDate::from_ymd_opt(2010, 8, 8).unwrap(); + assert!(!is_memories_match(file_date, today, MemoriesSpan::Day, 10)); + assert!(is_memories_match(file_date, today, MemoriesSpan::Day, 20)); + } +}