Add /memories endpoint
This commit is contained in:
199
src/memories.rs
Normal file
199
src/memories.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user