//! Smart-album rules: a list of conditions matched all / any / none, with nested groups. //! //! Each [`Rule`] is `{field, op, value}`; [`FIELDS`] lists the fields with their kind, which //! decides the operators ([`ops_for`]). Text compares case-insensitively; dates are ISO strings //! compared by prefix; "in the last N days/weeks/months/years" is relative to [`now`]. use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{Catalog, Photo, Source}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Match { #[default] All, Any, None, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct RuleSet { #[serde(default, rename = "match")] pub mode: Match, #[serde(default)] pub rules: Vec, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum Rule { /// A nested group with its own match mode. Group { group: RuleSet }, Field { field: String, op: String, #[serde(default)] value: Value, }, } /// How a field's value is compared. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Kind { Text, /// Keywords: text ops over each keyword (any keyword matches; `isEmpty` = none). Keywords, Number, Date, /// One of a fixed set (`choices`). Choice(&'static [&'static str]), Bool, } /// The fields rules can test: (id, label, kind). pub const FIELDS: &[(&str, &str, Kind)] = &[ ("rating", "Rating", Kind::Number), ("flag", "Pick Flag", Kind::Choice(&["pick", "reject", "none"])), ("label", "Color Label", Kind::Choice(&["red", "yellow", "green", "blue", "purple", "none"])), ("kind", "File Type", Kind::Choice(&["image", "raw", "video"])), ("edited", "Has Edits", Kind::Bool), ("keywords", "Keywords", Kind::Keywords), ("text", "Any Searchable Text", Kind::Text), ("fileName", "Filename", Kind::Text), ("filePath", "File Path", Kind::Text), ("format", "File Format", Kind::Text), ("title", "Title", Kind::Text), ("caption", "Caption", Kind::Text), ("camera", "Camera", Kind::Text), ("lens", "Lens", Kind::Text), ("location", "Location", Kind::Text), ("creator", "Creator", Kind::Text), ("copyright", "Copyright", Kind::Text), ("copyrightStatus", "Copyright Status", Kind::Choice(&["copyrighted", "publicDomain", "unknown"])), ("captureDate", "Capture Date", Kind::Date), ("importDate", "Import Date", Kind::Date), ("editDate", "Edit Date", Kind::Date), ("iso", "ISO Speed", Kind::Number), ("aperture", "Aperture", Kind::Number), ("focalLength", "Focal Length", Kind::Number), ("megapixels", "Megapixels", Kind::Number), ("hasGps", "Has GPS", Kind::Bool), ("virtualCopy", "Virtual Copy", Kind::Bool), ("album", "Album", Kind::Number), ("sharpness", "Focus (assisted culling)", Kind::Number), ("bestOfGroup", "Best of Similar Shots", Kind::Bool), ]; /// The operators for a field kind: (id, label). pub fn ops_for(kind: Kind) -> &'static [(&'static str, &'static str)] { match kind { Kind::Text => &[ ("contains", "contains"), ("notContains", "doesn't contain"), ("is", "is"), ("isNot", "isn't"), ("startsWith", "starts with"), ("endsWith", "ends with"), ("isEmpty", "is empty"), ("isNotEmpty", "isn't empty"), ], Kind::Keywords => &[ ("contains", "contains"), ("notContains", "doesn't contain"), ("is", "is"), ("startsWith", "starts with"), ("isEmpty", "are empty"), ("isNotEmpty", "aren't empty"), ], Kind::Number => { &[("is", "is"), ("isNot", "isn't"), ("gte", "is ≥"), ("lte", "is ≤"), ("gt", "is >"), ("lt", "is <"), ("between", "is between")] } Kind::Date => &[ ("is", "is"), ("after", "is after"), ("before", "is before"), ("between", "is between"), ("inLast", "is in the last"), ("notInLast", "isn't in the last"), ("isEmpty", "is unknown"), ], Kind::Choice(_) => &[("is", "is"), ("isNot", "isn't")], Kind::Bool => &[("is", "is")], } } pub fn field_kind(field: &str) -> Option { FIELDS.iter().find(|f| f.0 == field).map(|f| f.2) } thread_local! { static NOW: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } /// Set the time "in the last…" rules count back from (the engine's clock; tests). pub fn set_now(iso: Option) { NOW.with(|n| *n.borrow_mut() = iso); } /// The current time as ISO 8601: [`set_now`]'s, else the system clock (UTC). pub fn now() -> String { if let Some(n) = NOW.with(|n| n.borrow().clone()) { return n; } #[cfg(not(target_arch = "wasm32"))] { let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); crate::dates::civil(secs) } #[cfg(target_arch = "wasm32")] { "2026-01-01T00:00:00".to_string() } } fn lower(v: &Value) -> String { match v { Value::String(s) => s.trim().to_lowercase(), Value::Null => String::new(), other => other.to_string().to_lowercase(), } } fn text_op(op: &str, have: &str, want: &str) -> bool { let have = have.trim().to_lowercase(); match op { "contains" => want.split_whitespace().all(|w| have.contains(w)), "notContains" => !want.split_whitespace().any(|w| have.contains(w)), "is" => have == want, "isNot" => have != want, "startsWith" => have.starts_with(want), "endsWith" => have.ends_with(want), "isEmpty" => have.is_empty(), "isNotEmpty" => !have.is_empty(), _ => false, } } fn number(v: &Value) -> Option { match v { Value::Number(n) => n.as_f64(), Value::String(s) => s.trim().trim_start_matches("f/").trim_end_matches("mm").trim().parse().ok(), _ => None, } } fn num_op(op: &str, have: Option, value: &Value) -> bool { if op == "between" { let (a, b) = match value { Value::Array(v) if v.len() == 2 => (number(&v[0]), number(&v[1])), _ => (None, None), }; return matches!((have, a, b), (Some(h), Some(a), Some(b)) if h >= a.min(b) && h <= a.max(b)); } let (Some(h), Some(w)) = (have, number(value)) else { return op == "isNot" && have.is_none() }; match op { "is" => (h - w).abs() < 1e-6, "isNot" => (h - w).abs() >= 1e-6, "gte" => h >= w - 1e-9, "lte" => h <= w + 1e-9, "gt" => h > w, "lt" => h < w, _ => false, } } /// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days. fn last_secs(value: &Value) -> Option { let (n, unit) = match value { Value::Object(o) => (o.get("n").and_then(number)?, o.get("unit").and_then(Value::as_str).unwrap_or("days")), v => (number(v)?, "days"), }; let day = 86_400.0; let per = match unit { "hours" => 3600.0, "weeks" => 7.0 * day, "months" => 30.4375 * day, "years" => 365.25 * day, _ => day, }; Some((n * per) as i64) } fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool { if op == "isEmpty" { return have.is_none_or(str::is_empty); } let Some(h) = have.filter(|h| !h.is_empty()) else { return op == "notInLast" }; let s = |v: &Value| v.as_str().map(str::trim).unwrap_or("").to_string(); match op { // a prefix: 2026, 2026-04, 2026-04-12 "is" => { let w = s(value); !w.is_empty() && h.starts_with(&w) } "after" => { let w = s(value); !w.is_empty() && h > w.as_str() && !h.starts_with(&w) } "before" => { let w = s(value); !w.is_empty() && h < w.as_str() } "between" => match value { Value::Array(v) if v.len() == 2 => { let (a, b) = (s(&v[0]), s(&v[1])); h >= a.as_str() && (h <= b.as_str() || h.starts_with(&b)) } _ => false, }, "inLast" | "notInLast" => { let Some(secs) = last_secs(value) else { return false }; let from = crate::dates::shift_iso(&now(), -secs).unwrap_or_default(); (h >= from.as_str()) == (op == "inLast") } _ => false, } } impl Rule { pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool { let (field, op, value) = match self { Rule::Group { group } => return group.matches(p, cat), Rule::Field { field, op, value } => (field.as_str(), op.as_str(), value), }; let m = &p.meta; let want = lower(value); let text = |have: &str| text_op(op, have, &want); match field { "rating" => num_op(op, Some(p.rating as f64), value), "flag" => { let f = format!("{:?}", p.flag).to_lowercase(); (f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is") } "label" => { let l = p.label.map(|l| format!("{l:?}").to_lowercase()).unwrap_or_else(|| "none".into()); // custom label names count too let named = p.label.is_some_and(|l| cat.label_name(l).to_lowercase() == want); (l == want || named) == (op == "is") } "kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"), "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))), }, "text" => { let all = [ p.file_name.as_str(), &m.title, &m.caption, &m.camera, &m.lens, &m.location, &m.city, &m.country, &p.format, &m.keywords.join(" "), ] .join(" "); text(&all) } "fileName" => text(&p.file_name), "filePath" => { // `/` and `\` both separate, whatever platform the catalog came from; demo photos have no path let path = match &p.source { Source::File { path } => path.replace('\\', "/"), Source::Demo { .. } => String::new(), }; let want = want.replace('\\', "/"); match op { // the whole string, spaces included (not word by word like the other text fields) "contains" => !want.trim().is_empty() && path.to_lowercase().contains(want.trim()), "notContains" => want.trim().is_empty() || !path.to_lowercase().contains(want.trim()), _ => text_op(op, &path, &want), } } "format" => text(&p.format), "title" => text(&m.title), "caption" => text(&m.caption), "camera" => text(&m.camera), "lens" => text(&m.lens), "location" => text(&[m.location.as_str(), &m.city, &m.state, &m.country].join(" ")), "creator" => text(&m.creator), "copyright" => text(&m.copyright), "copyrightStatus" => { let want = crate::CopyrightStatus::parse(&want); (want == Some(m.copyright_status)) == (op == "is") } "captureDate" => date_op(op, p.captured.as_deref(), value), "importDate" => date_op(op, Some(&p.imported), value), "editDate" => date_op(op, p.edited.as_deref(), value), "iso" => num_op(op, m.iso.map(|v| v as f64), value), "aperture" => num_op(op, m.aperture.map(|v| v as f64), value), "focalLength" => num_op(op, m.focal_mm.map(|v| v as f64), value), "megapixels" => num_op(op, Some(p.width as f64 * p.height as f64 / 1e6), value), "sharpness" => num_op(op, p.analysis.map(|a| a.sharpness as f64), value), "bestOfGroup" => p.analysis.is_some_and(|a| a.best || a.group.is_none()) == value.as_bool().unwrap_or(true), "album" => { let id = number(value).map(|v| crate::AlbumId(v as u64)); id.is_some_and(|a| cat.album(a).is_some_and(|al| !al.is_smart()) && cat.album_contains(a, p)) == (op == "is") } _ => false, } } } impl RuleSet { pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool { if self.rules.is_empty() { return self.mode != Match::Any; } match self.mode { Match::All => self.rules.iter().all(|r| r.matches(p, cat)), Match::Any => self.rules.iter().any(|r| r.matches(p, cat)), Match::None => !self.rules.iter().any(|r| r.matches(p, cat)), } } /// Whether matches depend on the clock ("in the last…" rules, nested groups included): the /// same photos can enter or leave the set without any catalog change. pub fn depends_on_now(&self) -> bool { self.rules.iter().any(|r| match r { Rule::Group { group } => group.depends_on_now(), Rule::Field { op, .. } => op == "inLast" || op == "notInLast", }) } /// Unknown fields or operators (for command validation), as readable messages. pub fn problems(&self) -> Vec { let mut out = Vec::new(); for r in &self.rules { match r { Rule::Group { group } => out.extend(group.problems()), Rule::Field { field, op, .. } => match field_kind(field) { None => out.push(format!("unknown field `{field}`")), Some(k) if !ops_for(k).iter().any(|o| o.0 == op) => out.push(format!("`{field}` has no operator `{op}`")), _ => {} }, } } out } /// A short readable summary ("rating is ≥ 3 and keywords contains travel"). pub fn describe(&self) -> String { let join = match self.mode { Match::All => " and ", Match::Any => " or ", Match::None => " nor ", }; let parts: Vec = self .rules .iter() .map(|r| match r { Rule::Group { group } => format!("({})", group.describe()), Rule::Field { field, op, value } => { let label = FIELDS.iter().find(|f| f.0 == field).map_or(field.as_str(), |f| f.1).to_lowercase(); let op = field_kind(field).and_then(|k| ops_for(k).iter().find(|o| o.0 == op)).map_or(op.as_str(), |o| o.1); let v = match value { Value::String(s) => s.clone(), Value::Null => String::new(), Value::Object(o) => format!( "{} {}", o.get("n").map(|n| n.to_string()).unwrap_or_default(), o.get("unit").and_then(Value::as_str).unwrap_or("days") ), Value::Array(a) => a.iter().map(|x| x.as_str().map_or_else(|| x.to_string(), str::to_string)).collect::>().join(" – "), other => other.to_string(), }; format!("{label} {op} {v}").trim().to_string() } }) .collect(); let s = parts.join(join); if self.mode == Match::None { format!("none of: {s}") } else { s } } } #[cfg(test)] mod tests { use super::*; use crate::{ColorLabel, Flag, PhotoId, Source}; use serde_json::json; fn photo(id: u64) -> Photo { let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, "IMG_0042.CR2", "CR2", 6000, 4000, "2026-09-20T10:00:00"); p.rating = 4; p.flag = Flag::Pick; p.label = Some(ColorLabel::Red); p.captured = Some("2026-08-14T18:30:00".into()); p.meta.keywords = vec!["travel|italy|rome".into(), "food".into()]; p.meta.camera = "Model X2".into(); p.meta.iso = Some(1600); p.meta.aperture = Some(2.8); p } fn rs(v: serde_json::Value) -> RuleSet { serde_json::from_value(v).unwrap() } #[test] fn fields_ops_and_groups() { let cat = Catalog::new(); let p = photo(1); let yes = |v: serde_json::Value| assert!(rs(v.clone()).matches(&p, &cat), "{v}"); let no = |v: serde_json::Value| assert!(!rs(v.clone()).matches(&p, &cat), "{v}"); yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "flag", "op": "is", "value": "pick"}]})); no(json!({"rules": [{"field": "rating", "op": "gt", "value": 4}]})); yes(json!({"match": "any", "rules": [{"field": "rating", "op": "is", "value": 1}, {"field": "label", "op": "is", "value": "red"}]})); no(json!({"match": "none", "rules": [{"field": "label", "op": "is", "value": "red"}]})); yes(json!({"rules": [{"field": "keywords", "op": "is", "value": "italy"}]})); yes( json!({"rules": [{"field": "keywords", "op": "contains", "value": "ROME"}, {"field": "keywords", "op": "notContains", "value": "beach"}]}), ); no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]})); yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]})); // file path: the whole string (spaces included), either separator, case-insensitive let mut fp = photo(3); fp.source = Source::File { path: "D:\\Photos\\Aliah Ira Polanco-Grylls\\2026\\IMG_0042.CR2".into() }; let fpm = |v: serde_json::Value| rs(v).matches(&fp, &cat); assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Aliah Ira Polanco-Grylls/"}]}))); assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Ira Aliah/"}]}))); assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Polanco Grylls"}]}))); assert!(fpm(json!({"rules": [{"field": "filePath", "op": "notContains", "value": "/Other/"}]}))); assert!(fpm(json!({"rules": [{"field": "filePath", "op": "startsWith", "value": "d:/photos/"}]}))); no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Aliah/"}]})); // demo photo has no path yes(json!({"rules": [{"field": "filePath", "op": "isEmpty"}]})); yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]})); yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]})); yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]})); yes( json!({"rules": [{"field": "captureDate", "op": "is", "value": "2026-08"}, {"field": "captureDate", "op": "before", "value": "2026-09-01"}]}), ); yes(json!({"rules": [{"field": "captureDate", "op": "between", "value": ["2026-08-01", "2026-08"]}]})); no(json!({"rules": [{"field": "captureDate", "op": "after", "value": "2026-08"}]})); yes(json!({"rules": [{"field": "kind", "op": "isNot", "value": "video"}, {"field": "edited", "op": "is", "value": false}]})); // copyright status (unknown until set) yes(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "unknown"}]})); no(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "copyrighted"}]})); let mut pd = photo(2); pd.meta.copyright_status = crate::CopyrightStatus::PublicDomain; assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}]})).matches(&pd, &cat)); assert!(rs(json!({"rules": [{"field": "copyrightStatus", "op": "isNot", "value": "copyrighted"}]})).matches(&pd, &cat)); // nested: rating ≥ 4 and (label is blue or keywords contain food) yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 4}, {"group": {"match": "any", "rules": [ {"field": "label", "op": "is", "value": "blue"}, {"field": "keywords", "op": "contains", "value": "food"}]}}]})); // in the last N days, relative to now set_now(Some("2026-09-01T00:00:00".into())); yes(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 30, "unit": "days"}}]})); no(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "weeks"}}]})); yes(json!({"rules": [{"field": "captureDate", "op": "notInLast", "value": 7}]})); set_now(None); // an empty rule list: all → everything, any → nothing yes(json!({"rules": []})); no(json!({"match": "any", "rules": []})); } #[test] fn problems_and_description() { let r = rs(json!({"rules": [{"field": "rating", "op": "contains", "value": 1}, {"group": {"rules": [{"field": "nope", "op": "is"}]}}]})); assert_eq!(r.problems(), vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]); let r = rs( json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}), ); assert_eq!(r.describe(), "rating is ≥ 3 or capture date is in the last 2 weeks"); // every field has a kind with operators for (f, _, k) in FIELDS { assert!(!ops_for(*k).is_empty(), "{f}"); } } }