Dates: serve the display wall clock (wall_clock) to clients
Every payload a client renders a capture time from now carries `wall_clock`, a naive ISO-8601 string computed by the existing `date_view::row_display_wall`: the camera's wall clock (true instant + camera offset; the owner's display zone without an offset), with the per-source fallback for unconverted rows. Clients render it verbatim and do no zone math, which is the FileViewer-React #33 / Apollo #37 flip with the rule kept server-side: the drain's permanent `date_utc IS NULL` floor (#120: edited/exported files) would otherwise force every client to carry the per-source legacy conventions forever, and insight prompts already use the same function, so prompt and UI agree by construction. - `/image/metadata` (+ the GPS / date / date-clear mutation responses): `file_wall_clock` — the row's wall, else the filename-class date, else the earliest fs time in the display zone (unindexed files). - `/memories` items and `/photos/gps-summary` photos: `row_wall_clock`. - `get_all_with_gps` returns full rows instead of a 7-tuple that dropped `date_taken_source`, which unconverted rows need. - `date_view::owner_display_zone` resolves prefs -> app default per request. Legacy fields are untouched, so current clients see byte-identical responses (additive; safe to deploy ahead of the client flip, per #120). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f00a309a24
commit
7231383fa6
8 files changed
+259
-93
No files matched your search
@@ -467,6 +467,18 @@ are SMS-derived and unaffected. Progress: Prometheus
|
|||||||
`date_backfill_remaining` per library on `GET /libraries` (live count of
|
`date_backfill_remaining` per library on `GET /libraries` (live count of
|
||||||
`date_utc IS NULL`).
|
`date_utc IS NULL`).
|
||||||
|
|
||||||
|
**Displaying a capture time: `wall_clock`.** Every payload a client renders a
|
||||||
|
capture time from (`/image/metadata` + the date/GPS mutation responses,
|
||||||
|
`/memories` items, `/photos/gps-summary`) carries `wall_clock`, a naive
|
||||||
|
ISO-8601 string (`2018-09-29T14:00:00`) computed by
|
||||||
|
`date_view::row_display_wall` — the camera's wall clock (instant + camera
|
||||||
|
offset; the owner's display zone when there's no offset), with the per-source
|
||||||
|
fallback for rows the drain can't convert (its permanent floor).
|
||||||
|
`file_wall_clock` covers files with no row yet (filename date, then fs time).
|
||||||
|
Clients render it verbatim and do no zone math (ADR-0001; FileViewer-React #33,
|
||||||
|
Apollo #37). Insight prompts use the same function, so prompt and UI agree.
|
||||||
|
`dev/e2e/e2e.sh check` verifies it for every fixture on all three endpoints.
|
||||||
|
|
||||||
`/memories` is a single SQL query against this column
|
`/memories` is a single SQL query against this column
|
||||||
(`get_memories_in_window` in `src/database/mod.rs`), using
|
(`get_memories_in_window` in `src/database/mod.rs`), using
|
||||||
`strftime('%m-%d' | '%W' | '%m', date_taken, 'unixepoch', tz)` for
|
`strftime('%m-%d' | '%W' | '%m', date_taken, 'unixepoch', tz)` for
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ pub struct MetadataResponse {
|
|||||||
pub filename_date: Option<i64>, // Date extracted from filename
|
pub filename_date: Option<i64>, // Date extracted from filename
|
||||||
pub library_id: Option<i32>,
|
pub library_id: Option<i32>,
|
||||||
pub library_name: Option<String>,
|
pub library_name: Option<String>,
|
||||||
|
/// The capture wall clock to display, naive ISO-8601
|
||||||
|
/// (`2018-09-29T14:00:00`) — `date_view::file_wall_clock`. Render it
|
||||||
|
/// verbatim; never zone-convert it (ADR-0001).
|
||||||
|
pub wall_clock: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<fs::Metadata> for MetadataResponse {
|
impl From<fs::Metadata> for MetadataResponse {
|
||||||
@@ -277,6 +281,7 @@ impl From<fs::Metadata> for MetadataResponse {
|
|||||||
filename_date: None, // Will be set in endpoint handler
|
filename_date: None, // Will be set in endpoint handler
|
||||||
library_id: None,
|
library_id: None,
|
||||||
library_name: None,
|
library_name: None,
|
||||||
|
wall_clock: None, // Set in the endpoint handler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,6 +442,9 @@ pub struct GpsPhotoSummary {
|
|||||||
/// Which evidence pinned the instant (`offset_tag`, `gps`,
|
/// Which evidence pinned the instant (`offset_tag`, `gps`,
|
||||||
/// `assumed_server`, `utc_direct`, `manual`).
|
/// `assumed_server`, `utc_direct`, `manual`).
|
||||||
pub date_taken_tz_source: Option<String>,
|
pub date_taken_tz_source: Option<String>,
|
||||||
|
/// Capture wall clock to display (naive ISO-8601; see
|
||||||
|
/// `MetadataResponse::wall_clock`).
|
||||||
|
pub wall_clock: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|||||||
+10
-57
@@ -418,26 +418,16 @@ pub trait ExifDao: Sync + Send {
|
|||||||
context: &opentelemetry::Context,
|
context: &opentelemetry::Context,
|
||||||
) -> Result<Vec<String>, DbError>;
|
) -> Result<Vec<String>, DbError>;
|
||||||
|
|
||||||
/// Get all photos with GPS coordinates
|
/// Get all rows with GPS coordinates (both latitude and longitude set)
|
||||||
/// Returns Vec<(file_path, latitude, longitude, date_taken)>
|
/// under `base_path`. Full rows, so callers can apply the date contract
|
||||||
#[allow(clippy::type_complexity)]
|
/// (`date_view`) — which needs the date source and path, not just the
|
||||||
|
/// new-contract triple.
|
||||||
fn get_all_with_gps(
|
fn get_all_with_gps(
|
||||||
&mut self,
|
&mut self,
|
||||||
context: &opentelemetry::Context,
|
context: &opentelemetry::Context,
|
||||||
base_path: &str,
|
base_path: &str,
|
||||||
recursive: bool,
|
recursive: bool,
|
||||||
) -> Result<
|
) -> Result<Vec<ImageExif>, DbError>;
|
||||||
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
|
/// Return rows that still lack a `content_hash`, oldest first. Used by
|
||||||
/// the `backfill_hashes` binary to batch through the historical
|
/// the `backfill_hashes` binary to batch through the historical
|
||||||
@@ -1289,24 +1279,12 @@ impl ExifDao for SqliteExifDao {
|
|||||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
fn get_all_with_gps(
|
fn get_all_with_gps(
|
||||||
&mut self,
|
&mut self,
|
||||||
context: &opentelemetry::Context,
|
context: &opentelemetry::Context,
|
||||||
base_path: &str,
|
base_path: &str,
|
||||||
recursive: bool,
|
recursive: bool,
|
||||||
) -> Result<
|
) -> Result<Vec<ImageExif>, DbError> {
|
||||||
Vec<(
|
|
||||||
String,
|
|
||||||
f64,
|
|
||||||
f64,
|
|
||||||
Option<i64>,
|
|
||||||
Option<i64>,
|
|
||||||
Option<i32>,
|
|
||||||
Option<String>,
|
|
||||||
)>,
|
|
||||||
DbError,
|
|
||||||
> {
|
|
||||||
trace_db_call(context, "query", "get_all_with_gps", |span| {
|
trace_db_call(context, "query", "get_all_with_gps", |span| {
|
||||||
use opentelemetry::KeyValue;
|
use opentelemetry::KeyValue;
|
||||||
use opentelemetry::trace::Span;
|
use opentelemetry::trace::Span;
|
||||||
@@ -1342,36 +1320,11 @@ impl ExifDao for SqliteExifDao {
|
|||||||
.load::<ImageExif>(connection)
|
.load::<ImageExif>(connection)
|
||||||
.map_err(|e| anyhow::anyhow!("GPS query error: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("GPS query error: {}", e))?;
|
||||||
|
|
||||||
// Convert to tuple format (path, lat, lon, legacy date_taken,
|
// The filter already requires both coordinates; keep the guard so
|
||||||
// date_utc, camera_offset_minutes, date_taken_tz_source).
|
// callers can rely on lat/lon being present.
|
||||||
// Filter out any rows where GPS is still None (shouldn't happen due to filter)
|
let filtered: Vec<ImageExif> = results
|
||||||
// Cast f32 GPS values to f64 for API compatibility
|
|
||||||
let filtered: Vec<(
|
|
||||||
String,
|
|
||||||
f64,
|
|
||||||
f64,
|
|
||||||
Option<i64>,
|
|
||||||
Option<i64>,
|
|
||||||
Option<i32>,
|
|
||||||
Option<String>,
|
|
||||||
)> = results
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|exif| {
|
.filter(|exif| exif.gps_latitude.is_some() && exif.gps_longitude.is_some())
|
||||||
if let (Some(lat_val), Some(lon_val)) = (exif.gps_latitude, exif.gps_longitude)
|
|
||||||
{
|
|
||||||
Some((
|
|
||||||
exif.file_path,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
span.set_attribute(KeyValue::new("result_count", filtered.len() as i64));
|
span.set_attribute(KeyValue::new("result_count", filtered.len() as i64));
|
||||||
|
|||||||
@@ -184,6 +184,65 @@ pub fn row_display_day(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wire format for a wall clock: naive ISO-8601, no zone designator
|
||||||
|
/// (`2018-09-29T14:00:00`). Clients render it verbatim — no zone math —
|
||||||
|
/// so the UI shows exactly the wall [`row_display_wall`] computed and
|
||||||
|
/// insight prompts use (ADR-0001: consumers never re-anchor).
|
||||||
|
pub fn format_wall_clock(wall: NaiveDateTime) -> String {
|
||||||
|
wall.format("%Y-%m-%dT%H:%M:%S").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The owner's display zone for a request: prefs `display_tz`, else the
|
||||||
|
/// app default (never fails — an unreadable prefs row is the default).
|
||||||
|
pub fn owner_display_zone(
|
||||||
|
prefs_dao: &std::sync::Mutex<Box<dyn crate::database::UserAiPrefsDao>>,
|
||||||
|
context: &opentelemetry::Context,
|
||||||
|
) -> Tz {
|
||||||
|
use std::str::FromStr;
|
||||||
|
let stored = prefs_dao
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut dao| dao.get_prefs(context).ok().flatten())
|
||||||
|
.and_then(|p| p.display_tz);
|
||||||
|
Tz::from_str(&camera_tz::display_zone_for(stored.as_deref()))
|
||||||
|
.unwrap_or(chrono_tz::Tz::America__New_York)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`row_display_wall`] in wire format.
|
||||||
|
pub fn row_wall_clock(
|
||||||
|
row: &ImageExif,
|
||||||
|
display_zone: Tz,
|
||||||
|
server_zone: Option<Tz>,
|
||||||
|
) -> Option<String> {
|
||||||
|
row_display_wall(row, display_zone, server_zone).map(format_wall_clock)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wall clock for a file that may have no `image_exif` row yet (not yet
|
||||||
|
/// ingested, or excluded): the row's wall when it has one, else the
|
||||||
|
/// filename-class date (a wall pattern *is* the wall; a unix-timestamp
|
||||||
|
/// pattern renders in the display zone), else the file's earliest
|
||||||
|
/// filesystem time rendered in the display zone. The same precedence the
|
||||||
|
/// ingest waterfall applies, so the value doesn't jump once the row lands.
|
||||||
|
pub fn file_wall_clock(
|
||||||
|
row: Option<&ImageExif>,
|
||||||
|
rel_path: &str,
|
||||||
|
fs_earliest: Option<i64>,
|
||||||
|
display_zone: Tz,
|
||||||
|
server_zone: Option<Tz>,
|
||||||
|
) -> Option<String> {
|
||||||
|
if let Some(wall) = row.and_then(|r| row_display_wall(r, display_zone, server_zone)) {
|
||||||
|
return Some(format_wall_clock(wall));
|
||||||
|
}
|
||||||
|
let wall = match crate::memories::extract_date_from_filename(rel_path) {
|
||||||
|
Some(crate::memories::FilenameDate::Wall(wall)) => Some(wall),
|
||||||
|
Some(crate::memories::FilenameDate::Utc(dt)) => {
|
||||||
|
render_in_zone_opt(dt.timestamp(), display_zone)
|
||||||
|
}
|
||||||
|
None => fs_earliest.and_then(|t| render_in_zone_opt(t, display_zone)),
|
||||||
|
};
|
||||||
|
wall.map(format_wall_clock)
|
||||||
|
}
|
||||||
|
|
||||||
/// Render a unix instant as a naive wall in `zone`.
|
/// Render a unix instant as a naive wall in `zone`.
|
||||||
fn render_in_zone_opt(instant: i64, zone: Tz) -> Option<NaiveDateTime> {
|
fn render_in_zone_opt(instant: i64, zone: Tz) -> Option<NaiveDateTime> {
|
||||||
zone.timestamp_opt(instant, 0)
|
zone.timestamp_opt(instant, 0)
|
||||||
@@ -564,4 +623,102 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(row_true_instant(&fs, tokyo, Some(ny())), Some(instant));
|
assert_eq!(row_true_instant(&fs, tokyo, Some(ny())), Some(instant));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wall_clock_is_the_camera_wall_for_a_travel_zone_photo() {
|
||||||
|
// 14:00 in Tokyo (+09:00) = 05:00Z. The owner is in New York, but
|
||||||
|
// the wall clock is the camera's: 14:00, not 01:00.
|
||||||
|
let r = row(
|
||||||
|
"otd.jpg",
|
||||||
|
Some(utc_epoch(2018, 9, 29, 14, 0)),
|
||||||
|
Some("exif"),
|
||||||
|
Some(utc_epoch(2018, 9, 29, 5, 0)),
|
||||||
|
Some(540),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
row_wall_clock(&r, ny(), Some(ny())),
|
||||||
|
Some("2018-09-29T14:00:00".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wall_clock_without_offset_renders_the_instant_in_the_display_zone() {
|
||||||
|
// Container date (utc_direct): 12:30Z is 08:30 in New York (EDT).
|
||||||
|
let r = row(
|
||||||
|
"summit.mp4",
|
||||||
|
None,
|
||||||
|
Some("exiftool"),
|
||||||
|
Some(utc_epoch(2022, 8, 20, 12, 30)),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
row_wall_clock(&r, ny(), Some(ny())),
|
||||||
|
Some("2022-08-20T08:30:00".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wall_clock_serves_unconverted_rows_by_their_legacy_convention() {
|
||||||
|
// The drain's permanent floor never gets date_utc; an `exif` legacy
|
||||||
|
// value is the wall stored as UTC and must come back unshifted.
|
||||||
|
let r = row(
|
||||||
|
"edited.jpg",
|
||||||
|
Some(utc_epoch(2021, 5, 12, 10, 30)),
|
||||||
|
Some("exif"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
row_wall_clock(&r, ny(), Some(ny())),
|
||||||
|
Some("2021-05-12T10:30:00".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_wall_clock_prefers_the_row() {
|
||||||
|
let r = row(
|
||||||
|
"Screenshots/Screenshot_20220315-101500.png",
|
||||||
|
Some(utc_epoch(2022, 3, 15, 10, 15)),
|
||||||
|
Some("filename"),
|
||||||
|
Some(utc_epoch(2022, 3, 15, 14, 15)),
|
||||||
|
Some(-240),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
file_wall_clock(Some(&r), &r.file_path, Some(0), ny(), Some(ny())),
|
||||||
|
Some("2022-03-15T10:15:00".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_wall_clock_without_a_row_uses_the_filename_wall() {
|
||||||
|
assert_eq!(
|
||||||
|
file_wall_clock(
|
||||||
|
None,
|
||||||
|
"Screenshots/Screenshot_20220315-101500.png",
|
||||||
|
Some(0),
|
||||||
|
ny(),
|
||||||
|
Some(ny())
|
||||||
|
),
|
||||||
|
Some("2022-03-15T10:15:00".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_wall_clock_without_a_row_or_filename_date_uses_fs_time_in_display_zone() {
|
||||||
|
// 2015-03-01 17:00Z = 12:00 in New York (EST).
|
||||||
|
assert_eq!(
|
||||||
|
file_wall_clock(
|
||||||
|
None,
|
||||||
|
"Undated/scan_01.jpg",
|
||||||
|
Some(utc_epoch(2015, 3, 1, 17, 0)),
|
||||||
|
ny(),
|
||||||
|
Some(ny())
|
||||||
|
),
|
||||||
|
Some("2015-03-01T12:00:00".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
file_wall_clock(None, "Undated/scan_01.jpg", None, ny(), Some(ny())),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+18
-31
@@ -1140,27 +1140,25 @@ pub async fn get_gps_summary(
|
|||||||
let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao");
|
let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao");
|
||||||
match exif_dao_guard.get_all_with_gps(&cx, &requested_path, recursive) {
|
match exif_dao_guard.get_all_with_gps(&cx, &requested_path, recursive) {
|
||||||
Ok(gps_data) => {
|
Ok(gps_data) => {
|
||||||
|
let display_zone =
|
||||||
|
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao, &cx);
|
||||||
|
let server_zone = crate::camera_tz::server_zone();
|
||||||
let mut photos: Vec<GpsPhotoSummary> = gps_data
|
let mut photos: Vec<GpsPhotoSummary> = gps_data
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(
|
.filter_map(|row| {
|
||||||
|(
|
let wall_clock =
|
||||||
path,
|
crate::date_view::row_wall_clock(&row, display_zone, server_zone);
|
||||||
lat,
|
Some(GpsPhotoSummary {
|
||||||
lon,
|
lat: row.gps_latitude? as f64,
|
||||||
date_taken,
|
lon: row.gps_longitude? as f64,
|
||||||
date_utc,
|
path: row.file_path,
|
||||||
camera_offset_minutes,
|
date_taken: row.date_taken,
|
||||||
date_taken_tz_source,
|
date_utc: row.date_utc,
|
||||||
)| GpsPhotoSummary {
|
camera_offset_minutes: row.camera_offset_minutes,
|
||||||
path,
|
date_taken_tz_source: row.date_taken_tz_source,
|
||||||
lat,
|
wall_clock,
|
||||||
lon,
|
})
|
||||||
date_taken,
|
})
|
||||||
date_utc,
|
|
||||||
camera_offset_minutes,
|
|
||||||
date_taken_tz_source,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Sort by date_taken based on request, defaulting to ascending (oldest to newest)
|
// Sort by date_taken based on request, defaulting to ascending (oldest to newest)
|
||||||
@@ -1708,18 +1706,7 @@ mod tests {
|
|||||||
_context: &opentelemetry::Context,
|
_context: &opentelemetry::Context,
|
||||||
_base_path: &str,
|
_base_path: &str,
|
||||||
_recursive: bool,
|
_recursive: bool,
|
||||||
) -> Result<
|
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
|
||||||
Vec<(
|
|
||||||
String,
|
|
||||||
f64,
|
|
||||||
f64,
|
|
||||||
Option<i64>,
|
|
||||||
Option<i64>,
|
|
||||||
Option<i32>,
|
|
||||||
Option<String>,
|
|
||||||
)>,
|
|
||||||
DbError,
|
|
||||||
> {
|
|
||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+40
-5
@@ -530,11 +530,18 @@ pub async fn get_file_metadata(
|
|||||||
.map(memories::FilenameDate::legacy_timestamp);
|
.map(memories::FilenameDate::legacy_timestamp);
|
||||||
|
|
||||||
// Query EXIF data if available
|
// Query EXIF data if available
|
||||||
if let Ok(mut dao) = exif_dao.lock()
|
let row = exif_dao
|
||||||
&& let Ok(Some(exif)) = dao.get_exif(&span_context, &path.path)
|
.lock()
|
||||||
{
|
.ok()
|
||||||
response.exif = Some(exif.into());
|
.and_then(|mut dao| dao.get_exif(&span_context, &path.path).ok().flatten());
|
||||||
}
|
response.wall_clock = crate::date_view::file_wall_clock(
|
||||||
|
row.as_ref(),
|
||||||
|
&path.path,
|
||||||
|
earliest_fs_time(&response),
|
||||||
|
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao, &span_context),
|
||||||
|
crate::camera_tz::server_zone(),
|
||||||
|
);
|
||||||
|
response.exif = row.map(Into::into);
|
||||||
|
|
||||||
span.add_event(
|
span.add_event(
|
||||||
"Metadata fetched",
|
"Metadata fetched",
|
||||||
@@ -716,8 +723,16 @@ pub async fn set_image_gps(
|
|||||||
filename_date: None,
|
filename_date: None,
|
||||||
library_id: None,
|
library_id: None,
|
||||||
library_name: None,
|
library_name: None,
|
||||||
|
wall_clock: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
response.wall_clock = crate::date_view::file_wall_clock(
|
||||||
|
Some(&row),
|
||||||
|
&normalized_path,
|
||||||
|
earliest_fs_time(&response),
|
||||||
|
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao, &span_context),
|
||||||
|
crate::camera_tz::server_zone(),
|
||||||
|
);
|
||||||
response.exif = Some(row.into());
|
response.exif = Some(row.into());
|
||||||
response.library_id = Some(resolved_library.id);
|
response.library_id = Some(resolved_library.id);
|
||||||
response.library_name = Some(resolved_library.name.clone());
|
response.library_name = Some(resolved_library.name.clone());
|
||||||
@@ -860,6 +875,7 @@ fn build_metadata_response_for_date_mutation(
|
|||||||
library: &libraries::Library,
|
library: &libraries::Library,
|
||||||
rel_path: &str,
|
rel_path: &str,
|
||||||
exif: ImageExif,
|
exif: ImageExif,
|
||||||
|
display_zone: chrono_tz::Tz,
|
||||||
) -> MetadataResponse {
|
) -> MetadataResponse {
|
||||||
let full_path = is_valid_full_path(&library.root_path, &rel_path.to_string(), false);
|
let full_path = is_valid_full_path(&library.root_path, &rel_path.to_string(), false);
|
||||||
let fs_meta = full_path
|
let fs_meta = full_path
|
||||||
@@ -876,8 +892,16 @@ fn build_metadata_response_for_date_mutation(
|
|||||||
filename_date: None,
|
filename_date: None,
|
||||||
library_id: None,
|
library_id: None,
|
||||||
library_name: None,
|
library_name: None,
|
||||||
|
wall_clock: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
response.wall_clock = crate::date_view::file_wall_clock(
|
||||||
|
Some(&exif),
|
||||||
|
rel_path,
|
||||||
|
earliest_fs_time(&response),
|
||||||
|
display_zone,
|
||||||
|
crate::camera_tz::server_zone(),
|
||||||
|
);
|
||||||
response.exif = Some(exif.into());
|
response.exif = Some(exif.into());
|
||||||
response.library_id = Some(library.id);
|
response.library_id = Some(library.id);
|
||||||
response.library_name = Some(library.name.clone());
|
response.library_name = Some(library.name.clone());
|
||||||
@@ -886,6 +910,15 @@ fn build_metadata_response_for_date_mutation(
|
|||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Earlier of the file's created / modified times — the ingest waterfall's
|
||||||
|
/// `fs_time` rule — for the wall-clock fallback of unindexed files.
|
||||||
|
fn earliest_fs_time(response: &MetadataResponse) -> Option<i64> {
|
||||||
|
match (response.created, response.modified) {
|
||||||
|
(Some(c), Some(m)) => Some(c.min(m)),
|
||||||
|
(c, m) => c.or(m),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[post("/image/exif/date")]
|
#[post("/image/exif/date")]
|
||||||
pub async fn set_image_date(
|
pub async fn set_image_date(
|
||||||
_: Claims,
|
_: Claims,
|
||||||
@@ -946,6 +979,7 @@ pub async fn set_image_date(
|
|||||||
library,
|
library,
|
||||||
&normalized_path,
|
&normalized_path,
|
||||||
row,
|
row,
|
||||||
|
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao, &span_context),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -998,6 +1032,7 @@ pub async fn clear_image_date(
|
|||||||
library,
|
library,
|
||||||
&normalized_path,
|
&normalized_path,
|
||||||
row,
|
row,
|
||||||
|
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao, &span_context),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -164,6 +164,10 @@ pub struct MemoryItem {
|
|||||||
pub date_utc: Option<i64>,
|
pub date_utc: Option<i64>,
|
||||||
pub camera_offset_minutes: Option<i32>,
|
pub camera_offset_minutes: Option<i32>,
|
||||||
pub date_taken_tz_source: Option<String>,
|
pub date_taken_tz_source: Option<String>,
|
||||||
|
/// Capture wall clock to display, naive ISO-8601 (`row_display_wall`):
|
||||||
|
/// the camera's clock, which can differ from the owner's day that
|
||||||
|
/// `created` groups under. Render verbatim (ADR-0001).
|
||||||
|
pub wall_clock: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -648,6 +652,7 @@ pub fn gather_memory_items(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let created = crate::date_view::row_true_instant(&row, display_zone, server_zone);
|
let created = crate::date_view::row_true_instant(&row, display_zone, server_zone);
|
||||||
|
let wall_clock = crate::date_view::row_wall_clock(&row, display_zone, server_zone);
|
||||||
memories_with_dates.push((
|
memories_with_dates.push((
|
||||||
MemoryItem {
|
MemoryItem {
|
||||||
path: row.file_path,
|
path: row.file_path,
|
||||||
@@ -657,6 +662,7 @@ pub fn gather_memory_items(
|
|||||||
date_utc: row.date_utc,
|
date_utc: row.date_utc,
|
||||||
camera_offset_minutes: row.camera_offset_minutes,
|
camera_offset_minutes: row.camera_offset_minutes,
|
||||||
date_taken_tz_source: row.date_taken_tz_source,
|
date_taken_tz_source: row.date_taken_tz_source,
|
||||||
|
wall_clock,
|
||||||
},
|
},
|
||||||
display_day,
|
display_day,
|
||||||
));
|
));
|
||||||
@@ -1141,6 +1147,7 @@ mod tests {
|
|||||||
date_utc: None,
|
date_utc: None,
|
||||||
camera_offset_minutes: None,
|
camera_offset_minutes: None,
|
||||||
date_taken_tz_source: None,
|
date_taken_tz_source: None,
|
||||||
|
wall_clock: None,
|
||||||
},
|
},
|
||||||
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
|
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
|
||||||
),
|
),
|
||||||
@@ -1153,6 +1160,7 @@ mod tests {
|
|||||||
date_utc: None,
|
date_utc: None,
|
||||||
camera_offset_minutes: None,
|
camera_offset_minutes: None,
|
||||||
date_taken_tz_source: None,
|
date_taken_tz_source: None,
|
||||||
|
wall_clock: None,
|
||||||
},
|
},
|
||||||
NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(),
|
NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(),
|
||||||
),
|
),
|
||||||
@@ -1165,6 +1173,7 @@ mod tests {
|
|||||||
date_utc: None,
|
date_utc: None,
|
||||||
camera_offset_minutes: None,
|
camera_offset_minutes: None,
|
||||||
date_taken_tz_source: None,
|
date_taken_tz_source: None,
|
||||||
|
wall_clock: None,
|
||||||
},
|
},
|
||||||
NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(),
|
NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(),
|
||||||
),
|
),
|
||||||
@@ -1506,6 +1515,7 @@ mod tests {
|
|||||||
date_utc: None,
|
date_utc: None,
|
||||||
camera_offset_minutes: None,
|
camera_offset_minutes: None,
|
||||||
date_taken_tz_source: None,
|
date_taken_tz_source: None,
|
||||||
|
wall_clock: None,
|
||||||
};
|
};
|
||||||
let v: serde_json::Value =
|
let v: serde_json::Value =
|
||||||
serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap();
|
serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap();
|
||||||
|
|||||||
@@ -433,6 +433,7 @@ mod tests {
|
|||||||
date_utc: None,
|
date_utc: None,
|
||||||
camera_offset_minutes: None,
|
camera_offset_minutes: None,
|
||||||
date_taken_tz_source: None,
|
date_taken_tz_source: None,
|
||||||
|
wall_clock: None,
|
||||||
},
|
},
|
||||||
memories::MemoryItem {
|
memories::MemoryItem {
|
||||||
path: "b".into(),
|
path: "b".into(),
|
||||||
@@ -442,6 +443,7 @@ mod tests {
|
|||||||
date_utc: None,
|
date_utc: None,
|
||||||
camera_offset_minutes: None,
|
camera_offset_minutes: None,
|
||||||
date_taken_tz_source: None,
|
date_taken_tz_source: None,
|
||||||
|
wall_clock: None,
|
||||||
},
|
},
|
||||||
memories::MemoryItem {
|
memories::MemoryItem {
|
||||||
path: "c".into(),
|
path: "c".into(),
|
||||||
@@ -451,6 +453,7 @@ mod tests {
|
|||||||
date_utc: None,
|
date_utc: None,
|
||||||
camera_offset_minutes: None,
|
camera_offset_minutes: None,
|
||||||
date_taken_tz_source: None,
|
date_taken_tz_source: None,
|
||||||
|
wall_clock: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -469,6 +472,7 @@ mod tests {
|
|||||||
date_utc: None,
|
date_utc: None,
|
||||||
camera_offset_minutes: None,
|
camera_offset_minutes: None,
|
||||||
date_taken_tz_source: None,
|
date_taken_tz_source: None,
|
||||||
|
wall_clock: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn item_at(ts: i64, name: &str) -> memories::MemoryItem {
|
fn item_at(ts: i64, name: &str) -> memories::MemoryItem {
|
||||||
|
|||||||
Reference in new issue
Block a user