Merge pull request 'feat(dates): true-instant date contract — container dates, fs branch, manual edit (#114, #115)' (#121) from feature/true-utc-dates into master

Reviewed-on: #121
This commit was merged in pull request #121.
This commit is contained in:
cameron committed 2026-09-25 19:55:14 +00:00
commit 467abd41d5
25 files changed
+2010 -189

No files matched your search

+1
View File
@@ -10,6 +10,7 @@ database/target
/tmp /tmp
/docs/* /docs/*
!/docs/agents/ !/docs/agents/
!/docs/adr/
/specs /specs
# Default ignored files # Default ignored files
+18 -5
View File
@@ -380,9 +380,21 @@ land on this date?". Order:
QuickTime/MP4 (`MediaCreateDate`, `TrackCreateDate`, `CreateDate`), QuickTime/MP4 (`MediaCreateDate`, `TrackCreateDate`, `CreateDate`),
Apple's `ContentCreateDate`, MakerNote sub-IFDs. Required for Apple's `ContentCreateDate`, MakerNote sub-IFDs. Required for
videos to land a real date. Single-file at ingest; the per-tick videos to land a real date. Single-file at ingest; the per-tick
drain feeds the whole batch through one `exiftool -@ -` subprocess. drain feeds the whole batch through one `exiftool -j -q -G1 -@ -`
Degrades silently when `exiftool` isn't on PATH (resolver caches the subprocess (raw strings + group prefixes, parsed in Rust — no
"available" check via `OnceLock`). `-d %s`). Degrades silently when `exiftool` isn't on PATH (resolver
caches the "available" check via `OnceLock`).
**Group classification (ADR-0001, ticket #115):** 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`, no cascade — running them
through the wall-clock cascade was the "videos off by 4–5 hours"
bug); naive strings from photo-EXIF groups are camera wall clocks →
offset cascade (`OffsetTime*` + GPS evidence pulled in the same
pass). The legacy `date_taken` column stays byte-identical to the
old server-local `-d %s` interpretation; the new-contract
`date_utc` / `camera_offset_minutes` / `date_taken_tz_source`
columns carry the true instant.
3. **`filename`** — `extract_date_from_filename` in `memories.rs` 3. **`filename`** — `extract_date_from_filename` in `memories.rs`
matches screenshot, chat-export, and timestamp-named patterns. matches screenshot, chat-export, and timestamp-named patterns.
4. **`fs_time`** — `earliest_fs_time(metadata)` (earlier of created / 4. **`fs_time`** — `earliest_fs_time(metadata)` (earlier of created /
@@ -401,8 +413,9 @@ watcher tick alongside `backfill_unhashed_backlog` (also `src/backfill.rs`). It
`date_taken IS NULL` (backed by the `idx_image_exif_date_backfill` `date_taken IS NULL` (backed by the `idx_image_exif_date_backfill`
partial index), runs the waterfall batch via `resolve_dates_batch`, partial index), runs the waterfall batch via `resolve_dates_batch`,
and writes results via the `backfill_date_taken` DAO method (touches and writes results via the `backfill_date_taken` DAO method (touches
only `date_taken` + `date_taken_source` so EXIF / hash / perceptual `date_taken` + `date_taken_source` plus the new-contract
columns are preserved). Resolved rows — including the ones the `date_utc` / `camera_offset_minutes` / `date_taken_tz_source` triple,
so EXIF / hash / perceptual columns are preserved). Resolved rows — including the ones the
waterfall could only resolve via `fs_time` — are not re-eligible: waterfall could only resolve via `fs_time` — are not re-eligible:
the resolver is deterministic on file bytes + filename + fs metadata, the resolver is deterministic on file bytes + filename + fs metadata,
so re-running on the same inputs lands on the same source every time. so re-running on the same inputs lands on the same source every time.
+64
View File
@@ -0,0 +1,64 @@
# ImageApi
A photo/video memory system: it ingests media from one or more libraries, derives metadata
(hashes, dates, faces, insights) that follows the bytes rather than the path, and serves it
to two consumers (the Apollo web UI and the FileViewer mobile app) plus a message store (SMS-API).
## Language
### Dates & time
**True instant**:
The actual moment a photo or video was captured, as Unix seconds. The one clock the whole
system matches against — SMS messages, location track points, and photo dates are all true
instants, so they compare directly with no conversion.
_Avoid_: epoch, real UTC, wall time
**Wall clock**:
The local time the capturing device showed (e.g. "14:30 on the beach"), independent of where
the owner is. A wall clock is not an instant until paired with a zone; it is what the UI's
metadata line and insight prompts show so memories read the way the camera remembered them.
_Avoid_: local time, naive date, camera time, date_taken
**Camera offset**:
The zone offset that converts a photo's wall clock into its true instant, resolved per photo
at ingest from the most specific evidence available (an explicit EXIF offset tag, then GPS
position, then the server's zone as a flagged assumption). Recorded on the row so the
conversion is auditable.
_Avoid_: timezone, tz, server offset
**Display zone**:
The IANA zone name of the owner, used for day-level grouping (memories buckets, timelines,
daily summaries) and for rendering wall clocks when a photo carries no camera offset. Stored
as a zone name, never a fixed minute offset, so DST changes stay correct.
_Avoid_: timezone_offset, tz_offset_minutes, client offset
**Date source**:
Which step of the ingest waterfall filled a photo's date — EXIF tag, external metadata
tool, filename, filesystem time, or a manual edit. Recorded per row so weak entries can be
re-resolved when a better tool arrives and so any row's date can be explained.
_Avoid_: provenance, origin
**Filename date class**:
Filenames whose name itself carries the capture date (screenshots, timestamp-named exports).
For this class the filename is the original date of record and outranks EXIF, even though
EXIF wins everywhere else. Each pattern in the class carries its own convention: some names
encode a wall clock, others encode a true instant.
_Avoid_: regex dates, name parsing
**Manual date**:
A date the operator sets by hand, expressed as a wall clock in the display zone at edit time
and stored as the resulting true instant. The prior value is kept as the original date.
_Avoid_: corrected date, user date
**Re-anchoring (legacy)**:
The pre-instant habit of storing wall clocks as if they were UTC and having every consumer
shift them back by a zone. Survives only in the frozen legacy column and in the consumer
code that migrates off it; new code never re-anchors.
_Avoid_: tz fix, offset correction, naive handling
**Derived day data**:
Insights and pre-computed artifacts grouped by calendar day (daily message summaries,
reel pre-generation). Because it is keyed on the day, re-dating a photo across a day
boundary invalidates the derived data for that day, which regenerates on its normal schedule.
_Avoid_: cache, nightly output
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).
+82
View File
@@ -0,0 +1,82 @@
# ADR-0001: Dates are true instants; wall clocks are a display concern
**Status**: accepted (2026-09-24)
## Context
`image_exif.date_taken` (Unix i64) accumulated four conventions in one column, indistinguishable
without joining on `date_taken_source`: EXIF rows were camera wall-clocks reinterpreted as UTC,
exiftool rows were naive dates interpreted in the server's local zone, filename rows were
deliberate wall-as-UTC, and filesystem-time rows were true instants. SMS-API and the Apollo
location track were already true UTC. Every consumer had to re-derive the missing shift
(Apollo's `_naive_to_real_utc`, the frontend `naiveAsUtcToRealUtc`, FileViewer's bare
device-local formatting, `/memories`'s per-client offset) and they disagreed: the same photo
showed different clocks in the two apps, exiftool-sourced videos (whose dates only come from
exiftool) were double-shifted, and the insight-context prompt formatted a value 4–5 hours
wrong for video rows, rolling across midnight near day edges.
## Decision
The date a media file was captured is stored as a **true instant** (Unix seconds of the
actual moment). The wall clock the camera showed is a **display value**, recovered from the
instant plus a per-row **camera offset** resolved at ingest by a cascade (explicit EXIF
offset tag → GPS position → server zone, the last recorded as an assumption). Day-level
grouping uses the owner's **display zone** (an IANA zone name). Manual edits enter as wall
clocks in the display zone (rendered naive-as-UTC by the client) and are
stored as instants; the legacy column keeps the sent value verbatim and
revert restores a snapshot of both the legacy pair and the new-contract
triple (`original_date_taken*` / `original_date_utc` /
`original_camera_offset_minutes` / `original_date_taken_tz_source`).
Consequences of the decision:
- Matching photo↔SMS, photo↔location, and photo↔calendar becomes instant-to-instant with no
per-consumer shift; the re-anchoring helpers in the consumers are deleted, not migrated.
- A photo's displayed time can differ from the owner's local time (a photo taken in Japan
shows the Japan wall clock); grouping still uses the owner's day. This is deliberate.
- The legacy `date_taken` column is frozen during migration (a new `date_utc` column is
populated by a per-tick backfill drain), so rollback is a client-side flip and the old
contract stays byte-identical until cutover. The legacy column is retained after cutover
for historical context.
## Considered options
- **Standardize on wall-clock-as-UTC** (fix only the exiftool branch): cheapest, but every
consumer would still carry a display-zone re-anchor as a proxy for the camera's zone, and
cross-zone photo↔SMS matching would stay approximate. Rejected.
- **Two parallel fields as a permanent contract** (wall-as-UTC `date_taken` + `date_utc`):
honest, but leaves the ambiguity in the interface forever. The dual-field shape is used
*temporarily* (migration window) only.
- **Server-side conversion per request** (keep the column as-is, convert on read): pushes
the mixed-convention join onto every query and every consumer; rejected.
## Non-obvious rules worth remembering
- **Filename date class outranks EXIF** — for files whose filename carries the capture date
(screenshots, timestamp exports), the filename is the original date of record, inverting
the global EXIF-wins rule. The class is a documented exception, not a bug.
- **Offset resolution is per-row and audited** (`date_taken_tz_source`), because a library
contains photos from many zones; a single global offset would be wrong for every trip.
- **The display zone is a zone name, not minutes** — a hardcoded offset silently breaks at
DST. (The legacy `tz_offset_minutes` default of -480 on a New York server is a surviving
symptom of this.)
- **Re-dating across a day boundary invalidates derived day data** (daily summaries, reel
pre-gen), which regenerates on its normal schedule; the backfill drain detects the day
change per row.
- **Container dates are true instants, not wall clocks.** QuickTime/MP4/MOV
`creation_time` fields are seconds-since-1904 in UTC; exiftool renders them as
UTC-naive strings. Running a naive container string through the camera wall-clock
cascade reinterprets a UTC instant in the server zone and shifts every video by the
server's UTC offset (the "videos off by 4–5 hours" bug, ticket #115). Verified against
production data: a mirrorless video lands inside the same-moment photo burst, and the
delta across all sampled videos is the uniform server EDT/EST offset. So the exiftool
branch classifies the *group* of each tag: zoned strings are instants outright,
container-group naives are instants (`utc_direct`), and only photo-EXIF-group naives
run the cascade. The legacy column for those rows stays byte-identical to the old
`-d %s` server-local interpretation; the new-contract columns carry the true instant.
- **The legacy column is reproduced in Rust, not by re-invoking exiftool.** The old
`-d %s` path interpreted exiftool's rendered naive string in the *server's* local zone
(DST-aware, ignoring the file's own `OffsetTime*` tags — verified). `date_taken`
for exiftool rows must keep producing exactly that value, so the resolver parses the
raw string and applies the server-zone interpretation itself (`utc_direct` rows skip
the cascade but still get the server-local legacy value).
@@ -0,0 +1,7 @@
ALTER TABLE user_ai_prefs DROP COLUMN display_tz;
ALTER TABLE image_exif DROP COLUMN original_date_taken_tz_source;
ALTER TABLE image_exif DROP COLUMN original_camera_offset_minutes;
ALTER TABLE image_exif DROP COLUMN original_date_utc;
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,32 @@
-- True-instant date contract (ADR-0001 / spec #113, tickets #114 + #115).
--
-- 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.
--
-- Manual date edits (ticket #115) write the new-contract triple alongside
-- the legacy pair and must be revertible, so image_exif also snapshots the
-- pre-override new-contract columns next to the existing legacy snapshot
-- pair (original_date_taken / original_date_taken_source):
-- original_date_utc / original_camera_offset_minutes /
-- original_date_taken_tz_source.
--
-- All columns are additive and nullable; population happens at runtime
-- (ingest + the per-tick backfill drains), so no backfill SQL is needed
-- here. 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 image_exif ADD COLUMN original_date_utc BIGINT;
ALTER TABLE image_exif ADD COLUMN original_camera_offset_minutes INTEGER;
ALTER TABLE image_exif ADD COLUMN original_date_taken_tz_source TEXT;
ALTER TABLE user_ai_prefs ADD COLUMN display_tz TEXT;
+6
View File
@@ -3378,6 +3378,12 @@ 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()),
original_date_utc: None,
original_camera_offset_minutes: None,
original_date_taken_tz_source: None,
}); });
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");
+482
View File
@@ -0,0 +1,482 @@
//! 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 (POST /image/exif/date, ticket
/// #115): the wall the user picked, interpreted in their display zone.
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>) -> String {
match stored.map(str::trim).filter(|s| !s.is_empty()) {
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)
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).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]
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 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!(
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();
}
}
+113
View File
@@ -292,12 +292,29 @@ 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.
pub original_date_taken: Option<i64>, pub original_date_taken: Option<i64>,
/// When `date_taken_source = "manual"`, the prior source. /// When `date_taken_source = "manual"`, the prior source.
pub original_date_taken_source: Option<String>, pub original_date_taken_source: Option<String>,
/// When `date_taken_source = "manual"`, the prior `date_utc` snapshot
/// (the new-contract triple the revert restores alongside the legacy
/// pair above).
pub original_date_utc: Option<i64>,
/// When `date_taken_source = "manual"`, the prior camera offset.
pub original_camera_offset_minutes: Option<i32>,
/// When `date_taken_source = "manual"`, the prior tz source.
pub original_date_taken_tz_source: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -383,8 +400,14 @@ 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,
original_date_utc: exif.original_date_utc,
original_camera_offset_minutes: exif.original_camera_offset_minutes,
original_date_taken_tz_source: exif.original_date_taken_tz_source,
} }
} }
} }
@@ -428,6 +451,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 +594,86 @@ 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()),
original_date_utc: None,
original_camera_offset_minutes: None,
original_date_taken_tz_source: None,
}
}
/// /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");
}
}
+107 -21
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:
@@ -563,25 +568,32 @@ pub trait ExifDao: Sync + Send {
model_version: Option<&str>, model_version: Option<&str>,
) -> Result<Vec<(String, Vec<u8>)>, DbError>; ) -> Result<Vec<(String, Vec<u8>)>, DbError>;
/// Operator-driven date_taken override (POST /image/exif/date). Snapshots /// Operator-driven date_taken override (POST /image/exif/date).
/// the prior `(date_taken, date_taken_source)` into the `original_*` /// `date_taken` is the naive-as-UTC epoch the UI sent; the legacy
/// pair on first override, then writes the new value with /// column stores it verbatim (byte-identical to the pre-rework
/// `date_taken_source = 'manual'`. Subsequent overrides keep the /// behavior). The new-contract triple (ADR-0001, ticket #115) is
/// original snapshot intact so a single revert restores the resolver /// computed from the same value as a wall clock in `display_tz` —
/// result, not whatever override was set just before. Returns the /// the user's display zone — with `date_taken_source = 'manual'`.
/// post-update row. /// Snapshots the prior legacy pair AND prior new-contract triple into
/// the `original_*` columns on first override; subsequent overrides
/// keep the original snapshot intact so a single revert restores the
/// resolver result, not whatever override was set just before.
/// Returns the post-update row.
fn set_manual_date_taken( fn set_manual_date_taken(
&mut self, &mut self,
context: &opentelemetry::Context, context: &opentelemetry::Context,
library_id: i32, library_id: i32,
rel_path: &str, rel_path: &str,
date_taken: i64, date_taken: i64,
display_tz: &str,
) -> Result<ImageExif, DbError>; ) -> Result<ImageExif, DbError>;
/// Revert a manual override (POST /image/exif/date/clear): restore /// Revert a manual override (POST /image/exif/date/clear): restore
/// `date_taken` + `date_taken_source` from the `original_*` snapshot, /// `date_taken` + `date_taken_source` AND the new-contract triple
/// then null both originals. No-op (returns current row unchanged) when /// (`date_utc` / `camera_offset_minutes` / `date_taken_tz_source`,
/// no override is active. /// ADR-0001 ticket #115) from the `original_*` snapshots, then null
/// all five. No-op (returns current row unchanged) when no override is
/// active.
fn clear_manual_date_taken( fn clear_manual_date_taken(
&mut self, &mut self,
context: &opentelemetry::Context, context: &opentelemetry::Context,
@@ -1426,6 +1438,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 +1452,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 {
@@ -1626,6 +1647,7 @@ impl ExifDao for SqliteExifDao {
library_id_val: i32, library_id_val: i32,
rel_path_val: &str, rel_path_val: &str,
date_taken_val: i64, date_taken_val: i64,
display_tz_val: &str,
) -> Result<ImageExif, DbError> { ) -> Result<ImageExif, DbError> {
trace_db_call(context, "update", "set_manual_date_taken", |_span| { trace_db_call(context, "update", "set_manual_date_taken", |_span| {
use schema::image_exif::dsl::*; use schema::image_exif::dsl::*;
@@ -1638,18 +1660,51 @@ impl ExifDao for SqliteExifDao {
let current = let current =
locate_image_exif_row(connection.deref_mut(), library_id_val, rel_path_val)?; locate_image_exif_row(connection.deref_mut(), library_id_val, rel_path_val)?;
// Snapshot only on first override. Subsequent overrides keep // Snapshot both pairs only on first override. Subsequent
// the original snapshot intact so a single revert restores // overrides keep the original snapshot intact so a single
// the resolver-derived value, not the prior override. // revert restores the resolver-derived value, not the prior
let (orig_dt, orig_src) = if current.original_date_taken.is_none() { // override.
(current.date_taken, current.date_taken_source.clone()) let (orig_dt, orig_src, orig_dt_utc, orig_offset, orig_tz) =
if current.original_date_taken.is_none() {
(
current.date_taken,
current.date_taken_source.clone(),
current.date_utc,
current.camera_offset_minutes,
current.date_taken_tz_source.clone(),
)
} else { } else {
( (
current.original_date_taken, current.original_date_taken,
current.original_date_taken_source.clone(), current.original_date_taken_source.clone(),
current.original_date_utc,
current.original_camera_offset_minutes,
current.original_date_taken_tz_source.clone(),
) )
}; };
// The user's wall clock reinterpreted in their display zone
// (ADR-0001, ticket #115). `manual_date_resolution` is the
// pure seam; `None` (unreachable in practice —
// `display_zone_for` always yields a parseable zone) keeps
// the resolver's existing triple while the legacy columns
// still update.
let (new_utc, new_offset, new_tz) = match crate::camera_tz::manual_date_resolution(
Some(display_tz_val),
date_taken_val,
) {
Some((utc, offset)) => (
Some(utc),
Some(offset),
Some(crate::camera_tz::TzSource::Manual.as_str().to_string()),
),
None => (
current.date_utc,
current.camera_offset_minutes,
current.date_taken_tz_source.clone(),
),
};
// Update by primary key so we hit the exact row we read, // Update by primary key so we hit the exact row we read,
// even when the lookup fell back across libraries / slash forms. // even when the lookup fell back across libraries / slash forms.
let row_id = current.id; let row_id = current.id;
@@ -1659,6 +1714,12 @@ impl ExifDao for SqliteExifDao {
date_taken_source.eq(Some("manual".to_string())), date_taken_source.eq(Some("manual".to_string())),
original_date_taken.eq(orig_dt), original_date_taken.eq(orig_dt),
original_date_taken_source.eq(orig_src), original_date_taken_source.eq(orig_src),
original_date_utc.eq(orig_dt_utc),
original_camera_offset_minutes.eq(orig_offset),
original_date_taken_tz_source.eq(orig_tz),
date_utc.eq(new_utc),
camera_offset_minutes.eq(new_offset),
date_taken_tz_source.eq(new_tz),
)) ))
.execute(connection.deref_mut()) .execute(connection.deref_mut())
.map_err(|e| anyhow::Error::from(e).context("update_failed"))?; .map_err(|e| anyhow::Error::from(e).context("update_failed"))?;
@@ -1704,8 +1765,14 @@ impl ExifDao for SqliteExifDao {
.set(( .set((
date_taken.eq(current.original_date_taken), date_taken.eq(current.original_date_taken),
date_taken_source.eq(current.original_date_taken_source.clone()), date_taken_source.eq(current.original_date_taken_source.clone()),
date_utc.eq(current.original_date_utc),
camera_offset_minutes.eq(current.original_camera_offset_minutes),
date_taken_tz_source.eq(current.original_date_taken_tz_source.clone()),
original_date_taken.eq::<Option<i64>>(None), original_date_taken.eq::<Option<i64>>(None),
original_date_taken_source.eq::<Option<String>>(None), original_date_taken_source.eq::<Option<String>>(None),
original_date_utc.eq::<Option<i64>>(None),
original_camera_offset_minutes.eq::<Option<i32>>(None),
original_date_taken_tz_source.eq::<Option<String>>(None),
)) ))
.execute(connection.deref_mut()) .execute(connection.deref_mut())
.map_err(|e| anyhow::Error::from(e).context("update_failed"))?; .map_err(|e| anyhow::Error::from(e).context("update_failed"))?;
@@ -2547,6 +2614,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 +2765,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 +2802,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(
&ctx(),
1,
"main/x.jpg",
1700000000,
"exiftool",
Some(1700000000),
None,
Some("utc_direct"),
)
.unwrap(); .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));
+31
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,21 @@ 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>,
/// Snapshot of the prior `date_utc` taken on first manual override.
/// NULL when no override is active. POST /image/exif/date/clear
/// restores the new-contract triple from these columns.
pub original_date_utc: Option<i64>,
/// Snapshot of the prior `camera_offset_minutes` (first override).
pub original_camera_offset_minutes: Option<i32>,
/// Snapshot of the prior `date_taken_tz_source` (first override).
pub original_date_taken_tz_source: Option<String>,
} }
#[derive(Insertable, Clone)] #[derive(Insertable, Clone)]
@@ -549,6 +575,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 +588,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>,
} }
+7
View File
@@ -140,6 +140,12 @@ 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>,
original_date_utc -> Nullable<BigInt>,
original_camera_offset_minutes -> Nullable<Integer>,
original_date_taken_tz_source -> Nullable<Text>,
} }
} }
@@ -286,6 +292,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()));
} }
} }
+765 -150
View File
File diff suppressed because it is too large. Load diff
+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);
}
} }
+25
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,12 @@ 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,
original_date_utc: None,
original_camera_offset_minutes: None,
original_date_taken_tz_source: None,
} }
} }
@@ -1554,6 +1563,12 @@ 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(),
original_date_utc: data.date_utc,
original_camera_offset_minutes: data.camera_offset_minutes,
original_date_taken_tz_source: data.date_taken_tz_source.clone(),
}) })
} }
@@ -1602,6 +1617,12 @@ 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(),
original_date_utc: data.date_utc,
original_camera_offset_minutes: data.camera_offset_minutes,
original_date_taken_tz_source: data.date_taken_tz_source.clone(),
}) })
} }
@@ -1726,6 +1747,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(())
} }
@@ -1736,6 +1760,7 @@ mod tests {
library_id: i32, library_id: i32,
rel_path: &str, rel_path: &str,
date_taken: i64, date_taken: i64,
_display_tz: &str,
) -> Result<crate::database::models::ImageExif, DbError> { ) -> Result<crate::database::models::ImageExif, DbError> {
// Mock — files.rs tests don't exercise the date-override endpoints. // Mock — files.rs tests don't exercise the date-override endpoints.
// Returning a synthetic row keeps the trait satisfied without // Returning a synthetic row keeps the trait satisfied without
+48 -8
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 = {
@@ -818,10 +826,14 @@ pub async fn get_full_exif(
} }
} }
/// Body for `POST /image/exif/date` — operator-driven date_taken override. /// Body for `POST /image/exif/date` — operator-driven date override.
/// `date_taken` is unix seconds (matches `image_exif.date_taken`'s convention /// `date_taken` is unix seconds: the wall the user picked rendered in their
/// — naive local reinterpreted as UTC, not real UTC; the Apollo client passes /// display zone, encoded naive-as-UTC (the Apollo client passes through the
/// through the same value the photo carousel rendered before edit). /// same value the photo carousel rendered before edit). The legacy
/// `date_taken` column stores it verbatim; the new-contract triple
/// (`date_utc` / `camera_offset_minutes` / `date_taken_tz_source =
/// 'manual'`, ADR-0001 ticket #115) is derived from it as a wall clock in
/// the user's display zone.
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct SetDateRequest { struct SetDateRequest {
path: String, path: String,
@@ -902,9 +914,29 @@ pub async fn set_image_date(
// doesn't end up with a backslash variant that misses the row. // doesn't end up with a backslash variant that misses the row.
let normalized_path = body.path.replace('\\', "/"); let normalized_path = body.path.replace('\\', "/");
// The user's display zone — the wall the UI sent is rendered in this
// zone (ADR-0001, ticket #115). NULL / unparseable prefs fall back to
// the app default inside display_zone_for.
let display_tz = {
let mut prefs_dao = app_state
.user_ai_prefs_dao
.lock()
.expect("Unable to lock UserAiPrefsDao");
match prefs_dao.get_prefs(&span_context) {
Ok(Some(prefs)) => crate::camera_tz::display_zone_for(prefs.display_tz.as_deref()),
_ => crate::camera_tz::display_zone_for(None),
}
};
let updated = { let updated = {
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao"); let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
dao.set_manual_date_taken(&span_context, library.id, &normalized_path, body.date_taken) dao.set_manual_date_taken(
&span_context,
library.id,
&normalized_path,
body.date_taken,
&display_tz,
)
}; };
match updated { match updated {
@@ -1143,8 +1175,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 +1203,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.
+6 -2
View File
@@ -63,11 +63,15 @@ where
/// ``` /// ```
/// use image_api::utils::retry_with_backoff_if; /// use image_api::utils::retry_with_backoff_if;
/// ///
/// fn is_transient(e: &DbError) -> bool { /* ... */ } /// fn is_transient(e: &std::io::Error) -> bool {
/// // Only retry connection-class failures; a NotFound is deterministic.
/// e.kind() == std::io::ErrorKind::ConnectionRefused
/// }
/// ///
/// let result = retry_with_backoff_if("some-write", 4, is_transient, || { /// let result = retry_with_backoff_if("some-write", 4, is_transient, || {
/// Ok::<_, anyhow::Error>(42) /// Ok::<i32, _>(42)
/// }); /// });
/// assert!(result.is_ok_and(|v| v == 42));
/// ``` /// ```
pub fn retry_with_backoff_if<F, T, E, U: Fn(&E) -> bool>( pub fn retry_with_backoff_if<F, T, E, U: Fn(&E) -> bool>(
label: &str, label: &str,
+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");