diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index cb2f1981f..ca6c4795f 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -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] diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 6abb0930b..d20745dde 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -819,7 +819,7 @@ 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, 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 { }; 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); + v["choices"] = json!(c.iter().map(|c| c.0).collect::>()); + v["choiceLabels"] = c.iter().map(|(id, label)| (id.to_string(), json!(label))).collect::>().into(); } v }) diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index b4621e975..f3650803d 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -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}]}}}), diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 64a485ed2..8ae445bdb 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -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" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 461fa2f65..82d8441b1 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -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" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 49f2ef807..9357aa945 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -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": "正方形" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 81e331c5c..bdbc7b576 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -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" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 382bfc05a..bc5e64ad5 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -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": "Квадратная" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 4074dfbda..9a0302a9a 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -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": "正方形" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 96fcbb18b..3e96dc8c3 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -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": "正方形" } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 3cdd83b93..d86c81d05 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -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() diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index f3425b181..697f71253 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -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(),