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

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

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

No files matched your search

+452 -47
View File
@@ -1,12 +1,13 @@
use actix_web::web::Data;
use actix_web::{HttpRequest, HttpResponse, Responder, get, web};
use chrono::{DateTime, FixedOffset, Local, NaiveDate, NaiveDateTime, Utc};
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc};
use log::{debug, trace, warn};
use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, TraceContextExt, Tracer};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Mutex;
use crate::data::Claims;
@@ -110,7 +111,10 @@ pub enum MemoriesSpan {
#[derive(Deserialize)]
pub struct MemoriesRequest {
pub span: Option<MemoriesSpan>,
/// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for CET)
/// Client timezone offset in minutes from UTC (e.g., -480 for PST, 60 for
/// CET). Accepted on the wire for old clients but no longer drives
/// grouping (ADR-0001) — grouping uses the owner's display zone.
#[allow(dead_code)]
pub timezone_offset_minutes: Option<i32>,
/// Optional library filter. Accepts a library id (e.g. "1") or name
/// (e.g. "main"). When omitted, results span all libraries.
@@ -120,11 +124,21 @@ pub struct MemoriesRequest {
#[derive(Debug, Serialize, Clone)]
pub struct MemoryItem {
pub path: String,
/// True instant (unix seconds) the memory names (ADR-0001) — the
/// grouping anchor. For unconverted rows this is *derived* from the
/// legacy value under its per-source convention, so it is the real
/// moment even while the `backfill_utc_dates` drain is still running.
pub created: Option<i64>,
pub modified: Option<i64>,
/// Id of the library this memory belongs to. Allows clients to show a
/// per-item source badge in union mode.
pub library_id: i32,
/// New-contract triple (ADR-0001). `date_utc` is `None` until the
/// `backfill_utc_dates` drain has converted the row; clients should
/// prefer it over `created` when present.
pub date_utc: Option<i64>,
pub camera_offset_minutes: Option<i32>,
pub date_taken_tz_source: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -132,6 +146,50 @@ pub struct MemoriesResponse {
pub items: Vec<MemoryItem>,
}
/// Epoch windows for the memories SQL pre-filter (ADR-0001).
///
/// The span feature matches the same calendar position (month-day /
/// week-of-year / month) across every past year, so the pre-filter must
/// cover the FULL years-back window — not just the current span window —
/// or past-year rows are dropped before [`day_in_span`] gets to match
/// them. Converted rows are tested on `date_utc` (the true instant);
/// unconverted rows on legacy `date_taken`, which sits up to ~15 h off
/// the instant under the per-source conventions (wall↔instant confusion
/// ≤ 12 h, zone range ≤ 2 h, DST margin 1 h) — hence the ±15 h band.
/// By construction the band subsumes the converted window, so the DAO's
/// single outer bound on `date_taken` never drops a converted row.
pub fn memories_span_windows(now_ts: i64, years_back: i64) -> (i64, i64, i64, i64) {
let years_lo = now_ts - years_back * 365 * 86_400;
(years_lo, now_ts, years_lo - 15 * 3600, now_ts + 15 * 3600)
}
/// True if `display_day` falls in the span (day/week/month) containing
/// `today` — both already in the owner's display zone. Mirrors the legacy
/// SQLite `strftime('%m-%d' | '%W' | '%m')` equality: weeks are
/// Monday-based and the days before the year's first Monday are week 00.
pub fn day_in_span(display_day: NaiveDate, today: NaiveDate, span: MemoriesSpan) -> bool {
match span {
MemoriesSpan::Day => {
display_day.month() == today.month() && display_day.day() == today.day()
}
MemoriesSpan::Week => sqlite_w_week(display_day) == sqlite_w_week(today),
MemoriesSpan::Month => display_day.month() == today.month(),
}
}
/// SQLite's `%W`: Monday-based week-of-year, `0` for the days before the
/// year's first Monday, `1` starting on the first Monday.
fn sqlite_w_week(d: NaiveDate) -> i32 {
let jan1 = NaiveDate::from_ymd_opt(d.year(), 1, 1).expect("Jan 1 exists");
let days_in_week0 = (7 - jan1.weekday().num_days_from_monday() as i64) % 7;
let days = (d - jan1).num_days();
if days < days_in_week0 {
0
} else {
((days - days_in_week0) / 7 + 1) as i32
}
}
/// A date carried by a filename, with its convention (ticket #116).
///
/// The two families of pattern make fundamentally different claims:
@@ -398,17 +456,6 @@ fn plausible_filename_date(dt: DateTime<Utc>) -> Option<DateTime<Utc>> {
}
}
/// Convert a `date_taken` Unix-seconds value to a `NaiveDate` in the
/// client's local time. Falls back to server-local when the client didn't
/// send a tz hint.
fn date_in_client_tz(timestamp: i64, client_timezone: Option<FixedOffset>) -> Option<NaiveDate> {
let dt = DateTime::from_timestamp(timestamp, 0)?;
Some(match client_timezone {
Some(tz) => dt.with_timezone(&tz).date_naive(),
None => dt.with_timezone(&Local).date_naive(),
})
}
/// Default lookback for `/memories`. The original 15-year cap pre-dated
/// most of the imported libraries; bumped to 20 so users with deeper
/// archives see those photos surface on the matching anniversary too.
@@ -430,23 +477,29 @@ pub async fn list_memories(
let span_mode = q.span.unwrap_or(MemoriesSpan::Day);
// The SQL filter expects a signed offset in minutes from UTC; default
// 0 (UTC) when the client didn't send a hint. We also keep a chrono
// `FixedOffset` for sorting/secondary-key date math in Rust below —
// anchoring both sides on the same value keeps "what SQL matched" and
// "what we sort by" consistent.
let tz_offset_minutes = q.timezone_offset_minutes.unwrap_or(0);
let client_timezone = q
.timezone_offset_minutes
.and_then(|offset_mins| FixedOffset::east_opt(offset_mins * 60));
// Grouping happens in the owner's display zone (prefs → app default),
// not the client's device offset (ADR-0001): a photo taken in Japan at
// 21:00 JST belongs to the owner's day, and a container's UTC-rendered
// creation time is a true instant. The legacy `timezone_offset_minutes`
// param is still accepted on the wire for old clients but no longer
// drives grouping.
let display_zone_name = {
let prefs = app_state
.user_ai_prefs_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_prefs(&span_context).ok().flatten());
crate::camera_tz::display_zone_for(prefs.as_ref().and_then(|p| p.display_tz.as_deref()))
};
let display_zone =
chrono_tz::Tz::from_str(&display_zone_name).unwrap_or(chrono_tz::Tz::America__New_York);
let items = match gather_memory_items(
&app_state,
&exif_dao,
&span_context,
span_mode,
tz_offset_minutes,
client_timezone,
display_zone,
q.library.as_deref(),
) {
Ok(items) => items,
@@ -462,7 +515,7 @@ pub async fn list_memories(
KeyValue::new("span", format!("{:?}", span_mode)),
KeyValue::new("years_back", DEFAULT_YEARS_BACK.to_string()),
KeyValue::new("result_count", items.len().to_string()),
KeyValue::new("tz_offset_minutes", tz_offset_minutes.to_string()),
KeyValue::new("display_tz", display_zone_name),
KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)),
],
);
@@ -474,29 +527,41 @@ pub async fn list_memories(
/// Resolve an "on this day/week/month across past years" window into an
/// ordered list of [`MemoryItem`]s. Shared by the `/memories` handler and the
/// memory-reel selector so both honour the same library resolution, per-library
/// exclusions, timezone handling, and sort order. Returns `Err(message)` only
/// when the `library` param is invalid (callers map that to 400); per-library
/// exclusions, and sort order. Returns `Err(message)` only when the
/// `library` param is invalid (callers map that to 400); per-library
/// query/lock failures are logged and skipped, matching the handler's
/// best-effort behaviour.
///
/// Grouping is done in the owner's display zone (ADR-0001), on the row's
/// *true* instant — so a container's UTC-rendered creation time and an
/// unconverted EXIF wall clock both land in the same day a photo taken
/// locally did. The SQL pass is an interval pre-filter; exact span-day
/// matching happens here in Rust via [`date_view`].
pub fn gather_memory_items(
app_state: &AppState,
exif_dao: &Mutex<Box<dyn ExifDao>>,
span_context: &opentelemetry::Context,
span_mode: MemoriesSpan,
tz_offset_minutes: i32,
client_timezone: Option<FixedOffset>,
display_zone: chrono_tz::Tz,
library_param: Option<&str>,
) -> Result<Vec<MemoryItem>, String> {
let span_token = match span_mode {
MemoriesSpan::Day => "day",
MemoriesSpan::Week => "week",
MemoriesSpan::Month => "month",
};
let years_back: i32 = DEFAULT_YEARS_BACK;
let years_back: i64 = DEFAULT_YEARS_BACK as i64;
let now = Utc::now();
let now_ts = now.timestamp();
let today_d = display_zone
.from_utc_datetime(&now.naive_utc())
.date_naive();
// The SQL pass is a coarse pre-filter over the FULL years-back window
// (not the current span window) because this feature matches the same
// month-day / week / month across every past year; [`day_in_span`] does
// the exact matching in Rust (ADR-0001).
let (conv_lo, conv_hi, unconv_lo, unconv_hi) = memories_span_windows(now_ts, years_back);
let server_zone = crate::camera_tz::server_zone();
debug!(
"gather_memory_items: span={:?} tz_offset_min={} years_back={}",
span_mode, tz_offset_minutes, years_back
"gather_memory_items: span={:?} display_zone={:?} years_back={}",
span_mode, display_zone, years_back
);
let library = crate::libraries::resolve_library_param_state(app_state, library_param)?;
@@ -518,9 +583,10 @@ pub fn gather_memory_items(
Ok(mut dao) => match dao.get_memories_in_window(
span_context,
lib.id,
span_token,
years_back,
tz_offset_minutes,
conv_lo,
conv_hi,
unconv_lo,
unconv_hi,
) {
Ok(rows) => rows,
Err(e) => {
@@ -537,28 +603,37 @@ pub fn gather_memory_items(
}
};
for (rel_path, date_taken_ts, last_modified_ts) in rows {
for row in rows {
// Apply per-library exclusions in Rust — they're a small
// set and pushing them into the SQL WHERE adds bind-param
// gymnastics with no measurable win at this scale.
let full_path = base.join(&rel_path);
let full_path = base.join(&row.file_path);
if path_excluder.is_excluded(&full_path) {
trace!("Memory excluded by PathExcluder: {:?}", full_path);
continue;
}
let Some(file_date) = date_in_client_tz(date_taken_ts, client_timezone) else {
let Some(display_day) =
crate::date_view::row_display_day(&row, display_zone, server_zone)
else {
continue;
};
if !day_in_span(display_day, today_d, span_mode) {
continue;
}
let created = crate::date_view::row_true_instant(&row, display_zone, server_zone);
memories_with_dates.push((
MemoryItem {
path: rel_path,
created: Some(date_taken_ts),
modified: Some(last_modified_ts),
path: row.file_path,
created,
modified: Some(row.last_modified),
library_id: lib.id,
date_utc: row.date_utc,
camera_offset_minutes: row.camera_offset_minutes,
date_taken_tz_source: row.date_taken_tz_source,
},
file_date,
display_day,
));
}
}
@@ -1038,6 +1113,9 @@ mod tests {
created: Some(jan_15_2024_9am),
modified: Some(jan_15_2024_9am),
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
),
@@ -1047,6 +1125,9 @@ mod tests {
created: Some(jan_15_2020_10am),
modified: Some(jan_15_2020_10am),
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(),
),
@@ -1056,6 +1137,9 @@ mod tests {
created: Some(jan_16_2021_8am),
modified: Some(jan_16_2021_8am),
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(),
),
@@ -1087,4 +1171,325 @@ mod tests {
assert_eq!(memories_with_dates[1].0.created.unwrap(), jan_15_2024_9am);
assert_eq!(memories_with_dates[2].0.created.unwrap(), jan_16_2021_8am);
}
// ---- Day-matching (ticket #119) ----
#[test]
fn span_windows_cover_the_full_years_back_range() {
// Regression guard for the "current span window only" bug: the
// day/week/month spans match across PAST YEARS, so the SQL
// pre-filter must span the whole years-back horizon. If this
// narrowed to the current day/week/month, /memories would return
// only photos taken right now.
let now = 1_700_000_000i64;
let (clo, chi, ulo, uhi) = memories_span_windows(now, 20);
let lo_bound = now - 20 * 365 * 86_400;
assert_eq!(clo, lo_bound, "converted window must start at the horizon");
assert_eq!(chi, now, "converted window must end at now");
assert_eq!(
ulo,
lo_bound - 15 * 3600,
"unconverted band must pad the horizon"
);
assert_eq!(uhi, now + 15 * 3600, "unconverted band must pad now");
// The band subsumes the converted window so the DAO's single
// outer bound on legacy date_taken never drops a converted row.
assert!(ulo <= clo && chi <= uhi);
}
#[test]
fn day_in_span_day_and_month() {
let today = NaiveDate::from_ymd_opt(2024, 7, 4).unwrap();
assert!(day_in_span(
NaiveDate::from_ymd_opt(2024, 7, 4).unwrap(),
today,
MemoriesSpan::Day
));
assert!(!day_in_span(
NaiveDate::from_ymd_opt(2024, 7, 5).unwrap(),
today,
MemoriesSpan::Day
));
assert!(day_in_span(
NaiveDate::from_ymd_opt(2020, 7, 4).unwrap(),
today,
MemoriesSpan::Day
));
assert!(day_in_span(
NaiveDate::from_ymd_opt(2024, 7, 30).unwrap(),
today,
MemoriesSpan::Month
));
assert!(!day_in_span(
NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(),
today,
MemoriesSpan::Month
));
}
#[test]
fn day_in_span_week_is_monday_based_with_week_zero() {
let monday_2024 = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(); // Mon
// Same week: Sunday Jul 7 is still week of Mon Jul 1.
assert!(day_in_span(
NaiveDate::from_ymd_opt(2024, 7, 7).unwrap(),
monday_2024,
MemoriesSpan::Week
));
// Next Monday is the next week.
assert!(!day_in_span(
NaiveDate::from_ymd_opt(2024, 7, 8).unwrap(),
monday_2024,
MemoriesSpan::Week
));
// Previous Sunday is the previous week.
assert!(!day_in_span(
NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
monday_2024,
MemoriesSpan::Week
));
// 2022 week 00: Jan 1 (Sat) and Jan 2 (Sun) share week 00; Jan 3
// (Mon) starts week 1.
let jan1_2022 = NaiveDate::from_ymd_opt(2022, 1, 1).unwrap();
assert!(day_in_span(
NaiveDate::from_ymd_opt(2022, 1, 2).unwrap(),
jan1_2022,
MemoriesSpan::Week
));
assert!(!day_in_span(
NaiveDate::from_ymd_opt(2022, 1, 3).unwrap(),
jan1_2022,
MemoriesSpan::Week
));
}
#[test]
fn sqlite_w_week_matches_known_values() {
// 2024: Jan 1 is a Monday → week 1 starts Jan 1.
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
1
);
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2024, 12, 30).unwrap()),
53
);
// 2022: Jan 1 is a Saturday → two-day week 00.
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 1).unwrap()),
0
);
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 2).unwrap()),
0
);
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2022, 1, 3).unwrap()),
1
);
// 2023: Jan 1 is a Sunday → one-day week 00.
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2023, 1, 1).unwrap()),
0
);
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2023, 1, 2).unwrap()),
1
);
assert_eq!(
sqlite_w_week(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap()),
52
);
}
/// AC-2 / AC-3 / AC-7: seam test with mixed fixtures — a converted
/// row and two unconverted rows (EXIF wall convention + container UTC
/// convention) all group into the owner's display-zone month, and each
/// item carries its true instant under the per-source convention.
mod api {
use super::*;
use crate::database::SqliteExifDao;
use crate::database::models::InsertImageExif;
use crate::database::test::in_memory_db_connection;
fn seed_row(dao: &mut SqliteExifDao, rel: &str, date_taken: i64, source: &str) {
dao.store_exif(
&opentelemetry::Context::new(),
InsertImageExif {
library_id: 1,
file_path: rel.to_string(),
camera_make: None,
camera_model: None,
lens_model: None,
width: None,
height: None,
orientation: None,
gps_latitude: None,
gps_longitude: None,
gps_altitude: None,
focal_length: None,
aperture: None,
shutter_speed: None,
iso: None,
date_taken: Some(date_taken),
created_time: 0,
last_modified: 0,
content_hash: None,
size_bytes: None,
phash_64: None,
dhash_64: None,
date_taken_source: Some(source.to_string()),
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
},
)
.expect("insert row");
}
#[actix_rt::test]
async fn memories_mixed_rows_group_into_owner_month() {
let _ = env_logger::builder().is_test(true).try_init();
let state = AppState::test_state();
let zone = chrono_tz::Tz::America__New_York;
// Month span over the 5th of the CURRENT month in past years,
// at 12:00 naive: under all three conventions (camera wall in
// the server zone, container UTC, converted instant) the
// grouping day can't roll across midnight, so the fixture is
// stable across DST transitions and leap years.
let now = Utc::now();
let (month, year) = (now.month(), now.year());
let at_12 = |y: i32| {
NaiveDate::from_ymd_opt(y, month, 5)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap()
};
let mut dao = SqliteExifDao::from_connection(in_memory_db_connection());
// 1. Converted row: true instant 12:00 UTC (no camera offset).
let conv_instant = at_12(year - 3).and_utc().timestamp();
seed_row(&mut dao, "conv.jpg", conv_instant, "exif");
dao.backfill_date_taken(
&opentelemetry::Context::new(),
1,
"conv.jpg",
conv_instant,
"exif",
Some(conv_instant),
None,
Some("utc_direct"),
)
.expect("convert row");
// 2. Unconverted EXIF row: legacy is the camera wall read as
// UTC — the true moment is 12:00 in the server zone.
let exif_wall = at_12(year - 1);
let exif_legacy = exif_wall.and_utc().timestamp();
seed_row(&mut dao, "unconv-exif.jpg", exif_legacy, "exif");
// 3. Unconverted container row: legacy is the raw UTC creation
// time string rendered in the server zone (the old server
// read the epoch as a naive string and stamped it through
// its local zone). Pre-#115 this row double-shifted in
// /memories; it must now land in the same owner month as
// the other two.
let vid_legacy = at_12(year - 2).and_utc().timestamp();
seed_row(&mut dao, "unconv-vid.mp4", vid_legacy, "exiftool");
let exif_mutex: Mutex<Box<dyn ExifDao>> = Mutex::new(Box::new(dao));
let items = gather_memory_items(
&state,
&exif_mutex,
&opentelemetry::Context::new(),
MemoriesSpan::Month,
zone,
None,
)
.expect("gather");
let by_path = |p: &str| items.iter().find(|it| it.path == p);
// All three rows group into the same owner month (AC-2/AC-3).
let conv = by_path("conv.jpg").expect("converted row must be returned");
let exif_row =
by_path("unconv-exif.jpg").expect("unconverted EXIF row must be returned");
let vid =
by_path("unconv-vid.mp4").expect("unconverted container row must be returned");
// AC-1: the triple rides along — populated for the converted
// row, None for the unconverted ones.
assert_eq!(conv.date_utc, Some(conv_instant));
assert_eq!(conv.camera_offset_minutes, None);
assert_eq!(conv.date_taken_tz_source.as_deref(), Some("utc_direct"));
assert_eq!(exif_row.date_utc, None);
assert_eq!(exif_row.camera_offset_minutes, None);
assert_eq!(exif_row.date_taken_tz_source, None);
assert_eq!(vid.date_utc, None);
// AC-2: `created` is the true instant under each row's
// per-source convention. The server zone (the zone the old
// server rendered legacy values in) is a deploy property, so
// the expected values are derived from the convention, not
// hardcoded.
let server_zone = crate::camera_tz::server_zone();
let legacy_as_wall = |ts: i64| {
chrono::DateTime::from_timestamp(ts, 0)
.expect("valid epoch")
.naive_utc()
};
// exif: camera wall reinterpreted in the server zone.
let exif_expected = match server_zone {
Some(z) => z
.from_local_datetime(&legacy_as_wall(exif_legacy))
.single()
.expect("camera wall in server zone")
.timestamp(),
None => exif_legacy,
};
// exiftool video: raw UTC naive rendered in the server zone,
// read back as UTC.
let vid_expected = match server_zone {
Some(z) => z
.timestamp_opt(vid_legacy, 0)
.single()
.expect("server-zone render")
.naive_local()
.and_utc()
.timestamp(),
None => vid_legacy,
};
assert_eq!(conv.created, Some(conv_instant));
assert_eq!(exif_row.created, Some(exif_expected));
assert_eq!(vid.created, Some(vid_expected));
}
/// AC-1 (response shape): the serialized `/memories` item carries
/// the new-contract triple as explicit fields — null while the row
/// is unconverted.
#[test]
fn memories_response_shape_carries_triple() {
let item = MemoryItem {
path: "a.jpg".into(),
created: Some(1_700_000_000),
modified: Some(0),
library_id: 1,
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
};
let v: serde_json::Value =
serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap();
let it = &v["items"][0];
assert!(it.get("date_utc").is_some() && it["date_utc"].is_null());
assert!(
it.get("camera_offset_minutes").is_some() && it["camera_offset_minutes"].is_null()
);
assert!(it.get("date_taken_tz_source").is_some());
}
}
}