mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge PR #1468: Issue #1449: Search Photos finds named people, and quotes keep a name together (steps 1 and 2)
This commit is contained in:
@@ -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 <kbd>0</kbd>–<kbd>5</kbd>, flag with <kbd>P</kbd> / <kbd>X</kbd> /
|
||||
<kbd>U</kbd>, colour-label with <kbd>6</kbd>–<kbd>9</kbd>. 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.
|
||||
|
||||
|
||||
@@ -141,9 +141,40 @@ pub(crate) struct Prepared {
|
||||
only: Option<std::collections::HashSet<PhotoId>>,
|
||||
}
|
||||
|
||||
/// 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<Item = &str> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Region>, 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]
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`).
|
||||
|
||||
Reference in New Issue
Block a user