feat(dates): filename date class — per-pattern conventions + filename-over-EXIF priority (#116)

The filename date class is now first-class (ADR-0001):

- memories::extract_date_from_filename returns FilenameDate:
  Wall(NaiveDateTime) for the screenshot/dash/compact patterns,
  Utc(DateTime<Utc>) for the unix-timestamp patterns. No patterns
  added or removed; every existing pattern carries its convention.
- Waterfall inversion in both resolve_date_taken and
  resolve_dates_batch: an in-class name is the original date of
  record and resolves BEFORE EXIF / container dates are consulted
  (shared filename_class_resolved step; in-class files skip
  exiftool entirely). Wall patterns run the offset cascade over the
  kamadak prior's evidence (empty evidence -> server-zone
  assumption, so a class file always resolves); timestamp patterns
  are true instants (tz_source utc_direct, no offset).
- Legacy date_taken stays byte-identical: wall-as-UTC for wall
  patterns, epoch for timestamp patterns, via
  FilenameDate::legacy_datetime / legacy_timestamp at the seven
  consumer call sites.
- Out-of-class files keep the global EXIF-wins waterfall unchanged.
- CLAUDE.md waterfall section rewritten (step 0 = filename class;
  the EXIF-beats-filename note becomes out-of-class-only).

Tests: five new waterfall-seam tests (wall no-evidence ->
assumed_server; wall outranks EXIF with offset-tag evidence;
wall GPS evidence; timestamp utc_direct; in-class video name
resolves without exiftool) plus enum-shape assertions in every
extractor test. Code-reviewed on both axes (standards: no
violations, 3 of 4 P2 notes applied; spec: all acceptance
criteria verified, no scope creep).

716 integration + 639 lib tests pass; fmt + clippy clean.
This commit is contained in:
Cameron Cordes committed 2026-09-25 16:54:46 -04:00
1 parent 6f73a31e08
commit a97bf53603
7 files changed
+358 -143

No files matched your search

+126 -62
View File
@@ -1,6 +1,6 @@
use actix_web::web::Data;
use actix_web::{HttpRequest, HttpResponse, Responder, get, web};
use chrono::{DateTime, FixedOffset, Local, NaiveDate, TimeZone, Utc};
use chrono::{DateTime, FixedOffset, Local, NaiveDate, NaiveDateTime, Utc};
use log::{debug, trace, warn};
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
@@ -132,72 +132,108 @@ pub struct MemoriesResponse {
pub items: Vec<MemoryItem>,
}
pub fn extract_date_from_filename(filename: &str) -> Option<DateTime<FixedOffset>> {
// Filenames carry only digits — no timezone. We deliberately interpret
// them as UTC so `.timestamp()` returns the wall-clock-as-UTC unix
// seconds, matching the "naive local reinterpreted as UTC" convention
// image_exif.date_taken uses for kamadak-exif DateTimeOriginal (which
// is also naive). Anything else (Local::from_local_datetime, the
// previous behavior) shifted filename-sourced dates by the SERVER's
// TZ offset relative to UTC, making them disagree with EXIF-sourced
// dates by hours and double-shifting through Apollo's photo matcher
// (which re-anchors naive-as-UTC via the browser TZ).
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()?;
/// 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)]
pub enum FilenameDate {
/// Wall clock the device displayed (screenshot / dash / compact).
Wall(NaiveDateTime),
/// True instant the unix-timestamp pattern names directly.
Utc(DateTime<Utc>),
}
let naive = NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, min, sec)?;
Some(Utc.from_utc_datetime(&naive).fixed_offset())
};
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()
}
}
pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
// The wall-clock patterns (screenshot / dash / compact) carry the
// device's own clock; the unix-timestamp pattern carries a true
// instant. `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(captures) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})")
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_date_from_ymd_capture(&c))
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(captures);
return Some(FilenameDate::Wall(naive));
}
// 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})")
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_date_from_ymd_capture(&c))
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(captures);
return Some(FilenameDate::Wall(naive));
}
// 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})")
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_date_from_ymd_capture(&c))
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(captures);
return Some(FilenameDate::Wall(naive));
}
// 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})")
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_date_from_ymd_capture(&c))
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(captures);
return Some(FilenameDate::Wall(naive));
}
// 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})")
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_date_from_ymd_capture(&c))
.and_then(|c| build_wall_from_ymd_capture(&c))
{
return Some(captures);
return Some(FilenameDate::Wall(naive));
}
// 4. Timestamp format: 1401638400.jpeg, att_1422489664680106.jpeg, att_142248967186928.jpeg
@@ -242,11 +278,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<DateTime<FixedOffset
&& let Some(date_time) = timestamp_str
.parse::<i64>()
.ok()
.and_then(DateTime::from_timestamp_millis)
.map(|naive_dt| naive_dt.fixed_offset())
.and_then(DateTime::<Utc>::from_timestamp_millis)
.and_then(plausible_filename_date)
{
return Some(date_time);
return Some(FilenameDate::Utc(date_time));
}
// For 14-16 digits, treat first 10 digits as seconds to avoid far future dates
@@ -255,11 +290,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<DateTime<FixedOffset
&& 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())
.and_then(|ts| DateTime::<Utc>::from_timestamp(ts, 0))
.and_then(plausible_filename_date)
{
return Some(date_time);
return Some(FilenameDate::Utc(date_time));
}
// Exactly 10 digits - seconds since epoch
@@ -267,8 +301,7 @@ pub fn extract_date_from_filename(filename: &str) -> Option<DateTime<FixedOffset
&& 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())
.and_then(|ts| DateTime::<Utc>::from_timestamp(ts, 0))
.and_then(plausible_filename_date)
{
// Snapchat launched 2011-09-23. A 10-digit Snapchat filename
@@ -278,7 +311,7 @@ pub fn extract_date_from_filename(filename: &str) -> Option<DateTime<FixedOffset
if is_snapchat && date_time.timestamp() < SNAPCHAT_LAUNCH_TS {
return None;
}
return Some(date_time);
return Some(FilenameDate::Utc(date_time));
}
// 11-12 digits: try as milliseconds (might be partial millisecond timestamp)
@@ -286,11 +319,10 @@ pub fn extract_date_from_filename(filename: &str) -> Option<DateTime<FixedOffset
&& let Some(date_time) = timestamp_str
.parse::<i64>()
.ok()
.and_then(DateTime::from_timestamp_millis)
.map(|naive_dt| naive_dt.fixed_offset())
.and_then(DateTime::<Utc>::from_timestamp_millis)
.and_then(plausible_filename_date)
{
return Some(date_time);
return Some(FilenameDate::Utc(date_time));
}
}
@@ -304,7 +336,7 @@ pub fn extract_date_from_filename(filename: &str) -> Option<DateTime<FixedOffset
/// 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<FixedOffset>) -> Option<DateTime<FixedOffset>> {
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
@@ -524,7 +556,10 @@ mod tests {
#[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();
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);
@@ -537,7 +572,10 @@ mod tests {
#[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();
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);
@@ -550,7 +588,10 @@ mod tests {
#[test]
fn test_extract_date_from_filename_screenshot_underscores_format() {
let filename = "20140601_204450.png";
let date_time = extract_date_from_filename(filename).unwrap();
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);
@@ -563,7 +604,10 @@ mod tests {
#[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();
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);
@@ -576,7 +620,10 @@ mod tests {
#[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();
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);
@@ -589,7 +636,10 @@ mod tests {
#[test]
fn test_extract_date_from_filename_compact_format() {
let filename = "20140927101712.jpg";
let date_time = extract_date_from_filename(filename).unwrap();
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);
@@ -602,8 +652,10 @@ mod tests {
#[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();
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);
@@ -616,7 +668,10 @@ mod tests {
#[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();
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);
@@ -631,7 +686,10 @@ mod tests {
// 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();
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);
@@ -649,7 +707,10 @@ mod tests {
// 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();
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);
@@ -714,7 +775,10 @@ mod tests {
// 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 = extract_date_from_filename("Snapchat-1383929602.jpg").unwrap();
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);
}