diff --git a/CLAUDE.md b/CLAUDE.md index 9e6f3b1..daf3575 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -525,6 +525,15 @@ GET /photos?path=...&recursive=true&sort=DateTakenDesc&camera_make=Canon&gps_lat // ExifDao::query_by_exif — shared by /photos, /photos/exif and unified search. // A calendar-day range = that day's bounds in the owner's display zone. +// Natural-language search (LLM translates q into filters + a CLIP term) +GET /photos/search/unified?q=...&model=...&interpret=true +// Also accepts the /photos filters explicitly (tag_ids, exclude_tag_ids, +// tag_filter_mode, media_type, date_from/date_to, camera_make/camera_model, +// gps_lat/gps_lon/gps_radius_km) plus path (folder, recursive). They're +// AND-ed with what the model extracted; single-valued ones replace the +// model's guess, and `interpreted` only echoes what the model added. +// interpret=false skips the LLM: q is the CLIP term within the filters. + // Video streaming (HLS, the default playback path) POST /video/generate (queues .m3u8 playlist + .ts segments, returns playlist_url) GET /video/hls// (serves playlist / segments) @@ -534,7 +543,7 @@ POST /video/original (body: {path, library}) -> {url, expires_at} GET /video/original/stream?token=... (Range-capable original bytes) // Tags -GET /image/tags/all +GET /image/tags/all (tags + counts, camera_makes, and cameras: make+model bodies) POST /image/tags (add tag to file) DELETE /image/tags (remove tag from file) POST /image/tags/batch (bulk tag updates) diff --git a/src/database/mod.rs b/src/database/mod.rs index 06948f5..4a26450 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -388,6 +388,12 @@ pub trait ExifDao: Sync + Send { /// Get distinct camera makes with counts fn get_camera_makes(&mut self) -> Result, DbError>; + /// Distinct (make, model) pairs with counts, most-used first. Rows + /// without a model are skipped — `get_camera_makes` covers those. + fn get_cameras(&mut self) -> Result, DbError> { + Ok(Vec::new()) + } + /// Update file path in EXIF database fn update_file_path(&mut self, old_path: &str, new_path: &str) -> Result<(), DbError>; @@ -1147,6 +1153,31 @@ impl ExifDao for SqliteExifDao { .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } + fn get_cameras(&mut self) -> Result, DbError> { + trace_db_call("query", "get_cameras", |_span| { + use diesel::dsl::count; + use schema::image_exif::dsl::*; + + let mut connection = self.connection.lock().expect("Unable to get ExifDao"); + + image_exif + .filter(camera_make.is_not_null()) + .filter(camera_model.is_not_null()) + .group_by((camera_make, camera_model)) + .select((camera_make, camera_model, count(id))) + .order(count(id).desc()) + .load::<(Option, Option, i64)>(connection.deref_mut()) + .map(|records| { + records + .into_iter() + .filter_map(|(make, model, cnt)| Some((make?, model?, cnt))) + .collect() + }) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + fn update_file_path(&mut self, old_path: &str, new_path: &str) -> Result<(), DbError> { trace_db_call("update", "update_file_path", |_span| { use schema::image_exif::dsl::*; @@ -2584,6 +2615,56 @@ mod exif_dao_tests { assert_eq!(lib2[0].library_id, 2); } + #[test] + fn get_cameras_groups_make_and_model_most_used_first() { + let mut dao = setup_two_libraries(); + let mut camera = |rel: &str, make: Option<&str>, model: Option<&str>| { + dao.store_exif(InsertImageExif { + library_id: 1, + file_path: rel.to_string(), + camera_make: make.map(str::to_string), + camera_model: model.map(str::to_string), + lens_model: None, + width: None, + height: None, + orientation: None, + gps_latitude: None, + gps_longitude: None, + gps_altitude: None, + focal_length: None, + aperture: None, + shutter_speed: None, + iso: None, + date_taken: None, + created_time: 0, + last_modified: 0, + content_hash: None, + size_bytes: None, + phash_64: None, + dhash_64: None, + date_taken_source: None, + date_utc: None, + camera_offset_minutes: None, + date_taken_tz_source: None, + }) + .expect("insert exif row"); + }; + camera("a.jpg", Some("motorola"), Some("Nexus 6")); + camera("b.jpg", Some("Google"), Some("Pixel 7")); + camera("c.jpg", Some("Google"), Some("Pixel 7")); + // No model: covered by get_camera_makes, skipped here. + camera("d.jpg", Some("Canon"), None); + camera("e.jpg", None, None); + + assert_eq!( + dao.get_cameras().unwrap(), + vec![ + ("Google".to_string(), "Pixel 7".to_string(), 2), + ("motorola".to_string(), "Nexus 6".to_string(), 1), + ] + ); + } + /// Row with the full date triple, for the true-instant filter tests. #[allow(clippy::too_many_arguments)] fn insert_dated_row( diff --git a/src/tags.rs b/src/tags.rs index 6396cd9..b284d8d 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -213,9 +213,22 @@ async fn get_all_tags( .map(|(make, count)| CameraMakeCount { make, count }) .collect::>(); + let cameras = exif_dao + .lock() + .expect("Unable to get ExifDao") + .get_cameras() + .unwrap_or_else(|e| { + log::warn!("Failed to get cameras: {:?}", e); + Vec::new() + }) + .into_iter() + .map(|(make, model, count)| CameraCount { make, model, count }) + .collect::>(); + HttpResponse::Ok().json(AllTagsResponse { tags: tags_response, camera_makes, + cameras, }) }) .into_http_internal_err() @@ -465,10 +478,20 @@ pub struct CameraMakeCount { pub count: i64, } +/// One camera body (make + model) and how many files it took. Lets the +/// search screen offer "Nexus 6" directly instead of make-then-model. +#[derive(Serialize, Debug)] +pub struct CameraCount { + pub make: String, + pub model: String, + pub count: i64, +} + #[derive(Serialize, Debug)] pub struct AllTagsResponse { pub tags: Vec, pub camera_makes: Vec, + pub cameras: Vec, } #[derive(Insertable, Clone, Debug)] diff --git a/src/unified_search.rs b/src/unified_search.rs index 3b881fb..fa67800 100644 --- a/src/unified_search.rs +++ b/src/unified_search.rs @@ -25,7 +25,7 @@ use crate::ai::nl_query::{StructuredQuery, translate_nl_query}; use crate::clip_search::{ SearchHit, parse_library_scope, resolve_hits, score_error_response, score_photos, }; -use crate::data::Claims; +use crate::data::{Claims, FilterMode, MediaType}; use crate::database::ExifDao; use crate::file_types::{is_image_file, is_video_file}; use crate::geo::{forward_geocode, gps_bounding_box, haversine_distance}; @@ -58,6 +58,32 @@ pub struct UnifiedQuery { /// (avoids a llama-swap eviction / cold start). Falls back to the configured /// default local model when absent. Local only — no hybrid here. pub model: Option, + /// When false, skip the LLM translation and use `q` verbatim as the CLIP + /// term ("visual match only"). Explicit filters below still apply. + #[serde(default = "default_interpret")] + pub interpret: bool, + + // ── Explicit filters ── + // Set by the client's filter chips. They're AND-ed with whatever the + // model extracted from `q`; for single-valued filters (camera, date, + // media type, place) the explicit value replaces the model's guess. + // Same encodings as `/photos`. + /// Comma-separated tag ids the results must carry (see `tag_filter_mode`). + pub tag_ids: Option, + /// Comma-separated tag ids the results must not carry. + pub exclude_tag_ids: Option, + /// `All` (default) or `Any` for `tag_ids`. + pub tag_filter_mode: Option, + pub media_type: Option, + pub date_from: Option, + pub date_to: Option, + pub camera_make: Option, + pub camera_model: Option, + pub gps_lat: Option, + pub gps_lon: Option, + pub gps_radius_km: Option, + /// Folder (relative path) to search under, recursively. + pub path: Option, } fn default_limit() -> usize { @@ -66,6 +92,129 @@ fn default_limit() -> usize { fn default_threshold() -> f32 { 0.20 } +fn default_interpret() -> bool { + true +} + +/// The client's explicit tag filter, applied as its own predicate on top of +/// the model's (ANY-mode) tags. +#[derive(Debug, Clone, PartialEq)] +struct ExplicitTags { + include: Vec, + exclude: Vec, + mode: FilterMode, +} + +/// Parse a comma-separated id list, ignoring blanks. +fn parse_ids(raw: Option<&str>, name: &str) -> Result, String> { + raw.unwrap_or("") + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| { + s.parse::() + .map_err(|_| format!("invalid id in `{name}`: {s}")) + }) + .collect() +} + +/// Merge the explicit filters in `query` into the model's interpretation. +/// +/// Returns `(effective, echo, tags, gps)`: +/// - `effective` is what the search runs on: explicit single-valued filters +/// replace the model's, and the model's tags lose any the client already +/// included or excluded. +/// - `echo` is the model's interpretation minus everything the explicit +/// filters overrode, so the client's "interpreted" chips only show what +/// the model added. +/// - `tags` is the explicit tag predicate, if any. +/// - `gps` is the explicit place circle, if any (it replaces `place`). +#[allow(clippy::type_complexity)] +fn merge_explicit( + sq: StructuredQuery, + query: &UnifiedQuery, +) -> Result< + ( + StructuredQuery, + StructuredQuery, + Option, + Option<(f64, f64, f64)>, + ), + String, +> { + let include = parse_ids(query.tag_ids.as_deref(), "tag_ids")?; + let exclude = parse_ids(query.exclude_tag_ids.as_deref(), "exclude_tag_ids")?; + let tags = (!include.is_empty() || !exclude.is_empty()).then(|| ExplicitTags { + include: include.clone(), + exclude: exclude.clone(), + mode: query.tag_filter_mode.unwrap_or(FilterMode::All), + }); + + let gps = match (query.gps_lat, query.gps_lon, query.gps_radius_km) { + (Some(lat), Some(lon), Some(r)) => Some((lat, lon, r)), + (None, None, None) => None, + _ => return Err("GPS search requires gps_lat, gps_lon and gps_radius_km".into()), + }; + + let media = match query.media_type { + Some(MediaType::Photo) => Some("photo".to_string()), + Some(MediaType::Video) => Some("video".to_string()), + Some(MediaType::All) | None => None, + }; + let explicit_date = query.date_from.is_some() || query.date_to.is_some(); + let explicit_make = query + .camera_make + .as_deref() + .filter(|s| !s.trim().is_empty()); + let explicit_model = query + .camera_model + .as_deref() + .filter(|s| !s.trim().is_empty()); + + let mut echo = sq; + echo.tag_ids + .retain(|id| !include.contains(id) && !exclude.contains(id)); + echo.exclude_tag_ids + .retain(|id| !include.contains(id) && !exclude.contains(id)); + let mut effective = echo.clone(); + + if let Some(m) = media { + echo.media_type = None; + effective.media_type = Some(m); + } + if explicit_date { + // A date range is one filter: an explicit "after X" replaces the + // model's whole range, not just its lower bound. + echo.date_from = None; + echo.date_to = None; + effective.date_from = query.date_from; + effective.date_to = query.date_to; + } + if let Some(make) = explicit_make { + echo.camera_make = None; + effective.camera_make = Some(make.to_string()); + } + if let Some(model) = explicit_model { + echo.camera_model = None; + effective.camera_model = Some(model.to_string()); + } + if gps.is_some() { + echo.place = None; + effective.place = None; + } + Ok((effective, echo, tags, gps)) +} + +/// Normalized folder prefix for the `path` filter (`None` = whole library). +fn folder_prefix(path: Option<&str>) -> Option { + let p = path?.replace('\\', "/"); + let p = p.trim_matches('/'); + (!p.is_empty()).then(|| format!("{p}/")) +} + +fn in_folder(file_path: &str, prefix: &str) -> bool { + file_path.replace('\\', "/").starts_with(prefix) +} /// A geocoded place echoed back so the client can show / edit the location /// filter it actually searched. @@ -158,73 +307,27 @@ pub async fn unified_search( }; // ── 1. Translate the NL query, grounded on the real tag vocabulary ── - let tag_vocab: Vec<(i32, String)> = { - let mut dao = tag_dao.lock().expect("tag dao"); - match dao.get_all_tags(None) { - Ok(tags) => tags.into_iter().map(|(_, t)| (t.id, t.name)).collect(), - Err(e) => { - log::warn!("unified_search: get_all_tags failed: {e:?}"); - Vec::new() - } + // (or take it verbatim as the CLIP term when interpretation is off). + let sq = if query.interpret { + match translate(&state, &tag_dao, &query, &nl).await { + Ok(sq) => sq, + Err(resp) => return resp, + } + } else { + StructuredQuery { + semantic: Some(nl.clone()), + ..Default::default() } }; - - // Respect env/config for the LLM backend (LLM_BACKEND → ollama or - // llama-swap); local only, no hybrid, per the feature's design. - // - // Translation-model precedence: - // 1. UNIFIED_SEARCH_MODEL env — pin a small, fast model that can stay - // co-resident with CLIP (and the chat model) so translation never - // evicts them. This is the recommended setup on a tight VRAM budget. - // 2. the client-selected model — routes translation to whatever the user - // already has loaded (no swap) when no dedicated model is pinned. - // 3. None → resolve_backend uses the configured default local model. - let translation_model = std::env::var("UNIFIED_SEARCH_MODEL") - .ok() - .filter(|m| !m.trim().is_empty()) - .or_else(|| query.model.clone()) - .filter(|m| !m.trim().is_empty()); - let overrides = SamplingOverrides { - model: translation_model, - num_ctx: None, - temperature: None, - top_p: None, - top_k: None, - min_p: None, - enable_thinking: None, - }; - let backend = match state - .insight_generator - .resolve_backend(BackendKind::Local, &overrides) - .await - { - Ok(b) => b, - Err(e) => { - log::warn!("unified_search: resolve_backend failed: {e:?}"); - return HttpResponse::ServiceUnavailable().json(ErrorBody { - error: "LLM backend unavailable".into(), - }); - } - }; - log::info!("unified_search: translating with model={}", backend.model()); - - // The owner's day and zone, not UTC's: relative dates ("last week") - // anchor on the owner's today, and ISO dates become display-zone day - // bounds that the true-instant filters compare against (#152). - let display_zone = crate::date_view::owner_display_zone(&state.user_ai_prefs_dao); - let server_zone = crate::camera_tz::server_zone(); - let today = chrono::Utc::now().with_timezone(&display_zone).date_naive(); - let sq = match translate_nl_query(backend.chat(), &nl, &tag_vocab, today, display_zone).await { - Ok(sq) => sq, - Err(e) => { - log::warn!("unified_search: translate_nl_query failed: {e:?}"); - return HttpResponse::BadGateway().json(ErrorBody { - error: "could not interpret the query".into(), - }); - } + let (sq, echo, explicit_tags, explicit_gps) = match merge_explicit(sq, &query) { + Ok(merged) => merged, + Err(msg) => return bad_request(msg), }; + let folder = folder_prefix(query.path.as_deref()); // ── 2. Forward-geocode the place name into a gps circle ── + // (an explicit place from the client wins; merge_explicit dropped the + // model's place in that case, so no geocode call is spent on it). let resolved_place = match sq.place.as_deref() { Some(p) => forward_geocode(p).await.map(|g| ResolvedPlace { display_name: g.display_name, @@ -234,7 +337,7 @@ pub async fn unified_search( }), None => None, }; - let gps = resolved_place.as_ref().map(|p| (p.lat, p.lon, p.radius_km)); + let gps = explicit_gps.or(resolved_place.as_ref().map(|p| (p.lat, p.lon, p.radius_km))); let semantic = effective_semantic(&sq); @@ -243,8 +346,14 @@ pub async fn unified_search( || sq.lens_model.is_some() || sq.date_from.is_some() || sq.date_to.is_some(); - let has_struct = - has_exif_filter || gps.is_some() || !sq.tag_ids.is_empty() || sq.media_type.is_some(); + let has_struct = has_exif_filter + || gps.is_some() + || !sq.tag_ids.is_empty() + || sq.media_type.is_some() + || explicit_tags.is_some() + || folder.is_some(); + let display_zone = crate::date_view::owner_display_zone(&state.user_ai_prefs_dao); + let server_zone = crate::camera_tz::server_zone(); // Stage trace: what the model extracted + whether a structured filter is // active. The chips show this to the user too, but logging it makes the @@ -284,6 +393,36 @@ pub async fn unified_search( } } }; + // The client's explicit tags: their own predicate with the user's + // ALL/ANY choice. Exclude-only becomes a "must not carry" set. + let explicit_tag_filter: Option<(bool, HashSet)> = + explicit_tags.as_ref().map(|t| { + let mut dao = tag_dao.lock().expect("tag dao"); + let (must_contain, files) = if t.include.is_empty() { + ( + false, + dao.get_files_with_any_tag_ids(t.exclude.clone(), Vec::new()), + ) + } else if t.mode == FilterMode::Any { + ( + true, + dao.get_files_with_any_tag_ids(t.include.clone(), t.exclude.clone()), + ) + } else { + ( + true, + dao.get_files_with_all_tag_ids(t.include.clone(), t.exclude.clone()), + ) + }; + let files = files.unwrap_or_else(|e| { + log::warn!("unified_search: explicit tag filter failed: {e:?}"); + Vec::new() + }); + ( + must_contain, + files.into_iter().map(|f| f.file_name).collect(), + ) + }); log::info!( "unified_search: tag_ids={:?} -> tag_set_files={:?}", sq.tag_ids, @@ -351,6 +490,17 @@ pub async fn unified_search( { return false; } + if let Some((must_contain, ts)) = &explicit_tag_filter + && ts.contains(&row.file_path) != *must_contain + { + return false; + } + // Folder scope. + if let Some(prefix) = &folder + && !in_folder(&row.file_path, prefix) + { + return false; + } true }) .collect(); @@ -410,7 +560,7 @@ pub async fn unified_search( let results = resolve_hits(&exif_dao, &page); HttpResponse::Ok().json(UnifiedResponse { query: nl, - interpreted: interpreted(&sq, resolved_place), + interpreted: interpreted(&echo, resolved_place), model_version: Some(scored.model_version), considered: scored.considered, total_matching, @@ -443,7 +593,7 @@ pub async fn unified_search( }; HttpResponse::Ok().json(UnifiedResponse { query: nl, - interpreted: interpreted(&sq, resolved_place), + interpreted: interpreted(&echo, resolved_place), model_version: None, considered: 0, total_matching, @@ -454,6 +604,79 @@ pub async fn unified_search( } } +/// Translate `nl` into a structured query with the local LLM, grounded on the +/// real tag vocabulary. Errors are ready-to-send responses. +async fn translate( + state: &AppState, + tag_dao: &Mutex, + query: &UnifiedQuery, + nl: &str, +) -> Result { + let tag_vocab: Vec<(i32, String)> = { + let mut dao = tag_dao.lock().expect("tag dao"); + match dao.get_all_tags(None) { + Ok(tags) => tags.into_iter().map(|(_, t)| (t.id, t.name)).collect(), + Err(e) => { + log::warn!("unified_search: get_all_tags failed: {e:?}"); + Vec::new() + } + } + }; + + // Respect env/config for the LLM backend (LLM_BACKEND → ollama or + // llama-swap); local only, no hybrid, per the feature's design. + // + // Translation-model precedence: + // 1. UNIFIED_SEARCH_MODEL env — pin a small, fast model that can stay + // co-resident with CLIP (and the chat model) so translation never + // evicts them. This is the recommended setup on a tight VRAM budget. + // 2. the client-selected model — routes translation to whatever the user + // already has loaded (no swap) when no dedicated model is pinned. + // 3. None → resolve_backend uses the configured default local model. + let translation_model = std::env::var("UNIFIED_SEARCH_MODEL") + .ok() + .filter(|m| !m.trim().is_empty()) + .or_else(|| query.model.clone()) + .filter(|m| !m.trim().is_empty()); + let overrides = SamplingOverrides { + model: translation_model, + num_ctx: None, + temperature: None, + top_p: None, + top_k: None, + min_p: None, + enable_thinking: None, + }; + let backend = match state + .insight_generator + .resolve_backend(BackendKind::Local, &overrides) + .await + { + Ok(b) => b, + Err(e) => { + log::warn!("unified_search: resolve_backend failed: {e:?}"); + return Err(HttpResponse::ServiceUnavailable().json(ErrorBody { + error: "LLM backend unavailable".into(), + })); + } + }; + log::info!("unified_search: translating with model={}", backend.model()); + + // The owner's day and zone, not UTC's: relative dates ("last week") + // anchor on the owner's today, and ISO dates become display-zone day + // bounds that the true-instant filters compare against (#152). + let display_zone = crate::date_view::owner_display_zone(&state.user_ai_prefs_dao); + let today = chrono::Utc::now().with_timezone(&display_zone).date_naive(); + translate_nl_query(backend.chat(), nl, &tag_vocab, today, display_zone) + .await + .map_err(|e| { + log::warn!("unified_search: translate_nl_query failed: {e:?}"); + HttpResponse::BadGateway().json(ErrorBody { + error: "could not interpret the query".into(), + }) + }) +} + /// Slice a sorted hit list at `[offset, offset+limit)`, tolerating /// out-of-range offsets (empty page). fn paginate(hits: &[(f32, String)], offset: usize, limit: usize) -> Vec<(f32, String)> { @@ -513,6 +736,112 @@ mod tests { assert_eq!(effective_semantic(&sq).as_deref(), Some("disco")); } + fn parse(qs: &str) -> UnifiedQuery { + Query::::from_query(qs) + .expect("valid query string") + .into_inner() + } + + fn model_guess() -> StructuredQuery { + StructuredQuery { + semantic: Some("dog".into()), + tag_ids: vec![1, 2], + exclude_tag_ids: vec![3], + camera_make: Some("Canon".into()), + date_from: Some(100), + date_to: Some(200), + media_type: Some("photo".into()), + place: Some("Paris".into()), + ..Default::default() + } + } + + #[test] + fn query_defaults_to_interpreting_with_no_explicit_filters() { + let q = parse("q=dog"); + assert!(q.interpret); + let (effective, echo, tags, gps) = merge_explicit(model_guess(), &q).unwrap(); + assert_eq!(effective, model_guess()); + assert_eq!(echo, model_guess()); + assert_eq!(tags, None); + assert_eq!(gps, None); + } + + #[test] + fn explicit_filters_replace_the_models_and_leave_the_echo() { + let q = parse( + "q=dog&interpret=false&media_type=video&date_from=500&camera_model=Nexus%206\ + &gps_lat=1.5&gps_lon=2.5&gps_radius_km=10", + ); + assert!(!q.interpret); + let (effective, echo, _, gps) = merge_explicit(model_guess(), &q).unwrap(); + + assert_eq!(effective.media_type.as_deref(), Some("video")); + // A date range is one filter: the model's upper bound goes too. + assert_eq!((effective.date_from, effective.date_to), (Some(500), None)); + assert_eq!(effective.camera_model.as_deref(), Some("Nexus 6")); + // Not overridden: the model's make still applies. + assert_eq!(effective.camera_make.as_deref(), Some("Canon")); + assert_eq!(effective.place, None); + assert_eq!(gps, Some((1.5, 2.5, 10.0))); + + // The echo keeps only what the model added on top. + assert_eq!(echo.media_type, None); + assert_eq!((echo.date_from, echo.date_to), (None, None)); + assert_eq!(echo.camera_model, None); + assert_eq!(echo.camera_make.as_deref(), Some("Canon")); + assert_eq!(echo.place, None); + } + + #[test] + fn explicit_tags_become_their_own_predicate() { + let q = parse("q=dog&tag_ids=2,7&exclude_tag_ids=1&tag_filter_mode=Any"); + let (effective, echo, tags, _) = merge_explicit(model_guess(), &q).unwrap(); + assert_eq!( + tags, + Some(ExplicitTags { + include: vec![2, 7], + exclude: vec![1], + mode: FilterMode::Any, + }) + ); + // The model's guesses lose tags the user already decided on. + assert!(effective.tag_ids.is_empty()); + assert!(echo.tag_ids.is_empty()); + assert_eq!(echo.exclude_tag_ids, vec![3]); + } + + #[test] + fn explicit_tags_default_to_all_mode() { + let q = parse("q=dog&tag_ids=5"); + let (_, _, tags, _) = merge_explicit(StructuredQuery::default(), &q).unwrap(); + assert_eq!(tags.unwrap().mode, FilterMode::All); + } + + #[test] + fn media_type_all_is_no_filter() { + let q = parse("q=dog&media_type=all"); + let (effective, _, _, _) = merge_explicit(model_guess(), &q).unwrap(); + assert_eq!(effective.media_type.as_deref(), Some("photo")); + } + + #[test] + fn partial_gps_and_bad_ids_are_rejected() { + assert!(merge_explicit(StructuredQuery::default(), &parse("q=a&gps_lat=1")).is_err()); + assert!(merge_explicit(StructuredQuery::default(), &parse("q=a&tag_ids=1,x")).is_err()); + } + + #[test] + fn folder_prefix_matches_whole_segments() { + assert_eq!(folder_prefix(None), None); + assert_eq!(folder_prefix(Some("/")), None); + let p = folder_prefix(Some("2019\\Trip/")).unwrap(); + assert_eq!(p, "2019/Trip/"); + assert!(in_folder("2019/Trip/a.jpg", &p)); + assert!(in_folder("2019\\Trip\\sub\\b.jpg", &p)); + assert!(!in_folder("2019/Trips/c.jpg", &p)); + } + #[test] fn paginate_handles_out_of_range_offset() { let hits = vec![(0.9, "a".to_string()), (0.8, "b".to_string())];