4 Commits
Author SHA1 Message Date
Cameron CordesandClaude Opus 5.5 139de444af docs: date_from_day / date_to_day on the photo listing endpoints
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 03:24:50 +00:00
Cameron CordesandClaude Opus 5.5 bfc3c1c21a feat(dates): calendar-day date filters in the owner's display zone (#152)
/photos, /photos/exif and unified search take date_from_day /
date_to_day (YYYY-MM-DD): the server turns them into the first day's
start and the last day's end in the owner's display zone, the same days
/memories groups by. Each replaces its instant counterpart. Clients stop
computing midnights in the device's zone, which drifted by the travel
offset whenever the phone's zone differed from display_tz.

e2e check: every fixture is included by its display-zone day and
excluded by the days either side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 03:24:50 +00:00
cameron a11d20bdcd Merge pull request 'fix(dates): year-gate the filename wall-clock patterns (#160)' (#176) from fix/filename-wall-year-gate into master
Reviewed-on: #176
2026-10-17 03:22:43 +00:00
Cameron CordesandClaude Opus 5.5 36e4af9fa6 fix(dates): year-gate the filename wall-clock patterns (#160)
match_filename_date only gated the unix-timestamp branch to years
1995..=now+1; the five wall patterns returned any NaiveDateTime. Counters
and IDs shaped like YYYYMMDD_HHMMSS (an app's VID_<counter>_<HHMMSS>,
Facebook's <8 digits>_<id>_o.jpg) became dates of record in years from
267 to 9985: 67 rows on prod, all via screenshot_compact.

Both branches now share plausible_filename_year; a wall miss falls
through to the next pattern and then the rest of the waterfall. The
filename audit no longer proposes these dates.

A data migration clears the five date columns on filename-sourced rows
dated outside [1995, 2028) UTC, so backfill_missing_date_taken
re-resolves them from EXIF / exiftool / fs time on the next ticks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:06:23 -04:00
10 changed files with 347 additions and 21 deletions

No files matched your search

+4 -1
View File
@@ -523,7 +523,10 @@ GET /photos?path=...&recursive=true&sort=DateTakenDesc&camera_make=Canon&gps_lat
// date_from / date_to are inclusive TRUE INSTANTS, matched against each row's // date_from / date_to are inclusive TRUE INSTANTS, matched against each row's
// true instant (date_utc, else date_view::row_true_instant's fallback) in // true instant (date_utc, else date_view::row_true_instant's fallback) in
// ExifDao::query_by_exif — shared by /photos, /photos/exif and unified search. // ExifDao::query_by_exif — shared by /photos, /photos/exif and unified search.
// A calendar-day range = that day's bounds in the owner's display zone. // For calendar days send date_from_day / date_to_day (YYYY-MM-DD) instead: the
// handler turns them into the days' bounds in the owner's display zone
// (date_view::apply_day_bounds), each replacing its instant counterpart, so
// clients never compute midnights in the device's zone.
// Natural-language search (LLM translates q into filters + a CLIP term) // Natural-language search (LLM translates q into filters + a CLIP term)
GET /photos/search/unified?q=...&model=...&interpret=true GET /photos/search/unified?q=...&model=...&interpret=true
+20 -1
View File
@@ -7,7 +7,10 @@ For every indexed fixture, the manifest's expected wall clock must equal the
`/photos/exif`; and `/image/metadata` / `/photos/exif`'s `true_instant` must `/photos/exif`; and `/image/metadata` / `/photos/exif`'s `true_instant` must
equal the expected instant. The `/photos/exif` date filter must also treat equal the expected instant. The `/photos/exif` date filter must also treat
that instant as a range edge (#152): `[t, t]` includes the file, and a range that instant as a range edge (#152): `[t, t]` includes the file, and a range
ending at `t - 1` or starting at `t + 1` excludes it. One disagreement ending at `t - 1` or starting at `t + 1` excludes it; and filtering by the
file's calendar day in the owner's display zone (`date_from_day` /
`date_to_day`) includes it, while the day before or after excludes it. One
disagreement
anywhere is exactly the class of bug these fields exist to end, so any anywhere is exactly the class of bug these fields exist to end, so any
mismatch fails the run. mismatch fails the run.
@@ -19,9 +22,13 @@ import os
import subprocess import subprocess
import sys import sys
import urllib.parse import urllib.parse
from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
E2E = Path(__file__).with_name("e2e.sh") E2E = Path(__file__).with_name("e2e.sh")
# The owner's display zone in the e2e env (no prefs row → the app default).
DISPLAY_ZONE = ZoneInfo("America/New_York")
def api(path: str) -> dict: def api(path: str) -> dict:
@@ -44,6 +51,10 @@ def main() -> int:
rows = api(f"photos/exif?date_from={lo}&date_to={hi}")["photos"] rows = api(f"photos/exif?date_from={lo}&date_to={hi}")["photos"]
return any(r["file_path"] == path for r in rows) return any(r["file_path"] == path for r in rows)
def in_exif_days(path: str, first: str, last: str) -> bool:
rows = api(f"photos/exif?date_from_day={first}&date_to_day={last}")["photos"]
return any(r["file_path"] == path for r in rows)
failures = 0 failures = 0
for item in items: for item in items:
path, want = item["path"], item["wall_clock"] path, want = item["path"], item["wall_clock"]
@@ -70,6 +81,14 @@ def main() -> int:
bad["photos/exif [.., t-1]"] = "included" bad["photos/exif [.., t-1]"] = "included"
if in_exif_range(path, t + 1, t + 86_400): if in_exif_range(path, t + 1, t + 86_400):
bad["photos/exif [t+1, ..]"] = "included" bad["photos/exif [t+1, ..]"] = "included"
# Calendar days are the owner's display-zone days (#152).
day = datetime.fromtimestamp(t, timezone.utc).astimezone(DISPLAY_ZONE).date()
iso = lambda d: d.isoformat()
if not in_exif_days(path, iso(day), iso(day)):
bad[f"photos/exif day {day}"] = "excluded"
for other in (day - timedelta(days=1), day + timedelta(days=1)):
if in_exif_days(path, iso(other), iso(other)):
bad[f"photos/exif day {other}"] = "included"
if bad: if bad:
failures += 1 failures += 1
print(f"MISMATCH {path}: expected {want}, got {bad}") print(f"MISMATCH {path}: expected {want}, got {bad}")
@@ -0,0 +1,3 @@
-- Data fix only: the cleared dates were wrong and are re-resolved by the
-- drain, so there is nothing to restore.
SELECT 1;
@@ -0,0 +1,23 @@
-- #160: the filename wall-clock patterns had no year gate, so counters and
-- IDs shaped like YYYYMMDD_HHMMSS (`VID_22290524_080551.mp4`, Facebook's
-- `10530519_101527…_o.jpg`) became dates of record in years like 1053 and
-- 2229. The matcher now rejects years outside 1995..=now+1; this clears
-- the rows it already dated so they're re-resolved by the fixed waterfall.
--
-- Both the legacy pair and the new-contract triple are cleared:
-- `date_taken IS NULL` puts the row back in `backfill_missing_date_taken`,
-- which rewrites all five columns (EXIF / exiftool / fs_time now that the
-- filename no longer matches). Only filename-sourced rows dated outside
-- [1995-01-01, 2028-01-01) UTC are touched; a real date re-resolves to
-- itself anyway.
UPDATE image_exif
SET date_taken = NULL,
date_taken_source = NULL,
date_utc = NULL,
camera_offset_minutes = NULL,
date_taken_tz_source = NULL
WHERE date_taken_source = 'filename'
AND (
date_taken < 788918400 OR date_taken >= 1830297600
OR date_utc < 788918400 OR date_utc >= 1830297600
);
+9 -1
View File
@@ -153,9 +153,13 @@ pub struct FilesRequest {
// Date range filtering: inclusive true instants (unix seconds), compared // Date range filtering: inclusive true instants (unix seconds), compared
// against each row's true instant (ADR-0001, #152). For calendar days, // against each row's true instant (ADR-0001, #152). For calendar days,
// send the day's bounds in the owner's display zone. // send `date_from_day` / `date_to_day` (`YYYY-MM-DD`) instead: the server
// takes their bounds in the owner's display zone, and each replaces its
// instant counterpart.
pub date_from: Option<i64>, pub date_from: Option<i64>,
pub date_to: Option<i64>, pub date_to: Option<i64>,
pub date_from_day: Option<String>,
pub date_to_day: Option<String>,
// Media type filtering // Media type filtering
pub media_type: Option<MediaType>, pub media_type: Option<MediaType>,
@@ -538,6 +542,10 @@ pub struct ExifBatchRequest {
pub date_from: Option<i64>, pub date_from: Option<i64>,
/// Upper bound (inclusive). Same semantics as `date_to` on `/photos`. /// Upper bound (inclusive). Same semantics as `date_to` on `/photos`.
pub date_to: Option<i64>, pub date_to: Option<i64>,
/// Calendar-day bounds (`YYYY-MM-DD`) in the owner's display zone; each
/// replaces its instant counterpart. Same as on `/photos`.
pub date_from_day: Option<String>,
pub date_to_day: Option<String>,
/// Restrict results to a single library by id. Omit (or "" / "all") for /// Restrict results to a single library by id. Omit (or "" / "all") for
/// union mode — the default. Filtered post-query in the handler so the /// union mode — the default. Filtered post-query in the handler so the
/// existing `query_by_exif` DAO trait stays untouched. /// existing `query_by_exif` DAO trait stays untouched.
+74 -2
View File
@@ -121,13 +121,39 @@ pub fn day_start_instant(day: NaiveDate, zone: Tz) -> i64 {
/// exact call in Rust without a row being dropped first. /// exact call in Rust without a row being dropped first.
pub const LEGACY_INSTANT_BAND_SECS: i64 = 15 * 3600; pub const LEGACY_INSTANT_BAND_SECS: i64 = 15 * 3600;
/// Fold calendar-day filter params (`date_from_day` / `date_to_day`,
/// `YYYY-MM-DD`) into the instant bounds: the first day's start and the
/// last day's end in the owner's display zone, the same days `/memories`
/// groups by (#152). A day param replaces its instant counterpart; an
/// unparseable one is ignored. `zone` is only called when a day is given,
/// so requests without them never read prefs.
pub fn apply_day_bounds(
from: &mut Option<i64>,
to: &mut Option<i64>,
from_day: Option<&str>,
to_day: Option<&str>,
zone: impl FnOnce() -> Tz,
) {
if from_day.is_none() && to_day.is_none() {
return;
}
let zone = zone();
let parse = |d: &str| NaiveDate::parse_from_str(d.trim(), "%Y-%m-%d").ok();
if let Some(day) = from_day.and_then(parse) {
*from = Some(day_start_instant(day, zone));
}
if let Some(next) = to_day.and_then(parse).and_then(|d| d.succ_opt()) {
*to = Some(day_start_instant(next, zone) - 1);
}
}
/// An inclusive true-instant range (`date_from` / `date_to` on the photo /// An inclusive true-instant range (`date_from` / `date_to` on the photo
/// listing endpoints), plus the zones [`row_true_instant`] needs to read /// listing endpoints), plus the zones [`row_true_instant`] needs to read
/// unconverted rows. Either bound may be open. /// unconverted rows. Either bound may be open.
/// ///
/// Bounds are instants, never wall clocks: a client filtering by calendar /// Bounds are instants, never wall clocks: a client filtering by calendar
/// day sends that day's midnights in the owner's display zone (see /// day sends `date_from_day` / `date_to_day` and the handler turns them into
/// `nl_query::iso_to_unix`), so the range agrees with /// display-zone day bounds ([`apply_day_bounds`]), so the range agrees with
/// [`row_display_day`] grouping by construction. /// [`row_display_day`] grouping by construction.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct InstantRange { pub struct InstantRange {
@@ -698,6 +724,52 @@ mod tests {
); );
} }
#[test]
fn day_bounds_are_the_display_zone_day() {
// July 5 is a different stretch of instants for a NY and a Tokyo
// owner; the device's zone plays no part (#152).
let (mut from, mut to) = (None, None);
apply_day_bounds(
&mut from,
&mut to,
Some("2024-07-05"),
Some("2024-07-05"),
ny,
);
assert_eq!((from, to), (Some(1_720_152_000), Some(1_720_238_399)));
let (mut from, mut to) = (None, None);
apply_day_bounds(
&mut from,
&mut to,
Some("2024-07-05"),
Some("2024-07-05"),
|| Tz::Asia__Tokyo,
);
assert_eq!((from, to), (Some(1_720_105_200), Some(1_720_191_599)));
// A 23-hour DST day still ends at the next midnight.
let (mut from, mut to) = (None, None);
apply_day_bounds(
&mut from,
&mut to,
Some("2024-03-10"),
Some("2024-03-10"),
ny,
);
assert_eq!((from, to), (Some(1_710_046_800), Some(1_710_129_599)));
}
#[test]
fn day_bounds_replace_their_side_only_and_skip_bad_input() {
let (mut from, mut to) = (Some(1), Some(2));
apply_day_bounds(&mut from, &mut to, None, Some("2024-07-05"), ny);
assert_eq!((from, to), (Some(1), Some(1_720_238_399)));
let (mut from, mut to) = (Some(1), Some(2));
apply_day_bounds(&mut from, &mut to, Some("July 5th"), None, ny);
assert_eq!((from, to), (Some(1), Some(2)));
// No day params: the zone (a prefs read) is never asked for.
apply_day_bounds(&mut from, &mut to, None, None, || panic!("zone read"));
}
#[test] #[test]
fn display_day_respects_a_non_new_york_display_zone() { fn display_day_respects_a_non_new_york_display_zone() {
// Owner in Tokyo: 2024-07-05 01:30 UTC = 10:30 JST July 5 (not // Owner in Tokyo: 2024-07-05 01:30 UTC = 10:30 JST July 5 (not
+94
View File
@@ -474,6 +474,100 @@ mod tests {
assert!(!unidx.changed); assert!(!unidx.changed);
} }
#[test]
fn audit_leaves_out_counter_and_id_names_with_implausible_years() {
// #160: these matched screenshot_compact as years 2229 / 8580 /
// 1053 and the audit proposed them as dates of record.
let tmp = tempdir().unwrap();
for path in [
"VID_22290524_080551_818.mp4",
"VID_185800302_235056_842.mp4",
"10530519_10152700326093436_5479865808523667420_o.jpg",
"Screenshot_2014-06-01-20-44-50.png",
] {
fs::write(tmp.path().join(path), b"fixture").unwrap();
}
let mut conn = migrated_conn();
let lib = library(1, "photos", tmp.path(), true);
let stored = stored_dates_for_library(&mut conn, 1).unwrap();
let rows = audit_library(&lib, &[], &stored);
let rels: Vec<&str> = rows.iter().map(|r| r.rel_path.as_str()).collect();
assert_eq!(rels, vec!["Screenshot_2014-06-01-20-44-50.png"]);
}
#[test]
fn migration_clears_implausible_filename_dates_for_re_resolution() {
// Migrate up to just before the #160 data fix, seed rows as the
// old matcher left them, then apply it.
const FIX: &str = "reresolve_implausible_filename_dates";
let mut conn = SqliteConnection::establish(":memory:").expect("in-memory sqlite");
for m in conn.pending_migrations(DB_MIGRATIONS).expect("pending") {
if m.name().to_string().contains(FIX) {
break;
}
conn.run_migration(&*m).expect("migration");
}
// 2229-05-24 08:05:51 and 1053-05-19 10:15:27 (wall-as-UTC), a
// real 2014 filename date, and an fs_time row that isn't ours to
// touch even though its date is odd.
seed_row(
&mut conn,
1,
"VID_22290524_080551_818.mp4",
Some(8_185_651_551),
Some("filename"),
);
seed_row(
&mut conn,
1,
"10530519_1015270_o.jpg",
Some(-28_925_732_673),
Some("filename"),
);
seed_row(
&mut conn,
1,
"Screenshot_2014-06-01-20-44-50.png",
Some(1_401_655_490),
Some("filename"),
);
seed_row(&mut conn, 1, "old_scan.jpg", Some(100), Some("fs_time"));
diesel::update(image_exif::table)
.set((
image_exif::date_utc.eq(image_exif::date_taken),
image_exif::date_taken_tz_source.eq("assumed_server"),
))
.execute(&mut conn)
.unwrap();
conn.run_pending_migrations(DB_MIGRATIONS)
.expect("fix migration");
type Dates = (
String,
Option<i64>,
Option<String>,
Option<i64>,
Option<String>,
);
let rows: Vec<Dates> = image_exif::table
.select((
image_exif::rel_path,
image_exif::date_taken,
image_exif::date_taken_source,
image_exif::date_utc,
image_exif::date_taken_tz_source,
))
.order(image_exif::rel_path)
.load(&mut conn)
.unwrap();
let cleared = |r: &Dates| r.1.is_none() && r.2.is_none() && r.3.is_none() && r.4.is_none();
for r in &rows {
let expect_cleared = r.0.starts_with("VID_") || r.0.starts_with("1053");
assert_eq!(cleared(r), expect_cleared, "{:?}", r);
}
}
#[test] #[test]
fn run_audit_skips_disabled_and_offline_libraries() { fn run_audit_skips_disabled_and_offline_libraries() {
let tmp = tempdir().unwrap(); let tmp = tempdir().unwrap();
+18 -1
View File
@@ -199,6 +199,14 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
tag_dao: Data<Mutex<TagD>>, tag_dao: Data<Mutex<TagD>>,
exif_dao: Data<Mutex<Box<dyn ExifDao>>>, exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
) -> HttpResponse { ) -> HttpResponse {
let mut req = req.into_inner();
crate::date_view::apply_day_bounds(
&mut req.date_from,
&mut req.date_to,
req.date_from_day.as_deref(),
req.date_to_day.as_deref(),
|| crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
);
let search_path = &req.path; let search_path = &req.path;
let span = tracing::Span::current(); let span = tracing::Span::current();
@@ -1176,7 +1184,8 @@ pub async fn get_gps_summary(
/// Handler for the batch EXIF endpoint at `GET /photos/exif`. /// Handler for the batch EXIF endpoint at `GET /photos/exif`.
/// ///
/// Returns a single-row projection of `image_exif` for every photo whose /// Returns a single-row projection of `image_exif` for every photo whose
/// `date_taken` falls in `[date_from, date_to]`, across all libraries. /// true instant falls in `[date_from, date_to]` (or the display-zone
/// `date_from_day` / `date_to_day`), across all libraries.
/// Designed to replace the N+1 pattern of `/photos` + per-file /// Designed to replace the N+1 pattern of `/photos` + per-file
/// `/image/metadata` for window-scoped consumers like Apollo's photo-to- /// `/image/metadata` for window-scoped consumers like Apollo's photo-to-
/// track matcher: one DB query, one HTTP round-trip, one mutex acquisition. /// track matcher: one DB query, one HTTP round-trip, one mutex acquisition.
@@ -1194,6 +1203,14 @@ pub async fn list_exif_summary(
app_state: Data<AppState>, app_state: Data<AppState>,
) -> Result<HttpResponse, actix_web::Error> { ) -> Result<HttpResponse, actix_web::Error> {
let span = tracing::Span::current(); let span = tracing::Span::current();
let mut req = req.into_inner();
crate::date_view::apply_day_bounds(
&mut req.date_from,
&mut req.date_to,
req.date_from_day.as_deref(),
req.date_to_day.as_deref(),
|| crate::date_view::owner_display_zone(&app_state.user_ai_prefs_dao),
);
span.set_attribute( span.set_attribute(
"date_from", "date_from",
+90 -15
View File
@@ -328,12 +328,19 @@ pub fn match_filename_date(filename: &str) -> Option<FilenameMatch> {
NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, min, sec) NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, min, sec)
}; };
// Eight digits, a separator and six more also match counters and IDs
// (`VID_22290524_080551.mp4`, Facebook's `10530519_101527…_o.jpg`), so
// the wall patterns get the same year gate as the timestamp branch; a
// miss falls through to the next pattern (#160).
let build_wall = |captures: regex::Captures| -> Option<NaiveDateTime> {
build_wall_from_ymd_capture(&captures).filter(|naive| plausible_filename_year(naive.year()))
};
// 1. Screenshot format: Screenshot_2014-06-01-20-44-50.png // 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})") if let Some(naive) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})")
.ok()? .ok()?
.captures(filename) .captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c)) .and_then(build_wall)
{ {
return Some(FilenameMatch { return Some(FilenameMatch {
pattern: "screenshot", pattern: "screenshot",
@@ -345,7 +352,7 @@ pub fn match_filename_date(filename: &str) -> Option<FilenameMatch> {
if let Some(naive) = regex::Regex::new(r"(\d{4})(\d{2})(\d{2})[_-](\d{2})(\d{2})(\d{2})") if let Some(naive) = regex::Regex::new(r"(\d{4})(\d{2})(\d{2})[_-](\d{2})(\d{2})(\d{2})")
.ok()? .ok()?
.captures(filename) .captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c)) .and_then(build_wall)
{ {
return Some(FilenameMatch { return Some(FilenameMatch {
pattern: "screenshot_compact", pattern: "screenshot_compact",
@@ -357,7 +364,7 @@ pub fn match_filename_date(filename: &str) -> Option<FilenameMatch> {
if let Some(naive) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})") if let Some(naive) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})")
.ok()? .ok()?
.captures(filename) .captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c)) .and_then(build_wall)
{ {
return Some(FilenameMatch { return Some(FilenameMatch {
pattern: "dash", pattern: "dash",
@@ -369,7 +376,7 @@ pub fn match_filename_date(filename: &str) -> Option<FilenameMatch> {
if let Some(naive) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})") if let Some(naive) = regex::Regex::new(r"(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})")
.ok()? .ok()?
.captures(filename) .captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c)) .and_then(build_wall)
{ {
return Some(FilenameMatch { return Some(FilenameMatch {
pattern: "dash_compact", pattern: "dash_compact",
@@ -381,7 +388,7 @@ pub fn match_filename_date(filename: &str) -> Option<FilenameMatch> {
if let Some(naive) = regex::Regex::new(r"(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})") if let Some(naive) = regex::Regex::new(r"(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})")
.ok()? .ok()?
.captures(filename) .captures(filename)
.and_then(|c| build_wall_from_ymd_capture(&c)) .and_then(build_wall)
{ {
return Some(FilenameMatch { return Some(FilenameMatch {
pattern: "compact", pattern: "compact",
@@ -509,17 +516,15 @@ pub fn extract_date_from_filename(filename: &str) -> Option<FilenameDate> {
/// `fs_time`, which is a much better proxy for content age than a fake /// `fs_time`, which is a much better proxy for content age than a fake
/// epoch date. /// epoch date.
fn plausible_filename_date(dt: DateTime<Utc>) -> Option<DateTime<Utc>> { fn plausible_filename_date(dt: DateTime<Utc>) -> Option<DateTime<Utc>> {
use chrono::Datelike; plausible_filename_year(dt.year()).then_some(dt)
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
} }
/// The year window both filename branches (wall clock and unix timestamp)
/// accept. 1995 predates digital photography for most users; allowing one
/// year past `now` covers clock-skew on freshly-taken shots without letting
/// 2037 timestamps through.
fn plausible_filename_year(year: i32) -> bool {
(1995..=Utc::now().year() + 1).contains(&year)
} }
/// Default lookback for `/memories`. The original 15-year cap pre-dated /// Default lookback for `/memories`. The original 15-year cap pre-dated
@@ -994,6 +999,76 @@ mod tests {
} }
} }
#[test]
fn test_wall_patterns_reject_implausible_years() {
// One per wall pattern, too far out and too far back (#160).
let cases: &[&str] = &[
"Screenshot_2229-05-24-08-05-51.png",
"Screenshot_1053-05-19-10-15-27.png",
"VID_22290524_080551_818.mp4",
"IMG_10530519-101527.jpg",
"2229-05-24_08-05-51.jpg",
"1053-05-19_10-15-27.jpg",
"2229-05-24-080551.jpg",
"1053-05-19-101527.jpg",
"22290524080551.jpg",
"10530519101527.jpg",
];
// A miss may still fall through to a later pattern (a 14-digit run
// can read as a unix timestamp); it just can't be a wall date.
for name in cases {
if let Some(m) = match_filename_date(name) {
assert!(
!matches!(m.date, FilenameDate::Wall(_)),
"{} matched wall pattern {}",
name,
m.pattern
);
}
}
assert_eq!(
match_filename_date("Screenshot_2229-05-24-08-05-51.png"),
None
);
}
#[test]
fn test_wall_patterns_reject_prod_counter_and_id_names() {
// Shapes from the prod audit on #160: an app's VID_<counter> and
// Facebook download IDs, both caught by screenshot_compact.
for name in [
"VID_20950505_044049.mp4",
"VID_185800302_235056_842.mp4",
"VID_121920715_134958_293.mp4",
"10530519_10152700326093436_5479865808523667420_o.jpg",
"10460624_10202578362324975_1067700837005629791_o.jpg",
] {
assert_eq!(match_filename_date(name), None, "{} should not match", name);
}
}
#[test]
fn test_wall_pattern_miss_falls_through_to_next_pattern() {
// screenshot_compact sees year 2229 first; compact still finds 2014.
let m =
match_filename_date("VID_22290524_080551_20140927101712.jpg").expect("compact match");
assert_eq!(m.pattern, "compact");
let FilenameDate::Wall(naive) = m.date else {
panic!("compact is a wall pattern");
};
assert_eq!(naive.year(), 2014);
}
#[test]
fn test_wall_patterns_keep_plausible_edges() {
assert!(match_filename_date("Screenshot_1995-01-01-00-00-00.png").is_some());
let next_year = Utc::now().year() + 1;
let name = format!("Screenshot_{next_year}-01-01-00-00-00.png");
assert!(match_filename_date(&name).is_some());
let name = format!("Screenshot_{}-01-01-00-00-00.png", next_year + 1);
assert_eq!(match_filename_date(&name), None);
}
#[test] #[test]
fn test_match_filename_date_out_of_class_is_none() { fn test_match_filename_date_out_of_class_is_none() {
assert_eq!(match_filename_date("IMG_12345.jpg"), None); assert_eq!(match_filename_date("IMG_12345.jpg"), None);
+12
View File
@@ -77,6 +77,10 @@ pub struct UnifiedQuery {
pub media_type: Option<MediaType>, pub media_type: Option<MediaType>,
pub date_from: Option<i64>, pub date_from: Option<i64>,
pub date_to: Option<i64>, pub date_to: Option<i64>,
/// Calendar days (`YYYY-MM-DD`) in the owner's display zone; each
/// replaces its instant counterpart (see `date_view::apply_day_bounds`).
pub date_from_day: Option<String>,
pub date_to_day: Option<String>,
pub camera_make: Option<String>, pub camera_make: Option<String>,
pub camera_model: Option<String>, pub camera_model: Option<String>,
pub gps_lat: Option<f64>, pub gps_lat: Option<f64>,
@@ -292,6 +296,14 @@ pub async fn unified_search<TagD: TagDao>(
tag_dao: Data<Mutex<TagD>>, tag_dao: Data<Mutex<TagD>>,
query: Query<UnifiedQuery>, query: Query<UnifiedQuery>,
) -> HttpResponse { ) -> HttpResponse {
let mut query = query.into_inner();
crate::date_view::apply_day_bounds(
&mut query.date_from,
&mut query.date_to,
query.date_from_day.as_deref(),
query.date_to_day.as_deref(),
|| crate::date_view::owner_display_zone(&state.user_ai_prefs_dao),
);
let nl = query.q.trim().to_string(); let nl = query.q.trim().to_string();
if nl.is_empty() { if nl.is_empty() {
return bad_request("query parameter `q` is required"); return bad_request("query parameter `q` is required");