1use std::collections::{BTreeMap, HashMap, HashSet};
4
5use rusqlite::{params, params_from_iter, types::Value, Connection, Result as SqliteResult};
6
7use crate::search::date_parser::{DateParser, DateRange};
8use crate::search::SearchQuery;
9
10#[derive(Debug, Clone)]
12pub struct SearchResult {
13 pub photo_id: i64,
14 pub date_taken: Option<String>,
15 pub location_city: Option<String>,
16 pub location_country: Option<String>,
17 pub thumbnail_path: Option<String>,
18}
19
20#[allow(dead_code)]
22#[derive(Debug, Clone)]
23pub struct SearchResultGroup {
24 pub date: String,
25 pub location: Option<String>,
26 pub results: Vec<SearchResult>,
27}
28
29#[derive(Debug, Clone)]
31pub struct PersonHit {
32 pub cluster_id: i64,
33 pub name: String,
34 pub photo_count: i64,
35 pub face_thumbnail_path: Option<String>,
37}
38
39#[derive(Debug, Clone)]
41pub struct AlbumHit {
42 pub album_id: i64,
43 pub name: String,
44 pub photo_count: i64,
45 pub cover_photo_id: Option<i64>,
46 pub cover_thumbnail_path: Option<String>,
48}
49
50#[derive(Debug, Clone)]
52pub struct PlaceHit {
53 pub city: String,
54 pub country: Option<String>,
55 pub photo_count: i64,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct InterpretedFilter {
60 pub kind: String,
61 pub label: String,
62}
63
64#[derive(Debug, Clone, Default)]
66pub struct UnifiedSearchResults {
67 pub interpreted: Vec<InterpretedFilter>,
68 pub people: Vec<PersonHit>,
69 pub albums: Vec<AlbumHit>,
70 pub places: Vec<PlaceHit>,
71 pub photos: Vec<SearchResult>,
72 pub photos_grouped: Vec<SearchResultGroup>,
73 pub photo_ids: Vec<i64>,
75}
76
77pub struct SearchService;
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81enum SmartMediaType {
82 Photo,
83 Video,
84}
85
86impl SmartMediaType {
87 fn as_db(self) -> &'static str {
88 match self {
89 Self::Photo => "photo",
90 Self::Video => "video",
91 }
92 }
93
94 fn label(self) -> &'static str {
95 match self {
96 Self::Photo => "Photos",
97 Self::Video => "Videos",
98 }
99 }
100}
101
102#[derive(Debug, Clone)]
103struct ResolvedPerson {
104 id: i64,
105 name: String,
106}
107
108#[derive(Debug, Clone)]
109struct ResolvedAlbum {
110 id: i64,
111 name: String,
112}
113
114#[derive(Debug, Clone)]
115struct ResolvedPlace {
116 city: Option<String>,
117 country: Option<String>,
118 label: String,
119}
120
121#[derive(Debug, Clone, Default)]
122struct SmartIntent {
123 date_range: Option<DateRange>,
124 text: Option<String>,
125 people_all: Vec<ResolvedPerson>,
126 people_only: bool,
127 places: Vec<ResolvedPlace>,
128 albums: Vec<ResolvedAlbum>,
129 favorite: Option<bool>,
130 media_type: Option<SmartMediaType>,
131 semantic_photo_ids: Vec<i64>,
132}
133
134impl SmartIntent {
135 fn has_structured_filters(&self) -> bool {
136 self.date_range.is_some()
137 || !self.people_all.is_empty()
138 || !self.places.is_empty()
139 || !self.albums.is_empty()
140 || self.favorite.is_some()
141 || self.media_type.is_some()
142 }
143}
144
145impl SearchService {
146 pub fn search(conn: &Connection, query: &SearchQuery) -> SqliteResult<Vec<SearchResult>> {
147 if query.is_empty() {
148 return Ok(Vec::new());
149 }
150
151 let mut sql = String::from(
152 "SELECT id, file_path, date_taken, location_city, location_country, thumbnail_path FROM photos WHERE is_trashed = FALSE",
153 );
154 let mut params_dyn: Vec<String> = Vec::new();
155
156 if let Some(range) = query.date_range() {
157 sql.push_str(" AND date_taken >= ? AND date_taken <= ?");
158 params_dyn.push(range.start.to_rfc3339());
159 params_dyn.push(range.end.to_rfc3339());
160 }
161
162 if let Some(location) = query.location() {
163 sql.push_str(" AND (LOWER(location_city) LIKE LOWER(?) OR LOWER(location_country) LIKE LOWER(?))");
164 let like = format!("%{}%", location);
165 params_dyn.push(like.clone());
166 params_dyn.push(like);
167 }
168
169 sql.push_str(" ORDER BY date_taken DESC LIMIT 1000");
170
171 let mut stmt = conn.prepare(&sql)?;
172
173 let mut rows_vec: Vec<SearchResult> = Vec::new();
174 match params_dyn.len() {
175 0 => {
176 let rows = stmt.query_map([], |row| {
177 Ok(SearchResult {
178 photo_id: row.get(0)?,
179 date_taken: row.get(2)?,
180 location_city: row.get(3)?,
181 location_country: row.get(4)?,
182 thumbnail_path: row.get(5)?,
183 })
184 })?;
185 for r in rows {
186 rows_vec.push(r?);
187 }
188 }
189 2 => {
190 let rows = stmt.query_map(params![params_dyn[0], params_dyn[1]], |row| {
191 Ok(SearchResult {
192 photo_id: row.get(0)?,
193 date_taken: row.get(2)?,
194 location_city: row.get(3)?,
195 location_country: row.get(4)?,
196 thumbnail_path: row.get(5)?,
197 })
198 })?;
199 for r in rows {
200 rows_vec.push(r?);
201 }
202 }
203 4 => {
204 let rows = stmt.query_map(
205 params![params_dyn[0], params_dyn[1], params_dyn[2], params_dyn[3]],
206 |row| {
207 Ok(SearchResult {
208 photo_id: row.get(0)?,
209 date_taken: row.get(2)?,
210 location_city: row.get(3)?,
211 location_country: row.get(4)?,
212 thumbnail_path: row.get(5)?,
213 })
214 },
215 )?;
216 for r in rows {
217 rows_vec.push(r?);
218 }
219 }
220 _ => {
221 let rows = stmt.query_map(
222 params![
223 params_dyn[0],
224 params_dyn[1],
225 params_dyn[2],
226 params_dyn[3],
227 params_dyn[4],
228 params_dyn[5]
229 ],
230 |row| {
231 Ok(SearchResult {
232 photo_id: row.get(0)?,
233 date_taken: row.get(2)?,
234 location_city: row.get(3)?,
235 location_country: row.get(4)?,
236 thumbnail_path: row.get(5)?,
237 })
238 },
239 )?;
240 for r in rows {
241 rows_vec.push(r?);
242 }
243 }
244 }
245
246 if let Some(person_name) = query.person() {
247 rows_vec = Self::filter_by_person(conn, rows_vec, person_name)?;
248 }
249
250 Ok(rows_vec)
251 }
252
253 fn filter_by_person(
254 conn: &Connection,
255 results: Vec<SearchResult>,
256 person_name: &str,
257 ) -> SqliteResult<Vec<SearchResult>> {
258 let mut stmt = conn.prepare(
259 r#"
260 SELECT DISTINCT photo_id FROM (
261 SELECT f.photo_id AS photo_id
262 FROM faces f
263 JOIN face_clusters fc ON f.cluster_id = fc.id
264 WHERE fc.name IS NOT NULL AND LOWER(fc.name) LIKE LOWER(?1)
265
266 UNION
267
268 SELECT pii.photo_id AS photo_id
269 FROM photo_inferred_identities pii
270 JOIN face_clusters fc ON pii.cluster_id = fc.id
271 WHERE fc.name IS NOT NULL AND LOWER(fc.name) LIKE LOWER(?1)
272 )
273 "#,
274 )?;
275
276 let ids: HashSet<i64> = stmt
277 .query_map(params![format!("%{}%", person_name)], |row| row.get(0))?
278 .collect::<SqliteResult<HashSet<_>>>()?;
279
280 Ok(results
281 .into_iter()
282 .filter(|r| ids.contains(&r.photo_id))
283 .collect())
284 }
285
286 pub fn group_by_date(results: Vec<SearchResult>) -> Vec<SearchResultGroup> {
287 let mut groups: BTreeMap<String, Vec<SearchResult>> = BTreeMap::new();
288
289 for result in results {
290 let date = result
291 .date_taken
292 .as_ref()
293 .and_then(|d| d.get(..10))
294 .unwrap_or("Unknown")
295 .to_string();
296
297 groups.entry(date).or_default().push(result);
298 }
299
300 groups
301 .into_iter()
302 .rev()
303 .map(|(date, results)| {
304 let location = results.iter().find_map(|r| {
305 r.location_city
306 .clone()
307 .or_else(|| r.location_country.clone())
308 });
309
310 SearchResultGroup {
311 date,
312 location,
313 results,
314 }
315 })
316 .collect()
317 }
318
319 pub fn search_unified(
325 conn: &Connection,
326 raw_query: &str,
327 ) -> SqliteResult<UnifiedSearchResults> {
328 Self::search_unified_with_semantic(conn, raw_query, Vec::new())
329 }
330
331 pub fn search_unified_with_semantic(
332 conn: &Connection,
333 raw_query: &str,
334 semantic_photo_ids: Vec<i64>,
335 ) -> SqliteResult<UnifiedSearchResults> {
336 let query = raw_query.trim();
337 if query.is_empty() {
338 return Ok(UnifiedSearchResults::default());
339 }
340
341 let mut intent = Self::parse_smart_intent(conn, query)?;
342 intent.semantic_photo_ids = semantic_photo_ids;
343 let mut results = UnifiedSearchResults {
344 interpreted: Self::interpreted_filters(&intent),
345 people: Self::search_people(conn, query)?,
346 albums: Self::search_albums(conn, query)?,
347 places: Self::search_places(conn, query)?,
348 ..Default::default()
349 };
350
351 let photos = Self::search_smart_photos(conn, &intent)?;
358
359 results.photo_ids = photos.iter().map(|r| r.photo_id).collect();
360 results.photos_grouped = Self::group_by_date(photos.clone());
361 results.photos = photos;
362
363 Ok(results)
364 }
365
366 fn parse_smart_intent(conn: &Connection, raw: &str) -> SqliteResult<SmartIntent> {
367 let (text_part, date_range) = Self::split_query(raw);
368 let mut text = text_part.unwrap_or_default();
369 let mut intent = SmartIntent {
370 date_range,
371 ..Default::default()
372 };
373 let mut lower = text.to_lowercase();
374
375 if let Some(rest) = lower
376 .strip_prefix("only ")
377 .or_else(|| lower.strip_prefix("just "))
378 {
379 intent.people_only = true;
380 text = text[text.len() - rest.len()..].trim().to_string();
381 lower = text.to_lowercase();
382 }
383
384 for (needle, media) in [
385 ("videos", SmartMediaType::Video),
386 ("video", SmartMediaType::Video),
387 ("photos", SmartMediaType::Photo),
388 ("photo", SmartMediaType::Photo),
389 ] {
390 if Self::contains_word(&lower, needle) {
391 intent.media_type = Some(media);
392 text = Self::remove_word(&text, needle);
393 lower = text.to_lowercase();
394 break;
395 }
396 }
397
398 for needle in [
399 "favourites",
400 "favorites",
401 "favourite",
402 "favorite",
403 "starred",
404 ] {
405 if Self::contains_word(&lower, needle) {
406 intent.favorite = Some(true);
407 text = Self::remove_word(&text, needle);
408 lower = text.to_lowercase();
409 break;
410 }
411 }
412
413 let album_phrase = if let Some(rest) = lower.strip_prefix("album ") {
414 Some(text[text.len() - rest.len()..].trim().to_string())
415 } else {
416 lower
417 .strip_prefix("in album ")
418 .map(|rest| text[text.len() - rest.len()..].trim().to_string())
419 };
420 if let Some(album_query) = album_phrase.as_deref() {
421 intent.albums = Self::resolve_albums(conn, album_query)?;
422 if !intent.albums.is_empty() {
423 text.clear();
424 }
425 }
426
427 let mut remaining = text.trim().to_string();
428 if !remaining.is_empty() {
429 let people = Self::resolve_people(conn, &remaining)?;
430 if !people.is_empty() {
431 remaining =
432 Self::remove_entity_names(&remaining, people.iter().map(|p| p.name.as_str()));
433 intent.people_all = people;
434 }
435 }
436
437 if !remaining.trim().is_empty() {
438 let places = Self::resolve_places(conn, remaining.trim())?;
439 if !places.is_empty() {
440 remaining =
441 Self::remove_entity_names(&remaining, places.iter().map(|p| p.label.as_str()));
442 remaining = Self::remove_entity_names(
443 &remaining,
444 places.iter().filter_map(|p| p.city.as_deref()),
445 );
446 remaining = Self::remove_entity_names(
447 &remaining,
448 places.iter().filter_map(|p| p.country.as_deref()),
449 );
450 intent.places = places;
451 }
452 }
453
454 let cleanup = remaining
455 .split_whitespace()
456 .filter(|w| {
457 !matches!(
458 w.to_lowercase().as_str(),
459 "and"
460 | "&"
461 | "with"
462 | "in"
463 | "at"
464 | "from"
465 | "person"
466 | "people"
467 | "containing"
468 )
469 })
470 .collect::<Vec<_>>()
471 .join(" ");
472 if !cleanup.trim().is_empty() {
473 intent.text = Some(cleanup.trim().to_string());
474 }
475 Ok(intent)
476 }
477
478 fn interpreted_filters(intent: &SmartIntent) -> Vec<InterpretedFilter> {
479 let mut out = Vec::new();
480 if intent.people_only {
481 out.push(InterpretedFilter {
482 kind: "only".into(),
483 label: "Only".into(),
484 });
485 }
486 for p in &intent.people_all {
487 out.push(InterpretedFilter {
488 kind: "person".into(),
489 label: p.name.clone(),
490 });
491 }
492 for p in &intent.places {
493 out.push(InterpretedFilter {
494 kind: "place".into(),
495 label: p.label.clone(),
496 });
497 }
498 for a in &intent.albums {
499 out.push(InterpretedFilter {
500 kind: "album".into(),
501 label: a.name.clone(),
502 });
503 }
504 if let Some(media) = intent.media_type {
505 out.push(InterpretedFilter {
506 kind: "media".into(),
507 label: media.label().into(),
508 });
509 }
510 if intent.favorite == Some(true) {
511 out.push(InterpretedFilter {
512 kind: "favorite".into(),
513 label: "Favourites".into(),
514 });
515 }
516 if let Some(range) = &intent.date_range {
517 out.push(InterpretedFilter {
518 kind: "date".into(),
519 label: Self::date_label(range),
520 });
521 }
522 if let Some(text) = &intent.text {
523 if intent.semantic_photo_ids.is_empty() {
524 out.push(InterpretedFilter {
525 kind: "text".into(),
526 label: text.clone(),
527 });
528 } else {
529 out.push(InterpretedFilter {
530 kind: "semantic".into(),
531 label: text.clone(),
532 });
533 }
534 } else if !intent.semantic_photo_ids.is_empty() {
535 out.push(InterpretedFilter {
536 kind: "semantic".into(),
537 label: "Visual meaning".into(),
538 });
539 }
540 out
541 }
542
543 fn split_query(q: &str) -> (Option<String>, Option<DateRange>) {
546 let trimmed = q.trim();
547 if trimmed.is_empty() {
548 return (None, None);
549 }
550 if let Some(range) = DateParser::parse(trimmed) {
552 return (None, Some(range));
553 }
554 let words: Vec<&str> = trimmed.split_whitespace().collect();
555 if words.len() >= 2 {
558 for i in 0..(words.len() - 1) {
559 let candidate = format!("{} {}", words[i], words[i + 1]);
560 if let Some(range) = DateParser::parse(&candidate) {
561 let rest = words
562 .iter()
563 .enumerate()
564 .filter_map(|(idx, word)| {
565 if idx == i || idx == i + 1 {
566 None
567 } else {
568 Some(*word)
569 }
570 })
571 .collect::<Vec<_>>()
572 .join(" ");
573 let rest = rest.trim().to_string();
574 return (if rest.is_empty() { None } else { Some(rest) }, Some(range));
575 }
576 }
577 }
578 for i in 0..words.len() {
581 if let Some(range) = DateParser::parse(words[i]) {
582 let rest = words
583 .iter()
584 .enumerate()
585 .filter_map(|(idx, word)| if idx == i { None } else { Some(*word) })
586 .collect::<Vec<_>>()
587 .join(" ");
588 let rest = rest.trim().to_string();
589 return (if rest.is_empty() { None } else { Some(rest) }, Some(range));
590 }
591 }
592 (Some(trimmed.to_string()), None)
593 }
594
595 fn search_smart_photos(
596 conn: &Connection,
597 intent: &SmartIntent,
598 ) -> SqliteResult<Vec<SearchResult>> {
599 let mut sql = String::from(
600 "SELECT p.id, p.date_taken, p.location_city, p.location_country, p.thumbnail_path \
601 FROM photos p WHERE p.is_trashed = FALSE",
602 );
603 let mut bind: Vec<Value> = Vec::new();
604
605 if let Some(d) = &intent.date_range {
606 sql.push_str(" AND p.date_taken >= ? AND p.date_taken <= ?");
607 bind.push(Value::Text(d.start.to_rfc3339()));
608 bind.push(Value::Text(d.end.to_rfc3339()));
609 }
610 if let Some(media) = intent.media_type {
611 sql.push_str(" AND p.media_type = ?");
612 bind.push(Value::Text(media.as_db().to_string()));
613 }
614 if intent.favorite == Some(true) {
615 sql.push_str(" AND p.is_favorite = TRUE");
616 }
617 for album in &intent.albums {
618 sql.push_str(
619 " AND EXISTS (SELECT 1 FROM album_photos ap WHERE ap.photo_id = p.id AND ap.album_id = ?)",
620 );
621 bind.push(Value::Integer(album.id));
622 }
623 for place in &intent.places {
624 match (&place.city, &place.country) {
625 (Some(city), Some(country)) => {
626 sql.push_str(
627 " AND LOWER(p.location_city) LIKE LOWER(?) AND LOWER(p.location_country) LIKE LOWER(?)",
628 );
629 bind.push(Value::Text(format!("%{}%", city)));
630 bind.push(Value::Text(format!("%{}%", country)));
631 }
632 (Some(city), None) => {
633 sql.push_str(" AND LOWER(p.location_city) LIKE LOWER(?)");
634 bind.push(Value::Text(format!("%{}%", city)));
635 }
636 (None, Some(country)) => {
637 sql.push_str(" AND LOWER(p.location_country) LIKE LOWER(?)");
638 bind.push(Value::Text(format!("%{}%", country)));
639 }
640 (None, None) => {}
641 }
642 }
643 for person in &intent.people_all {
644 sql.push_str(
645 " AND (EXISTS (
646 SELECT 1 FROM faces f
647 WHERE f.photo_id = p.id AND f.cluster_id = ?
648 ) OR EXISTS (
649 SELECT 1 FROM photo_inferred_identities pii
650 WHERE pii.photo_id = p.id AND pii.cluster_id = ?
651 ))",
652 );
653 bind.push(Value::Integer(person.id));
654 bind.push(Value::Integer(person.id));
655 }
656 if intent.people_only && !intent.people_all.is_empty() {
657 sql.push_str(" AND p.faces_processed = TRUE");
658 let placeholders = std::iter::repeat_n("?", intent.people_all.len())
659 .collect::<Vec<_>>()
660 .join(",");
661 sql.push_str(&format!(
662 " AND NOT EXISTS (
663 SELECT 1 FROM faces f
664 WHERE f.photo_id = p.id
665 AND (f.cluster_id IS NULL OR f.cluster_id NOT IN ({placeholders}))
666 )"
667 ));
668 for person in &intent.people_all {
669 bind.push(Value::Integer(person.id));
670 }
671 sql.push_str(&format!(
672 " AND NOT EXISTS (
673 SELECT 1 FROM photo_inferred_identities pii
674 WHERE pii.photo_id = p.id AND pii.cluster_id NOT IN ({placeholders})
675 )"
676 ));
677 for person in &intent.people_all {
678 bind.push(Value::Integer(person.id));
679 }
680 }
681 if let Some(t) = &intent.text {
682 let semantic_clause = if intent.semantic_photo_ids.is_empty() {
683 String::new()
684 } else {
685 format!(
686 " OR p.id IN ({})",
687 std::iter::repeat_n("?", intent.semantic_photo_ids.len())
688 .collect::<Vec<_>>()
689 .join(",")
690 )
691 };
692 sql.push_str(&format!(
693 " AND (
694 LOWER(p.file_name) LIKE LOWER(?) OR
695 LOWER(p.location_city) LIKE LOWER(?) OR
696 LOWER(p.location_country) LIKE LOWER(?) OR
697 LOWER(p.camera_make) LIKE LOWER(?) OR
698 LOWER(p.camera_model) LIKE LOWER(?) OR
699 LOWER(COALESCE(p.camera_make, '') || ' ' || COALESCE(p.camera_model, '')) LIKE LOWER(?) OR
700 LOWER(COALESCE(p.ocr_text, '')) LIKE LOWER(?) OR
701 EXISTS (
702 SELECT 1
703 FROM faces f
704 JOIN face_clusters fc ON fc.id = f.cluster_id
705 WHERE f.photo_id = p.id AND LOWER(fc.name) LIKE LOWER(?)
706 ) OR
707 EXISTS (
708 SELECT 1
709 FROM photo_inferred_identities pii
710 JOIN face_clusters fc ON fc.id = pii.cluster_id
711 WHERE pii.photo_id = p.id AND LOWER(fc.name) LIKE LOWER(?)
712 )
713 {semantic_clause}
714 )",
715 ));
716 let like = Value::Text(format!("%{}%", t));
717 for _ in 0..9 {
718 bind.push(like.clone());
719 }
720 for id in &intent.semantic_photo_ids {
721 bind.push(Value::Integer(*id));
722 }
723 } else if !intent.semantic_photo_ids.is_empty() && !intent.has_structured_filters() {
724 sql.push_str(&format!(
725 " AND p.id IN ({})",
726 std::iter::repeat_n("?", intent.semantic_photo_ids.len())
727 .collect::<Vec<_>>()
728 .join(",")
729 ));
730 for id in &intent.semantic_photo_ids {
731 bind.push(Value::Integer(*id));
732 }
733 }
734
735 let limit = if intent.semantic_photo_ids.is_empty() {
736 1000
737 } else {
738 5000
739 };
740 sql.push_str(&format!(
741 " ORDER BY p.date_taken DESC, p.id DESC LIMIT {limit}"
742 ));
743 let mut stmt = conn.prepare(&sql)?;
744 let rows = stmt.query_map(params_from_iter(bind.iter()), |row| {
745 Ok(SearchResult {
746 photo_id: row.get(0)?,
747 date_taken: row.get(1)?,
748 location_city: row.get(2)?,
749 location_country: row.get(3)?,
750 thumbnail_path: row.get(4)?,
751 })
752 })?;
753 let mut results = rows.collect::<SqliteResult<Vec<_>>>()?;
754 if !intent.semantic_photo_ids.is_empty() {
755 let rank: HashMap<i64, usize> = intent
756 .semantic_photo_ids
757 .iter()
758 .enumerate()
759 .map(|(idx, id)| (*id, idx))
760 .collect();
761 results.sort_by(
762 |a, b| match (rank.get(&a.photo_id), rank.get(&b.photo_id)) {
763 (Some(ra), Some(rb)) => ra.cmp(rb),
764 (Some(_), None) => std::cmp::Ordering::Less,
765 (None, Some(_)) => std::cmp::Ordering::Greater,
766 (None, None) => b
767 .date_taken
768 .cmp(&a.date_taken)
769 .then(b.photo_id.cmp(&a.photo_id)),
770 },
771 );
772 results.truncate(1000);
773 }
774 Ok(results)
775 }
776
777 fn resolve_people(conn: &Connection, text: &str) -> SqliteResult<Vec<ResolvedPerson>> {
778 let mut stmt = conn.prepare(
779 "SELECT id, name FROM face_clusters
780 WHERE name IS NOT NULL AND trim(name) != ''
781 ORDER BY length(name) DESC, photo_count DESC",
782 )?;
783 let rows = stmt.query_map([], |row| {
784 Ok(ResolvedPerson {
785 id: row.get(0)?,
786 name: row.get(1)?,
787 })
788 })?;
789 let lower = text.to_lowercase();
790 let mut out = Vec::new();
791 for row in rows {
792 let person = row?;
793 if Self::contains_phrase(&lower, &person.name.to_lowercase()) {
794 out.push(person);
795 }
796 }
797 Ok(out)
798 }
799
800 fn resolve_albums(conn: &Connection, text: &str) -> SqliteResult<Vec<ResolvedAlbum>> {
801 let like = format!("%{}%", text.trim());
802 let mut stmt = conn.prepare(
803 "SELECT id, name FROM albums
804 WHERE LOWER(name) LIKE LOWER(?1)
805 ORDER BY updated_at DESC
806 LIMIT 5",
807 )?;
808 let rows = stmt.query_map(params![like], |row| {
809 Ok(ResolvedAlbum {
810 id: row.get(0)?,
811 name: row.get(1)?,
812 })
813 })?;
814 rows.collect()
815 }
816
817 fn resolve_places(conn: &Connection, text: &str) -> SqliteResult<Vec<ResolvedPlace>> {
818 let mut stmt = conn.prepare(
819 "SELECT location_city, location_country, COUNT(*) AS cnt
820 FROM photos
821 WHERE is_trashed = FALSE
822 AND (location_city IS NOT NULL OR location_country IS NOT NULL)
823 GROUP BY location_city, location_country
824 ORDER BY cnt DESC
825 LIMIT 100",
826 )?;
827 let rows = stmt.query_map([], |row| {
828 let city: Option<String> = row.get(0)?;
829 let country: Option<String> = row.get(1)?;
830 let label = match (&city, &country) {
831 (Some(c), Some(country)) => format!("{}, {}", c, country),
832 (Some(c), None) => c.clone(),
833 (None, Some(country)) => country.clone(),
834 (None, None) => String::new(),
835 };
836 Ok(ResolvedPlace {
837 city,
838 country,
839 label,
840 })
841 })?;
842 let lower = text.to_lowercase();
843 let mut out = Vec::new();
844 for row in rows {
845 let place = row?;
846 let city_match = place
847 .city
848 .as_deref()
849 .is_some_and(|city| Self::contains_phrase(&lower, &city.to_lowercase()));
850 let country_match = place
851 .country
852 .as_deref()
853 .is_some_and(|country| Self::contains_phrase(&lower, &country.to_lowercase()));
854 let label_match = !place.label.is_empty()
855 && Self::contains_phrase(&lower, &place.label.to_lowercase());
856 if city_match || country_match || label_match {
857 out.push(place);
858 if out.len() >= 3 {
859 break;
860 }
861 }
862 }
863 Ok(out)
864 }
865
866 fn contains_word(lower: &str, needle: &str) -> bool {
867 lower
868 .split(|c: char| !c.is_alphanumeric())
869 .any(|w| w == needle)
870 }
871
872 fn contains_phrase(lower: &str, phrase: &str) -> bool {
873 if phrase.trim().is_empty() {
874 return false;
875 }
876 lower == phrase
877 || lower.contains(&format!(" {} ", phrase))
878 || lower.starts_with(&format!("{} ", phrase))
879 || lower.ends_with(&format!(" {}", phrase))
880 }
881
882 fn remove_word(text: &str, needle: &str) -> String {
883 text.split_whitespace()
884 .filter(|w| {
885 w.trim_matches(|c: char| !c.is_alphanumeric())
886 .to_lowercase()
887 != needle
888 })
889 .collect::<Vec<_>>()
890 .join(" ")
891 }
892
893 fn remove_entity_names<'a>(text: &str, names: impl Iterator<Item = &'a str>) -> String {
894 let mut out = text.to_string();
895 for name in names {
896 out = out.replace(name, " ");
897 out = out.replace(&name.to_lowercase(), " ");
898 }
899 out
900 }
901
902 fn date_label(range: &DateRange) -> String {
903 let start = range.start.format("%Y-%m-%d").to_string();
904 let end = range.end.format("%Y-%m-%d").to_string();
905 if start == end {
906 start
907 } else {
908 format!("{} to {}", start, end)
909 }
910 }
911
912 fn search_people(conn: &Connection, q: &str) -> SqliteResult<Vec<PersonHit>> {
913 let like = format!("%{}%", q);
914 let mut stmt = conn.prepare(
918 r#"
919 SELECT fc.id, fc.name, fc.photo_count, fc.representative_face_id
920 FROM face_clusters fc
921 WHERE fc.name IS NOT NULL
922 AND LOWER(fc.name) LIKE LOWER(?1)
923 ORDER BY fc.photo_count DESC
924 LIMIT 10
925 "#,
926 )?;
927 let rows = stmt.query_map(params![like], |row| {
928 let face_id: Option<i64> = row.get(3)?;
929 Ok(PersonHit {
930 cluster_id: row.get(0)?,
931 name: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
932 photo_count: row.get(2)?,
933 face_thumbnail_path: face_id.map(|id| format!(".photovault/faces/{}.jpg", id)),
934 })
935 })?;
936 rows.collect()
937 }
938
939 fn search_albums(conn: &Connection, q: &str) -> SqliteResult<Vec<AlbumHit>> {
940 let like = format!("%{}%", q);
941 let mut stmt = conn.prepare(
944 r#"
945 SELECT a.id, a.name, a.photo_count, a.cover_photo_id, pcov.thumbnail_path
946 FROM albums a
947 LEFT JOIN photos pcov ON pcov.id = a.cover_photo_id
948 WHERE LOWER(a.name) LIKE LOWER(?1)
949 ORDER BY a.updated_at DESC
950 LIMIT 10
951 "#,
952 )?;
953 let rows = stmt.query_map(params![like], |row| {
954 Ok(AlbumHit {
955 album_id: row.get(0)?,
956 name: row.get(1)?,
957 photo_count: row.get(2)?,
958 cover_photo_id: row.get(3)?,
959 cover_thumbnail_path: row.get(4)?,
960 })
961 })?;
962 rows.collect()
963 }
964
965 fn search_places(conn: &Connection, q: &str) -> SqliteResult<Vec<PlaceHit>> {
966 let like = format!("%{}%", q);
967 let mut stmt = conn.prepare(
968 r#"
969 SELECT location_city, location_country, COUNT(*) AS cnt
970 FROM photos
971 WHERE is_trashed = FALSE
972 AND location_city IS NOT NULL
973 AND (LOWER(location_city) LIKE LOWER(?1)
974 OR LOWER(location_country) LIKE LOWER(?1))
975 GROUP BY location_city, location_country
976 ORDER BY cnt DESC
977 LIMIT 10
978 "#,
979 )?;
980 let rows = stmt.query_map(params![like], |row| {
981 Ok(PlaceHit {
982 city: row.get(0)?,
983 country: row.get(1)?,
984 photo_count: row.get(2)?,
985 })
986 })?;
987 rows.collect()
988 }
989}
990
991#[cfg(test)]
992mod tests {
993 use super::*;
994
995 fn search_test_conn() -> Connection {
996 let conn = Connection::open_in_memory().unwrap();
997 conn.execute_batch(
998 r#"
999 CREATE TABLE photos (
1000 id INTEGER PRIMARY KEY,
1001 file_path TEXT NOT NULL,
1002 file_name TEXT NOT NULL,
1003 file_hash TEXT NOT NULL,
1004 file_size INTEGER NOT NULL,
1005 date_taken TEXT,
1006 location_city TEXT,
1007 location_country TEXT,
1008 camera_make TEXT,
1009 camera_model TEXT,
1010 ocr_text TEXT,
1011 thumbnail_path TEXT,
1012 faces_processed BOOLEAN DEFAULT FALSE,
1013 media_type TEXT NOT NULL DEFAULT 'photo',
1014 is_favorite BOOLEAN DEFAULT FALSE,
1015 is_trashed BOOLEAN DEFAULT FALSE
1016 );
1017 CREATE TABLE face_clusters (
1018 id INTEGER PRIMARY KEY,
1019 name TEXT,
1020 photo_count INTEGER NOT NULL DEFAULT 0,
1021 representative_face_id INTEGER
1022 );
1023 CREATE TABLE faces (
1024 id INTEGER PRIMARY KEY,
1025 photo_id INTEGER NOT NULL,
1026 cluster_id INTEGER
1027 );
1028 CREATE TABLE photo_inferred_identities (
1029 photo_id INTEGER NOT NULL,
1030 cluster_id INTEGER NOT NULL
1031 );
1032 CREATE TABLE albums (
1033 id INTEGER PRIMARY KEY,
1034 name TEXT NOT NULL,
1035 photo_count INTEGER NOT NULL DEFAULT 0,
1036 cover_photo_id INTEGER,
1037 updated_at TEXT
1038 );
1039 CREATE TABLE album_photos (
1040 album_id INTEGER NOT NULL,
1041 photo_id INTEGER NOT NULL
1042 );
1043 "#,
1044 )
1045 .unwrap();
1046 conn
1047 }
1048
1049 fn insert_photo(conn: &Connection, id: i64, file_name: &str, date_taken: &str) {
1050 conn.execute(
1051 "INSERT INTO photos
1052 (id, file_path, file_name, file_hash, file_size, date_taken, media_type, thumbnail_path)
1053 VALUES (?1, ?2, ?3, ?4, 100, ?5, 'photo', ?6)",
1054 params![
1055 id,
1056 format!("{file_name}.jpg"),
1057 file_name,
1058 format!("hash-{id}"),
1059 date_taken,
1060 format!(".photovault/thumbs/{file_name}.jpg")
1061 ],
1062 )
1063 .unwrap();
1064 }
1065
1066 fn set_location(conn: &Connection, id: i64, city: &str, country: &str) {
1067 conn.execute(
1068 "UPDATE photos SET location_city = ?2, location_country = ?3 WHERE id = ?1",
1069 params![id, city, country],
1070 )
1071 .unwrap();
1072 }
1073
1074 fn insert_person(conn: &Connection, id: i64, name: &str, photo_ids: &[i64]) {
1075 conn.execute(
1076 "INSERT INTO face_clusters (id, name, photo_count) VALUES (?1, ?2, ?3)",
1077 params![id, name, photo_ids.len() as i64],
1078 )
1079 .unwrap();
1080 for (idx, photo_id) in photo_ids.iter().enumerate() {
1081 conn.execute(
1082 "INSERT INTO faces (id, photo_id, cluster_id) VALUES (?1, ?2, ?3)",
1083 params![10_000 + idx as i64 + id * 100, photo_id, id],
1084 )
1085 .unwrap();
1086 }
1087 }
1088
1089 #[test]
1090 fn unified_search_matches_ocr_text() {
1091 let conn = search_test_conn();
1092 insert_photo(&conn, 1, "scan-001", "2024-01-01T10:00:00Z");
1093 insert_photo(&conn, 2, "scan-002", "2024-01-02T10:00:00Z");
1094 conn.execute(
1095 "UPDATE photos SET ocr_text = 'Boarding pass Bengaluru to Delhi' WHERE id = 2",
1096 [],
1097 )
1098 .unwrap();
1099
1100 let results =
1101 SearchService::search_unified_with_semantic(&conn, "boarding", vec![]).unwrap();
1102
1103 assert_eq!(results.photo_ids, vec![2]);
1104 }
1105
1106 #[test]
1107 fn unified_search_returns_semantic_matches_when_text_does_not_match_metadata() {
1108 let conn = search_test_conn();
1109 insert_photo(&conn, 1, "img001", "2024-01-01T10:00:00Z");
1110 insert_photo(&conn, 2, "img002", "2024-01-02T10:00:00Z");
1111 insert_photo(&conn, 3, "img003", "2024-01-03T10:00:00Z");
1112
1113 let results =
1114 SearchService::search_unified_with_semantic(&conn, "group photo", vec![3, 1]).unwrap();
1115
1116 assert_eq!(results.photo_ids, vec![3, 1]);
1117 assert_eq!(
1118 results
1119 .interpreted
1120 .iter()
1121 .map(|f| (f.kind.as_str(), f.label.as_str()))
1122 .collect::<Vec<_>>(),
1123 vec![("media", "Photos"), ("semantic", "group")]
1124 );
1125 }
1126
1127 #[test]
1128 fn unified_search_ands_date_filter_with_semantic_matches() {
1129 let conn = search_test_conn();
1130 insert_photo(&conn, 1, "img001", "2023-06-01T10:00:00Z");
1131 insert_photo(&conn, 2, "img002", "2024-06-01T10:00:00Z");
1132 insert_photo(&conn, 3, "img003", "2024-07-01T10:00:00Z");
1133
1134 let results =
1135 SearchService::search_unified_with_semantic(&conn, "family 2024", vec![1, 3, 2])
1136 .unwrap();
1137
1138 assert_eq!(results.photo_ids, vec![3, 2]);
1139 assert!(results
1140 .interpreted
1141 .iter()
1142 .any(|f| f.kind == "semantic" && f.label == "family"));
1143 assert!(results.interpreted.iter().any(|f| f.kind == "date"));
1144 }
1145
1146 #[test]
1147 fn unified_search_excludes_trashed_semantic_matches() {
1148 let conn = search_test_conn();
1149 insert_photo(&conn, 1, "img001", "2024-01-01T10:00:00Z");
1150 insert_photo(&conn, 2, "img002", "2024-01-02T10:00:00Z");
1151 conn.execute("UPDATE photos SET is_trashed = TRUE WHERE id = 1", [])
1152 .unwrap();
1153
1154 let results =
1155 SearchService::search_unified_with_semantic(&conn, "beach", vec![1, 2]).unwrap();
1156
1157 assert_eq!(results.photo_ids, vec![2]);
1158 }
1159
1160 #[test]
1161 fn unified_search_resolves_person_and_place_independent_of_order() {
1162 let conn = search_test_conn();
1163 insert_photo(&conn, 1, "vizag-tata", "2024-01-01T10:00:00Z");
1164 insert_photo(&conn, 2, "vizag-other", "2024-01-02T10:00:00Z");
1165 insert_photo(&conn, 3, "goa-tata", "2024-01-03T10:00:00Z");
1166 set_location(&conn, 1, "Vizianagaram", "India");
1167 set_location(&conn, 2, "Vizianagaram", "India");
1168 set_location(&conn, 3, "Goa", "India");
1169 insert_person(&conn, 7, "Tata", &[1, 3]);
1170
1171 let a = SearchService::search_unified_with_semantic(&conn, "tata vizianagaram", vec![])
1172 .unwrap();
1173 let b = SearchService::search_unified_with_semantic(&conn, "vizianagaram tata", vec![])
1174 .unwrap();
1175
1176 assert_eq!(a.photo_ids, vec![1]);
1177 assert_eq!(b.photo_ids, vec![1]);
1178 assert_eq!(
1179 a.interpreted
1180 .iter()
1181 .map(|f| (f.kind.as_str(), f.label.as_str()))
1182 .collect::<Vec<_>>(),
1183 vec![("person", "Tata"), ("place", "Vizianagaram, India")]
1184 );
1185 assert_eq!(
1186 b.interpreted
1187 .iter()
1188 .map(|f| (f.kind.as_str(), f.label.as_str()))
1189 .collect::<Vec<_>>(),
1190 vec![("person", "Tata"), ("place", "Vizianagaram, India")]
1191 );
1192 }
1193
1194 #[test]
1195 fn semantic_does_not_delete_fully_structured_matches() {
1196 let conn = search_test_conn();
1197 insert_photo(&conn, 1, "vizag-tata", "2024-01-01T10:00:00Z");
1198 insert_photo(&conn, 2, "vizag-tata-older", "2023-01-01T10:00:00Z");
1199 set_location(&conn, 1, "Vizianagaram", "India");
1200 set_location(&conn, 2, "Vizianagaram", "India");
1201 insert_person(&conn, 7, "Tata", &[1, 2]);
1202
1203 let results =
1204 SearchService::search_unified_with_semantic(&conn, "tata vizianagaram", vec![1])
1205 .unwrap();
1206
1207 assert_eq!(results.photo_ids, vec![1, 2]);
1208 }
1209
1210 #[test]
1211 fn semantic_visual_text_still_filters_inside_structured_matches() {
1212 let conn = search_test_conn();
1213 insert_photo(&conn, 1, "vizag-tata-car", "2024-01-01T10:00:00Z");
1214 insert_photo(&conn, 2, "vizag-tata-home", "2024-01-02T10:00:00Z");
1215 set_location(&conn, 1, "Vizianagaram", "India");
1216 set_location(&conn, 2, "Vizianagaram", "India");
1217 insert_person(&conn, 7, "Tata", &[1, 2]);
1218
1219 let results =
1220 SearchService::search_unified_with_semantic(&conn, "car tata vizianagaram", vec![1])
1221 .unwrap();
1222
1223 assert_eq!(results.photo_ids, vec![1]);
1224 assert!(results
1225 .interpreted
1226 .iter()
1227 .any(|f| f.kind == "semantic" && f.label == "car"));
1228 }
1229}