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:
1 parent
ee773a34ef
commit
2f2107d4fa
20 files changed
+1039
-36
No files matched your search
+40
-7
@@ -495,10 +495,12 @@ pub trait ExifDao: Sync + Send {
|
||||
limit: i64,
|
||||
) -> Result<Vec<(i32, String)>, DbError>;
|
||||
|
||||
/// Persist a resolver result for an existing row. Touches `date_taken`
|
||||
/// and `date_taken_source` only — leaves all other columns alone so
|
||||
/// the drain doesn't accidentally clobber EXIF/hash/perceptual data
|
||||
/// the watcher / GPS-write path may have already written.
|
||||
/// Persist a resolver result for an existing row. Touches `date_taken`,
|
||||
/// `date_taken_source`, and the new-contract columns (`date_utc`,
|
||||
/// `camera_offset_minutes`, `date_taken_tz_source`) only — leaves all
|
||||
/// 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(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
@@ -506,6 +508,9 @@ pub trait ExifDao: Sync + Send {
|
||||
rel_path: &str,
|
||||
date_taken: i64,
|
||||
source: &str,
|
||||
date_utc: Option<i64>,
|
||||
camera_offset_minutes: Option<i32>,
|
||||
date_taken_tz_source: Option<&str>,
|
||||
) -> Result<(), DbError>;
|
||||
|
||||
/// Find image_exif rows needing a CLIP embedding for semantic search:
|
||||
@@ -1426,6 +1431,9 @@ impl ExifDao for SqliteExifDao {
|
||||
rel_path_val: &str,
|
||||
date_taken_val: i64,
|
||||
source: &str,
|
||||
date_utc_val: Option<i64>,
|
||||
camera_offset_minutes_val: Option<i32>,
|
||||
date_taken_tz_source_val: Option<&str>,
|
||||
) -> Result<(), DbError> {
|
||||
trace_db_call(context, "update", "backfill_date_taken", |_span| {
|
||||
use schema::image_exif::dsl::*;
|
||||
@@ -1437,7 +1445,13 @@ impl ExifDao for SqliteExifDao {
|
||||
.filter(library_id.eq(library_id_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());
|
||||
|
||||
match result {
|
||||
@@ -2547,6 +2561,9 @@ mod exif_dao_tests {
|
||||
phash_64: None,
|
||||
dhash_64: None,
|
||||
date_taken_source: None,
|
||||
date_utc: None,
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
},
|
||||
)
|
||||
.expect("insert exif row");
|
||||
@@ -2695,6 +2712,9 @@ mod exif_dao_tests {
|
||||
phash_64: None,
|
||||
dhash_64: None,
|
||||
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");
|
||||
@@ -2729,12 +2749,25 @@ mod exif_dao_tests {
|
||||
dao.backfill_content_hash(&ctx(), 1, "main/x.jpg", "deadbeef", 1024)
|
||||
.unwrap();
|
||||
|
||||
dao.backfill_date_taken(&ctx(), 1, "main/x.jpg", 1700000000, "exiftool")
|
||||
.unwrap();
|
||||
dao.backfill_date_taken(
|
||||
&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();
|
||||
assert_eq!(row.date_taken, Some(1700000000));
|
||||
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.
|
||||
assert_eq!(row.content_hash, Some("deadbeef".to_string()));
|
||||
assert_eq!(row.size_bytes, Some(1024));
|
||||
|
||||
@@ -136,6 +136,17 @@ pub struct InsertImageExif {
|
||||
/// drain re-resolves rows whose source is `"fs_time"` once exiftool
|
||||
/// has had a chance to run.
|
||||
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`.
|
||||
@@ -190,6 +201,13 @@ pub struct ImageExif {
|
||||
/// swap of `APOLLO_CLIP_MODEL` re-eligibilizes rows whose stored
|
||||
/// version differs so the drain rebuilds them.
|
||||
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)]
|
||||
@@ -549,6 +567,10 @@ pub struct UserAiPrefs {
|
||||
pub tz_offset_minutes: Option<i32>,
|
||||
pub library: Option<String>,
|
||||
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)]
|
||||
@@ -558,4 +580,5 @@ pub struct UpsertUserAiPrefs {
|
||||
pub tz_offset_minutes: Option<i32>,
|
||||
pub library: Option<String>,
|
||||
pub updated_at: i64,
|
||||
pub display_tz: Option<String>,
|
||||
}
|
||||
@@ -140,6 +140,9 @@ diesel::table! {
|
||||
original_date_taken_source -> Nullable<Text>,
|
||||
clip_embedding -> Nullable<Binary>,
|
||||
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>,
|
||||
library -> Nullable<Text>,
|
||||
updated_at -> BigInt,
|
||||
display_tz -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ impl UserAiPrefsDao for SqliteUserAiPrefsDao {
|
||||
dsl::voice.eq(&prefs.voice),
|
||||
dsl::tz_offset_minutes.eq(&prefs.tz_offset_minutes),
|
||||
dsl::library.eq(&prefs.library),
|
||||
dsl::display_tz.eq(&prefs.display_tz),
|
||||
dsl::updated_at.eq(&prefs.updated_at),
|
||||
))
|
||||
.execute(connection.deref_mut())
|
||||
@@ -145,6 +146,7 @@ mod tests {
|
||||
tz_offset_minutes: Some(-480),
|
||||
library: Some("1".to_string()),
|
||||
updated_at: now,
|
||||
display_tz: Some("America/New_York".to_string()),
|
||||
};
|
||||
dao.upsert_prefs(&ctx(), &prefs).unwrap();
|
||||
|
||||
@@ -153,6 +155,7 @@ mod tests {
|
||||
assert_eq!(row.voice, Some("grandma".to_string()));
|
||||
assert_eq!(row.tz_offset_minutes, Some(-480));
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -167,6 +170,7 @@ mod tests {
|
||||
tz_offset_minutes: Some(-480),
|
||||
library: Some("1".to_string()),
|
||||
updated_at: now1,
|
||||
display_tz: Some("America/New_York".to_string()),
|
||||
};
|
||||
dao.upsert_prefs(&ctx(), &prefs1).unwrap();
|
||||
|
||||
@@ -175,6 +179,7 @@ mod tests {
|
||||
tz_offset_minutes: Some(-300),
|
||||
library: None,
|
||||
updated_at: now2,
|
||||
display_tz: None,
|
||||
};
|
||||
dao.upsert_prefs(&ctx(), &prefs2).unwrap();
|
||||
|
||||
@@ -182,6 +187,7 @@ mod tests {
|
||||
assert_eq!(row.voice, Some("dad".to_string()));
|
||||
assert_eq!(row.tz_offset_minutes, Some(-300));
|
||||
assert!(row.library.is_none());
|
||||
assert!(row.display_tz.is_none());
|
||||
assert_eq!(row.updated_at, now2);
|
||||
}
|
||||
|
||||
@@ -195,6 +201,7 @@ mod tests {
|
||||
tz_offset_minutes: Some(-480),
|
||||
library: None,
|
||||
updated_at: now,
|
||||
display_tz: Some("Europe/Berlin".to_string()),
|
||||
};
|
||||
dao.upsert_prefs(&ctx(), &prefs).unwrap();
|
||||
|
||||
@@ -202,5 +209,6 @@ mod tests {
|
||||
assert_eq!(row.tz_offset_minutes, Some(-480));
|
||||
assert!(row.voice.is_none());
|
||||
assert!(row.library.is_none());
|
||||
assert_eq!(row.display_tz, Some("Europe/Berlin".to_string()));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user