mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 19:47:35 -06:00
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:
co-authored by
Claude Opus 5.5
parent
6b46c98654
commit
12e1105861
@@ -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]
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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}]}}}),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "正方形"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Квадратная"
|
||||
}
|
||||
|
||||
@@ -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": "正方形"
|
||||
}
|
||||
|
||||
@@ -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": "正方形"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user