Switch read paths to the true-instant date contract (ticket #119)

- New src/date_view.rs: pure read-side functions (row_true_instant,
  row_display_wall, row_display_day, filename_true_instant) applying the
  per-source convention table for unconverted rows and the new-contract
  triple for converted rows (ADR-0001).
- /memories: grouping on the row's true instant rendered in the owner's
  display zone (prefs -> app default), not the client device offset.
  SQL is a coarse interval pre-filter over the full years-back window;
  exact month-day/week/month matching across past years happens in Rust
  (memories::day_in_span, sqlite_w_week).
- Reels: ReelSelector::Memories carries an IANA display_tz instead of a
  fixed minute offset; pre-gen params read REEL_PREGEN_DISPLAY_TZ; the
  tz_offset_minutes ledger column stores the zone offset at render time.
- Insights: prompt-facing dates render as the display wall; the
  proximity timestamp and chat context block use the true instant;
  InsightGenerator reads the owner's display zone from prefs per call.
- GpsPhotoSummary carries the new-contract triple alongside the frozen
  legacy date_taken.

Closes #119
This commit is contained in:
Cameron Cordes committed 2026-09-26 19:28:26 -04:00
1 parent cd375f18db
commit af87765bbb
13 files changed
+1590 -301

No files matched your search

+185 -17
View File
@@ -1,5 +1,5 @@
use anyhow::{Result, anyhow, bail};
use chrono::Utc;
use chrono::{TimeZone, Utc};
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use std::collections::HashMap;
@@ -1175,7 +1175,12 @@ impl InsightChatService {
let image_base64: Option<String> = self.generator.load_image_as_base64(&normalized).ok();
let exif = self.generator.fetch_exif(&normalized);
let date_taken_str = resolve_date_taken_for_context(&exif, &normalized);
let date_taken_str = resolve_date_taken_for_context(
&exif,
&normalized,
self.generator.display_zone(),
crate::camera_tz::server_zone(),
);
let gps = exif
.as_ref()
.and_then(|e| match (e.gps_latitude, e.gps_longitude) {
@@ -1644,7 +1649,12 @@ impl InsightChatService {
// would otherwise default to today's date and miss every
// historical photo).
let exif = self.generator.fetch_exif(&normalized);
let date_taken_str = resolve_date_taken_for_context(&exif, &normalized);
let date_taken_str = resolve_date_taken_for_context(
&exif,
&normalized,
self.generator.display_zone(),
crate::camera_tz::server_zone(),
);
let gps = exif
.as_ref()
.and_then(|e| match (e.gps_latitude, e.gps_longitude) {
@@ -2020,24 +2030,33 @@ fn build_bootstrap_system_message(
}
/// Resolve a human-readable `YYYY-MM-DD` date string for the photo
/// context block. Waterfall: EXIF `date_taken` → filename pattern →
/// `None`. The fs-time fallback that `generate_agentic_insight_for_photo`
/// context block (ADR-0001). Waterfall: the row's display wall (new
/// contract when converted, per-source convention while unconverted) →
/// filename pattern (wall patterns as-is, unix-timestamp patterns in the
/// display zone) → `None`. The fs-time fallback that insight generation
/// uses is intentionally NOT applied here — for chat we'd rather show
/// "unknown" than a misleading inode mtime as the photo's date.
fn resolve_date_taken_for_context(
exif: &Option<crate::database::models::ImageExif>,
file_path: &str,
display_zone: chrono_tz::Tz,
server_zone: Option<chrono_tz::Tz>,
) -> Option<String> {
let from_exif = exif
.as_ref()
.and_then(|e| e.date_taken)
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
.map(|dt| dt.format("%Y-%m-%d").to_string());
if from_exif.is_some() {
return from_exif;
if let Some(e) = exif
&& let Some(wall) = crate::date_view::row_display_wall(e, display_zone, server_zone)
{
return Some(wall.format("%Y-%m-%d").to_string());
}
crate::memories::extract_date_from_filename(file_path)
.map(|fd| fd.legacy_datetime().format("%Y-%m-%d").to_string())
crate::memories::extract_date_from_filename(file_path).and_then(|fd| {
let wall = match &fd {
crate::memories::FilenameDate::Wall(w) => *w,
crate::memories::FilenameDate::Utc(dt) => display_zone
.timestamp_opt(dt.timestamp(), 0)
.single()?
.naive_local(),
};
Some(wall.format("%Y-%m-%d").to_string())
})
}
/// Validate a stored→effective backend transition for a chat continuation.
@@ -3385,7 +3404,12 @@ mod tests {
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",
chrono_tz::Tz::America__New_York,
Some(chrono_tz::Tz::America__New_York),
);
assert_eq!(out.as_deref(), Some("2021-08-15"));
}
@@ -3394,13 +3418,157 @@ mod tests {
// memories::extract_date_from_filename requires date+time in
// the filename — date-only patterns aren't matched. Use the
// canonical screenshot pattern for the regression case.
let out = resolve_date_taken_for_context(&None, "Screenshot_2014-06-01-20-44-50.png");
let out = resolve_date_taken_for_context(
&None,
"Screenshot_2014-06-01-20-44-50.png",
chrono_tz::Tz::America__New_York,
Some(chrono_tz::Tz::America__New_York),
);
assert_eq!(out.as_deref(), Some("2014-06-01"));
}
#[test]
fn date_taken_for_context_returns_none_when_neither_source() {
let out = resolve_date_taken_for_context(&None, "DSC_5171.JPG");
let out = resolve_date_taken_for_context(
&None,
"DSC_5171.JPG",
chrono_tz::Tz::America__New_York,
Some(chrono_tz::Tz::America__New_York),
);
assert!(out.is_none());
}
/// AC-4 / AC-5: a Japan-offset (+9 h) photo anchored at local 21:00.
/// The AI is told the camera wall clock (instant + offset — a midnight
/// rollover relative to the owner's display zone is expected and
/// correct), while the SMS/location/calendar windows anchor on the
/// true instant with zero offset math.
#[test]
fn japan_offset_row_ai_date_is_camera_wall_and_anchor_unshifted() {
use chrono::NaiveDate;
// 2024-07-05 23:30 UTC = 2024-07-06 08:30 JST = 2024-07-05 19:30 EDT.
let instant = NaiveDate::from_ymd_opt(2024, 7, 5)
.unwrap()
.and_hms_opt(23, 30, 0)
.unwrap()
.and_utc();
let date_utc = instant.timestamp();
let row = crate::database::models::ImageExif {
id: 0,
library_id: 1,
file_path: "tokyo.jpg".to_string(),
camera_make: None,
camera_model: None,
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,
date_taken: Some(date_utc),
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(date_utc),
camera_offset_minutes: Some(540),
date_taken_tz_source: Some("offset_tag".to_string()),
original_date_utc: None,
original_camera_offset_minutes: None,
original_date_taken_tz_source: None,
};
let ny = chrono_tz::Tz::America__New_York;
// The prompt/UI date is the camera wall: 2024-07-06 08:30 JST —
// one day AFTER the owner's display-zone date (midnight rollover
// is correct here, the AI must see what the camera saw).
let ai_date = resolve_date_taken_for_context(&Some(row.clone()), "tokyo.jpg", ny, Some(ny));
assert_eq!(ai_date.as_deref(), Some("2024-07-06"));
// The proximity windows (SMS / location / calendar) anchor on the
// true instant — no +9 h, no -4 h, nothing.
let anchor = crate::date_view::row_true_instant(&row, ny, Some(ny));
assert_eq!(anchor, Some(date_utc));
}
/// AC-6: an unconverted container video row — the AI context date is
/// the display-zone rendering of the true instant (the raw UTC creation
/// time), matching the metadata/UI date. No 4–5 h offset, no midnight
/// rollover relative to the UTC-naive reading.
#[test]
fn unconverted_video_row_ai_date_matches_display_wall() {
use chrono::NaiveDate;
// The container's raw creation string is "2024-07-06 01:30:00"
// (a true UTC instant). The old server parsed that naive string
// as server-local (EDT) → legacy epoch = 05:30 UTC. The AI must
// be told the owner's evening — July 5 — not July 6 (the UTC
// date the old code would have rendered).
let legacy = NaiveDate::from_ymd_opt(2024, 7, 6)
.unwrap()
.and_hms_opt(5, 30, 0)
.unwrap()
.and_utc()
.timestamp();
let row = Some(crate::database::models::ImageExif {
id: 0,
library_id: 1,
file_path: "movie.mp4".to_string(),
camera_make: None,
camera_model: None,
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,
date_taken: Some(legacy),
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("exiftool".to_string()),
original_date_taken: None,
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,
});
let ny = chrono_tz::Tz::America__New_York;
let ai_date = resolve_date_taken_for_context(&row, "movie.mp4", ny, Some(ny));
assert_eq!(ai_date.as_deref(), Some("2024-07-05"));
}
}
+89 -59
View File
@@ -1,12 +1,13 @@
use anyhow::Result;
use base64::Engine as _;
use chrono::{DateTime, Local, NaiveDate, Utc};
use chrono::{DateTime, Local, NaiveDate, TimeZone, Utc};
use image::ImageFormat;
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use serde::Deserialize;
use std::fs::File;
use std::io::Cursor;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use crate::ai::apollo_client::{ApolloClient, ApolloPlace};
@@ -23,7 +24,7 @@ use crate::database::{
LocationHistoryDao, SearchHistoryDao,
};
use crate::libraries::Library;
use crate::memories::{FilenameDate, extract_date_from_filename};
use crate::memories::extract_date_from_filename;
use crate::otel::global_tracer;
use crate::tags::TagDao;
use crate::utils::{earliest_fs_time, normalize_path, retry_with_backoff, retry_with_backoff_if};
@@ -161,6 +162,10 @@ pub struct InsightGenerator {
// the per-persona "reviewed-only" strict-mode toggle).
persona_dao: Arc<Mutex<Box<dyn crate::database::PersonaDao>>>,
/// Owner display-zone prefs (ADR-0001): prompt dates and the chat
/// context block render in this zone.
user_ai_prefs_dao: Arc<Mutex<Box<dyn crate::database::UserAiPrefsDao>>>,
libraries: Vec<Library>,
}
@@ -201,6 +206,7 @@ impl InsightGenerator {
face_dao: Arc<Mutex<Box<dyn crate::faces::FaceDao>>>,
knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>>,
persona_dao: Arc<Mutex<Box<dyn crate::database::PersonaDao>>>,
user_ai_prefs_dao: Arc<Mutex<Box<dyn crate::database::UserAiPrefsDao>>>,
libraries: Vec<Library>,
) -> Self {
Self {
@@ -219,10 +225,86 @@ impl InsightGenerator {
face_dao,
knowledge_dao,
persona_dao,
user_ai_prefs_dao,
libraries,
}
}
/// The owner's display zone (ADR-0001): prefs `display_tz` when set and
/// valid, the app default otherwise. Read per call so a pref change
/// takes effect without a restart; a DB error degrades to the default.
pub fn display_zone(&self) -> chrono_tz::Tz {
let name = {
let stored = self
.user_ai_prefs_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_prefs(&opentelemetry::Context::new()).ok().flatten())
.and_then(|p| p.display_tz);
crate::camera_tz::display_zone_for(stored.as_deref())
};
chrono_tz::Tz::from_str(&name).unwrap_or(chrono_tz::Tz::America__New_York)
}
/// The photo's date anchor pair (ADR-0001), shared by the non-agentic
/// and agentic insight paths:
/// - the **true instant** — the row's new-contract instant when present,
/// the legacy value under its per-source convention while unconverted,
/// then filename / fs-time / now — used as the zero-shift anchor for
/// the SMS / location / calendar proximity windows;
/// - the **prompt-facing display wall** — the row's display wall (camera
/// wall when the row carries an offset, display-zone rendering
/// otherwise) — the date the AI is told, matching what the owner sees.
fn resolve_date_anchor(
&self,
exif: &Option<crate::database::models::ImageExif>,
file_path: &str,
log_label: &str,
) -> (i64, NaiveDate) {
let display_zone = self.display_zone();
let server_zone = crate::camera_tz::server_zone();
let timestamp = exif
.as_ref()
.and_then(|e| crate::date_view::row_true_instant(e, display_zone, server_zone))
.unwrap_or_else(|| {
log::warn!("No date in EXIF for {}, trying filename", file_path);
extract_date_from_filename(file_path)
.and_then(|fd| crate::date_view::filename_true_instant(&fd, server_zone))
.or_else(|| {
let full_path = self.resolve_full_path(file_path)?;
File::open(&full_path)
.and_then(|f| f.metadata())
.inspect_err(|e| {
log::warn!(
"Failed to get file timestamp for {} {}: {}",
log_label,
file_path,
e
)
})
.ok()
.and_then(|m| earliest_fs_time(&m))
.map(|t| DateTime::<Utc>::from(t).timestamp())
})
.unwrap_or_else(|| Utc::now().timestamp())
});
let date_taken = exif
.as_ref()
.and_then(|e| crate::date_view::row_display_wall(e, display_zone, server_zone))
.map(|w| w.date())
.or_else(|| {
display_zone
.timestamp_opt(timestamp, 0)
.single()
.map(|dt| dt.date_naive())
})
.unwrap_or_else(|| Utc::now().date_naive());
(timestamp, date_taken)
}
/// Accessor for the insight DAO (used by async job completion to
/// look up the stored insight id).
pub fn insight_dao(&self) -> &Arc<Mutex<Box<dyn InsightDao>>> {
@@ -1055,35 +1137,8 @@ impl InsightGenerator {
.map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))?
};
// Get full timestamp for proximity-based message filtering
let timestamp = if let Some(ts) = exif.as_ref().and_then(|e| e.date_taken) {
ts
} else {
log::warn!("No date_taken in EXIF for {}, trying filename", file_path);
extract_date_from_filename(&file_path)
.map(FilenameDate::legacy_timestamp)
.or_else(|| {
let full_path = self.resolve_full_path(&file_path)?;
File::open(&full_path)
.and_then(|f| f.metadata())
.inspect_err(|e| {
log::warn!(
"Failed to get file timestamp for insight {}: {}",
file_path,
e
)
})
.ok()
.and_then(|m| earliest_fs_time(&m))
.map(|t| DateTime::<Utc>::from(t).timestamp())
})
.unwrap_or_else(|| Utc::now().timestamp())
};
let date_taken = DateTime::from_timestamp(timestamp, 0)
.map(|dt| dt.date_naive())
.unwrap_or_else(|| Utc::now().date_naive());
// True-instant anchor + prompt-facing display wall (ADR-0001).
let (timestamp, date_taken) = self.resolve_date_anchor(&exif, &file_path, "insight");
// 3. Extract contact name from file path
let contact = Self::extract_contact_from_path(&file_path);
@@ -4147,34 +4202,9 @@ Return ONLY the summary, nothing else."#,
.map_err(|e| anyhow::anyhow!("Failed to get EXIF: {:?}", e))?
};
// 4. Extract timestamp and contact
let timestamp = if let Some(ts) = exif.as_ref().and_then(|e| e.date_taken) {
ts
} else {
log::warn!("No date_taken in EXIF for {}, trying filename", file_path);
extract_date_from_filename(&file_path)
.map(FilenameDate::legacy_timestamp)
.or_else(|| {
let full_path = self.resolve_full_path(&file_path)?;
File::open(&full_path)
.and_then(|f| f.metadata())
.inspect_err(|e| {
log::warn!(
"Failed to get file timestamp for agentic insight {}: {}",
file_path,
e
)
})
.ok()
.and_then(|m| earliest_fs_time(&m))
.map(|t| DateTime::<Utc>::from(t).timestamp())
})
.unwrap_or_else(|| Utc::now().timestamp())
};
let date_taken = DateTime::from_timestamp(timestamp, 0)
.map(|dt| dt.date_naive())
.unwrap_or_else(|| Utc::now().date_naive());
// 4. Extract date and contact. The agentic prompt only needs the
// display wall (ADR-0001) — its proximity windows are tool-driven.
let (_, date_taken) = self.resolve_date_anchor(&exif, &file_path, "agentic insight");
let contact = Self::extract_contact_from_path(&file_path);
log::info!("Agentic: date_taken={}, contact={:?}", date_taken, contact);
+4
View File
@@ -185,6 +185,9 @@ async fn main() -> anyhow::Result<()> {
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
let face_dao: Arc<Mutex<Box<dyn FaceDao>>> =
Arc::new(Mutex::new(Box::new(SqliteFaceDao::new())));
let user_ai_prefs_dao: Arc<Mutex<Box<dyn image_api::database::UserAiPrefsDao>>> = Arc::new(
Mutex::new(Box::new(image_api::database::SqliteUserAiPrefsDao::new())),
);
let persona_dao: Arc<Mutex<Box<dyn image_api::database::PersonaDao>>> = Arc::new(Mutex::new(
Box::new(image_api::database::SqlitePersonaDao::new()),
));
@@ -208,6 +211,7 @@ async fn main() -> anyhow::Result<()> {
face_dao,
knowledge_dao,
persona_dao,
user_ai_prefs_dao,
all_libs.clone(),
);
+8
View File
@@ -428,7 +428,15 @@ pub struct GpsPhotoSummary {
pub path: String,
pub lat: f64,
pub lon: f64,
/// Legacy mixed-convention date (frozen for existing rows; ADR-0001).
pub date_taken: Option<i64>,
/// True instant (epoch seconds), when the row has been converted.
pub date_utc: Option<i64>,
/// Camera offset in minutes from UTC at capture, when known.
pub camera_offset_minutes: Option<i32>,
/// Which evidence pinned the instant (`offset_tag`, `gps`,
/// `assumed_server`, `utc_direct`, `manual`).
pub date_taken_tz_source: Option<String>,
}
#[derive(Debug, Serialize)]
+179 -124
View File
@@ -9,21 +9,6 @@ use crate::database::models::{
};
use crate::otel::trace_db_call;
/// Decoded shape for `get_memories_in_window`'s raw `sql_query`. Diesel's
/// query DSL doesn't expose strftime, so the memories filter is hand-
/// written SQL — but the returned columns are simple enough that a small
/// `QueryableByName` struct suffices, kept private to this module.
#[derive(diesel::QueryableByName)]
#[allow(dead_code)] // fields read via Diesel's QueryableByName derive
struct MemoriesWindowRow {
#[diesel(sql_type = diesel::sql_types::Text)]
rel_path: String,
#[diesel(sql_type = diesel::sql_types::BigInt)]
date_taken: i64,
#[diesel(sql_type = diesel::sql_types::BigInt)]
last_modified: i64,
}
/// Wire shape for a single member of a duplicate group, returned by
/// `list_duplicates_*` and `lookup_duplicate_row`. Carries everything
/// the Apollo modal needs to render a member tile and its meta line —
@@ -441,7 +426,18 @@ pub trait ExifDao: Sync + Send {
context: &opentelemetry::Context,
base_path: &str,
recursive: bool,
) -> Result<Vec<(String, f64, f64, Option<i64>)>, DbError>;
) -> Result<
Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)>,
DbError,
>;
/// Return rows that still lack a `content_hash`, oldest first. Used by
/// the `backfill_hashes` binary to batch through the historical
@@ -652,34 +648,34 @@ pub trait ExifDao: Sync + Send {
rel_path: &str,
) -> Result<ImageExif, DbError>;
/// Single-query backend for `/memories`. Returns
/// `(rel_path, date_taken, last_modified)` for rows in `library_id`
/// whose `date_taken` falls within `[now - years_back y, now]` and
/// whose calendar position matches the request's span:
/// - `"day"` — same month + day-of-month (any year)
/// - `"week"` — same week-of-year (SQLite `%W`, Monday-anchored —
/// close to but not exactly ISO week 8601; the boundary cases
/// at year-start/end can shift by ±1 vs the prior request-time
/// `iso_week()` filter)
/// - `"month"` — same month (any year)
/// Coarse pre-filter for the `/memories` span query (ADR-0001).
///
/// `tz_offset_minutes` is applied to both sides of the strftime
/// comparison so the calendar match is in the user's local time.
/// Backed by the `(library_id, date_taken)` index.
/// The span feature matches the same calendar position (month-day /
/// week-of-year / month) across every past year, so this query only
/// bounds the rows by epoch windows; the exact span-day match happens
/// in the caller (`memories::day_in_span`), because it needs the
/// owner's display zone.
///
/// This is the single-SQL replacement for the EXIF-loop +
/// WalkDir-fallback that powered `/memories` previously; it's
/// correct only because the canonical-date waterfall at ingest
/// (`crate::date_resolver`) populates `date_taken` for every row
/// it can resolve.
/// Eligibility:
/// - `date_taken` must be set and sit inside the unconverted band
/// `[unconv_lo, unconv_hi]` (a ±15 h margin around the years-back
/// window that covers every per-source legacy convention, and which
/// also caps how far into the future a row may sit);
/// - converted rows (`date_utc IS NOT NULL`) additionally need
/// `date_utc` inside `[conv_lo, conv_hi]` — the true-instant
/// window; unconverted rows pass on the band alone.
///
/// Returns full rows so the caller can read the date triple
/// (`date_utc` / `camera_offset_minutes` / `date_taken_tz_source`).
fn get_memories_in_window(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
span_token: &str,
years_back: i32,
tz_offset_minutes: i32,
) -> Result<Vec<(String, i64, i64)>, DbError>;
conv_lo: i64,
conv_hi: i64,
unconv_lo: i64,
unconv_hi: i64,
) -> Result<Vec<ImageExif>, DbError>;
/// Return image rows that have a `content_hash` but no `phash_64`,
/// oldest first. Used by the `backfill_perceptual_hash` binary.
@@ -1293,12 +1289,24 @@ impl ExifDao for SqliteExifDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
#[allow(clippy::type_complexity)]
fn get_all_with_gps(
&mut self,
context: &opentelemetry::Context,
base_path: &str,
recursive: bool,
) -> Result<Vec<(String, f64, f64, Option<i64>)>, DbError> {
) -> Result<
Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)>,
DbError,
> {
trace_db_call(context, "query", "get_all_with_gps", |span| {
use opentelemetry::KeyValue;
use opentelemetry::trace::Span;
@@ -1334,10 +1342,19 @@ impl ExifDao for SqliteExifDao {
.load::<ImageExif>(connection)
.map_err(|e| anyhow::anyhow!("GPS query error: {}", e))?;
// Convert to tuple format (path, lat, lon, date_taken)
// Convert to tuple format (path, lat, lon, legacy date_taken,
// date_utc, camera_offset_minutes, date_taken_tz_source).
// Filter out any rows where GPS is still None (shouldn't happen due to filter)
// Cast f32 GPS values to f64 for API compatibility
let filtered: Vec<(String, f64, f64, Option<i64>)> = results
let filtered: Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)> = results
.into_iter()
.filter_map(|exif| {
if let (Some(lat_val), Some(lon_val)) = (exif.gps_latitude, exif.gps_longitude)
@@ -1347,6 +1364,9 @@ impl ExifDao for SqliteExifDao {
lat_val as f64,
lon_val as f64,
exif.date_taken,
exif.date_utc,
exif.camera_offset_minutes,
exif.date_taken_tz_source,
))
} else {
None
@@ -1937,51 +1957,27 @@ impl ExifDao for SqliteExifDao {
&mut self,
context: &opentelemetry::Context,
library_id: i32,
span_token: &str,
years_back: i32,
tz_offset_minutes: i32,
) -> Result<Vec<(String, i64, i64)>, DbError> {
conv_lo: i64,
conv_hi: i64,
unconv_lo: i64,
unconv_hi: i64,
) -> Result<Vec<ImageExif>, DbError> {
trace_db_call(context, "query", "get_memories_in_window", |_span| {
// strftime pattern is span-dependent; the rest of the WHERE
// clause is shared. Only `%m-%d`, `%W`, `%m` are accepted —
// anything else is a programmer error.
let pattern = match span_token {
"day" => "%m-%d",
"week" => "%W",
"month" => "%m",
_ => return Err(anyhow::anyhow!("invalid span token: {}", span_token)),
};
// SQLite's date modifiers want a string like `'-480 minutes'`
// (signed) or `'-15 years'`. Use the `+` flag so positive
// offsets render as `+480 minutes`.
let tz_modifier = format!("{:+} minutes", tz_offset_minutes);
let years_modifier = format!("-{} years", years_back);
let sql = format!(
"SELECT rel_path, date_taken, last_modified \
FROM image_exif \
WHERE library_id = ?1 \
AND date_taken IS NOT NULL \
AND date_taken <= unixepoch('now') \
AND date_taken >= unixepoch('now', ?2) \
AND strftime('{p}', date_taken, 'unixepoch', ?3) \
= strftime('{p}', 'now', ?3)",
p = pattern,
);
use crate::database::schema::image_exif::dsl as d;
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
diesel::sql_query(sql)
.bind::<diesel::sql_types::Integer, _>(library_id)
.bind::<diesel::sql_types::Text, _>(years_modifier)
.bind::<diesel::sql_types::Text, _>(tz_modifier)
.load::<MemoriesWindowRow>(connection.deref_mut())
.map(|rows| {
rows.into_iter()
.map(|r| (r.rel_path, r.date_taken, r.last_modified))
.collect()
})
d::image_exif
.filter(d::library_id.eq(library_id))
.filter(d::date_taken.is_not_null())
.filter(d::date_taken.ge(unconv_lo))
.filter(d::date_taken.le(unconv_hi))
.filter(
d::date_utc
.is_null()
.or(d::date_utc.ge(conv_lo).and(d::date_utc.le(conv_hi))),
)
.load::<ImageExif>(connection.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
@@ -2966,25 +2962,21 @@ mod exif_dao_tests {
assert_eq!(row.size_bytes, Some(1024));
}
/// Wide intervals that admit everything in the years-back window —
/// span-day matching happens in the caller, so tests that don't care
/// about the span pass these.
/// Mirrors the production caller (`memories::memories_span_windows`):
/// the converted window spans the full years-back horizon and the
/// unconverted band pads it by ±15 h — so far-future legacy values
/// are dropped at the DAO, not by the band.
fn wide_windows(now_ts: i64, years_back: i64) -> (i64, i64, i64, i64) {
let lo = now_ts - years_back * 366 * 86_400;
(lo, now_ts, lo - 15 * 3600, now_ts + 15 * 3600)
}
#[test]
fn get_memories_in_window_day_matches_only_same_md_in_year_window() {
fn get_memories_in_window_filters_future_no_date_and_years_back() {
let mut dao = setup_two_libraries();
// Anchor on a known date so the test is timezone-stable: insert
// rows whose date_taken IS the same wall-clock time as `now()`
// would have been some N years ago, and verify the day-span
// filter returns them. We can't bind 'now' from Rust, so instead
// we insert rows for the *current* day (offset by 365 days * N
// years) and rely on SQLite computing the same `%m-%d` for both
// sides of the equality. Using the unix-now-minus-365*N seconds
// approximation is good enough — leap years drift by ~one day
// every four years, but the test only checks day-of-year match
// for rows inserted "today minus N years (no leap correction)".
// To dodge the leap-year drift entirely, we use rows whose
// calendar date is read back from SQLite and we just check
// membership.
// 1y, 5y, 10y, 21y back from 'now':
let now_ts = chrono::Utc::now().timestamp();
let year_secs: i64 = 365 * 86_400;
insert_row_with_source(
@@ -2994,13 +2986,6 @@ mod exif_dao_tests {
Some(now_ts - year_secs),
Some("exif"),
);
insert_row_with_source(
&mut dao,
1,
"y5.jpg",
Some(now_ts - 5 * year_secs),
Some("exif"),
);
insert_row_with_source(
&mut dao,
1,
@@ -3027,20 +3012,15 @@ mod exif_dao_tests {
// No date — never returned regardless of source.
insert_row_with_source(&mut dao, 1, "nodate.jpg", None, None);
// Month span returns rows from the same calendar month over the
// window — y1, y5, y10 should all qualify (same month any year),
// y21 trims (out of years_back), future trims (> now), nodate
// never qualifies. Day-of-month leap drift means even with 365-
// day approximation a row may shift by one in either direction;
// month is the safer assertion under that approximation.
let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20);
let rows = dao
.get_memories_in_window(&ctx(), 1, "month", 20, 0)
.get_memories_in_window(&ctx(), 1, clo, chi, ulo, uhi)
.unwrap();
let paths: std::collections::HashSet<String> =
rows.into_iter().map(|(p, _, _)| p).collect();
rows.into_iter().map(|r| r.file_path).collect();
assert!(
paths.contains("y1.jpg") && paths.contains("y5.jpg") && paths.contains("y10.jpg"),
"month span should include all in-window rows: {:?}",
paths.contains("y1.jpg") && paths.contains("y10.jpg"),
"in-window rows should be returned: {:?}",
paths
);
assert!(
@@ -3067,23 +3047,98 @@ mod exif_dao_tests {
Some(now_ts - year),
Some("exif"),
);
let (clo, chi, ulo, uhi) = wide_windows(now_ts, 20);
let lib1 = dao
.get_memories_in_window(&ctx(), 1, "month", 20, 0)
.get_memories_in_window(&ctx(), 1, clo, chi, ulo, uhi)
.unwrap();
let lib2 = dao
.get_memories_in_window(&ctx(), 2, "month", 20, 0)
.get_memories_in_window(&ctx(), 2, clo, chi, ulo, uhi)
.unwrap();
assert_eq!(lib1.len(), 1);
assert_eq!(lib1[0].0, "main/x.jpg");
assert_eq!(lib1[0].file_path, "main/x.jpg");
assert_eq!(lib2.len(), 1);
assert_eq!(lib2[0].0, "archive/x.jpg");
assert_eq!(lib2[0].file_path, "archive/x.jpg");
}
#[test]
fn get_memories_in_window_rejects_unknown_span_token() {
fn get_memories_in_window_branches_converted_on_date_utc() {
let mut dao = setup_two_libraries();
let err = dao.get_memories_in_window(&ctx(), 1, "decade", 20, 0);
assert!(err.is_err());
let now_ts = chrono::Utc::now().timestamp();
let year = 365 * 86_400i64;
// A converted row whose `date_utc` sits INSIDE the conv interval
// but whose legacy `date_taken` sits OUTSIDE the unconv interval:
// it must still be returned (proof the converted branch won).
insert_row_with_source(&mut dao, 1, "conv.jpg", Some(now_ts - year), Some("exif"));
dao.backfill_date_taken(
&ctx(),
1,
"conv.jpg",
now_ts - year,
"exif",
Some(now_ts - year - 9000), // shifted true instant
None,
Some("assumed_server"),
)
.unwrap();
// An unconverted row: judged on legacy `date_taken` only.
insert_row_with_source(
&mut dao,
1,
"unconv.jpg",
Some(now_ts - year),
Some("fs_time"),
);
// A converted row OUTSIDE the conv interval: excluded even though
// its legacy value is inside the unconv band.
insert_row_with_source(
&mut dao,
1,
"conv-out.jpg",
Some(now_ts - year),
Some("exif"),
);
dao.backfill_date_taken(
&ctx(),
1,
"conv-out.jpg",
now_ts - year,
"exif",
Some(now_ts - 30 * year),
None,
Some("assumed_server"),
)
.unwrap();
// conv interval: a tight band around (now - year - 9000) that
// excludes (now - 30*year) and excludes the unconv row's legacy
// (now - year) is irrelevant — it's unconverted. The unconv band
// is wide for unconv.jpg.
let clo = now_ts - year - 86_400;
let chi = now_ts - year;
let ulo = now_ts - year - 86_400;
let uhi = now_ts;
let rows = dao
.get_memories_in_window(&ctx(), 1, clo, chi, ulo, uhi)
.unwrap();
let paths: std::collections::HashSet<String> =
rows.into_iter().map(|r| r.file_path).collect();
assert!(
paths.contains("conv.jpg"),
"converted row in conv band: {:?}",
paths
);
assert!(
paths.contains("unconv.jpg"),
"unconverted row in legacy band: {:?}",
paths
);
assert!(
!paths.contains("conv-out.jpg"),
"converted row outside conv band must be excluded: {:?}",
paths
);
}
}
+567
View File
@@ -0,0 +1,567 @@
//! Read-side views of the true-instant date contract (ADR-0001).
//!
//! [`crate::date_resolver`] is the ingest-side counterpart: it *writes* the
//! `(date_utc, camera_offset_minutes, date_taken_tz_source)` triple. This
//! module is what *reads* it. For a row — converted or not yet — it answers:
//!
//! 1. **What true instant does the row name?** — [`row_true_instant`].
//! Anchors time-window queries (SMS messages, location history, calendar
//! events) with zero offset arithmetic: the window is centered on the
//! instant, never on a wall clock.
//! 2. **What wall clock does the owner see?** — [`row_display_wall`].
//! The date insight prompts tell the model (camera wall clock when the
//! row carries an offset, display-zone rendering when it doesn't). Must
//! equal the wall the metadata endpoint reports, so prompt and UI agree.
//! 3. **Which owner day does the row land on?** — [`row_display_day`].
//! The grouping rule for memories buckets and reels: a photo taken in
//! Japan at 21:00 JST lands in the *owner's* day, not the camera's.
//!
//! Converted rows read the new triple directly. Unconverted rows (the
//! `backfill_utc_dates` drain has not reached them yet) fall back to the
//! legacy `date_taken` under its per-source convention (ADR-0001):
//!
//! | source | legacy holds |
//! |---|---|
//! | `exif` | camera wall clock, reinterpreted as UTC |
//! | `exiftool` (video) | the container's UTC-rendered naive, rendered in the server zone |
//! | `exiftool` (image) | camera wall clock, rendered in the server zone |
//! | `filename` (wall patterns) | wall clock, reinterpreted as UTC |
//! | `filename` (unix-timestamp) | a true instant |
//! | `fs_time` | a true instant |
//! | `manual` | the display-zone wall the operator picked, reinterpreted as UTC |
//!
//! so mixed rows mid-drain still group into the right day and the insight
//! prompts stay correct row-by-row as the drain converts.
use chrono::{NaiveDate, NaiveDateTime, TimeZone};
use chrono_tz::Tz;
use crate::camera_tz;
use crate::database::models::ImageExif;
/// Best-effort true instant (unix seconds) the row names.
///
/// `date_utc` when the drain has converted the row; otherwise derived from
/// the legacy value under its per-source convention. `server_zone` is the
/// fallback rung the drain would use without offset-tag or GPS evidence
/// (the camera was probably carried in the operator's zone); `display_zone`
/// interprets manual rows.
pub fn row_true_instant(row: &ImageExif, display_zone: Tz, server_zone: Option<Tz>) -> Option<i64> {
let legacy = row.date_taken?;
// Converted rows name their instant directly.
if let Some(utc) = row.date_utc {
return Some(utc);
}
match row.date_taken_source.as_deref() {
// Camera wall reinterpreted as UTC: the wall is the legacy value
// itself; the instant is that wall in the assumed zone.
Some("exif") => wall_in_zone(legacy, server_zone),
// exiftool rendered a naive string in the server zone. Videos hold
// the container's UTC-rendered creation time (a true instant behind
// one server-zone render); photos hold the camera wall.
Some("exiftool") => {
if crate::file_types::is_video_file(std::path::Path::new(&row.file_path)) {
// legacy = server.render(naive_utc) → recover naive_utc and
// read it back as UTC.
let naive = render_in_zone_fallback(legacy, server_zone);
Some(naive.and_utc().timestamp())
} else {
// legacy = server.render(wall); the instant is that wall in
// the server zone, which is `legacy` by construction.
Some(legacy)
}
}
// Filename class: wall patterns name a wall clock, the
// unix-timestamp pattern names a true instant directly.
Some("filename") => match crate::memories::match_filename_date(&row.file_path) {
Some(m) if matches!(m.date, crate::memories::FilenameDate::Wall(_)) => {
wall_in_zone(legacy, server_zone)
}
_ => Some(legacy),
},
Some("fs_time") => Some(legacy),
// The operator picked a display-zone wall; interpret it there.
Some("manual") => camera_tz::manual_date_resolution(Some(display_zone.name()), legacy)
.map(|(instant, _)| instant),
// Unknown / absent source: treat the legacy value as a true instant
// (the most common convention) rather than a wall clock.
_ => Some(legacy),
}
}
/// The wall clock the owner sees for this row — camera wall when the row
/// carries an offset (a Japan photo shows the Japan wall), display-zone
/// rendering when it doesn't. Unconverted rows use their per-source
/// convention (see module docs).
pub fn row_display_wall(
row: &ImageExif,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<NaiveDateTime> {
if let Some(utc) = row.date_utc {
// Converted: wall = instant + camera offset (offset = local − UTC).
if let Some(offset_min) = row.camera_offset_minutes {
let wall = chrono::DateTime::from_timestamp(utc + offset_min as i64 * 60, 0)?;
return Some(wall.naive_utc());
}
return render_in_zone_opt(utc, display_zone);
}
let legacy = row.date_taken?;
match row.date_taken_source.as_deref() {
// The wall clock itself, stored as naive-as-UTC.
Some("exif") => Some(legacy_as_naive(legacy)),
Some("exiftool") => {
if crate::file_types::is_video_file(std::path::Path::new(&row.file_path)) {
// No camera wall exists for a container; show the owner's
// zone rendering of the recovered instant.
let naive = render_in_zone_fallback(legacy, server_zone);
let instant = naive.and_utc().timestamp();
render_in_zone_opt(instant, display_zone)
} else {
// The camera wall, recovered from the server-zone render.
Some(render_in_zone_fallback(legacy, server_zone))
}
}
Some("filename") => match crate::memories::match_filename_date(&row.file_path) {
Some(m) if matches!(m.date, crate::memories::FilenameDate::Wall(_)) => {
Some(legacy_as_naive(legacy))
}
_ => render_in_zone_opt(legacy, display_zone),
},
Some("fs_time") => render_in_zone_opt(legacy, display_zone),
Some("manual") => Some(legacy_as_naive(legacy)),
_ => render_in_zone_opt(legacy, display_zone),
}
}
/// The true instant a filename-class date names, for callers that fall
/// back to the filename when the row carries no date at all: wall patterns
/// are read in `server_zone` (the zone the camera was probably in),
/// unix-timestamp patterns are true instants outright.
pub fn filename_true_instant(
fd: &crate::memories::FilenameDate,
server_zone: Option<Tz>,
) -> Option<i64> {
match fd {
crate::memories::FilenameDate::Utc(dt) => Some(dt.timestamp()),
crate::memories::FilenameDate::Wall(wall) => match server_zone {
Some(z) => z
.from_local_datetime(wall)
.single()
.map(|dt| dt.timestamp()),
None => Some(wall.and_utc().timestamp()),
},
}
}
/// The owner's calendar day for this row: the day of the true instant in
/// the owner's display zone. This is the grouping key — a photo taken in
/// Japan at 21:00 JST lands in the owner's day, not the camera's.
pub fn row_display_day(
row: &ImageExif,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<NaiveDate> {
let instant = row_true_instant(row, display_zone, server_zone)?;
Some(
display_zone
.timestamp_opt(instant, 0)
.single()?
.date_naive(),
)
}
/// Render a unix instant as a naive wall in `zone`.
fn render_in_zone_opt(instant: i64, zone: Tz) -> Option<NaiveDateTime> {
zone.timestamp_opt(instant, 0)
.single()
.map(|dt| dt.naive_local())
}
/// Render a unix instant as a naive wall in `zone`, falling back to the
/// naive-as-UTC reading when the zone is unknown.
fn render_in_zone_fallback(instant: i64, zone: Option<Tz>) -> NaiveDateTime {
match zone {
Some(z) => z
.timestamp_opt(instant, 0)
.single()
.map(|dt| dt.naive_local())
.unwrap_or_else(|| legacy_as_naive(instant)),
None => legacy_as_naive(instant),
}
}
/// Read a stored wall-as-UTC epoch back as its naive wall.
fn legacy_as_naive(legacy: i64) -> NaiveDateTime {
chrono::DateTime::from_timestamp(legacy, 0)
.expect("valid epoch")
.naive_utc()
}
/// The true instant of a wall clock assumed to have been read in `zone`;
/// naive-as-UTC when the zone is unknown.
fn wall_in_zone(legacy_wall_epoch: i64, zone: Option<Tz>) -> Option<i64> {
let wall = legacy_as_naive(legacy_wall_epoch);
match zone {
Some(z) => z
.from_local_datetime(&wall)
.single()
.map(|dt| dt.timestamp()),
None => Some(legacy_wall_epoch),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{NaiveDate, TimeZone};
use chrono_tz::Tz;
fn ny() -> Tz {
Tz::America__New_York
}
/// Build a minimal `ImageExif` row for date-field tests.
fn row(
path: &str,
date_taken: Option<i64>,
source: Option<&str>,
date_utc: Option<i64>,
offset: Option<i32>,
) -> ImageExif {
ImageExif {
id: 1,
library_id: 1,
file_path: path.to_string(),
camera_make: None,
camera_model: None,
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,
date_taken,
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: source.map(str::to_string),
original_date_taken: None,
original_date_taken_source: None,
clip_embedding: None,
clip_model_version: None,
date_utc,
camera_offset_minutes: offset,
date_taken_tz_source: None,
original_date_utc: None,
original_camera_offset_minutes: None,
original_date_taken_tz_source: None,
}
}
fn utc_epoch(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> i64 {
NaiveDate::from_ymd_opt(y, mo, d)
.unwrap()
.and_hms_opt(h, mi, 0)
.unwrap()
.and_utc()
.timestamp()
}
/// The AC-3 fixture: three rows naming the same true instant
/// (2024-07-05 01:30 UTC = 2024-07-04 21:30 EDT — the owner's day is
/// July 4), one per source convention. Server zone = display zone = NY.
fn ac3_fixtures() -> (ImageExif, ImageExif, ImageExif, i64) {
let instant = utc_epoch(2024, 7, 5, 1, 30);
let exif = row(
"photos/IMG_1234.jpg",
Some(utc_epoch(2024, 7, 4, 21, 30)), // wall 21:30 as naive-as-UTC
Some("exif"),
None,
None,
);
let fs = row(
"photos/scan.pdf.jpg",
Some(instant),
Some("fs_time"),
None,
None,
);
// Container: exiftool rendered the UTC naive "2024-07-05 01:30" in
// the server zone (EDT) → instant + 4h.
let container = row(
"videos/clip.mp4",
Some(instant + 4 * 3600),
Some("exiftool"),
None,
None,
);
(exif, fs, container, instant)
}
#[test]
fn ac3_same_instant_three_sources_group_into_one_owner_day() {
let (exif, fs, container, instant) = ac3_fixtures();
let d = ny();
for (name, r) in [
("exif", &exif),
("fs_time", &fs),
("exiftool-video", &container),
] {
assert_eq!(
row_true_instant(r, d, Some(d)),
Some(instant),
"{name}: wrong instant"
);
assert_eq!(
row_display_day(r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-04".to_string()),
"{name}: wrong owner day"
);
}
}
#[test]
fn ac3_old_client_offset_grouping_would_have_misplaced_the_container() {
// Regression guard for the double-shift: rendering the container's
// legacy value in the client (NY) offset lands on July 5, a day
// after the owner's.
let (_, _, container, _) = ac3_fixtures();
let legacy = container.date_taken.unwrap();
let old_grouping_day = chrono::FixedOffset::east_opt(-4 * 3600)
.unwrap()
.timestamp_opt(legacy, 0)
.single()
.unwrap()
.date_naive();
assert_eq!(old_grouping_day.to_string(), "2024-07-05");
}
#[test]
fn converted_row_with_offset_shows_camera_wall() {
// Japan (UTC+9) photo: instant 2024-07-05 12:00 UTC, wall 21:00 JST.
let r = row(
"japan/IMG_0001.jpg",
Some(utc_epoch(2024, 7, 5, 21, 0)),
Some("exif"),
Some(utc_epoch(2024, 7, 5, 12, 0)),
Some(9 * 60),
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-07-05 21:00:00".to_string())
);
// ...but groups into the owner's (NY) day: 12:00 UTC = 08:00 EDT.
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-05".to_string())
);
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 12, 0))
);
}
#[test]
fn converted_row_without_offset_renders_in_display_zone() {
// fs_time row, no camera offset: wall = display-zone rendering.
// 2024-01-15 04:30 UTC = 23:30 Jan 14 EST (DST-aware).
let r = row(
"fs/IMG_9.jpg",
Some(utc_epoch(2024, 1, 15, 4, 30)),
Some("fs_time"),
Some(utc_epoch(2024, 1, 15, 4, 30)),
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-01-14 23:30:00".to_string())
);
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-01-14".to_string())
);
}
#[test]
fn unconverted_exif_wall_is_the_legacy_naive_and_instant_uses_server_zone() {
// Wall 2024-01-15 21:30 (EST era), legacy = 21:30 naive-as-UTC.
// Instant = 21:30 EST = 2024-01-16 02:30 UTC (DST-aware, not +4h).
let r = row(
"photo.jpg",
Some(utc_epoch(2024, 1, 15, 21, 30)),
Some("exif"),
None,
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-01-15 21:30:00".to_string())
);
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 1, 16, 2, 30))
);
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-01-15".to_string())
);
}
#[test]
fn unconverted_exiftool_photo_wall_is_server_zone_render() {
// Camera wall 2024-07-04 12:00 rendered in server zone EDT →
// legacy = 16:00 UTC. The wall comes back out the other side.
let r = row(
"raw/IMG_5.NEF",
Some(utc_epoch(2024, 7, 4, 16, 0)),
Some("exiftool"),
None,
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-07-04 12:00:00".to_string())
);
// Instant = that wall in the server zone = the legacy value itself.
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 7, 4, 16, 0))
);
}
#[test]
fn unconverted_manual_row_interprets_wall_in_display_zone() {
// Operator picked 2024-07-04 21:30 in their display zone (NY/EDT):
// legacy = 21:30 naive-as-UTC, instant = 01:30 UTC July 5.
let r = row(
"manual.jpg",
Some(utc_epoch(2024, 7, 4, 21, 30)),
Some("manual"),
None,
None,
);
let d = ny();
assert_eq!(
row_display_wall(&r, d, Some(d)).map(|w| w.to_string()),
Some("2024-07-04 21:30:00".to_string())
);
assert_eq!(
row_true_instant(&r, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 1, 30))
);
assert_eq!(
row_display_day(&r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-04".to_string())
);
}
#[test]
fn unconverted_filename_wall_and_utc_patterns_diverge() {
// Wall pattern (screenshot): legacy = wall as naive-as-UTC.
let wall = row(
"Screenshot_2024-07-04-21-30-00.png",
Some(utc_epoch(2024, 7, 4, 21, 30)),
Some("filename"),
None,
None,
);
// Unix-timestamp pattern: legacy is the true instant itself.
let ts = row(
"1720128600.jpg",
Some(utc_epoch(2024, 7, 5, 1, 30)),
Some("filename"),
None,
None,
);
let d = ny();
assert_eq!(
row_true_instant(&wall, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 1, 30)),
"wall 21:30 EDT → 01:30 UTC"
);
assert_eq!(
row_true_instant(&ts, d, Some(d)),
Some(utc_epoch(2024, 7, 5, 1, 30)),
"timestamp pattern is already the instant"
);
// Both land on the owner's July 4.
for r in [&wall, &ts] {
assert_eq!(
row_display_day(r, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-04".to_string())
);
}
}
#[test]
fn display_day_is_dst_correct_across_the_year() {
// Same UTC instant, winter vs summer: the owner's day differs by
// the DST shift. 04:30 UTC = 23:30 previous day EST, 00:30 same
// day EDT.
let winter = row(
"w.jpg",
Some(utc_epoch(2024, 1, 15, 4, 30)),
Some("fs_time"),
Some(utc_epoch(2024, 1, 15, 4, 30)),
None,
);
let summer = row(
"s.jpg",
Some(utc_epoch(2024, 7, 15, 4, 30)),
Some("fs_time"),
Some(utc_epoch(2024, 7, 15, 4, 30)),
None,
);
let d = ny();
assert_eq!(
row_display_day(&winter, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-01-14".to_string())
);
assert_eq!(
row_display_day(&summer, d, Some(d)).map(|dt| dt.to_string()),
Some("2024-07-15".to_string())
);
}
#[test]
fn display_day_respects_a_non_new_york_display_zone() {
// Owner in Tokyo: 2024-07-05 01:30 UTC = 10:30 JST July 5 (not
// the July 4 a NY owner sees).
let (_, fs, _, instant) = ac3_fixtures();
let tokyo = Tz::Asia__Tokyo;
assert_eq!(
row_display_day(&fs, tokyo, Some(ny())).map(|dt| dt.to_string()),
Some("2024-07-05".to_string())
);
assert_eq!(row_true_instant(&fs, tokyo, Some(ny())), Some(instant));
}
}
+36 -11
View File
@@ -1142,12 +1142,25 @@ pub async fn get_gps_summary(
Ok(gps_data) => {
let mut photos: Vec<GpsPhotoSummary> = gps_data
.into_iter()
.map(|(path, lat, lon, date_taken)| GpsPhotoSummary {
path,
lat,
lon,
date_taken,
})
.map(
|(
path,
lat,
lon,
date_taken,
date_utc,
camera_offset_minutes,
date_taken_tz_source,
)| GpsPhotoSummary {
path,
lat,
lon,
date_taken,
date_utc,
camera_offset_minutes,
date_taken_tz_source,
},
)
.collect();
// Sort by date_taken based on request, defaulting to ascending (oldest to newest)
@@ -1695,7 +1708,18 @@ mod tests {
_context: &opentelemetry::Context,
_base_path: &str,
_recursive: bool,
) -> Result<Vec<(String, f64, f64, Option<i64>)>, DbError> {
) -> Result<
Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)>,
DbError,
> {
todo!()
}
@@ -1817,10 +1841,11 @@ mod tests {
&mut self,
_context: &opentelemetry::Context,
_library_id: i32,
_span_token: &str,
_years_back: i32,
_tz_offset_minutes: i32,
) -> Result<Vec<(String, i64, i64)>, DbError> {
_conv_lo: i64,
_conv_hi: i64,
_unconv_lo: i64,
_unconv_hi: i64,
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
Ok(Vec::new())
}
+1
View File
@@ -14,6 +14,7 @@ pub mod content_hash;
pub mod data;
pub mod database;
pub mod date_resolver;
pub mod date_view;
pub mod duplicates;
pub mod error;
pub mod exif;
+1
View File
@@ -41,6 +41,7 @@ mod content_hash;
mod data;
mod database;
mod date_resolver;
mod date_view;
mod duplicates;
mod error;
mod exif;
+452 -47
View File
@@ -1,12 +1,13 @@
use actix_web::web::Data;
use actix_web::{HttpRequest, HttpResponse, Responder, get, web};
use chrono::{DateTime, FixedOffset, Local, NaiveDate, NaiveDateTime, Utc};
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc};
use log::{debug, trace, warn};
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Mutex;
use crate::data::Claims;
@@ -110,7 +111,10 @@ pub enum MemoriesSpan {
#[derive(Deserialize)]
pub struct MemoriesRequest {
pub span: Option<MemoriesSpan>,
/// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for CET)
/// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for
/// CET). Accepted on the wire for old clients but no longer drives
/// grouping (ADR-0001) — grouping uses the owner's display zone.
#[allow(dead_code)]
pub timezone_offset_minutes: Option<i32>,
/// Optional library filter. Accepts a library id (e.g. "1") or name
/// (e.g. "main"). When omitted, results span all libraries.
@@ -120,11 +124,21 @@ pub struct MemoriesRequest {
#[derive(Debug, Serialize, Clone)]
pub struct MemoryItem {
pub path: String,
/// True instant (unix seconds) the memory names (ADR-0001) — the
/// grouping anchor. For unconverted rows this is *derived* from the
/// legacy value under its per-source convention, so it is the real
/// moment even while the `backfill_utc_dates` drain is still running.
pub created: Option<i64>,
pub modified: Option<i64>,
/// Id of the library this memory belongs to. Allows clients to show a
/// per-item source badge in union mode.
pub library_id: i32,
/// New-contract triple (ADR-0001). `date_utc` is `None` until the
/// `backfill_utc_dates` drain has converted the row; clients should
/// prefer it over `created` when present.
pub date_utc: Option<i64>,
pub camera_offset_minutes: Option<i32>,
pub date_taken_tz_source: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -132,6 +146,50 @@ pub struct MemoriesResponse {
pub items: Vec<MemoryItem>,
}
/// Epoch windows for the memories SQL pre-filter (ADR-0001).
///
/// The span feature matches the same calendar position (month-day /
/// week-of-year / month) across every past year, so the pre-filter must
/// cover the FULL years-back window — not just the current span window —
/// or past-year rows are dropped before [`day_in_span`] gets to match
/// them. Converted rows are tested on `date_utc` (the true instant);
/// unconverted rows on legacy `date_taken`, which sits up to ~15 h off
/// the instant under the per-source conventions (wall↔instant confusion
/// ≤ 12 h, zone range ≤ 2 h, DST margin 1 h) — hence the ±15 h band.
/// By construction the band subsumes the converted window, so the DAO's
/// single outer bound on `date_taken` never drops a converted row.
pub fn memories_span_windows(now_ts: i64, years_back: i64) -> (i64, i64, i64, i64) {
let years_lo = now_ts - years_back * 365 * 86_400;
(years_lo, now_ts, years_lo - 15 * 3600, now_ts + 15 * 3600)
}
/// True if `display_day` falls in the span (day/week/month) containing
/// `today` — both already in the owner's display zone. Mirrors the legacy
/// SQLite `strftime('%m-%d' | '%W' | '%m')` equality: weeks are
/// Monday-based and the days before the year's first Monday are week 00.
pub fn day_in_span(display_day: NaiveDate, today: NaiveDate, span: MemoriesSpan) -> bool {
match span {
MemoriesSpan::Day => {
display_day.month() == today.month() && display_day.day() == today.day()
}
MemoriesSpan::Week => sqlite_w_week(display_day) == sqlite_w_week(today),
MemoriesSpan::Month => display_day.month() == today.month(),
}
}
/// SQLite's `%W`: Monday-based week-of-year, `0` for the days before the
/// year's first Monday, `1` starting on the first Monday.
fn sqlite_w_week(d: NaiveDate) -> i32 {
let jan1 = NaiveDate::from_ymd_opt(d.year(), 1, 1).expect("Jan 1 exists");
let days_in_week0 = (7 - jan1.weekday().num_days_from_monday() as i64) % 7;
let days = (d - jan1).num_days();
if days < days_in_week0 {
0
} else {
((days - days_in_week0) / 7 + 1) as i32
}
}
/// A date carried by a filename, with its convention (ticket #116).
///
/// The two families of pattern make fundamentally different claims:
@@ -398,17 +456,6 @@ fn plausible_filename_date(dt: DateTime<Utc>) -> Option<DateTime<Utc>> {
}
}
/// Convert a `date_taken` Unix-seconds value to a `NaiveDate` in the
/// client's local time. Falls back to server-local when the client didn't
/// send a tz hint.
fn date_in_client_tz(timestamp: i64, client_timezone: Option<FixedOffset>) -> Option<NaiveDate> {
let dt = DateTime::from_timestamp(timestamp, 0)?;
Some(match client_timezone {
Some(tz) => dt.with_timezone(&tz).date_naive(),
None => dt.with_timezone(&Local).date_naive(),
})
}
/// Default lookback for `/memories`. The original 15-year cap pre-dated
/// most of the imported libraries; bumped to 20 so users with deeper
/// archives see those photos surface on the matching anniversary too.
@@ -430,23 +477,29 @@ pub async fn list_memories(
let span_mode = q.span.unwrap_or(MemoriesSpan::Day);
// The SQL filter expects a signed offset in minutes from UTC; default
// 0 (UTC) when the client didn't send a hint. We also keep a chrono
// `FixedOffset` for sorting/secondary-key date math in Rust below —
// anchoring both sides on the same value keeps "what SQL matched" and
// "what we sort by" consistent.
let tz_offset_minutes = q.timezone_offset_minutes.unwrap_or(0);
let client_timezone = q
.timezone_offset_minutes
.and_then(|offset_mins| FixedOffset::east_opt(offset_mins * 60));
// Grouping happens in the owner's display zone (prefs → app default),
// not the client's device offset (ADR-0001): a photo taken in Japan at
// 21:00 JST belongs to the owner's day, and a container's UTC-rendered
// creation time is a true instant. The legacy `timezone_offset_minutes`
// param is still accepted on the wire for old clients but no longer
// drives grouping.
let display_zone_name = {
let prefs = app_state
.user_ai_prefs_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_prefs(&span_context).ok().flatten());
crate::camera_tz::display_zone_for(prefs.as_ref().and_then(|p| p.display_tz.as_deref()))
};
let display_zone =
chrono_tz::Tz::from_str(&display_zone_name).unwrap_or(chrono_tz::Tz::America__New_York);
let items = match gather_memory_items(
&app_state,
&exif_dao,
&span_context,
span_mode,
tz_offset_minutes,
client_timezone,
display_zone,
q.library.as_deref(),
) {
Ok(items) => items,
@@ -462,7 +515,7 @@ pub async fn list_memories(
KeyValue::new("span", format!("{:?}", span_mode)),
KeyValue::new("years_back", DEFAULT_YEARS_BACK.to_string()),
KeyValue::new("result_count", items.len().to_string()),
KeyValue::new("tz_offset_minutes", tz_offset_minutes.to_string()),
KeyValue::new("display_tz", display_zone_name),
KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)),
],
);
@@ -474,29 +527,41 @@ pub async fn list_memories(
/// Resolve an "on this day/week/month across past years" window into an
/// ordered list of [`MemoryItem`]s. Shared by the `/memories` handler and the
/// memory-reel selector so both honour the same library resolution, per-library
/// exclusions, timezone handling, and sort order. Returns `Err(message)` only
/// when the `library` param is invalid (callers map that to 400); per-library
/// exclusions, and sort order. Returns `Err(message)` only when the
/// `library` param is invalid (callers map that to 400); per-library
/// query/lock failures are logged and skipped, matching the handler's
/// best-effort behaviour.
///
/// Grouping is done in the owner's display zone (ADR-0001), on the row's
/// *true* instant — so a container's UTC-rendered creation time and an
/// unconverted EXIF wall clock both land in the same day a photo taken
/// locally did. The SQL pass is an interval pre-filter; exact span-day
/// matching happens here in Rust via [`date_view`].
pub fn gather_memory_items(
app_state: &AppState,
exif_dao: &Mutex<Box<dyn ExifDao>>,
span_context: &opentelemetry::Context,
span_mode: MemoriesSpan,
tz_offset_minutes: i32,
client_timezone: Option<FixedOffset>,
display_zone: chrono_tz::Tz,
library_param: Option<&str>,
) -> Result<Vec<MemoryItem>, String> {
let span_token = match span_mode {
MemoriesSpan::Day => "day",
MemoriesSpan::Week => "week",
MemoriesSpan::Month => "month",
};
let years_back: i32 = DEFAULT_YEARS_BACK;
let years_back: i64 = DEFAULT_YEARS_BACK as i64;
let now = Utc::now();
let now_ts = now.timestamp();
let today_d = display_zone
.from_utc_datetime(&now.naive_utc())
.date_naive();
// The SQL pass is a coarse pre-filter over the FULL years-back window
// (not the current span window) because this feature matches the same
// month-day / week / month across every past year; [`day_in_span`] does
// the exact matching in Rust (ADR-0001).
let (conv_lo, conv_hi, unconv_lo, unconv_hi) = memories_span_windows(now_ts, years_back);
let server_zone = crate::camera_tz::server_zone();
debug!(
"gather_memory_items: span={:?} tz_offset_min={} years_back={}",
span_mode, tz_offset_minutes, years_back
"gather_memory_items: span={:?} display_zone={:?} years_back={}",
span_mode, display_zone, years_back
);
let library = crate::libraries::resolve_library_param_state(app_state, library_param)?;
@@ -518,9 +583,10 @@ pub fn gather_memory_items(
Ok(mut dao) => match dao.get_memories_in_window(
span_context,
lib.id,
span_token,
years_back,
tz_offset_minutes,
conv_lo,
conv_hi,
unconv_lo,
unconv_hi,
) {
Ok(rows) => rows,
Err(e) => {
@@ -537,28 +603,37 @@ pub fn gather_memory_items(
}
};
for (rel_path, date_taken_ts, last_modified_ts) in rows {
for row in rows {
// Apply per-library exclusions in Rust — they're a small
// set and pushing them into the SQL WHERE adds bind-param
// gymnastics with no measurable win at this scale.
let full_path = base.join(&rel_path);
let full_path = base.join(&row.file_path);
if path_excluder.is_excluded(&full_path) {
trace!("Memory excluded by PathExcluder: {:?}", full_path);
continue;
}
let Some(file_date) = date_in_client_tz(date_taken_ts, client_timezone) else {
let Some(display_day) =
crate::date_view::row_display_day(&row, display_zone, server_zone)
else {
continue;
};
if !day_in_span(display_day, today_d, span_mode) {
continue;
}
let created = crate::date_view::row_true_instant(&row, display_zone, server_zone);
memories_with_dates.push((
MemoryItem {
path: rel_path,
created: Some(date_taken_ts),
modified: Some(last_modified_ts),
path: row.file_path,
created,
modified: Some(row.last_modified),
library_id: lib.id,
date_utc: row.date_utc,
camera_offset_minutes: row.camera_offset_minutes,
date_taken_tz_source: row.date_taken_tz_source,
},
file_date,
display_day,
));
}
}
@@ -1038,6 +1113,9 @@ mod tests {
created: Some(jan_15_2024_9am),
modified: Some(jan_15_2024_9am),
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
),
@@ -1047,6 +1125,9 @@ mod tests {
created: Some(jan_15_2020_10am),
modified: Some(jan_15_2020_10am),
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(),
),
@@ -1056,6 +1137,9 @@ mod tests {
created: Some(jan_16_2021_8am),
modified: Some(jan_16_2021_8am),
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(),
),
@@ -1087,4 +1171,325 @@ mod tests {
assert_eq!(memories_with_dates[1].0.created.unwrap(), jan_15_2024_9am);
assert_eq!(memories_with_dates[2].0.created.unwrap(), jan_16_2021_8am);
}
// ---- Day-matching (ticket #119) ----
#[test]
fn span_windows_cover_the_full_years_back_range() {
// Regression guard for the "current span window only" bug: the
// day/week/month spans match across PAST YEARS, so the SQL
// pre-filter must span the whole years-back horizon. If this
// narrowed to the current day/week/month, /memories would return
// only photos taken right now.
let now = 1_700_000_000i64;
let (clo, chi, ulo, uhi) = memories_span_windows(now, 20);
let lo_bound = now - 20 * 365 * 86_400;
assert_eq!(clo, lo_bound, "converted window must start at the horizon");
assert_eq!(chi, now, "converted window must end at now");
assert_eq!(
ulo,
lo_bound - 15 * 3600,
"unconverted band must pad the horizon"
);
assert_eq!(uhi, now + 15 * 3600, "unconverted band must pad now");
// The band subsumes the converted window so the DAO's single
// outer bound on legacy date_taken never drops a converted row.
assert!(ulo <= clo && chi <= uhi);
}
#[test]
fn day_in_span_day_and_month() {
let today = NaiveDate::from_ymd_opt(2024, 7, 4).unwrap();
assert!(day_in_span(
NaiveDate::from_ymd_opt(2024, 7, 4).unwrap(),
today,
MemoriesSpan::Day
));
assert!(!day_in_span(
NaiveDate::from_ymd_opt(2024, 7, 5).unwrap(),
today,
MemoriesSpan::Day
));
assert!(day_in_span(
NaiveDate::from_ymd_opt(2020, 7, 4).unwrap(),
today,
MemoriesSpan::Day
));
assert!(day_in_span(
NaiveDate::from_ymd_opt(2024, 7, 30).unwrap(),
today,
MemoriesSpan::Month
));
assert!(!day_in_span(
NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
today,
MemoriesSpan::Month
));
}
#[test]
fn day_in_span_week_is_monday_based_with_week_zero() {
let monday_2024 = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(); // Mon
// Same week: Sunday Jul 7 is still week of Mon Jul 1.
assert!(day_in_span(
NaiveDate::from_ymd_opt(2024, 7, 7).unwrap(),
monday_2024,
MemoriesSpan::Week
));
// Next Monday is the next week.
assert!(!day_in_span(
NaiveDate::from_ymd_opt(2024, 7, 8).unwrap(),
monday_2024,
MemoriesSpan::Week
));
// Previous Sunday is the previous week.
assert!(!day_in_span(
NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
monday_2024,
MemoriesSpan::Week
));
// 2022 week 00: Jan 1 (Sat) and Jan 2 (Sun) share week 00; Jan 3
// (Mon) starts week 1.
let jan1_2022 = NaiveDate::from_ymd_opt(2022, 1, 1).unwrap();
assert!(day_in_span(
NaiveDate::from_ymd_opt(2022, 1, 2).unwrap(),
jan1_2022,
MemoriesSpan::Week
));
assert!(!day_in_span(
NaiveDate::from_ymd_opt(2022, 1, 3).unwrap(),
jan1_2022,
MemoriesSpan::Week
));
}
#[test]
fn sqlite_w_week_matches_known_values() {
// 2024: Jan 1 is a Monday → week 1 starts Jan 1.
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
1
);
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2024, 12, 30).unwrap()),
53
);
// 2022: Jan 1 is a Saturday → two-day week 00.
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 1).unwrap()),
0
);
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 2).unwrap()),
0
);
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 3).unwrap()),
1
);
// 2023: Jan 1 is a Sunday → one-day week 00.
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2023, 1, 1).unwrap()),
0
);
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2023, 1, 2).unwrap()),
1
);
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap()),
52
);
}
/// AC-2 / AC-3 / AC-7: seam test with mixed fixtures — a converted
/// row and two unconverted rows (EXIF wall convention + container UTC
/// convention) all group into the owner's display-zone month, and each
/// item carries its true instant under the per-source convention.
mod api {
use super::*;
use crate::database::SqliteExifDao;
use crate::database::models::InsertImageExif;
use crate::database::test::in_memory_db_connection;
fn seed_row(dao: &mut SqliteExifDao, rel: &str, date_taken: i64, source: &str) {
dao.store_exif(
&opentelemetry::Context::new(),
InsertImageExif {
library_id: 1,
file_path: rel.to_string(),
camera_make: None,
camera_model: None,
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,
date_taken: Some(date_taken),
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
date_taken_source: Some(source.to_string()),
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
)
.expect("insert row");
}
#[actix_rt::test]
async fn memories_mixed_rows_group_into_owner_month() {
let _ = env_logger::builder().is_test(true).try_init();
let state = AppState::test_state();
let zone = chrono_tz::Tz::America__New_York;
// Month span over the 5th of the CURRENT month in past years,
// at 12:00 naive: under all three conventions (camera wall in
// the server zone, container UTC, converted instant) the
// grouping day can't roll across midnight, so the fixture is
// stable across DST transitions and leap years.
let now = Utc::now();
let (month, year) = (now.month(), now.year());
let at_12 = |y: i32| {
NaiveDate::from_ymd_opt(y, month, 5)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap()
};
let mut dao = SqliteExifDao::from_connection(in_memory_db_connection());
// 1. Converted row: true instant 12:00 UTC (no camera offset).
let conv_instant = at_12(year - 3).and_utc().timestamp();
seed_row(&mut dao, "conv.jpg", conv_instant, "exif");
dao.backfill_date_taken(
&opentelemetry::Context::new(),
1,
"conv.jpg",
conv_instant,
"exif",
Some(conv_instant),
None,
Some("utc_direct"),
)
.expect("convert row");
// 2. Unconverted EXIF row: legacy is the camera wall read as
// UTC — the true moment is 12:00 in the server zone.
let exif_wall = at_12(year - 1);
let exif_legacy = exif_wall.and_utc().timestamp();
seed_row(&mut dao, "unconv-exif.jpg", exif_legacy, "exif");
// 3. Unconverted container row: legacy is the raw UTC creation
// time string rendered in the server zone (the old server
// read the epoch as a naive string and stamped it through
// its local zone). Pre-#115 this row double-shifted in
// /memories; it must now land in the same owner month as
// the other two.
let vid_legacy = at_12(year - 2).and_utc().timestamp();
seed_row(&mut dao, "unconv-vid.mp4", vid_legacy, "exiftool");
let exif_mutex: Mutex<Box<dyn ExifDao>> = Mutex::new(Box::new(dao));
let items = gather_memory_items(
&state,
&exif_mutex,
&opentelemetry::Context::new(),
MemoriesSpan::Month,
zone,
None,
)
.expect("gather");
let by_path = |p: &str| items.iter().find(|it| it.path == p);
// All three rows group into the same owner month (AC-2/AC-3).
let conv = by_path("conv.jpg").expect("converted row must be returned");
let exif_row =
by_path("unconv-exif.jpg").expect("unconverted EXIF row must be returned");
let vid =
by_path("unconv-vid.mp4").expect("unconverted container row must be returned");
// AC-1: the triple rides along — populated for the converted
// row, None for the unconverted ones.
assert_eq!(conv.date_utc, Some(conv_instant));
assert_eq!(conv.camera_offset_minutes, None);
assert_eq!(conv.date_taken_tz_source.as_deref(), Some("utc_direct"));
assert_eq!(exif_row.date_utc, None);
assert_eq!(exif_row.camera_offset_minutes, None);
assert_eq!(exif_row.date_taken_tz_source, None);
assert_eq!(vid.date_utc, None);
// AC-2: `created` is the true instant under each row's
// per-source convention. The server zone (the zone the old
// server rendered legacy values in) is a deploy property, so
// the expected values are derived from the convention, not
// hardcoded.
let server_zone = crate::camera_tz::server_zone();
let legacy_as_wall = |ts: i64| {
chrono::DateTime::from_timestamp(ts, 0)
.expect("valid epoch")
.naive_utc()
};
// exif: camera wall reinterpreted in the server zone.
let exif_expected = match server_zone {
Some(z) => z
.from_local_datetime(&legacy_as_wall(exif_legacy))
.single()
.expect("camera wall in server zone")
.timestamp(),
None => exif_legacy,
};
// exiftool video: raw UTC naive rendered in the server zone,
// read back as UTC.
let vid_expected = match server_zone {
Some(z) => z
.timestamp_opt(vid_legacy, 0)
.single()
.expect("server-zone render")
.naive_local()
.and_utc()
.timestamp(),
None => vid_legacy,
};
assert_eq!(conv.created, Some(conv_instant));
assert_eq!(exif_row.created, Some(exif_expected));
assert_eq!(vid.created, Some(vid_expected));
}
/// AC-1 (response shape): the serialized `/memories` item carries
/// the new-contract triple as explicit fields — null while the row
/// is unconverted.
#[test]
fn memories_response_shape_carries_triple() {
let item = MemoryItem {
path: "a.jpg".into(),
created: Some(1_700_000_000),
modified: Some(0),
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
};
let v: serde_json::Value =
serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap();
let it = &v["items"][0];
assert!(it.get("date_utc").is_some() && it["date_utc"].is_null());
assert!(
it.get("camera_offset_minutes").is_some() && it["camera_offset_minutes"].is_null()
);
assert!(it.get("date_taken_tz_source").is_some());
}
}
}
+32 -25
View File
@@ -417,9 +417,23 @@ pub async fn create_reel_handler(
let span = req.span.unwrap_or(MemoriesSpan::Day);
let max_segments = req.max_segments.unwrap_or(selector::DEFAULT_MAX_SEGMENTS);
// Grouping zone: explicit request param → stored pref → app default
// (ADR-0001). The legacy device-offset param is still mirrored into the
// frozen prefs column by `capture_prefs` for rollback but no longer
// drives selection.
let display_zone_name = {
let req_tz = req.display_tz.clone().filter(|s| !s.trim().is_empty());
let stored = app_state
.user_ai_prefs_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_prefs(&opentelemetry::Context::new()).ok().flatten())
.and_then(|p| p.display_tz);
crate::camera_tz::display_zone_for(req_tz.as_deref().or(stored.as_deref()))
};
let selector = ReelSelector::Memories {
span,
tz_offset_minutes: req.timezone_offset_minutes.unwrap_or(0),
display_tz: display_zone_name,
library: req.library.clone(),
max_segments,
};
@@ -965,23 +979,26 @@ pub(crate) fn secs_until_next_run_hour(now: chrono::DateTime<chrono::Local>, run
}
/// Load pre-gen parameters: tries the user_ai_prefs DB row first, falls back
/// to env vars, then to server-local defaults.
fn load_pregen_params(app_state: &AppState) -> (i32, Option<String>, String) {
/// to env vars, then to the app default display zone. Returns the display
/// zone as an IANA name (ADR-0001) — `REEL_PREGEN_DISPLAY_TZ` (e.g.
/// "America/New_York") overrides the stored pref when set. IANA zones are
/// DST-proof, unlike the old fixed-offset env vars.
fn load_pregen_params(app_state: &AppState) -> (String, Option<String>, String) {
// Try DB row first
if let Ok(mut dao) = app_state.user_ai_prefs_dao.lock() {
let ctx = opentelemetry::Context::new();
if let Ok(Some(prefs)) = dao.get_prefs(&ctx) {
let tz = prefs.tz_offset_minutes.unwrap_or_else(fixed_tz_offset);
let tz = crate::camera_tz::display_zone_for(prefs.display_tz.as_deref());
let voice = prefs.voice;
let library = prefs.library.unwrap_or_else(|| "all".to_string());
return (tz, voice, library);
}
}
// Fall back to env (explicit offset overrides auto-detect)
let tz = std::env::var("REEL_PREGEN_TZ_OFFSET_MINUTES")
// Fall back to env (explicit zone overrides auto-detect)
let tz = std::env::var("REEL_PREGEN_DISPLAY_TZ")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(fixed_tz_offset);
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| crate::camera_tz::display_zone_for(None));
let voice = std::env::var("REEL_PREGEN_VOICE").ok();
let library = std::env::var("REEL_PREGEN_LIBRARY")
.ok()
@@ -989,17 +1006,6 @@ fn load_pregen_params(app_state: &AppState) -> (i32, Option<String>, String) {
(tz, voice, library)
}
/// Fixed timezone offset: reads `REEL_PREGEN_TZ_FIXED_MINUTES` (e.g. "-480"
/// for US Eastern) when set, falling back to the system local offset. Using
/// a fixed offset avoids DST shifts changing the pre-gen schedule halfway
/// through the year.
fn fixed_tz_offset() -> i32 {
std::env::var("REEL_PREGEN_TZ_FIXED_MINUTES")
.ok()
.and_then(|v| v.trim().parse().ok())
.unwrap_or_else(|| chrono::Local::now().offset().local_minus_utc())
}
/// Spawn the nightly pre-generation scheduler. Runs behind `REEL_PREGEN_ENABLED`.
pub(crate) async fn spawn_pregen_scheduler(app_state: web::Data<AppState>) {
if std::env::var("REEL_PREGEN_ENABLED").ok() != Some("1".to_string()) {
@@ -1044,7 +1050,7 @@ async fn run_pregen_batch(app_state: &AppState) -> anyhow::Result<()> {
let (tz, voice, library) = load_pregen_params(app_state);
for span in spans {
if let Err(e) = pregen_one(app_state, span, tz, voice.clone(), &library).await {
if let Err(e) = pregen_one(app_state, span, &tz, voice.clone(), &library).await {
log::error!("Pre-gen failed for span={}: {}", span, e);
}
}
@@ -1056,7 +1062,7 @@ async fn run_pregen_batch(app_state: &AppState) -> anyhow::Result<()> {
async fn pregen_one(
app_state: &AppState,
span: &str,
tz: i32,
display_tz: &str,
voice: Option<String>,
library: &str,
) -> anyhow::Result<()> {
@@ -1069,7 +1075,7 @@ async fn pregen_one(
let selector = ReelSelector::Memories {
span: memories_span,
tz_offset_minutes: tz,
display_tz: display_tz.to_string(),
library: if library == "all" {
None
} else {
@@ -1179,7 +1185,8 @@ async fn pregen_one(
title,
media_count,
render_version: RENDER_VERSION as i32,
tz_offset_minutes: tz,
tz_offset_minutes: crate::camera_tz::display_zone_offset_minutes(display_tz, now)
.unwrap_or(0) as i32,
voice: voice.clone(),
generated_at: now,
},
@@ -1346,7 +1353,7 @@ mod tests {
fn day_selector() -> ReelSelector {
ReelSelector::Memories {
span: MemoriesSpan::Day,
tz_offset_minutes: 0,
display_tz: "America/New_York".to_string(),
library: None,
max_segments: 24,
}
@@ -1379,7 +1386,7 @@ mod tests {
// Span matters.
let week = ReelSelector::Memories {
span: MemoriesSpan::Week,
tz_offset_minutes: 0,
display_tz: "America/New_York".to_string(),
library: None,
max_segments: 24,
};
+34 -18
View File
@@ -9,9 +9,10 @@
//! runs in the background job.
use std::path::Path;
use std::str::FromStr;
use std::sync::Mutex;
use chrono::{DateTime, Datelike, FixedOffset};
use chrono::{Datelike, TimeZone};
use super::{PlannedBeat, ReelMeta, SegmentMedia};
use crate::database::{ExifDao, InsightDao};
@@ -64,7 +65,10 @@ pub fn budget_segments(requested_max: usize) -> usize {
pub enum ReelSelector {
Memories {
span: MemoriesSpan,
tz_offset_minutes: i32,
/// Owner's display zone as an IANA name (ADR-0001) — grouping is
/// zone-anchored, not device-offset-anchored, so DST shifts are
/// absorbed instead of silently re-bucketing the reel.
display_tz: String,
library: Option<String>,
max_segments: usize,
},
@@ -78,13 +82,13 @@ impl ReelSelector {
match self {
ReelSelector::Memories {
span,
tz_offset_minutes,
display_tz,
library,
max_segments,
} => format!(
"memories:span={:?}:tz={}:lib={}:max={}",
span,
tz_offset_minutes,
display_tz,
library.as_deref().unwrap_or("all"),
max_segments
),
@@ -281,24 +285,24 @@ pub fn resolve(
match selector {
ReelSelector::Memories {
span,
tz_offset_minutes,
display_tz,
library,
max_segments,
} => {
let client_tz = FixedOffset::east_opt(tz_offset_minutes * 60);
let zone =
chrono_tz::Tz::from_str(display_tz).unwrap_or(chrono_tz::Tz::America__New_York);
let items = memories::gather_memory_items(
app_state,
exif_dao,
span_context,
*span,
*tz_offset_minutes,
client_tz,
zone,
library.as_deref(),
)?;
// Split into photos and video clips; anything that's neither is
// dropped. Years span both, computed before the budget narrows it.
let years = distinct_years(&items, client_tz);
let years = distinct_years(&items, zone);
let meta = ReelMeta { span: *span, years };
let (photos, videos): (Vec<_>, Vec<_>) = items
@@ -321,15 +325,12 @@ pub fn resolve(
/// Distinct calendar years represented by the selected media, in the client's
/// timezone, ascending. Used to tell the scripter how far back the reel reaches.
fn distinct_years(items: &[memories::MemoryItem], tz: Option<FixedOffset>) -> Vec<i32> {
fn distinct_years(items: &[memories::MemoryItem], zone: chrono_tz::Tz) -> Vec<i32> {
let mut years: Vec<i32> = items
.iter()
.filter_map(|it| it.created)
.filter_map(|ts| DateTime::from_timestamp(ts, 0))
.map(|dt| match tz {
Some(off) => dt.with_timezone(&off).year(),
None => dt.year(),
})
.filter_map(|ts| zone.timestamp_opt(ts, 0).single())
.map(|dt| dt.year())
.collect();
years.sort_unstable();
years.dedup();
@@ -406,13 +407,13 @@ mod tests {
fn descriptor_is_stable_and_distinguishes_inputs() {
let a = ReelSelector::Memories {
span: MemoriesSpan::Day,
tz_offset_minutes: -480,
display_tz: "America/New_York".to_string(),
library: None,
max_segments: 24,
};
let b = ReelSelector::Memories {
span: MemoriesSpan::Week,
tz_offset_minutes: -480,
display_tz: "America/New_York".to_string(),
library: None,
max_segments: 24,
};
@@ -429,21 +430,33 @@ mod tests {
created: Some(1_560_000_000), // 2019
modified: None,
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
memories::MemoryItem {
path: "b".into(),
created: Some(1_560_086_400), // 2019
modified: None,
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
memories::MemoryItem {
path: "c".into(),
created: Some(1_623_000_000), // 2021
modified: None,
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
];
assert_eq!(distinct_years(&items, None), vec![2019, 2021]);
assert_eq!(
distinct_years(&items, chrono_tz::Tz::America__New_York),
vec![2019, 2021]
);
}
// Build an item at a given unix timestamp (seconds) with a chosen extension.
@@ -453,6 +466,9 @@ mod tests {
created: Some(ts),
modified: None,
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
}
}
fn item_at(ts: i64, name: &str) -> memories::MemoryItem {
+2
View File
@@ -323,6 +323,7 @@ impl Default for AppState {
face_dao.clone(),
knowledge_dao,
persona_dao.clone(),
user_ai_prefs_dao.clone(),
libraries_vec.clone(),
);
@@ -550,6 +551,7 @@ impl AppState {
face_dao.clone(),
knowledge_dao,
persona_dao.clone(),
Arc::new(Mutex::new(Box::new(SqliteUserAiPrefsDao::new()))),
vec![test_lib],
);