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
+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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user