LRC-LIB-SMARTCOLL: readable, translated labels for smart-album choice values

Choice values showed their rule ids in English ("Copyright Status is
publicDomain", "Treatment is monochrome"), and Russian had no
translation for any of them. Kind::Choice now carries (id, label) pairs
like the operators: rules still store and accept the ids, and people
see the labels.

- catalog: choice_label(field, id); RuleSet::describe uses the label
  ("copyright status is public domain").
- engine: album.ruleFields keeps `choices` as ids and adds
  `choiceLabels` {id: label}. A test pins the ids, since the new tuple
  type would otherwise serialize as pairs.
- ui: i18n::choice_text is the one path for the editor's value dropdown
  and translated summaries. Aspect labels are "Landscape (wide)" /
  "Portrait (tall)", because the existing "Landscape" / "Portrait" keys
  mean scenery and people. Seven locales gain Video, Copyrighted, Public
  Domain, Unknown, the aspect labels and Square (ru also Raw). A test
  requires every choice label to be translated in every language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 10:54:56 -04:00
co-authored by Claude Opus 5.5
parent 6b46c98654
commit 12e1105861
12 changed files with 170 additions and 29 deletions
+50 -9
View File
@@ -48,8 +48,8 @@ pub enum Kind {
Keywords,
Number,
Date,
/// One of a fixed set (`choices`).
Choice(&'static [&'static str]),
/// One of a fixed set: (id, label). Rules store the id; people see the label.
Choice(&'static [(&'static str, &'static str)]),
Bool,
}
@@ -57,8 +57,12 @@ pub enum Kind {
/// 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"])),
("flag", "Pick Flag", Kind::Choice(&[("pick", "Picked"), ("reject", "Rejected"), ("none", "Unflagged")])),
(
"label",
"Color Label",
Kind::Choice(&[("red", "Red"), ("yellow", "Yellow"), ("green", "Green"), ("blue", "Blue"), ("purple", "Purple"), ("none", "None")]),
),
("text", "Any Searchable Text", Kind::Text),
// Source
("album", "Album", Kind::Number),
@@ -69,7 +73,7 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[
("fileName", "Filename", Kind::Text),
("extension", "File Extension", Kind::Text),
("filePath", "File Path", Kind::Text),
("kind", "File Type", Kind::Choice(&["image", "raw", "video"])),
("kind", "File Type", Kind::Choice(&[("image", "Image"), ("raw", "Raw"), ("video", "Video")])),
("format", "File Format", Kind::Text),
("duration", "Video Duration", Kind::Number),
// Date
@@ -87,7 +91,11 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[
("altText", "Alt Text", Kind::Text),
("creator", "Creator", Kind::Text),
("copyright", "Copyright", Kind::Text),
("copyrightStatus", "Copyright Status", Kind::Choice(&["copyrighted", "publicDomain", "unknown"])),
(
"copyrightStatus",
"Copyright Status",
Kind::Choice(&[("copyrighted", "Copyrighted"), ("publicDomain", "Public Domain"), ("unknown", "Unknown")]),
),
// Camera Info
("camera", "Camera", Kind::Text),
("lens", "Lens", Kind::Text),
@@ -104,12 +112,12 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[
// Size
("longEdge", "Long Edge", Kind::Number),
("shortEdge", "Short Edge", Kind::Number),
("aspect", "Aspect Ratio", Kind::Choice(&["landscape", "portrait", "square"])),
("aspect", "Aspect Ratio", Kind::Choice(&[("landscape", "Landscape (wide)"), ("portrait", "Portrait (tall)"), ("square", "Square")])),
("megapixels", "Megapixels", Kind::Number),
// Develop
("edited", "Has Edits", Kind::Bool),
("cropped", "Cropped", Kind::Bool),
("treatment", "Treatment", Kind::Choice(&["color", "monochrome"])),
("treatment", "Treatment", Kind::Choice(&[("color", "Color"), ("monochrome", "Black & White")])),
// Assisted Culling
("sharpness", "Focus (assisted culling)", Kind::Number),
("bestOfGroup", "Best of Similar Shots", Kind::Bool),
@@ -138,6 +146,13 @@ 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 choice `id` of `field` ("Public Domain" for `publicDomain`); `None` when
/// `field` isn't a choice field or has no such choice.
pub fn choice_label(field: &str, id: &str) -> Option<&'static str> {
let Some(Kind::Choice(choices)) = field_kind(field) else { return None };
choices.iter().find(|c| c.0 == id).map(|c| c.1)
}
/// 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)
@@ -519,7 +534,7 @@ impl RuleSet {
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(),
Value::String(s) => choice_label(field, s).map_or_else(|| s.clone(), str::to_lowercase),
Value::Null => String::new(),
Value::Object(o) => format!(
"{} {}",
@@ -827,6 +842,32 @@ mod tests {
}
}
/// Choice values have readable labels: the editor and summaries show "Public Domain" and
/// "Black & White", never ids like `publicDomain`; rules still store and accept the ids.
#[test]
fn choices_have_readable_labels() {
assert_eq!(choice_label("copyrightStatus", "publicDomain"), Some("Public Domain"));
assert_eq!(choice_label("treatment", "monochrome"), Some("Black & White"));
assert_eq!(choice_label("flag", "pick"), Some("Picked"));
assert_eq!(choice_label("aspect", "landscape"), Some("Landscape (wide)"));
assert_eq!(choice_label("flag", "nope"), None);
assert_eq!(choice_label("rating", "pick"), None, "not a choice field");
for (field, _, kind) in FIELDS {
let Kind::Choice(choices) = kind else { continue };
for (id, label) in *choices {
assert!(!label.is_empty() && label != id, "{field}: {id} has no label of its own");
assert!(label.starts_with(|c: char| c.is_uppercase()), "{field}: {label}");
}
}
let r = rs(
json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}, {"field": "flag", "op": "isNot", "value": "reject"}]}),
);
assert_eq!(r.describe(), "copyright status is public domain and pick flag isn't rejected");
let mut p = photo(1);
p.meta.copyright_status = crate::CopyrightStatus::PublicDomain;
assert!(r.matches(&p, &Catalog::new()), "ids still match");
}
/// 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]
+3 -2
View File
@@ -819,7 +819,7 @@ 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, group (field-menu submenu, null at the top level), ops: [{op, label}], choices?}] for ruleSet rules, in menu order", always, |_, _| {
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? (ids), choiceLabels? ({id: label})}] for ruleSet rules, in menu order", always, |_, _| {
use lightcraft_catalog::rules::{FIELDS, Kind, field_group, ops_for};
Ok(json!(FIELDS
.iter()
@@ -834,7 +834,8 @@ pub fn specs() -> Vec<CommandSpec> {
};
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);
v["choices"] = json!(c.iter().map(|c| c.0).collect::<Vec<_>>());
v["choiceLabels"] = c.iter().map(|(id, label)| (id.to_string(), json!(label))).collect::<serde_json::Map<_, _>>().into();
}
v
})
+4
View File
@@ -256,6 +256,10 @@ fn smart_album_rule_sets() {
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));
// choices stay the ids rules store; choiceLabels maps them to what people read
let flag = fields.as_array().unwrap().iter().find(|f| f["field"] == "copyrightStatus").unwrap();
assert_eq!(flag["choices"], serde_json::json!(["copyrighted", "publicDomain", "unknown"]));
assert_eq!(flag["choiceLabels"]["publicDomain"], "Public Domain");
let bad = s.execute(
"album.createSmart",
&serde_json::json!({"name": "Bad", "rules": {"ruleSet": {"rules": [{"field": "rating", "op": "contains", "value": 1}]}}}),
+8 -1
View File
@@ -1887,5 +1887,12 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Video": "Video",
"Copyrighted": "Urheberrechtlich geschützt",
"Public Domain": "Gemeinfrei",
"Unknown": "Unbekannt",
"Landscape (wide)": "Querformat",
"Portrait (tall)": "Hochformat",
"Square": "Quadratisch"
}
+8 -1
View File
@@ -1732,5 +1732,12 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Video": "Vídeo",
"Copyrighted": "Con copyright",
"Public Domain": "Dominio público",
"Unknown": "Desconocido",
"Landscape (wide)": "Horizontal (apaisada)",
"Portrait (tall)": "Vertical (retrato)",
"Square": "Cuadrada"
}
+8 -1
View File
@@ -1732,5 +1732,12 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Video": "動画",
"Copyrighted": "著作権あり",
"Public Domain": "パブリックドメイン",
"Unknown": "不明",
"Landscape (wide)": "横長",
"Portrait (tall)": "縦長",
"Square": "正方形"
}
+8 -1
View File
@@ -1732,5 +1732,12 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Video": "Vídeo",
"Copyrighted": "Protegido por direitos autorais",
"Public Domain": "Domínio público",
"Unknown": "Desconhecido",
"Landscape (wide)": "Paisagem (horizontal)",
"Portrait (tall)": "Retrato (vertical)",
"Square": "Quadrada"
}
+9 -1
View File
@@ -1403,5 +1403,13 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Raw": "RAW",
"Video": "Видео",
"Copyrighted": "Защищено авторским правом",
"Public Domain": "Общественное достояние",
"Unknown": "Неизвестно",
"Landscape (wide)": "Альбомная",
"Portrait (tall)": "Книжная",
"Square": "Квадратная"
}
+8 -1
View File
@@ -1732,5 +1732,12 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Video": "视频",
"Copyrighted": "受版权保护",
"Public Domain": "公共领域",
"Unknown": "未知",
"Landscape (wide)": "横向",
"Portrait (tall)": "纵向",
"Square": "正方形"
}
+8 -1
View File
@@ -1732,5 +1732,12 @@
"Pending photo action cancelled": "Pending photo action cancelled",
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions"
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Video": "影片",
"Copyrighted": "受著作權保護",
"Public Domain": "公有領域",
"Unknown": "未知",
"Landscape (wide)": "橫向",
"Portrait (tall)": "直向",
"Square": "正方形"
}
+45 -3
View File
@@ -318,6 +318,16 @@ pub fn filter_label(filter: &lightcraft_catalog::Filter, catalog: &lightcraft_ca
}
}
/// What people read for smart-album choice `id` of `field` ("Gemeinfrei" for `publicDomain` in
/// German); an id the field doesn't know, as written.
pub fn choice_text<'a>(field: &str, id: &'a str) -> &'a str {
choice_text_in(language(), field, id)
}
fn choice_text_in<'a>(language: Locale, field: &str, id: &'a str) -> &'a str {
lightcraft_catalog::rules::choice_label(field, id).map_or(id, |label| tr_in(language, label))
}
/// A rule summary for display, keeping free-text rule values verbatim.
pub fn rules_label(rules: &lightcraft_catalog::RuleSet) -> String {
fn describe(rules: &lightcraft_catalog::RuleSet, depth: usize) -> String {
@@ -337,9 +347,8 @@ pub fn rules_label(rules: &lightcraft_catalog::RuleSet) -> String {
let Some((_, label, kind)) = FIELDS.iter().find(|entry| entry.0 == field) else { return field.clone() };
let operator = ops_for(*kind).iter().find(|entry| entry.0 == op).map_or(op.as_str(), |entry| entry.1);
let text = match kind {
Kind::Choice(_) | Kind::Bool => {
tr(value.as_str().unwrap_or(if value.as_bool() == Some(true) { "true" } else { "false" })).to_string()
}
Kind::Choice(_) => value.as_str().map_or_else(|| value.to_string(), |id| choice_text(field, id).to_string()),
Kind::Bool => tr(if value.as_bool() == Some(true) { "true" } else { "false" }).to_string(),
_ if matches!(op.as_str(), "inLast" | "notInLast") => format!(
"{} {}",
value.get("n").unwrap_or(&serde_json::Value::Null),
@@ -631,6 +640,30 @@ mod tests {
/// catalog can translate. What a language lacks is reported, not failed (like
/// `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR doesn't have to ship every
/// language, and the translations catch up at their own pace.
/// Smart-album choice values read as words in every language (English "Public Domain", German
/// "Gemeinfrei"), never as rule ids like `publicDomain`; an id the field doesn't know shows as
/// written. Unlike other labels these must be translated: an untranslated one would be English
/// in the middle of a translated rule.
#[test]
fn choice_values_read_as_words_in_every_language() {
use lightcraft_catalog::rules::{FIELDS, Kind};
assert_eq!(choice_text_in(Locale::En, "copyrightStatus", "publicDomain"), "Public Domain");
assert_eq!(choice_text_in(Locale::De, "copyrightStatus", "publicDomain"), "Gemeinfrei");
assert_eq!(choice_text_in(Locale::De, "copyrightStatus", "someday"), "someday");
for language in Locale::ALL {
for (field, _, kind) in FIELDS {
let Kind::Choice(choices) = kind else { continue };
for (id, label) in *choices {
let text = choice_text_in(*language, field, id);
assert_ne!(text, *id, "{}: {field} {id}", language.code());
if *language != Locale::En {
assert!(language.catalog().contains_key(*label), "{} lacks {label:?}", language.code());
}
}
}
}
}
#[test]
fn display_label_gaps_are_reported() {
let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect();
@@ -705,6 +738,15 @@ mod tests {
rules: vec![lightcraft_catalog::Rule::Field { field: "keywords".into(), op: "contains".into(), value: serde_json::json!("Color") }],
};
assert_eq!(rules_label(&rules), "Stichwörter enthält Color");
let rules = lightcraft_catalog::RuleSet {
mode: lightcraft_catalog::Match::All,
rules: vec![lightcraft_catalog::Rule::Field {
field: "copyrightStatus".into(),
op: "is".into(),
value: serde_json::json!("publicDomain"),
}],
};
assert!(rules_label(&rules).ends_with(" Gemeinfrei"), "{}", rules_label(&rules));
let filter = lightcraft_catalog::Filter {
labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue],
..Default::default()
+11 -8
View File
@@ -19,7 +19,7 @@ pub fn default_value(field: &str, op: &str) -> Value {
(Some(Kind::Number), _) if field == "shutterSpeed" => json!("1/250"),
(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::Choice(c)), _) => json!(c.first().map_or("", |c| c.0)),
(Some(Kind::Bool), _) => json!(true),
_ => json!(""),
}
@@ -119,13 +119,16 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &
(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(crate::i18n::tr(&cur)).show_ui(ui, |ui| {
for x in c.iter() {
if ui.selectable_label(cur == *x, crate::i18n::tr(x)).clicked() {
*v = json!(x);
egui::ComboBox::from_id_salt(format!("{salt}-choice")).width(120.0).selected_text(crate::i18n::choice_text(field, &cur)).show_ui(
ui,
|ui| {
for (id, _) in c.iter() {
if ui.selectable_label(cur == *id, crate::i18n::choice_text(field, id)).clicked() {
*v = json!(id);
}
}
}
});
},
);
}
(Some(Kind::Bool), _) => {
let mut b = v.as_bool().unwrap_or(true);
@@ -296,7 +299,7 @@ mod tests {
let _ = rs.matches(&p, &cat);
let v = default_value(field, op);
let one = |v: &Value| match kind {
Kind::Choice(c) => v.as_str().is_some_and(|s| c.contains(&s)),
Kind::Choice(c) => v.as_str().is_some_and(|s| c.iter().any(|c| c.0 == s)),
Kind::Number if *field == "shutterSpeed" => v.as_str().and_then(lightcraft_catalog::parse_shutter_seconds).is_some(),
Kind::Number => v.is_number(),
Kind::Bool => v.is_boolean(),