feat(dates): foundation for the true-instant date contract (#114)

Populate the new-contract date columns at ingest while keeping the
legacy date_taken column byte-identical (ADR-0001):

- camera_tz module: offset cascade (offset tag -> GPS zone -> server
  zone) as a pure, testable function; bundled offline TZF geometry for
  GPS -> IANA resolution; DST-correct offsets for the capture date;
  display-zone helpers (DEFAULT_DISPLAY_TZ, display_zone_for,
  display_zone_offset_minutes) for the read-side cutover.
- EXIF branch: kamadak-exif now collects OffsetTime* tags paired with
  the winning date tag; resolve_date_taken takes an ExifPrior and
  emits (date_utc, camera_offset_minutes, date_taken_tz_source) per
  branch: exif -> cascade, exiftool/fs_time -> utc_direct, filename
  -> unclassified (pending #116).
- Schema: migration adds image_exif.date_utc /
  camera_offset_minutes / date_taken_tz_source and
  user_ai_prefs.display_tz (all nullable; down.sql drops them).
- DAO + ingest: backfill_date_taken persists the triple alongside the
  legacy pair; watcher, upload, and set_image_gps call sites feed the
  cascade.
- Response surface: ExifMetadata and ExifSummary carry the triple so
  /image/metadata and /photos/exif expose it (null for unconverted
  rows); reel requests mirror display_tz into prefs.

cargo build + clippy clean; 625 lib + 702 bin tests pass (the one
failing doctest in utils.rs is pre-existing at the base commit).
This commit is contained in:
Cameron Cordes committed 2026-09-24 21:58:46 -04:00
1 parent ee773a34ef
commit 2f2107d4fa
20 files changed
+1039 -36

No files matched your search

Generated
+71
View File
@@ -779,6 +779,16 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf 0.12.1",
]
[[package]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.4" version = "0.4.4"
@@ -1552,6 +1562,15 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "geometry-rs"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "794fbaf8ce24464265e001a7a1dc819e401a303127c0cb714538e21ebe077c77"
dependencies = [
"rtree_rs",
]
[[package]] [[package]]
name = "getopts" name = "getopts"
version = "0.2.24" version = "0.2.24"
@@ -2070,6 +2089,7 @@ dependencies = [
"blake3", "blake3",
"bytes", "bytes",
"chrono", "chrono",
"chrono-tz",
"clap", "clap",
"diesel", "diesel",
"diesel_migrations", "diesel_migrations",
@@ -2103,6 +2123,7 @@ dependencies = [
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-util", "tokio-util",
"tzf-rs",
"urlencoding", "urlencoding",
"uuid", "uuid",
"walkdir", "walkdir",
@@ -2886,6 +2907,15 @@ dependencies = [
"phf_shared 0.11.3", "phf_shared 0.11.3",
] ]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared 0.12.1",
]
[[package]] [[package]]
name = "phf_codegen" name = "phf_codegen"
version = "0.10.0" version = "0.10.0"
@@ -2957,6 +2987,15 @@ dependencies = [
"siphasher 1.0.3", "siphasher 1.0.3",
] ]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher 1.0.3",
]
[[package]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.12" version = "1.1.12"
@@ -3041,6 +3080,12 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "pqueue"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2145d14f09d5fc7fe7134b556146599c2929af5b1f1e3a0ecc9d582a9b85e4d"
[[package]] [[package]]
name = "precomputed-hash" name = "precomputed-hash"
version = "0.1.1" version = "0.1.1"
@@ -3460,6 +3505,15 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
[[package]]
name = "rtree_rs"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7787960e978c1c675fd6b8eb7d56b9c7162aec55c41d368add6f3ff39251b96e"
dependencies = [
"pqueue",
]
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -4314,6 +4368,23 @@ version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "tzf-dist"
version = "0.0.2026-d-fix1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2523c6471fd5827173b5b9d51d4444e1033a1f19339da61603cee9300e3f44be"
[[package]]
name = "tzf-rs"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bd4bd76dc7816db8a12043dbac00254158c17cd2c731aa951fd85f1a9d1f1ae"
dependencies = [
"clap",
"geometry-rs",
"tzf-dist",
]
[[package]] [[package]]
name = "unicase" name = "unicase"
version = "2.9.0" version = "2.9.0"
+3 -1
View File
@@ -25,7 +25,7 @@ futures = "0.3.5"
jsonwebtoken = "9.3.0" jsonwebtoken = "9.3.0"
serde = "1" serde = "1"
serde_json = "1" serde_json = "1"
diesel = { version = "2.2.10", features = ["sqlite"] } diesel = { version = "2.2.10", features = ["sqlite", "64-column-tables"] }
libsqlite3-sys = "0.35" libsqlite3-sys = "0.35"
diesel_migrations = "2.2.0" diesel_migrations = "2.2.0"
chrono = "0.4" chrono = "0.4"
@@ -67,6 +67,8 @@ bk-tree = "0.5"
async-trait = "0.1" async-trait = "0.1"
indicatif = "0.17" indicatif = "0.17"
uuid = { version = "1.10", features = ["v4", "serde"] } uuid = { version = "1.10", features = ["v4", "serde"] }
chrono-tz = "0.10"
tzf-rs = "2.1.2"
# Windows lacks system sqlite3, so re-enable the bundled C build there. # Windows lacks system sqlite3, so re-enable the bundled C build there.
# Linux/macOS use the system library (faster builds, smaller binary). # Linux/macOS use the system library (faster builds, smaller binary).
@@ -0,0 +1,4 @@
ALTER TABLE user_ai_prefs DROP COLUMN display_tz;
ALTER TABLE image_exif DROP COLUMN date_taken_tz_source;
ALTER TABLE image_exif DROP COLUMN camera_offset_minutes;
ALTER TABLE image_exif DROP COLUMN date_utc;
@@ -0,0 +1,19 @@
-- True-instant date contract (ADR-0001 / spec #113, ticket #114).
--
-- image_exif gains the new-contract columns alongside the untouched legacy
-- `date_taken`:
-- date_utc — true UTC instant (unix seconds) under the new
-- contract; NULL while the row is still on the
-- legacy convention (drain pending).
-- camera_offset_minutes — camera local − UTC in minutes that produced
-- date_utc (NULL for direct-UTC branches).
-- date_taken_tz_source — why the offset is what it is:
-- offset_tag | gps | assumed_server | utc_direct
-- | manual.
-- user_ai_prefs gains the DST-proof display zone (IANA name); the legacy
-- tz_offset_minutes column stays frozen for rollback.
ALTER TABLE image_exif ADD COLUMN date_utc BIGINT;
ALTER TABLE image_exif ADD COLUMN camera_offset_minutes INTEGER;
ALTER TABLE image_exif ADD COLUMN date_taken_tz_source TEXT;
ALTER TABLE user_ai_prefs ADD COLUMN display_tz TEXT;
+3
View File
@@ -3378,6 +3378,9 @@ mod tests {
original_date_taken_source: None, original_date_taken_source: None,
clip_embedding: None, clip_embedding: None,
clip_model_version: None, clip_model_version: None,
date_utc: Some(1_629_028_800),
camera_offset_minutes: None,
date_taken_tz_source: Some("utc_direct".to_string()),
}); });
let out = resolve_date_taken_for_context(&exif, "Screenshot_2014-06-01.png"); let out = resolve_date_taken_for_context(&exif, "Screenshot_2014-06-01.png");
assert_eq!(out.as_deref(), Some("2021-08-15")); assert_eq!(out.as_deref(), Some("2021-08-15"));
+6
View File
@@ -192,6 +192,9 @@ pub fn backfill_missing_date_taken(
rel_path, rel_path,
rd.timestamp, rd.timestamp,
rd.source.as_str(), rd.source.as_str(),
rd.date_utc,
rd.camera_offset_minutes,
rd.tz_source.map(|t| t.as_str()),
) { ) {
Ok(()) => { Ok(()) => {
backfilled += 1; backfilled += 1;
@@ -616,6 +619,9 @@ mod tests {
phash_64: None, phash_64: None,
dhash_64: None, dhash_64: None,
date_taken_source: None, date_taken_source: None,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
}, },
) )
.expect("insert"); .expect("insert");
+421
View File
@@ -0,0 +1,421 @@
//! Camera-offset cascade and display-zone helpers for the true-instant date
//! contract (see `docs/adr/0001-true-utc-dates.md`).
//!
//! The legacy `image_exif.date_taken` column mixes conventions: EXIF-sourced
//! rows hold camera wall-clock *as UTC*, exiftool / filesystem rows hold true
//! UTC instants. The new columns — `date_utc`, `camera_offset_minutes`,
//! `date_taken_tz_source` — are populated at ingest by the offset cascade:
//!
//! 1. **offset tag** — the EXIF `OffsetTime*` tag, when the camera stamped
//! it (explicit, most accurate).
//! 2. **gps** — the capture location resolved to an IANA zone (bundled
//! offline TZF geometry) and that zone's offset for the capture *date*
//! (DST-correct via chrono-tz).
//! 3. **assumed_server** — the server's own IANA zone, offset at the
//! capture date. A camera without offset or GPS was probably carried
//! around by its owner, i.e. in the operator's home zone.
//!
//! The cascade always returns `(offset_minutes, source)` so the caller can
//! compute `date_utc = wall − offset` and audit why the row has the offset
//! it does. Branches that already produce true UTC instants (exiftool,
//! filesystem time) skip the cascade entirely and are recorded as
//! [`TzSource::UtcDirect`].
use std::str::FromStr;
use std::sync::OnceLock;
use chrono::{Local, LocalResult, NaiveDateTime, Offset, TimeZone};
use chrono_tz::Tz;
/// Why a `date_utc` row has the offset it does — or that it is a direct
/// true instant. Written verbatim to `image_exif.date_taken_tz_source`.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum TzSource {
/// An EXIF `OffsetTime*` tag supplied the camera offset directly.
OffsetTag,
/// GPS → IANA-zone lookup resolved the offset for the capture date.
Gps,
/// No offset tag and no usable GPS: assumed the server's zone.
AssumedServer,
/// 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)]
Manual,
}
impl TzSource {
pub fn as_str(self) -> &'static str {
match self {
TzSource::OffsetTag => "offset_tag",
TzSource::Gps => "gps",
TzSource::AssumedServer => "assumed_server",
TzSource::UtcDirect => "utc_direct",
TzSource::Manual => "manual",
}
}
}
/// Evidence available to the offset cascade for one file.
#[derive(Copy, Clone, Debug, Default)]
pub struct OffsetEvidence {
/// Signed minutes from the EXIF `OffsetTime*` tag (e.g. EDT = `-240`),
/// when the camera stamped one.
pub offset_tag_minutes: Option<i32>,
/// `(latitude, longitude)` from the EXIF GPS tags, when present.
pub gps: Option<(f64, f64)>,
}
/// A resolved camera offset for one capture.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct OffsetResolution {
/// Camera local time minus UTC, in minutes (negative = west of UTC).
pub minutes: i32,
/// Which rung of the cascade produced it.
pub source: TzSource,
}
/// GPS → IANA-zone seam. The production adapter wraps the bundled offline
/// TZF geometry; tests inject a stub.
pub trait GpsZoneLookup {
fn zone_at(&self, lat: f64, lon: f64) -> Option<Tz>;
}
/// Production [`GpsZoneLookup`]: bundled lite TZF geometry. No network,
/// ~4 MB, loaded once per process on first use.
pub struct TzfGpsZoneLookup {
finder: OnceLock<tzf_rs::DefaultFinder>,
}
impl TzfGpsZoneLookup {
pub fn new() -> Self {
Self {
finder: OnceLock::new(),
}
}
fn finder(&self) -> &tzf_rs::DefaultFinder {
self.finder.get_or_init(tzf_rs::DefaultFinder::new)
}
}
impl Default for TzfGpsZoneLookup {
fn default() -> Self {
Self::new()
}
}
impl GpsZoneLookup for TzfGpsZoneLookup {
fn zone_at(&self, lat: f64, lon: f64) -> Option<Tz> {
// tzf-rs takes (lng, lat) — the reverse of our (lat, lon) convention.
let name = self.finder().get_tz_name(lon, lat);
if name.is_empty() {
return None;
}
Tz::from_str(name).ok()
}
}
/// The zone offset, in minutes, for a naive wall-clock reading.
///
/// DST-correct: resolves the zone's offset for the wall-clock *date itself*
/// (the same zone rule the camera's own clock followed). Ambiguous
/// fall-back wall times resolve to the earlier (pre-transition) offset;
/// nonexistent spring-forward wall times to the later one — each within one
/// hour of the true capture instant, never off by a full day.
pub fn offset_minutes_for_zone(zone: Tz, wall: NaiveDateTime) -> i32 {
let dt = match zone.from_local_datetime(&wall) {
LocalResult::Single(dt) => dt,
LocalResult::Ambiguous(early, _) => early,
// Nonexistent spring-forward wall time: use the zone's offset at
// that wall reading interpreted as UTC — within an hour of truth.
LocalResult::None => zone.from_utc_datetime(&wall),
};
dt.offset().fix().local_minus_utc() / 60
}
/// True UTC instant (unix seconds) for a camera wall-clock reading and its
/// resolved offset: `instant = wall − offset`.
///
/// e.g. wall `2024-07-04 12:00:00` at EDT (`-240`) → `16:00:00Z`.
pub fn wall_to_instant(wall: NaiveDateTime, offset_minutes: i32) -> i64 {
wall.and_utc().timestamp() - (offset_minutes as i64) * 60
}
/// The offset cascade: offset tag → GPS zone → server zone.
///
/// `server_zone` is the operator's home zone (see [`server_zone`]); when it
/// is `None` (unresolvable on this host) the cascade falls back to the
/// process-local offset, which is only exact for fixed-offset zones.
pub fn resolve_camera_offset(
wall: NaiveDateTime,
evidence: &OffsetEvidence,
server_zone: Option<Tz>,
zone_lookup: &dyn GpsZoneLookup,
) -> OffsetResolution {
if let Some(minutes) = evidence.offset_tag_minutes {
return OffsetResolution {
minutes,
source: TzSource::OffsetTag,
};
}
if let Some((lat, lon)) = evidence.gps
&& let Some(zone) = zone_lookup.zone_at(lat, lon)
{
return OffsetResolution {
minutes: offset_minutes_for_zone(zone, wall),
source: TzSource::Gps,
};
}
let minutes = match server_zone {
Some(zone) => offset_minutes_for_zone(zone, wall),
None => Local::now().offset().fix().local_minus_utc() / 60,
};
OffsetResolution {
minutes,
source: TzSource::AssumedServer,
}
}
/// Resolve the server's IANA zone from the host environment, in order:
/// `TZ` env var (zone-name form), `/etc/timezone`, then the
/// `/etc/localtime` symlink target. Returns `None` when nothing parses —
/// callers then fall back to the process-local offset.
pub fn server_zone() -> Option<Tz> {
if let Ok(tz) = std::env::var("TZ")
&& let Some(zone) = Tz::from_str(tz.trim()).ok()
{
return Some(zone);
}
if let Ok(contents) = std::fs::read_to_string("/etc/timezone")
&& let Some(zone) = Tz::from_str(contents.trim()).ok()
{
return Some(zone);
}
if let Ok(target) = std::fs::read_link("/etc/localtime") {
let target = target.to_string_lossy().into_owned();
let zone_name = target
.trim_start_matches("/usr/share/zoneinfo/")
.trim_start_matches("/etc/zoneinfo/")
.trim_start_matches("/var/db/timezone/zoneinfo/");
if !zone_name.is_empty()
&& zone_name != "localtime"
&& let Ok(zone) = Tz::from_str(zone_name)
{
return Some(zone);
}
}
None
}
/// App default display zone when the user hasn't stored one
/// (`user_ai_prefs.display_tz` NULL).
pub const DEFAULT_DISPLAY_TZ: &str = "America/New_York";
/// Resolve the user's stored display zone to a usable IANA name.
/// 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 {
match stored.map(str::trim).filter(|s| !s.is_empty()) {
Some(name) if Tz::from_str(name).is_ok() => name,
_ => DEFAULT_DISPLAY_TZ,
}
}
/// 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)
pub fn display_zone_offset_minutes(zone_name: &str, instant: i64) -> Option<i32> {
let zone = Tz::from_str(zone_name).ok()?;
let dt = zone.timestamp_opt(instant, 0).single()?;
Some(dt.offset().fix().local_minus_utc() / 60)
}
#[cfg(test)]
mod tests {
use super::*;
#[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);
}
#[test]
fn display_zone_for_passes_through_valid_names() {
assert_eq!(display_zone_for(Some("Europe/Berlin")), "Europe/Berlin");
assert_eq!(display_zone_for(Some(" Asia/Tokyo ")), "Asia/Tokyo");
}
/// Stub lookup for cascade tests: one fixed zone or none.
struct StubLookup(Option<Tz>);
impl GpsZoneLookup for StubLookup {
fn zone_at(&self, _lat: f64, _lon: f64) -> Option<Tz> {
self.0
}
}
fn ny() -> Tz {
chrono_tz::America::New_York
}
fn wall_july() -> NaiveDateTime {
NaiveDateTime::parse_from_str("2024-07-04 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
}
fn wall_january() -> NaiveDateTime {
NaiveDateTime::parse_from_str("2024-01-15 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
}
#[test]
fn offset_tag_wins_over_everything() {
let evidence = OffsetEvidence {
offset_tag_minutes: Some(330), // +05:30
gps: Some((40.71, -74.00)),
};
let res =
resolve_camera_offset(wall_july(), &evidence, Some(ny()), &StubLookup(Some(ny())));
assert_eq!(res.minutes, 330);
assert_eq!(res.source, TzSource::OffsetTag);
}
#[test]
fn gps_zone_wins_when_no_tag() {
let evidence = OffsetEvidence {
offset_tag_minutes: None,
gps: Some((40.71, -74.00)),
};
let res =
resolve_camera_offset(wall_july(), &evidence, Some(ny()), &StubLookup(Some(ny())));
assert_eq!(res.minutes, -240, "July wall time in NY is EDT (UTC-4)");
assert_eq!(res.source, TzSource::Gps);
}
#[test]
fn server_zone_is_last_resort() {
let evidence = OffsetEvidence::default();
let res = resolve_camera_offset(
wall_january(),
&evidence,
Some(ny()),
&StubLookup(Some(ny())), // stub is ignored: no GPS evidence
);
assert_eq!(res.minutes, -300, "January wall time in NY is EST (UTC-5)");
assert_eq!(res.source, TzSource::AssumedServer);
}
#[test]
fn gps_miss_falls_through_to_server_zone() {
let evidence = OffsetEvidence {
offset_tag_minutes: None,
gps: Some((30.0, -40.0)), // open ocean: lookup returns None
};
let res = resolve_camera_offset(wall_january(), &evidence, Some(ny()), &StubLookup(None));
assert_eq!(res.source, TzSource::AssumedServer);
}
#[test]
fn zone_offset_is_dst_correct_for_capture_date() {
assert_eq!(offset_minutes_for_zone(ny(), wall_july()), -240);
assert_eq!(offset_minutes_for_zone(ny(), wall_january()), -300);
// Berlin: CEST +120 in summer, CET +60 in winter.
let berlin = chrono_tz::Europe::Berlin;
assert_eq!(offset_minutes_for_zone(berlin, wall_july()), 120);
assert_eq!(offset_minutes_for_zone(berlin, wall_january()), 60);
}
#[test]
fn wall_to_instant_edt_example() {
// Wall 2024-07-04 12:00:00 at EDT (-240) is 16:00:00 UTC
// = 1720108800. This is the exact pair the mixed-convention audit
// used to prove the legacy EXIF branch was off by 4 hours.
assert_eq!(wall_to_instant(wall_july(), -240), 1_720_108_800);
}
#[test]
fn wall_to_instant_positive_offset() {
// Wall 2024-07-04 12:00:00 at +05:30 (330) is 06:30:00 UTC.
let expected = wall_july().and_utc().timestamp() - 330 * 60;
assert_eq!(wall_to_instant(wall_july(), 330), expected);
}
#[test]
fn display_zone_offset_minutes_is_dst_aware() {
// 2024-07-04 16:00:00Z — New York is on EDT.
assert_eq!(
display_zone_offset_minutes("America/New_York", 1_720_108_800),
Some(-240)
);
// 2024-01-15 17:00:00Z — New York is on EST.
assert_eq!(
display_zone_offset_minutes("America/New_York", 1_705_335_600),
Some(-300)
);
}
#[test]
fn display_zone_offset_minutes_unknown_zone_is_none() {
assert_eq!(
display_zone_offset_minutes("Not/A_Zone", 1_700_000_000),
None
);
}
#[test]
fn tz_source_as_str_round_trip() {
let pairs = [
(TzSource::OffsetTag, "offset_tag"),
(TzSource::Gps, "gps"),
(TzSource::AssumedServer, "assumed_server"),
(TzSource::UtcDirect, "utc_direct"),
(TzSource::Manual, "manual"),
];
for (src, expected) in pairs {
assert_eq!(src.as_str(), expected);
}
}
#[test]
fn tzf_lookup_resolves_new_york() {
let lookup = TzfGpsZoneLookup::new();
assert_eq!(
lookup.zone_at(40.7128, -74.0060),
Some(chrono_tz::America::New_York)
);
}
#[test]
fn tzf_lookup_resolves_berlin() {
let lookup = TzfGpsZoneLookup::new();
assert_eq!(
lookup.zone_at(52.5200, 13.4050),
Some(chrono_tz::Europe::Berlin)
);
}
#[test]
fn tzf_lookup_open_ocean_resolves_etc_zone() {
// The bundled lite data covers open ocean with fixed-offset
// Etc/GMT* zones — a mid-Atlantic photo gets a sane local offset
// rather than falling through to the server zone.
let lookup = TzfGpsZoneLookup::new();
let zone = lookup
.zone_at(30.0, -40.0)
.expect("ocean resolves to Etc/GMT*");
assert!(zone.name().starts_with("Etc/GMT"), "got {}", zone.name());
}
#[test]
fn server_zone_resolves_on_this_host_or_none() {
// The host's zone is whatever it is — the contract is that a
// parseable result is a real Tz and a missing config is None, not a
// panic. Both outcomes are valid; assert only the no-panic shape.
let _ = server_zone();
}
}
+99
View File
@@ -292,6 +292,15 @@ pub struct ExifMetadata {
/// `"exif" | "exiftool" | "filename" | "fs_time" | "manual"`. NULL when /// `"exif" | "exiftool" | "filename" | "fs_time" | "manual"`. NULL when
/// `date_taken` itself is NULL. /// `date_taken` itself is NULL.
pub date_taken_source: Option<String>, pub date_taken_source: Option<String>,
/// True UTC instant under the new date contract (ADR-0001). NULL while
/// the row is still on the legacy convention (drain pending, or the
/// unclassified filename branch).
pub date_utc: Option<i64>,
/// Camera offset (local − UTC, minutes) that produced `date_utc`.
pub camera_offset_minutes: Option<i32>,
/// Why the offset is what it is: `offset_tag` | `gps` |
/// `assumed_server` | `utc_direct` | `manual`.
pub date_taken_tz_source: Option<String>,
/// When `date_taken_source = "manual"`, the prior `date_taken` snapshot. /// When `date_taken_source = "manual"`, the prior `date_taken` snapshot.
/// Used by the UI's revert affordance and to label "manually overridden; /// Used by the UI's revert affordance and to label "manually overridden;
/// originally X" in the details modal. /// originally X" in the details modal.
@@ -383,6 +392,9 @@ impl From<ImageExif> for ExifMetadata {
}, },
date_taken: exif.date_taken, date_taken: exif.date_taken,
date_taken_source: exif.date_taken_source, date_taken_source: exif.date_taken_source,
date_utc: exif.date_utc,
camera_offset_minutes: exif.camera_offset_minutes,
date_taken_tz_source: exif.date_taken_tz_source,
original_date_taken: exif.original_date_taken, original_date_taken: exif.original_date_taken,
original_date_taken_source: exif.original_date_taken_source, original_date_taken_source: exif.original_date_taken_source,
} }
@@ -428,6 +440,13 @@ pub struct ExifSummary {
pub gps_latitude: Option<f64>, pub gps_latitude: Option<f64>,
pub gps_longitude: Option<f64>, pub gps_longitude: Option<f64>,
pub date_taken: Option<i64>, pub date_taken: Option<i64>,
/// True UTC instant under the new date contract (ADR-0001). NULL while
/// the row is still on the legacy convention.
pub date_utc: Option<i64>,
/// Camera offset (local − UTC, minutes) that produced `date_utc`.
pub camera_offset_minutes: Option<i32>,
/// How the offset was determined. See `camera_tz::TzSource`.
pub date_taken_tz_source: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -564,3 +583,83 @@ mod tests {
assert!(previews[1].get("preview_url").is_none()); assert!(previews[1].get("preview_url").is_none());
} }
} }
#[cfg(test)]
mod date_contract_tests {
use super::*;
fn sample_row() -> crate::database::models::ImageExif {
crate::database::models::ImageExif {
id: 1,
library_id: 1,
file_path: "2024/vacation/IMG_0001.jpg".to_string(),
camera_make: None,
camera_model: Some("NIKON Z 8".to_string()),
lens_model: None,
width: None,
height: None,
orientation: None,
gps_latitude: None,
gps_longitude: None,
gps_altitude: None,
focal_length: None,
aperture: None,
shutter_speed: None,
iso: None,
// Wall 2024-07-04 12:00:00 stored legacy-style (as-UTC).
date_taken: Some(1_720_094_400),
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
duplicate_of_hash: None,
duplicate_decided_at: None,
date_taken_source: Some("exif".to_string()),
original_date_taken: None,
original_date_taken_source: None,
clip_embedding: None,
clip_model_version: None,
date_utc: Some(1_720_108_800),
camera_offset_minutes: Some(-240),
date_taken_tz_source: Some("offset_tag".to_string()),
}
}
/// /image/metadata contract: the new-contract date fields ride along on
/// the EXIF metadata payload alongside the legacy `date_taken`.
#[test]
fn exif_metadata_response_includes_date_contract_fields() {
let meta = ExifMetadata::from(sample_row());
assert_eq!(meta.date_taken, Some(1_720_094_400));
assert_eq!(meta.date_utc, Some(1_720_108_800));
assert_eq!(meta.camera_offset_minutes, Some(-240));
assert_eq!(meta.date_taken_tz_source.as_deref(), Some("offset_tag"));
}
/// /photos/exif contract: Apollo's per-photo summary carries the same
/// fields so the viewer flip (spec #113) can read one shape.
#[test]
fn exif_summary_shape_includes_date_contract_fields() {
let summary = ExifSummary {
file_path: "2024/vacation/IMG_0001.jpg".to_string(),
library_id: 1,
library_name: Some("main".to_string()),
camera_model: Some("NIKON Z 8".to_string()),
width: None,
height: None,
gps_latitude: None,
gps_longitude: None,
date_taken: Some(1_720_094_400),
date_utc: Some(1_720_108_800),
camera_offset_minutes: Some(-240),
date_taken_tz_source: Some("offset_tag".to_string()),
};
// Verify the JSON wire names clients (Apollo, FVR) will consume.
let json = serde_json::to_value(&summary).unwrap();
assert_eq!(json["date_utc"], 1_720_108_800);
assert_eq!(json["camera_offset_minutes"], -240);
assert_eq!(json["date_taken_tz_source"], "offset_tag");
}
}
+40 -7
View File
@@ -495,10 +495,12 @@ pub trait ExifDao: Sync + Send {
limit: i64, limit: i64,
) -> Result<Vec<(i32, String)>, DbError>; ) -> Result<Vec<(i32, String)>, DbError>;
/// Persist a resolver result for an existing row. Touches `date_taken` /// Persist a resolver result for an existing row. Touches `date_taken`,
/// and `date_taken_source` only — leaves all other columns alone so /// `date_taken_source`, and the new-contract columns (`date_utc`,
/// the drain doesn't accidentally clobber EXIF/hash/perceptual data /// `camera_offset_minutes`, `date_taken_tz_source`) only — leaves all
/// the watcher / GPS-write path may have already written. /// other columns alone so the drain doesn't accidentally clobber
/// EXIF/hash/perceptual data the watcher / GPS-write path may have
/// already written.
fn backfill_date_taken( fn backfill_date_taken(
&mut self, &mut self,
context: &opentelemetry::Context, context: &opentelemetry::Context,
@@ -506,6 +508,9 @@ pub trait ExifDao: Sync + Send {
rel_path: &str, rel_path: &str,
date_taken: i64, date_taken: i64,
source: &str, source: &str,
date_utc: Option<i64>,
camera_offset_minutes: Option<i32>,
date_taken_tz_source: Option<&str>,
) -> Result<(), DbError>; ) -> Result<(), DbError>;
/// Find image_exif rows needing a CLIP embedding for semantic search: /// Find image_exif rows needing a CLIP embedding for semantic search:
@@ -1426,6 +1431,9 @@ impl ExifDao for SqliteExifDao {
rel_path_val: &str, rel_path_val: &str,
date_taken_val: i64, date_taken_val: i64,
source: &str, source: &str,
date_utc_val: Option<i64>,
camera_offset_minutes_val: Option<i32>,
date_taken_tz_source_val: Option<&str>,
) -> Result<(), DbError> { ) -> Result<(), DbError> {
trace_db_call(context, "update", "backfill_date_taken", |_span| { trace_db_call(context, "update", "backfill_date_taken", |_span| {
use schema::image_exif::dsl::*; use schema::image_exif::dsl::*;
@@ -1437,7 +1445,13 @@ impl ExifDao for SqliteExifDao {
.filter(library_id.eq(library_id_val)) .filter(library_id.eq(library_id_val))
.filter(rel_path.eq(rel_path_val)), .filter(rel_path.eq(rel_path_val)),
) )
.set((date_taken.eq(date_taken_val), date_taken_source.eq(source))) .set((
date_taken.eq(date_taken_val),
date_taken_source.eq(source),
date_utc.eq(date_utc_val),
camera_offset_minutes.eq(camera_offset_minutes_val),
date_taken_tz_source.eq(date_taken_tz_source_val),
))
.execute(connection.deref_mut()); .execute(connection.deref_mut());
match result { match result {
@@ -2547,6 +2561,9 @@ mod exif_dao_tests {
phash_64: None, phash_64: None,
dhash_64: None, dhash_64: None,
date_taken_source: None, date_taken_source: None,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
}, },
) )
.expect("insert exif row"); .expect("insert exif row");
@@ -2695,6 +2712,9 @@ mod exif_dao_tests {
phash_64: None, phash_64: None,
dhash_64: None, dhash_64: None,
date_taken_source: source.map(|s| s.to_string()), date_taken_source: source.map(|s| s.to_string()),
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
}, },
) )
.expect("insert exif row"); .expect("insert exif row");
@@ -2729,12 +2749,25 @@ mod exif_dao_tests {
dao.backfill_content_hash(&ctx(), 1, "main/x.jpg", "deadbeef", 1024) dao.backfill_content_hash(&ctx(), 1, "main/x.jpg", "deadbeef", 1024)
.unwrap(); .unwrap();
dao.backfill_date_taken(&ctx(), 1, "main/x.jpg", 1700000000, "exiftool") dao.backfill_date_taken(
.unwrap(); &ctx(),
1,
"main/x.jpg",
1700000000,
"exiftool",
Some(1700000000),
None,
Some("utc_direct"),
)
.unwrap();
let row = dao.get_exif(&ctx(), "main/x.jpg").unwrap().unwrap(); let row = dao.get_exif(&ctx(), "main/x.jpg").unwrap().unwrap();
assert_eq!(row.date_taken, Some(1700000000)); assert_eq!(row.date_taken, Some(1700000000));
assert_eq!(row.date_taken_source, Some("exiftool".to_string())); assert_eq!(row.date_taken_source, Some("exiftool".to_string()));
// New-contract columns written alongside the legacy pair.
assert_eq!(row.date_utc, Some(1700000000));
assert_eq!(row.camera_offset_minutes, None);
assert_eq!(row.date_taken_tz_source, Some("utc_direct".to_string()));
// Untouched columns survive. // Untouched columns survive.
assert_eq!(row.content_hash, Some("deadbeef".to_string())); assert_eq!(row.content_hash, Some("deadbeef".to_string()));
assert_eq!(row.size_bytes, Some(1024)); assert_eq!(row.size_bytes, Some(1024));
+23
View File
@@ -136,6 +136,17 @@ pub struct InsertImageExif {
/// drain re-resolves rows whose source is `"fs_time"` once exiftool /// drain re-resolves rows whose source is `"fs_time"` once exiftool
/// has had a chance to run. /// has had a chance to run.
pub date_taken_source: Option<String>, pub date_taken_source: Option<String>,
/// True UTC instant (unix seconds) under the new date contract
/// (ADR-0001). NULL while the row is still on the legacy convention —
/// either unclassified (filename branch, ticket #116) or pending the
/// backfill drain (#118).
pub date_utc: Option<i64>,
/// Camera offset (local − UTC, minutes) that produced `date_utc`.
/// NULL for direct-true-UTC branches and unclassified rows.
pub camera_offset_minutes: Option<i32>,
/// Why the offset is what it is: `offset_tag` | `gps` |
/// `assumed_server` | `utc_direct` | `manual`. See `camera_tz::TzSource`.
pub date_taken_tz_source: Option<String>,
} }
// Field order matches the post-migration column order in `image_exif`. // Field order matches the post-migration column order in `image_exif`.
@@ -190,6 +201,13 @@ pub struct ImageExif {
/// swap of `APOLLO_CLIP_MODEL` re-eligibilizes rows whose stored /// swap of `APOLLO_CLIP_MODEL` re-eligibilizes rows whose stored
/// version differs so the drain rebuilds them. /// version differs so the drain rebuilds them.
pub clip_model_version: Option<String>, pub clip_model_version: Option<String>,
/// True UTC instant (unix seconds) under the new date contract
/// (ADR-0001). NULL while the row is still on the legacy convention.
pub date_utc: Option<i64>,
/// Camera offset (local − UTC, minutes) that produced `date_utc`.
pub camera_offset_minutes: Option<i32>,
/// How the offset was determined. See `camera_tz::TzSource`.
pub date_taken_tz_source: Option<String>,
} }
#[derive(Insertable, Clone)] #[derive(Insertable, Clone)]
@@ -549,6 +567,10 @@ pub struct UserAiPrefs {
pub tz_offset_minutes: Option<i32>, pub tz_offset_minutes: Option<i32>,
pub library: Option<String>, pub library: Option<String>,
pub updated_at: i64, pub updated_at: i64,
/// User's display time zone as an IANA name (DST-proof). NULL = app
/// default (`camera_tz::DEFAULT_DISPLAY_TZ`). The legacy
/// `tz_offset_minutes` column stays frozen for rollback.
pub display_tz: Option<String>,
} }
#[derive(Insertable, Debug, Clone, serde::Deserialize, serde::Serialize)] #[derive(Insertable, Debug, Clone, serde::Deserialize, serde::Serialize)]
@@ -558,4 +580,5 @@ pub struct UpsertUserAiPrefs {
pub tz_offset_minutes: Option<i32>, pub tz_offset_minutes: Option<i32>,
pub library: Option<String>, pub library: Option<String>,
pub updated_at: i64, pub updated_at: i64,
pub display_tz: Option<String>,
} }
+4
View File
@@ -140,6 +140,9 @@ diesel::table! {
original_date_taken_source -> Nullable<Text>, original_date_taken_source -> Nullable<Text>,
clip_embedding -> Nullable<Binary>, clip_embedding -> Nullable<Binary>,
clip_model_version -> Nullable<Text>, clip_model_version -> Nullable<Text>,
date_utc -> Nullable<BigInt>,
camera_offset_minutes -> Nullable<Integer>,
date_taken_tz_source -> Nullable<Text>,
} }
} }
@@ -286,6 +289,7 @@ diesel::table! {
tz_offset_minutes -> Nullable<Integer>, tz_offset_minutes -> Nullable<Integer>,
library -> Nullable<Text>, library -> Nullable<Text>,
updated_at -> BigInt, updated_at -> BigInt,
display_tz -> Nullable<Text>,
} }
} }
+8
View File
@@ -99,6 +99,7 @@ impl UserAiPrefsDao for SqliteUserAiPrefsDao {
dsl::voice.eq(&prefs.voice), dsl::voice.eq(&prefs.voice),
dsl::tz_offset_minutes.eq(&prefs.tz_offset_minutes), dsl::tz_offset_minutes.eq(&prefs.tz_offset_minutes),
dsl::library.eq(&prefs.library), dsl::library.eq(&prefs.library),
dsl::display_tz.eq(&prefs.display_tz),
dsl::updated_at.eq(&prefs.updated_at), dsl::updated_at.eq(&prefs.updated_at),
)) ))
.execute(connection.deref_mut()) .execute(connection.deref_mut())
@@ -145,6 +146,7 @@ mod tests {
tz_offset_minutes: Some(-480), tz_offset_minutes: Some(-480),
library: Some("1".to_string()), library: Some("1".to_string()),
updated_at: now, updated_at: now,
display_tz: Some("America/New_York".to_string()),
}; };
dao.upsert_prefs(&ctx(), &prefs).unwrap(); dao.upsert_prefs(&ctx(), &prefs).unwrap();
@@ -153,6 +155,7 @@ mod tests {
assert_eq!(row.voice, Some("grandma".to_string())); assert_eq!(row.voice, Some("grandma".to_string()));
assert_eq!(row.tz_offset_minutes, Some(-480)); assert_eq!(row.tz_offset_minutes, Some(-480));
assert_eq!(row.library, Some("1".to_string())); assert_eq!(row.library, Some("1".to_string()));
assert_eq!(row.display_tz, Some("America/New_York".to_string()));
assert_eq!(row.updated_at, now); assert_eq!(row.updated_at, now);
} }
@@ -167,6 +170,7 @@ mod tests {
tz_offset_minutes: Some(-480), tz_offset_minutes: Some(-480),
library: Some("1".to_string()), library: Some("1".to_string()),
updated_at: now1, updated_at: now1,
display_tz: Some("America/New_York".to_string()),
}; };
dao.upsert_prefs(&ctx(), &prefs1).unwrap(); dao.upsert_prefs(&ctx(), &prefs1).unwrap();
@@ -175,6 +179,7 @@ mod tests {
tz_offset_minutes: Some(-300), tz_offset_minutes: Some(-300),
library: None, library: None,
updated_at: now2, updated_at: now2,
display_tz: None,
}; };
dao.upsert_prefs(&ctx(), &prefs2).unwrap(); dao.upsert_prefs(&ctx(), &prefs2).unwrap();
@@ -182,6 +187,7 @@ mod tests {
assert_eq!(row.voice, Some("dad".to_string())); assert_eq!(row.voice, Some("dad".to_string()));
assert_eq!(row.tz_offset_minutes, Some(-300)); assert_eq!(row.tz_offset_minutes, Some(-300));
assert!(row.library.is_none()); assert!(row.library.is_none());
assert!(row.display_tz.is_none());
assert_eq!(row.updated_at, now2); assert_eq!(row.updated_at, now2);
} }
@@ -195,6 +201,7 @@ mod tests {
tz_offset_minutes: Some(-480), tz_offset_minutes: Some(-480),
library: None, library: None,
updated_at: now, updated_at: now,
display_tz: Some("Europe/Berlin".to_string()),
}; };
dao.upsert_prefs(&ctx(), &prefs).unwrap(); dao.upsert_prefs(&ctx(), &prefs).unwrap();
@@ -202,5 +209,6 @@ mod tests {
assert_eq!(row.tz_offset_minutes, Some(-480)); assert_eq!(row.tz_offset_minutes, Some(-480));
assert!(row.voice.is_none()); assert!(row.voice.is_none());
assert!(row.library.is_none()); assert!(row.library.is_none());
assert_eq!(row.display_tz, Some("Europe/Berlin".to_string()));
} }
} }
+176 -23
View File
@@ -32,10 +32,11 @@ use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::OnceLock; use std::sync::OnceLock;
use chrono::{DateTime, Utc}; use chrono::{DateTime, NaiveDateTime, Utc};
use log::{debug, trace, warn}; use log::{debug, trace, warn};
use serde::Deserialize; use serde::Deserialize;
use crate::camera_tz;
use crate::utils::earliest_fs_time; use crate::utils::earliest_fs_time;
#[derive(Copy, Clone, Debug, PartialEq, Eq)] #[derive(Copy, Clone, Debug, PartialEq, Eq)]
@@ -61,27 +62,101 @@ impl DateSource {
} }
} }
/// What the ingest path already knows from kamadak-exif for one file: the
/// camera wall-clock reading plus the offset evidence for the cascade.
#[derive(Copy, Clone, Debug)]
pub struct ExifPrior {
/// Camera wall clock, decoded the same way the legacy column stores it
/// (naive-as-UTC from `parse_exif_datetime`). The legacy `date_taken`
/// value stays byte-identical.
pub wall: NaiveDateTime,
/// Offset evidence (EXIF `OffsetTime*` tag, GPS coordinates).
pub evidence: camera_tz::OffsetEvidence,
}
#[derive(Copy, Clone, Debug)] #[derive(Copy, Clone, Debug)]
pub struct ResolvedDate { pub struct ResolvedDate {
/// Legacy `date_taken` value. Kept byte-identical to the pre-migration
/// behavior of each branch so rollback stays trivial (ADR-0001).
pub timestamp: i64, pub timestamp: i64,
pub source: DateSource, 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).
pub date_utc: Option<i64>,
/// Camera offset (minutes) that produced `date_utc`; `None` when the
/// branch is a direct true instant or unclassified.
pub camera_offset_minutes: Option<i32>,
/// Why `date_utc` has the offset it does. `None` when unclassified.
pub tz_source: Option<camera_tz::TzSource>,
}
/// Build an [`ExifPrior`] from a kamadak-exif [`crate::exif::ExifData`]:
/// the wall clock (naive-as-UTC, matching the legacy column) plus the
/// offset evidence (stamped offset tag + GPS). `None` when the file had no
/// EXIF date.
pub fn exif_prior_from(data: &crate::exif::ExifData) -> Option<ExifPrior> {
let wall = DateTime::from_timestamp(data.date_taken?, 0)?.naive_utc();
Some(ExifPrior {
wall,
evidence: camera_tz::OffsetEvidence {
offset_tag_minutes: data.offset_time_minutes,
gps: match (data.gps_latitude, data.gps_longitude) {
(Some(lat), Some(lon)) => Some((lat, lon)),
_ => None,
},
},
})
}
/// EXIF-branch resolution: run the offset cascade over the wall clock and
/// offset evidence kamadak-exif already extracted. Legacy timestamp is the
/// wall clock as-UTC — unchanged from the pre-migration behavior.
fn exif_prior_resolved(prior: &ExifPrior) -> ResolvedDate {
let resolution = camera_tz::resolve_camera_offset(
prior.wall,
&prior.evidence,
server_zone_cached(),
gps_lookup(),
);
ResolvedDate {
timestamp: prior.wall.and_utc().timestamp(),
source: DateSource::Exif,
date_utc: Some(camera_tz::wall_to_instant(prior.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();
*ZONE.get_or_init(camera_tz::server_zone)
}
/// Shared GPS → zone adapter (bundled TZF geometry, loaded on first use).
fn gps_lookup() -> &'static camera_tz::TzfGpsZoneLookup {
static LOOKUP: OnceLock<camera_tz::TzfGpsZoneLookup> = OnceLock::new();
LOOKUP.get_or_init(camera_tz::TzfGpsZoneLookup::new)
} }
/// Resolve the canonical date for a single file, given an already-extracted /// Resolve the canonical date for a single file, given an already-extracted
/// kamadak-exif date if available. Returns `None` only if every step in the /// kamadak-exif reading if available. Returns `None` only if every step in
/// waterfall fails — for files that exist on disk this should be vanishingly /// the waterfall fails — for files that exist on disk this should be
/// rare (the fs-time fallback alone almost always succeeds). /// vanishingly rare (the fs-time fallback alone almost always succeeds).
pub fn resolve_date_taken(path: &Path, prior_exif_date: Option<i64>) -> Option<ResolvedDate> { pub fn resolve_date_taken(path: &Path, prior_exif: Option<&ExifPrior>) -> Option<ResolvedDate> {
if let Some(ts) = prior_exif_date { if let Some(prior) = prior_exif {
return Some(ResolvedDate { return Some(exif_prior_resolved(prior));
timestamp: ts,
source: DateSource::Exif,
});
} }
if let Some(ts) = exiftool_date_single(path) { if let Some(ts) = exiftool_date_single(path) {
return Some(ResolvedDate { return Some(ResolvedDate {
timestamp: ts, timestamp: ts,
source: DateSource::Exiftool, source: DateSource::Exiftool,
// exiftool's `-d %s` conversion interprets the container date
// in the server's local zone — already a true UTC instant.
date_utc: Some(ts),
camera_offset_minutes: None,
tz_source: Some(camera_tz::TzSource::UtcDirect),
}); });
} }
if let Some(dt) = path if let Some(dt) = path
@@ -92,6 +167,12 @@ pub fn resolve_date_taken(path: &Path, prior_exif_date: Option<i64>) -> Option<R
return Some(ResolvedDate { return Some(ResolvedDate {
timestamp: dt.timestamp(), timestamp: dt.timestamp(),
source: DateSource::Filename, 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) if let Ok(meta) = std::fs::metadata(path)
@@ -101,6 +182,9 @@ pub fn resolve_date_taken(path: &Path, prior_exif_date: Option<i64>) -> Option<R
return Some(ResolvedDate { return Some(ResolvedDate {
timestamp: dt.timestamp(), timestamp: dt.timestamp(),
source: DateSource::FsTime, source: DateSource::FsTime,
date_utc: Some(dt.timestamp()),
camera_offset_minutes: None,
tz_source: Some(camera_tz::TzSource::UtcDirect),
}); });
} }
None None
@@ -116,20 +200,14 @@ pub fn resolve_date_taken(path: &Path, prior_exif_date: Option<i64>) -> Option<R
/// at a time and uses one exiftool subprocess to drain the lot. /// at a time and uses one exiftool subprocess to drain the lot.
pub fn resolve_dates_batch( pub fn resolve_dates_batch(
paths: &[PathBuf], paths: &[PathBuf],
prior_exif_dates: &HashMap<PathBuf, i64>, prior_exif_priors: &HashMap<PathBuf, ExifPrior>,
) -> HashMap<PathBuf, ResolvedDate> { ) -> HashMap<PathBuf, ResolvedDate> {
let mut out: HashMap<PathBuf, ResolvedDate> = HashMap::new(); let mut out: HashMap<PathBuf, ResolvedDate> = HashMap::new();
let mut needs_exiftool: Vec<&Path> = Vec::with_capacity(paths.len()); let mut needs_exiftool: Vec<&Path> = Vec::with_capacity(paths.len());
for path in paths { for path in paths {
if let Some(&ts) = prior_exif_dates.get(path) { if let Some(prior) = prior_exif_priors.get(path) {
out.insert( out.insert(path.clone(), exif_prior_resolved(prior));
path.clone(),
ResolvedDate {
timestamp: ts,
source: DateSource::Exif,
},
);
} else { } else {
needs_exiftool.push(path.as_path()); needs_exiftool.push(path.as_path());
} }
@@ -144,6 +222,9 @@ pub fn resolve_dates_batch(
ResolvedDate { ResolvedDate {
timestamp: ts, timestamp: ts,
source: DateSource::Exiftool, source: DateSource::Exiftool,
date_utc: Some(ts),
camera_offset_minutes: None,
tz_source: Some(camera_tz::TzSource::UtcDirect),
}, },
); );
} }
@@ -164,6 +245,9 @@ pub fn resolve_dates_batch(
ResolvedDate { ResolvedDate {
timestamp: dt.timestamp(), timestamp: dt.timestamp(),
source: DateSource::Filename, source: DateSource::Filename,
date_utc: None,
camera_offset_minutes: None,
tz_source: None,
}, },
); );
continue; continue;
@@ -177,6 +261,9 @@ pub fn resolve_dates_batch(
ResolvedDate { ResolvedDate {
timestamp: dt.timestamp(), timestamp: dt.timestamp(),
source: DateSource::FsTime, source: DateSource::FsTime,
date_utc: Some(dt.timestamp()),
camera_offset_minutes: None,
tz_source: Some(camera_tz::TzSource::UtcDirect),
}, },
); );
} }
@@ -474,23 +561,85 @@ mod tests {
#[test] #[test]
fn resolve_uses_prior_exif_when_present() { fn resolve_uses_prior_exif_when_present() {
// Path doesn't need to exist when prior_exif_date short-circuits. // Path doesn't need to exist when a prior EXIF reading
// short-circuits. Legacy timestamp stays the wall-clock-as-UTC
// value; the new-contract fields come from the cascade.
let prior = ExifPrior {
wall: chrono::DateTime::from_timestamp(1_700_000_000, 0)
.unwrap()
.naive_utc(),
evidence: camera_tz::OffsetEvidence::default(),
};
let resolved = let resolved =
resolve_date_taken(Path::new("/nonexistent/file.jpg"), Some(1700000000)).unwrap(); resolve_date_taken(Path::new("/nonexistent/file.jpg"), Some(&prior)).unwrap();
assert_eq!(resolved.timestamp, 1700000000); assert_eq!(resolved.timestamp, 1_700_000_000);
assert_eq!(resolved.source, DateSource::Exif); assert_eq!(resolved.source, DateSource::Exif);
// No offset evidence in the stub-less test env: cascade lands on
// assumed_server (or the local-offset fallback when the host zone
// doesn't parse), and date_utc is always offset-correct.
assert!(resolved.camera_offset_minutes.is_some());
assert!(resolved.date_utc.is_some());
assert_eq!(
resolved.date_utc.unwrap(),
resolved.timestamp - (resolved.camera_offset_minutes.unwrap() as i64) * 60
);
}
#[test]
fn resolve_exif_offset_tag_gives_true_instant_and_source() {
// Wall 2024-07-04 12:00:00 with a camera-stamped EDT offset.
// Deterministic: the offset tag short-circuits the cascade before
// GPS / server-zone lookup.
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: Some(-240),
gps: None,
},
};
let resolved =
resolve_date_taken(Path::new("/nonexistent/IMG_0001.jpg"), Some(&prior)).unwrap();
assert_eq!(resolved.timestamp, 1_720_094_400); // legacy wall-as-UTC
assert_eq!(resolved.date_utc, Some(1_720_108_800)); // 16:00:00Z
assert_eq!(resolved.camera_offset_minutes, Some(-240));
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::OffsetTag));
}
#[test]
fn resolve_exif_gps_evidence_uses_capture_zone() {
// No offset tag; GPS points at Manhattan. The bundled TZF geometry
// resolves America/New_York, whose offset for the July wall time
// is EDT (-240) — DST-correct for the capture date, not "now".
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/IMG_0001.jpg"), Some(&prior)).unwrap();
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));
} }
#[test] #[test]
fn resolve_filename_when_no_exif_and_file_missing() { fn resolve_filename_when_no_exif_and_file_missing() {
// No prior EXIF, no exiftool match (file missing), but the filename // No prior EXIF, no exiftool match (file missing), but the filename
// pattern still matches so the resolver lands on Filename. // pattern still matches so the resolver lands on Filename. The
// filename branch stays unclassified (#116): date_utc is NULL.
let resolved = resolve_date_taken( let resolved = resolve_date_taken(
Path::new("/nonexistent/Screenshot_2014-06-01-20-44-50.png"), Path::new("/nonexistent/Screenshot_2014-06-01-20-44-50.png"),
None, None,
) )
.unwrap(); .unwrap();
assert_eq!(resolved.source, DateSource::Filename); assert_eq!(resolved.source, DateSource::Filename);
assert_eq!(resolved.date_utc, None);
assert_eq!(resolved.camera_offset_minutes, None);
assert_eq!(resolved.tz_source, None);
} }
#[test] #[test]
@@ -503,5 +652,9 @@ mod tests {
// way the file has no EXIF and no filename date, so we should // way the file has no EXIF and no filename date, so we should
// fall to fs_time. // fall to fs_time.
assert_eq!(resolved.source, DateSource::FsTime); assert_eq!(resolved.source, DateSource::FsTime);
// fs_time is already a true UTC instant — recorded as utc_direct.
assert_eq!(resolved.date_utc, Some(resolved.timestamp));
assert_eq!(resolved.camera_offset_minutes, None);
assert_eq!(resolved.tz_source, Some(camera_tz::TzSource::UtcDirect));
} }
} }
+103
View File
@@ -25,6 +25,20 @@ pub struct ExifData {
pub shutter_speed: Option<String>, pub shutter_speed: Option<String>,
pub iso: Option<i32>, pub iso: Option<i32>,
pub date_taken: Option<i64>, pub date_taken: Option<i64>,
/// Camera offset (local − UTC, minutes) from the EXIF `OffsetTime*`
/// tag paired with the winning `DateTime*` tag, when the camera
/// stamped one. Feeds the offset cascade in `camera_tz`.
pub offset_time_minutes: Option<i32>,
// Raw per-tag offsets, collected in the field loop and paired with the
// winning date tag after it. Not part of the wire format.
#[serde(skip)]
offset_time: Option<i32>,
#[serde(skip)]
offset_time_original: Option<i32>,
#[serde(skip)]
offset_time_digitized: Option<i32>,
#[serde(skip)]
date_tag: Option<Tag>,
} }
/// TIFF-based RAW formats where `JPEGInterchangeFormat` offsets are /// TIFF-based RAW formats where `JPEGInterchangeFormat` offsets are
@@ -331,12 +345,41 @@ pub fn extract_exif_from_path(path: &Path) -> Result<ExifData> {
Tag::DateTime | Tag::DateTimeOriginal => { Tag::DateTime | Tag::DateTimeOriginal => {
if data.date_taken.is_none() { if data.date_taken.is_none() {
data.date_taken = parse_exif_datetime(field); data.date_taken = parse_exif_datetime(field);
data.date_tag = Some(field.tag);
}
}
Tag::OffsetTime => {
if data.offset_time.is_none() {
data.offset_time = parse_offset_time(field);
}
}
Tag::OffsetTimeOriginal => {
if data.offset_time_original.is_none() {
data.offset_time_original = parse_offset_time(field);
}
}
Tag::OffsetTimeDigitized => {
if data.offset_time_digitized.is_none() {
data.offset_time_digitized = parse_offset_time(field);
} }
} }
_ => {} _ => {}
} }
} }
// Pair the offset with the date tag that won (DateTimeOriginal pairs
// with OffsetTimeOriginal, etc.); fall back to any stamped offset.
data.offset_time_minutes = match data.date_tag {
Some(Tag::DateTimeOriginal) => data
.offset_time_original
.or(data.offset_time)
.or(data.offset_time_digitized),
_ => data
.offset_time
.or(data.offset_time_original)
.or(data.offset_time_digitized),
};
// Extract GPS coordinates // Extract GPS coordinates
if let Some(lat) = extract_gps_coordinate(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef) { if let Some(lat) = extract_gps_coordinate(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef) {
data.gps_latitude = Some(lat); data.gps_latitude = Some(lat);
@@ -465,6 +508,27 @@ fn parse_exif_datetime(field: &exif::Field) -> Option<i64> {
} }
} }
/// Parse an EXIF `OffsetTime*` value into signed minutes from UTC.
///
/// EXIF stores `+HH:MM` or `-HH:MM` (seconds are optional and ignored); the
/// special value `Z` means UTC (zero minutes). Returns `None` for anything
/// that isn't a plausible offset (hour > 14, minute > 59, unparseable).
fn parse_offset_time(field: &exif::Field) -> Option<i32> {
let s = get_string_value(field)?.trim().to_string();
if s == "Z" {
return Some(0);
}
let sign: i32 = if s.starts_with('-') { -1 } else { 1 };
let digits = s.trim_start_matches(['+', '-']);
let mut parts = digits.split(':');
let hh: i32 = parts.next()?.parse().ok()?;
let mm: i32 = parts.next()?.parse().ok()?;
if hh > 14 || mm > 59 {
return None;
}
Some(sign * (hh * 60 + mm))
}
fn extract_gps_coordinate(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option<f64> { fn extract_gps_coordinate(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option<f64> {
let coord_field = exif.get_field(coord_tag, In::PRIMARY)?; let coord_field = exif.get_field(coord_tag, In::PRIMARY)?;
let ref_field = exif.get_field(ref_tag, In::PRIMARY)?; let ref_field = exif.get_field(ref_tag, In::PRIMARY)?;
@@ -576,4 +640,43 @@ mod tests {
fn test_supports_exif_no_extension() { fn test_supports_exif_no_extension() {
assert!(!supports_exif(Path::new("test"))); assert!(!supports_exif(Path::new("test")));
} }
fn offset_field(s: &[u8]) -> exif::Field {
exif::Field {
tag: Tag::OffsetTimeOriginal,
ifd_num: In::PRIMARY,
value: Value::Ascii(vec![s.to_vec()]),
}
}
#[test]
fn test_parse_offset_time_positive_and_negative() {
assert_eq!(parse_offset_time(&offset_field(b"-04:00")), Some(-240));
assert_eq!(parse_offset_time(&offset_field(b"+05:30")), Some(330));
assert_eq!(parse_offset_time(&offset_field(b"+00:00")), Some(0));
}
#[test]
fn test_parse_offset_time_with_seconds_and_z() {
assert_eq!(parse_offset_time(&offset_field(b"-04:00:00")), Some(-240));
assert_eq!(parse_offset_time(&offset_field(b"Z")), Some(0));
}
#[test]
fn test_parse_offset_time_rejects_garbage() {
assert_eq!(parse_offset_time(&offset_field(b"garbage")), None);
assert_eq!(parse_offset_time(&offset_field(b"+15:00")), None);
assert_eq!(parse_offset_time(&offset_field(b"+04:60")), None);
assert_eq!(parse_offset_time(&offset_field(b"")), None);
}
#[test]
fn test_parse_offset_time_missing_tag_is_none() {
let empty = exif::Field {
tag: Tag::OffsetTimeOriginal,
ifd_num: In::PRIMARY,
value: Value::Ascii(Vec::new()),
};
assert_eq!(parse_offset_time(&empty), None);
}
} }
+15
View File
@@ -1281,6 +1281,9 @@ pub async fn list_exif_summary(
gps_latitude: r.gps_latitude.map(f64::from), gps_latitude: r.gps_latitude.map(f64::from),
gps_longitude: r.gps_longitude.map(f64::from), gps_longitude: r.gps_longitude.map(f64::from),
date_taken: r.date_taken, date_taken: r.date_taken,
date_utc: r.date_utc,
camera_offset_minutes: r.camera_offset_minutes,
date_taken_tz_source: r.date_taken_tz_source,
}) })
.collect(); .collect();
@@ -1513,6 +1516,9 @@ mod tests {
original_date_taken_source: None, original_date_taken_source: None,
clip_embedding: None, clip_embedding: None,
clip_model_version: None, clip_model_version: None,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
} }
} }
@@ -1554,6 +1560,9 @@ mod tests {
original_date_taken_source: None, original_date_taken_source: None,
clip_embedding: None, clip_embedding: None,
clip_model_version: None, clip_model_version: None,
date_utc: data.date_utc,
camera_offset_minutes: data.camera_offset_minutes,
date_taken_tz_source: data.date_taken_tz_source.clone(),
}) })
} }
@@ -1602,6 +1611,9 @@ mod tests {
original_date_taken_source: None, original_date_taken_source: None,
clip_embedding: None, clip_embedding: None,
clip_model_version: None, clip_model_version: None,
date_utc: data.date_utc,
camera_offset_minutes: data.camera_offset_minutes,
date_taken_tz_source: data.date_taken_tz_source.clone(),
}) })
} }
@@ -1726,6 +1738,9 @@ mod tests {
_rel_path: &str, _rel_path: &str,
_date_taken: i64, _date_taken: i64,
_source: &str, _source: &str,
_date_utc: Option<i64>,
_camera_offset_minutes: Option<i32>,
_date_taken_tz_source: Option<&str>,
) -> Result<(), DbError> { ) -> Result<(), DbError> {
Ok(()) Ok(())
} }
+19 -3
View File
@@ -642,7 +642,10 @@ pub async fn set_image_gps(
// writing GPS doesn't change the capture date, but if the row was // writing GPS doesn't change the capture date, but if the row was
// previously sourced from `fs_time` the re-read may have given us a // previously sourced from `fs_time` the re-read may have given us a
// real EXIF date this time, and we want to upgrade the source. // real EXIF date this time, and we want to upgrade the source.
let resolved_date = date_resolver::resolve_date_taken(&full_path, extracted.date_taken); let resolved_date = date_resolver::resolve_date_taken(
&full_path,
date_resolver::exif_prior_from(&extracted).as_ref(),
);
let insert_exif = InsertImageExif { let insert_exif = InsertImageExif {
library_id: resolved_library.id, library_id: resolved_library.id,
file_path: normalized_path.clone(), file_path: normalized_path.clone(),
@@ -678,6 +681,11 @@ pub async fn set_image_gps(
phash_64: perceptual_hash::compute(&full_path).map(|h| h.phash_64), phash_64: perceptual_hash::compute(&full_path).map(|h| h.phash_64),
dhash_64: perceptual_hash::compute(&full_path).map(|h| h.dhash_64), dhash_64: perceptual_hash::compute(&full_path).map(|h| h.dhash_64),
date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()), date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()),
date_utc: resolved_date.and_then(|r| r.date_utc),
camera_offset_minutes: resolved_date.and_then(|r| r.camera_offset_minutes),
date_taken_tz_source: resolved_date
.and_then(|r| r.tz_source)
.map(|t| t.as_str().to_string()),
}; };
let updated = { let updated = {
@@ -1143,8 +1151,10 @@ pub async fn upload_image(
} }
}; };
let perceptual = perceptual_hash::compute(&uploaded_path); let perceptual = perceptual_hash::compute(&uploaded_path);
let resolved_date = let resolved_date = date_resolver::resolve_date_taken(
date_resolver::resolve_date_taken(&uploaded_path, exif_data.date_taken); &uploaded_path,
date_resolver::exif_prior_from(&exif_data).as_ref(),
);
let insert_exif = InsertImageExif { let insert_exif = InsertImageExif {
library_id: target_library.id, library_id: target_library.id,
file_path: relative_path.clone(), file_path: relative_path.clone(),
@@ -1169,6 +1179,12 @@ pub async fn upload_image(
phash_64: perceptual.map(|h| h.phash_64), phash_64: perceptual.map(|h| h.phash_64),
dhash_64: perceptual.map(|h| h.dhash_64), dhash_64: perceptual.map(|h| h.dhash_64),
date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()), date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()),
date_utc: resolved_date.and_then(|r| r.date_utc),
camera_offset_minutes: resolved_date
.and_then(|r| r.camera_offset_minutes),
date_taken_tz_source: resolved_date
.and_then(|r| r.tz_source)
.map(|t| t.as_str().to_string()),
}; };
if let Ok(mut dao) = exif_dao.lock() { if let Ok(mut dao) = exif_dao.lock() {
+1
View File
@@ -6,6 +6,7 @@ extern crate diesel;
pub mod ai; pub mod ai;
pub mod auth; pub mod auth;
pub mod bin_progress; pub mod bin_progress;
pub mod camera_tz;
pub mod cleanup; pub mod cleanup;
pub mod clip_search; pub mod clip_search;
pub mod clip_watch; pub mod clip_watch;
+4
View File
@@ -31,6 +31,10 @@ use log::{error, info};
mod ai; mod ai;
mod auth; mod auth;
mod backfill; mod backfill;
// Display-zone helpers land in the read-side cutover (#119/#120); the
// binary's private copy of this module doesn't use them yet.
#[allow(dead_code)]
mod camera_tz;
mod clip_search; mod clip_search;
mod clip_watch; mod clip_watch;
mod content_hash; mod content_hash;
+7
View File
@@ -103,6 +103,7 @@ fn capture_prefs(
), ),
library: Some(library), library: Some(library),
updated_at: now as i64, updated_at: now as i64,
display_tz: req.display_tz.clone().filter(|s| !s.is_empty()),
}, },
) )
.map_err(|e| anyhow::anyhow!("failed to upsert user_ai_prefs: {e}")) .map_err(|e| anyhow::anyhow!("failed to upsert user_ai_prefs: {e}"))
@@ -351,6 +352,12 @@ pub struct CreateReelRequest {
pub span: Option<MemoriesSpan>, pub span: Option<MemoriesSpan>,
#[serde(default)] #[serde(default)]
pub timezone_offset_minutes: Option<i32>, pub timezone_offset_minutes: Option<i32>,
/// User's display time zone as an IANA name (DST-proof; e.g.
/// "America/New_York"). Mirrored into `user_ai_prefs.display_tz`.
/// When omitted, the stored display zone is unchanged (NULL = app
/// default). The legacy offset above is still mirrored for rollback.
#[serde(default)]
pub display_tz: Option<String>,
#[serde(default)] #[serde(default)]
pub library: Option<String>, pub library: Option<String>,
/// Cloned TTS voice for the narration; server default when omitted. /// Cloned TTS voice for the narration; server default when omitted.
+13 -2
View File
@@ -787,10 +787,16 @@ pub fn process_new_files(
// computed above) → exiftool fallback for videos / MakerNote / // computed above) → exiftool fallback for videos / MakerNote /
// QuickTime → filename regex → earliest_fs_time. Source is // QuickTime → filename regex → earliest_fs_time. Source is
// recorded so the per-tick backfill drain can re-run weak // recorded so the per-tick backfill drain can re-run weak
// resolutions later. // resolutions later. The EXIF branch additionally runs the
// camera-offset cascade to produce the new-contract columns
// (date_utc / camera_offset_minutes / date_taken_tz_source,
// ADR-0001) while the legacy date_taken stays byte-identical.
let resolved_date = date_resolver::resolve_date_taken( let resolved_date = date_resolver::resolve_date_taken(
&file_path, &file_path,
exif_fields.as_ref().and_then(|e| e.date_taken), exif_fields
.as_ref()
.and_then(date_resolver::exif_prior_from)
.as_ref(),
); );
let insert_exif = InsertImageExif { let insert_exif = InsertImageExif {
@@ -827,6 +833,11 @@ pub fn process_new_files(
phash_64: perceptual.map(|h| h.phash_64), phash_64: perceptual.map(|h| h.phash_64),
dhash_64: perceptual.map(|h| h.dhash_64), dhash_64: perceptual.map(|h| h.dhash_64),
date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()), date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()),
date_utc: resolved_date.and_then(|r| r.date_utc),
camera_offset_minutes: resolved_date.and_then(|r| r.camera_offset_minutes),
date_taken_tz_source: resolved_date
.and_then(|r| r.tz_source)
.map(|t| t.as_str().to_string()),
}; };
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao"); let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");