Files
ImageApi/src/memories.rs
T
Cameron Cordes 3aa6ce314c feat(dates): pre-cutover audit utility for the filename date class (#117)
One-shot, operator-run, strictly read-only utility that lists every
filename-class file with its stored date/source and the date/source/
offset-source the re-based resolver would assign it — the human
spot-check that gates the production backfill drain (#118).

- src/filename_audit.rs: StoredDate/AuditRow/SkippedLibrary; walk +
  PathExcluder (depth-0 guarded) + match_filename_date -> one
  resolve_dates_batch pass with EMPTY priors (mirrors the drain
  exactly); disabled and probe-stale libraries reported as skipped,
  never partially processed; output stable-sorted by (library, path).
- src/bin/filename_audit.rs: operator binary (dotenv, DATABASE_URL,
  EXCLUDED_DIRS, libraries::load_all, format_report).
- src/memories.rs: match_filename_date canonical matcher returning
  FilenameMatch { pattern, date }; extract_date_from_filename is now
  a thin wrapper (byte-for-byte behavior-preserving, verified by
  review).

4 audit tests (inversion + utc_direct + exclusions, offline/disabled
skip, stable sort, report determinism incl. no debug quotes) + 2
matcher tests. 645 lib + 718 integration pass; clippy/fmt clean.
Standards review applied: {:?}->{}, depth-0 walk guard, dead
per-file exclude check removed, unused Default derive removed,
pattern column widened, doc comment reworded.
2026-09-25 18:03:38 -04:00

1091 lines
42 KiB
Rust

use actix_web::web::Data;
use actix_web::{HttpRequest, HttpResponse, Responder, get, web};
use chrono::{DateTime, FixedOffset, Local, NaiveDate, NaiveDateTime, Utc};
use log::{debug, trace, warn};
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::path::PathBuf;
use std::sync::Mutex;
use crate::data::Claims;
use crate::database::ExifDao;
use crate::otel::{extract_context_from_request, global_tracer};
use crate::state::AppState;
// 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>,
}
/// A date carried by a filename, with its convention (ticket #116).
///
/// The two families of pattern make fundamentally different claims:
/// - **`Wall`** — the device's own clock, as displayed (screenshots,
/// dash / compact name formats). It is a wall clock and needs the
/// offset cascade (offset tag → GPS → server zone) to become a true
/// instant, exactly like a photo-EXIF wall clock.
/// - **`Utc`** — a unix-timestamp name (`1401638400`, `att_1422489664…`)
/// is already a true instant; no offset is involved.
///
/// Files whose name matches any pattern are **in the filename date
/// class**: the filename is the original date of record and outranks
/// EXIF / container dates (ADR-0001). Files whose name matches nothing
/// are out of the class and keep the global EXIF-wins waterfall.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum FilenameDate {
/// Wall clock the device displayed (screenshot / dash / compact).
Wall(NaiveDateTime),
/// True instant the unix-timestamp pattern names directly.
Utc(DateTime<Utc>),
}
impl FilenameDate {
/// The exact value the legacy `date_taken` column stores for this
/// filename: wall patterns as wall-as-UTC (the pre-migration
/// convention), timestamp patterns as their epoch. Consumers that
/// only need the legacy value (metadata responses, insight prompt
/// fallbacks) use this so the legacy contract stays byte-identical.
pub fn legacy_datetime(self) -> DateTime<Utc> {
match self {
FilenameDate::Wall(wall) => wall.and_utc(),
FilenameDate::Utc(dt) => dt,
}
}
/// The exact legacy `date_taken` unix seconds (`legacy_datetime`
/// as an epoch). The one-liner most consumers need.
pub fn legacy_timestamp(self) -> i64 {
self.legacy_datetime().timestamp()
}
}
/// A filename that matched one of the filename-class patterns (#116):
/// which pattern fired and the date it carries.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct FilenameMatch {
/// Stable pattern name for audit / debug surfaces: `screenshot`,
/// `screenshot_compact`, `dash`, `dash_compact`, `compact`,
/// `unix_timestamp`.
pub pattern: &'static str,
pub date: FilenameDate,
}
/// Match a filename against the filename-class patterns, reporting which
/// pattern fired. This is the canonical matcher; `extract_date_from_filename`
/// is the date-only projection of it.
pub fn match_filename_date(filename: &str) -> Option<FilenameMatch> {
// The wall-clock patterns (screenshot / dash / compact) carry the
// device's own clock; the unix-timestamp pattern carries a true
// instant. `FilenameDate::legacy_datetime()` reproduces the
// pre-migration interpretation of each (naive-as-UTC / epoch) for the
// legacy column and its consumers.
let build_wall_from_ymd_capture = |captures: &regex::Captures| -> Option<NaiveDateTime> {
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()?;
NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, min, sec)
};
// 1. Screenshot format: Screenshot_2014-06-01-20-44-50.png
if let Some(naive) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})")
.ok()?
.captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(FilenameMatch {
pattern: "screenshot",
date: FilenameDate::Wall(naive),
});
}
// Screenshot format: Screenshot_20140601[_-]204450.png
if let Some(naive) = regex::Regex::new(r"(\d{4})(\d{2})(\d{2})[_-](\d{2})(\d{2})(\d{2})")
.ok()?
.captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(FilenameMatch {
pattern: "screenshot_compact",
date: FilenameDate::Wall(naive),
});
}
// 2. Dash format: 2015-01-09_02-15-15.jpg
if let Some(naive) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})")
.ok()?
.captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(FilenameMatch {
pattern: "dash",
date: FilenameDate::Wall(naive),
});
}
// Dash with compact time format: 2015-01-09-021515.jpg
if let Some(naive) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})")
.ok()?
.captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(FilenameMatch {
pattern: "dash_compact",
date: FilenameDate::Wall(naive),
});
}
// 3. Compact format: 20140927101712.jpg
if let Some(naive) = regex::Regex::new(r"(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})")
.ok()?
.captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(FilenameMatch {
pattern: "compact",
date: FilenameDate::Wall(naive),
});
}
// 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();
// Snapchat used real unix-second filenames in its early era
// (e.g. `Snapchat-1383929602.jpg` = 2013-11-08), then switched to
// monotonic sequential IDs whose digits overlap plausible epoch
// ranges (`Snapchat-1021849065.mp4` truncates to 2002, actually
// saved 2021; `Snapchat-1751031586660373917.jpg` is 19 digits,
// truncates to 2002, actually 2016). Discriminate by:
// - exactly 10 captured digits AND post-2011-09-23 (launch) → real epoch
// - anything else under this prefix → sequential ID, fall through
// The Snapchat-launch floor catches the 10-digit-2002 case; the
// length=10 gate catches the multi-digit sequential IDs (which
// get truncated to 16 by the regex above).
let lower = filename.to_ascii_lowercase();
let is_snapchat = lower.starts_with("snapchat-");
if is_snapchat && len != 10 {
return None;
}
// 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;
}
// A leading zero rules out a real unix timestamp at any sane
// resolution (seconds since 2001-09-09, ms since 1970-01-01 are
// both 10+ digits with no leading zero). Filenames like
// `000227580005.jpg` are sequential scan IDs, not timestamps.
if timestamp_str.starts_with('0') {
return None;
}
// Try milliseconds first (13 digits exactly)
if len == 13
&& let Some(date_time) = timestamp_str
.parse::<i64>()
.ok()
.and_then(DateTime::<Utc>::from_timestamp_millis)
.and_then(plausible_filename_date)
{
return Some(FilenameMatch {
pattern: "unix_timestamp",
date: FilenameDate::Utc(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(|ts| DateTime::<Utc>::from_timestamp(ts, 0))
.and_then(plausible_filename_date)
{
return Some(FilenameMatch {
pattern: "unix_timestamp",
date: FilenameDate::Utc(date_time),
});
}
// Exactly 10 digits - seconds since epoch
if len == 10
&& let Some(date_time) = timestamp_str
.parse::<i64>()
.ok()
.and_then(|ts| DateTime::<Utc>::from_timestamp(ts, 0))
.and_then(plausible_filename_date)
{
// Snapchat launched 2011-09-23. A 10-digit Snapchat filename
// dated before that is a sequential ID (e.g.
// `Snapchat-1021849065.mp4` parses to 2002), not a real epoch.
const SNAPCHAT_LAUNCH_TS: i64 = 1_316_736_000;
if is_snapchat && date_time.timestamp() < SNAPCHAT_LAUNCH_TS {
return None;
}
return Some(FilenameMatch {
pattern: "unix_timestamp",
date: FilenameDate::Utc(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::<Utc>::from_timestamp_millis)
.and_then(plausible_filename_date)
{
return Some(FilenameMatch {
pattern: "unix_timestamp",
date: FilenameDate::Utc(date_time),
});
}
}
None
}
/// Date-only projection of [`match_filename_date`]: the class match
/// without the pattern name. Resolver and consumer call sites use this;
/// the audit utility uses the full match.
pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
match_filename_date(filename).map(|m| m.date)
}
/// Sanity gate for filename-derived timestamps. Real photo capture dates
/// live in a narrow window; values outside it are almost always sequential
/// scan IDs (`000227580005.jpg` → 1970) or arbitrary numeric suffixes
/// (`IMG_21323906751390.jpeg` → 2037) that the regex caught by accident.
/// Rejecting them lets the date_resolver waterfall fall through to
/// `fs_time`, which is a much better proxy for content age than a fake
/// epoch date.
fn plausible_filename_date(dt: DateTime<Utc>) -> Option<DateTime<Utc>> {
use chrono::Datelike;
let year = dt.year();
// 1995 predates digital photography for most users; allowing one year
// past `now` covers clock-skew on freshly-taken shots without letting
// 2037 timestamps through.
let max_year = Utc::now().year() + 1;
if (1995..=max_year).contains(&year) {
Some(dt)
} else {
None
}
}
/// Convert a `date_taken` Unix-seconds value to a `NaiveDate` in the
/// client's local time. Falls back to server-local when the client didn't
/// send a tz hint.
fn date_in_client_tz(timestamp: i64, client_timezone: Option<FixedOffset>) -> Option<NaiveDate> {
let dt = DateTime::from_timestamp(timestamp, 0)?;
Some(match client_timezone {
Some(tz) => dt.with_timezone(&tz).date_naive(),
None => dt.with_timezone(&Local).date_naive(),
})
}
/// Default lookback for `/memories`. The original 15-year cap pre-dated
/// most of the imported libraries; bumped to 20 so users with deeper
/// archives see those photos surface on the matching anniversary too.
pub const DEFAULT_YEARS_BACK: i32 = 20;
#[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);
// The SQL filter expects a signed offset in minutes from UTC; default
// 0 (UTC) when the client didn't send a hint. We also keep a chrono
// `FixedOffset` for sorting/secondary-key date math in Rust below —
// anchoring both sides on the same value keeps "what SQL matched" and
// "what we sort by" consistent.
let tz_offset_minutes = q.timezone_offset_minutes.unwrap_or(0);
let client_timezone = q
.timezone_offset_minutes
.and_then(|offset_mins| FixedOffset::east_opt(offset_mins * 60));
let items = match gather_memory_items(
&app_state,
&exif_dao,
&span_context,
span_mode,
tz_offset_minutes,
client_timezone,
q.library.as_deref(),
) {
Ok(items) => items,
Err(msg) => {
warn!("Rejecting /memories request: {}", msg);
return HttpResponse::BadRequest().body(msg);
}
};
span.add_event(
"memories_scanned",
vec![
KeyValue::new("span", format!("{:?}", span_mode)),
KeyValue::new("years_back", DEFAULT_YEARS_BACK.to_string()),
KeyValue::new("result_count", items.len().to_string()),
KeyValue::new("tz_offset_minutes", tz_offset_minutes.to_string()),
KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)),
],
);
span.set_status(Status::Ok);
HttpResponse::Ok().json(MemoriesResponse { items })
}
/// Resolve an "on this day/week/month across past years" window into an
/// ordered list of [`MemoryItem`]s. Shared by the `/memories` handler and the
/// memory-reel selector so both honour the same library resolution, per-library
/// exclusions, timezone handling, and sort order. Returns `Err(message)` only
/// when the `library` param is invalid (callers map that to 400); per-library
/// query/lock failures are logged and skipped, matching the handler's
/// best-effort behaviour.
pub fn gather_memory_items(
app_state: &AppState,
exif_dao: &Mutex<Box<dyn ExifDao>>,
span_context: &opentelemetry::Context,
span_mode: MemoriesSpan,
tz_offset_minutes: i32,
client_timezone: Option<FixedOffset>,
library_param: Option<&str>,
) -> Result<Vec<MemoryItem>, String> {
let span_token = match span_mode {
MemoriesSpan::Day => "day",
MemoriesSpan::Week => "week",
MemoriesSpan::Month => "month",
};
let years_back: i32 = DEFAULT_YEARS_BACK;
debug!(
"gather_memory_items: span={:?} tz_offset_min={} years_back={}",
span_mode, tz_offset_minutes, years_back
);
let library = crate::libraries::resolve_library_param_state(app_state, library_param)?;
let libraries_to_scan: Vec<&crate::libraries::Library> = match library {
Some(lib) => vec![lib],
None => app_state.libraries.iter().collect(),
};
// (item, date) tuples — `date` is the canonical NaiveDate of the
// memory in the client's tz, used as the primary sort key.
let mut memories_with_dates: Vec<(MemoryItem, NaiveDate)> = Vec::new();
for lib in &libraries_to_scan {
let base = Path::new(&lib.root_path);
let effective = lib.effective_excluded_dirs(&app_state.excluded_dirs);
let path_excluder = PathExcluder::new(base, &effective);
let rows = match exif_dao.lock() {
Ok(mut dao) => match dao.get_memories_in_window(
span_context,
lib.id,
span_token,
years_back,
tz_offset_minutes,
) {
Ok(rows) => rows,
Err(e) => {
warn!(
"Failed to query memories for library '{}': {:?}",
lib.name, e
);
continue;
}
},
Err(e) => {
warn!("Failed to lock EXIF DAO: {:?}", e);
continue;
}
};
for (rel_path, date_taken_ts, last_modified_ts) in rows {
// Apply per-library exclusions in Rust — they're a small
// set and pushing them into the SQL WHERE adds bind-param
// gymnastics with no measurable win at this scale.
let full_path = base.join(&rel_path);
if path_excluder.is_excluded(&full_path) {
trace!("Memory excluded by PathExcluder: {:?}", full_path);
continue;
}
let Some(file_date) = date_in_client_tz(date_taken_ts, client_timezone) else {
continue;
};
memories_with_dates.push((
MemoryItem {
path: rel_path,
created: Some(date_taken_ts),
modified: Some(last_modified_ts),
library_id: lib.id,
},
file_date,
));
}
}
// Sort once over the merged result set. The SQL filter handles the
// matching; sort order is purely UI concern.
match span_mode {
// Month: chronological — gives an "overview" feel.
MemoriesSpan::Month => memories_with_dates.sort_by(|a, b| a.1.cmp(&b.1)),
// Week: full date then timestamp (oldest → newest).
MemoriesSpan::Week => {
memories_with_dates.sort_by(|a, b| {
a.1.cmp(&b.1)
.then_with(|| match (a.0.created, b.0.created) {
(Some(at), Some(bt)) => at.cmp(&bt),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
})
});
}
// Day: same calendar day across years, sub-sorted by timestamp.
MemoriesSpan::Day => {
memories_with_dates.sort_by(|a, b| match (a.0.created, b.0.created) {
(Some(at), Some(bt)) => at.cmp(&bt),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
});
}
}
Ok(memories_with_dates.into_iter().map(|(m, _)| m).collect())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Datelike, 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 = match extract_date_from_filename(filename).unwrap() {
FilenameDate::Wall(naive) => naive,
other => panic!("expected a wall-clock pattern, got {:?}", other),
};
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 = match extract_date_from_filename(filename).unwrap() {
FilenameDate::Wall(naive) => naive,
other => panic!("expected a wall-clock pattern, got {:?}", other),
};
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 = match extract_date_from_filename(filename).unwrap() {
FilenameDate::Wall(naive) => naive,
other => panic!("expected a wall-clock pattern, got {:?}", other),
};
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 = match extract_date_from_filename(filename).unwrap() {
FilenameDate::Wall(naive) => naive,
other => panic!("expected a wall-clock pattern, got {:?}", other),
};
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 = match extract_date_from_filename(filename).unwrap() {
FilenameDate::Wall(naive) => naive,
other => panic!("expected a wall-clock pattern, got {:?}", other),
};
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 = match extract_date_from_filename(filename).unwrap() {
FilenameDate::Wall(naive) => naive,
other => panic!("expected a wall-clock pattern, got {:?}", other),
};
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
let date_time = match extract_date_from_filename(filename).unwrap() {
FilenameDate::Utc(dt) => dt,
other => panic!("expected a unix-timestamp pattern, got {:?}", other),
};
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 = match extract_date_from_filename(filename).unwrap() {
FilenameDate::Utc(dt) => dt,
other => panic!("expected a unix-timestamp pattern, got {:?}", other),
};
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 = match extract_date_from_filename(filename).unwrap() {
FilenameDate::Utc(dt) => dt,
other => panic!("expected a unix-timestamp pattern, got {:?}", other),
};
// 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 = match extract_date_from_filename(filename).unwrap() {
FilenameDate::Utc(dt) => dt,
other => panic!("expected a unix-timestamp pattern, got {:?}", other),
};
// 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_extract_date_from_filename_leading_zero_scan_id_should_not_match() {
// Sequential film-scan IDs like 000227580005.jpg parsed as a 12-digit
// ms timestamp resolve to 1970-01-03; the leading zero rules out a
// real epoch value at any sane resolution. Resolver should fall
// through to fs_time instead of pinning the photo to 1970.
assert!(extract_date_from_filename("000227580005.jpg").is_none());
}
#[test]
fn test_extract_date_from_filename_far_future_should_not_match() {
// IMG_21323906751390.jpeg → first 10 digits = 2132390675 → 2037.
// Plausibility gate rejects it so the resolver falls through to
// fs_time (which carries the real ingest date).
assert!(extract_date_from_filename("IMG_21323906751390.jpeg").is_none());
}
#[test]
fn test_extract_date_from_filename_snapchat_sequential_ids_rejected() {
// Modern Snapchat-prefixed filenames carry sequential app-assigned
// IDs whose digits happen to fall inside plausible epoch ranges
// when truncated. Reported cases (real save dates per FileModifyDate):
// Snapchat-1021849065.mp4 → 10 digits → 2002-05-19 (saved 2021)
// Snapchat-1751031586660373917.jpg → 19 digits → 2002-09-09 (saved 2016)
// We discriminate by length + Snapchat-launch floor: only exactly
// 10 digits AND post-2011-09-23 (Snapchat launch) is treated as
// a real unix epoch. Anything else falls through to fs_time.
assert!(extract_date_from_filename("Snapchat-1021849065.mp4").is_none());
assert!(extract_date_from_filename("Snapchat-1751031586660373917.jpg").is_none());
// Case-insensitive match — lowercase variant should also reject.
assert!(extract_date_from_filename("snapchat-1021849065.mp4").is_none());
}
#[test]
fn test_extract_date_from_filename_snapchat_early_era_unix_epoch() {
// Early Snapchat (2013-2014ish) wrote real unix-second filenames.
// Snapchat-1383929602.jpg → 1383929602 = 2013-11-08 16:53:22 UTC.
// The blanket-prefix denial introduced for sequential IDs broke
// these — restore via a length=10 + post-launch sanity gate.
let date_time = match extract_date_from_filename("Snapchat-1383929602.jpg").unwrap() {
FilenameDate::Utc(dt) => dt,
other => panic!("expected a unix-timestamp pattern, got {:?}", other),
};
assert_eq!(date_time.timestamp(), 1383929602);
}
// Pattern-name seam (ticket #117): the audit utility prints which
// pattern fired, so every class pattern must report a stable name.
#[test]
fn test_match_filename_date_reports_pattern_names() {
let cases: &[(&str, &str)] = &[
("Screenshot_2014-06-01-20-44-50.png", "screenshot"),
("Screenshot_20140601_204450.png", "screenshot_compact"),
("2015-01-09_02-15-15.jpg", "dash"),
("2015-01-09-021515.jpg", "dash_compact"),
("20140927101712.jpg", "compact"),
("att_1422489664680106.jpeg", "unix_timestamp"),
("1401638400.jpeg", "unix_timestamp"),
];
for (name, pattern) in cases {
let match_ =
match_filename_date(name).unwrap_or_else(|| panic!("no match for {}", name));
assert_eq!(match_.pattern, *pattern, "pattern name for {}", name);
}
}
#[test]
fn test_match_filename_date_out_of_class_is_none() {
assert_eq!(match_filename_date("IMG_12345.jpg"), None);
assert_eq!(match_filename_date("000227580005.jpg"), None);
}
// The obsolete `test_memory_date_priority_*` tests covered the old
// request-time waterfall in `get_memory_date_with_priority`. Their
// replacement lives in `crate::date_resolver::tests` (resolver
// waterfall) and the SQL surface is exercised by integration tests
// that hit `get_memories_in_window` directly.
#[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);
}
}