Files
lightcraft/crates/catalog/src/rules.rs
T
Brandon Thomas efdd8a4e10 Merge main into feat/copyright-status
# Conflicts:
#	crates/engine/src/tests_xmp.rs
#	docs/parity.md
2026-10-04 15:26:53 -04:00

492 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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};
#[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<Rule>,
}
#[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),
("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<Kind> {
FIELDS.iter().find(|f| f.0 == field).map(|f| f.2)
}
thread_local! {
static NOW: std::cell::RefCell<Option<String>> = 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<String>) {
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<f64> {
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<f64>, 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<i64> {
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),
"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<String> {
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<String> = 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::<Vec<_>>().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"}]}));
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}");
}
}
}