diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 14ba8626..b53c198a 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -12,6 +12,7 @@ pub mod journal; pub mod keywords; pub mod model; pub mod query; +pub mod rules; pub mod stacks; pub mod store; @@ -24,6 +25,7 @@ pub use keywords::KeywordNode; use lightcraft_develop::DevelopSettings; pub use model::*; pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey}; +pub use rules::{Match, Rule, RuleSet}; use serde::{Deserialize, Serialize}; pub use store::{FsStore, MemStore, Store}; diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index f9d22cf7..9db19c5a 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -49,6 +49,9 @@ pub struct Filter { /// A folder on disk: its files only (browsed ones too); `subfolders` includes everything below. pub folder: Option, pub subfolders: bool, + /// Smart-album rules (all / any / none, nested groups; see [`crate::rules`]). + #[serde(skip_serializing_if = "Option::is_none")] + pub rule_set: Option, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -156,6 +159,9 @@ impl Filter { if !self.text.trim().is_empty() { v.push(format!("“{}”", self.text.trim())); } + if let Some(rs) = self.rule_set.as_ref().filter(|r| !r.rules.is_empty()) { + v.push(rs.describe()); + } if v.is_empty() { "all photos".into() } else { v.join(", ") } } @@ -190,6 +196,11 @@ impl Filter { if self.edited.is_some_and(|e| e != p.is_edited()) { return false; } + if let Some(rs) = &self.rule_set + && !rs.matches(p, cat) + { + return false; + } if let Some(want) = &self.merged { match merged_kind(&p.file_name) { Some(k) if want == "any" || want == k => {} diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs new file mode 100644 index 00000000..fe309956 --- /dev/null +++ b/crates/catalog/src/rules.rs @@ -0,0 +1,466 @@ +//! 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, +} + +#[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), + ("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), +]; + +/// 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), + "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), + "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), + "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)), + } + } + + /// 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"}]})); + 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}]})); + // 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}"); + } + } +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 8de6cce3..ce64a6aa 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -441,7 +441,7 @@ pub fn specs() -> Vec { "New Smart Album…", [], None, - "{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album), parent?: folderId} — without `rules`, saves the current view (source + filter)", + "{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album, ruleSet: {match: all|any|none, rules: [{field, op, value} | {group: ruleSet}]} — see album.ruleFields), parent?: folderId} — without `rules`, saves the current view (source + filter)", always, |s, p| { let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string(); @@ -459,6 +459,27 @@ pub fn specs() -> Vec { Ok(json!({"id": id.0, "count": s.catalog.album_count(id)})) } ), + cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind, ops: [{op, label}], choices?}] for ruleSet rules", always, |_, _| { + use lightcraft_catalog::rules::{FIELDS, Kind, ops_for}; + Ok(json!(FIELDS + .iter() + .map(|(id, label, kind)| { + let k = match kind { + Kind::Text => "text", + Kind::Keywords => "keywords", + Kind::Number => "number", + Kind::Date => "date", + Kind::Choice(_) => "choice", + Kind::Bool => "bool", + }; + let mut v = json!({"field": id, "label": label, "kind": k, "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::>()}); + if let Kind::Choice(c) = kind { + v["choices"] = json!(c); + } + v + }) + .collect::>())) + }), cmd!( "album.setRules", "Edit Smart Album", @@ -689,7 +710,11 @@ pub fn specs() -> Vec { fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result { let mut v = serde_json::to_value(base).unwrap_or_default(); lightcraft_develop::presets::deep_merge(&mut v, patch); - serde_json::from_value(v).map_err(|e| bad(c, e.to_string())) + let f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?; + if let Some(problem) = f.rule_set.as_ref().and_then(|r| r.problems().into_iter().next()) { + return Err(bad(c, problem)); + } + Ok(f) } /// The current view (source + filter) as smart-album rules. Viewing a smart album starts from diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4dc193e5..9997f7aa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -420,6 +420,8 @@ impl Session { pub fn visible(&mut self) -> &[PhotoId] { let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse)); if self.visible_key.as_ref() != Some(&key) { + // "in the last N days" rules count back from the session's clock + lightcraft_catalog::rules::set_now(Some((self.clock)())); let mut f = self.source.to_filter(&self.filter, &self.catalog); if self.source == LibrarySource::Folder { // no folder chosen: nothing (an empty path matches nothing) diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index bd3397ca..43e724bd 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -152,3 +152,40 @@ fn profile_favorites_and_recent_survive_reopen() { assert!(list.as_array().unwrap().iter().any(|p| p["id"] == "lc.bw.sepia" && p["favorite"] == true)); let _ = std::fs::remove_dir_all(&dir); } + +/// Smart albums with a rule set: all / any / none, nested groups, validation, live updates. +#[test] +fn smart_album_rule_sets() { + let mut s = crate::Session::with_demo(); + s.clock = Box::new(|| "2026-10-02T12:00:00".to_string()); + let fields = s.execute("album.ruleFields", &serde_json::json!({})).unwrap(); + assert!(fields.as_array().unwrap().iter().any(|f| f["field"] == "keywords" && f["ops"].as_array().unwrap().len() > 3)); + let bad = s.execute( + "album.createSmart", + &serde_json::json!({"name": "Bad", "rules": {"ruleSet": {"rules": [{"field": "rating", "op": "contains", "value": 1}]}}}), + ); + assert!(bad.is_err(), "operators are checked"); + let r = s + .execute( + "album.createSmart", + &serde_json::json!({"name": "Good ones", "rules": {"ruleSet": {"match": "all", "rules": [ + {"field": "rating", "op": "gte", "value": 4}, + {"group": {"match": "none", "rules": [{"field": "flag", "op": "is", "value": "reject"}]}} + ]}}}), + ) + .unwrap(); + let id = lightcraft_catalog::AlbumId(r["id"].as_u64().unwrap()); + let want = s.catalog.photos().filter(|p| !p.deleted && p.rating >= 4 && p.flag != lightcraft_catalog::Flag::Reject).count(); + assert!(want > 0); + assert_eq!(s.catalog.album_count(id), want); + // live: a new 5-star photo joins + let other = s.catalog.photos().find(|p| p.rating < 4 && !p.deleted).unwrap().id; + s.execute("photo.rate", &serde_json::json!({"ids": [other.0], "rating": 5})).unwrap(); + assert_eq!(s.catalog.album_count(id), want + 1); + // the same rules work as a library filter (agents: library.filter {ruleSet}) + s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "is", "value": 5}]}})).unwrap(); + assert!(s.visible_cloned().iter().all(|id| s.catalog.photo(*id).unwrap().rating == 5)); + let list = s.execute("albums.list", &serde_json::json!({})).unwrap(); + let a = list.as_array().unwrap().iter().find(|a| a["name"] == "Good ones").unwrap().clone(); + assert!(a.to_string().contains("rating is ≥ 4"), "{a}"); +} diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 1e6dca13..1485e616 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -60,6 +60,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "---", "dialog.newAlbum", "dialog.newFolder", + "dialog.smartAlbum", "dialog.newSmartAlbum", "---", "file.importPresets", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d3426ddf..f2a9cb83 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -77,6 +77,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("brush.featherMore", "Increase Brush Feather", Some("Shift+]"), "Window>Tools"), ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), ("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"), + ("dialog.smartAlbum", "New Smart Album…", None, "File"), ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), ("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"), @@ -521,6 +522,26 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // {id?}: edit that smart album's rules; without: a new one starting at Rating ≥ 3 + let album = p + .get("id") + .and_then(Value::as_u64) + .and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id)).filter(|a| a.is_smart()).cloned()); + app.ui.dialog = Some(match album { + Some(a) => Dialog::SmartRules { + id: Some(a.id.0), + name: a.name.clone(), + rules: a.smart.as_ref().and_then(|f| f.rule_set.clone()).unwrap_or_default(), + }, + None => Dialog::SmartRules { + id: None, + name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), + rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, + }, + }); + Ok(Value::Null) + } "dialog.newSmartAlbum" => { app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into() }); Ok(Value::Null) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 55c5d0fd..431d95a9 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -30,6 +30,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::RenameKeyword { .. } => "Rename Keyword", Dialog::MergeKeywords { .. } => "Merge Keywords", Dialog::NewSmartAlbum { .. } => "Create Smart Album", + Dialog::SmartRules { id: None, .. } => "New Smart Album", + Dialog::SmartRules { .. } => "Edit Smart Album", Dialog::AutoStack { .. } => "Auto-Stack by Capture Time", Dialog::CreatePreset { .. } => "Create Preset", Dialog::CopySettings { .. } => "Choose Edit Settings to Copy", @@ -48,7 +50,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .resizable(false) .frame(frame) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .default_width(if matches!(dlg, Dialog::Import { .. }) { 760.0 } else { 380.0 }) + .default_width(match dlg { + Dialog::Import { .. } => 760.0, + Dialog::SmartRules { .. } => 680.0, + _ => 380.0, + }) .show(ctx, |ui| { ui.spacing_mut().item_spacing.y = 8.0; match &mut dlg { @@ -67,6 +73,25 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { egui::RichText::new(format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"])).color(t.text_dim), ); } + Dialog::SmartRules { name, rules, .. } => { + let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY)); + crate::widgets::register(ui.ctx(), "field:smartName", r.rect); + ui.add_space(6.0); + egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| { + crate::panels::rules_editor::edit(ui, rules, "rules", 0); + }); + let problems = rules.problems(); + let f = lightcraft_catalog::Filter { rule_set: Some(rules.clone()), ..Default::default() }; + let n = if problems.is_empty() { app.session.catalog.query(&f, &Default::default()).len() } else { 0 }; + ui.add_space(4.0); + ui.label( + egui::RichText::new(match problems.first() { + Some(p) => p.clone(), + None => format!("{n} photo{} match · updates automatically as photos change", if n == 1 { "" } else { "s" }), + }) + .color(t.text_dim), + ); + } Dialog::NewSmartAlbum { name } => { let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY)); r.request_focus(); @@ -654,6 +679,18 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("keyword.rename", json!({"from": from, "to": to})), Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})), Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), + Dialog::SmartRules { id, name, rules } => { + let name = if name.trim().is_empty() { "Smart Album".to_string() } else { name.trim().to_string() }; + match id { + Some(id) => { + if app.session.catalog.album(lightcraft_catalog::AlbumId(*id)).is_some_and(|a| a.name != name) { + app.run("album.rename", json!({"id": id, "name": name}))?; + } + app.run("album.setRules", json!({"id": id, "replace": true, "rules": {"ruleSet": rules}})) + } + None => app.run("album.createSmart", json!({"name": name, "rules": {"ruleSet": rules}})), + } + } Dialog::NewSmartAlbum { name } => app.run("album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }})), Dialog::CreatePreset { name, group, groups } => app.run( "preset.create", diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index f79ff254..1e3cb4b9 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -95,6 +95,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if ui.button("Create Album…").clicked() { app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false }); } + if ui.button("Create Smart Album…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::SmartRules { + id: None, + name: String::new(), + rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, + }); + } if ui.button("Create Smart Album from Filter…").clicked() { app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new() }); } @@ -287,6 +294,11 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { if !a.folder && !a.is_smart() && ui.button("Add Selected Photos").clicked() { let _ = app.run("album.addPhotos", json!({"id": a.id.0})); } + if a.is_smart() && ui.button("Edit Smart Album…").clicked() { + // older smart albums keep their filter fields; the editor works on the rule set + let rules = a.smart.as_ref().and_then(|f| f.rule_set.clone()).unwrap_or_default(); + app.ui.dialog = Some(crate::state::Dialog::SmartRules { id: Some(a.id.0), name: a.name.clone(), rules }); + } if a.is_smart() && ui.button("Update Rules from Current Filter").clicked() { let _ = app.run("album.setRules", json!({"id": a.id.0, "fromView": true})); } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 7699bdfb..b140f710 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -13,6 +13,7 @@ pub mod masking; pub mod presets; pub mod profiles; pub mod right; +pub mod rules_editor; pub mod settings; pub mod strip; pub mod topbar; diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs new file mode 100644 index 00000000..bfe3f4d0 --- /dev/null +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -0,0 +1,222 @@ +//! The smart-album rule editor: match all / any / none of a list of rules (field, operator, +//! value), with nested groups. Field and operator lists come from `lightcraft_catalog::rules`. + +use egui::RichText; +use lightcraft_catalog::rules::{FIELDS, Kind, field_kind, ops_for}; +use lightcraft_catalog::{Match, Rule, RuleSet}; +use serde_json::{Value, json}; + +use crate::theme::Tokens; +use crate::widgets::register; + +/// A sensible starting value for `field` / `op`. +pub fn default_value(field: &str, op: &str) -> Value { + match (field_kind(field), op) { + (_, "isEmpty" | "isNotEmpty") => Value::Null, + (Some(Kind::Date), "inLast" | "notInLast") => json!({"n": 30, "unit": "days"}), + (Some(Kind::Date), "between") => json!(["", ""]), + (Some(Kind::Number), "between") => json!([0, 0]), + (Some(Kind::Number), _) => json!(if field == "rating" { 3 } else { 0 }), + (Some(Kind::Choice(c)), _) => json!(c.first().copied().unwrap_or("")), + (Some(Kind::Bool), _) => json!(true), + _ => json!(""), + } +} + +/// A new rule (Rating ≥ 3). +pub fn new_rule() -> Rule { + Rule::Field { field: "rating".into(), op: "gte".into(), value: json!(3) } +} + +fn match_combo(ui: &mut egui::Ui, salt: &str, m: &mut Match) { + let label = |m: Match| match m { + Match::All => "all", + Match::Any => "any", + Match::None => "none", + }; + egui::ComboBox::from_id_salt(format!("{salt}-match")).width(64.0).selected_text(label(*m)).show_ui(ui, |ui| { + for x in [Match::All, Match::Any, Match::None] { + ui.selectable_value(m, x, label(x)); + } + }); +} + +fn text_value(ui: &mut egui::Ui, v: &mut Value, width: f32, hint: &str, salt: &str) { + let mut s = match &*v { + Value::String(s) => s.clone(), + Value::Null => String::new(), + other => other.to_string(), + }; + let r = ui.add(egui::TextEdit::singleline(&mut s).hint_text(hint).desired_width(width)); + register(ui.ctx(), format!("field:{salt}"), r.rect); + if r.changed() { + *v = json!(s); + } +} + +fn number_value(ui: &mut egui::Ui, v: &mut Value, field: &str) { + let mut n = v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok())).unwrap_or(0.0); + let (lo, hi, speed) = match field { + "rating" => (0.0, 5.0, 0.05), + "iso" => (0.0, 409_600.0, 10.0), + "aperture" => (0.0, 64.0, 0.05), + _ => (0.0, 100_000.0, 0.5), + }; + let whole = matches!(field, "rating" | "iso" | "album"); + 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() { + *v = if whole { json!(n.round() as i64) } else { json!(n) }; + } +} + +/// The value editor for one rule. +fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &str) { + match (field_kind(field), op) { + (_, "isEmpty" | "isNotEmpty") => {} + (Some(Kind::Date), "inLast" | "notInLast") => { + if !v.is_object() { + *v = default_value(field, op); + } + let mut n = v["n"].as_f64().unwrap_or(30.0); + if ui.add(egui::DragValue::new(&mut n).range(1.0..=10_000.0).speed(0.2).fixed_decimals(0)).changed() { + v["n"] = json!(n.round()); + } + let unit = v["unit"].as_str().unwrap_or("days").to_string(); + egui::ComboBox::from_id_salt(format!("{salt}-unit")).width(70.0).selected_text(&unit).show_ui(ui, |ui| { + for u in ["hours", "days", "weeks", "months", "years"] { + if ui.selectable_label(unit == u, u).clicked() { + v["unit"] = json!(u); + } + } + }); + } + (Some(k), "between") => { + if v.as_array().is_none_or(|a| a.len() != 2) { + *v = default_value(field, op); + } + let Value::Array(a) = v else { return }; + if k == Kind::Number { + number_value(ui, &mut a[0], field); + ui.label("and"); + number_value(ui, &mut a[1], field); + } else { + text_value(ui, &mut a[0], 86.0, "2026-01-01", &format!("{salt}-a")); + ui.label("and"); + text_value(ui, &mut a[1], 86.0, "2026-12", &format!("{salt}-b")); + } + } + (Some(Kind::Number), _) => number_value(ui, v, field), + (Some(Kind::Choice(c)), _) => { + let cur = v.as_str().unwrap_or("").to_string(); + egui::ComboBox::from_id_salt(format!("{salt}-choice")).width(90.0).selected_text(&cur).show_ui(ui, |ui| { + for x in c.iter() { + if ui.selectable_label(cur == *x, *x).clicked() { + *v = json!(x); + } + } + }); + } + (Some(Kind::Bool), _) => { + let mut b = v.as_bool().unwrap_or(true); + egui::ComboBox::from_id_salt(format!("{salt}-bool")).width(60.0).selected_text(if b { "true" } else { "false" }).show_ui(ui, |ui| { + ui.selectable_value(&mut b, true, "true"); + ui.selectable_value(&mut b, false, "false"); + }); + *v = json!(b); + } + (Some(Kind::Date), _) => text_value(ui, v, 120.0, "2026-04 or 2026-04-12", salt), + _ => text_value(ui, v, 140.0, "", salt), + } +} + +/// Edit `rs` in place; `salt` keeps widget ids apart between groups. +pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.label(RichText::new("Match").color(t.text_label)); + match_combo(ui, salt, &mut rs.mode); + ui.label(RichText::new("of the following rules:").color(t.text_label)); + }); + let mut remove = None; + let mut insert: Option<(usize, Rule)> = None; + for (i, rule) in rs.rules.iter_mut().enumerate() { + let rsalt = format!("{salt}-{i}"); + match rule { + Rule::Group { group } => { + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + if ui.small_button("−").on_hover_text("Remove this group").clicked() { + remove = Some(i); + } + ui.vertical(|ui| edit(ui, group, &rsalt, depth + 1)); + }); + }); + } + Rule::Field { field, op, value } => { + ui.horizontal(|ui| { + let label = FIELDS.iter().find(|f| f.0 == field.as_str()).map_or(field.as_str(), |f| f.1).to_string(); + egui::ComboBox::from_id_salt(format!("{rsalt}-field")).width(150.0).height(400.0).selected_text(label).show_ui(ui, |ui| { + for (id, l, k) in FIELDS { + if ui.selectable_label(field == id, *l).clicked() && field != id { + *field = id.to_string(); + if !ops_for(*k).iter().any(|o| o.0 == op.as_str()) { + *op = ops_for(*k)[0].0.to_string(); + } + *value = default_value(field, op); + } + } + }); + let kind = field_kind(field).unwrap_or(Kind::Text); + let op_label = ops_for(kind).iter().find(|o| o.0 == op.as_str()).map_or(op.as_str(), |o| o.1).to_string(); + egui::ComboBox::from_id_salt(format!("{rsalt}-op")).width(110.0).selected_text(op_label).show_ui(ui, |ui| { + for (id, l) in ops_for(kind) { + if ui.selectable_label(op == id, *l).clicked() && op != id { + let was_special = matches!(op.as_str(), "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty"); + *op = id.to_string(); + if was_special || matches!(*id, "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty") { + *value = default_value(field, op); + } + } + } + }); + value_editor(ui, field, op, value, &rsalt); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let add = ui.small_button("+").on_hover_text("Add a rule (⌥-click: a group)"); + register(ui.ctx(), format!("button:ruleAdd-{rsalt}"), add.rect); + if add.clicked() { + let group = ui.input(|i| i.modifiers.alt) && depth < 3; + insert = Some(( + i + 1, + if group { Rule::Group { group: RuleSet { mode: Match::Any, rules: vec![new_rule()] } } } else { new_rule() }, + )); + } + let del = ui.small_button("−").on_hover_text("Remove this rule"); + register(ui.ctx(), format!("button:ruleRemove-{rsalt}"), del.rect); + if del.clicked() { + remove = Some(i); + } + }); + }); + } + } + } + if let Some(i) = remove { + rs.rules.remove(i); + } + if let Some((i, r)) = insert { + rs.rules.insert(i.min(rs.rules.len()), r); + } + if rs.rules.is_empty() || depth == 0 { + ui.horizontal(|ui| { + let add = ui.small_button("+ Rule"); + register(ui.ctx(), format!("button:ruleAddEnd-{salt}"), add.rect); + if add.clicked() { + rs.rules.push(new_rule()); + } + if depth < 3 && ui.small_button("+ Group").clicked() { + rs.rules.push(Rule::Group { group: RuleSet { mode: Match::Any, rules: vec![new_rule()] } }); + } + }); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6ffd3dee..93f9dd4e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -347,6 +347,12 @@ pub enum Dialog { NewSmartAlbum { name: String, }, + /// Create (`id` None) or edit a smart album's rules. + SmartRules { + id: Option, + name: String, + rules: lightcraft_catalog::RuleSet, + }, /// `groups`: the settings groups the preset includes (`SettingsGroup` ids). CreatePreset { name: String, diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 7881f2c8..1956785b 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -254,3 +254,29 @@ fn develop_photo_keywords(h: &Headless) -> Vec { let id = h.app.session.active().expect("active photo"); h.app.session.catalog.photo(id).unwrap().meta.keywords.clone() } + +#[test] +fn smart_album_rule_editor_creates_and_edits() { + let mut h = detail("panel.edit"); + exec(&mut h, "dialog.smartAlbum", json!({"name": "Keepers"})); + // the editor starts with Rating ≥ 3; "+" adds a second rule + let r = h.request("ui.clickWidget", json!({"id": "button:ruleAdd-rules-0"}), T); + assert_eq!(r["ok"], true, "{r}"); + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &mut h.app.ui.dialog else { panic!("no rule editor") }; + assert_eq!(rules.rules.len(), 2); + rules.rules[1] = serde_json::from_value(json!({"field": "flag", "op": "isNot", "value": "reject"})).unwrap(); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + let a = h.app.session.catalog.albums().find(|a| a.name == "Keepers").expect("album").clone(); + let n = h.app.session.catalog.photos().filter(|p| !p.deleted && p.rating >= 3 && p.flag != lightcraft_catalog::Flag::Reject).count(); + assert_eq!(h.app.session.catalog.album_count(a.id), n); + // edit: back to one rule + exec(&mut h, "dialog.smartAlbum", json!({"id": a.id.0})); + let r = h.request("ui.clickWidget", json!({"id": "button:ruleRemove-rules-1"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + let n3 = h.app.session.catalog.photos().filter(|p| !p.deleted && p.rating >= 3).count(); + assert_eq!(h.app.session.catalog.album_count(a.id), n3); + h.settle(SETTLE); +} diff --git a/docs/parity.md b/docs/parity.md index 58cc812f..931c62f4 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | X. Cross-cutting behaviours (BEHAV) | 13 | 0 | 4 | 1 | 8/8 (100%) | 5/5 (100%) | | Y. Menus | 70 | 7 | 8 | 8 | 46/47 (98%) | 19/23 (83%) | | Z. Keyboard shortcuts (desktop) | 68 | 8 | 4 | 1 | 49/52 (94%) | 17/23 (74%) | -| Lightroom Classic extras | 15 | 15 | 49 | 9 | — | 14/21 (67%) | -| **Total** | 330 | 46 | 124 | 37 | 193/198 (97%) | 117/144 (81%) | +| Lightroom Classic extras | 16 | 14 | 49 | 9 | — | 15/21 (71%) | +| **Total** | 331 | 45 | 124 | 37 | 193/198 (97%) | 118/144 (82%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **70.6%** of 500 in-scope rows — P0 98.7% of 198 · P1 89.2% of 144 · P2 18.4% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **70.7%** of 500 in-scope rows — P0 98.7% of 198 · P1 89.6% of 144 · P2 18.4% of 158. ## Top gaps @@ -695,7 +695,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | 🟡 | `cmd:library.browse`, `crates/ui-egui/src/panels/left.rs` (`local_section`) | standard places + the browsed folder + Browse Folder…, breadcrumb navigation upwards; no expandable folder tree, no folder rename/move | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | 🟡 | `cmd:album.create` | albums + folders; no smart / quick / target collections | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | 🟡 | `cmd:album.createSmart`, `crates/catalog/src/query.rs` | the filter fields + date range; no rule editor, any/none groups or operators beyond ≥/=/≤ | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 26 fields (rating, flag, label, type, edits, keywords, any text, filename, format, title, caption, camera, lens, location, creator, copyright, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | 🟡 | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; no lock or multi-select columns | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down |