feature/memories #35

Merged
cameron merged 11 commits from feature/memories into master 2025-08-16 16:42:39 +00:00
2 changed files with 201 additions and 0 deletions
Showing only changes of commit caed787c04 - Show all commits

View File

@@ -59,6 +59,7 @@ mod state;
mod tags; mod tags;
mod video; mod video;
mod memories;
mod otel; mod otel;
mod service; mod service;
#[cfg(test)] #[cfg(test)]
@@ -654,6 +655,7 @@ fn main() -> std::io::Result<()> {
.service(put_add_favorite) .service(put_add_favorite)
.service(delete_favorite) .service(delete_favorite)
.service(get_file_metadata) .service(get_file_metadata)
.service(memories::list_memories)
.add_feature(add_tag_services::<_, SqliteTagDao>) .add_feature(add_tag_services::<_, SqliteTagDao>)
.app_data(app_data.clone()) .app_data(app_data.clone())
.app_data::<Data<RealFileSystem>>(Data::new(RealFileSystem::new( .app_data::<Data<RealFileSystem>>(Data::new(RealFileSystem::new(

199
src/memories.rs Normal file
View File

@@ -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<MemoriesSpan>,
}
#[derive(Debug, Serialize, Clone)]
pub struct MemoryItem {
pub path: String,
pub created: Option<i64>,
pub modified: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct MemoriesResponse {
pub items: Vec<MemoryItem>,
}
#[get("/memories")]
pub async fn list_memories(
_claims: Claims,
request: HttpRequest,
q: web::Query<MemoriesRequest>,
app_state: Data<AppState>,
) -> 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<MemoryItem> = 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<NaiveDate> {
let meta = std::fs::metadata(path).ok()?;
let system_time = meta.created().ok().or_else(|| meta.modified().ok())?;
let local_dt = chrono::DateTime::<Local>::from(system_time);
Some(local_dt.date_naive())
}
fn file_times_epoch_secs(path: &Path) -> (Option<i64>, Option<i64>) {
let meta = match std::fs::metadata(path) {
Ok(m) => m,
Err(_) => return (None, None),
};
let created = meta.created().ok().map(|t| {
let utc: DateTime<Utc> = t.into();
utc.timestamp()
});
let modified = meta.modified().ok().map(|t| {
let utc: DateTime<Utc> = 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));
}
}