Merge pull request 'feat(search): explicit filters on unified search; list camera bodies' (#171) from feat/search-explicit-filters into master

Reviewed-on: #171
This commit was merged in pull request #171.
This commit is contained in:
cameron committed 2026-10-16 01:13:44 +00:00
commit 50dd9aa016
4 files changed
+511 -69

No files matched your search

+10 -1
View File
@@ -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/<hash>/<file> (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)
+81
View File
@@ -388,6 +388,12 @@ pub trait ExifDao: Sync + Send {
/// Get distinct camera makes with counts
fn get_camera_makes(&mut self) -> Result<Vec<(String, i64)>, 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<Vec<(String, String, i64)>, 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<Vec<(String, String, i64)>, 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<String>, Option<String>, 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(
+23
View File
@@ -213,9 +213,22 @@ async fn get_all_tags<D: TagDao>(
.map(|(make, count)| CameraMakeCount { make, count })
.collect::<Vec<CameraMakeCount>>();
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::<Vec<CameraCount>>();
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<TagWithTagCount>,
pub camera_makes: Vec<CameraMakeCount>,
pub cameras: Vec<CameraCount>,
}
#[derive(Insertable, Clone, Debug)]
+397 -68
View File
@@ -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<String>,
/// 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<String>,
/// Comma-separated tag ids the results must not carry.
pub exclude_tag_ids: Option<String>,
/// `All` (default) or `Any` for `tag_ids`.
pub tag_filter_mode: Option<FilterMode>,
pub media_type: Option<MediaType>,
pub date_from: Option<i64>,
pub date_to: Option<i64>,
pub camera_make: Option<String>,
pub camera_model: Option<String>,
pub gps_lat: Option<f64>,
pub gps_lon: Option<f64>,
pub gps_radius_km: Option<f64>,
/// Folder (relative path) to search under, recursively.
pub path: Option<String>,
}
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<i32>,
exclude: Vec<i32>,
mode: FilterMode,
}
/// Parse a comma-separated id list, ignoring blanks.
fn parse_ids(raw: Option<&str>, name: &str) -> Result<Vec<i32>, String> {
raw.unwrap_or("")
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| {
s.parse::<i32>()
.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<ExplicitTags>,
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<String> {
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<TagD: TagDao>(
};
// ── 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<TagD: TagDao>(
}),
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<TagD: TagDao>(
|| 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<TagD: TagDao>(
}
}
};
// 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<String>)> =
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<TagD: TagDao>(
{
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<TagD: TagDao>(
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<TagD: TagDao>(
};
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<TagD: TagDao>(
}
}
/// 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<TagD: TagDao>(
state: &AppState,
tag_dao: &Mutex<TagD>,
query: &UnifiedQuery,
nl: &str,
) -> Result<StructuredQuery, HttpResponse> {
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::<UnifiedQuery>::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())];