mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 21:18:11 -06:00
LRC-LIB-SMARTCOLL: group smart-album rule fields for the field menu
FIELDS is now listed in field-menu order, with TOP_LEVEL_FIELDS (rating, pick flag, colour label, any searchable text) followed by FIELD_GROUPS (Source, File, Date, Keywords & People, Description, Camera Info, Location, Size, Develop, Assisted Culling). field_group / field_label look a field up. album.ruleFields reports each field's group so agents see the same structure as the editor. Rule ids are unchanged, so saved smart albums keep working. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
65ad290e89
commit
adcc2830dd
+95
-18
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -819,8 +819,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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::<Vec<_>>()});
|
||||
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::<Vec<_>>()});
|
||||
if let Kind::Choice(c) = kind {
|
||||
v["choices"] = json!(c);
|
||||
}
|
||||
|
||||
@@ -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}]}}}),
|
||||
|
||||
Reference in New Issue
Block a user