From e54acbd1768f90e6f31b53e54035bfb8a311cfde Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:00:09 -0400 Subject: [PATCH] LRC-LIB-SMARTCOLL: Keyword Count, People and People Count rules Smart albums can test how many keywords a photo has ("2 or more", "2 or fewer", "exactly 2" via the number operators), who is in it (People: the names on face regions, with the keyword operators) and how many people. Photo::keyword_count counts a hierarchical keyword once and ignores case and blank entries; Photo::people is the domain rule for "people in a photo" (named face regions, trimmed, case-insensitive, pets and unnamed faces excluded), now shared by the existing person filter. Keyword and people rules share one name-list matcher. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/model.rs | 20 ++++++ crates/catalog/src/query.rs | 2 +- crates/catalog/src/rules.rs | 83 ++++++++++++++++++++--- crates/ui-egui/src/panels/rules_editor.rs | 3 +- 4 files changed, 97 insertions(+), 11 deletions(-) diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 957f5b68c..d5a649220 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -365,6 +365,26 @@ impl Photo { } d } + /// How many keywords the photo has: a hierarchical keyword (`travel|italy|rome`) is one, and + /// the same keyword in different case or with stray spaces counts once; blank ones don't count. + pub fn keyword_count(&self) -> usize { + let mut seen: Vec = self.meta.keywords.iter().map(|k| k.trim().to_lowercase()).filter(|k| !k.is_empty()).collect(); + seen.sort_unstable(); + seen.dedup(); + seen.len() + } + /// The people in the photo: the names on its face regions, trimmed, each once (names that + /// differ only in case are one person, as first seen). Pets and unnamed faces are not people. + pub fn people(&self) -> Vec<&str> { + let mut out: Vec<&str> = Vec::new(); + for r in self.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) { + let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; + if !out.iter().any(|o| o.to_lowercase() == name.to_lowercase()) { + out.push(name); + } + } + out + } /// In the library: not deleted and not only browsed (Local). pub fn in_library(&self) -> bool { !self.deleted && !self.local diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 47223d5d8..3e9cded58 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -154,7 +154,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool { /// Whether `p` has a named face region called `name` (case-insensitive). fn has_person(p: &Photo, name: &str) -> bool { let name = name.trim().to_lowercase(); - p.meta.regions.iter().any(|r| r.kind == lightcraft_meta::RegionKind::Face && r.name.as_deref().is_some_and(|n| n.to_lowercase() == name)) + p.people().iter().any(|n| n.to_lowercase() == name) } impl Filter { diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index fe7a8d607..cacc9c2df 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -43,7 +43,8 @@ pub enum Rule { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Kind { Text, - /// Keywords: text ops over each keyword (any keyword matches; `isEmpty` = none). + /// A list of names (keywords, people): text ops over each name (any name matches; `isEmpty` = + /// none). Keywords, Number, Date, @@ -73,6 +74,9 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[ ("editDate", "Edit Date", Kind::Date), // Keywords & People ("keywords", "Keywords", Kind::Keywords), + ("keywordCount", "Keyword Count", Kind::Number), + ("person", "People", Kind::Keywords), + ("personCount", "People Count", Kind::Number), // Description ("title", "Title", Kind::Text), ("caption", "Caption", Kind::Text), @@ -106,7 +110,7 @@ pub const FIELD_GROUPS: &[(&str, &[&str])] = &[ ("Source", &["album", "virtualCopy"]), ("File", &["fileName", "filePath", "kind", "format"]), ("Date", &["captureDate", "importDate", "editDate"]), - ("Keywords & People", &["keywords"]), + ("Keywords & People", &["keywords", "keywordCount", "person", "personCount"]), ("Description", &["title", "caption", "creator", "copyright", "copyrightStatus"]), ("Camera Info", &["camera", "lens", "focalLength", "aperture", "iso"]), ("Location", &["location", "hasGps"]), @@ -215,6 +219,18 @@ fn text_op(op: &str, have: &str, want: &str) -> bool { } } +/// A [`Kind::Keywords`] op over a list of names: any name matches (none for `notContains`); `is` +/// also matches one level of a hierarchical keyword (`italy` in `travel|italy|rome`). +fn names_op>(op: &str, names: &[S], want: &str) -> bool { + let names = names.iter().map(AsRef::as_ref); + match op { + "isEmpty" => names.clone().all(|n| n.trim().is_empty()), + "isNotEmpty" => names.clone().any(|n| !n.trim().is_empty()), + "notContains" => !names.clone().any(|n| text_op("contains", n, want)), + _ => names.clone().any(|n| text_op(op, n, want) || (op == "is" && n.to_lowercase().split('|').any(|part| part.trim() == want))), + } +} + fn number(v: &Value) -> Option { match v { Value::Number(n) => n.as_f64(), @@ -321,12 +337,10 @@ impl Rule { "edited" => p.is_edited() == value.as_bool().unwrap_or(true), "hasGps" => m.gps.is_some() == value.as_bool().unwrap_or(true), "virtualCopy" => p.copy_of.is_some() == value.as_bool().unwrap_or(true), - "keywords" => match op { - "isEmpty" => m.keywords.is_empty(), - "isNotEmpty" => !m.keywords.is_empty(), - "notContains" => !m.keywords.iter().any(|k| text_op("contains", k, &want)), - _ => m.keywords.iter().any(|k| text_op(op, k, &want) || (op == "is" && k.to_lowercase().split('|').any(|part| part == want))), - }, + "keywords" => names_op(op, &m.keywords, &want), + "keywordCount" => num_op(op, Some(p.keyword_count() as f64), value), + "person" => names_op(op, &p.people(), &want), + "personCount" => num_op(op, Some(p.people().len() as f64), value), "text" => { let all = [ p.file_name.as_str(), @@ -554,6 +568,57 @@ mod tests { } } + /// Keyword Count: a photo with 2 keywords is in "≥ 2", "≤ 2" and "is 2"; a hierarchical path + /// (travel|italy|rome) is one keyword, and the same keyword twice in different case is one. + #[test] + fn keyword_count_rules() { + let cat = Catalog::new(); + let mut p = photo(1); // travel|italy|rome, food + let m = |p: &Photo, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": "keywordCount", "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "gte", json!(2)) && m(&p, "lte", json!(2)) && m(&p, "is", json!(2))); + assert!(!m(&p, "gte", json!(3)) && !m(&p, "lt", json!(2)) && m(&p, "between", json!([1, 2]))); + p.meta.keywords.push("Food".into()); + assert!(m(&p, "is", json!(2)), "Food and food are one keyword"); + p.meta.keywords.clear(); + assert!(m(&p, "is", json!(0)) && m(&p, "lte", json!(2))); + assert_eq!(field_group("keywordCount"), Some("Keywords & People")); + } + + /// People: the names on face regions. "People contains ana" finds Ana Lima; pets and unnamed + /// faces are not people; People Count counts each person once. + #[test] + fn people_rules() { + use lightcraft_meta::{Rect, Region, RegionKind}; + let cat = Catalog::new(); + let region = |name: Option<&str>, kind: RegionKind| Region { + rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, + kind, + name: name.map(str::to_string), + description: None, + }; + let mut p = photo(1); + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "person", "isEmpty", json!(null)) && m(&p, "personCount", "is", json!(0))); + p.meta.regions = vec![ + region(Some("Ana Lima"), RegionKind::Face), + region(Some(" ana lima "), RegionKind::Face), + region(Some("Rex"), RegionKind::Pet), + region(None, RegionKind::Face), + region(Some("Bo"), RegionKind::Face), + ]; + assert!(m(&p, "person", "contains", json!("ana"))); + assert!(m(&p, "person", "is", json!("ANA LIMA"))); + assert!(!m(&p, "person", "is", json!("rex")), "a pet isn't a person"); + assert!(m(&p, "person", "notContains", json!("rex"))); + assert!(m(&p, "person", "isNotEmpty", json!(null))); + assert!(m(&p, "personCount", "is", json!(2)), "Ana Lima once, Bo; not the pet or the unnamed face"); + assert_eq!(field_group("person"), Some("Keywords & People")); + } + /// The field menu shows every rule field once: at the top level or in exactly one group, /// and [`FIELDS`] lists them in menu order. #[test] @@ -581,7 +646,7 @@ mod tests { same(&["fileName", "filePath", "kind", "format"], "File"); same(&["camera", "lens", "focalLength", "aperture", "iso"], "Camera Info"); same(&["captureDate", "importDate", "editDate"], "Date"); - same(&["keywords"], "Keywords & People"); + same(&["keywords", "keywordCount", "person", "personCount"], "Keywords & People"); same(&["title", "caption", "creator", "copyright", "copyrightStatus"], "Description"); same(&["location", "hasGps"], "Location"); for f in ["rating", "flag", "label", "text"] { diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index 98918414c..c3344983d 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -60,9 +60,10 @@ fn number_value(ui: &mut egui::Ui, v: &mut Value, field: &str) { "rating" => (0.0, 5.0, 0.05), "iso" => (0.0, 409_600.0, 10.0), "aperture" => (0.0, 64.0, 0.05), + "keywordCount" | "personCount" => (0.0, 1000.0, 0.05), _ => (0.0, 100_000.0, 0.5), }; - let whole = matches!(field, "rating" | "iso" | "album"); + let whole = matches!(field, "rating" | "iso" | "album" | "keywordCount" | "personCount"); let dv = egui::DragValue::new(&mut n).range(lo..=hi).speed(speed); let dv = if whole { dv.fixed_decimals(0) } else { dv.max_decimals(2) }; if ui.add(dv).changed() {