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_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
(`get_memories_in_window` in `src/database/mod.rs`), using
`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 reset # back to the pristine baseline (after tests mutate things)
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
@@ -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
scenario burned into the pixels — a screenshot tells you exactly which
fixture is on screen. `.work/state/manifest.json` lists every item with its
expected date, GPS, camera, tags and duplicate relationship, plus expected
counts; assert against it rather than hardcoding.
expected date, GPS, camera, tags, duplicate relationship and **expected
`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 |
|---|---|
+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
SECRET_KEY=e2e-dev-only-secret-not-for-production
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.
WATCH_QUICK_INTERVAL_SECONDS=3
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)
reset stop, wipe db/thumbs/hls, re-copy media, recreate user
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
api METHOD PATH [JSON]
authenticated request, e.g. api GET 'photos?path=&recursive=true'
@@ -248,6 +254,7 @@ case "${1:-help}" in
restart) cmd_stop; cmd_start ;;
status) cmd_status ;;
token) login ;;
check) E2E_TOKEN="$(login)" python3 "$HERE/check_wall_clock.py" "$STATE/manifest.json" ;;
api) shift; api "$@" ;;
logs) tail -f "$LOG" ;;
info) cmd_info ;;
+48 -3
View File
@@ -28,6 +28,7 @@ import sys
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path
from zoneinfo import ZoneInfo
FFMPEG = shutil.which("ffmpeg.exe") or shutil.which("ffmpeg")
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.
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 = {
"santa_monica": (34.0100, -118.4960),
"paris": (48.8584, 2.2945),
@@ -42,6 +47,15 @@ PLACES = {
"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 = [
"0x1f4e79", "0x7a3e9d", "0x2e7d32", "0xb71c1c", "0xe65100",
"0x006064", "0x4e342e", "0x283593", "0x827717", "0xad1457",
@@ -53,7 +67,9 @@ class Item:
rel: str # path relative to the library root, forward slashes
kind: str # "image" | "video"
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
camera: tuple[str, str] | None = None # (Make, Model)
mtime: dt.datetime | None = None # pinned file mtime (undated fallback)
@@ -160,6 +176,30 @@ def build_items(today: dt.date) -> list[Item]:
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:
# Feb 29 in a non-leap year would be invalid; fall back to Feb 28.
try:
@@ -200,7 +240,8 @@ def render(item: Item, idx: int, media: Path) -> None:
"-c:a", "aac", "-shortest",
]
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)
@@ -271,7 +312,7 @@ def main() -> int:
# Pin mtimes last: exiftool rewrites (and so touches) the files.
for it in items:
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))
imported = 0
@@ -280,6 +321,7 @@ def main() -> int:
shutil.copytree(a.import_dir, dest) # copy: the source is never touched
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]] = {}
for it in items:
for t in it.tags:
@@ -306,6 +348,9 @@ def main() -> int:
"taken": it.taken.isoformat() if it.taken else None,
"gps": it.gps, "camera": it.camera[1] if it.camera else None,
"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
],
+13
View File
@@ -259,6 +259,14 @@ pub struct MetadataResponse {
pub filename_date: Option<i64>, // Date extracted from filename
pub library_id: Option<i32>,
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 {
@@ -277,6 +285,8 @@ impl From<fs::Metadata> for MetadataResponse {
filename_date: None, // Will be set in endpoint handler
library_id: 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`,
/// `assumed_server`, `utc_direct`, `manual`).
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)]
+10 -57
View File
@@ -389,25 +389,15 @@ pub trait ExifDao: Sync + Send {
/// Get all file paths from EXIF database
fn get_all_file_paths(&mut self) -> Result<Vec<String>, DbError>;
/// Get all photos with GPS coordinates
/// Returns Vec<(file_path, latitude, longitude, date_taken)>
#[allow(clippy::type_complexity)]
/// Get all rows with GPS coordinates (both latitude and longitude set)
/// under `base_path`. Full rows, so callers can apply the date contract
/// (`date_view`) — which needs the date source and path, not just the
/// new-contract triple.
fn get_all_with_gps(
&mut self,
base_path: &str,
recursive: bool,
) -> Result<
Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)>,
DbError,
>;
) -> Result<Vec<ImageExif>, DbError>;
/// Return rows that still lack a `content_hash`, oldest first. Used by
/// 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))
}
#[allow(clippy::type_complexity)]
fn get_all_with_gps(
&mut self,
base_path: &str,
recursive: bool,
) -> Result<
Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)>,
DbError,
> {
) -> Result<Vec<ImageExif>, DbError> {
trace_db_call("query", "get_all_with_gps", |span| {
use schema::image_exif::dsl::*;
@@ -1216,36 +1194,11 @@ impl ExifDao for SqliteExifDao {
.load::<ImageExif>(connection)
.map_err(|e| anyhow::anyhow!("GPS query error: {}", e))?;
// Convert to tuple format (path, lat, lon, legacy date_taken,
// date_utc, camera_offset_minutes, date_taken_tz_source).
// Filter out any rows where GPS is still None (shouldn't happen due to filter)
// Cast f32 GPS values to f64 for API compatibility
let filtered: Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)> = results
// The filter already requires both coordinates; keep the guard so
// callers can rely on lat/lon being present.
let filtered: Vec<ImageExif> = results
.into_iter()
.filter_map(|exif| {
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
}
})
.filter(|exif| exif.gps_latitude.is_some() && exif.gps_longitude.is_some())
.collect();
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`.
fn render_in_zone_opt(instant: i64, zone: Tz) -> Option<NaiveDateTime> {
zone.timestamp_opt(instant, 0)
@@ -564,4 +642,141 @@ mod tests {
);
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");
match exif_dao_guard.get_all_with_gps(&requested_path, recursive) {
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
.into_iter()
.map(
|(
path,
lat,
lon,
date_taken,
date_utc,
camera_offset_minutes,
date_taken_tz_source,
)| GpsPhotoSummary {
path,
lat,
lon,
date_taken,
date_utc,
camera_offset_minutes,
date_taken_tz_source,
},
)
.filter_map(|row| {
let wall_clock =
crate::date_view::row_wall_clock(&row, display_zone, server_zone);
Some(GpsPhotoSummary {
lat: row.gps_latitude? as f64,
lon: row.gps_longitude? as f64,
path: row.file_path,
date_taken: row.date_taken,
date_utc: row.date_utc,
camera_offset_minutes: row.camera_offset_minutes,
date_taken_tz_source: row.date_taken_tz_source,
wall_clock,
})
})
.collect();
// Sort by date_taken based on request, defaulting to ascending (oldest to newest)
@@ -1632,18 +1629,7 @@ mod tests {
&mut self,
_base_path: &str,
_recursive: bool,
) -> Result<
Vec<(
String,
f64,
f64,
Option<i64>,
Option<i64>,
Option<i32>,
Option<String>,
)>,
DbError,
> {
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
todo!()
}
+51 -5
View File
@@ -524,11 +524,17 @@ pub async fn get_file_metadata(
.map(memories::FilenameDate::legacy_timestamp);
// Query EXIF data if available
if let Ok(mut dao) = exif_dao.lock()
&& let Ok(Some(exif)) = dao.get_exif(&path.path)
{
response.exif = Some(exif.into());
}
let row = exif_dao
.lock()
.ok()
.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(
"Metadata fetched",
@@ -706,8 +712,16 @@ pub async fn set_image_gps(
filename_date: None,
library_id: 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.library_id = Some(resolved_library.id);
response.library_name = Some(resolved_library.name.clone());
@@ -848,6 +862,7 @@ fn build_metadata_response_for_date_mutation(
library: &libraries::Library,
rel_path: &str,
exif: ImageExif,
display_zone: chrono_tz::Tz,
) -> MetadataResponse {
let full_path = is_valid_full_path(&library.root_path, &rel_path.to_string(), false);
let fs_meta = full_path
@@ -864,8 +879,11 @@ fn build_metadata_response_for_date_mutation(
filename_date: None,
library_id: 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.library_id = Some(library.id);
response.library_name = Some(library.name.clone());
@@ -874,6 +892,32 @@ fn build_metadata_response_for_date_mutation(
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")]
#[tracing::instrument(name = "set_image_date", skip_all)]
pub async fn set_image_date(
@@ -924,6 +968,7 @@ pub async fn set_image_date(
library,
&normalized_path,
row,
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
))
}
Err(e) => {
@@ -972,6 +1017,7 @@ pub async fn clear_image_date(
library,
&normalized_path,
row,
crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
))
}
Err(e) => {
+10
View File
@@ -164,6 +164,10 @@ pub struct MemoryItem {
pub date_utc: Option<i64>,
pub camera_offset_minutes: Option<i32>,
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)]
@@ -637,6 +641,7 @@ pub fn gather_memory_items(
}
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((
MemoryItem {
path: row.file_path,
@@ -646,6 +651,7 @@ pub fn gather_memory_items(
date_utc: row.date_utc,
camera_offset_minutes: row.camera_offset_minutes,
date_taken_tz_source: row.date_taken_tz_source,
wall_clock,
},
display_day,
));
@@ -1130,6 +1136,7 @@ mod tests {
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
wall_clock: None,
},
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
),
@@ -1142,6 +1149,7 @@ mod tests {
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
wall_clock: None,
},
NaiveDate::from_ymd_opt(2020, 1, 15).unwrap(),
),
@@ -1154,6 +1162,7 @@ mod tests {
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
wall_clock: None,
},
NaiveDate::from_ymd_opt(2021, 1, 16).unwrap(),
),
@@ -1484,6 +1493,7 @@ mod tests {
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
wall_clock: None,
};
let v: serde_json::Value =
serde_json::to_value(&MemoriesResponse { items: vec![item] }).unwrap();
+4
View File
@@ -430,6 +430,7 @@ mod tests {
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
wall_clock: None,
},
memories::MemoryItem {
path: "b".into(),
@@ -439,6 +440,7 @@ mod tests {
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
wall_clock: None,
},
memories::MemoryItem {
path: "c".into(),
@@ -448,6 +450,7 @@ mod tests {
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
wall_clock: None,
},
];
assert_eq!(
@@ -466,6 +469,7 @@ mod tests {
date_utc: None,
camera_offset_minutes: None,
date_taken_tz_source: None,
wall_clock: None,
}
}
fn item_at(ts: i64, name: &str) -> memories::MemoryItem {