Files
ImageApi/src/memories.rs
Cameron Cordes f77e44b34d faces: fix PathExcluder false-positive + cover face_client/crop in tests
PathExcluder was iterating every component of the absolute path,
including the system prefix. Two of the existing memories tests had
been failing on master because tempdir() lives under /tmp on Linux
and a pattern like "tmp" then matched the system /tmp component
rather than anything the user actually asked to exclude. Phase 3's
file-watch hook will use the same code to skip @eaDir / .thumbnails
under each library's BASE_PATH, so the bug would hide every photo
on a host whose BASE_PATH passes through a directory named the same
as a user pattern.

Fix: store base in PathExcluder and strip it before scanning
components. A path that lives outside base falls through to the
no-match branch (defensive — nothing legit hits that today).

Also extracted the face_client error classification into a pure
classify_error_response(status, body) so the marker-row contract
with Apollo (422 → Permanent / 'failed', 5xx → Transient / defer)
is unit-testable without spinning up an HTTP server.

New tests:
  memories::tests::test_path_excluder_*           — 2 previously
    failing tests now pass.
  ai::face_client::tests::classify_*              — 4 cases:
    422 decode_failed → Permanent, 503 cuda_oom → Transient
    (handles both string and {code:..} detail shapes), 5xx →
    Transient + other 4xx → Permanent, unparseable HTML body still
    classifies on status.
  faces::tests::crop_*                            — 3 cases:
    invalid bbox rejected, valid bbox round-trips through JPEG
    decode, corner crop with 10% padding clamps inside source.

cargo test --lib: 165 passed / 0 failed (was 156 / 2 failed).
cargo fmt and clippy on new code clean. The remaining
sort_by clippy warnings in pre-existing files (memories.rs,
files.rs, exif.rs) are unrelated and present on master.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:09:44 +00:00

1191 lines
41 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::libraries::Library;
use crate::otel::{extract_context_from_request, global_tracer};
use crate::state::AppState;
use crate::utils::earliest_fs_time;
// Helper that encapsulates path-exclusion semantics
#[derive(Debug)]
pub struct PathExcluder {
base: PathBuf,
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 path-component patterns that
/// match a directory or file name *under* `base`. The base prefix is
/// stripped before matching so a system-level component (e.g. the
/// `tmp` in `/tmp/...` when running tests) doesn't masquerade as a
/// user-defined exclude.
pub 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. base={:?}, dirs={:?}, patterns={:?}",
base, excluded_dirs, excluded_patterns
);
Self {
base: base.to_path_buf(),
excluded_dirs,
excluded_patterns,
}
}
/// Returns true if `path` should be excluded.
pub 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;
}
}
if self.excluded_patterns.is_empty() {
return false;
}
// Strip the base prefix before scanning components. Without this,
// every path component above `base` (e.g. `tmp` in `/tmp/test123`
// under tempdir, or the user's `home` in `/home/user/Pictures`)
// would match user-defined patterns and produce false positives.
let scan_root = path.strip_prefix(&self.base).unwrap_or(path);
for component in scan_root.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>,
/// Optional library filter. Accepts a library id (e.g. "1") or name
/// (e.g. "main"). When omitted, results span all libraries.
pub library: Option<String>,
}
#[derive(Debug, Serialize, Clone)]
pub struct MemoryItem {
pub path: String,
pub created: Option<i64>,
pub modified: Option<i64>,
/// Id of the library this memory belongs to. Allows clients to show a
/// per-item source badge in union mode.
pub library_id: i32,
}
#[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: &regex::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 (earlier of created/modified — see utils::earliest_fs_time)
let system_time = earliest_fs_time(&meta)?;
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,
library_id: i32,
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, Some(library_id)) {
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,
library_id,
},
file_date,
))
})
.collect()
}
/// Collect memories from file system scan (for files not in EXIF DB)
fn collect_filesystem_memories(
base_path: &str,
library_id: i32,
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,
library_id,
},
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);
// Resolve the optional library filter. Unknown values are a 400; None
// means "all libraries" — currently equivalent to the primary library
// while only one is configured.
let library = match crate::libraries::resolve_library_param(&app_state, q.library.as_deref()) {
Ok(lib) => lib,
Err(msg) => {
warn!("Rejecting /memories request: {}", msg);
return HttpResponse::BadRequest().body(msg);
}
};
// When `library` is `Some`, scope to that one library; otherwise union
// across every configured library and let the results interleave.
let libraries_to_scan: Vec<&Library> = match library {
Some(lib) => vec![lib],
None => app_state.libraries.iter().collect(),
};
let mut memories_with_dates: Vec<(MemoryItem, NaiveDate)> = Vec::new();
for lib in &libraries_to_scan {
let base = Path::new(&lib.root_path);
let path_excluder = PathExcluder::new(base, &app_state.excluded_dirs);
let exif_memories = collect_exif_memories(
&exif_dao,
&span_context,
&lib.root_path,
lib.id,
now,
span_mode,
years_back,
&client_timezone,
&path_excluder,
);
let exif_paths: HashSet<PathBuf> = exif_memories
.iter()
.map(|(item, _)| PathBuf::from(&lib.root_path).join(&item.path))
.collect();
let fs_memories = collect_filesystem_memories(
&lib.root_path,
lib.id,
&path_excluder,
&exif_paths,
now,
span_mode,
years_back,
&client_timezone,
);
memories_with_dates.extend(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 full date + timestamp (chronological)
MemoriesSpan::Week => {
memories_with_dates.sort_by(|a, b| {
// First, sort by full date (year, month, day)
let date_cmp = a.1.cmp(&b.1);
if date_cmp != std::cmp::Ordering::Equal {
return date_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!((1422480000..=1422576000).contains(&timestamp)); // 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!((1422480000..=1422576000).contains(&timestamp)); // 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 = [
(
MemoryItem {
path: "photo1.jpg".to_string(),
created: Some(jan_15_2024_9am),
modified: Some(jan_15_2024_9am),
library_id: 1,
},
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),
library_id: 1,
},
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),
library_id: 1,
},
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);
}
}