Compare commits
4
Commits
ebafa0deca
...
139de444af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
139de444af | ||
|
|
bfc3c1c21a | ||
|
|
a11d20bdcd | ||
|
|
36e4af9fa6 |
No files matched your search
@@ -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
|
||||
// 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.
|
||||
// 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)
|
||||
GET /photos/search/unified?q=...&model=...&interpret=true
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
mismatch fails the run.
|
||||
|
||||
@@ -19,9 +22,13 @@ import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
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:
|
||||
@@ -44,6 +51,10 @@ def main() -> int:
|
||||
rows = api(f"photos/exif?date_from={lo}&date_to={hi}")["photos"]
|
||||
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
|
||||
for item in items:
|
||||
path, want = item["path"], item["wall_clock"]
|
||||
@@ -70,6 +81,14 @@ def main() -> int:
|
||||
bad["photos/exif [.., t-1]"] = "included"
|
||||
if in_exif_range(path, t + 1, t + 86_400):
|
||||
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:
|
||||
failures += 1
|
||||
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
@@ -153,9 +153,13 @@ pub struct FilesRequest {
|
||||
|
||||
// Date range filtering: inclusive true instants (unix seconds), compared
|
||||
// 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_to: Option<i64>,
|
||||
pub date_from_day: Option<String>,
|
||||
pub date_to_day: Option<String>,
|
||||
|
||||
// Media type filtering
|
||||
pub media_type: Option<MediaType>,
|
||||
@@ -538,6 +542,10 @@ pub struct ExifBatchRequest {
|
||||
pub date_from: Option<i64>,
|
||||
/// Upper bound (inclusive). Same semantics as `date_to` on `/photos`.
|
||||
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
|
||||
/// union mode — the default. Filtered post-query in the handler so the
|
||||
/// existing `query_by_exif` DAO trait stays untouched.
|
||||
|
||||
+74
-2
@@ -121,13 +121,39 @@ pub fn day_start_instant(day: NaiveDate, zone: Tz) -> i64 {
|
||||
/// exact call in Rust without a row being dropped first.
|
||||
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
|
||||
/// listing endpoints), plus the zones [`row_true_instant`] needs to read
|
||||
/// unconverted rows. Either bound may be open.
|
||||
///
|
||||
/// Bounds are instants, never wall clocks: a client filtering by calendar
|
||||
/// day sends that day's midnights in the owner's display zone (see
|
||||
/// `nl_query::iso_to_unix`), so the range agrees with
|
||||
/// day sends `date_from_day` / `date_to_day` and the handler turns them into
|
||||
/// display-zone day bounds ([`apply_day_bounds`]), so the range agrees with
|
||||
/// [`row_display_day`] grouping by construction.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
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]
|
||||
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
|
||||
|
||||
@@ -474,6 +474,100 @@ mod tests {
|
||||
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]
|
||||
fn run_audit_skips_disabled_and_offline_libraries() {
|
||||
let tmp = tempdir().unwrap();
|
||||
|
||||
+18
-1
@@ -199,6 +199,14 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
tag_dao: Data<Mutex<TagD>>,
|
||||
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
||||
) -> 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 span = tracing::Span::current();
|
||||
@@ -1176,7 +1184,8 @@ pub async fn get_gps_summary(
|
||||
/// Handler for the batch EXIF endpoint at `GET /photos/exif`.
|
||||
///
|
||||
/// 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
|
||||
/// `/image/metadata` for window-scoped consumers like Apollo's photo-to-
|
||||
/// 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>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
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(
|
||||
"date_from",
|
||||
|
||||
+91
-16
@@ -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)
|
||||
};
|
||||
// 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
|
||||
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))
|
||||
.and_then(build_wall)
|
||||
{
|
||||
return Some(FilenameMatch {
|
||||
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})")
|
||||
.ok()?
|
||||
.captures(filename)
|
||||
.and_then(|c| build_wall_from_ymd_capture(&c))
|
||||
.and_then(build_wall)
|
||||
{
|
||||
return Some(FilenameMatch {
|
||||
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})")
|
||||
.ok()?
|
||||
.captures(filename)
|
||||
.and_then(|c| build_wall_from_ymd_capture(&c))
|
||||
.and_then(build_wall)
|
||||
{
|
||||
return Some(FilenameMatch {
|
||||
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})")
|
||||
.ok()?
|
||||
.captures(filename)
|
||||
.and_then(|c| build_wall_from_ymd_capture(&c))
|
||||
.and_then(build_wall)
|
||||
{
|
||||
return Some(FilenameMatch {
|
||||
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})")
|
||||
.ok()?
|
||||
.captures(filename)
|
||||
.and_then(|c| build_wall_from_ymd_capture(&c))
|
||||
.and_then(build_wall)
|
||||
{
|
||||
return Some(FilenameMatch {
|
||||
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
|
||||
/// 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
|
||||
}
|
||||
plausible_filename_year(dt.year()).then_some(dt)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -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]
|
||||
fn test_match_filename_date_out_of_class_is_none() {
|
||||
assert_eq!(match_filename_date("IMG_12345.jpg"), None);
|
||||
|
||||
@@ -77,6 +77,10 @@ pub struct UnifiedQuery {
|
||||
pub media_type: Option<MediaType>,
|
||||
pub date_from: 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_model: Option<String>,
|
||||
pub gps_lat: Option<f64>,
|
||||
@@ -292,6 +296,14 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
tag_dao: Data<Mutex<TagD>>,
|
||||
query: Query<UnifiedQuery>,
|
||||
) -> 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();
|
||||
if nl.is_empty() {
|
||||
return bad_request("query parameter `q` is required");
|
||||
|
||||
Reference in new issue
Block a user