1149 lines
39 KiB
Rust
1149 lines
39 KiB
Rust
use actix_web::web::Data;
|
|
use actix_web::{HttpRequest, HttpResponse, Responder, get, web};
|
|
use chrono::LocalResult::{Ambiguous, Single};
|
|
use chrono::{DateTime, Datelike, FixedOffset, Local, LocalResult, NaiveDate, TimeZone, Utc};
|
|
use log::{debug, trace, warn};
|
|
use opentelemetry::KeyValue;
|
|
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
|
|
use rayon::prelude::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashSet;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::sync::Mutex;
|
|
use walkdir::WalkDir;
|
|
|
|
use crate::data::Claims;
|
|
use crate::database::ExifDao;
|
|
use crate::files::is_image_or_video;
|
|
use crate::otel::{extract_context_from_request, global_tracer};
|
|
use crate::state::AppState;
|
|
|
|
// Helper that encapsulates path-exclusion semantics
|
|
#[derive(Debug)]
|
|
struct PathExcluder {
|
|
excluded_dirs: Vec<PathBuf>,
|
|
excluded_patterns: Vec<String>,
|
|
}
|
|
|
|
impl PathExcluder {
|
|
/// Build from a `base` path and the raw exclusion entries.
|
|
///
|
|
/// Rules:
|
|
/// - Entries starting with '/' are interpreted as "absolute under base"
|
|
/// (e.g. "/photos/private" -> base/photos/private).
|
|
/// - Entries without '/' are treated as substring patterns that match
|
|
/// anywhere in the full path string (still scoped under base).
|
|
fn new(base: &Path, raw_excluded: &[String]) -> Self {
|
|
let mut excluded_dirs = Vec::new();
|
|
let mut excluded_patterns = Vec::new();
|
|
|
|
for dir in raw_excluded {
|
|
if let Some(rel) = dir.strip_prefix('/') {
|
|
// Absolute under base
|
|
if !rel.is_empty() {
|
|
excluded_dirs.push(base.join(rel));
|
|
}
|
|
} else {
|
|
// Pattern anywhere under base
|
|
excluded_patterns.push(dir.clone());
|
|
}
|
|
}
|
|
|
|
debug!(
|
|
"PathExcluder created. dirs={:?}, patterns={:?}",
|
|
excluded_dirs, excluded_patterns
|
|
);
|
|
|
|
Self {
|
|
excluded_dirs,
|
|
excluded_patterns,
|
|
}
|
|
}
|
|
|
|
/// Returns true if `path` should be excluded.
|
|
fn is_excluded(&self, path: &Path) -> bool {
|
|
// Directory-based exclusions
|
|
for excluded in &self.excluded_dirs {
|
|
if path.starts_with(excluded) {
|
|
trace!(
|
|
"PathExcluder: excluded by dir: {:?} (rule: {:?})",
|
|
path, excluded
|
|
);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Pattern-based exclusions: match whole path components (dir or file name),
|
|
// not substrings.
|
|
if !self.excluded_patterns.is_empty() {
|
|
for component in path.components() {
|
|
if let Some(comp_str) = component.as_os_str().to_str()
|
|
&& self.excluded_patterns.iter().any(|pat| pat == comp_str)
|
|
{
|
|
trace!(
|
|
"PathExcluder: excluded by component pattern: {:?} (component: {:?}, patterns: {:?})",
|
|
path, comp_str, self.excluded_patterns
|
|
);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
}
|
|
|
|
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum MemoriesSpan {
|
|
Day,
|
|
Week,
|
|
Month,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct MemoriesRequest {
|
|
pub span: Option<MemoriesSpan>,
|
|
/// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for CET)
|
|
pub timezone_offset_minutes: Option<i32>,
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
/// Convert Unix timestamp to NaiveDate in client timezone
|
|
fn timestamp_to_naive_date(
|
|
timestamp: i64,
|
|
client_timezone: &Option<FixedOffset>,
|
|
) -> Option<NaiveDate> {
|
|
let dt_utc = DateTime::<Utc>::from_timestamp(timestamp, 0)?;
|
|
|
|
let date = if let Some(tz) = client_timezone {
|
|
dt_utc.with_timezone(tz).date_naive()
|
|
} else {
|
|
dt_utc.with_timezone(&Local).date_naive()
|
|
};
|
|
|
|
Some(date)
|
|
}
|
|
|
|
pub fn extract_date_from_filename(filename: &str) -> Option<DateTime<FixedOffset>> {
|
|
let build_date_from_ymd_capture =
|
|
|captures: ®ex::Captures| -> Option<DateTime<FixedOffset>> {
|
|
let year = captures.get(1)?.as_str().parse::<i32>().ok()?;
|
|
let month = captures.get(2)?.as_str().parse::<u32>().ok()?;
|
|
let day = captures.get(3)?.as_str().parse::<u32>().ok()?;
|
|
let hour = captures.get(4)?.as_str().parse::<u32>().ok()?;
|
|
let min = captures.get(5)?.as_str().parse::<u32>().ok()?;
|
|
let sec = captures.get(6)?.as_str().parse::<u32>().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"(\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);
|
|
}
|
|
|
|
// Screenshot format: Screenshot_20140601[_-]204450.png
|
|
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);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Dash with compact time format: 2015-01-09-021515.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, att_1422489664680106.jpeg, att_142248967186928.jpeg
|
|
// Matches timestamps with 10-16 digits (seconds, milliseconds, microseconds)
|
|
if let Some(captures) = regex::Regex::new(r"(\d{10,16})\.").ok()?.captures(filename) {
|
|
let timestamp_str = captures.get(1)?.as_str();
|
|
let len = timestamp_str.len();
|
|
|
|
// Skip autogenerated filenames that start with "10000" (e.g., 1000004178.jpg)
|
|
// These are not timestamps but auto-generated file IDs
|
|
if timestamp_str.starts_with("10000") {
|
|
return None;
|
|
}
|
|
|
|
// Try milliseconds first (13 digits exactly)
|
|
if len == 13
|
|
&& let Some(date_time) = timestamp_str
|
|
.parse::<i64>()
|
|
.ok()
|
|
.and_then(DateTime::from_timestamp_millis)
|
|
.map(|naive_dt| naive_dt.fixed_offset())
|
|
{
|
|
return Some(date_time);
|
|
}
|
|
|
|
// For 14-16 digits, treat first 10 digits as seconds to avoid far future dates
|
|
// Examples: att_1422489664680106 (16 digits), att_142248967186928 (15 digits)
|
|
if (14..=16).contains(&len)
|
|
&& let Some(date_time) = timestamp_str[0..10]
|
|
.parse::<i64>()
|
|
.ok()
|
|
.and_then(|timestamp_secs| DateTime::from_timestamp(timestamp_secs, 0))
|
|
.map(|naive_dt| naive_dt.fixed_offset())
|
|
{
|
|
return Some(date_time);
|
|
}
|
|
|
|
// Exactly 10 digits - seconds since epoch
|
|
if len == 10
|
|
&& let Some(date_time) = timestamp_str
|
|
.parse::<i64>()
|
|
.ok()
|
|
.and_then(|timestamp_secs| DateTime::from_timestamp(timestamp_secs, 0))
|
|
.map(|naive_dt| naive_dt.fixed_offset())
|
|
{
|
|
return Some(date_time);
|
|
}
|
|
|
|
// 11-12 digits: try as milliseconds (might be partial millisecond timestamp)
|
|
if (len == 11 || len == 12)
|
|
&& let Some(date_time) = timestamp_str
|
|
.parse::<i64>()
|
|
.ok()
|
|
.and_then(DateTime::from_timestamp_millis)
|
|
.map(|naive_dt| naive_dt.fixed_offset())
|
|
{
|
|
return Some(date_time);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Get the canonical date for a memory with priority: filename → EXIF → metadata
|
|
/// Returns (NaiveDate for matching, timestamp for display, modified timestamp)
|
|
fn get_memory_date_with_priority(
|
|
path: &Path,
|
|
exif_date_taken: Option<i64>,
|
|
client_timezone: &Option<FixedOffset>,
|
|
) -> Option<(NaiveDate, Option<i64>, Option<i64>)> {
|
|
// Read file metadata once
|
|
let meta = std::fs::metadata(path).ok()?;
|
|
|
|
// Priority 1: Try to extract date from filename
|
|
if let Some(filename_date) = path
|
|
.file_name()
|
|
.and_then(|f| f.to_str())
|
|
.and_then(extract_date_from_filename)
|
|
{
|
|
// Convert to client timezone if specified
|
|
let date_in_timezone = if let Some(tz) = client_timezone {
|
|
filename_date.with_timezone(tz)
|
|
} else {
|
|
filename_date.with_timezone(&Local).fixed_offset()
|
|
};
|
|
|
|
let timestamp = if let Some(tz) = client_timezone {
|
|
filename_date.with_timezone(tz).timestamp()
|
|
} else {
|
|
filename_date.timestamp()
|
|
};
|
|
|
|
let modified = meta.modified().ok().map(|t| {
|
|
let utc: DateTime<Utc> = t.into();
|
|
if let Some(tz) = client_timezone {
|
|
utc.with_timezone(tz).timestamp()
|
|
} else {
|
|
utc.timestamp()
|
|
}
|
|
});
|
|
|
|
debug!(
|
|
"Memory date from filename {:?} > {:?} = {:?}",
|
|
path.file_name(),
|
|
filename_date,
|
|
date_in_timezone
|
|
);
|
|
return Some((date_in_timezone.date_naive(), Some(timestamp), modified));
|
|
}
|
|
|
|
// Priority 2: Use EXIF date_taken if available
|
|
if let Some(exif_timestamp) = exif_date_taken {
|
|
let date = timestamp_to_naive_date(exif_timestamp, client_timezone)?;
|
|
|
|
let modified = meta.modified().ok().map(|t| {
|
|
let utc: DateTime<Utc> = t.into();
|
|
if let Some(tz) = client_timezone {
|
|
utc.with_timezone(tz).timestamp()
|
|
} else {
|
|
utc.timestamp()
|
|
}
|
|
});
|
|
|
|
debug!("Memory date from EXIF {:?} = {:?}", path.file_name(), date);
|
|
return Some((date, Some(exif_timestamp), modified));
|
|
}
|
|
|
|
// Priority 3: Fall back to metadata
|
|
let system_time = meta.created().ok().or_else(|| meta.modified().ok())?;
|
|
let dt_utc: DateTime<Utc> = 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()
|
|
};
|
|
|
|
let created_timestamp = if let Some(tz) = client_timezone {
|
|
dt_utc.with_timezone(tz).timestamp()
|
|
} else {
|
|
dt_utc.timestamp()
|
|
};
|
|
|
|
let modified = meta.modified().ok().map(|t| {
|
|
let utc: DateTime<Utc> = t.into();
|
|
if let Some(tz) = client_timezone {
|
|
utc.with_timezone(tz).timestamp()
|
|
} else {
|
|
utc.timestamp()
|
|
}
|
|
});
|
|
|
|
trace!("Fallback metadata create date = {:?}", date_in_timezone);
|
|
Some((date_in_timezone, Some(created_timestamp), modified))
|
|
}
|
|
|
|
/// Collect memories from EXIF database
|
|
fn collect_exif_memories(
|
|
exif_dao: &Data<Mutex<Box<dyn ExifDao>>>,
|
|
context: &opentelemetry::Context,
|
|
base_path: &str,
|
|
now: NaiveDate,
|
|
span_mode: MemoriesSpan,
|
|
years_back: u32,
|
|
client_timezone: &Option<FixedOffset>,
|
|
path_excluder: &PathExcluder,
|
|
) -> Vec<(MemoryItem, NaiveDate)> {
|
|
// Query database for all files with date_taken
|
|
let exif_records = match exif_dao.lock() {
|
|
Ok(mut dao) => match dao.get_all_with_date_taken(context) {
|
|
Ok(records) => records,
|
|
Err(e) => {
|
|
warn!("Failed to query EXIF database: {:?}", e);
|
|
return Vec::new(); // Graceful fallback
|
|
}
|
|
},
|
|
Err(e) => {
|
|
warn!("Failed to lock EXIF DAO: {:?}", e);
|
|
return Vec::new();
|
|
}
|
|
};
|
|
|
|
// Parallel processing with Rayon
|
|
exif_records
|
|
.par_iter()
|
|
.filter_map(|(file_path, date_taken_ts)| {
|
|
// Build full path
|
|
let full_path = Path::new(base_path).join(file_path);
|
|
|
|
// Check exclusions
|
|
if path_excluder.is_excluded(&full_path) {
|
|
return None;
|
|
}
|
|
|
|
// Verify file exists
|
|
if !full_path.exists() || !full_path.is_file() {
|
|
warn!("EXIF record exists but file not found: {:?}", full_path);
|
|
return None;
|
|
}
|
|
|
|
// Get date with priority: filename → EXIF → metadata
|
|
// This ensures sorting and display use the same date source
|
|
let (file_date, created, modified) =
|
|
get_memory_date_with_priority(&full_path, Some(*date_taken_ts), client_timezone)?;
|
|
|
|
// Check if matches memory criteria
|
|
if !is_memories_match(file_path, file_date, now, span_mode, years_back) {
|
|
return None;
|
|
}
|
|
|
|
Some((
|
|
MemoryItem {
|
|
path: file_path.clone(),
|
|
created,
|
|
modified,
|
|
},
|
|
file_date,
|
|
))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Collect memories from file system scan (for files not in EXIF DB)
|
|
fn collect_filesystem_memories(
|
|
base_path: &str,
|
|
path_excluder: &PathExcluder,
|
|
skip_paths: &HashSet<PathBuf>,
|
|
now: NaiveDate,
|
|
span_mode: MemoriesSpan,
|
|
years_back: u32,
|
|
client_timezone: &Option<FixedOffset>,
|
|
) -> Vec<(MemoryItem, NaiveDate)> {
|
|
let base = Path::new(base_path);
|
|
|
|
let entries: Vec<_> = WalkDir::new(base)
|
|
.into_iter()
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| {
|
|
let path = e.path();
|
|
|
|
// Skip if already processed by EXIF query
|
|
if skip_paths.contains(path) {
|
|
return false;
|
|
}
|
|
|
|
// Check exclusions
|
|
if path_excluder.is_excluded(path) {
|
|
return false;
|
|
}
|
|
|
|
// Only process image/video files
|
|
e.file_type().is_file() && is_image_or_video(path)
|
|
})
|
|
.collect();
|
|
|
|
entries
|
|
.par_iter()
|
|
.filter_map(|entry| {
|
|
// Use unified date priority function (no EXIF for filesystem scan)
|
|
let (file_date, created, modified) =
|
|
get_memory_date_with_priority(entry.path(), None, client_timezone)?;
|
|
|
|
if is_memories_match(
|
|
entry.path().to_str().unwrap_or("Unknown"),
|
|
file_date,
|
|
now,
|
|
span_mode,
|
|
years_back,
|
|
) {
|
|
let path_relative = entry.path().strip_prefix(base).ok()?.to_str()?.to_string();
|
|
|
|
Some((
|
|
MemoryItem {
|
|
path: path_relative,
|
|
created,
|
|
modified,
|
|
},
|
|
file_date,
|
|
))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[get("/memories")]
|
|
pub async fn list_memories(
|
|
_claims: Claims,
|
|
request: HttpRequest,
|
|
q: web::Query<MemoriesRequest>,
|
|
app_state: Data<AppState>,
|
|
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
|
) -> impl Responder {
|
|
let tracer = global_tracer();
|
|
let parent_context = extract_context_from_request(&request);
|
|
let mut span = tracer.start_with_context("list_memories", &parent_context);
|
|
let span_context =
|
|
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
|
|
|
|
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 the path excluder from base and env-configured exclusions
|
|
let path_excluder = PathExcluder::new(base, &app_state.excluded_dirs);
|
|
|
|
// Phase 1: Query EXIF database
|
|
let exif_memories = collect_exif_memories(
|
|
&exif_dao,
|
|
&span_context,
|
|
&app_state.base_path,
|
|
now,
|
|
span_mode,
|
|
years_back,
|
|
&client_timezone,
|
|
&path_excluder,
|
|
);
|
|
|
|
// Build HashSet for deduplication
|
|
let exif_paths: HashSet<PathBuf> = exif_memories
|
|
.iter()
|
|
.map(|(item, _)| PathBuf::from(&app_state.base_path).join(&item.path))
|
|
.collect();
|
|
|
|
// Phase 2: File system scan (skip EXIF files)
|
|
let fs_memories = collect_filesystem_memories(
|
|
&app_state.base_path,
|
|
&path_excluder,
|
|
&exif_paths,
|
|
now,
|
|
span_mode,
|
|
years_back,
|
|
&client_timezone,
|
|
);
|
|
|
|
// Phase 3: Merge and sort
|
|
let mut memories_with_dates = exif_memories;
|
|
memories_with_dates.extend(fs_memories);
|
|
|
|
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)),
|
|
// For week span, sort by day of month, then by full timestamp (oldest first)
|
|
MemoriesSpan::Week => {
|
|
memories_with_dates.sort_by(|a, b| {
|
|
// First, sort by day of month
|
|
let day_cmp = a.1.day().cmp(&b.1.day());
|
|
if day_cmp != std::cmp::Ordering::Equal {
|
|
return day_cmp;
|
|
}
|
|
|
|
// Then sort by full created timestamp (oldest to newest)
|
|
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,
|
|
}
|
|
});
|
|
}
|
|
// For day span, sort by day of month then by time
|
|
MemoriesSpan::Day => {
|
|
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<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()),
|
|
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_path: &str,
|
|
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_path, 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::{self, File};
|
|
use tempfile::tempdir;
|
|
|
|
#[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_screenshot_less_dashes_format() {
|
|
let filename = "Screenshot_20140601-204450.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_screenshot_underscores_format() {
|
|
let filename = "20140601_204450.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_dash_compact_time_format() {
|
|
let filename = "2015-01-09-021515.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_extract_date_from_filename_attachment_15_digits() {
|
|
// att_142248967186928.jpeg - 15 digits, should parse first 10 as seconds
|
|
// 1422489671 = 2015-01-28 23:07:51 UTC (converts to local timezone)
|
|
let filename = "att_142248967186928.jpeg";
|
|
let date_time = extract_date_from_filename(filename).unwrap();
|
|
|
|
// Verify year and month are correct (2015-01)
|
|
assert_eq!(date_time.year(), 2015);
|
|
assert_eq!(date_time.month(), 1);
|
|
// Day may be 28 or 29 depending on timezone
|
|
assert!(date_time.day() >= 28 && date_time.day() <= 29);
|
|
|
|
// Verify timestamp is within expected range (should be around 1422489671)
|
|
let timestamp = date_time.timestamp();
|
|
assert!(timestamp >= 1422480000 && timestamp <= 1422576000); // Jan 28-29, 2015
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_attachment_16_digits() {
|
|
// att_1422489664680106.jpeg - 16 digits, should parse first 10 as seconds
|
|
// 1422489664 = 2015-01-28 23:07:44 UTC (converts to local timezone)
|
|
let filename = "att_1422489664680106.jpeg";
|
|
let date_time = extract_date_from_filename(filename).unwrap();
|
|
|
|
// Verify year and month are correct (2015-01)
|
|
assert_eq!(date_time.year(), 2015);
|
|
assert_eq!(date_time.month(), 1);
|
|
// Day may be 28 or 29 depending on timezone
|
|
assert!(date_time.day() >= 28 && date_time.day() <= 29);
|
|
|
|
// Verify timestamp is within expected range (should be around 1422489664)
|
|
let timestamp = date_time.timestamp();
|
|
assert!(timestamp >= 1422480000 && timestamp <= 1422576000); // Jan 28-29, 2015
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_autogenerated_should_not_match() {
|
|
// Autogenerated filenames like 1000004178.jpg should NOT be parsed as timestamps
|
|
// These start with "10000" which would be Sept 2001 if parsed literally
|
|
let filename = "1000004178.jpg";
|
|
let date_time = extract_date_from_filename(filename);
|
|
|
|
assert!(
|
|
date_time.is_none(),
|
|
"Autogenerated filenames starting with 10000 should not be parsed as dates"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_memory_date_priority_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();
|
|
|
|
// Test that filename takes priority (even with EXIF data available)
|
|
let exif_date = DateTime::<Utc>::from_timestamp(1609459200, 0) // 2021-01-01
|
|
.unwrap()
|
|
.timestamp();
|
|
|
|
let (date, created, _) = get_memory_date_with_priority(
|
|
&temp_file,
|
|
Some(exif_date),
|
|
&Some(*Local::now().fixed_offset().offset()),
|
|
)
|
|
.unwrap();
|
|
|
|
// Check that date is from filename (2014), NOT EXIF (2021)
|
|
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_memory_date_priority_metadata_fallback() {
|
|
let temp_dir = tempdir().unwrap();
|
|
let temp_file = temp_dir.path().join("regular_image.jpg");
|
|
File::create(&temp_file).unwrap();
|
|
|
|
// Test metadata fallback when no filename date or EXIF
|
|
let (date, created, modified) =
|
|
get_memory_date_with_priority(&temp_file, None, &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::<Utc>::from_timestamp(created.unwrap(), 0).unwrap();
|
|
assert_eq!(dt_created.year(), today.year());
|
|
|
|
let dt_modified = DateTime::<Utc>::from_timestamp(modified.unwrap(), 0).unwrap();
|
|
assert_eq!(dt_modified.year(), today.year());
|
|
}
|
|
|
|
#[test]
|
|
fn test_memory_date_priority_exif_over_metadata() {
|
|
let temp_dir = tempdir().unwrap();
|
|
let temp_file = temp_dir.path().join("regular_image.jpg");
|
|
File::create(&temp_file).unwrap();
|
|
|
|
// Test that EXIF takes priority over metadata (but not filename)
|
|
// EXIF date: June 15, 2020 12:00:00 UTC (safe from timezone edge cases)
|
|
let exif_date = DateTime::<Utc>::from_timestamp(1592222400, 0) // 2020-06-15 12:00:00 UTC
|
|
.unwrap()
|
|
.timestamp();
|
|
|
|
let (date, created, modified) =
|
|
get_memory_date_with_priority(&temp_file, Some(exif_date), &None).unwrap();
|
|
|
|
// Date should be from EXIF (2020), not metadata (today)
|
|
assert_eq!(date.year(), 2020);
|
|
assert_eq!(date.month(), 6);
|
|
assert_eq!(date.day(), 15);
|
|
|
|
// Created timestamp should also be from EXIF
|
|
assert!(created.is_some());
|
|
assert_eq!(created.unwrap(), exif_date);
|
|
|
|
// Modified should still be from metadata
|
|
assert!(modified.is_some());
|
|
let today = Local::now().date_naive();
|
|
let dt_modified = DateTime::<Utc>::from_timestamp(modified.unwrap(), 0).unwrap();
|
|
assert_eq!(dt_modified.year(), today.year());
|
|
}
|
|
|
|
#[test]
|
|
fn test_path_excluder_absolute_under_base() {
|
|
let tmp = tempdir().unwrap();
|
|
let base = tmp.path();
|
|
|
|
// Simulate structure:
|
|
// base/photos/private/secret.jpg
|
|
// base/photos/public/ok.jpg
|
|
// base/screenshots/img.png
|
|
let photos_private = base.join("photos/private");
|
|
let photos_public = base.join("photos/public");
|
|
let screenshots = base.join("screenshots");
|
|
|
|
fs::create_dir_all(&photos_private).unwrap();
|
|
fs::create_dir_all(&photos_public).unwrap();
|
|
fs::create_dir_all(&screenshots).unwrap();
|
|
|
|
let secret = photos_private.join("secret.jpg");
|
|
let ok = photos_public.join("ok.jpg");
|
|
let shot = screenshots.join("img.png");
|
|
|
|
File::create(&secret).unwrap();
|
|
File::create(&ok).unwrap();
|
|
File::create(&shot).unwrap();
|
|
|
|
// Exclude "/photos/private" and "/screenshots" under base
|
|
let excluded = vec![
|
|
String::from("/photos/private"),
|
|
String::from("/screenshots"),
|
|
];
|
|
let excluder = PathExcluder::new(base, &excluded);
|
|
|
|
assert!(excluder.is_excluded(&secret), "secret should be excluded");
|
|
assert!(
|
|
excluder.is_excluded(&shot),
|
|
"screenshots should be excluded"
|
|
);
|
|
assert!(
|
|
!excluder.is_excluded(&ok),
|
|
"public photo should NOT be excluded"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_path_excluder_pattern_anywhere_under_base() {
|
|
let tmp = tempdir().unwrap();
|
|
let base = tmp.path();
|
|
|
|
// Simulate:
|
|
// base/a/tmp_file.jpg
|
|
// base/b/normal.jpg
|
|
// base/c/sometmpdir/file.jpg
|
|
let a = base.join("a");
|
|
let b = base.join("b");
|
|
let c = base.join("c/tmp");
|
|
|
|
fs::create_dir_all(&a).unwrap();
|
|
fs::create_dir_all(&b).unwrap();
|
|
fs::create_dir_all(&c).unwrap();
|
|
|
|
let tmp_file = a.join("tmp_file.jpg");
|
|
let normal = b.join("normal.jpg");
|
|
let tmp_dir_file = c.join("file.jpg");
|
|
|
|
File::create(&tmp_file).unwrap();
|
|
File::create(&normal).unwrap();
|
|
File::create(&tmp_dir_file).unwrap();
|
|
|
|
// Exclude any path containing "tmp"
|
|
let excluded = vec![String::from("tmp")];
|
|
let excluder = PathExcluder::new(base, &excluded);
|
|
|
|
assert!(
|
|
!excluder.is_excluded(&tmp_file),
|
|
"file with 'tmp' in name should NOT be excluded"
|
|
);
|
|
assert!(
|
|
excluder.is_excluded(&tmp_dir_file),
|
|
"file in directory with 'tmp' in path should be excluded"
|
|
);
|
|
assert!(
|
|
!excluder.is_excluded(&normal),
|
|
"file without 'tmp' in its path should NOT be excluded"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_path_excluder_mixed_absolute_and_pattern() {
|
|
let tmp = tempdir().unwrap();
|
|
let base = tmp.path();
|
|
|
|
// Simulate:
|
|
// base/photos/private/secret_tmp.jpg -> excluded by absolute dir rule
|
|
// base/photos/private/secret.jpg -> excluded by absolute dir rule
|
|
// base/photos/tmp/public.jpg -> excluded by pattern "tmp" (dir name)
|
|
// base/photos/public/tmp_public.jpg -> NOT excluded (file name contains "tmp" but not equal)
|
|
// base/other/keep.jpg -> NOT excluded
|
|
let photos_private = base.join("photos/private");
|
|
let photos_tmp = base.join("photos/tmp");
|
|
let photos_public = base.join("photos/public");
|
|
let other = base.join("other");
|
|
|
|
fs::create_dir_all(&photos_private).unwrap();
|
|
fs::create_dir_all(&photos_tmp).unwrap();
|
|
fs::create_dir_all(&photos_public).unwrap();
|
|
fs::create_dir_all(&other).unwrap();
|
|
|
|
let secret_tmp = photos_private.join("secret_tmp.jpg");
|
|
let secret = photos_private.join("secret.jpg");
|
|
let tmp_dir_file = photos_tmp.join("public.jpg");
|
|
let tmp_in_name = photos_public.join("tmp_public.jpg");
|
|
let keep = other.join("keep.jpg");
|
|
|
|
File::create(&secret_tmp).unwrap();
|
|
File::create(&secret).unwrap();
|
|
File::create(&tmp_dir_file).unwrap();
|
|
File::create(&tmp_in_name).unwrap();
|
|
File::create(&keep).unwrap();
|
|
|
|
// Mixed: exclude "/photos/private" (dir) and any component equal to "tmp"
|
|
let excluded = vec![String::from("/photos/private"), String::from("tmp")];
|
|
let excluder = PathExcluder::new(base, &excluded);
|
|
|
|
// Entire private tree is excluded by dir rule
|
|
assert!(excluder.is_excluded(&secret_tmp));
|
|
assert!(excluder.is_excluded(&secret));
|
|
|
|
// Dir 'tmp' under photos excluded by pattern
|
|
assert!(excluder.is_excluded(&tmp_dir_file));
|
|
|
|
// File name containing 'tmp' but not equal should NOT be excluded
|
|
assert!(!excluder.is_excluded(&tmp_in_name));
|
|
|
|
// keep.jpg doesn't match any rule
|
|
assert!(!excluder.is_excluded(&keep));
|
|
}
|
|
|
|
#[test]
|
|
fn test_week_span_sorting_chronological_by_day() {
|
|
// Test that Week span sorts by:
|
|
// 1. Day of month (ascending)
|
|
// 2. Full timestamp oldest to newest (year + time combined)
|
|
|
|
// Create test data:
|
|
// - Jan 15, 2024 at 9:00 AM
|
|
// - Jan 15, 2020 at 10:00 AM
|
|
// - Jan 16, 2021 at 8:00 AM
|
|
|
|
let jan_15_2024_9am = NaiveDate::from_ymd_opt(2024, 1, 15)
|
|
.unwrap()
|
|
.and_hms_opt(9, 0, 0)
|
|
.unwrap()
|
|
.and_utc()
|
|
.timestamp();
|
|
|
|
let jan_15_2020_10am = NaiveDate::from_ymd_opt(2020, 1, 15)
|
|
.unwrap()
|
|
.and_hms_opt(10, 0, 0)
|
|
.unwrap()
|
|
.and_utc()
|
|
.timestamp();
|
|
|
|
let jan_16_2021_8am = NaiveDate::from_ymd_opt(2021, 1, 16)
|
|
.unwrap()
|
|
.and_hms_opt(8, 0, 0)
|
|
.unwrap()
|
|
.and_utc()
|
|
.timestamp();
|
|
|
|
let mut memories_with_dates = vec![
|
|
(
|
|
MemoryItem {
|
|
path: "photo1.jpg".to_string(),
|
|
created: Some(jan_15_2024_9am),
|
|
modified: Some(jan_15_2024_9am),
|
|
},
|
|
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
|
|
),
|
|
(
|
|
MemoryItem {
|
|
path: "photo2.jpg".to_string(),
|
|
created: Some(jan_15_2020_10am),
|
|
modified: Some(jan_15_2020_10am),
|
|
},
|
|
NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(),
|
|
),
|
|
(
|
|
MemoryItem {
|
|
path: "photo3.jpg".to_string(),
|
|
created: Some(jan_16_2021_8am),
|
|
modified: Some(jan_16_2021_8am),
|
|
},
|
|
NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(),
|
|
),
|
|
];
|
|
|
|
// Sort using Week span logic
|
|
memories_with_dates.sort_by(|a, b| {
|
|
// First, sort by day of month
|
|
let day_cmp = a.1.day().cmp(&b.1.day());
|
|
if day_cmp != std::cmp::Ordering::Equal {
|
|
return day_cmp;
|
|
}
|
|
|
|
// Then sort by full created timestamp (oldest to newest)
|
|
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,
|
|
}
|
|
});
|
|
|
|
// Expected order:
|
|
// 1. Jan 15, 2020 at 10:00 AM (oldest Jan 15 photo)
|
|
// 2. Jan 15, 2024 at 9:00 AM (newer Jan 15 photo)
|
|
// 3. Jan 16, 2021 at 8:00 AM (all Jan 16 photos after Jan 15)
|
|
|
|
assert_eq!(memories_with_dates[0].0.created.unwrap(), jan_15_2020_10am);
|
|
assert_eq!(memories_with_dates[1].0.created.unwrap(), jan_15_2024_9am);
|
|
assert_eq!(memories_with_dates[2].0.created.unwrap(), jan_16_2021_8am);
|
|
}
|
|
}
|