3 Commits
Author SHA1 Message Date
Cameron CordesandClaude Opus 5.5 e104f452f3 feat(insights): read location context from Apollo (#177)
The local location_history table is a one-time Takeout import: it ends
in 2019, still has the ghost-device and coarse cell-fix samples Apollo
has since excluded, and was read loosely. The per-photo line took the
nearest sample within +/-3 h at any distance (17% of photo moments got a
position >2 km away), and get_location_history listed the first 20 rows
of a +/-14-day window sorted ascending (always ~14 days before the date).

With APOLLO_API_BASE_URL set:
- per-photo context comes from /api/location/at (30 min max gap; no
  sample -> no line). A Place stay renders as "at Home (14:02-18:30)" in
  the display zone; an unnamed sample is reverse-geocoded.
- get_location_history returns Apollo's /api/location/day timeline for
  the date (+/-1 day at most), and is offered even with an empty local
  table.

Both fall back to the local table when Apollo is unset or a call fails;
the local tool path now covers the requested local day(s) and samples
evenly instead of listing a window's first rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 04:11:37 +00:00
cameron a605ccd6f8 Merge pull request 'fix(dates): resolve IFD0 ModifyDate as a last-resort capture date' (#181) from fix/modifydate-fallback into master
Reviewed-on: #181
2026-10-17 04:10:56 +00:00
Cameron CordesandClaude Opus 5.5 5c410b09d9 fix(dates): resolve IFD0 ModifyDate as a last-resort capture date
Scans, edited exports and some downloads carry only EXIF DateTime
(exiftool: IFD0:ModifyDate). The kamadak ingest path already takes it
as the capture date, but the exiftool resolver didn't ask for it, so
the date_utc drain resolved those rows to fs_time and then (rightly)
refused to overwrite their tag-sourced legacy date. 147 prod rows sat
on the legacy convention forever, re-running exiftool every tick.

ModifyDate is now the lowest-priority tag, and only from IFD0 — XMP /
QuickTime ModifyDate is an edit or mux time, not capture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:03:17 -04:00
+65
View File
@@ -322,6 +322,13 @@ pub fn resolve_dates_batch(
/// timestamps. `ContentCreateDate` is Apple's iOS-set tag; it often
/// reflects local capture time on iPhone exports better than the others.
///
/// `ModifyDate` comes last and only from `IFD0` (see
/// [`MODIFY_DATE_GROUP`]): that's EXIF `DateTime`, which the kamadak
/// ingest path already takes as the capture date when nothing better
/// exists. Scans, edits and some downloads carry only that tag, and
/// without it here the drain resolved them to fs_time and then refused
/// to overwrite their tag-sourced legacy date — stuck forever.
///
/// Notably absent: `FileModifyDate` / `FileAccessDate` — those are
/// filesystem-derived and the resolver covers them via the `fs_time`
/// fallback. Letting exiftool pull them here would mask "no real EXIF
@@ -333,8 +340,14 @@ const EXIFTOOL_DATE_TAGS: &[&str] = &[
"MediaCreateDate",
"TrackCreateDate",
"ContentCreateDate",
"ModifyDate",
];
/// The only group whose `ModifyDate` counts as a date: `IFD0`, i.e. EXIF
/// `DateTime`. XMP / QuickTime `ModifyDate` record when an editor or
/// muxer last touched the file, which isn't a capture time.
const MODIFY_DATE_GROUP: &str = "IFD0";
/// Offset + GPS evidence tags requested in the same exiftool pass. They
/// feed the camera-offset cascade for naive wall-clock readings that come
/// from photo-EXIF groups (ADR-0001).
@@ -735,6 +748,9 @@ fn resolve_exiftool_payload(
});
for cand in candidates {
if cand.tag == "ModifyDate" && cand.group != MODIFY_DATE_GROUP {
continue;
}
let Some(reading) = parse_exiftool_date_string(&cand.value) else {
continue;
};
@@ -1063,6 +1079,55 @@ mod tests {
assert_eq!(resolved.date_utc, Some(1_720_108_800));
}
#[test]
fn ifd0_modify_date_is_a_last_resort() {
// Scans / edits with only EXIF DateTime (exiftool: IFD0:ModifyDate)
// resolve from it as a camera wall clock, like the kamadak path.
let resolved = resolve_exiftool_payload(
&tags(&[
("IFD0", "ModifyDate", "2009:03:04 14:55:48"),
("ExifIFD", "DateTimeOriginal", ""),
]),
&ExiftoolEvidence {
offset_time: Some(-300),
..Default::default()
},
)
.unwrap();
assert_eq!(resolved.source, DateSource::Exiftool);
assert_eq!(resolved.date_utc, Some(1_236_196_548));
assert_eq!(resolved.camera_offset_minutes, Some(-300));
// Any capture tag outranks it.
let resolved = resolve_exiftool_payload(
&tags(&[
("IFD0", "ModifyDate", "2022:11:08 15:18:20"),
("ExifIFD", "CreateDate", "2022:11:01 09:00:00"),
]),
&ExiftoolEvidence {
offset_time: Some(-240),
..Default::default()
},
)
.unwrap();
assert_eq!(resolved.date_utc, Some(1_667_307_600));
}
#[test]
fn non_exif_modify_date_is_ignored() {
// XMP / QuickTime ModifyDate is an edit or mux time, not capture.
for group in ["XMP-xmp", "QuickTime"] {
assert!(
resolve_exiftool_payload(
&tags(&[(group, "ModifyDate", "2024:01:01 00:00:00")]),
&ExiftoolEvidence::default(),
)
.is_none(),
"{group}"
);
}
}
#[test]
fn zero_date_falls_through_to_next_tag() {
// exiftool emits "0000:00:00 00:00:00" for missing-but-allocated