Merge pull request 'feat(dates): serve wall_clock + true_instant to clients (#113, #120)' (#146) from feature/wall-clock-dates into master

Reviewed-on: #146
This commit was merged in pull request #146.
This commit is contained in:
cameron committed 2026-09-29 22:01:23 +00:00
commit 3ee2517a9e
12 files changed
+453 -98

No files matched your search

+12
View File
@@ -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
+6 -2
View File
@@ -7,6 +7,7 @@ end-to-end and agent-driven UI testing of the SynologyFileViewer app.
dev/e2e/e2e.sh up # build + fixtures + reset + start; blocks until ingest is done dev/e2e/e2e.sh up # build + fixtures + reset + start; blocks until ingest is done
dev/e2e/e2e.sh reset # back to the pristine baseline (after tests mutate things) dev/e2e/e2e.sh reset # back to the pristine baseline (after tests mutate things)
dev/e2e/e2e.sh stop dev/e2e/e2e.sh stop
dev/e2e/e2e.sh check # every fixture's displayed capture time (wall_clock) vs the manifest
``` ```
Log in from the app with **server** `http://10.0.2.2:8090` (Android Log in from the app with **server** `http://10.0.2.2:8090` (Android
@@ -32,8 +33,11 @@ server. Everything under `.work/` is gitignored and safe to delete.
`gen_fixtures.py` renders every image with its relative path, date and `gen_fixtures.py` renders every image with its relative path, date and
scenario burned into the pixels — a screenshot tells you exactly which scenario burned into the pixels — a screenshot tells you exactly which
fixture is on screen. `.work/state/manifest.json` lists every item with its fixture is on screen. `.work/state/manifest.json` lists every item with its
expected date, GPS, camera, tags and duplicate relationship, plus expected expected date, GPS, camera, tags, duplicate relationship and **expected
counts; assert against it rather than hardcoding. `wall_clock`** (the capture time the UI must show), plus expected counts;
assert against it rather than hardcoding. Naive fixture times are walls in
America/New_York (the e2e display and server zone); videos are stamped the
way phones write containers (that wall, as a UTC `creation_time`).
| Folder | Covers | | Folder | Covers |
|---|---| |---|---|
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Regression check for capture dates (ADR-0001 `wall_clock` / `true_instant`).
For every indexed fixture, the manifest's expected wall clock must equal the
`wall_clock` the server reports on each endpoint that includes the file:
`/image/metadata`, `/memories` (today's day span) and `/photos/gps-summary`;
and `/image/metadata`'s `true_instant` must equal the expected instant.
One disagreement anywhere is exactly the class of bug this field exists to
end, so any mismatch fails the run.
Usage: check_wall_clock.py <manifest.json> (run via `e2e.sh check`)
"""
import json
import os
import subprocess
import sys
import urllib.parse
from pathlib import Path
E2E = Path(__file__).with_name("e2e.sh")
def api(path: str) -> dict:
return json.loads(subprocess.check_output([str(E2E), "api", "GET", path]))
def main() -> int:
manifest = json.load(open(sys.argv[1]))
excluded = tuple(d.strip("/") + "/" for d in manifest["excluded_dirs"])
items = [i for i in manifest["items"] if not i["path"].startswith(excluded)]
memories = {m["path"]: m.get("wall_clock") for m in api("memories?span=day")["items"]}
gps = {g["path"]: g.get("wall_clock") for g in api("photos/gps-summary?path=")["photos"]}
failures = 0
for item in items:
path, want = item["path"], item["wall_clock"]
meta = api("image/metadata?path=" + urllib.parse.quote(path))
got = {"metadata": meta.get("wall_clock")}
if path in memories:
got["memories"] = memories[path]
if path in gps:
got["gps-summary"] = gps[path]
bad = {k: v for k, v in got.items() if v != want}
# The matching anchor (SMS / location windows) must be the true instant.
if meta.get("true_instant") != item["true_instant"]:
bad["metadata true_instant"] = (meta.get("true_instant"), "expected", item["true_instant"])
if bad:
failures += 1
print(f"MISMATCH {path}: expected {want}, got {bad}")
print(f"{len(items) - failures}/{len(items)} fixtures report the expected wall clock + instant "
f"({len(memories)} in memories, {len(gps)} in gps-summary)")
return 1 if failures else 0
if __name__ == "__main__":
os.environ.setdefault("E2E_TOKEN", subprocess.check_output([str(E2E), "token"], text=True).strip())
sys.exit(main())
+7
View File
@@ -87,6 +87,10 @@ LOCAL_BIND_URL=
CORS_ALLOWED_ORIGINS=http://localhost:8081,http://localhost:19006 CORS_ALLOWED_ORIGINS=http://localhost:8081,http://localhost:19006
SECRET_KEY=e2e-dev-only-secret-not-for-production SECRET_KEY=e2e-dev-only-secret-not-for-production
RUST_LOG=info RUST_LOG=info
# Pin the server zone (ImageApi #120 preflight): the Windows exe has no
# /etc/timezone, so without this server_zone() is None and assumed_server
# rows fall back to the process-local offset.
TZ=America/New_York
# Fast ingest so a fresh reset is browsable within seconds. # Fast ingest so a fresh reset is browsable within seconds.
WATCH_QUICK_INTERVAL_SECONDS=3 WATCH_QUICK_INTERVAL_SECONDS=3
WATCH_FULL_INTERVAL_SECONDS=60 WATCH_FULL_INTERVAL_SECONDS=60
@@ -227,6 +231,8 @@ Usage: dev/e2e/e2e.sh <command>
real folder in as media/imported/; the source is read-only) real folder in as media/imported/; the source is read-only)
reset stop, wipe db/thumbs/hls, re-copy media, recreate user reset stop, wipe db/thumbs/hls, re-copy media, recreate user
start | stop | restart | status start | stop | restart | status
check verify every fixture's displayed capture time (wall_clock)
on metadata, memories and gps-summary against the manifest
token print a fresh JWT for the e2e user token print a fresh JWT for the e2e user
api METHOD PATH [JSON] api METHOD PATH [JSON]
authenticated request, e.g. api GET 'photos?path=&recursive=true' authenticated request, e.g. api GET 'photos?path=&recursive=true'
@@ -248,6 +254,7 @@ case "${1:-help}" in
restart) cmd_stop; cmd_start ;; restart) cmd_stop; cmd_start ;;
status) cmd_status ;; status) cmd_status ;;
token) login ;; token) login ;;
check) E2E_TOKEN="$(login)" python3 "$HERE/check_wall_clock.py" "$STATE/manifest.json" ;;
api) shift; api "$@" ;; api) shift; api "$@" ;;
logs) tail -f "$LOG" ;; logs) tail -f "$LOG" ;;
info) cmd_info ;; info) cmd_info ;;
+48 -3
View File
@@ -28,6 +28,7 @@ import sys
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
FFMPEG = shutil.which("ffmpeg.exe") or shutil.which("ffmpeg") FFMPEG = shutil.which("ffmpeg.exe") or shutil.which("ffmpeg")
EXIFTOOL = shutil.which("exiftool.exe") or shutil.which("exiftool") EXIFTOOL = shutil.which("exiftool.exe") or shutil.which("exiftool")
@@ -35,6 +36,10 @@ EXIFTOOL = shutil.which("exiftool.exe") or shutil.which("exiftool")
# the drive letter must be escaped inside a filter argument. # the drive letter must be escaped inside a filter argument.
FONT = "C\\:/Windows/Fonts/consola.ttf" FONT = "C\\:/Windows/Fonts/consola.ttf"
# The owner's display zone and the server zone the e2e env pins (TZ=...).
# Naive fixture datetimes without GPS are walls in this zone.
HOME_TZ = ZoneInfo("America/New_York")
PLACES = { PLACES = {
"santa_monica": (34.0100, -118.4960), "santa_monica": (34.0100, -118.4960),
"paris": (48.8584, 2.2945), "paris": (48.8584, 2.2945),
@@ -42,6 +47,15 @@ PLACES = {
"tokyo": (35.6595, 139.7005), "tokyo": (35.6595, 139.7005),
} }
# IANA zone of each place — what the server's GPS rung of the offset cascade
# resolves, so the expected true instant is the wall read in this zone.
PLACE_ZONES = {
"santa_monica": "America/Los_Angeles",
"paris": "Europe/Paris",
"yosemite": "America/Los_Angeles",
"tokyo": "Asia/Tokyo",
}
PALETTE = [ PALETTE = [
"0x1f4e79", "0x7a3e9d", "0x2e7d32", "0xb71c1c", "0xe65100", "0x1f4e79", "0x7a3e9d", "0x2e7d32", "0xb71c1c", "0xe65100",
"0x006064", "0x4e342e", "0x283593", "0x827717", "0xad1457", "0x006064", "0x4e342e", "0x283593", "0x827717", "0xad1457",
@@ -53,7 +67,9 @@ class Item:
rel: str # path relative to the library root, forward slashes rel: str # path relative to the library root, forward slashes
kind: str # "image" | "video" kind: str # "image" | "video"
size: tuple[int, int] = (1600, 1200) size: tuple[int, int] = (1600, 1200)
taken: dt.datetime | None = None # written to EXIF / video creation_time # Capture wall clock: EXIF DateTimeOriginal as-is; for videos, the wall in
# HOME_TZ, stamped as UTC creation_time the way phones write containers.
taken: dt.datetime | None = None
gps: str | None = None # key into PLACES gps: str | None = None # key into PLACES
camera: tuple[str, str] | None = None # (Make, Model) camera: tuple[str, str] | None = None # (Make, Model)
mtime: dt.datetime | None = None # pinned file mtime (undated fallback) mtime: dt.datetime | None = None # pinned file mtime (undated fallback)
@@ -160,6 +176,30 @@ def build_items(today: dt.date) -> list[Item]:
return items return items
def expected_wall_clock(it: Item) -> str:
"""The wall clock the server should report (`wall_clock`): the filename
date for the filename class (it outranks EXIF), else the capture wall,
else the pinned mtime rendered in HOME_TZ."""
import re
name = it.rel.rsplit("/", 1)[-1]
m = (re.search(r"(\d{4})(\d{2})(\d{2})[_-](\d{2})(\d{2})(\d{2})", name)
or re.search(r"(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})", name))
if m:
wall = dt.datetime(*map(int, m.groups()))
else:
wall = it.taken or it.mtime or dt.datetime(2024, 1, 1)
return wall.strftime("%Y-%m-%dT%H:%M:%S")
def expected_true_instant(it: Item) -> int:
"""The true instant the server should report: the expected wall read in
the zone the camera was in (GPS place, else HOME_TZ — the server's
assumed zone). Filename walls outrank EXIF, but still take GPS evidence."""
wall = dt.datetime.fromisoformat(expected_wall_clock(it))
zone = ZoneInfo(PLACE_ZONES[it.gps]) if it.gps else HOME_TZ
return int(wall.replace(tzinfo=zone).timestamp())
def _same_day(today: dt.date, year: int, hour: int) -> dt.datetime: def _same_day(today: dt.date, year: int, hour: int) -> dt.datetime:
# Feb 29 in a non-leap year would be invalid; fall back to Feb 28. # Feb 29 in a non-leap year would be invalid; fall back to Feb 28.
try: try:
@@ -200,7 +240,8 @@ def render(item: Item, idx: int, media: Path) -> None:
"-c:a", "aac", "-shortest", "-c:a", "aac", "-shortest",
] ]
if item.taken: if item.taken:
cmd += ["-metadata", f"creation_time={item.taken.isoformat()}Z"] utc = item.taken.replace(tzinfo=HOME_TZ).astimezone(dt.timezone.utc)
cmd += ["-metadata", f"creation_time={utc.strftime('%Y-%m-%dT%H:%M:%S')}Z"]
subprocess.run(cmd + [win(out)], check=True) subprocess.run(cmd + [win(out)], check=True)
@@ -271,7 +312,7 @@ def main() -> int:
# Pin mtimes last: exiftool rewrites (and so touches) the files. # Pin mtimes last: exiftool rewrites (and so touches) the files.
for it in items: for it in items:
when = it.mtime or it.taken or dt.datetime(2024, 1, 1) when = it.mtime or it.taken or dt.datetime(2024, 1, 1)
ts = when.timestamp() ts = when.replace(tzinfo=HOME_TZ).timestamp() # not WSL's local zone
os.utime(media / it.rel, (ts, ts)) os.utime(media / it.rel, (ts, ts))
imported = 0 imported = 0
@@ -280,6 +321,7 @@ def main() -> int:
shutil.copytree(a.import_dir, dest) # copy: the source is never touched shutil.copytree(a.import_dir, dest) # copy: the source is never touched
imported = sum(1 for p in dest.rglob("*") if p.is_file()) imported = sum(1 for p in dest.rglob("*") if p.is_file())
by_path = {it.rel: it for it in items}
tags: dict[str, list[str]] = {} tags: dict[str, list[str]] = {}
for it in items: for it in items:
for t in it.tags: for t in it.tags:
@@ -306,6 +348,9 @@ def main() -> int:
"taken": it.taken.isoformat() if it.taken else None, "taken": it.taken.isoformat() if it.taken else None,
"gps": it.gps, "camera": it.camera[1] if it.camera else None, "gps": it.gps, "camera": it.camera[1] if it.camera else None,
"tags": it.tags, "duplicate_of": it.copy_of, "tags": it.tags, "duplicate_of": it.copy_of,
# A duplicate carries its source's bytes, EXIF included.
"wall_clock": expected_wall_clock(by_path.get(it.copy_of, it)),
"true_instant": expected_true_instant(by_path.get(it.copy_of, it)),
} }
for it in items for it in items
], ],
+13
View File
@@ -259,6 +259,14 @@ 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>,
/// True instant of capture (unix seconds) — `date_view::file_true_instant`.
/// For instant-to-instant matching (SMS / location windows); never
/// render it, render `wall_clock`.
pub true_instant: Option<i64>,
} }
impl From<fs::Metadata> for MetadataResponse { impl From<fs::Metadata> for MetadataResponse {
@@ -277,6 +285,8 @@ 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
true_instant: None, // Set in the endpoint handler
} }
} }
} }
@@ -437,6 +447,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
View File
@@ -389,25 +389,15 @@ pub trait ExifDao: Sync + Send {
/// Get all file paths from EXIF database /// Get all file paths from EXIF database
fn get_all_file_paths(&mut self) -> Result<Vec<String>, DbError>; fn get_all_file_paths(&mut self) -> 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,
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
@@ -1168,23 +1158,11 @@ 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,
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("query", "get_all_with_gps", |span| { trace_db_call("query", "get_all_with_gps", |span| {
use schema::image_exif::dsl::*; use schema::image_exif::dsl::*;
@@ -1216,36 +1194,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("result_count", filtered.len() as i64); span.set_attribute("result_count", filtered.len() as i64);
+215
View File
@@ -184,6 +184,84 @@ 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>>,
) -> Tz {
use std::str::FromStr;
let stored = prefs_dao
.lock()
.ok()
.and_then(|mut dao| dao.get_prefs().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)
}
/// True instant for a file that may have no `image_exif` row yet — the
/// [`file_wall_clock`] precedence (row, then filename-class date, then
/// earliest fs time). The anchor for instant-to-instant matching (the
/// "messages around this photo" window), never for display.
pub fn file_true_instant(
row: Option<&ImageExif>,
rel_path: &str,
fs_earliest: Option<i64>,
display_zone: Tz,
server_zone: Option<Tz>,
) -> Option<i64> {
if let Some(instant) = row.and_then(|r| row_true_instant(r, display_zone, server_zone)) {
return Some(instant);
}
match crate::memories::extract_date_from_filename(rel_path) {
Some(fd) => filename_true_instant(&fd, server_zone),
None => fs_earliest,
}
}
/// 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 +642,141 @@ 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
);
}
#[test]
fn file_true_instant_uses_the_row_then_filename_then_fs_time() {
// Row: the converted instant, not the wall (Tokyo 14:00 = 05:00Z).
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!(
file_true_instant(Some(&r), "otd.jpg", Some(0), ny(), Some(ny())),
Some(utc_epoch(2018, 9, 29, 5, 0))
);
// No row: a wall-pattern filename is read in the server zone
// (10:15 EDT = 14:15Z).
assert_eq!(
file_true_instant(
None,
"Screenshots/Screenshot_20220315-101500.png",
Some(0),
ny(),
Some(ny())
),
Some(utc_epoch(2022, 3, 15, 14, 15))
);
// No row, no filename date: fs time is already an instant.
assert_eq!(
file_true_instant(
None,
"Undated/scan.jpg",
Some(1_425_229_200),
ny(),
Some(ny())
),
Some(1_425_229_200)
);
}
} }
+17 -31
View File
@@ -1100,27 +1100,24 @@ 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(&requested_path, recursive) { match exif_dao_guard.get_all_with_gps(&requested_path, recursive) {
Ok(gps_data) => { Ok(gps_data) => {
let display_zone = crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao);
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)
@@ -1632,18 +1629,7 @@ mod tests {
&mut self, &mut self,
_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!()
} }
+51 -5
View File
@@ -524,11 +524,17 @@ 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(&path.path) .lock()
{ .ok()
response.exif = Some(exif.into()); .and_then(|mut dao| dao.get_exif(&path.path).ok().flatten());
} set_capture_dates(
&mut response,
row.as_ref(),
&path.path,
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
);
response.exif = row.map(Into::into);
span.add_event( span.add_event(
"Metadata fetched", "Metadata fetched",
@@ -706,8 +712,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,
true_instant: None,
}, },
}; };
set_capture_dates(
&mut response,
Some(&row),
&normalized_path,
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
);
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());
@@ -848,6 +862,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
@@ -864,8 +879,11 @@ 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,
true_instant: None,
}, },
}; };
set_capture_dates(&mut response, Some(&exif), rel_path, display_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());
@@ -874,6 +892,32 @@ fn build_metadata_response_for_date_mutation(
response response
} }
/// Fill the capture-date fields every metadata response carries (ADR-0001):
/// `wall_clock` to display and `true_instant` to match against. Needs the
/// fs times already on the response (the unindexed-file fallback).
fn set_capture_dates(
response: &mut MetadataResponse,
row: Option<&ImageExif>,
rel_path: &str,
display_zone: chrono_tz::Tz,
) {
let server_zone = crate::camera_tz::server_zone();
let fs_earliest = earliest_fs_time(response);
response.wall_clock =
crate::date_view::file_wall_clock(row, rel_path, fs_earliest, display_zone, server_zone);
response.true_instant =
crate::date_view::file_true_instant(row, rel_path, fs_earliest, display_zone, server_zone);
}
/// 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")]
#[tracing::instrument(name = "set_image_date", skip_all)] #[tracing::instrument(name = "set_image_date", skip_all)]
pub async fn set_image_date( pub async fn set_image_date(
@@ -924,6 +968,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),
)) ))
} }
Err(e) => { Err(e) => {
@@ -972,6 +1017,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),
)) ))
} }
Err(e) => { Err(e) => {
+10
View File
@@ -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)]
@@ -637,6 +641,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,
@@ -646,6 +651,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,
)); ));
@@ -1130,6 +1136,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(),
), ),
@@ -1142,6 +1149,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(),
), ),
@@ -1154,6 +1162,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(),
), ),
@@ -1484,6 +1493,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();
+4
View File
@@ -430,6 +430,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(),
@@ -439,6 +440,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(),
@@ -448,6 +450,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!(
@@ -466,6 +469,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 {