Files
lightcraft/crates/catalog/src/query.rs
T
Brandon ThomasandClaude Opus 5.5 71dc30b951 LR-IMP-LOCAL: browse folders on disk without adding them (Local), failed renders aren't retried
- library.browse {path, subfolders}: a folder's photos are read in place as "local" photos
  (catalog flag, SetLocal op) that only folder views list — not All Photos, albums, date
  groups, keyword counts or stats. photo.addToLibrary promotes them; importing the folder for
  real promotes instead of reporting duplicates. The browsed folder persists with the view.
- UI: Local section (Pictures, Desktop, Downloads, Home, the browsed folder, Browse Folder…);
  folder views get a breadcrumb header (each part opens that folder), Include subfolders and
  "Add N to My Photos".
- Renderer: a job that fails (unreadable / missing file) is remembered by slot + key and not
  requested again every frame — the grid re-queued it forever (a render loop per broken
  file). The loupe says "This photo can't be opened" with the reason; grid cells show "!".
  ui.inspect lists pending render slots.
- Headless app.export (MCP, lightcraft-cli run) exports the selection like the desktop app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 08:53:58 -04:00

307 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Filtering, search and sorting.
use serde::{Deserialize, Serialize};
use crate::{AlbumId, Catalog, ColorLabel, Flag, MediaKind, Photo, PhotoId};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RatingOp {
#[default]
AtLeast,
Exactly,
AtMost,
}
/// What the grid shows. Empty/None fields don't filter.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Filter {
/// Free-text search: every token must match filename, title, caption, keywords, camera, lens,
/// location or format. Tokens like `rating:3`, `flag:pick`, `iso:>800`, `camera:x2` are fielded.
pub text: String,
pub rating: u8,
pub rating_op: RatingOp,
pub flag: Option<Flag>,
pub label: Option<ColorLabel>,
pub kind: Option<MediaKind>,
pub edited: Option<bool>,
pub album: Option<AlbumId>,
/// Show "Recently Deleted" instead of the library.
pub deleted: bool,
/// Capture date prefix (`2026`, `2026-04`, `2026-04-12`).
pub date: Option<String>,
/// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`).
pub keyword: Option<String>,
pub camera: Option<String>,
/// Lens (case-insensitive substring).
pub lens: Option<String>,
/// Capture date range, inclusive: `dateFrom` is compared as a lower bound (`2026-04-01`),
/// `dateTo` as a prefix upper bound (`2026-04` includes all of April).
pub date_from: Option<String>,
pub date_to: Option<String>,
/// Import date prefix (Recently Added).
pub imported: Option<String>,
/// A folder on disk: its files only (browsed ones too); `subfolders` includes everything below.
pub folder: Option<String>,
pub subfolders: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SortKey {
#[default]
CaptureDate,
ImportDate,
EditDate,
FileName,
Rating,
FileSize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Sort {
pub key: SortKey,
pub ascending: bool,
/// Date headers in the grid (date sort keys only).
pub group: crate::GroupBy,
}
impl Default for Sort {
fn default() -> Self {
Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto }
}
}
fn token_matches(p: &Photo, tok: &str) -> bool {
let t = tok.to_lowercase();
if let Some((field, val)) = t.split_once(':') {
let num = |s: &str| -> Option<(char, f64)> {
let (op, rest) = match s.chars().next()? {
c @ ('>' | '<' | '=') => (c, &s[1..]),
_ => ('=', s),
};
rest.parse().ok().map(|v| (op, v))
};
let cmp = |x: f64, s: &str| match num(s) {
Some(('>', v)) => x > v,
Some(('<', v)) => x < v,
Some((_, v)) => (x - v).abs() < 1e-9,
None => false,
};
return match field {
"rating" | "stars" => cmp(p.rating as f64, val),
"flag" => Flag::parse(val) == Some(p.flag),
"label" | "color" => p.label.is_some_and(|l| format!("{l:?}").eq_ignore_ascii_case(val)),
"iso" => p.meta.iso.is_some_and(|i| cmp(i as f64, val)),
"f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp(a as f64, val)),
"focal" => p.meta.focal_mm.is_some_and(|a| cmp(a as f64, val)),
"camera" => p.meta.camera.to_lowercase().contains(val),
"lens" => p.meta.lens.to_lowercase().contains(val),
"keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)),
"type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val),
"edited" => (val == "true" || val == "yes") == p.is_edited(),
"date" => p.date().starts_with(val),
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
"name" | "file" => p.file_name.to_lowercase().contains(val),
_ => false,
};
}
let hay = [&p.file_name, &p.meta.title, &p.meta.caption, &p.meta.camera, &p.meta.lens, &p.meta.location, &p.format];
hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t))
}
impl Filter {
/// Human-readable summary of the active rules (smart album tooltips, `album.list`).
pub fn describe(&self) -> String {
let mut v: Vec<String> = Vec::new();
if self.rating > 0 {
let op = match self.rating_op {
RatingOp::AtLeast => "≥",
RatingOp::Exactly => "=",
RatingOp::AtMost => "≤",
};
v.push(format!("rating {op} {}", self.rating));
}
if let Some(f) = self.flag {
v.push(format!("flag {}", format!("{f:?}").to_lowercase()));
}
if let Some(l) = self.label {
v.push(format!("label {}", format!("{l:?}").to_lowercase()));
}
if let Some(k) = self.kind {
v.push(format!("kind {}", format!("{k:?}").to_lowercase()));
}
if let Some(e) = self.edited {
v.push(if e { "edited".into() } else { "unedited".into() });
}
for (name, val) in
[("keyword", &self.keyword), ("camera", &self.camera), ("lens", &self.lens), ("date", &self.date), ("imported", &self.imported)]
{
if let Some(x) = val {
v.push(format!("{name} {x}"));
}
}
match (&self.date_from, &self.date_to) {
(Some(a), Some(b)) => v.push(format!("captured {a} – {b}")),
(Some(a), None) => v.push(format!("captured from {a}")),
(None, Some(b)) => v.push(format!("captured until {b}")),
_ => {}
}
if !self.text.trim().is_empty() {
v.push(format!("“{}”", self.text.trim()));
}
if v.is_empty() { "all photos".into() } else { v.join(", ") }
}
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
if p.deleted != self.deleted {
return false;
}
match &self.folder {
// browsed photos only show in folder views
None if p.local => return false,
None => {}
Some(dir) => {
let crate::Source::File { path } = &p.source else { return false };
if !in_folder(path, dir, self.subfolders) {
return false;
}
}
}
if self.rating > 0 {
let ok = match self.rating_op {
RatingOp::AtLeast => p.rating >= self.rating,
RatingOp::Exactly => p.rating == self.rating,
RatingOp::AtMost => p.rating <= self.rating,
};
if !ok {
return false;
}
}
if self.flag.is_some_and(|f| f != p.flag) || self.label.is_some_and(|l| Some(l) != p.label) || self.kind.is_some_and(|k| k != p.kind) {
return false;
}
if self.edited.is_some_and(|e| e != p.is_edited()) {
return false;
}
if let Some(a) = self.album
&& !cat.album_contains(a, p)
{
return false;
}
if let Some(from) = &self.date_from
&& p.date() < from.as_str()
{
return false;
}
if let Some(to) = &self.date_to
&& p.date().get(..to.len()).unwrap_or(p.date()) > to.as_str()
{
return false;
}
if let Some(l) = &self.lens
&& !p.meta.lens.to_lowercase().contains(&l.to_lowercase())
{
return false;
}
if let Some(d) = &self.date
&& !p.date().starts_with(d.as_str())
{
return false;
}
if let Some(d) = &self.imported
&& !p.imported.starts_with(d.as_str())
{
return false;
}
if let Some(k) = &self.keyword
&& !p.meta.keywords.iter().any(|x| crate::keywords::is_under(x, k))
{
return false;
}
if let Some(c) = &self.camera
&& !p.meta.camera.eq_ignore_ascii_case(c)
{
return false;
}
self.text.split_whitespace().all(|tok| token_matches(p, tok))
}
}
impl Catalog {
/// Photos matching `filter`, in `sort` order (ties broken by id for stability).
pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec<PhotoId> {
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches(p, self)).collect();
v.sort_by(|a, b| {
let o = match sort.key {
SortKey::CaptureDate => a.date().cmp(b.date()),
SortKey::ImportDate => a.imported.cmp(&b.imported),
SortKey::EditDate => a.edited.cmp(&b.edited),
SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()),
SortKey::Rating => a.rating.cmp(&b.rating),
SortKey::FileSize => a.file_size.cmp(&b.file_size),
}
.then(a.id.cmp(&b.id));
if sort.ascending { o } else { o.reverse() }
});
v.into_iter().map(|p| p.id).collect()
}
/// Year → month → day counts for the "By Date" section (newest first).
pub fn date_groups(&self) -> Vec<DateGroup> {
use std::collections::BTreeMap;
let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new();
for p in self.photos().filter(|p| p.in_library()) {
let d = p.date();
if d.len() >= 10 {
let (months, days) = years.entry(&d[..4]).or_default();
*months.entry(&d[..7]).or_default() += 1;
*days.entry(&d[..10]).or_default() += 1;
}
}
let newest_first = |m: BTreeMap<&str, usize>| m.into_iter().rev().map(|(k, n)| (k.to_string(), n)).collect::<Vec<_>>();
years
.into_iter()
.rev()
.map(|(year, (months, days))| DateGroup {
year: year.to_string(),
count: months.values().sum(),
months: newest_first(months),
days: newest_first(days),
})
.collect()
}
/// All keywords with usage counts, sorted by name.
pub fn keywords(&self) -> Vec<(String, usize)> {
let mut m: std::collections::BTreeMap<String, usize> = Default::default();
for p in self.photos().filter(|p| p.in_library()) {
for k in &p.meta.keywords {
*m.entry(k.clone()).or_default() += 1;
}
}
m.into_iter().collect()
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct DateGroup {
pub year: String,
pub count: usize,
/// `YYYY-MM` and counts, newest first.
pub months: Vec<(String, usize)>,
/// `YYYY-MM-DD` and counts, newest first.
pub days: Vec<(String, usize)>,
}
/// Whether file `path` is directly in `dir` (or anywhere below it with `deep`). Both `/` and `\\`
/// separate.
pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
let dir = dir.trim_end_matches(['/', '\\']);
let Some(rest) = path.strip_prefix(dir) else { return false };
let Some(rest) = rest.strip_prefix(['/', '\\']) else { return false };
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
}