feat(dates): container video dates, filesystem branch, manual edit (#115)
The exiftool branch of the date waterfall now pulls raw strings with group prefixes (one `exiftool -j -q -G1` subprocess per batch, no `-d %s`) and classifies each candidate: - zoned strings (own offset / Z) are true instants; - naive strings from container groups (QuickTime/Track*/Apple) are UTC-rendered creation_time fields -> true instants (utc_direct). Running them through the camera wall-clock cascade was the "videos off by 4-5 hours" bug: it reinterpreted a UTC instant in the server zone; - naive strings from photo-EXIF groups are camera wall clocks and run the offset cascade (OffsetTime* pairing + GPS evidence pulled in the same exiftool pass). The legacy date_taken column stays byte-identical to the old server-local interpretation (reproduced in Rust, DST-aware); the new-contract date_utc / camera_offset_minutes / date_taken_tz_source columns carry the true instant. Manual date edit (POST /image/exif/date) reinterprets the naive-as-UTC epoch the client sent as a wall clock in the user's display zone (camera_tz::manual_date_resolution, tz_source = 'manual'), snapshots the pre-override new-contract triple next to the existing legacy snapshot pair (3 new columns, migration 2026-09-25-000000), and revert restores both pairs. ADR-0001 records the container-date finding; CLAUDE.md date-pipeline section updated.
This commit is contained in:
1 parent
6f020f4201
commit
d2263be6ac
13 files changed
+866
-178
No files matched your search
+71
-10
@@ -40,9 +40,8 @@ pub enum TzSource {
|
||||
/// The resolver branch already produced a true UTC instant (exiftool,
|
||||
/// filesystem time, unix-timestamp filename). No offset is involved.
|
||||
UtcDirect,
|
||||
/// An operator set the date manually.
|
||||
// Written by the manual-edit rework (#118); nothing constructs it yet.
|
||||
#[allow(dead_code)]
|
||||
/// An operator set the date manually (POST /image/exif/date, ticket
|
||||
/// #115): the wall the user picked, interpreted in their display zone.
|
||||
Manual,
|
||||
}
|
||||
|
||||
@@ -218,13 +217,39 @@ pub const DEFAULT_DISPLAY_TZ: &str = "America/New_York";
|
||||
/// NULL / empty / unparseable prefs fall back to the app default, so
|
||||
/// read paths (tickets #119/#120) never have to special-case the
|
||||
/// unset state themselves.
|
||||
pub fn display_zone_for(stored: Option<&str>) -> &str {
|
||||
pub fn display_zone_for(stored: Option<&str>) -> String {
|
||||
match stored.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(name) if Tz::from_str(name).is_ok() => name,
|
||||
_ => DEFAULT_DISPLAY_TZ,
|
||||
Some(name) if Tz::from_str(name).is_ok() => name.to_string(),
|
||||
_ => DEFAULT_DISPLAY_TZ.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure seam for the manual date-edit endpoints (ADR-0001, ticket #115):
|
||||
/// reinterpret the naive-as-UTC epoch the UI sent as a wall clock in the
|
||||
/// user's display zone, and produce the new-contract
|
||||
/// `(date_utc, camera_offset_minutes)` pair. The caller writes
|
||||
/// `date_taken_tz_source = 'manual'` itself and stores the epoch verbatim
|
||||
/// in the legacy `date_taken` column (byte-identical to the pre-rework
|
||||
/// behavior).
|
||||
///
|
||||
/// `stored_zone` is `user_ai_prefs.display_tz` (NULL / unparseable → app
|
||||
/// default). The offset follows the wall's own date through the zone's DST
|
||||
/// rules — ambiguous fall-back walls take the earlier (pre-transition)
|
||||
/// offset; nonexistent spring-forward walls take the zone's offset at the
|
||||
/// wall-as-UTC reading, within an hour of truth (see
|
||||
/// [`offset_minutes_for_zone`]). `None` only when the zone name is
|
||||
/// unparseable, which [`display_zone_for`] already rules out — the caller
|
||||
/// then leaves the new-contract triple untouched.
|
||||
pub fn manual_date_resolution(
|
||||
stored_zone: Option<&str>,
|
||||
wall_naive_epoch: i64,
|
||||
) -> Option<(i64, i32)> {
|
||||
let zone = Tz::from_str(&display_zone_for(stored_zone)).ok()?;
|
||||
let wall = chrono::DateTime::from_timestamp(wall_naive_epoch, 0).map(|dt| dt.naive_utc())?;
|
||||
let minutes = offset_minutes_for_zone(zone, wall);
|
||||
Some((wall_to_instant(wall, minutes), minutes))
|
||||
}
|
||||
|
||||
/// Display-zone offset, in minutes, for a true UTC instant — DST-aware.
|
||||
/// `None` when the zone name doesn't parse.
|
||||
#[allow(dead_code)] // consumed by the read-side cutover (#119/#120)
|
||||
@@ -240,10 +265,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn display_zone_for_falls_back_to_default() {
|
||||
assert_eq!(display_zone_for(None), DEFAULT_DISPLAY_TZ);
|
||||
assert_eq!(display_zone_for(Some("")), DEFAULT_DISPLAY_TZ);
|
||||
assert_eq!(display_zone_for(Some(" ")), DEFAULT_DISPLAY_TZ);
|
||||
assert_eq!(display_zone_for(Some("Not/A_Zone")), DEFAULT_DISPLAY_TZ);
|
||||
assert_eq!(display_zone_for(None).as_str(), DEFAULT_DISPLAY_TZ);
|
||||
assert_eq!(display_zone_for(Some("")).as_str(), DEFAULT_DISPLAY_TZ);
|
||||
assert_eq!(display_zone_for(Some(" ")).as_str(), DEFAULT_DISPLAY_TZ);
|
||||
assert_eq!(
|
||||
display_zone_for(Some("Not/A_Zone")).as_str(),
|
||||
DEFAULT_DISPLAY_TZ
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -359,6 +387,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_date_resolution_display_zone_edt() {
|
||||
// The UI sends the wall rendered in the display zone, as a
|
||||
// naive-as-UTC epoch: 1720080000 = "2024-07-04 04:00:00". July in
|
||||
// New York is EDT (-240): the instant is 08:00:00Z.
|
||||
let (utc, off) = manual_date_resolution(Some("America/New_York"), 1_720_080_000).unwrap();
|
||||
assert_eq!(off, -240);
|
||||
assert_eq!(utc, 1_720_080_000 + 4 * 3600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_date_resolution_display_zone_est() {
|
||||
// Same function in January: EST (-300). Wall 2024-01-15 04:00:00
|
||||
// (naive epoch 1705291200) → 09:00:00Z.
|
||||
let (utc, off) = manual_date_resolution(Some("America/New_York"), 1_705_291_200).unwrap();
|
||||
assert_eq!(off, -300);
|
||||
assert_eq!(utc, 1_705_291_200 + 5 * 3600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_date_resolution_positive_zone_and_default() {
|
||||
// Tokyo is +09:00 year-round: wall 04:00 → previous day 19:00Z.
|
||||
let (utc, off) = manual_date_resolution(Some("Asia/Tokyo"), 1_720_080_000).unwrap();
|
||||
assert_eq!(off, 540);
|
||||
assert_eq!(utc, 1_720_080_000 - 540 * 60);
|
||||
// NULL stored zone → app default (America/New_York), July → EDT.
|
||||
let (_, off) = manual_date_resolution(None, 1_720_080_000).unwrap();
|
||||
assert_eq!(off, -240);
|
||||
// Unparseable stored zone also falls back to the default.
|
||||
let (_, off) = manual_date_resolution(Some("Not/A_Zone"), 1_720_080_000).unwrap();
|
||||
assert_eq!(off, -240);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_zone_offset_minutes_unknown_zone_is_none() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in new issue
Block a user