Add /memories endpoint
This commit is contained in:
@@ -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
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