1use anyhow::Result;
9use rusqlite::Connection;
10use std::collections::HashSet;
11
12pub const EFFECTIVE_DATE_SQL: &str = "CASE WHEN exif_date IS NOT NULL \
19 AND exif_date NOT LIKE '0000%' THEN exif_date ELSE modified_at END";
20
21pub fn by_date(
26 conn: &Connection,
27 after: Option<&str>,
28 before: Option<&str>,
29) -> Result<HashSet<String>> {
30 let mut sql =
31 format!("SELECT DISTINCT hash FROM file_hashes WHERE {EFFECTIVE_DATE_SQL} IS NOT NULL");
32 let mut params: Vec<String> = Vec::new();
33 if let Some(a) = after {
34 sql.push_str(&format!(" AND {EFFECTIVE_DATE_SQL} >= ?"));
35 params.push(a.to_string());
36 }
37 if let Some(b) = before {
38 sql.push_str(&format!(" AND {EFFECTIVE_DATE_SQL} < ?"));
39 params.push(b.to_string());
40 }
41 let mut stmt = conn.prepare(&sql)?;
42 let rows = stmt.query_map(rusqlite::params_from_iter(params), |r| {
43 r.get::<_, String>(0)
44 })?;
45 Ok(rows.collect::<rusqlite::Result<HashSet<String>>>()?)
46}
47
48use chrono::NaiveDate;
49
50const DATE_FORMS: &str = "expected YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DDTHH:MM:SS";
51
52fn start_of(y: i32, m: u32, d: u32) -> Result<String> {
53 NaiveDate::from_ymd_opt(y, m, d)
54 .map(|x| format!("{}T00:00:00", x.format("%Y-%m-%d")))
55 .ok_or_else(|| anyhow::anyhow!("invalid date {y:04}-{m:02}-{d:02}; {DATE_FORMS}"))
56}
57
58pub fn expand_date(spec: &str) -> Result<(String, String)> {
60 let parts: Vec<&str> = spec.split('-').collect();
61 let bad = || anyhow::anyhow!("cannot parse date {spec:?}; {DATE_FORMS}");
62 match parts.as_slice() {
63 [y] => {
64 let y: i32 = y.parse().map_err(|_| bad())?;
65 Ok((start_of(y, 1, 1)?, start_of(y + 1, 1, 1)?))
66 }
67 [y, m] => {
68 let (y, m): (i32, u32) = (y.parse().map_err(|_| bad())?, m.parse().map_err(|_| bad())?);
69 let start = start_of(y, m, 1)?;
70 let end = if m == 12 {
71 start_of(y + 1, 1, 1)?
72 } else {
73 start_of(y, m + 1, 1)?
74 };
75 Ok((start, end))
76 }
77 [y, m, d] => {
78 let (y, m, d): (i32, u32, u32) = (
79 y.parse().map_err(|_| bad())?,
80 m.parse().map_err(|_| bad())?,
81 d.parse().map_err(|_| bad())?,
82 );
83 let day = NaiveDate::from_ymd_opt(y, m, d).ok_or_else(bad)?;
84 let next = day.succ_opt().ok_or_else(bad)?;
85 Ok((
86 format!("{}T00:00:00", day.format("%Y-%m-%d")),
87 format!("{}T00:00:00", next.format("%Y-%m-%d")),
88 ))
89 }
90 _ => Err(bad()),
91 }
92}
93
94pub fn normalise_bound(spec: &str) -> Result<String> {
96 if spec.contains('T') {
97 return Ok(spec.to_string());
98 }
99 let (start, _) = expand_date(spec)?;
100 Ok(start)
101}
102
103pub fn by_person(conn: &Connection, name: &str) -> Result<HashSet<String>> {
105 let mut stmt =
106 conn.prepare("SELECT DISTINCT hash FROM faces WHERE person_label = ?1 AND confirmed = 1")?;
107 let rows = stmt.query_map(rusqlite::params![name], |r| r.get::<_, String>(0))?;
108 Ok(rows.collect::<rusqlite::Result<HashSet<String>>>()?)
109}
110
111pub fn by_category(conn: &Connection, model_id: &str, category: &str) -> Result<HashSet<String>> {
113 let mut stmt = conn.prepare(
114 "SELECT DISTINCT hash FROM classifications WHERE model_id = ?1 AND category = ?2",
115 )?;
116 let rows = stmt.query_map(rusqlite::params![model_id, category], |r| {
117 r.get::<_, String>(0)
118 })?;
119 Ok(rows.collect::<rusqlite::Result<HashSet<String>>>()?)
120}
121
122use std::collections::HashMap;
123
124fn haversine(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
126 const R: f64 = 6371.0;
127 let (dlat, dlon) = ((lat2 - lat1).to_radians(), (lon2 - lon1).to_radians());
128 let a = (dlat / 2.0).sin().powi(2)
129 + lat1.to_radians().cos() * lat2.to_radians().cos() * (dlon / 2.0).sin().powi(2);
130 2.0 * R * a.sqrt().asin()
131}
132
133pub fn by_location(
138 conn: &Connection,
139 lat: f64,
140 lon: f64,
141 radius_km: f64,
142) -> Result<HashMap<String, f64>> {
143 let mut stmt = conn.prepare(
144 "SELECT hash, gps_lat, gps_lon FROM file_hashes
145 WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL",
146 )?;
147 let rows = stmt.query_map([], |r| {
148 Ok((
149 r.get::<_, String>(0)?,
150 r.get::<_, f64>(1)?,
151 r.get::<_, f64>(2)?,
152 ))
153 })?;
154 let mut out = HashMap::new();
155 for row in rows {
156 let (hash, plat, plon) = row?;
157 let d = haversine(lat, lon, plat, plon);
158 if d <= radius_km {
159 out.entry(hash)
161 .and_modify(|e: &mut f64| *e = e.min(d))
162 .or_insert(d);
163 }
164 }
165 Ok(out)
166}
167
168#[derive(Debug, Clone, Copy)]
169pub struct GeoFilter {
170 pub lat: f64,
171 pub lon: f64,
172 pub radius_km: f64,
173}
174
175pub struct Candidates {
182 pub hashes: Option<HashSet<String>>,
185 pub distances: Option<HashMap<String, f64>>,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum SortField {
191 Relevance,
192 Distance,
193 Date,
194 Size,
195}
196
197impl SortField {
198 fn default_desc(self) -> bool {
201 !matches!(self, SortField::Distance)
202 }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub struct SortKey {
207 pub field: SortField,
208 pub desc: bool,
209}
210
211const SORT_FIELDS: &str = "valid fields: relevance, distance, date, size";
212const SORT_DIRS: &str = "valid directions: asc, desc";
213
214pub fn parse_sort(spec: &str) -> Result<Vec<SortKey>> {
215 let mut out: Vec<SortKey> = Vec::new();
216 for raw in spec.split(',') {
217 let part = raw.trim();
218 if part.is_empty() {
219 anyhow::bail!("empty sort field; {SORT_FIELDS}");
220 }
221 let (name, dir) = match part.split_once(':') {
222 Some((n, d)) => (n.trim(), Some(d.trim())),
223 None => (part, None),
224 };
225 let field = match name.to_ascii_lowercase().as_str() {
226 "relevance" => SortField::Relevance,
227 "distance" => SortField::Distance,
228 "date" => SortField::Date,
229 "size" => SortField::Size,
230 other => anyhow::bail!("unknown sort field {other:?}; {SORT_FIELDS}"),
231 };
232 let desc = match dir.map(|d| d.to_ascii_lowercase()) {
233 None => field.default_desc(),
234 Some(d) if d == "asc" => false,
235 Some(d) if d == "desc" => true,
236 Some(d) => anyhow::bail!("unknown sort direction {d:?}; {SORT_DIRS}"),
237 };
238 if out.iter().any(|k| k.field == field) {
239 anyhow::bail!("sort field {name:?} repeated; each field may appear once");
240 }
241 out.push(SortKey { field, desc });
242 }
243 Ok(out)
244}
245
246use std::cmp::Ordering;
247
248#[derive(Debug, Clone)]
251pub struct Sortable {
252 pub path: String,
253 pub score: Option<f32>,
254 pub distance_km: Option<f64>,
255 pub date: Option<String>,
256 pub size_bytes: Option<i64>,
257}
258
259fn cmp_opt<T: PartialOrd>(a: &Option<T>, b: &Option<T>, desc: bool) -> Ordering {
262 match (a, b) {
263 (None, None) => Ordering::Equal,
264 (None, Some(_)) => Ordering::Greater,
265 (Some(_), None) => Ordering::Less,
266 (Some(x), Some(y)) => {
267 let base = x.partial_cmp(y).unwrap_or(Ordering::Equal);
268 if desc {
269 base.reverse()
270 } else {
271 base
272 }
273 }
274 }
275}
276
277pub fn apply_sort(hits: &mut [Sortable], keys: &[SortKey]) {
279 hits.sort_by(|a, b| {
280 for k in keys {
281 let ord = match k.field {
282 SortField::Relevance => cmp_opt(&a.score, &b.score, k.desc),
283 SortField::Distance => cmp_opt(&a.distance_km, &b.distance_km, k.desc),
284 SortField::Date => cmp_opt(&a.date, &b.date, k.desc),
285 SortField::Size => cmp_opt(&a.size_bytes, &b.size_bytes, k.desc),
286 };
287 if ord != Ordering::Equal {
288 return ord;
289 }
290 }
291 Ordering::Equal
292 });
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use rusqlite::Connection;
299
300 fn db() -> Connection {
301 let conn = Connection::open_in_memory().unwrap();
302 conn.execute_batch(
303 "CREATE TABLE file_hashes (
304 path TEXT PRIMARY KEY, hash TEXT NOT NULL,
305 size_bytes INTEGER, modified_at TEXT, exif_date TEXT
306 );",
307 )
308 .unwrap();
309 conn
310 }
311
312 fn add(conn: &Connection, path: &str, hash: &str, exif: Option<&str>, mtime: &str) {
313 conn.execute(
314 "INSERT INTO file_hashes (path, hash, size_bytes, modified_at, exif_date)
315 VALUES (?1, ?2, 100, ?3, ?4)",
316 rusqlite::params![path, hash, mtime, exif],
317 )
318 .unwrap();
319 }
320
321 #[test]
322 fn date_filter_matches_on_exif_when_present() {
323 let conn = db();
324 add(
325 &conn,
326 "/a.jpg",
327 "h1",
328 Some("2025-05-14T10:00:00"),
329 "2026-01-01T00:00:00",
330 );
331 let got = by_date(
332 &conn,
333 Some("2025-05-01T00:00:00"),
334 Some("2025-06-01T00:00:00"),
335 )
336 .unwrap();
337 assert!(got.contains("h1"), "exif_date must win over modified_at");
338 }
339
340 #[test]
341 fn date_filter_falls_back_to_modified_at() {
342 let conn = db();
343 add(&conn, "/b.png", "h2", None, "2025-05-14T10:00:00");
344 let got = by_date(
345 &conn,
346 Some("2025-05-01T00:00:00"),
347 Some("2025-06-01T00:00:00"),
348 )
349 .unwrap();
350 assert!(
351 got.contains("h2"),
352 "a file with no EXIF must match on modified_at"
353 );
354 }
355
356 #[test]
357 fn date_filter_ignores_zero_exif_dates() {
358 let conn = db();
359 add(
360 &conn,
361 "/c.jpg",
362 "h3",
363 Some("0000-00-00T00:00:00"),
364 "2025-05-14T10:00:00",
365 );
366 let got = by_date(
367 &conn,
368 Some("2025-05-01T00:00:00"),
369 Some("2025-06-01T00:00:00"),
370 )
371 .unwrap();
372 assert!(
373 got.contains("h3"),
374 "an unset camera clock must fall back, not match year 0"
375 );
376 }
377
378 #[test]
379 fn before_is_exclusive_so_ranges_tile() {
380 let conn = db();
381 add(
382 &conn,
383 "/d.jpg",
384 "h4",
385 Some("2025-06-01T00:00:00"),
386 "2025-06-01T00:00:00",
387 );
388 let may = by_date(
389 &conn,
390 Some("2025-05-01T00:00:00"),
391 Some("2025-06-01T00:00:00"),
392 )
393 .unwrap();
394 let jun = by_date(
395 &conn,
396 Some("2025-06-01T00:00:00"),
397 Some("2025-07-01T00:00:00"),
398 )
399 .unwrap();
400 assert!(
401 !may.contains("h4"),
402 "the boundary instant belongs to June only"
403 );
404 assert!(jun.contains("h4"));
405 }
406
407 #[test]
408 fn open_ended_ranges_work() {
409 let conn = db();
410 add(
411 &conn,
412 "/e.jpg",
413 "h5",
414 Some("2025-05-14T10:00:00"),
415 "2025-05-14T10:00:00",
416 );
417 assert!(by_date(&conn, Some("2025-01-01T00:00:00"), None)
418 .unwrap()
419 .contains("h5"));
420 assert!(by_date(&conn, None, Some("2026-01-01T00:00:00"))
421 .unwrap()
422 .contains("h5"));
423 }
424
425 #[test]
426 fn date_shorthand_expands_to_half_open_ranges() {
427 assert_eq!(
428 expand_date("2025").unwrap(),
429 ("2025-01-01T00:00:00".into(), "2026-01-01T00:00:00".into())
430 );
431 assert_eq!(
432 expand_date("2025-05").unwrap(),
433 ("2025-05-01T00:00:00".into(), "2025-06-01T00:00:00".into())
434 );
435 assert_eq!(
436 expand_date("2025-12").unwrap(),
437 ("2025-12-01T00:00:00".into(), "2026-01-01T00:00:00".into())
438 );
439 assert_eq!(
440 expand_date("2025-05-14").unwrap(),
441 ("2025-05-14T00:00:00".into(), "2025-05-15T00:00:00".into())
442 );
443 }
444
445 #[test]
446 fn date_shorthand_handles_month_and_year_rollover() {
447 assert_eq!(expand_date("2024-02-29").unwrap().1, "2024-03-01T00:00:00");
448 assert_eq!(expand_date("2025-12-31").unwrap().1, "2026-01-01T00:00:00");
449 }
450
451 #[test]
452 fn normalise_bound_accepts_date_or_datetime() {
453 assert_eq!(
454 normalise_bound("2025-05-14").unwrap(),
455 "2025-05-14T00:00:00"
456 );
457 assert_eq!(
458 normalise_bound("2025-05-14T09:30:00").unwrap(),
459 "2025-05-14T09:30:00"
460 );
461 }
462
463 #[test]
464 fn bad_dates_are_rejected_with_a_helpful_message() {
465 for bad in ["", "May 2025", "2025-13", "2025-02-30", "20250514"] {
466 let err = expand_date(bad).unwrap_err().to_string();
467 assert!(
468 err.contains("YYYY"),
469 "error for {bad:?} should name the accepted forms, got: {err}"
470 );
471 }
472 }
473
474 fn db_with_faces_and_classes() -> Connection {
475 let conn = db();
476 conn.execute_batch(
477 "CREATE TABLE faces (id INTEGER PRIMARY KEY, hash TEXT NOT NULL,
478 person_label TEXT, confirmed INTEGER DEFAULT 0);
479 CREATE TABLE classifications (model_id TEXT NOT NULL, hash TEXT NOT NULL,
480 category TEXT NOT NULL, confidence REAL NOT NULL,
481 classified_at TEXT NOT NULL, PRIMARY KEY (model_id, hash));",
482 )
483 .unwrap();
484 conn
485 }
486
487 #[test]
488 fn person_predicate_returns_confirmed_only() {
489 let conn = db_with_faces_and_classes();
490 conn.execute_batch(
491 "INSERT INTO faces (hash, person_label, confirmed) VALUES
492 ('h1','Alice',1), ('h2','Alice',0), ('h3','Bob',1);",
493 )
494 .unwrap();
495 let got = by_person(&conn, "Alice").unwrap();
496 assert!(got.contains("h1"));
497 assert!(!got.contains("h2"), "unconfirmed faces must not match");
498 assert!(!got.contains("h3"));
499 }
500
501 #[test]
502 fn category_predicate_is_model_scoped() {
503 let conn = db_with_faces_and_classes();
504 conn.execute_batch(
505 "INSERT INTO classifications VALUES
506 ('m1','h1','screenshot',0.9,'now'),
507 ('m2','h2','screenshot',0.9,'now');",
508 )
509 .unwrap();
510 let got = by_category(&conn, "m1", "screenshot").unwrap();
511 assert!(got.contains("h1"));
512 assert!(!got.contains("h2"), "another model's rows must not leak in");
513 }
514
515 #[test]
516 fn predicates_return_empty_not_error_when_nothing_matches() {
517 let conn = db_with_faces_and_classes();
518 assert!(by_person(&conn, "Nobody").unwrap().is_empty());
519 assert!(by_category(&conn, "m1", "meme").unwrap().is_empty());
520 }
521
522 fn db_with_gps() -> Connection {
523 let conn = Connection::open_in_memory().unwrap();
524 conn.execute_batch(
525 "CREATE TABLE file_hashes (
526 path TEXT PRIMARY KEY, hash TEXT NOT NULL, size_bytes INTEGER,
527 modified_at TEXT, exif_date TEXT, gps_lat REAL, gps_lon REAL);
528 INSERT INTO file_hashes VALUES
529 ('/near.jpg','hn',100,'2025-01-01T00:00:00',NULL,52.5200,13.4050),
530 ('/far.jpg','hf',100,'2025-01-01T00:00:00',NULL,48.8566,2.3522),
531 ('/nogps.jpg','hx',100,'2025-01-01T00:00:00',NULL,NULL,NULL);",
532 )
533 .unwrap();
534 conn
535 }
536
537 #[test]
538 fn location_predicate_returns_only_within_radius_with_distances() {
539 let conn = db_with_gps();
540 let got = by_location(&conn, 52.5200, 13.4050, 10.0).unwrap();
542 assert!(got.contains_key("hn"));
543 assert!(
544 !got.contains_key("hf"),
545 "Paris is not within 10 km of Berlin"
546 );
547 assert!(!got.contains_key("hx"), "a file with no GPS cannot match");
548 assert!(
549 got["hn"] < 0.1,
550 "distance to itself should be ~0, got {}",
551 got["hn"]
552 );
553 }
554
555 #[test]
556 fn location_distance_is_roughly_correct_over_a_long_span() {
557 let conn = db_with_gps();
558 let got = by_location(&conn, 52.5200, 13.4050, 2000.0).unwrap();
559 let d = got["hf"];
560 assert!(
561 (870.0..890.0).contains(&d),
562 "Berlin to Paris is ~878 km, got {d}"
563 );
564 }
565
566 #[test]
567 fn sort_defaults_direction_per_field() {
568 assert_eq!(
569 parse_sort("distance,date").unwrap(),
570 vec![
571 SortKey {
572 field: SortField::Distance,
573 desc: false
574 },
575 SortKey {
576 field: SortField::Date,
577 desc: true
578 },
579 ]
580 );
581 assert_eq!(
582 parse_sort("relevance").unwrap(),
583 vec![SortKey {
584 field: SortField::Relevance,
585 desc: true
586 }]
587 );
588 assert_eq!(
589 parse_sort("size").unwrap(),
590 vec![SortKey {
591 field: SortField::Size,
592 desc: true
593 }]
594 );
595 }
596
597 #[test]
598 fn sort_accepts_explicit_directions() {
599 assert_eq!(
600 parse_sort("distance:desc,date:asc").unwrap(),
601 vec![
602 SortKey {
603 field: SortField::Distance,
604 desc: true
605 },
606 SortKey {
607 field: SortField::Date,
608 desc: false
609 },
610 ]
611 );
612 }
613
614 #[test]
615 fn sort_tolerates_spaces_and_case() {
616 assert_eq!(parse_sort(" Distance : ASC ").unwrap()[0].desc, false);
617 }
618
619 #[test]
620 fn sort_rejects_bad_specs_naming_valid_values() {
621 for (spec, needle) in [
622 ("bogus", "relevance"),
623 ("date:sideways", "asc"),
624 ("date,date", "repeated"),
625 ("", "relevance"),
626 ] {
627 let err = parse_sort(spec).unwrap_err().to_string();
628 assert!(
629 err.contains(needle),
630 "error for {spec:?} should mention {needle:?}, got: {err}"
631 );
632 }
633 }
634
635 fn hit(path: &str, score: Option<f32>, km: Option<f64>, date: &str, size: i64) -> Sortable {
636 Sortable {
637 path: path.into(),
638 score,
639 distance_km: km,
640 date: Some(date.into()),
641 size_bytes: Some(size),
642 }
643 }
644
645 #[test]
646 fn multi_field_sort_uses_later_fields_as_tie_breakers() {
647 let mut v = vec![
648 hit("/old.jpg", None, Some(1.0), "2024-01-01T00:00:00", 10),
649 hit("/new.jpg", None, Some(1.0), "2025-01-01T00:00:00", 10),
650 hit("/far.jpg", None, Some(9.0), "2026-01-01T00:00:00", 10),
651 ];
652 apply_sort(&mut v, &parse_sort("distance,date").unwrap());
653 let order: Vec<&str> = v.iter().map(|h| h.path.as_str()).collect();
654 assert_eq!(
655 order,
656 vec!["/new.jpg", "/old.jpg", "/far.jpg"],
657 "nearest first, newest first within the same distance"
658 );
659 }
660
661 #[test]
662 fn missing_sort_values_sort_last_in_both_directions() {
663 let mut v = vec![
664 Sortable {
665 path: "/none.jpg".into(),
666 score: None,
667 distance_km: None,
668 date: None,
669 size_bytes: None,
670 },
671 hit("/has.jpg", None, None, "2025-01-01T00:00:00", 10),
672 ];
673 apply_sort(&mut v, &parse_sort("date:desc").unwrap());
674 assert_eq!(
675 v[0].path, "/has.jpg",
676 "a row with no date must not outrank one with a date"
677 );
678 apply_sort(&mut v, &parse_sort("date:asc").unwrap());
679 assert_eq!(v[0].path, "/has.jpg", "and the same when ascending");
680 }
681
682 #[test]
683 fn sort_is_stable_on_full_ties() {
684 let mut v = vec![
685 hit("/a.jpg", None, Some(1.0), "2025-01-01T00:00:00", 10),
686 hit("/b.jpg", None, Some(1.0), "2025-01-01T00:00:00", 10),
687 ];
688 apply_sort(&mut v, &parse_sort("distance,date").unwrap());
689 assert_eq!(v[0].path, "/a.jpg", "equal rows keep their input order");
690 }
691}