Skip to main content

smriti/services/
search.rs

1//! Search service - executes parsed queries against the database.
2
3use 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/// A single search result row.
11#[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/// Search results grouped by date.
21#[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/// A person matching the search query.
30#[derive(Debug, Clone)]
31pub struct PersonHit {
32    pub cluster_id: i64,
33    pub name: String,
34    pub photo_count: i64,
35    /// Resolved at load time (absolute path to face crop jpg).
36    pub face_thumbnail_path: Option<String>,
37}
38
39/// An album matching the search query.
40#[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    /// Resolved at load time (absolute path to cover thumbnail).
47    pub cover_thumbnail_path: Option<String>,
48}
49
50/// A place (city) matching the search query.
51#[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/// Unified search results across all entity types.
65#[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    /// Flat list for cull mode.
74    pub photo_ids: Vec<i64>,
75}
76
77/// Search service.
78pub 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    /// Run a unified multi-entity search.
320    ///
321    /// The query string is parsed once via `QueryParser`; people/album/place
322    /// matches use simple substring LIKE. Photos use the existing
323    /// QueryParser-based filter.
324    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        // 4. Photos — splits the query into "date part" (if any) and
352        // "free-text part" (the rest), then runs a single SQL that ANDs
353        // the date filter with an OR-match across location, filename,
354        // OCR text, and any face cluster's name. This means "Goa 2023"
355        // matches photos in 2023 with location LIKE %Goa% — even though
356        // "Goa" isn't in any hardcoded location list.
357        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    /// Peel a date range off the end (or whole) of `q` and return
544    /// (remaining_text, date_range). Either component may be None.
545    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        // Whole-string match first — "March 2019" → date only.
551        if let Some(range) = DateParser::parse(trimmed) {
552            return (None, Some(range));
553        }
554        let words: Vec<&str> = trimmed.split_whitespace().collect();
555        // Try any two-word date span first — "Goa March 2019 videos",
556        // "March 2019 Goa", "last year Paris".
557        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        // Try any one-word date span — "2023 Goa", "Goa 2023",
579        // "videos 2023 Goa".
580        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        // representative_face_id → relative `.photovault/faces/<id>.jpg`
915        // path. Mirrors face_repo::populate_face_thumbnails so the
916        // frontend's thumbUrl helper resolves the file uniformly.
917        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        // LEFT JOIN to pull the cover photo's thumbnail_path so the
942        // frontend can render the album hit with its cover image.
943        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}