Closes #127. Grows user_ai_prefs with five nullable owner-default columns and makes them the server-side fallback layer: request param -> stored pref -> hard default, at insight generation (agentic + standard), all three insight-chat turn variants, and reel creation. - PUT /prefs accepts the fields with the existing three-state partial update semantics (TriState generalized to TriState<T> for the numeric reel_max_segments); validation (canonical BackendKind::parse, MemoriesSpan::from_str) runs before any write. - GET /prefs returns the new fields. - capture_prefs reads the existing row and carries the AI owner-defaults through unchanged (the upsert overwrites all columns), mirroring only the effective reel span/segments. - MemoriesSpan gains Display + FromStr so stored spans round-trip losslessly. 1463/0 tests (+27), clippy clean.
1521 lines
59 KiB
Rust
1521 lines
59 KiB
Rust
use actix_web::web::Data;
|
|
use actix_web::{HttpRequest, HttpResponse, Responder, get, web};
|
|
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;
|
|
use crate::database::ExifDao;
|
|
use crate::otel::{extract_context_from_request, global_tracer};
|
|
use crate::state::AppState;
|
|
|
|
// Helper that encapsulates path-exclusion semantics
|
|
#[derive(Debug)]
|
|
pub struct PathExcluder {
|
|
base: PathBuf,
|
|
excluded_dirs: Vec<PathBuf>,
|
|
excluded_patterns: Vec<String>,
|
|
}
|
|
|
|
impl PathExcluder {
|
|
/// Build from a `base` path and the raw exclusion entries.
|
|
///
|
|
/// Rules:
|
|
/// - Entries starting with '/' are interpreted as "absolute under base"
|
|
/// (e.g. "/photos/private" -> base/photos/private).
|
|
/// - Entries without '/' are treated as path-component patterns that
|
|
/// match a directory or file name *under* `base`. The base prefix is
|
|
/// stripped before matching so a system-level component (e.g. the
|
|
/// `tmp` in `/tmp/...` when running tests) doesn't masquerade as a
|
|
/// user-defined exclude.
|
|
pub fn new(base: &Path, raw_excluded: &[String]) -> Self {
|
|
let mut excluded_dirs = Vec::new();
|
|
let mut excluded_patterns = Vec::new();
|
|
|
|
for dir in raw_excluded {
|
|
if let Some(rel) = dir.strip_prefix('/') {
|
|
// Absolute under base
|
|
if !rel.is_empty() {
|
|
excluded_dirs.push(base.join(rel));
|
|
}
|
|
} else {
|
|
// Pattern anywhere under base
|
|
excluded_patterns.push(dir.clone());
|
|
}
|
|
}
|
|
|
|
debug!(
|
|
"PathExcluder created. base={:?}, dirs={:?}, patterns={:?}",
|
|
base, excluded_dirs, excluded_patterns
|
|
);
|
|
|
|
Self {
|
|
base: base.to_path_buf(),
|
|
excluded_dirs,
|
|
excluded_patterns,
|
|
}
|
|
}
|
|
|
|
/// Returns true if `path` should be excluded.
|
|
pub fn is_excluded(&self, path: &Path) -> bool {
|
|
// Directory-based exclusions
|
|
for excluded in &self.excluded_dirs {
|
|
if path.starts_with(excluded) {
|
|
trace!(
|
|
"PathExcluder: excluded by dir: {:?} (rule: {:?})",
|
|
path, excluded
|
|
);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if self.excluded_patterns.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
// Strip the base prefix before scanning components. Without this,
|
|
// every path component above `base` (e.g. `tmp` in `/tmp/test123`
|
|
// under tempdir, or the user's `home` in `/home/user/Pictures`)
|
|
// would match user-defined patterns and produce false positives.
|
|
let scan_root = path.strip_prefix(&self.base).unwrap_or(path);
|
|
for component in scan_root.components() {
|
|
if let Some(comp_str) = component.as_os_str().to_str()
|
|
&& self.excluded_patterns.iter().any(|pat| pat == comp_str)
|
|
{
|
|
trace!(
|
|
"PathExcluder: excluded by component pattern: {:?} (component: {:?}, patterns: {:?})",
|
|
path, comp_str, self.excluded_patterns
|
|
);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
}
|
|
|
|
#[derive(Copy, Clone, Deserialize, PartialEq, Debug)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum MemoriesSpan {
|
|
Day,
|
|
Week,
|
|
Month,
|
|
}
|
|
|
|
/// Wire/storage string for the span (`"day"` | `"week"` | `"month"`) — the
|
|
/// same lowercase names as the serde rename, so a stored pref round-trips
|
|
/// through `MemoriesRequest.span` unchanged.
|
|
impl std::fmt::Display for MemoriesSpan {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(match self {
|
|
MemoriesSpan::Day => "day",
|
|
MemoriesSpan::Week => "week",
|
|
MemoriesSpan::Month => "month",
|
|
})
|
|
}
|
|
}
|
|
|
|
impl std::str::FromStr for MemoriesSpan {
|
|
type Err = String;
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
match s.trim().to_ascii_lowercase().as_str() {
|
|
"day" => Ok(MemoriesSpan::Day),
|
|
"week" => Ok(MemoriesSpan::Week),
|
|
"month" => Ok(MemoriesSpan::Month),
|
|
other => Err(format!("invalid span {other:?} (expected day|week|month)")),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct MemoriesRequest {
|
|
pub span: Option<MemoriesSpan>,
|
|
/// 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.
|
|
pub library: Option<String>,
|
|
}
|
|
|
|
#[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)]
|
|
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:
|
|
/// - **`Wall`** — the device's own clock, as displayed (screenshots,
|
|
/// dash / compact name formats). It is a wall clock and needs the
|
|
/// offset cascade (offset tag → GPS → server zone) to become a true
|
|
/// instant, exactly like a photo-EXIF wall clock.
|
|
/// - **`Utc`** — a unix-timestamp name (`1401638400`, `att_1422489664…`)
|
|
/// is already a true instant; no offset is involved.
|
|
///
|
|
/// Files whose name matches any pattern are **in the filename date
|
|
/// class**: the filename is the original date of record and outranks
|
|
/// EXIF / container dates (ADR-0001). Files whose name matches nothing
|
|
/// are out of the class and keep the global EXIF-wins waterfall.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
pub enum FilenameDate {
|
|
/// Wall clock the device displayed (screenshot / dash / compact).
|
|
Wall(NaiveDateTime),
|
|
/// True instant the unix-timestamp pattern names directly.
|
|
Utc(DateTime<Utc>),
|
|
}
|
|
|
|
impl FilenameDate {
|
|
/// The exact value the legacy `date_taken` column stores for this
|
|
/// filename: wall patterns as wall-as-UTC (the pre-migration
|
|
/// convention), timestamp patterns as their epoch. Consumers that
|
|
/// only need the legacy value (metadata responses, insight prompt
|
|
/// fallbacks) use this so the legacy contract stays byte-identical.
|
|
pub fn legacy_datetime(self) -> DateTime<Utc> {
|
|
match self {
|
|
FilenameDate::Wall(wall) => wall.and_utc(),
|
|
FilenameDate::Utc(dt) => dt,
|
|
}
|
|
}
|
|
|
|
/// The exact legacy `date_taken` unix seconds (`legacy_datetime`
|
|
/// as an epoch). The one-liner most consumers need.
|
|
pub fn legacy_timestamp(self) -> i64 {
|
|
self.legacy_datetime().timestamp()
|
|
}
|
|
}
|
|
|
|
/// A filename that matched one of the filename-class patterns (#116):
|
|
/// which pattern fired and the date it carries.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
pub struct FilenameMatch {
|
|
/// Stable pattern name for audit / debug surfaces: `screenshot`,
|
|
/// `screenshot_compact`, `dash`, `dash_compact`, `compact`,
|
|
/// `unix_timestamp`.
|
|
pub pattern: &'static str,
|
|
pub date: FilenameDate,
|
|
}
|
|
|
|
/// Match a filename against the filename-class patterns, reporting which
|
|
/// pattern fired. This is the canonical matcher; `extract_date_from_filename`
|
|
/// is the date-only projection of it.
|
|
pub fn match_filename_date(filename: &str) -> Option<FilenameMatch> {
|
|
// The wall-clock patterns (screenshot / dash / compact) carry the
|
|
// device's own clock; the unix-timestamp pattern carries a true
|
|
// instant. `FilenameDate::legacy_datetime()` reproduces the
|
|
// pre-migration interpretation of each (naive-as-UTC / epoch) for the
|
|
// legacy column and its consumers.
|
|
let build_wall_from_ymd_capture = |captures: ®ex::Captures| -> Option<NaiveDateTime> {
|
|
let year = captures.get(1)?.as_str().parse::<i32>().ok()?;
|
|
let month = captures.get(2)?.as_str().parse::<u32>().ok()?;
|
|
let day = captures.get(3)?.as_str().parse::<u32>().ok()?;
|
|
let hour = captures.get(4)?.as_str().parse::<u32>().ok()?;
|
|
let min = captures.get(5)?.as_str().parse::<u32>().ok()?;
|
|
let sec = captures.get(6)?.as_str().parse::<u32>().ok()?;
|
|
|
|
NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, min, sec)
|
|
};
|
|
|
|
// 1. Screenshot format: Screenshot_2014-06-01-20-44-50.png
|
|
if let Some(naive) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})")
|
|
.ok()?
|
|
.captures(filename)
|
|
.and_then(|c| build_wall_from_ymd_capture(&c))
|
|
{
|
|
return Some(FilenameMatch {
|
|
pattern: "screenshot",
|
|
date: FilenameDate::Wall(naive),
|
|
});
|
|
}
|
|
|
|
// Screenshot format: Screenshot_20140601[_-]204450.png
|
|
if let Some(naive) = regex::Regex::new(r"(\d{4})(\d{2})(\d{2})[_-](\d{2})(\d{2})(\d{2})")
|
|
.ok()?
|
|
.captures(filename)
|
|
.and_then(|c| build_wall_from_ymd_capture(&c))
|
|
{
|
|
return Some(FilenameMatch {
|
|
pattern: "screenshot_compact",
|
|
date: FilenameDate::Wall(naive),
|
|
});
|
|
}
|
|
|
|
// 2. Dash format: 2015-01-09_02-15-15.jpg
|
|
if let Some(naive) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})")
|
|
.ok()?
|
|
.captures(filename)
|
|
.and_then(|c| build_wall_from_ymd_capture(&c))
|
|
{
|
|
return Some(FilenameMatch {
|
|
pattern: "dash",
|
|
date: FilenameDate::Wall(naive),
|
|
});
|
|
}
|
|
|
|
// Dash with compact time format: 2015-01-09-021515.jpg
|
|
if let Some(naive) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})")
|
|
.ok()?
|
|
.captures(filename)
|
|
.and_then(|c| build_wall_from_ymd_capture(&c))
|
|
{
|
|
return Some(FilenameMatch {
|
|
pattern: "dash_compact",
|
|
date: FilenameDate::Wall(naive),
|
|
});
|
|
}
|
|
|
|
// 3. Compact format: 20140927101712.jpg
|
|
if let Some(naive) = regex::Regex::new(r"(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})")
|
|
.ok()?
|
|
.captures(filename)
|
|
.and_then(|c| build_wall_from_ymd_capture(&c))
|
|
{
|
|
return Some(FilenameMatch {
|
|
pattern: "compact",
|
|
date: FilenameDate::Wall(naive),
|
|
});
|
|
}
|
|
|
|
// 4. Timestamp format: 1401638400.jpeg, att_1422489664680106.jpeg, att_142248967186928.jpeg
|
|
// Matches timestamps with 10-16 digits (seconds, milliseconds, microseconds)
|
|
if let Some(captures) = regex::Regex::new(r"(\d{10,16})\.").ok()?.captures(filename) {
|
|
let timestamp_str = captures.get(1)?.as_str();
|
|
let len = timestamp_str.len();
|
|
|
|
// Snapchat used real unix-second filenames in its early era
|
|
// (e.g. `Snapchat-1383929602.jpg` = 2013-11-08), then switched to
|
|
// monotonic sequential IDs whose digits overlap plausible epoch
|
|
// ranges (`Snapchat-1021849065.mp4` truncates to 2002, actually
|
|
// saved 2021; `Snapchat-1751031586660373917.jpg` is 19 digits,
|
|
// truncates to 2002, actually 2016). Discriminate by:
|
|
// - exactly 10 captured digits AND post-2011-09-23 (launch) → real epoch
|
|
// - anything else under this prefix → sequential ID, fall through
|
|
// The Snapchat-launch floor catches the 10-digit-2002 case; the
|
|
// length=10 gate catches the multi-digit sequential IDs (which
|
|
// get truncated to 16 by the regex above).
|
|
let lower = filename.to_ascii_lowercase();
|
|
let is_snapchat = lower.starts_with("snapchat-");
|
|
if is_snapchat && len != 10 {
|
|
return None;
|
|
}
|
|
|
|
// Skip autogenerated filenames that start with "10000" (e.g., 1000004178.jpg)
|
|
// These are not timestamps but auto-generated file IDs
|
|
if timestamp_str.starts_with("10000") {
|
|
return None;
|
|
}
|
|
|
|
// A leading zero rules out a real unix timestamp at any sane
|
|
// resolution (seconds since 2001-09-09, ms since 1970-01-01 are
|
|
// both 10+ digits with no leading zero). Filenames like
|
|
// `000227580005.jpg` are sequential scan IDs, not timestamps.
|
|
if timestamp_str.starts_with('0') {
|
|
return None;
|
|
}
|
|
|
|
// Try milliseconds first (13 digits exactly)
|
|
if len == 13
|
|
&& let Some(date_time) = timestamp_str
|
|
.parse::<i64>()
|
|
.ok()
|
|
.and_then(DateTime::<Utc>::from_timestamp_millis)
|
|
.and_then(plausible_filename_date)
|
|
{
|
|
return Some(FilenameMatch {
|
|
pattern: "unix_timestamp",
|
|
date: FilenameDate::Utc(date_time),
|
|
});
|
|
}
|
|
|
|
// For 14-16 digits, treat first 10 digits as seconds to avoid far future dates
|
|
// Examples: att_1422489664680106 (16 digits), att_142248967186928 (15 digits)
|
|
if (14..=16).contains(&len)
|
|
&& let Some(date_time) = timestamp_str[0..10]
|
|
.parse::<i64>()
|
|
.ok()
|
|
.and_then(|ts| DateTime::<Utc>::from_timestamp(ts, 0))
|
|
.and_then(plausible_filename_date)
|
|
{
|
|
return Some(FilenameMatch {
|
|
pattern: "unix_timestamp",
|
|
date: FilenameDate::Utc(date_time),
|
|
});
|
|
}
|
|
|
|
// Exactly 10 digits - seconds since epoch
|
|
if len == 10
|
|
&& let Some(date_time) = timestamp_str
|
|
.parse::<i64>()
|
|
.ok()
|
|
.and_then(|ts| DateTime::<Utc>::from_timestamp(ts, 0))
|
|
.and_then(plausible_filename_date)
|
|
{
|
|
// Snapchat launched 2011-09-23. A 10-digit Snapchat filename
|
|
// dated before that is a sequential ID (e.g.
|
|
// `Snapchat-1021849065.mp4` parses to 2002), not a real epoch.
|
|
const SNAPCHAT_LAUNCH_TS: i64 = 1_316_736_000;
|
|
if is_snapchat && date_time.timestamp() < SNAPCHAT_LAUNCH_TS {
|
|
return None;
|
|
}
|
|
return Some(FilenameMatch {
|
|
pattern: "unix_timestamp",
|
|
date: FilenameDate::Utc(date_time),
|
|
});
|
|
}
|
|
|
|
// 11-12 digits: try as milliseconds (might be partial millisecond timestamp)
|
|
if (len == 11 || len == 12)
|
|
&& let Some(date_time) = timestamp_str
|
|
.parse::<i64>()
|
|
.ok()
|
|
.and_then(DateTime::<Utc>::from_timestamp_millis)
|
|
.and_then(plausible_filename_date)
|
|
{
|
|
return Some(FilenameMatch {
|
|
pattern: "unix_timestamp",
|
|
date: FilenameDate::Utc(date_time),
|
|
});
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Date-only projection of [`match_filename_date`]: the class match
|
|
/// without the pattern name. Resolver and consumer call sites use this;
|
|
/// the audit utility uses the full match.
|
|
pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
|
|
match_filename_date(filename).map(|m| m.date)
|
|
}
|
|
|
|
/// Sanity gate for filename-derived timestamps. Real photo capture dates
|
|
/// live in a narrow window; values outside it are almost always sequential
|
|
/// scan IDs (`000227580005.jpg` → 1970) or arbitrary numeric suffixes
|
|
/// (`IMG_21323906751390.jpeg` → 2037) that the regex caught by accident.
|
|
/// Rejecting them lets the date_resolver waterfall fall through to
|
|
/// `fs_time`, which is a much better proxy for content age than a fake
|
|
/// epoch date.
|
|
fn plausible_filename_date(dt: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
|
use chrono::Datelike;
|
|
let year = dt.year();
|
|
// 1995 predates digital photography for most users; allowing one year
|
|
// past `now` covers clock-skew on freshly-taken shots without letting
|
|
// 2037 timestamps through.
|
|
let max_year = Utc::now().year() + 1;
|
|
if (1995..=max_year).contains(&year) {
|
|
Some(dt)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
pub const DEFAULT_YEARS_BACK: i32 = 20;
|
|
|
|
#[get("/memories")]
|
|
pub async fn list_memories(
|
|
_claims: Claims,
|
|
request: HttpRequest,
|
|
q: web::Query<MemoriesRequest>,
|
|
app_state: Data<AppState>,
|
|
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
|
) -> impl Responder {
|
|
let tracer = global_tracer();
|
|
let parent_context = extract_context_from_request(&request);
|
|
let mut span = tracer.start_with_context("list_memories", &parent_context);
|
|
let span_context =
|
|
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
|
|
|
|
let span_mode = q.span.unwrap_or(MemoriesSpan::Day);
|
|
|
|
// 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,
|
|
display_zone,
|
|
q.library.as_deref(),
|
|
) {
|
|
Ok(items) => items,
|
|
Err(msg) => {
|
|
warn!("Rejecting /memories request: {}", msg);
|
|
return HttpResponse::BadRequest().body(msg);
|
|
}
|
|
};
|
|
|
|
span.add_event(
|
|
"memories_scanned",
|
|
vec![
|
|
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("display_tz", display_zone_name),
|
|
KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)),
|
|
],
|
|
);
|
|
span.set_status(Status::Ok);
|
|
|
|
HttpResponse::Ok().json(MemoriesResponse { items })
|
|
}
|
|
|
|
/// 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, 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,
|
|
display_zone: chrono_tz::Tz,
|
|
library_param: Option<&str>,
|
|
) -> Result<Vec<MemoryItem>, String> {
|
|
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={:?} display_zone={:?} years_back={}",
|
|
span_mode, display_zone, years_back
|
|
);
|
|
|
|
let library = crate::libraries::resolve_library_param_state(app_state, library_param)?;
|
|
let libraries_to_scan: Vec<&crate::libraries::Library> = match library {
|
|
Some(lib) => vec![lib],
|
|
None => app_state.libraries.iter().collect(),
|
|
};
|
|
|
|
// (item, date) tuples — `date` is the canonical NaiveDate of the
|
|
// memory in the client's tz, used as the primary sort key.
|
|
let mut memories_with_dates: Vec<(MemoryItem, NaiveDate)> = Vec::new();
|
|
|
|
for lib in &libraries_to_scan {
|
|
let base = Path::new(&lib.root_path);
|
|
let effective = lib.effective_excluded_dirs(&app_state.excluded_dirs);
|
|
let path_excluder = PathExcluder::new(base, &effective);
|
|
|
|
let rows = match exif_dao.lock() {
|
|
Ok(mut dao) => match dao.get_memories_in_window(
|
|
span_context,
|
|
lib.id,
|
|
conv_lo,
|
|
conv_hi,
|
|
unconv_lo,
|
|
unconv_hi,
|
|
) {
|
|
Ok(rows) => rows,
|
|
Err(e) => {
|
|
warn!(
|
|
"Failed to query memories for library '{}': {:?}",
|
|
lib.name, e
|
|
);
|
|
continue;
|
|
}
|
|
},
|
|
Err(e) => {
|
|
warn!("Failed to lock EXIF DAO: {:?}", e);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
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(&row.file_path);
|
|
if path_excluder.is_excluded(&full_path) {
|
|
trace!("Memory excluded by PathExcluder: {:?}", full_path);
|
|
continue;
|
|
}
|
|
|
|
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: 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,
|
|
},
|
|
display_day,
|
|
));
|
|
}
|
|
}
|
|
|
|
// Sort once over the merged result set. The SQL filter handles the
|
|
// matching; sort order is purely UI concern.
|
|
match span_mode {
|
|
// Month: chronological — gives an "overview" feel.
|
|
MemoriesSpan::Month => memories_with_dates.sort_by(|a, b| a.1.cmp(&b.1)),
|
|
// Week: full date then timestamp (oldest → newest).
|
|
MemoriesSpan::Week => {
|
|
memories_with_dates.sort_by(|a, b| {
|
|
a.1.cmp(&b.1)
|
|
.then_with(|| match (a.0.created, b.0.created) {
|
|
(Some(at), Some(bt)) => at.cmp(&bt),
|
|
(Some(_), None) => std::cmp::Ordering::Less,
|
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
|
(None, None) => std::cmp::Ordering::Equal,
|
|
})
|
|
});
|
|
}
|
|
// Day: same calendar day across years, sub-sorted by timestamp.
|
|
MemoriesSpan::Day => {
|
|
memories_with_dates.sort_by(|a, b| match (a.0.created, b.0.created) {
|
|
(Some(at), Some(bt)) => at.cmp(&bt),
|
|
(Some(_), None) => std::cmp::Ordering::Less,
|
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
|
(None, None) => std::cmp::Ordering::Equal,
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(memories_with_dates.into_iter().map(|(m, _)| m).collect())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::{Datelike, Timelike};
|
|
use std::fs::{self, File};
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_screenshot_format() {
|
|
let filename = "Screenshot_2014-06-01-20-44-50.png";
|
|
let date_time = match extract_date_from_filename(filename).unwrap() {
|
|
FilenameDate::Wall(naive) => naive,
|
|
other => panic!("expected a wall-clock pattern, got {:?}", other),
|
|
};
|
|
|
|
assert_eq!(date_time.year(), 2014);
|
|
assert_eq!(date_time.month(), 6);
|
|
assert_eq!(date_time.day(), 1);
|
|
assert_eq!(date_time.hour(), 20);
|
|
assert_eq!(date_time.minute(), 44);
|
|
assert_eq!(date_time.second(), 50);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_screenshot_less_dashes_format() {
|
|
let filename = "Screenshot_20140601-204450.png";
|
|
let date_time = match extract_date_from_filename(filename).unwrap() {
|
|
FilenameDate::Wall(naive) => naive,
|
|
other => panic!("expected a wall-clock pattern, got {:?}", other),
|
|
};
|
|
|
|
assert_eq!(date_time.year(), 2014);
|
|
assert_eq!(date_time.month(), 6);
|
|
assert_eq!(date_time.day(), 1);
|
|
assert_eq!(date_time.hour(), 20);
|
|
assert_eq!(date_time.minute(), 44);
|
|
assert_eq!(date_time.second(), 50);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_screenshot_underscores_format() {
|
|
let filename = "20140601_204450.png";
|
|
let date_time = match extract_date_from_filename(filename).unwrap() {
|
|
FilenameDate::Wall(naive) => naive,
|
|
other => panic!("expected a wall-clock pattern, got {:?}", other),
|
|
};
|
|
|
|
assert_eq!(date_time.year(), 2014);
|
|
assert_eq!(date_time.month(), 6);
|
|
assert_eq!(date_time.day(), 1);
|
|
assert_eq!(date_time.hour(), 20);
|
|
assert_eq!(date_time.minute(), 44);
|
|
assert_eq!(date_time.second(), 50);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_dash_format() {
|
|
let filename = "2015-01-09_02-15-15.jpg";
|
|
let date_time = match extract_date_from_filename(filename).unwrap() {
|
|
FilenameDate::Wall(naive) => naive,
|
|
other => panic!("expected a wall-clock pattern, got {:?}", other),
|
|
};
|
|
|
|
assert_eq!(date_time.year(), 2015);
|
|
assert_eq!(date_time.month(), 1);
|
|
assert_eq!(date_time.day(), 9);
|
|
assert_eq!(date_time.hour(), 2);
|
|
assert_eq!(date_time.minute(), 15);
|
|
assert_eq!(date_time.second(), 15);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_dash_compact_time_format() {
|
|
let filename = "2015-01-09-021515.jpg";
|
|
let date_time = match extract_date_from_filename(filename).unwrap() {
|
|
FilenameDate::Wall(naive) => naive,
|
|
other => panic!("expected a wall-clock pattern, got {:?}", other),
|
|
};
|
|
|
|
assert_eq!(date_time.year(), 2015);
|
|
assert_eq!(date_time.month(), 1);
|
|
assert_eq!(date_time.day(), 9);
|
|
assert_eq!(date_time.hour(), 2);
|
|
assert_eq!(date_time.minute(), 15);
|
|
assert_eq!(date_time.second(), 15);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_compact_format() {
|
|
let filename = "20140927101712.jpg";
|
|
let date_time = match extract_date_from_filename(filename).unwrap() {
|
|
FilenameDate::Wall(naive) => naive,
|
|
other => panic!("expected a wall-clock pattern, got {:?}", other),
|
|
};
|
|
|
|
assert_eq!(date_time.year(), 2014);
|
|
assert_eq!(date_time.month(), 9);
|
|
assert_eq!(date_time.day(), 27);
|
|
assert_eq!(date_time.hour(), 10);
|
|
assert_eq!(date_time.minute(), 17);
|
|
assert_eq!(date_time.second(), 12);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_timestamp_format() {
|
|
let filename = "xyz_1401638400.jpeg"; // Unix timestamp for 2014-06-01 16:00:00 UTC
|
|
let date_time = match extract_date_from_filename(filename).unwrap() {
|
|
FilenameDate::Utc(dt) => dt,
|
|
other => panic!("expected a unix-timestamp pattern, got {:?}", other),
|
|
};
|
|
|
|
assert_eq!(date_time.year(), 2014);
|
|
assert_eq!(date_time.month(), 6);
|
|
assert_eq!(date_time.day(), 1);
|
|
assert_eq!(date_time.hour(), 16);
|
|
assert_eq!(date_time.minute(), 0);
|
|
assert_eq!(date_time.second(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_timestamp_millis_format() {
|
|
let filename = "xyz_1401638400000.jpeg"; // Unix timestamp in milliseconds
|
|
let date_time = match extract_date_from_filename(filename).unwrap() {
|
|
FilenameDate::Utc(dt) => dt,
|
|
other => panic!("expected a unix-timestamp pattern, got {:?}", other),
|
|
};
|
|
|
|
assert_eq!(date_time.year(), 2014);
|
|
assert_eq!(date_time.month(), 6);
|
|
assert_eq!(date_time.day(), 1);
|
|
assert_eq!(date_time.hour(), 16);
|
|
assert_eq!(date_time.minute(), 0);
|
|
assert_eq!(date_time.second(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_attachment_15_digits() {
|
|
// att_142248967186928.jpeg - 15 digits, should parse first 10 as seconds
|
|
// 1422489671 = 2015-01-28 23:07:51 UTC (converts to local timezone)
|
|
let filename = "att_142248967186928.jpeg";
|
|
let date_time = match extract_date_from_filename(filename).unwrap() {
|
|
FilenameDate::Utc(dt) => dt,
|
|
other => panic!("expected a unix-timestamp pattern, got {:?}", other),
|
|
};
|
|
|
|
// Verify year and month are correct (2015-01)
|
|
assert_eq!(date_time.year(), 2015);
|
|
assert_eq!(date_time.month(), 1);
|
|
// Day may be 28 or 29 depending on timezone
|
|
assert!(date_time.day() >= 28 && date_time.day() <= 29);
|
|
|
|
// Verify timestamp is within expected range (should be around 1422489671)
|
|
let timestamp = date_time.timestamp();
|
|
assert!((1422480000..=1422576000).contains(×tamp)); // Jan 28-29, 2015
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_attachment_16_digits() {
|
|
// att_1422489664680106.jpeg - 16 digits, should parse first 10 as seconds
|
|
// 1422489664 = 2015-01-28 23:07:44 UTC (converts to local timezone)
|
|
let filename = "att_1422489664680106.jpeg";
|
|
let date_time = match extract_date_from_filename(filename).unwrap() {
|
|
FilenameDate::Utc(dt) => dt,
|
|
other => panic!("expected a unix-timestamp pattern, got {:?}", other),
|
|
};
|
|
|
|
// Verify year and month are correct (2015-01)
|
|
assert_eq!(date_time.year(), 2015);
|
|
assert_eq!(date_time.month(), 1);
|
|
// Day may be 28 or 29 depending on timezone
|
|
assert!(date_time.day() >= 28 && date_time.day() <= 29);
|
|
|
|
// Verify timestamp is within expected range (should be around 1422489664)
|
|
let timestamp = date_time.timestamp();
|
|
assert!((1422480000..=1422576000).contains(×tamp)); // Jan 28-29, 2015
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_autogenerated_should_not_match() {
|
|
// Autogenerated filenames like 1000004178.jpg should NOT be parsed as timestamps
|
|
// These start with "10000" which would be Sept 2001 if parsed literally
|
|
let filename = "1000004178.jpg";
|
|
let date_time = extract_date_from_filename(filename);
|
|
|
|
assert!(
|
|
date_time.is_none(),
|
|
"Autogenerated filenames starting with 10000 should not be parsed as dates"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_leading_zero_scan_id_should_not_match() {
|
|
// Sequential film-scan IDs like 000227580005.jpg parsed as a 12-digit
|
|
// ms timestamp resolve to 1970-01-03; the leading zero rules out a
|
|
// real epoch value at any sane resolution. Resolver should fall
|
|
// through to fs_time instead of pinning the photo to 1970.
|
|
assert!(extract_date_from_filename("000227580005.jpg").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_far_future_should_not_match() {
|
|
// IMG_21323906751390.jpeg → first 10 digits = 2132390675 → 2037.
|
|
// Plausibility gate rejects it so the resolver falls through to
|
|
// fs_time (which carries the real ingest date).
|
|
assert!(extract_date_from_filename("IMG_21323906751390.jpeg").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_snapchat_sequential_ids_rejected() {
|
|
// Modern Snapchat-prefixed filenames carry sequential app-assigned
|
|
// IDs whose digits happen to fall inside plausible epoch ranges
|
|
// when truncated. Reported cases (real save dates per FileModifyDate):
|
|
// Snapchat-1021849065.mp4 → 10 digits → 2002-05-19 (saved 2021)
|
|
// Snapchat-1751031586660373917.jpg → 19 digits → 2002-09-09 (saved 2016)
|
|
// We discriminate by length + Snapchat-launch floor: only exactly
|
|
// 10 digits AND post-2011-09-23 (Snapchat launch) is treated as
|
|
// a real unix epoch. Anything else falls through to fs_time.
|
|
assert!(extract_date_from_filename("Snapchat-1021849065.mp4").is_none());
|
|
assert!(extract_date_from_filename("Snapchat-1751031586660373917.jpg").is_none());
|
|
// Case-insensitive match — lowercase variant should also reject.
|
|
assert!(extract_date_from_filename("snapchat-1021849065.mp4").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_date_from_filename_snapchat_early_era_unix_epoch() {
|
|
// Early Snapchat (2013-2014ish) wrote real unix-second filenames.
|
|
// Snapchat-1383929602.jpg → 1383929602 = 2013-11-08 16:53:22 UTC.
|
|
// The blanket-prefix denial introduced for sequential IDs broke
|
|
// these — restore via a length=10 + post-launch sanity gate.
|
|
let date_time = match extract_date_from_filename("Snapchat-1383929602.jpg").unwrap() {
|
|
FilenameDate::Utc(dt) => dt,
|
|
other => panic!("expected a unix-timestamp pattern, got {:?}", other),
|
|
};
|
|
assert_eq!(date_time.timestamp(), 1383929602);
|
|
}
|
|
|
|
// Pattern-name seam (ticket #117): the audit utility prints which
|
|
// pattern fired, so every class pattern must report a stable name.
|
|
#[test]
|
|
fn test_match_filename_date_reports_pattern_names() {
|
|
let cases: &[(&str, &str)] = &[
|
|
("Screenshot_2014-06-01-20-44-50.png", "screenshot"),
|
|
("Screenshot_20140601_204450.png", "screenshot_compact"),
|
|
("2015-01-09_02-15-15.jpg", "dash"),
|
|
("2015-01-09-021515.jpg", "dash_compact"),
|
|
("20140927101712.jpg", "compact"),
|
|
("att_1422489664680106.jpeg", "unix_timestamp"),
|
|
("1401638400.jpeg", "unix_timestamp"),
|
|
];
|
|
for (name, pattern) in cases {
|
|
let match_ =
|
|
match_filename_date(name).unwrap_or_else(|| panic!("no match for {}", name));
|
|
assert_eq!(match_.pattern, *pattern, "pattern name for {}", name);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_match_filename_date_out_of_class_is_none() {
|
|
assert_eq!(match_filename_date("IMG_12345.jpg"), None);
|
|
assert_eq!(match_filename_date("000227580005.jpg"), None);
|
|
}
|
|
|
|
// The obsolete `test_memory_date_priority_*` tests covered the old
|
|
// request-time waterfall in `get_memory_date_with_priority`. Their
|
|
// replacement lives in `crate::date_resolver::tests` (resolver
|
|
// waterfall) and the SQL surface is exercised by integration tests
|
|
// that hit `get_memories_in_window` directly.
|
|
|
|
#[test]
|
|
fn test_path_excluder_absolute_under_base() {
|
|
let tmp = tempdir().unwrap();
|
|
let base = tmp.path();
|
|
|
|
// Simulate structure:
|
|
// base/photos/private/secret.jpg
|
|
// base/photos/public/ok.jpg
|
|
// base/screenshots/img.png
|
|
let photos_private = base.join("photos/private");
|
|
let photos_public = base.join("photos/public");
|
|
let screenshots = base.join("screenshots");
|
|
|
|
fs::create_dir_all(&photos_private).unwrap();
|
|
fs::create_dir_all(&photos_public).unwrap();
|
|
fs::create_dir_all(&screenshots).unwrap();
|
|
|
|
let secret = photos_private.join("secret.jpg");
|
|
let ok = photos_public.join("ok.jpg");
|
|
let shot = screenshots.join("img.png");
|
|
|
|
File::create(&secret).unwrap();
|
|
File::create(&ok).unwrap();
|
|
File::create(&shot).unwrap();
|
|
|
|
// Exclude "/photos/private" and "/screenshots" under base
|
|
let excluded = vec![
|
|
String::from("/photos/private"),
|
|
String::from("/screenshots"),
|
|
];
|
|
let excluder = PathExcluder::new(base, &excluded);
|
|
|
|
assert!(excluder.is_excluded(&secret), "secret should be excluded");
|
|
assert!(
|
|
excluder.is_excluded(&shot),
|
|
"screenshots should be excluded"
|
|
);
|
|
assert!(
|
|
!excluder.is_excluded(&ok),
|
|
"public photo should NOT be excluded"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_path_excluder_pattern_anywhere_under_base() {
|
|
let tmp = tempdir().unwrap();
|
|
let base = tmp.path();
|
|
|
|
// Simulate:
|
|
// base/a/tmp_file.jpg
|
|
// base/b/normal.jpg
|
|
// base/c/sometmpdir/file.jpg
|
|
let a = base.join("a");
|
|
let b = base.join("b");
|
|
let c = base.join("c/tmp");
|
|
|
|
fs::create_dir_all(&a).unwrap();
|
|
fs::create_dir_all(&b).unwrap();
|
|
fs::create_dir_all(&c).unwrap();
|
|
|
|
let tmp_file = a.join("tmp_file.jpg");
|
|
let normal = b.join("normal.jpg");
|
|
let tmp_dir_file = c.join("file.jpg");
|
|
|
|
File::create(&tmp_file).unwrap();
|
|
File::create(&normal).unwrap();
|
|
File::create(&tmp_dir_file).unwrap();
|
|
|
|
// Exclude any path containing "tmp"
|
|
let excluded = vec![String::from("tmp")];
|
|
let excluder = PathExcluder::new(base, &excluded);
|
|
|
|
assert!(
|
|
!excluder.is_excluded(&tmp_file),
|
|
"file with 'tmp' in name should NOT be excluded"
|
|
);
|
|
assert!(
|
|
excluder.is_excluded(&tmp_dir_file),
|
|
"file in directory with 'tmp' in path should be excluded"
|
|
);
|
|
assert!(
|
|
!excluder.is_excluded(&normal),
|
|
"file without 'tmp' in its path should NOT be excluded"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_path_excluder_mixed_absolute_and_pattern() {
|
|
let tmp = tempdir().unwrap();
|
|
let base = tmp.path();
|
|
|
|
// Simulate:
|
|
// base/photos/private/secret_tmp.jpg -> excluded by absolute dir rule
|
|
// base/photos/private/secret.jpg -> excluded by absolute dir rule
|
|
// base/photos/tmp/public.jpg -> excluded by pattern "tmp" (dir name)
|
|
// base/photos/public/tmp_public.jpg -> NOT excluded (file name contains "tmp" but not equal)
|
|
// base/other/keep.jpg -> NOT excluded
|
|
let photos_private = base.join("photos/private");
|
|
let photos_tmp = base.join("photos/tmp");
|
|
let photos_public = base.join("photos/public");
|
|
let other = base.join("other");
|
|
|
|
fs::create_dir_all(&photos_private).unwrap();
|
|
fs::create_dir_all(&photos_tmp).unwrap();
|
|
fs::create_dir_all(&photos_public).unwrap();
|
|
fs::create_dir_all(&other).unwrap();
|
|
|
|
let secret_tmp = photos_private.join("secret_tmp.jpg");
|
|
let secret = photos_private.join("secret.jpg");
|
|
let tmp_dir_file = photos_tmp.join("public.jpg");
|
|
let tmp_in_name = photos_public.join("tmp_public.jpg");
|
|
let keep = other.join("keep.jpg");
|
|
|
|
File::create(&secret_tmp).unwrap();
|
|
File::create(&secret).unwrap();
|
|
File::create(&tmp_dir_file).unwrap();
|
|
File::create(&tmp_in_name).unwrap();
|
|
File::create(&keep).unwrap();
|
|
|
|
// Mixed: exclude "/photos/private" (dir) and any component equal to "tmp"
|
|
let excluded = vec![String::from("/photos/private"), String::from("tmp")];
|
|
let excluder = PathExcluder::new(base, &excluded);
|
|
|
|
// Entire private tree is excluded by dir rule
|
|
assert!(excluder.is_excluded(&secret_tmp));
|
|
assert!(excluder.is_excluded(&secret));
|
|
|
|
// Dir 'tmp' under photos excluded by pattern
|
|
assert!(excluder.is_excluded(&tmp_dir_file));
|
|
|
|
// File name containing 'tmp' but not equal should NOT be excluded
|
|
assert!(!excluder.is_excluded(&tmp_in_name));
|
|
|
|
// keep.jpg doesn't match any rule
|
|
assert!(!excluder.is_excluded(&keep));
|
|
}
|
|
|
|
#[test]
|
|
fn test_week_span_sorting_chronological_by_day() {
|
|
// Test that Week span sorts by:
|
|
// 1. Day of month (ascending)
|
|
// 2. Full timestamp oldest to newest (year + time combined)
|
|
|
|
// Create test data:
|
|
// - Jan 15, 2024 at 9:00 AM
|
|
// - Jan 15, 2020 at 10:00 AM
|
|
// - Jan 16, 2021 at 8:00 AM
|
|
|
|
let jan_15_2024_9am = NaiveDate::from_ymd_opt(2024, 1, 15)
|
|
.unwrap()
|
|
.and_hms_opt(9, 0, 0)
|
|
.unwrap()
|
|
.and_utc()
|
|
.timestamp();
|
|
|
|
let jan_15_2020_10am = NaiveDate::from_ymd_opt(2020, 1, 15)
|
|
.unwrap()
|
|
.and_hms_opt(10, 0, 0)
|
|
.unwrap()
|
|
.and_utc()
|
|
.timestamp();
|
|
|
|
let jan_16_2021_8am = NaiveDate::from_ymd_opt(2021, 1, 16)
|
|
.unwrap()
|
|
.and_hms_opt(8, 0, 0)
|
|
.unwrap()
|
|
.and_utc()
|
|
.timestamp();
|
|
|
|
let mut memories_with_dates = [
|
|
(
|
|
MemoryItem {
|
|
path: "photo1.jpg".to_string(),
|
|
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(),
|
|
),
|
|
(
|
|
MemoryItem {
|
|
path: "photo2.jpg".to_string(),
|
|
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(),
|
|
),
|
|
(
|
|
MemoryItem {
|
|
path: "photo3.jpg".to_string(),
|
|
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(),
|
|
),
|
|
];
|
|
|
|
// Sort using Week span logic
|
|
memories_with_dates.sort_by(|a, b| {
|
|
// First, sort by day of month
|
|
let day_cmp = a.1.day().cmp(&b.1.day());
|
|
if day_cmp != std::cmp::Ordering::Equal {
|
|
return day_cmp;
|
|
}
|
|
|
|
// Then sort by full created timestamp (oldest to newest)
|
|
match (a.0.created, b.0.created) {
|
|
(Some(a_time), Some(b_time)) => a_time.cmp(&b_time),
|
|
(Some(_), None) => std::cmp::Ordering::Less,
|
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
|
(None, None) => std::cmp::Ordering::Equal,
|
|
}
|
|
});
|
|
|
|
// Expected order:
|
|
// 1. Jan 15, 2020 at 10:00 AM (oldest Jan 15 photo)
|
|
// 2. Jan 15, 2024 at 9:00 AM (newer Jan 15 photo)
|
|
// 3. Jan 16, 2021 at 8:00 AM (all Jan 16 photos after Jan 15)
|
|
|
|
assert_eq!(memories_with_dates[0].0.created.unwrap(), jan_15_2020_10am);
|
|
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());
|
|
}
|
|
}
|
|
}
|