feat(search): explicit filters on unified search; list camera bodies

The app's new search screen keeps tags, type, date, camera and place as
chips and sends free text to the unified endpoint. For the AI to add to
those chips instead of replacing them, the endpoint now takes them:

- /photos/search/unified accepts the /photos filters explicitly (tag_ids
  with All/Any, exclude_tag_ids, media_type, dates, camera make/model,
  gps circle) plus a recursive `path`. Explicit tags are their own
  predicate on top of the model's ANY-mode tags; single-valued filters
  replace the model's guess. `interpreted` echoes only what the model
  added, so the client doesn't show the user's own chips twice.
- interpret=false skips the LLM and uses q verbatim as the CLIP term,
  which replaces the app's separate "Semantic" mode with a visual match
  inside the user's filters.
- /image/tags/all also returns `cameras` (make + model with counts) so
  the camera picker can offer e.g. "Nexus 6" directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-15 21:09:06 -04:00
1 parent ed52a07d9d
commit 015ddd6c77
4 files changed
+511 -69

No files matched your search

+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(