diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index fab8566c1..fe7a8d607 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -52,40 +52,79 @@ pub enum Kind { Bool, } -/// The fields rules can test: (id, label, kind). +/// The fields rules can test: (id, label, kind), in field-menu order ([`TOP_LEVEL_FIELDS`], then +/// each of [`FIELD_GROUPS`]). 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), + // Source + ("album", "Album", Kind::Number), + ("virtualCopy", "Virtual Copy", Kind::Bool), + // File ("fileName", "Filename", Kind::Text), ("filePath", "File Path", Kind::Text), + ("kind", "File Type", Kind::Choice(&["image", "raw", "video"])), ("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"])), + // Date ("captureDate", "Capture Date", Kind::Date), ("importDate", "Import Date", Kind::Date), ("editDate", "Edit Date", Kind::Date), - ("iso", "ISO Speed", Kind::Number), - ("aperture", "Aperture", Kind::Number), + // Keywords & People + ("keywords", "Keywords", Kind::Keywords), + // Description + ("title", "Title", Kind::Text), + ("caption", "Caption", Kind::Text), + ("creator", "Creator", Kind::Text), + ("copyright", "Copyright", Kind::Text), + ("copyrightStatus", "Copyright Status", Kind::Choice(&["copyrighted", "publicDomain", "unknown"])), + // Camera Info + ("camera", "Camera", Kind::Text), + ("lens", "Lens", Kind::Text), ("focalLength", "Focal Length", Kind::Number), - ("megapixels", "Megapixels", Kind::Number), + ("aperture", "Aperture", Kind::Number), + ("iso", "ISO Speed", Kind::Number), + // Location + ("location", "Location", Kind::Text), ("hasGps", "Has GPS", Kind::Bool), - ("virtualCopy", "Virtual Copy", Kind::Bool), - ("album", "Album", Kind::Number), + // Size + ("megapixels", "Megapixels", Kind::Number), + // Develop + ("edited", "Has Edits", Kind::Bool), + // Assisted Culling ("sharpness", "Focus (assisted culling)", Kind::Number), ("bestOfGroup", "Best of Similar Shots", Kind::Bool), ]; +/// The fields the field menu shows at its top level, before the groups. +pub const TOP_LEVEL_FIELDS: &[&str] = &["rating", "flag", "label", "text"]; + +/// The field menu's submenus: (label, fields), in order. Every field of [`FIELDS`] is either here +/// once or in [`TOP_LEVEL_FIELDS`]. +pub const FIELD_GROUPS: &[(&str, &[&str])] = &[ + ("Source", &["album", "virtualCopy"]), + ("File", &["fileName", "filePath", "kind", "format"]), + ("Date", &["captureDate", "importDate", "editDate"]), + ("Keywords & People", &["keywords"]), + ("Description", &["title", "caption", "creator", "copyright", "copyrightStatus"]), + ("Camera Info", &["camera", "lens", "focalLength", "aperture", "iso"]), + ("Location", &["location", "hasGps"]), + ("Size", &["megapixels"]), + ("Develop", &["edited"]), + ("Assisted Culling", &["sharpness", "bestOfGroup"]), +]; + +/// The field-menu group `field` sits in; `None` for a top-level or unknown field. +pub fn field_group(field: &str) -> Option<&'static str> { + FIELD_GROUPS.iter().find(|g| g.1.contains(&field)).map(|g| g.0) +} + +/// The display label of `field`; `None` for an unknown field. +pub fn field_label(field: &str) -> Option<&'static str> { + FIELDS.iter().find(|f| f.0 == field).map(|f| f.1) +} + /// The operators for a field kind: (id, label). pub fn ops_for(kind: Kind) -> &'static [(&'static str, &'static str)] { match kind { @@ -399,7 +438,7 @@ impl RuleSet { .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 label = field_label(field).unwrap_or(field).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(), @@ -514,4 +553,42 @@ mod tests { assert!(!ops_for(*k).is_empty(), "{f}"); } } + + /// 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] + fn every_field_is_in_the_menu_exactly_once() { + let menu: Vec<&str> = TOP_LEVEL_FIELDS.iter().chain(FIELD_GROUPS.iter().flat_map(|g| g.1.iter())).copied().collect(); + let fields: Vec<&str> = FIELDS.iter().map(|f| f.0).collect(); + assert_eq!(menu, fields, "the menu and FIELDS list the same fields in the same order"); + for (label, group) in FIELD_GROUPS { + assert!(!group.is_empty(), "group {label} is empty"); + } + let mut labels: Vec<&str> = FIELD_GROUPS.iter().map(|g| g.0).collect(); + labels.dedup(); + assert_eq!(labels.len(), FIELD_GROUPS.len(), "group labels are unique"); + } + + /// Related fields sit together: all the file fields, all the camera (EXIF) fields, all the + /// dates, all the keyword fields. + #[test] + fn related_fields_share_a_group() { + let same = |fields: &[&str], group: &str| { + for f in fields { + assert_eq!(field_group(f), Some(group), "{f}"); + } + }; + same(&["fileName", "filePath", "kind", "format"], "File"); + same(&["camera", "lens", "focalLength", "aperture", "iso"], "Camera Info"); + same(&["captureDate", "importDate", "editDate"], "Date"); + same(&["keywords"], "Keywords & People"); + same(&["title", "caption", "creator", "copyright", "copyrightStatus"], "Description"); + same(&["location", "hasGps"], "Location"); + for f in ["rating", "flag", "label", "text"] { + assert_eq!(field_group(f), None, "{f} stays at the top level"); + } + assert_eq!(field_group("nope"), None); + assert_eq!(field_label("filePath"), Some("File Path")); + assert_eq!(field_label("nope"), None); + } } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index d900eacff..6abb0930b 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -819,8 +819,8 @@ 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}; + cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind, group (field-menu submenu, null at the top level), ops: [{op, label}], choices?}] for ruleSet rules, in menu order", always, |_, _| { + use lightcraft_catalog::rules::{FIELDS, Kind, field_group, ops_for}; Ok(json!(FIELDS .iter() .map(|(id, label, kind)| { @@ -832,7 +832,7 @@ pub fn specs() -> Vec { 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::>()}); + let mut v = json!({"field": id, "label": label, "kind": k, "group": field_group(id), "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::>()}); if let Kind::Choice(c) = kind { v["choices"] = json!(c); } diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index f7b89225e..b4621e975 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -252,6 +252,10 @@ fn smart_album_rule_sets() { 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)); + // each field names its field-menu group (null at the top level) + let group = |id: &str| fields.as_array().unwrap().iter().find(|f| f["field"] == id).map(|f| f["group"].clone()); + assert_eq!(group("filePath"), Some(serde_json::json!("File"))); + assert_eq!(group("rating"), Some(serde_json::Value::Null)); let bad = s.execute( "album.createSmart", &serde_json::json!({"name": "Bad", "rules": {"ruleSet": {"rules": [{"field": "rating", "op": "contains", "value": 1}]}}}),