diff --git a/README.md b/README.md index 42b164d6c..adeae67f7 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,8 @@ Then tone it with Color Grading for selenium, sepia or split-tone prints. A library that stays out of your way: **All Photos**, **Recently Added**, **Picks**, **By Date**, **Albums** nested in **Folders**, and **Recently Deleted**. Rate with 0–5, flag with P / X / U, colour-label with 6–9. Search understands fields: -`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`. Every view sorts by capture date, +`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains person:"Jane Doe"`; plain words also match +the people named on faces, and quotes keep several words together. Every view sorts by capture date, import date, edit date, name, rating, size or at random (a stable shuffle; View → Sort → Reshuffle for a new one). The justified **Photo Grid** and **Square Grid** views are virtualized, so they stay smooth whether you have forty photos or forty thousand. diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 7625c9ab6..a94421369 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -141,9 +141,40 @@ pub(crate) struct Prepared { only: Option>, } +/// The tokens of a search text: split at whitespace, except inside double quotes, so +/// `person:"Jane Doe"` is one token (issue #1449). An unclosed quote runs to the end of the text; a +/// backslash is an ordinary character. One pass over the text, nothing allocated. +fn search_tokens(text: &str) -> impl Iterator { + let mut rest = text; + std::iter::from_fn(move || { + rest = rest.trim_start(); + if rest.is_empty() { + return None; + } + let mut quoted = false; + let end = rest + .char_indices() + .find(|&(_, c)| { + quoted ^= c == '"'; + !quoted && c.is_whitespace() + }) + .map_or(rest.len(), |(i, _)| i); + let (tok, tail) = rest.split_at(end); + rest = tail; + Some(tok) + }) +} + +/// `s` without the double quotes around it (a missing closing quote is allowed). +fn unquote(s: &str) -> &str { + s.strip_prefix('"').map_or(s, |inner| inner.strip_suffix('"').unwrap_or(inner)) +} + fn token_matches(p: &Photo, tok: &str) -> bool { let t = tok.to_lowercase(); - if let Some((field, val)) = t.split_once(':') { + // a token that starts with a quote is a phrase, even with a colon in it + if let Some((field, quoted)) = t.split_once(':').filter(|_| !t.starts_with('"')) { + let val = unquote(quoted); let num = |s: &str| -> Option<(char, f64)> { let (op, rest) = match s.chars().next()? { c @ ('>' | '<' | '=') => (c, &s[1..]), @@ -180,18 +211,22 @@ fn token_matches(p: &Photo, tok: &str) -> bool { "date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)), "copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(), "name" | "file" => p.file_name.to_lowercase().contains(val), - // Not a known field: the colon is part of a plain word ("10:30", "Re:"). + // Not a known field: the colon is part of a plain word ("10:30", "Re:"), its value unquoted + // (`re:"a b"` is the text `re:a b`). + _ if val.len() != quoted.len() => text_matches(p, &format!("{field}:{val}")), _ => text_matches(p, &t), }; } - text_matches(p, &t) + text_matches(p, unquote(&t)) } /// Whether the lower-cased word `t` is in the file name, title, caption, camera, lens, location, -/// format or a keyword of `p`. +/// format, a keyword or the name of a person (a named face) of `p`. fn text_matches(p: &Photo, t: &str) -> bool { 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)) + hay.iter().any(|h| h.to_lowercase().contains(t)) + || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(t)) + || p.people().iter().any(|n| n.to_lowercase().contains(t)) } /// Whether `p` has a named face region called `name` (case-insensitive). @@ -409,7 +444,7 @@ impl Filter { { return false; } - self.text.split_whitespace().all(|tok| token_matches(p, tok)) + search_tokens(&self.text).all(|tok| token_matches(p, tok)) } } diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index a32b5acf5..f8b72dad8 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -836,6 +836,71 @@ fn cmp_name_ignores_case_without_allocating() { assert_eq!(cmp_name("a", "ab"), Less); } +/// Search Photos finds named people in free text, and a quoted value keeps a name of several words +/// together: `person:"Jane Doe"` (issue #1449). Malformed quotes narrow the result or change nothing. +#[test] +fn search_text_finds_people_and_quoted_values() { + use lightcraft_meta::{Rect, Region, RegionKind}; + let region = |name: &str, kind: RegionKind| Region { + rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, + kind, + name: Some(name.to_string()), + description: None, + }; + let mut c = Catalog::new(); + let mut add = |name: &str, regions: Vec, keywords: &[&str]| { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00"); + p.meta.regions = regions; + p.meta.keywords = keywords.iter().map(|k| k.to_string()).collect(); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + let jane = add("a.jpg", vec![region("Jane Doe", RegionKind::Face), region("Rex", RegionKind::Pet)], &[]); + let both = add("b.jpg", vec![region("Jane Roe", RegionKind::Face), region("John Doe", RegionKind::Face)], &[]); + let zoe = add("c.jpg", vec![region("Zoë Ångström", RegionKind::Face)], &["New York"]); + let none = add("lunch 10:30 am.jpg", vec![], &["york minster"]); + let q = |text: &str| { + let mut ids = c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default()); + ids.sort(); + ids + }; + + // free text: a person's name, any case, any part of it; pets are not people + assert_eq!(q("jane"), vec![jane, both]); + assert_eq!(q("DOE"), vec![jane, both]); + assert_eq!(q("ångström"), vec![zoe]); + assert!(q("rex").is_empty(), "a pet is not a person"); + // words are still ANDed: b.jpg has a Jane and a Doe, a quoted phrase asks for them together + assert_eq!(q("jane doe"), vec![jane, both]); + assert_eq!(q("\"jane doe\""), vec![jane]); + + // fielded: the exact name, of several words when quoted + assert_eq!(q("person:\"Jane Doe\""), vec![jane]); + assert_eq!(q("who:\"jane roe\""), vec![both]); + assert_eq!(q("person:\"Zoë Ångström\""), vec![zoe]); + assert!(q("person:jane").is_empty(), "person: is the whole name, as before"); + assert!(q("person:Jane Doe").is_empty(), "unquoted, the second word is its own token, as before"); + assert_eq!(q("keyword:\"new york\""), vec![zoe], "other fields take quoted values too"); + assert_eq!(q("person:\"Jane Doe\" a.jpg"), vec![jane], "tokens after a quoted one still count"); + + // malformed input never widens the match and never panics + assert_eq!(q("person:\"Jane Doe"), vec![jane], "an unclosed quote runs to the end"); + assert!(q("person:\"Jane Doe a.jpg").is_empty(), "…and takes the rest of the text with it"); + assert!(q("person:\"\"").is_empty()); + assert!(q("person:").is_empty()); + assert_eq!(q("\""), vec![jane, both, zoe, none], "a lone quote is an empty phrase, like an empty search"); + assert_eq!(q("\"\" jane"), vec![jane, both]); + assert!(q("\"jane:doe\"").is_empty(), "a quoted phrase is free text even with a colon"); + // an unknown field is plain text, its quoted value unquoted (#2099): `10:"30 am"` is the text `10:30 am` + assert_eq!(q("10:\"30 am\""), vec![none]); + assert!(q("10:\"30 pm\"").is_empty()); + assert!(q("jane\\\"").is_empty(), "a backslash is an ordinary character"); + let long = format!("person:\"{}", "é ".repeat(50_000)); + assert!(q(&long).is_empty()); + assert_eq!(q(" \t jane \u{3000} "), vec![jane, both], "any whitespace separates tokens"); +} + /// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be /// said photo by photo. #[test] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index c4a94f46e..eaed8cd22 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -148,7 +148,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-FILT-SEARCH-META | Text search | P0 | ✅ | `cmd:library.filter` (`text`), `crates/ui-egui/src/panels/topbar.rs`, `crates/catalog/src/query.rs` | fielded tokens (`rating:3`, `iso:>800`, `camera:…`); no suggestions dropdown | +| LR-FILT-SEARCH-META | Text search | P0 | ✅ | `cmd:library.filter` (`text`), `crates/ui-egui/src/panels/topbar.rs`, `crates/catalog/src/query.rs` | fielded tokens (`rating:3`, `iso:>800`, `camera:…`); plain words also match the people named on faces; double quotes keep several words in one token (`person:"Jane Doe"`, `"jane doe"`, issue #1449); no suggestions dropdown | | LR-FILT-SEARCH-AI | Natural-language search | P2 | ⬜ | | | | LR-FILT-RATING | Rating filter | P0 | ✅ | `cmd:library.filter` (`rating`, `ratingOp`), `crates/ui-egui/src/panels/filterbar.rs` | ≥ / = / ≤ stars in the filter bar (`cmd:view.filterBar`) | | LR-FILT-FLAG | Flag filter | P0 | ✅ | `cmd:library.filter` (`flag`), `crates/ui-egui/src/panels/filterbar.rs` | picked / rejected / unflagged (one at a time) | @@ -157,7 +157,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-FILT-KEYWORD | Keyword filter | P1 | ✅ | `cmd:library.filter` (`keyword`), `crates/ui-egui/src/panels/filterbar.rs` | keyword picker | | LR-FILT-CAMERA | Camera / lens filter | P1 | ✅ | `cmd:library.filter` (`camera`, `lens`), `crates/ui-egui/src/panels/filterbar.rs` | camera and lens pickers | | LR-FILT-LOCATION | Location filter | P2 | ✅ | `cmd:library.filter` (`text`, `ruleSet` field `location`) | free text, or a rule on location / city / state / country (smart albums, `library.filter`) | -| LR-FILT-PEOPLE | People filter | P2 | 🟡 | `cmd:library.filter`, `crates/catalog/src/query.rs` | filter by a person's name (case-insensitive, named Face regions only) from the People view, a filter chip or `person:`; no unnamed / suggested people | +| LR-FILT-PEOPLE | People filter | P2 | 🟡 | `cmd:library.filter`, `crates/catalog/src/query.rs` | filter by a person's name (case-insensitive, named Face regions only) from the People view, a filter chip, `person:` (`person:"Jane Doe"` for a name of several words) or a plain word in Search Photos (part of the name); no People picker in the filter bar and one person at a time (issue #1449); no unnamed / suggested people | | LR-FILT-CULL | Culling-score filters | P2 | ✅ | `cmd:library.filter` (`ruleSet` fields `sharpness`, `bestOfGroup`) | Focus and Best of Similar Shots in the rule editor / smart albums | | LR-FILT-SORT | Sort | P0 | ✅ | `cmd:library.sort`, `cmd:library.shuffle`, `crates/ui-egui/src/panels/bottombar.rs` | Random sort (seeded, stable under edits; choosing Random or Reshuffle picks a new seed); the shuffle is part of the saved view state; no effect in Recently Added (always newest import first); no colour-label or custom (manual) order | | LR-FILT-SAVED | Filter presets [Classic] | P2 | ✅ | `cmd:filter.savePreset`, `cmd:filter.applyPreset`, `cmd:filter.presets`, `cmd:filter.deletePreset`, `crates/ui-egui/src/panels/filterbar.rs` | filter bar → Presets: apply, save current filter, delete (right-click); saved with the library | diff --git a/docs/whats-new.md b/docs/whats-new.md index df0f69118..69f70706b 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -240,6 +240,11 @@ - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. +### Search finds people +- Typing a person's name in Search Photos finds the photos with that named face (issue #1449). +- Quotes keep several words together: `person:"Jane Doe"` finds exactly that person, `"jane doe"` those words side + by side, and `keyword:"new york"` a keyword of two words. + ### Where the camera focused - **View ▸ Show Focus Point** (⌥F) draws the autofocus frame the camera recorded over the photo, in Detail, Compare and Survey, following rotation and crops; the Info panel says how it focused (`AF-C · Wide · Animal Eye Tracking`).