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

+198 -59
View File
@@ -16,20 +16,37 @@
//! `creation_time` fields) and skip the cascade; photo-EXIF wall
//! clocks run the cascade over the file's own `OffsetTime*` / GPS
//! evidence (ADR-0001, ticket #115).
//! 3. **filename regex** — `memories::extract_date_from_filename` covers
//! common screenshot / chat-export / timestamp-named patterns.
//! 4. **earliest filesystem time** — `utils::earliest_fs_time` picks the
//! 0. **filename date class** (ticket #116) — `memories::extract_date_from_filename`
//! covers common screenshot / dash / compact / unix-timestamp patterns.
//! A file whose name matches any pattern is **in the class**: the
//! filename is the original date of record and wins *before* EXIF / container
//! dates are even consulted (wall patterns run the offset cascade over the
//! file's own evidence; timestamp patterns are true instants).
//! 1. **kamadak-exif** — fast in-process EXIF read. Already done by
//! `exif::extract_exif_from_path` for image-bearing formats; callers
//! pass that result in via `prior_exif_date` so we don't re-parse.
//! 2. **exiftool** — shell-out fallback that reaches places kamadak-exif
//! can't: QuickTime/MP4 (`MediaCreateDate`, `TrackCreateDate`,
//! `CreateDate`), Apple's `ContentCreateDate`, MakerNote sub-IFDs.
//! Required for videos to land a real date; degrades silently when
//! `exiftool` isn't on PATH. Unlike the kamadak branch, this branch
//! reads raw date *strings* (no `-d %s`) and interprets them itself:
//! QuickTime/MP4 container dates are true instants (UTC-rendered
//! `creation_time` fields) and skip the cascade; photo-EXIF wall
//! clocks run the cascade over the file's own `OffsetTime*` / GPS
//! evidence (ADR-0001, ticket #115).
//! 3. **earliest filesystem time** — `utils::earliest_fs_time` picks the
//! earlier of created / modified, which on copied-from-backup files is
//! a better proxy for content age than either alone.
//!
//! `DateSource` records which step won so the per-tick drain can re-resolve
//! weak sources (`fs_time`) once exiftool becomes available, and so the
//! UI/debug surface can answer "why does this photo show up under this
//! date." Note that the previous `/memories` request-time logic preferred
//! filename even when EXIF was present; this resolver inverts that — EXIF
//! is authoritative when it exists, on the theory that an EXIF
//! `DateTimeOriginal` is more reliable than a filename pattern that may
//! reflect import time rather than capture time.
//! date." For out-of-class files EXIF is authoritative when it exists,
//! on the theory that an EXIF `DateTimeOriginal` is more reliable than a
//! filename pattern that may reflect import time rather than capture time.
//! In-class files invert that rule deliberately (ADR-0001): a renamed
//! screenshot or a chat-export timestamp is the only honest date left.
use std::collections::HashMap;
use std::io::Write;
@@ -49,7 +66,8 @@ pub enum DateSource {
Exif,
/// exiftool fallback caught a video / MakerNote / QuickTime tag.
Exiftool,
/// `extract_date_from_filename` matched a known pattern.
/// `extract_date_from_filename` matched a known pattern — the file is
/// in the filename date class and the name outranked EXIF (#116).
Filename,
/// Fell through to `earliest_fs_time(metadata)`.
FsTime,
@@ -84,14 +102,16 @@ pub struct ResolvedDate {
/// behavior of each branch so rollback stays trivial (ADR-0001).
pub timestamp: i64,
pub source: DateSource,
/// True UTC instant under the new contract (ADR-0001). `None` for
/// filename-sourced rows: their convention (wall-clock vs UTC) is a
/// per-pattern decision made by the filename date-class work (#116).
/// True UTC instant under the new contract (ADR-0001). Every branch
/// classifies (wall clocks via the cascade, instants directly);
/// `Option` is kept for schema compatibility with rows written before
/// the date-class work landed (#116).
pub date_utc: Option<i64>,
/// Camera offset (minutes) that produced `date_utc`; `None` when the
/// branch is a direct true instant or unclassified.
/// Camera offset (minutes) that produced `date_utc`; `None` for direct
/// true instants (no offset is involved).
pub camera_offset_minutes: Option<i32>,
/// Why `date_utc` has the offset it does. `None` when unclassified.
/// Why `date_utc` has the offset it does; `None` on rows written
/// before every branch classified (#116).
pub tz_source: Option<camera_tz::TzSource>,
}
@@ -132,6 +152,57 @@ fn exif_prior_resolved(prior: &ExifPrior) -> ResolvedDate {
}
}
/// The filename-class step (ticket #116), shared by both waterfalls: the
/// in-class name is the original date of record and outranks EXIF /
/// container dates. `evidence` is the kamadak prior's offset evidence when
/// the file has one; `None` means empty evidence — the cascade then assumes
/// the server zone, so a class file always resolves.
fn filename_class_resolved(
path: &Path,
evidence: Option<&camera_tz::OffsetEvidence>,
) -> Option<ResolvedDate> {
let filename_date = path
.file_name()
.and_then(|f| f.to_str())
.and_then(crate::memories::extract_date_from_filename)?;
let ev = evidence.copied().unwrap_or_default();
Some(filename_resolved(&filename_date, &ev))
}
/// Classify one filename date (ticket #116). Wall patterns run the offset
/// cascade over whatever evidence the file carries; timestamp patterns are
/// true instants outright. The legacy value is the pattern's pre-migration
/// interpretation: wall-as-UTC / epoch.
fn filename_resolved(
filename_date: &crate::memories::FilenameDate,
evidence: &camera_tz::OffsetEvidence,
) -> ResolvedDate {
match filename_date {
crate::memories::FilenameDate::Utc(instant) => ResolvedDate {
timestamp: instant.timestamp(),
source: DateSource::Filename,
date_utc: Some(instant.timestamp()),
camera_offset_minutes: None,
tz_source: Some(camera_tz::TzSource::UtcDirect),
},
crate::memories::FilenameDate::Wall(wall) => {
let resolution = camera_tz::resolve_camera_offset(
*wall,
evidence,
server_zone_cached(),
gps_lookup(),
);
ResolvedDate {
timestamp: wall.and_utc().timestamp(),
source: DateSource::Filename,
date_utc: Some(camera_tz::wall_to_instant(*wall, resolution.minutes)),
camera_offset_minutes: Some(resolution.minutes),
tz_source: Some(resolution.source),
}
}
}
}
/// Host zone, resolved once per process.
fn server_zone_cached() -> Option<chrono_tz::Tz> {
static ZONE: OnceLock<Option<chrono_tz::Tz>> = OnceLock::new();
@@ -149,28 +220,17 @@ fn gps_lookup() -> &'static camera_tz::TzfGpsZoneLookup {
/// the waterfall fails — for files that exist on disk this should be
/// vanishingly rare (the fs-time fallback alone almost always succeeds).
pub fn resolve_date_taken(path: &Path, prior_exif: Option<&ExifPrior>) -> Option<ResolvedDate> {
// Filename date class first (ticket #116): an in-class name is the
// original date of record and outranks EXIF / container dates.
if let Some(resolved) = filename_class_resolved(path, prior_exif.map(|p| &p.evidence)) {
return Some(resolved);
}
if let Some(prior) = prior_exif {
return Some(exif_prior_resolved(prior));
}
if let Some(resolved) = exiftool_date_single(path) {
return Some(resolved);
}
if let Some(dt) = path
.file_name()
.and_then(|f| f.to_str())
.and_then(crate::memories::extract_date_from_filename)
{
return Some(ResolvedDate {
timestamp: dt.timestamp(),
source: DateSource::Filename,
// Filename conventions (wall-clock vs UTC) are decided
// per-pattern by the filename date-class work (#116); until
// then these rows stay unclassified.
date_utc: None,
camera_offset_minutes: None,
tz_source: None,
});
}
if let Ok(meta) = std::fs::metadata(path)
&& let Some(t) = earliest_fs_time(&meta)
{
@@ -186,11 +246,12 @@ pub fn resolve_date_taken(path: &Path, prior_exif: Option<&ExifPrior>) -> Option
None
}
/// Batch waterfall. exiftool runs once over the whole batch (single
/// subprocess); everything else is per-file and runs only on misses.
/// `prior_exif_dates` lets the caller pass in already-known kamadak dates
/// keyed by path; entries without a prior date fall through to exiftool
/// and the rest of the waterfall.
/// Batch waterfall. In-class filenames resolve first (ticket #116) and
/// never hit exiftool; exiftool then runs once over the remaining
/// no-prior files (single subprocess); everything else is per-file and
/// runs only on misses. `prior_exif_priors` lets the caller pass in
/// already-known kamadak readings keyed by path; entries without a prior
/// date fall through to exiftool and the rest of the waterfall.
///
/// The per-tick backfill drain is the primary caller — it loads ~500 rows
/// at a time and uses one exiftool subprocess to drain the lot.
@@ -202,6 +263,14 @@ pub fn resolve_dates_batch(
let mut needs_exiftool: Vec<&Path> = Vec::with_capacity(paths.len());
for path in paths {
// Filename date class first (ticket #116) — same rule as the
// single-file waterfall; in-class files skip exiftool entirely.
if let Some(resolved) =
filename_class_resolved(path, prior_exif_priors.get(path).map(|p| &p.evidence))
{
out.insert(path.clone(), resolved);
continue;
}
if let Some(prior) = prior_exif_priors.get(path) {
out.insert(path.clone(), exif_prior_resolved(prior));
} else {
@@ -217,27 +286,13 @@ pub fn resolve_dates_batch(
}
}
// Remaining misses are out-of-class files with no EXIF / container
// date — the filename step can't match them (any match would have
// resolved in the first loop), so they fall straight to fs-time.
for path in paths {
if out.contains_key(path) {
continue;
}
if let Some(dt) = path
.file_name()
.and_then(|f| f.to_str())
.and_then(crate::memories::extract_date_from_filename)
{
out.insert(
path.clone(),
ResolvedDate {
timestamp: dt.timestamp(),
source: DateSource::Filename,
date_utc: None,
camera_offset_minutes: None,
tz_source: None,
},
);
continue;
}
if let Ok(meta) = std::fs::metadata(path)
&& let Some(t) = earliest_fs_time(&meta)
{
@@ -1089,19 +1144,103 @@ mod tests {
}
#[test]
fn resolve_filename_when_no_exif_and_file_missing() {
// No prior EXIF, no exiftool match (file missing), but the filename
// pattern still matches so the resolver lands on Filename. The
// filename branch stays unclassified (#116): date_utc is NULL.
fn filename_class_wall_without_evidence_assumes_server_zone() {
// In-class screenshot name, no prior EXIF, file missing. The wall
// clock runs the cascade with empty evidence and lands on
// assumed_server — a class file always resolves (#116). Legacy
// stays the pre-migration wall-as-UTC interpretation.
let resolved = resolve_date_taken(
Path::new("/nonexistent/Screenshot_2014-06-01-20-44-50.png"),
None,
)
.unwrap();
let wall =
NaiveDateTime::parse_from_str("2014-06-01 20:44:50", "%Y-%m-%d %H:%M:%S").unwrap();
assert_eq!(resolved.source, DateSource::Filename);
assert_eq!(resolved.date_utc, None);
assert_eq!(resolved.timestamp, wall.and_utc().timestamp());
assert!(resolved.date_utc.is_some());
assert!(resolved.camera_offset_minutes.is_some());
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::AssumedServer));
assert_eq!(
resolved.date_utc.unwrap(),
resolved.timestamp - (resolved.camera_offset_minutes.unwrap() as i64) * 60
);
}
#[test]
fn filename_class_wall_outranks_exif_and_uses_its_evidence() {
// In-class filename beats the EXIF date entirely (the priority
// inversion), and the wall cascade uses the prior's offset-tag
// evidence. The EXIF wall (2020) never reaches the result.
let prior = ExifPrior {
wall: NaiveDateTime::parse_from_str("2020-05-05 05:05:05", "%Y-%m-%d %H:%M:%S")
.unwrap(),
evidence: camera_tz::OffsetEvidence {
offset_tag_minutes: Some(-240),
gps: None,
},
};
let resolved = resolve_date_taken(
Path::new("/nonexistent/2015-01-09_02-15-15.jpg"),
Some(&prior),
)
.unwrap();
assert_eq!(resolved.source, DateSource::Filename);
assert_eq!(resolved.timestamp, 1_420_769_715); // wall-as-UTC legacy
assert_eq!(resolved.camera_offset_minutes, Some(-240));
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::OffsetTag));
// The offset tag short-circuits the cascade and is used as-stamped:
// wall − (−240) → 2015-01-09 06:15:15Z.
assert_eq!(resolved.date_utc, Some(1_420_784_115));
}
#[test]
fn filename_class_wall_gps_evidence_uses_capture_zone() {
// No offset tag; the prior carries Manhattan GPS. The bundled TZF
// geometry resolves America/New_York, whose offset for the
// July wall is EDT (-240).
let prior = ExifPrior {
wall: NaiveDateTime::parse_from_str("2024-07-04 12:00:00", "%Y-%m-%d %H:%M:%S")
.unwrap(),
evidence: camera_tz::OffsetEvidence {
offset_tag_minutes: None,
gps: Some((40.7128, -74.0060)),
},
};
let resolved = resolve_date_taken(
Path::new("/nonexistent/Screenshot_2024-07-04-12-00-00.png"),
Some(&prior),
)
.unwrap();
assert_eq!(resolved.source, DateSource::Filename);
assert_eq!(resolved.camera_offset_minutes, Some(-240));
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::Gps));
assert_eq!(resolved.date_utc, Some(1_720_108_800)); // 16:00:00Z
}
#[test]
fn filename_class_timestamp_is_utc_direct() {
// A unix-timestamp name is a true instant: no cascade, no offset,
// and legacy equals the epoch (the pre-migration interpretation).
let resolved =
resolve_date_taken(Path::new("/nonexistent/xyz_1401638400.jpeg"), None).unwrap();
assert_eq!(resolved.source, DateSource::Filename);
assert_eq!(resolved.timestamp, 1_401_638_400);
assert_eq!(resolved.date_utc, Some(1_401_638_400));
assert_eq!(resolved.camera_offset_minutes, None);
assert_eq!(resolved.tz_source, None);
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::UtcDirect));
}
#[test]
fn in_class_video_name_resolves_without_exiftool() {
// A chat-export-style name on a video path: the class check runs
// before exiftool is consulted — the file need not even exist for
// the result to be class-resolved.
let resolved =
resolve_date_taken(Path::new("/nonexistent/att_1422489664680106.mp4"), None).unwrap();
assert_eq!(resolved.source, DateSource::Filename);
assert_eq!(resolved.date_utc, Some(1_422_489_664));
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::UtcDirect));
}
#[test]