mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 11:09:23 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8c0b70b37f
commit
e54acbd176
@@ -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<String> = 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<S: AsRef<str>>(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<f64> {
|
||||
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"] {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user