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:
1 parent
6f73a31e08
commit
a97bf53603
7 files changed
+358
-143
No files matched your search
+126
-62
@@ -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: ®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()?;
|
||||
/// 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: ®ex::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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user