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:
1 parent
cd375f18db
commit
af87765bbb
13 files changed
+1590
-301
No files matched your search
+185
-17
@@ -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
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user