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:
commit
467abd41d5
25 files changed
+2017
-196
No files matched your search
@@ -10,6 +10,7 @@ database/target
|
||||
/tmp
|
||||
/docs/*
|
||||
!/docs/agents/
|
||||
!/docs/adr/
|
||||
/specs
|
||||
|
||||
# Default ignored files
|
||||
|
||||
@@ -380,9 +380,21 @@ land on this date?". Order:
|
||||
QuickTime/MP4 (`MediaCreateDate`, `TrackCreateDate`, `CreateDate`),
|
||||
Apple's `ContentCreateDate`, MakerNote sub-IFDs. Required for
|
||||
videos to land a real date. Single-file at ingest; the per-tick
|
||||
drain feeds the whole batch through one `exiftool -@ -` subprocess.
|
||||
Degrades silently when `exiftool` isn't on PATH (resolver caches the
|
||||
"available" check via `OnceLock`).
|
||||
drain feeds the whole batch through one `exiftool -j -q -G1 -@ -`
|
||||
subprocess (raw strings + group prefixes, parsed in Rust — no
|
||||
`-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`
|
||||
matches screenshot, chat-export, and timestamp-named patterns.
|
||||
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`
|
||||
partial index), runs the waterfall batch via `resolve_dates_batch`,
|
||||
and writes results via the `backfill_date_taken` DAO method (touches
|
||||
only `date_taken` + `date_taken_source` so EXIF / hash / perceptual
|
||||
columns are preserved). Resolved rows — including the ones the
|
||||
`date_taken` + `date_taken_source` plus the new-contract
|
||||
`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:
|
||||
the resolver is deterministic on file bytes + filename + fs metadata,
|
||||
so re-running on the same inputs lands on the same source every time.
|
||||
|
||||
+64
@@ -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
@@ -779,6 +779,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -1552,6 +1562,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
@@ -2070,6 +2089,7 @@ dependencies = [
|
||||
"blake3",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"clap",
|
||||
"diesel",
|
||||
"diesel_migrations",
|
||||
@@ -2103,6 +2123,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tzf-rs",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
@@ -2886,6 +2907,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "phf_codegen"
|
||||
version = "0.10.0"
|
||||
@@ -2957,6 +2987,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "pin-project"
|
||||
version = "1.1.12"
|
||||
@@ -3041,6 +3080,12 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pqueue"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2145d14f09d5fc7fe7134b556146599c2929af5b1f1e3a0ecc9d582a9b85e4d"
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -3460,6 +3505,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -4314,6 +4368,23 @@ version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
|
||||
+3
-1
@@ -25,7 +25,7 @@ futures = "0.3.5"
|
||||
jsonwebtoken = "9.3.0"
|
||||
serde = "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"
|
||||
diesel_migrations = "2.2.0"
|
||||
chrono = "0.4"
|
||||
@@ -67,6 +67,8 @@ bk-tree = "0.5"
|
||||
async-trait = "0.1"
|
||||
indicatif = "0.17"
|
||||
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.
|
||||
# Linux/macOS use the system library (faster builds, smaller binary).
|
||||
|
||||
@@ -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;
|
||||
@@ -3378,6 +3378,12 @@ mod tests {
|
||||
original_date_taken_source: None,
|
||||
clip_embedding: 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");
|
||||
assert_eq!(out.as_deref(), Some("2021-08-15"));
|
||||
|
||||
@@ -192,6 +192,9 @@ pub fn backfill_missing_date_taken(
|
||||
rel_path,
|
||||
rd.timestamp,
|
||||
rd.source.as_str(),
|
||||
rd.date_utc,
|
||||
rd.camera_offset_minutes,
|
||||
rd.tz_source.map(|t| t.as_str()),
|
||||
) {
|
||||
Ok(()) => {
|
||||
backfilled += 1;
|
||||
@@ -616,6 +619,9 @@ mod 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");
|
||||
|
||||
@@ -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
@@ -292,12 +292,29 @@ pub struct ExifMetadata {
|
||||
/// `"exif" | "exiftool" | "filename" | "fs_time" | "manual"`. NULL when
|
||||
/// `date_taken` itself is NULL.
|
||||
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.
|
||||
/// Used by the UI's revert affordance and to label "manually overridden;
|
||||
/// originally X" in the details modal.
|
||||
pub original_date_taken: Option<i64>,
|
||||
/// When `date_taken_source = "manual"`, the prior source.
|
||||
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)]
|
||||
@@ -383,8 +400,14 @@ impl From<ImageExif> for ExifMetadata {
|
||||
},
|
||||
date_taken: exif.date_taken,
|
||||
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_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_longitude: Option<f64>,
|
||||
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)]
|
||||
@@ -564,3 +594,86 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
}
|
||||
+113
-27
@@ -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:
|
||||
@@ -563,25 +568,32 @@ pub trait ExifDao: Sync + Send {
|
||||
model_version: Option<&str>,
|
||||
) -> Result<Vec<(String, Vec<u8>)>, DbError>;
|
||||
|
||||
/// Operator-driven date_taken override (POST /image/exif/date). Snapshots
|
||||
/// the prior `(date_taken, date_taken_source)` into the `original_*`
|
||||
/// pair on first override, then writes the new value with
|
||||
/// `date_taken_source = 'manual'`. 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.
|
||||
/// Operator-driven date_taken override (POST /image/exif/date).
|
||||
/// `date_taken` is the naive-as-UTC epoch the UI sent; the legacy
|
||||
/// column stores it verbatim (byte-identical to the pre-rework
|
||||
/// behavior). The new-contract triple (ADR-0001, ticket #115) is
|
||||
/// computed from the same value as a wall clock in `display_tz` —
|
||||
/// the user's display zone — with `date_taken_source = 'manual'`.
|
||||
/// 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(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
library_id: i32,
|
||||
rel_path: &str,
|
||||
date_taken: i64,
|
||||
display_tz: &str,
|
||||
) -> Result<ImageExif, DbError>;
|
||||
|
||||
/// Revert a manual override (POST /image/exif/date/clear): restore
|
||||
/// `date_taken` + `date_taken_source` from the `original_*` snapshot,
|
||||
/// then null both originals. No-op (returns current row unchanged) when
|
||||
/// no override is active.
|
||||
/// `date_taken` + `date_taken_source` AND the new-contract triple
|
||||
/// (`date_utc` / `camera_offset_minutes` / `date_taken_tz_source`,
|
||||
/// 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(
|
||||
&mut self,
|
||||
context: &opentelemetry::Context,
|
||||
@@ -1426,6 +1438,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 +1452,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 {
|
||||
@@ -1626,6 +1647,7 @@ impl ExifDao for SqliteExifDao {
|
||||
library_id_val: i32,
|
||||
rel_path_val: &str,
|
||||
date_taken_val: i64,
|
||||
display_tz_val: &str,
|
||||
) -> Result<ImageExif, DbError> {
|
||||
trace_db_call(context, "update", "set_manual_date_taken", |_span| {
|
||||
use schema::image_exif::dsl::*;
|
||||
@@ -1638,16 +1660,49 @@ impl ExifDao for SqliteExifDao {
|
||||
let current =
|
||||
locate_image_exif_row(connection.deref_mut(), library_id_val, rel_path_val)?;
|
||||
|
||||
// Snapshot only on first override. Subsequent overrides keep
|
||||
// the original snapshot intact so a single revert restores
|
||||
// the resolver-derived value, not the prior override.
|
||||
let (orig_dt, orig_src) = if current.original_date_taken.is_none() {
|
||||
(current.date_taken, current.date_taken_source.clone())
|
||||
} else {
|
||||
(
|
||||
current.original_date_taken,
|
||||
current.original_date_taken_source.clone(),
|
||||
)
|
||||
// Snapshot both pairs only on first override. Subsequent
|
||||
// overrides keep the original snapshot intact so a single
|
||||
// revert restores the resolver-derived value, not the prior
|
||||
// override.
|
||||
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 {
|
||||
(
|
||||
current.original_date_taken,
|
||||
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,
|
||||
@@ -1659,6 +1714,12 @@ impl ExifDao for SqliteExifDao {
|
||||
date_taken_source.eq(Some("manual".to_string())),
|
||||
original_date_taken.eq(orig_dt),
|
||||
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())
|
||||
.map_err(|e| anyhow::Error::from(e).context("update_failed"))?;
|
||||
@@ -1704,8 +1765,14 @@ impl ExifDao for SqliteExifDao {
|
||||
.set((
|
||||
date_taken.eq(current.original_date_taken),
|
||||
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_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())
|
||||
.map_err(|e| anyhow::Error::from(e).context("update_failed"))?;
|
||||
@@ -2547,6 +2614,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 +2765,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 +2802,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,21 @@ 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>,
|
||||
/// 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)]
|
||||
@@ -549,6 +575,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 +588,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,12 @@ 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>,
|
||||
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>,
|
||||
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()));
|
||||
}
|
||||
}
|
||||
+766
-151
File diff suppressed because it is too large.
Load diff
+103
@@ -25,6 +25,20 @@ pub struct ExifData {
|
||||
pub shutter_speed: Option<String>,
|
||||
pub iso: Option<i32>,
|
||||
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
|
||||
@@ -331,12 +345,41 @@ pub fn extract_exif_from_path(path: &Path) -> Result<ExifData> {
|
||||
Tag::DateTime | Tag::DateTimeOriginal => {
|
||||
if data.date_taken.is_none() {
|
||||
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
|
||||
if let Some(lat) = extract_gps_coordinate(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef) {
|
||||
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> {
|
||||
let coord_field = exif.get_field(coord_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() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1281,6 +1281,9 @@ pub async fn list_exif_summary(
|
||||
gps_latitude: r.gps_latitude.map(f64::from),
|
||||
gps_longitude: r.gps_longitude.map(f64::from),
|
||||
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();
|
||||
|
||||
@@ -1513,6 +1516,12 @@ mod tests {
|
||||
original_date_taken_source: None,
|
||||
clip_embedding: 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,
|
||||
clip_embedding: 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,
|
||||
clip_embedding: 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,
|
||||
_date_taken: i64,
|
||||
_source: &str,
|
||||
_date_utc: Option<i64>,
|
||||
_camera_offset_minutes: Option<i32>,
|
||||
_date_taken_tz_source: Option<&str>,
|
||||
) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -1736,6 +1760,7 @@ mod tests {
|
||||
library_id: i32,
|
||||
rel_path: &str,
|
||||
date_taken: i64,
|
||||
_display_tz: &str,
|
||||
) -> Result<crate::database::models::ImageExif, DbError> {
|
||||
// Mock — files.rs tests don't exercise the date-override endpoints.
|
||||
// Returning a synthetic row keeps the trait satisfied without
|
||||
|
||||
+48
-8
@@ -642,7 +642,10 @@ pub async fn set_image_gps(
|
||||
// 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
|
||||
// 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 {
|
||||
library_id: resolved_library.id,
|
||||
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),
|
||||
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_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 = {
|
||||
@@ -818,10 +826,14 @@ pub async fn get_full_exif(
|
||||
}
|
||||
}
|
||||
|
||||
/// Body for `POST /image/exif/date` — operator-driven date_taken override.
|
||||
/// `date_taken` is unix seconds (matches `image_exif.date_taken`'s convention
|
||||
/// — naive local reinterpreted as UTC, not real UTC; the Apollo client passes
|
||||
/// through the same value the photo carousel rendered before edit).
|
||||
/// Body for `POST /image/exif/date` — operator-driven date override.
|
||||
/// `date_taken` is unix seconds: the wall the user picked rendered in their
|
||||
/// display zone, encoded naive-as-UTC (the Apollo client passes through the
|
||||
/// 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)]
|
||||
struct SetDateRequest {
|
||||
path: String,
|
||||
@@ -902,9 +914,29 @@ pub async fn set_image_date(
|
||||
// doesn't end up with a backslash variant that misses the row.
|
||||
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 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 {
|
||||
@@ -1143,8 +1175,10 @@ pub async fn upload_image(
|
||||
}
|
||||
};
|
||||
let perceptual = perceptual_hash::compute(&uploaded_path);
|
||||
let resolved_date =
|
||||
date_resolver::resolve_date_taken(&uploaded_path, exif_data.date_taken);
|
||||
let resolved_date = date_resolver::resolve_date_taken(
|
||||
&uploaded_path,
|
||||
date_resolver::exif_prior_from(&exif_data).as_ref(),
|
||||
);
|
||||
let insert_exif = InsertImageExif {
|
||||
library_id: target_library.id,
|
||||
file_path: relative_path.clone(),
|
||||
@@ -1169,6 +1203,12 @@ pub async fn upload_image(
|
||||
phash_64: perceptual.map(|h| h.phash_64),
|
||||
dhash_64: perceptual.map(|h| h.dhash_64),
|
||||
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() {
|
||||
|
||||
@@ -6,6 +6,7 @@ extern crate diesel;
|
||||
pub mod ai;
|
||||
pub mod auth;
|
||||
pub mod bin_progress;
|
||||
pub mod camera_tz;
|
||||
pub mod cleanup;
|
||||
pub mod clip_search;
|
||||
pub mod clip_watch;
|
||||
|
||||
@@ -31,6 +31,10 @@ use log::{error, info};
|
||||
mod ai;
|
||||
mod auth;
|
||||
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_watch;
|
||||
mod content_hash;
|
||||
|
||||
@@ -103,6 +103,7 @@ fn capture_prefs(
|
||||
),
|
||||
library: Some(library),
|
||||
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}"))
|
||||
@@ -351,6 +352,12 @@ pub struct CreateReelRequest {
|
||||
pub span: Option<MemoriesSpan>,
|
||||
#[serde(default)]
|
||||
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)]
|
||||
pub library: Option<String>,
|
||||
/// Cloned TTS voice for the narration; server default when omitted.
|
||||
|
||||
+6
-2
@@ -63,11 +63,15 @@ where
|
||||
/// ```
|
||||
/// 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, || {
|
||||
/// 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>(
|
||||
label: &str,
|
||||
|
||||
+13
-2
@@ -787,10 +787,16 @@ pub fn process_new_files(
|
||||
// computed above) → exiftool fallback for videos / MakerNote /
|
||||
// QuickTime → filename regex → earliest_fs_time. Source is
|
||||
// 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(
|
||||
&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 {
|
||||
@@ -827,6 +833,11 @@ pub fn process_new_files(
|
||||
phash_64: perceptual.map(|h| h.phash_64),
|
||||
dhash_64: perceptual.map(|h| h.dhash_64),
|
||||
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");
|
||||
|
||||
Reference in new issue
Block a user