From adcc2830ddd585d5e4412f244522337fd0917951 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 06:53:01 -0400 Subject: [PATCH 01/65] 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 --- crates/catalog/src/rules.rs | 113 ++++++++++++++++++++++++----- crates/engine/src/cmd/library.rs | 6 +- crates/engine/src/tests_library.rs | 4 + 3 files changed, 102 insertions(+), 21 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index fab8566c..fe7a8d60 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 d900eacf..6abb0930 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 f7b89225..b4621e97 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}]}}}), From 8c0b70b37f04468ccce9343e1b446d62ae9f79d5 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 06:58:31 -0400 Subject: [PATCH 02/65] LRC-LIB-SMARTCOLL: field menu with submenus in the rule editor The rule editor's field dropdown shows Rating, Pick Flag, Color Label and Any Searchable Text at the top, then a separator and one submenu per field group instead of one long flat list. The group holding the current field is shown in bold. Picking a field from a submenu closes the menu and keeps the operator only when the new field has it. Widget ids ruleField / ruleFieldGroup / ruleFieldItem let the control channel drive it, and group names join the i18n display-label gap report. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/panels/rules_editor.rs | 68 +++++++++++++++++------ crates/ui-egui/src/tests_masking.rs | 28 ++++++++++ 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 15992604..3cdd83b9 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -640,6 +640,7 @@ mod tests { labels.push(label); labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); } + labels.extend(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|group| group.0)); labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); let ui_labels = labels.len(); diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index b498f669..98918414 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -2,7 +2,7 @@ //! value), with nested groups. Field and operator lists come from `lightcraft_catalog::rules`. use egui::RichText; -use lightcraft_catalog::rules::{FIELDS, Kind, field_kind, ops_for}; +use lightcraft_catalog::rules::{FIELD_GROUPS, Kind, TOP_LEVEL_FIELDS, field_kind, field_label, ops_for}; use lightcraft_catalog::{Match, Rule, RuleSet}; use serde_json::{Value, json}; @@ -133,6 +133,56 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & } } +/// The field menu of one rule: the top-level fields, then one submenu per field group. Picking a +/// field keeps the operator when the new field has it, else takes the field's first operator. +fn field_menu(ui: &mut egui::Ui, salt: &str, field: &mut String, op: &mut String, value: &mut Value) { + let label = field_label(field).unwrap_or(field.as_str()).to_string(); + let current = field.clone(); + let r = + egui::ComboBox::from_id_salt(format!("{salt}-field")).width(150.0).height(400.0).selected_text(crate::i18n::tr(&label)).show_ui(ui, |ui| { + let mut pick = None; + let mut item = |ui: &mut egui::Ui, id: &'static str| { + let r = ui.selectable_label(current == id, crate::i18n::tr(field_label(id).unwrap_or(id))); + register(ui.ctx(), format!("ruleFieldItem:{id}:{salt}"), r.rect); + if r.clicked() { + pick = Some(id); + ui.close(); + } + }; + for id in TOP_LEVEL_FIELDS { + item(ui, id); + } + ui.separator(); + // submenu rows look like the items above them; the one holding the current field stands out + // (a transparent frame, not none: a frameless button drops its padding and hover fill) + let inactive = &mut ui.visuals_mut().widgets.inactive; + inactive.weak_bg_fill = egui::Color32::TRANSPARENT; + inactive.bg_stroke = egui::Stroke::NONE; + for (group, ids) in FIELD_GROUPS { + let text = RichText::new(crate::i18n::tr(group)); + let text = if ids.contains(¤t.as_str()) { text.strong() } else { text }; + let r = ui.menu_button(text, |ui| { + for id in *ids { + item(ui, id); + } + }); + register(ui.ctx(), format!("ruleFieldGroup:{group}:{salt}"), r.response.rect); + } + pick + }); + register(ui.ctx(), format!("ruleField:{salt}"), r.response.rect); + let Some(Some(id)) = r.inner else { return }; + if field == id { + return; + } + *field = id.to_string(); + let ops = ops_for(field_kind(id).unwrap_or(Kind::Text)); + if !ops.iter().any(|o| o.0 == op.as_str()) { + *op = ops.first().map_or("is", |o| o.0).to_string(); + } + *value = default_value(field, op); +} + /// Edit `rs` in place; `salt` keeps widget ids apart between groups. pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) { let t = Tokens::get(ui.ctx()); @@ -158,21 +208,7 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) { } Rule::Field { field, op, value } => { ui.horizontal(|ui| { - let label = FIELDS.iter().find(|f| f.0 == field.as_str()).map_or(field.as_str(), |f| f.1).to_string(); - egui::ComboBox::from_id_salt(format!("{rsalt}-field")).width(150.0).height(400.0).selected_text(crate::i18n::tr(&label)).show_ui( - ui, - |ui| { - for (id, l, k) in FIELDS { - if ui.selectable_label(field == id, crate::i18n::tr(l)).clicked() && field != id { - *field = id.to_string(); - if !ops_for(*k).iter().any(|o| o.0 == op.as_str()) { - *op = ops_for(*k)[0].0.to_string(); - } - *value = default_value(field, op); - } - } - }, - ); + field_menu(ui, &rsalt, field, op, value); let kind = field_kind(field).unwrap_or(Kind::Text); let op_label = ops_for(kind).iter().find(|o| o.0 == op.as_str()).map_or(op.as_str(), |o| o.1).to_string(); egui::ComboBox::from_id_salt(format!("{rsalt}-op")).width(110.0).selected_text(crate::i18n::tr(&op_label)).show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 0729a854..5358f371 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -8,6 +8,7 @@ use serde_json::json; use crate::headless::Headless; use crate::state::RightPanel; use crate::{LightcraftApp, Services}; +use lightcraft_catalog::Rule; const T: Duration = Duration::from_secs(20); const SETTLE: Duration = Duration::from_secs(120); @@ -295,6 +296,33 @@ fn smart_album_rule_editor_creates_and_edits() { h.settle(SETTLE); } +/// Given the rule editor, the field menu shows the top-level fields and one submenu per group; +/// picking a field from a submenu changes the rule and gives it an operator of that field. +#[test] +fn smart_album_field_menu_groups_fields_in_submenus() { + let mut h = detail("panel.edit"); + exec(&mut h, "dialog.smartAlbum", json!({"name": "Grouped"})); + let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id); + let r = h.request("ui.clickWidget", json!({"id": "ruleField:rules-0"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(has(&h, "ruleFieldItem:rating:rules-0"), "top-level fields are in the menu itself"); + for (group, _) in lightcraft_catalog::rules::FIELD_GROUPS { + assert!(has(&h, &format!("ruleFieldGroup:{group}:rules-0")), "no {group} submenu"); + } + assert!(!has(&h, "ruleFieldItem:filePath:rules-0"), "grouped fields wait in their submenu"); + let r = h.request("ui.hoverWidget", json!({"id": "ruleFieldGroup:File:rules-0"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "ruleFieldItem:filePath:rules-0"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") }; + let Rule::Field { field, op, .. } = &rules.rules[0] else { panic!("not a field rule") }; + assert_eq!((field.as_str(), op.as_str()), ("filePath", "contains"), "Rating's ≥ isn't a text operator"); + assert!(!has(&h, "ruleFieldItem:rating:rules-0") && !has(&h, "ruleFieldItem:filePath:rules-0"), "picking a field closes the menu"); +} + #[test] fn g_toggles_grids_and_shift_g_starts_guided_upright() { let mut h = detail("panel.edit"); From e54acbd1768f90e6f31b53e54035bfb8a311cfde Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:00:09 -0400 Subject: [PATCH 03/65] LRC-LIB-SMARTCOLL: Keyword Count, People and People Count rules Smart albums can test how many keywords a photo has ("2 or more", "2 or fewer", "exactly 2" via the number operators), who is in it (People: the names on face regions, with the keyword operators) and how many people. Photo::keyword_count counts a hierarchical keyword once and ignores case and blank entries; Photo::people is the domain rule for "people in a photo" (named face regions, trimmed, case-insensitive, pets and unnamed faces excluded), now shared by the existing person filter. Keyword and people rules share one name-list matcher. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/model.rs | 20 ++++++ crates/catalog/src/query.rs | 2 +- crates/catalog/src/rules.rs | 83 ++++++++++++++++++++--- crates/ui-egui/src/panels/rules_editor.rs | 3 +- 4 files changed, 97 insertions(+), 11 deletions(-) diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 957f5b68..d5a64922 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -365,6 +365,26 @@ impl Photo { } d } + /// How many keywords the photo has: a hierarchical keyword (`travel|italy|rome`) is one, and + /// the same keyword in different case or with stray spaces counts once; blank ones don't count. + pub fn keyword_count(&self) -> usize { + let mut seen: Vec = self.meta.keywords.iter().map(|k| k.trim().to_lowercase()).filter(|k| !k.is_empty()).collect(); + seen.sort_unstable(); + seen.dedup(); + seen.len() + } + /// The people in the photo: the names on its face regions, trimmed, each once (names that + /// differ only in case are one person, as first seen). Pets and unnamed faces are not people. + pub fn people(&self) -> Vec<&str> { + let mut out: Vec<&str> = Vec::new(); + for r in self.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) { + let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; + if !out.iter().any(|o| o.to_lowercase() == name.to_lowercase()) { + out.push(name); + } + } + out + } /// In the library: not deleted and not only browsed (Local). pub fn in_library(&self) -> bool { !self.deleted && !self.local diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 47223d5d..3e9cded5 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -154,7 +154,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool { /// Whether `p` has a named face region called `name` (case-insensitive). fn has_person(p: &Photo, name: &str) -> bool { let name = name.trim().to_lowercase(); - p.meta.regions.iter().any(|r| r.kind == lightcraft_meta::RegionKind::Face && r.name.as_deref().is_some_and(|n| n.to_lowercase() == name)) + p.people().iter().any(|n| n.to_lowercase() == name) } impl Filter { diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index fe7a8d60..cacc9c2d 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -43,7 +43,8 @@ pub enum Rule { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Kind { Text, - /// Keywords: text ops over each keyword (any keyword matches; `isEmpty` = none). + /// A list of names (keywords, people): text ops over each name (any name matches; `isEmpty` = + /// none). Keywords, Number, Date, @@ -73,6 +74,9 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[ ("editDate", "Edit Date", Kind::Date), // Keywords & People ("keywords", "Keywords", Kind::Keywords), + ("keywordCount", "Keyword Count", Kind::Number), + ("person", "People", Kind::Keywords), + ("personCount", "People Count", Kind::Number), // Description ("title", "Title", Kind::Text), ("caption", "Caption", Kind::Text), @@ -106,7 +110,7 @@ pub const FIELD_GROUPS: &[(&str, &[&str])] = &[ ("Source", &["album", "virtualCopy"]), ("File", &["fileName", "filePath", "kind", "format"]), ("Date", &["captureDate", "importDate", "editDate"]), - ("Keywords & People", &["keywords"]), + ("Keywords & People", &["keywords", "keywordCount", "person", "personCount"]), ("Description", &["title", "caption", "creator", "copyright", "copyrightStatus"]), ("Camera Info", &["camera", "lens", "focalLength", "aperture", "iso"]), ("Location", &["location", "hasGps"]), @@ -215,6 +219,18 @@ fn text_op(op: &str, have: &str, want: &str) -> bool { } } +/// A [`Kind::Keywords`] op over a list of names: any name matches (none for `notContains`); `is` +/// also matches one level of a hierarchical keyword (`italy` in `travel|italy|rome`). +fn names_op>(op: &str, names: &[S], want: &str) -> bool { + let names = names.iter().map(AsRef::as_ref); + match op { + "isEmpty" => names.clone().all(|n| n.trim().is_empty()), + "isNotEmpty" => names.clone().any(|n| !n.trim().is_empty()), + "notContains" => !names.clone().any(|n| text_op("contains", n, want)), + _ => names.clone().any(|n| text_op(op, n, want) || (op == "is" && n.to_lowercase().split('|').any(|part| part.trim() == want))), + } +} + fn number(v: &Value) -> Option { match v { Value::Number(n) => n.as_f64(), @@ -321,12 +337,10 @@ impl Rule { "edited" => p.is_edited() == value.as_bool().unwrap_or(true), "hasGps" => m.gps.is_some() == value.as_bool().unwrap_or(true), "virtualCopy" => p.copy_of.is_some() == value.as_bool().unwrap_or(true), - "keywords" => match op { - "isEmpty" => m.keywords.is_empty(), - "isNotEmpty" => !m.keywords.is_empty(), - "notContains" => !m.keywords.iter().any(|k| text_op("contains", k, &want)), - _ => m.keywords.iter().any(|k| text_op(op, k, &want) || (op == "is" && k.to_lowercase().split('|').any(|part| part == want))), - }, + "keywords" => names_op(op, &m.keywords, &want), + "keywordCount" => num_op(op, Some(p.keyword_count() as f64), value), + "person" => names_op(op, &p.people(), &want), + "personCount" => num_op(op, Some(p.people().len() as f64), value), "text" => { let all = [ p.file_name.as_str(), @@ -554,6 +568,57 @@ mod tests { } } + /// Keyword Count: a photo with 2 keywords is in "≥ 2", "≤ 2" and "is 2"; a hierarchical path + /// (travel|italy|rome) is one keyword, and the same keyword twice in different case is one. + #[test] + fn keyword_count_rules() { + let cat = Catalog::new(); + let mut p = photo(1); // travel|italy|rome, food + let m = |p: &Photo, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": "keywordCount", "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "gte", json!(2)) && m(&p, "lte", json!(2)) && m(&p, "is", json!(2))); + assert!(!m(&p, "gte", json!(3)) && !m(&p, "lt", json!(2)) && m(&p, "between", json!([1, 2]))); + p.meta.keywords.push("Food".into()); + assert!(m(&p, "is", json!(2)), "Food and food are one keyword"); + p.meta.keywords.clear(); + assert!(m(&p, "is", json!(0)) && m(&p, "lte", json!(2))); + assert_eq!(field_group("keywordCount"), Some("Keywords & People")); + } + + /// People: the names on face regions. "People contains ana" finds Ana Lima; pets and unnamed + /// faces are not people; People Count counts each person once. + #[test] + fn people_rules() { + use lightcraft_meta::{Rect, Region, RegionKind}; + let cat = Catalog::new(); + let region = |name: Option<&str>, kind: RegionKind| Region { + rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, + kind, + name: name.map(str::to_string), + description: None, + }; + let mut p = photo(1); + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "person", "isEmpty", json!(null)) && m(&p, "personCount", "is", json!(0))); + p.meta.regions = vec![ + region(Some("Ana Lima"), RegionKind::Face), + region(Some(" ana lima "), RegionKind::Face), + region(Some("Rex"), RegionKind::Pet), + region(None, RegionKind::Face), + region(Some("Bo"), RegionKind::Face), + ]; + assert!(m(&p, "person", "contains", json!("ana"))); + assert!(m(&p, "person", "is", json!("ANA LIMA"))); + assert!(!m(&p, "person", "is", json!("rex")), "a pet isn't a person"); + assert!(m(&p, "person", "notContains", json!("rex"))); + assert!(m(&p, "person", "isNotEmpty", json!(null))); + assert!(m(&p, "personCount", "is", json!(2)), "Ana Lima once, Bo; not the pet or the unnamed face"); + assert_eq!(field_group("person"), Some("Keywords & People")); + } + /// 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] @@ -581,7 +646,7 @@ mod tests { same(&["fileName", "filePath", "kind", "format"], "File"); same(&["camera", "lens", "focalLength", "aperture", "iso"], "Camera Info"); same(&["captureDate", "importDate", "editDate"], "Date"); - same(&["keywords"], "Keywords & People"); + same(&["keywords", "keywordCount", "person", "personCount"], "Keywords & People"); same(&["title", "caption", "creator", "copyright", "copyrightStatus"], "Description"); same(&["location", "hasGps"], "Location"); for f in ["rating", "flag", "label", "text"] { diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index 98918414..c3344983 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -60,9 +60,10 @@ fn number_value(ui: &mut egui::Ui, v: &mut Value, field: &str) { "rating" => (0.0, 5.0, 0.05), "iso" => (0.0, 409_600.0, 10.0), "aperture" => (0.0, 64.0, 0.05), + "keywordCount" | "personCount" => (0.0, 1000.0, 0.05), _ => (0.0, 100_000.0, 0.5), }; - let whole = matches!(field, "rating" | "iso" | "album"); + let whole = matches!(field, "rating" | "iso" | "album" | "keywordCount" | "personCount"); let dv = egui::DragValue::new(&mut n).range(lo..=hi).speed(speed); let dv = if whole { dv.fixed_decimals(0) } else { dv.max_decimals(2) }; if ui.add(dv).changed() { From 9101757bfb7f2ebcf4f85f3fc9d0b7f03dbb26de Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:02:23 -0400 Subject: [PATCH 04/65] LRC-LIB-SMARTCOLL: Shutter Speed rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Camera Info gains Shutter Speed, compared in seconds and written as the camera shows it: "≤ 1/60" finds faster exposures, "between 1/1000 and 1/125" works, and a value that isn't a time matches nothing. The editor takes it as text (1/250, 0.5, 2"). The shutter parser moves from engine export into the catalog (parse_shutter_seconds) so export and the rule share it. A new rules-editor test checks that every field × operator default value makes a valid rule. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/model.rs | 11 ++++++ crates/catalog/src/rules.rs | 48 ++++++++++++++++++++++- crates/engine/src/export.rs | 12 +----- crates/ui-egui/src/panels/rules_editor.rs | 37 ++++++++++++++++- 4 files changed, 94 insertions(+), 14 deletions(-) diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index d5a64922..584a35ef 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -152,6 +152,17 @@ impl CopyrightStatus { } } +/// A shutter speed as cameras write it (`1/250`, `0.5`, `2"`, `30s`) in seconds; `None` for +/// anything that isn't a positive, finite time. +pub fn parse_shutter_seconds(s: &str) -> Option { + let s = s.trim().trim_end_matches(['s', '"']).trim(); + match s.split_once('/') { + Some((n, d)) => Some(n.trim().parse::().ok()? / d.trim().parse::().ok()?), + None => s.parse().ok(), + } + .filter(|v: &f64| v.is_finite() && *v > 0.0) +} + /// Descriptive + capture metadata. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index cacc9c2d..bc318a3f 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -88,6 +88,7 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[ ("lens", "Lens", Kind::Text), ("focalLength", "Focal Length", Kind::Number), ("aperture", "Aperture", Kind::Number), + ("shutterSpeed", "Shutter Speed", Kind::Number), ("iso", "ISO Speed", Kind::Number), // Location ("location", "Location", Kind::Text), @@ -112,7 +113,7 @@ pub const FIELD_GROUPS: &[(&str, &[&str])] = &[ ("Date", &["captureDate", "importDate", "editDate"]), ("Keywords & People", &["keywords", "keywordCount", "person", "personCount"]), ("Description", &["title", "caption", "creator", "copyright", "copyrightStatus"]), - ("Camera Info", &["camera", "lens", "focalLength", "aperture", "iso"]), + ("Camera Info", &["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"]), ("Location", &["location", "hasGps"]), ("Size", &["megapixels"]), ("Develop", &["edited"]), @@ -259,6 +260,19 @@ fn num_op(op: &str, have: Option, value: &Value) -> bool { } } +/// A shutter-speed rule's value in seconds: camera notation (`1/250`) or a number; an operand +/// that isn't a time becomes `null`, which matches nothing. +fn shutter_value(value: &Value) -> Value { + let secs = |v: &Value| match v { + Value::String(s) => crate::parse_shutter_seconds(s).map_or(Value::Null, Value::from), + other => number(other).filter(|n| n.is_finite() && *n > 0.0).map_or(Value::Null, Value::from), + }; + match value { + Value::Array(a) => Value::Array(a.iter().map(secs).collect()), + v => secs(v), + } +} + /// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days. fn last_secs(value: &Value) -> Option { let (n, unit) = match value { @@ -387,6 +401,7 @@ impl Rule { "captureDate" => date_op(op, p.captured.as_deref(), value), "importDate" => date_op(op, Some(&p.imported), value), "editDate" => date_op(op, p.edited.as_deref(), value), + "shutterSpeed" => num_op(op, crate::parse_shutter_seconds(&m.shutter), &shutter_value(value)), "iso" => num_op(op, m.iso.map(|v| v as f64), value), "aperture" => num_op(op, m.aperture.map(|v| v as f64), value), "focalLength" => num_op(op, m.focal_mm.map(|v| v as f64), value), @@ -619,6 +634,35 @@ mod tests { assert_eq!(field_group("person"), Some("Keywords & People")); } + /// Shutter Speed compares exposure times in seconds, written as the camera shows them: a + /// photo at 1/250 is in "≤ 1/60" (faster) and not in "≥ 1/60"; "between 1/1000 and 1/125". + #[test] + fn shutter_speed_rules() { + let cat = Catalog::new(); + let mut p = photo(1); + let m = |p: &Photo, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": "shutterSpeed", "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(!m(&p, "gte", json!(0)), "no shutter speed recorded"); + p.meta.shutter = "1/250".into(); + assert!(m(&p, "is", json!("1/250")) && m(&p, "lte", json!("1/60")) && !m(&p, "gte", json!("1/60"))); + assert!(m(&p, "between", json!(["1/1000", "1/125"])) && m(&p, "lt", json!(0.01))); + assert!(!m(&p, "is", json!("1/0")) && !m(&p, "is", json!("fast")), "a value that isn't a time matches nothing"); + p.meta.shutter = "2\"".into(); + assert!(m(&p, "gte", json!("1")) && m(&p, "is", json!(2))); + assert_eq!(field_group("shutterSpeed"), Some("Camera Info")); + } + + #[test] + fn shutter_seconds_parses_camera_notation() { + for (s, want) in [("1/250", Some(0.004)), (" 1/250 s", Some(0.004)), ("0.5", Some(0.5)), ("2\"", Some(2.0)), ("30s", Some(30.0))] { + assert_eq!(crate::parse_shutter_seconds(s), want, "{s}"); + } + for s in ["", "1/0", "0", "-1/250", "fast", "inf", "NaN", "1/x"] { + assert_eq!(crate::parse_shutter_seconds(s), None, "{s}"); + } + } + /// 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] @@ -644,7 +688,7 @@ mod tests { } }; same(&["fileName", "filePath", "kind", "format"], "File"); - same(&["camera", "lens", "focalLength", "aperture", "iso"], "Camera Info"); + same(&["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"], "Camera Info"); same(&["captureDate", "importDate", "editDate"], "Date"); same(&["keywords", "keywordCount", "person", "personCount"], "Keywords & People"); same(&["title", "caption", "creator", "copyright", "copyrightStatus"], "Description"); diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 33366285..b9a531eb 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1231,16 +1231,6 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) r.map_err(|e| e.to_string()) } -/// Parse a shutter speed such as `1/250`, `0.5` or `2"` into seconds. -fn parse_shutter(s: &str) -> Option { - let s = s.trim().trim_end_matches(['s', '"']).trim(); - match s.split_once('/') { - Some((n, d)) => Some(n.trim().parse::().ok()? / d.trim().parse::().ok()?), - None => s.parse().ok(), - } - .filter(|v: &f64| v.is_finite() && *v > 0.0) -} - /// The metadata to embed for `photo` under `o.metadata` / `o.remove_location`. `None` = embed nothing. pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> Option { let m = &photo.meta; @@ -1283,7 +1273,7 @@ pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> out.lens_model = text(&m.lens); out.focal_length = m.focal_mm.map(f64::from); out.f_number = m.aperture.map(f64::from); - out.exposure_time = parse_shutter(&m.shutter); + out.exposure_time = lightcraft_catalog::parse_shutter_seconds(&m.shutter); out.iso = m.iso; } Some(out) diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index c3344983..ca01e6f8 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -15,6 +15,8 @@ pub fn default_value(field: &str, op: &str) -> Value { (_, "isEmpty" | "isNotEmpty") => Value::Null, (Some(Kind::Date), "inLast" | "notInLast") => json!({"n": 30, "unit": "days"}), (Some(Kind::Date), "between") => json!(["", ""]), + (Some(Kind::Number), "between") if field == "shutterSpeed" => json!(["1/1000", "1/125"]), + (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("")), @@ -97,7 +99,11 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & *v = default_value(field, op); } let Value::Array(a) = v else { return }; - if k == Kind::Number { + if field == "shutterSpeed" { + text_value(ui, &mut a[0], 60.0, "1/1000", &format!("{salt}-a")); + ui.label(crate::i18n::tr("and")); + text_value(ui, &mut a[1], 60.0, "1/125", &format!("{salt}-b")); + } else if k == Kind::Number { number_value(ui, &mut a[0], field); ui.label(crate::i18n::tr("and")); number_value(ui, &mut a[1], field); @@ -107,6 +113,8 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & text_value(ui, &mut a[1], 86.0, "2026-12", &format!("{salt}-b")); } } + // camera notation: 1/250, 0.5, 2" + (Some(Kind::Number), _) if field == "shutterSpeed" => text_value(ui, v, 70.0, "1/250", salt), (Some(Kind::Number), _) => number_value(ui, v, field), (Some(Kind::Choice(c)), _) => { let cur = v.as_str().unwrap_or("").to_string(); @@ -263,3 +271,30 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) { }); } } + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_catalog::rules::FIELDS; + use lightcraft_catalog::{Catalog, Photo, PhotoId, Source}; + + /// Every field × operator starts with a value the rule accepts; a new shutter-speed rule starts + /// at a real time in camera notation. + #[test] + fn default_values_make_valid_rules() { + let cat = Catalog::new(); + let p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "JPEG", 10, 10, "2026-10-01T00:00:00"); + for (field, _, kind) in FIELDS { + for (op, _) in ops_for(*kind) { + let rs = RuleSet { + mode: Match::All, + rules: vec![Rule::Field { field: field.to_string(), op: op.to_string(), value: default_value(field, op) }], + }; + assert!(rs.problems().is_empty(), "{field} {op}: {:?}", rs.problems()); + let _ = rs.matches(&p, &cat); + } + } + assert_eq!(default_value("shutterSpeed", "gte"), json!("1/250")); + assert_eq!(default_value("shutterSpeed", "between"), json!(["1/1000", "1/125"])); + } +} From 5fa77aa8782b07c4436f0049de224c9706adc0a6 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:05:28 -0400 Subject: [PATCH 05/65] LRC-LIB-SMARTCOLL: file, size, location, description and develop rules New smart-album fields, each in its menu group: - Source: Copy Name, In a Stack - File: File Extension (case and leading dot ignored), Video Duration - Description: Alt Text - Location: City, State / Province, Country (Location still matches all) - Size: Long Edge, Short Edge, Aspect Ratio (landscape / portrait / square within 1 %), measured on the photo as shown (oriented and cropped) - Develop: Cropped (crop or straighten), Treatment (color / monochrome) Photo::extension, Photo::shown_size and Photo::is_cropped hold the domain rules. A crop rectangle that is NaN or outside the frame is clamped, so hostile settings can't produce NaN sizes. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/catalog/Cargo.toml | 1 + crates/catalog/src/model.rs | 20 +++ crates/catalog/src/rules.rs | 155 ++++++++++++++++++++-- crates/ui-egui/src/panels/rules_editor.rs | 3 +- 5 files changed, 170 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 397094ed..0a544274 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2998,6 +2998,7 @@ version = "0.4.0" dependencies = [ "crc32fast", "lightcraft-develop", + "lightcraft-geom", "lightcraft-meta", "log", "proptest", diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index c70c2831..604fb039 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -19,4 +19,5 @@ log = { workspace = true } web-time = { workspace = true } [dev-dependencies] +lightcraft-geom = { workspace = true } proptest = { workspace = true } diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 584a35ef..f03772a3 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -376,6 +376,26 @@ impl Photo { } d } + /// The file name's extension without the dot, as written (`CR2` for `IMG_0042.CR2`); empty + /// when the name has none. + pub fn extension(&self) -> &str { + self.file_name.rsplit_once('.').map_or("", |(_, ext)| ext) + } + /// Width and height in pixels as the photo is shown: turned by its orientation and cut by its + /// crop. Straightening and Constrain Crop shrink the crop without changing its shape, so the + /// aspect is exact and the edges can read a little larger than an export. + pub fn shown_size(&self) -> (f64, f64) { + let (w, h) = (self.width as f64, self.height as f64); + let (w, h) = if self.develop.orientation.swaps_axes() { (h, w) } else { (w, h) }; + let r = self.develop.crop.geometry.rect; + // a crop read from a file or an agent is hostile: no NaN, nothing outside the frame + let part = |len: f64| if len.is_finite() { len.clamp(0.0, 1.0) } else { 1.0 }; + (w * part(r.width()), h * part(r.height())) + } + /// Cropped or straightened. + pub fn is_cropped(&self) -> bool { + self.develop.crop.geometry != lightcraft_develop::Crop::default().geometry + } /// How many keywords the photo has: a hierarchical keyword (`travel|italy|rome`) is one, and /// the same keyword in different case or with stray spaces counts once; blank ones don't count. pub fn keyword_count(&self) -> usize { diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index bc318a3f..6e97fc66 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -63,11 +63,15 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[ // Source ("album", "Album", Kind::Number), ("virtualCopy", "Virtual Copy", Kind::Bool), + ("copyName", "Copy Name", Kind::Text), + ("stacked", "In a Stack", Kind::Bool), // File ("fileName", "Filename", Kind::Text), + ("extension", "File Extension", Kind::Text), ("filePath", "File Path", Kind::Text), ("kind", "File Type", Kind::Choice(&["image", "raw", "video"])), ("format", "File Format", Kind::Text), + ("duration", "Video Duration", Kind::Number), // Date ("captureDate", "Capture Date", Kind::Date), ("importDate", "Import Date", Kind::Date), @@ -80,6 +84,7 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[ // Description ("title", "Title", Kind::Text), ("caption", "Caption", Kind::Text), + ("altText", "Alt Text", Kind::Text), ("creator", "Creator", Kind::Text), ("copyright", "Copyright", Kind::Text), ("copyrightStatus", "Copyright Status", Kind::Choice(&["copyrighted", "publicDomain", "unknown"])), @@ -92,11 +97,19 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[ ("iso", "ISO Speed", Kind::Number), // Location ("location", "Location", Kind::Text), + ("city", "City", Kind::Text), + ("state", "State / Province", Kind::Text), + ("country", "Country", Kind::Text), ("hasGps", "Has GPS", Kind::Bool), // Size + ("longEdge", "Long Edge", Kind::Number), + ("shortEdge", "Short Edge", Kind::Number), + ("aspect", "Aspect Ratio", Kind::Choice(&["landscape", "portrait", "square"])), ("megapixels", "Megapixels", Kind::Number), // Develop ("edited", "Has Edits", Kind::Bool), + ("cropped", "Cropped", Kind::Bool), + ("treatment", "Treatment", Kind::Choice(&["color", "monochrome"])), // Assisted Culling ("sharpness", "Focus (assisted culling)", Kind::Number), ("bestOfGroup", "Best of Similar Shots", Kind::Bool), @@ -108,15 +121,15 @@ 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"]), + ("Source", &["album", "virtualCopy", "copyName", "stacked"]), + ("File", &["fileName", "extension", "filePath", "kind", "format", "duration"]), ("Date", &["captureDate", "importDate", "editDate"]), ("Keywords & People", &["keywords", "keywordCount", "person", "personCount"]), - ("Description", &["title", "caption", "creator", "copyright", "copyrightStatus"]), + ("Description", &["title", "caption", "altText", "creator", "copyright", "copyrightStatus"]), ("Camera Info", &["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"]), - ("Location", &["location", "hasGps"]), - ("Size", &["megapixels"]), - ("Develop", &["edited"]), + ("Location", &["location", "city", "state", "country", "hasGps"]), + ("Size", &["longEdge", "shortEdge", "aspect", "megapixels"]), + ("Develop", &["edited", "cropped", "treatment"]), ("Assisted Culling", &["sharpness", "bestOfGroup"]), ]; @@ -260,6 +273,20 @@ fn num_op(op: &str, have: Option, value: &Value) -> bool { } } +/// `landscape`, `portrait` or `square` (edges within 1 %) for a `w × h` frame; `None` when it has no area. +fn aspect((w, h): (f64, f64)) -> Option<&'static str> { + if !(w > 0.0 && h > 0.0) { + return None; + } + Some(if (w - h).abs() <= 0.01 * w.max(h) { + "square" + } else if w > h { + "landscape" + } else { + "portrait" + }) +} + /// A shutter-speed rule's value in seconds: camera notation (`1/250`) or a number; an operand /// that isn't a time becomes `null`, which matches nothing. fn shutter_value(value: &Value) -> Value { @@ -351,6 +378,13 @@ impl Rule { "edited" => p.is_edited() == value.as_bool().unwrap_or(true), "hasGps" => m.gps.is_some() == value.as_bool().unwrap_or(true), "virtualCopy" => p.copy_of.is_some() == value.as_bool().unwrap_or(true), + "copyName" => text(p.copy_name.as_deref().unwrap_or("")), + "stacked" => cat.stack_of(p.id).is_some() == value.as_bool().unwrap_or(true), + "cropped" => p.is_cropped() == value.as_bool().unwrap_or(true), + "treatment" => { + let t = if p.develop.treatment == lightcraft_develop::Treatment::Bw { "monochrome" } else { "color" }; + (t == want) == (op == "is") + } "keywords" => names_op(op, &m.keywords, &want), "keywordCount" => num_op(op, Some(p.keyword_count() as f64), value), "person" => names_op(op, &p.people(), &want), @@ -372,6 +406,8 @@ impl Rule { text(&all) } "fileName" => text(&p.file_name), + "extension" => text_op(op, p.extension(), want.trim_start_matches('.')), + "duration" => num_op(op, p.duration.filter(|d| d.is_finite()), value), "filePath" => { // `/` and `\` both separate, whatever platform the catalog came from; demo photos have no path let path = match &p.source { @@ -389,6 +425,10 @@ impl Rule { "format" => text(&p.format), "title" => text(&m.title), "caption" => text(&m.caption), + "altText" => text(&m.alt_text), + "city" => text(&m.city), + "state" => text(&m.state), + "country" => text(&m.country), "camera" => text(&m.camera), "lens" => text(&m.lens), "location" => text(&[m.location.as_str(), &m.city, &m.state, &m.country].join(" ")), @@ -405,6 +445,11 @@ impl Rule { "iso" => num_op(op, m.iso.map(|v| v as f64), value), "aperture" => num_op(op, m.aperture.map(|v| v as f64), value), "focalLength" => num_op(op, m.focal_mm.map(|v| v as f64), value), + "longEdge" | "shortEdge" => { + let (w, h) = p.shown_size(); + num_op(op, Some(if field == "longEdge" { w.max(h) } else { w.min(h) }.round()), value) + } + "aspect" => (aspect(p.shown_size()) == Some(want.as_str())) == (op == "is"), "megapixels" => num_op(op, Some(p.width as f64 * p.height as f64 / 1e6), value), "sharpness" => num_op(op, p.analysis.map(|a| a.sharpness as f64), value), "bestOfGroup" => p.analysis.is_some_and(|a| a.best || a.group.is_none()) == value.as_bool().unwrap_or(true), @@ -663,6 +708,95 @@ mod tests { } } + /// File Extension ignores case and a leading dot; Copy Name, Alt Text, City, State / + /// Province and Country are text fields of their own (Location still matches all of them). + #[test] + fn file_source_description_and_location_rules() { + let cat = Catalog::new(); + let mut p = photo(1); // IMG_0042.CR2 + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "extension", "is", json!("cr2")) && m(&p, "extension", "is", json!(".CR2"))); + assert!(!m(&p, "extension", "is", json!("jpg"))); + p.file_name = "README".into(); + assert!(m(&p, "extension", "isEmpty", json!(null)), "no dot, no extension"); + p.file_name = "archive.tar.gz".into(); + assert!(m(&p, "extension", "is", json!("gz"))); + assert!(m(&p, "copyName", "isEmpty", json!(null))); + p.copy_name = Some("Black and white".into()); + assert!(m(&p, "copyName", "contains", json!("black"))); + p.meta.alt_text = "A red kite over a hill".into(); + assert!(m(&p, "altText", "contains", json!("kite"))); + p.meta.city = "Porto".into(); + p.meta.state = "Porto District".into(); + p.meta.country = "Portugal".into(); + assert!(m(&p, "city", "is", json!("porto")) && m(&p, "state", "startsWith", json!("porto")) && m(&p, "country", "is", json!("portugal"))); + assert!(!m(&p, "city", "is", json!("portugal")), "City tests only the city"); + assert!(m(&p, "location", "contains", json!("portugal")), "Location still covers the country"); + } + + /// In a Stack, Video Duration (seconds; photos have none), Cropped and Treatment. + #[test] + fn source_file_and_develop_rules() { + use crate::{Op, Stack, StackId}; + let mut cat = Catalog::new(); + let a = photo(1); + let b = photo(2); + let c = photo(3); + for p in [&a, &b, &c] { + cat.apply(Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap(); + } + cat.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![a.id, b.id], collapsed: false } }).unwrap(); + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&a, "stacked", "is", json!(true)) && m(&b, "stacked", "is", json!(true))); + assert!(m(&c, "stacked", "is", json!(false))); + let mut v = photo(4); + assert!(!m(&v, "duration", "gte", json!(0)), "a photo has no duration"); + v.duration = Some(95.0); + assert!(m(&v, "duration", "gt", json!(60)) && m(&v, "duration", "between", json!([90, 120]))); + let mut d = photo(5); + assert!(m(&d, "cropped", "is", json!(false)) && m(&d, "treatment", "is", json!("color"))); + let mut s = (*d.develop).clone(); + s.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.0, 0.9, 1.0); + s.treatment = lightcraft_develop::Treatment::Bw; + d.develop = std::sync::Arc::new(s); + assert!(m(&d, "cropped", "is", json!(true)) && m(&d, "treatment", "is", json!("monochrome"))); + assert!(m(&d, "treatment", "isNot", json!("color"))); + let mut s = (*d.develop).clone(); + s.crop = Default::default(); + s.crop.geometry.angle = 2.0; + d.develop = std::sync::Arc::new(s); + assert!(m(&d, "cropped", "is", json!(true)), "straightening counts as a crop"); + } + + /// Size follows the photo as shown: rotated a quarter turn a landscape frame is a portrait, and + /// a crop changes its edges. Long / Short Edge are in pixels. + #[test] + fn size_rules_follow_orientation_and_crop() { + let cat = Catalog::new(); + let mut p = photo(1); // 6000 × 4000 + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "aspect", "is", json!("landscape")) && m(&p, "longEdge", "is", json!(6000)) && m(&p, "shortEdge", "is", json!(4000))); + let mut s = (*p.develop).clone(); + s.orientation = lightcraft_geom::Orientation::Rotate90; + p.develop = std::sync::Arc::new(s.clone()); + assert!(m(&p, "aspect", "is", json!("portrait")) && m(&p, "longEdge", "is", json!(6000))); + // a square crop of the rotated frame: 4000 wide, 4000 of its 6000 tall + s.crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 1.0 / 6.0, 1.0, 5.0 / 6.0); + p.develop = std::sync::Arc::new(s); + assert!(m(&p, "aspect", "is", json!("square")) && m(&p, "longEdge", "is", json!(4000))); + assert!(m(&p, "aspect", "isNot", json!("portrait"))); + let empty = Photo::new(PhotoId(9), Source::Demo { scene: 0 }, "x.jpg", "JPEG", 0, 0, "2026-09-20T10:00:00"); + assert!( + !m(&empty, "aspect", "is", json!("landscape")) && !m(&empty, "aspect", "is", json!("square")) && m(&empty, "longEdge", "is", json!(0)) + ); + } + /// 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] @@ -687,12 +821,15 @@ mod tests { assert_eq!(field_group(f), Some(group), "{f}"); } }; - same(&["fileName", "filePath", "kind", "format"], "File"); + same(&["fileName", "extension", "filePath", "kind", "format", "duration"], "File"); + same(&["album", "virtualCopy", "copyName", "stacked"], "Source"); + same(&["longEdge", "shortEdge", "aspect", "megapixels"], "Size"); + same(&["edited", "cropped", "treatment"], "Develop"); same(&["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"], "Camera Info"); same(&["captureDate", "importDate", "editDate"], "Date"); same(&["keywords", "keywordCount", "person", "personCount"], "Keywords & People"); - same(&["title", "caption", "creator", "copyright", "copyrightStatus"], "Description"); - same(&["location", "hasGps"], "Location"); + same(&["title", "caption", "altText", "creator", "copyright", "copyrightStatus"], "Description"); + same(&["location", "city", "state", "country", "hasGps"], "Location"); for f in ["rating", "flag", "label", "text"] { assert_eq!(field_group(f), None, "{f} stays at the top level"); } diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index ca01e6f8..403113ad 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -63,9 +63,10 @@ fn number_value(ui: &mut egui::Ui, v: &mut Value, field: &str) { "iso" => (0.0, 409_600.0, 10.0), "aperture" => (0.0, 64.0, 0.05), "keywordCount" | "personCount" => (0.0, 1000.0, 0.05), + "longEdge" | "shortEdge" => (0.0, 100_000.0, 10.0), _ => (0.0, 100_000.0, 0.5), }; - let whole = matches!(field, "rating" | "iso" | "album" | "keywordCount" | "personCount"); + let whole = matches!(field, "rating" | "iso" | "album" | "keywordCount" | "personCount" | "longEdge" | "shortEdge"); let dv = egui::DragValue::new(&mut n).range(lo..=hi).speed(speed); let dv = if whole { dv.fixed_decimals(0) } else { dv.max_decimals(2) }; if ui.add(dv).changed() { From 5d86976b821dd27fdbd590180a4eb712e3bcff20 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:05:55 -0400 Subject: [PATCH 06/65] LRC-LIB-SMARTCOLL: Any Searchable Text covers state, alt text, people, copy name The any-text rule already searched the city and country but skipped the state / province. It now also searches the alt text, the people on faces and a virtual copy's name, so it agrees with the dedicated fields. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/rules.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 6e97fc66..8975ce3b 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -398,9 +398,13 @@ impl Rule { &m.lens, &m.location, &m.city, + &m.state, &m.country, + &m.alt_text, &p.format, &m.keywords.join(" "), + &p.people().join(" "), + p.copy_name.as_deref().unwrap_or(""), ] .join(" "); text(&all) @@ -797,6 +801,30 @@ mod tests { ); } + /// Any Searchable Text also finds the state / province, the alt text, a person on a face and + /// a virtual copy's name, as it already finds the city and country. + #[test] + fn any_searchable_text_covers_new_fields() { + let cat = Catalog::new(); + let mut p = photo(1); + let m = |p: &Photo, value: &str| rs(json!({"rules": [{"field": "text", "op": "contains", "value": value}]})).matches(p, &cat); + for word in ["oregon", "kite", "ana", "bluish"] { + assert!(!m(&p, word), "{word}"); + } + p.meta.state = "Oregon".into(); + p.meta.alt_text = "A kite".into(); + p.meta.regions = vec![lightcraft_meta::Region { + rect: lightcraft_meta::Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: Some("Ana".into()), + description: None, + }]; + p.copy_name = Some("Bluish".into()); + for word in ["oregon", "kite", "ana", "bluish"] { + assert!(m(&p, word), "{word}"); + } + } + /// 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] From 5d4eea5f7df04f8d28b2b20d17486c23337534cb Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:06:55 -0400 Subject: [PATCH 07/65] LRC-LIB-SMARTCOLL: document grouped rule menu and new fields parity.md: LRC-LIB-SMARTCOLL lists the 47 fields by menu group and what Classic has that is still missing. LR-LIB-SMARTALBUM no longer claims "match-all only, no rule editor". What's New describes the grouped menu and the new fields. Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 4 ++-- docs/whats-new.md | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index 4b992c99..66cbe984 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -157,7 +157,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | | LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP) | | LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | -| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`); sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists | +| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | | LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 30 fields (rating, flag, label, type, edits, keywords, any text, filename, file path, format, title, caption, camera, lens, location, creator, copyright, copyright status, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge and aspect ratio of the oriented, cropped photo; megapixels), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections; English shows camelCase choice ids (publicDomain) | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | diff --git a/docs/whats-new.md b/docs/whats-new.md index 96b10a97..5154482d 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,15 @@ ## October 2026 +### Smart album rules +- The rule editor's field menu is grouped: Rating, Pick Flag, Color Label and Any Searchable Text at the top, then + submenus for Source, File, Date, Keywords & People, Description, Camera Info, Location, Size, Develop and Assisted + Culling. +- New fields: Keyword Count ("2 or more", "exactly 2"…), People and People Count (named faces), Shutter Speed (written + as 1/250), File Extension, Video Duration, Copy Name, In a Stack, Alt Text, City, State / Province, Country, Long + Edge, Short Edge, Aspect Ratio (landscape / portrait / square), Cropped and Treatment (color / monochrome). +- Any Searchable Text also finds the state / province, alt text, people and a virtual copy's name. + ### Albums tree - Album folders fold with the same disclosure triangle as Local, By Date, Folders and Keywords. - Right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… From c755656bfbeecc5464ea8774ffa3c219a50de518 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:13:25 -0400 Subject: [PATCH 08/65] LRC-LIB-SMARTCOLL: dot files have no extension; reuse CropGeometry::is_identity From review: `.hidden` reported the extension "hidden", so it matched "File Extension is hidden". A name whose only dot leads has no extension now. Photo::is_cropped uses the existing CropGeometry::is_identity instead of comparing against a default crop. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/model.rs | 6 +++--- crates/catalog/src/rules.rs | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index f03772a3..153c715e 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -377,9 +377,9 @@ impl Photo { d } /// The file name's extension without the dot, as written (`CR2` for `IMG_0042.CR2`); empty - /// when the name has none. + /// when the name has none (`README`, a dot file such as `.hidden`). pub fn extension(&self) -> &str { - self.file_name.rsplit_once('.').map_or("", |(_, ext)| ext) + self.file_name.rsplit_once('.').filter(|(stem, _)| !stem.is_empty()).map_or("", |(_, ext)| ext) } /// Width and height in pixels as the photo is shown: turned by its orientation and cut by its /// crop. Straightening and Constrain Crop shrink the crop without changing its shape, so the @@ -394,7 +394,7 @@ impl Photo { } /// Cropped or straightened. pub fn is_cropped(&self) -> bool { - self.develop.crop.geometry != lightcraft_develop::Crop::default().geometry + !self.develop.crop.geometry.is_identity() } /// How many keywords the photo has: a hierarchical keyword (`travel|italy|rome`) is one, and /// the same keyword in different case or with stray spaces counts once; blank ones don't count. diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 8975ce3b..cb2f1981 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -727,6 +727,8 @@ mod tests { assert!(m(&p, "extension", "isEmpty", json!(null)), "no dot, no extension"); p.file_name = "archive.tar.gz".into(); assert!(m(&p, "extension", "is", json!("gz"))); + p.file_name = ".hidden".into(); + assert!(m(&p, "extension", "isEmpty", json!(null)), "a dot file has no extension"); assert!(m(&p, "copyName", "isEmpty", json!(null))); p.copy_name = Some("Black and white".into()); assert!(m(&p, "copyName", "contains", json!("black"))); From 6b46c9865428c75599a7856edae8a7c7c415f5f0 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:13:25 -0400 Subject: [PATCH 09/65] LRC-LIB-SMARTCOLL: stronger rule-editor tests The field-menu UI test now clicks a group row instead of hovering it, guarding against the combo popup closing on that click (touch input, fast clicks). default_values_make_valid_rules now checks each default value's shape (none, pair, choice, number, a real shutter time) as its earlier commit message claimed; before, it only checked operators. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/rules_editor.rs | 19 +++++++++++++++++-- crates/ui-egui/src/tests_masking.rs | 3 ++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index 403113ad..f3425b18 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -279,8 +279,9 @@ mod tests { use lightcraft_catalog::rules::FIELDS; use lightcraft_catalog::{Catalog, Photo, PhotoId, Source}; - /// Every field × operator starts with a value the rule accepts; a new shutter-speed rule starts - /// at a real time in camera notation. + /// Every field × operator starts with a known operator and a value of the right shape: none for + /// the empty tests, a pair for "between", one of the choices, a number for number fields (a + /// real time in camera notation for shutter speed); and evaluating it never panics. #[test] fn default_values_make_valid_rules() { let cat = Catalog::new(); @@ -293,6 +294,20 @@ mod tests { }; assert!(rs.problems().is_empty(), "{field} {op}: {:?}", rs.problems()); 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::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(), + Kind::Text | Kind::Keywords | Kind::Date => v.is_string(), + }; + match *op { + "isEmpty" | "isNotEmpty" => assert!(v.is_null(), "{field} {op}: {v}"), + "between" => assert!(v.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(one)), "{field} {op}: {v}"), + "inLast" | "notInLast" => assert!(v["n"].is_number(), "{field} {op}: {v}"), + _ => assert!(one(&v), "{field} {op}: {v}"), + } } } assert_eq!(default_value("shutterSpeed", "gte"), json!("1/250")); diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 5358f371..cbafe8f1 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -311,7 +311,8 @@ fn smart_album_field_menu_groups_fields_in_submenus() { assert!(has(&h, &format!("ruleFieldGroup:{group}:rules-0")), "no {group} submenu"); } assert!(!has(&h, "ruleFieldItem:filePath:rules-0"), "grouped fields wait in their submenu"); - let r = h.request("ui.hoverWidget", json!({"id": "ruleFieldGroup:File:rules-0"}), T); + // clicking a group row (a touch, or a click faster than hover) opens it, not closes the menu + let r = h.request("ui.clickWidget", json!({"id": "ruleFieldGroup:File:rules-0"}), T); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); let r = h.request("ui.clickWidget", json!({"id": "ruleFieldItem:filePath:rules-0"}), T); From 12e1105861ae7d2848dba51b0ea48250b04a1bc0 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:29:48 -0400 Subject: [PATCH 10/65] 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 --- crates/catalog/src/rules.rs | 59 +++++++++++++++++++---- crates/engine/src/cmd/library.rs | 5 +- crates/engine/src/tests_library.rs | 4 ++ crates/ui-egui/locales/de.json | 9 +++- crates/ui-egui/locales/es.json | 9 +++- crates/ui-egui/locales/ja.json | 9 +++- crates/ui-egui/locales/pt-br.json | 9 +++- crates/ui-egui/locales/ru.json | 10 +++- crates/ui-egui/locales/zh-hans.json | 9 +++- crates/ui-egui/locales/zh-hant.json | 9 +++- crates/ui-egui/src/i18n.rs | 48 ++++++++++++++++-- crates/ui-egui/src/panels/rules_editor.rs | 19 +++++--- 12 files changed, 170 insertions(+), 29 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index cb2f1981..ca6c4795 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 6abb0930..d20745dd 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 b4621e97..f3650803 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 64a485ed..8ae445bd 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 461fa2f6..82d8441b 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 49f2ef80..9357aa94 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 81e331c5..bdbc7b57 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 382bfc05..bc5e64ad 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 4074dfbd..9a0302a9 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 96fcbb18..3e96dc8c 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 3cdd83b9..d86c81d0 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 f3425b18..697f7125 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(), From 26df5ab60b879db249932e3febd5b6726e16a263 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:29:48 -0400 Subject: [PATCH 11/65] LRC-LIB-SMARTCOLL: document readable choice labels Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 2 +- docs/whats-new.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index 66cbe984..0fb2d287 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge and aspect ratio of the oriented, cropped photo; megapixels), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections; English shows camelCase choice ids (publicDomain) | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge and aspect ratio of the oriented, cropped photo; megapixels), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | diff --git a/docs/whats-new.md b/docs/whats-new.md index 5154482d..ed6589c6 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -8,7 +8,9 @@ Culling. - New fields: Keyword Count ("2 or more", "exactly 2"…), People and People Count (named faces), Shutter Speed (written as 1/250), File Extension, Video Duration, Copy Name, In a Stack, Alt Text, City, State / Province, Country, Long - Edge, Short Edge, Aspect Ratio (landscape / portrait / square), Cropped and Treatment (color / monochrome). + Edge, Short Edge, Aspect Ratio (landscape / portrait / square), Cropped and Treatment (color / black & white). +- Choice values read as words in every language: "Public Domain", "Picked", "Black & White" rather than ids like + `publicDomain` (rules and agents keep using the ids; `album.ruleFields` adds `choiceLabels`). - Any Searchable Text also finds the state / province, alt text, people and a virtual copy's name. ### Albums tree From 4c826246c3dee9cb848ab321c8658c24d7f5fa89 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:33:10 -0400 Subject: [PATCH 12/65] LRC-LIB-SMARTCOLL: own labels for treatment Color and label None MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review: the treatment choice "Color" reused the shared "Color" key, which zh-hans and ru translate as the colour noun (颜色, Цвет) for the Color panel, giving "Treatment is colour". The colour-label choice "None" was masculine in Spanish (Ninguno) next to the feminine "etiqueta". They are now "In Color" and "No Label", translated in all seven locales (彩色, Цветное, Sin etiqueta…). Also moves display_label_gaps_are_reported's doc comment back onto it; the new choice test had been inserted between them. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/rules.rs | 8 ++++++-- crates/ui-egui/locales/de.json | 4 +++- crates/ui-egui/locales/es.json | 4 +++- crates/ui-egui/locales/ja.json | 4 +++- crates/ui-egui/locales/pt-br.json | 4 +++- crates/ui-egui/locales/ru.json | 4 +++- crates/ui-egui/locales/zh-hans.json | 4 +++- crates/ui-egui/locales/zh-hant.json | 4 +++- crates/ui-egui/src/i18n.rs | 11 +++++++---- docs/whats-new.md | 2 +- 10 files changed, 35 insertions(+), 14 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index ca6c4795..95c0c0ac 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -61,7 +61,7 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[ ( "label", "Color Label", - Kind::Choice(&[("red", "Red"), ("yellow", "Yellow"), ("green", "Green"), ("blue", "Blue"), ("purple", "Purple"), ("none", "None")]), + Kind::Choice(&[("red", "Red"), ("yellow", "Yellow"), ("green", "Green"), ("blue", "Blue"), ("purple", "Purple"), ("none", "No Label")]), ), ("text", "Any Searchable Text", Kind::Text), // Source @@ -117,7 +117,7 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[ // Develop ("edited", "Has Edits", Kind::Bool), ("cropped", "Cropped", Kind::Bool), - ("treatment", "Treatment", Kind::Choice(&[("color", "Color"), ("monochrome", "Black & White")])), + ("treatment", "Treatment", Kind::Choice(&[("color", "In Color"), ("monochrome", "Black & White")])), // Assisted Culling ("sharpness", "Focus (assisted culling)", Kind::Number), ("bestOfGroup", "Best of Similar Shots", Kind::Bool), @@ -848,6 +848,10 @@ mod tests { fn choices_have_readable_labels() { assert_eq!(choice_label("copyrightStatus", "publicDomain"), Some("Public Domain")); assert_eq!(choice_label("treatment", "monochrome"), Some("Black & White")); + // own labels where a shared word would translate wrongly: "Color" is the colour noun (the + // Color panel) and "None" agrees with other nouns in gendered languages + assert_eq!(choice_label("treatment", "color"), Some("In Color")); + assert_eq!(choice_label("label", "none"), Some("No Label")); assert_eq!(choice_label("flag", "pick"), Some("Picked")); assert_eq!(choice_label("aspect", "landscape"), Some("Landscape (wide)")); assert_eq!(choice_label("flag", "nope"), None); diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 8ae445bd..936a11ec 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1894,5 +1894,7 @@ "Unknown": "Unbekannt", "Landscape (wide)": "Querformat", "Portrait (tall)": "Hochformat", - "Square": "Quadratisch" + "Square": "Quadratisch", + "In Color": "Farbig", + "No Label": "Ohne Markierung" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 82d8441b..475744d2 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1739,5 +1739,7 @@ "Unknown": "Desconocido", "Landscape (wide)": "Horizontal (apaisada)", "Portrait (tall)": "Vertical (retrato)", - "Square": "Cuadrada" + "Square": "Cuadrada", + "In Color": "En color", + "No Label": "Sin etiqueta" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9357aa94..d38fe90f 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1739,5 +1739,7 @@ "Unknown": "不明", "Landscape (wide)": "横長", "Portrait (tall)": "縦長", - "Square": "正方形" + "Square": "正方形", + "In Color": "カラー", + "No Label": "ラベルなし" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index bdbc7b57..2bac81d4 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1739,5 +1739,7 @@ "Unknown": "Desconhecido", "Landscape (wide)": "Paisagem (horizontal)", "Portrait (tall)": "Retrato (vertical)", - "Square": "Quadrada" + "Square": "Quadrada", + "In Color": "Colorida", + "No Label": "Sem rótulo" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index bc5e64ad..cd36a04e 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1411,5 +1411,7 @@ "Unknown": "Неизвестно", "Landscape (wide)": "Альбомная", "Portrait (tall)": "Книжная", - "Square": "Квадратная" + "Square": "Квадратная", + "In Color": "Цветное", + "No Label": "Без метки" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 9a0302a9..e1d579ac 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1739,5 +1739,7 @@ "Unknown": "未知", "Landscape (wide)": "横向", "Portrait (tall)": "纵向", - "Square": "正方形" + "Square": "正方形", + "In Color": "彩色", + "No Label": "无标签" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 3e96dc8c..dc80a7b3 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1739,5 +1739,7 @@ "Unknown": "未知", "Landscape (wide)": "橫向", "Portrait (tall)": "直向", - "Square": "正方形" + "Square": "正方形", + "In Color": "彩色", + "No Label": "無標籤" } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index d86c81d0..2d869ecd 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -636,10 +636,6 @@ mod tests { } } - /// Every command, control, rule and rename label, and every line of What's New, is a message a - /// 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 @@ -650,6 +646,9 @@ mod tests { 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"); + assert_eq!(choice_text_in(Locale::ZhHans, "treatment", "color"), "彩色", "in colour, not the colour noun"); + assert_eq!(choice_text_in(Locale::Ru, "treatment", "color"), "Цветное"); + assert_eq!(choice_text_in(Locale::Es, "label", "none"), "Sin etiqueta"); for language in Locale::ALL { for (field, _, kind) in FIELDS { let Kind::Choice(choices) = kind else { continue }; @@ -664,6 +663,10 @@ mod tests { } } + /// Every command, control, rule and rename label, and every line of What's New, is a message a + /// 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. #[test] fn display_label_gaps_are_reported() { let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); diff --git a/docs/whats-new.md b/docs/whats-new.md index ed6589c6..eb44424a 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -8,7 +8,7 @@ Culling. - New fields: Keyword Count ("2 or more", "exactly 2"…), People and People Count (named faces), Shutter Speed (written as 1/250), File Extension, Video Duration, Copy Name, In a Stack, Alt Text, City, State / Province, Country, Long - Edge, Short Edge, Aspect Ratio (landscape / portrait / square), Cropped and Treatment (color / black & white). + Edge, Short Edge, Aspect Ratio (landscape / portrait / square), Cropped and Treatment (in color / black & white). - Choice values read as words in every language: "Public Domain", "Picked", "Black & White" rather than ids like `publicDomain` (rules and agents keep using the ids; `album.ruleFields` adds `choiceLabels`). - Any Searchable Text also finds the state / province, alt text, people and a virtual copy's name. From 7d3c61c56b3b955c1b87fb1fd01c7082919449e3 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:39:44 -0400 Subject: [PATCH 13/65] LRC-LIB-SMARTCOLL: yes/no rule values read Yes / No and mean what they say MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yes/no fields (Has Edits, Cropped, Has GPS, Virtual Copy, In a Stack, Best of Similar Shots) showed "true" / "false" in the editor and summaries, and Russian had no translation for either. Matching read the value with as_bool().unwrap_or(true), so an agent's "false" (a string) or any other non-boolean counted as yes, while the summary said "false". - catalog: bool_value is the one reading. true / "yes" / "true" / 1 / no value mean yes; false / "no" / "false" / 0 mean no (strings in any case). Anything else matches nothing, and RuleSet::problems reports it ("`edited` is yes or no, not \"maybe\""), so album.createSmart and setRules refuse it. bool_label gives "Yes" / "No"; describe() reads "has edits is yes". - ui: i18n::bool_text for the editor's dropdown and translated summaries (Ja / Nein…, existing keys in all locales). The editor stores the value back as a plain boolean. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/rules.rs | 73 ++++++++++++++++++++--- crates/engine/src/tests_library.rs | 14 +++++ crates/ui-egui/src/i18n.rs | 26 +++++++- crates/ui-egui/src/panels/rules_editor.rs | 16 +++-- 4 files changed, 112 insertions(+), 17 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 95c0c0ac..6b430608 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -146,6 +146,31 @@ pub fn field_group(field: &str) -> Option<&'static str> { FIELD_GROUPS.iter().find(|g| g.1.contains(&field)).map(|g| g.0) } +/// What a yes/no rule's value means: `true`, `"yes"`, `1` or no value is yes; `false`, `"no"`, `0` +/// is no (strings in any case); `None` for anything else, which matches nothing. +pub fn bool_value(value: &Value) -> Option { + match value { + Value::Bool(b) => Some(*b), + Value::Null => Some(true), + Value::Number(n) => match n.as_u64() { + Some(1) => Some(true), + Some(0) => Some(false), + _ => None, + }, + Value::String(s) => match s.trim().to_lowercase().as_str() { + "true" | "yes" | "1" => Some(true), + "false" | "no" | "0" => Some(false), + _ => None, + }, + _ => None, + } +} + +/// The display label of a yes/no value. +pub fn bool_label(b: bool) -> &'static str { + if b { "Yes" } else { "No" } +} + /// 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> { @@ -377,6 +402,7 @@ impl Rule { let m = &p.meta; let want = lower(value); let text = |have: &str| text_op(op, have, &want); + let yes = |have: bool| bool_value(value).is_some_and(|b| have == b); match field { "rating" => num_op(op, Some(p.rating as f64), value), "flag" => { @@ -390,12 +416,12 @@ impl Rule { (l == want || named) == (op == "is") } "kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"), - "edited" => p.is_edited() == value.as_bool().unwrap_or(true), - "hasGps" => m.gps.is_some() == value.as_bool().unwrap_or(true), - "virtualCopy" => p.copy_of.is_some() == value.as_bool().unwrap_or(true), + "edited" => yes(p.is_edited()), + "hasGps" => yes(m.gps.is_some()), + "virtualCopy" => yes(p.copy_of.is_some()), "copyName" => text(p.copy_name.as_deref().unwrap_or("")), - "stacked" => cat.stack_of(p.id).is_some() == value.as_bool().unwrap_or(true), - "cropped" => p.is_cropped() == value.as_bool().unwrap_or(true), + "stacked" => yes(cat.stack_of(p.id).is_some()), + "cropped" => yes(p.is_cropped()), "treatment" => { let t = if p.develop.treatment == lightcraft_develop::Treatment::Bw { "monochrome" } else { "color" }; (t == want) == (op == "is") @@ -471,7 +497,7 @@ impl Rule { "aspect" => (aspect(p.shown_size()) == Some(want.as_str())) == (op == "is"), "megapixels" => num_op(op, Some(p.width as f64 * p.height as f64 / 1e6), value), "sharpness" => num_op(op, p.analysis.map(|a| a.sharpness as f64), value), - "bestOfGroup" => p.analysis.is_some_and(|a| a.best || a.group.is_none()) == value.as_bool().unwrap_or(true), + "bestOfGroup" => yes(p.analysis.is_some_and(|a| a.best || a.group.is_none())), "album" => { let id = number(value).map(|v| crate::AlbumId(v as u64)); id.is_some_and(|a| cat.album(a).is_some_and(|al| !al.is_smart()) && cat.album_contains(a, p)) == (op == "is") @@ -508,9 +534,10 @@ impl RuleSet { for r in &self.rules { match r { Rule::Group { group } => out.extend(group.problems()), - Rule::Field { field, op, .. } => match field_kind(field) { + Rule::Field { field, op, value } => match field_kind(field) { None => out.push(format!("unknown field `{field}`")), Some(k) if !ops_for(k).iter().any(|o| o.0 == op) => out.push(format!("`{field}` has no operator `{op}`")), + Some(Kind::Bool) if bool_value(value).is_none() => out.push(format!("`{field}` is yes or no, not {value}")), _ => {} }, } @@ -534,6 +561,9 @@ 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 { + _ if field_kind(field) == Some(Kind::Bool) => { + bool_value(value).map_or_else(|| value.to_string(), |b| bool_label(b).to_lowercase()) + } Value::String(s) => choice_label(field, s).map_or_else(|| s.clone(), str::to_lowercase), Value::Null => String::new(), Value::Object(o) => format!( @@ -872,6 +902,35 @@ mod tests { assert!(r.matches(&p, &Catalog::new()), "ids still match"); } + /// Yes/no fields read "Yes" / "No", and a value means the same to matching and to every + /// summary: `false`, `"false"`, `"no"` and `0` are no; `true`, `"yes"`, `1` and a missing value + /// are yes. Anything else is reported, not silently taken as yes. + #[test] + fn yes_no_values() { + for v in [json!(true), json!("true"), json!("Yes"), json!(1), json!(null)] { + assert_eq!(bool_value(&v), Some(true), "{v}"); + } + for v in [json!(false), json!("false"), json!(" NO "), json!(0)] { + assert_eq!(bool_value(&v), Some(false), "{v}"); + } + for v in [json!("maybe"), json!(2), json!([true]), json!({"x": 1})] { + assert_eq!(bool_value(&v), None, "{v}"); + } + assert_eq!((bool_label(true), bool_label(false)), ("Yes", "No")); + let cat = Catalog::new(); + let p = photo(1); // unedited + assert!(rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}]})).matches(&p, &cat), "\"false\" means no"); + assert!( + !rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}, {"field": "hasGps", "op": "is", "value": "yes"}]})) + .matches(&p, &cat) + ); + assert!(!rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})).matches(&p, &cat), "an unreadable value matches nothing"); + let r = rs(json!({"rules": [{"field": "edited", "op": "is", "value": true}, {"field": "cropped", "op": "is", "value": "false"}]})); + assert_eq!(r.describe(), "has edits is yes and cropped is no"); + let bad = rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})); + assert_eq!(bad.problems(), vec!["`edited` is yes or no, not \"maybe\"".to_string()]); + } + /// 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/tests_library.rs b/crates/engine/src/tests_library.rs index f3650803..7719193e 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -265,6 +265,20 @@ fn smart_album_rule_sets() { &serde_json::json!({"name": "Bad", "rules": {"ruleSet": {"rules": [{"field": "rating", "op": "contains", "value": 1}]}}}), ); assert!(bad.is_err(), "operators are checked"); + // yes/no values: "false" means no, anything unreadable is an error rather than a silent yes + let maybe = s.execute( + "album.createSmart", + &serde_json::json!({"name": "Maybe", "rules": {"ruleSet": {"rules": [{"field": "edited", "op": "is", "value": "maybe"}]}}}), + ); + assert!(maybe.is_err_and(|e| e.to_string().contains("yes or no")), "unreadable yes/no value"); + let r = s + .execute( + "album.createSmart", + &serde_json::json!({"name": "Unedited", "rules": {"ruleSet": {"rules": [{"field": "edited", "op": "is", "value": "false"}]}}}), + ) + .unwrap(); + let unedited = s.catalog.photos().filter(|p| !p.deleted && !p.is_edited()).count(); + assert_eq!(r["count"].as_u64(), Some(unedited as u64)); let r = s .execute( "album.createSmart", diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 2d869ecd..0f08e244 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -328,6 +328,15 @@ 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)) } +/// What people read for a smart-album yes/no value ("Ja" / "Nein" in German). +pub fn bool_text(b: bool) -> &'static str { + bool_text_in(language(), b) +} + +fn bool_text_in(language: Locale, b: bool) -> &'static str { + tr_in(language, lightcraft_catalog::rules::bool_label(b)) +} + /// 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 { @@ -348,7 +357,7 @@ pub fn rules_label(rules: &lightcraft_catalog::RuleSet) -> String { 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(_) => 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(), + Kind::Bool => lightcraft_catalog::rules::bool_value(value).map_or_else(|| value.to_string(), |b| bool_text(b).to_string()), _ if matches!(op.as_str(), "inLast" | "notInLast") => format!( "{} {}", value.get("n").unwrap_or(&serde_json::Value::Null), @@ -649,6 +658,14 @@ mod tests { assert_eq!(choice_text_in(Locale::ZhHans, "treatment", "color"), "彩色", "in colour, not the colour noun"); assert_eq!(choice_text_in(Locale::Ru, "treatment", "color"), "Цветное"); assert_eq!(choice_text_in(Locale::Es, "label", "none"), "Sin etiqueta"); + // yes/no fields read Yes / No, never true / false + assert_eq!((bool_text_in(Locale::En, true), bool_text_in(Locale::En, false)), ("Yes", "No")); + assert_eq!(bool_text_in(Locale::De, false), "Nein"); + for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { + for label in [lightcraft_catalog::rules::bool_label(true), lightcraft_catalog::rules::bool_label(false)] { + assert!(language.catalog().contains_key(label), "{} lacks {label:?}", language.code()); + } + } for language in Locale::ALL { for (field, _, kind) in FIELDS { let Kind::Choice(choices) = kind else { continue }; @@ -750,6 +767,13 @@ mod tests { }], }; assert!(rules_label(&rules).ends_with(" Gemeinfrei"), "{}", rules_label(&rules)); + for value in [serde_json::json!(false), serde_json::json!("false")] { + let rules = lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::Match::All, + rules: vec![lightcraft_catalog::Rule::Field { field: "edited".into(), op: "is".into(), value }], + }; + assert!(rules_label(&rules).ends_with(" Nein"), "{}", 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 697f7125..1ee1872d 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -2,7 +2,7 @@ //! value), with nested groups. Field and operator lists come from `lightcraft_catalog::rules`. use egui::RichText; -use lightcraft_catalog::rules::{FIELD_GROUPS, Kind, TOP_LEVEL_FIELDS, field_kind, field_label, ops_for}; +use lightcraft_catalog::rules::{FIELD_GROUPS, Kind, TOP_LEVEL_FIELDS, bool_value, field_kind, field_label, ops_for}; use lightcraft_catalog::{Match, Rule, RuleSet}; use serde_json::{Value, json}; @@ -131,14 +131,12 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & ); } (Some(Kind::Bool), _) => { - let mut b = v.as_bool().unwrap_or(true); - egui::ComboBox::from_id_salt(format!("{salt}-bool")) - .width(60.0) - .selected_text(crate::i18n::tr(if b { "true" } else { "false" })) - .show_ui(ui, |ui| { - ui.selectable_value(&mut b, true, crate::i18n::tr("true")); - ui.selectable_value(&mut b, false, crate::i18n::tr("false")); - }); + // the catalog's reading of the value ("false" is no), stored back as a plain boolean + let mut b = bool_value(v).unwrap_or(true); + egui::ComboBox::from_id_salt(format!("{salt}-bool")).width(60.0).selected_text(crate::i18n::bool_text(b)).show_ui(ui, |ui| { + ui.selectable_value(&mut b, true, crate::i18n::bool_text(true)); + ui.selectable_value(&mut b, false, crate::i18n::bool_text(false)); + }); *v = json!(b); } (Some(Kind::Date), _) => text_value(ui, v, 120.0, "2026-04 or 2026-04-12", salt), From b0de6d3fef7e8f50b336eb141b02fff26ae88ef5 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:39:44 -0400 Subject: [PATCH 14/65] LRC-LIB-SMARTCOLL: document Yes / No values and the "false" fix Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 2 +- docs/whats-new.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/parity.md b/docs/parity.md index 0fb2d287..5edb96e5 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge and aspect ratio of the oriented, cropped photo; megapixels), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge and aspect ratio of the oriented, cropped photo; megapixels), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0 (anything else is an error) | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | diff --git a/docs/whats-new.md b/docs/whats-new.md index eb44424a..47ce0440 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -11,6 +11,8 @@ Edge, Short Edge, Aspect Ratio (landscape / portrait / square), Cropped and Treatment (in color / black & white). - Choice values read as words in every language: "Public Domain", "Picked", "Black & White" rather than ids like `publicDomain` (rules and agents keep using the ids; `album.ruleFields` adds `choiceLabels`). +- Yes/no fields (Has Edits, Cropped, Has GPS…) read Yes / No instead of true / false. A rule sent as `"false"` now + means no (it used to count as yes), and a value that is neither yes nor no is refused. - Any Searchable Text also finds the state / province, alt text, people and a virtual copy's name. ### Albums tree From 07d5bc3152172f7cf620f1ccd899c09a843e9ac4 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:47:14 -0400 Subject: [PATCH 15/65] LRC-LIB-SMARTCOLL: yes/no rule values accept 1.0 and 0.0 From review: an agent sending a float (1.0) was refused while 1 was accepted. Any number equal to 1 or 0 now reads as yes or no; other numbers (2, 0.5, -1) are still refused. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/rules.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 6b430608..69d4a629 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -146,15 +146,15 @@ pub fn field_group(field: &str) -> Option<&'static str> { FIELD_GROUPS.iter().find(|g| g.1.contains(&field)).map(|g| g.0) } -/// What a yes/no rule's value means: `true`, `"yes"`, `1` or no value is yes; `false`, `"no"`, `0` -/// is no (strings in any case); `None` for anything else, which matches nothing. +/// What a yes/no rule's value means: `true`, `"yes"`, `1` (or `1.0`) or no value is yes; `false`, +/// `"no"`, `0` is no (strings in any case); `None` for anything else, which matches nothing. pub fn bool_value(value: &Value) -> Option { match value { Value::Bool(b) => Some(*b), Value::Null => Some(true), - Value::Number(n) => match n.as_u64() { - Some(1) => Some(true), - Some(0) => Some(false), + Value::Number(n) => match n.as_f64() { + Some(1.0) => Some(true), + Some(0.0) => Some(false), _ => None, }, Value::String(s) => match s.trim().to_lowercase().as_str() { @@ -907,13 +907,13 @@ mod tests { /// are yes. Anything else is reported, not silently taken as yes. #[test] fn yes_no_values() { - for v in [json!(true), json!("true"), json!("Yes"), json!(1), json!(null)] { + for v in [json!(true), json!("true"), json!("Yes"), json!(1), json!(1.0), json!(null)] { assert_eq!(bool_value(&v), Some(true), "{v}"); } - for v in [json!(false), json!("false"), json!(" NO "), json!(0)] { + for v in [json!(false), json!("false"), json!(" NO "), json!(0), json!(0.0)] { assert_eq!(bool_value(&v), Some(false), "{v}"); } - for v in [json!("maybe"), json!(2), json!([true]), json!({"x": 1})] { + for v in [json!("maybe"), json!(2), json!(0.5), json!(-1), json!([true]), json!({"x": 1})] { assert_eq!(bool_value(&v), None, "{v}"); } assert_eq!((bool_label(true), bool_label(false)), ("Yes", "No")); From d3730a702473e480db506dc030ef306c6f1d9a57 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:47:14 -0400 Subject: [PATCH 16/65] LRC-LIB-SMARTCOLL: library.filter checks its rule set From review: album.createSmart and setRules refuse an unknown field, a missing operator or a yes/no value that is neither yes nor no, but library.filter applied such a rule set and showed an empty grid. It now returns the same problems as an error and leaves the current filter in place. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 4 ++++ crates/engine/src/tests_library.rs | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index d20745dd..0a7a3208 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -330,6 +330,10 @@ pub fn specs() -> Vec { if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder { return Err(bad("library.filter", "a folder is already shown (library.source): show another source first")); } + // checked like a smart album's rules: an unknown field or a value that can't match is an error, not an empty grid + if let Some(problems) = f.rule_set.as_ref().map(|rs| rs.problems()).filter(|p| !p.is_empty()) { + return Err(bad("library.filter", problems.join("; "))); + } s.filter = f; Ok(json!({"count": s.visible().len()})) } diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 7719193e..9dd0e6b8 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -271,6 +271,14 @@ fn smart_album_rule_sets() { &serde_json::json!({"name": "Maybe", "rules": {"ruleSet": {"rules": [{"field": "edited", "op": "is", "value": "maybe"}]}}}), ); assert!(maybe.is_err_and(|e| e.to_string().contains("yes or no")), "unreadable yes/no value"); + // the library filter checks a rule set the same way, instead of quietly showing nothing + for rules in [serde_json::json!([{"field": "edited", "op": "is", "value": "maybe"}]), serde_json::json!([{"field": "nope", "op": "is"}])] { + let r = s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": rules}})); + assert!(r.is_err(), "{rules}"); + } + assert!(s.filter.rule_set.is_none(), "a refused filter isn't applied"); + s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "edited", "op": "is", "value": "no"}]}})).unwrap(); + s.execute("library.filter", &serde_json::json!({"ruleSet": null})).unwrap(); let r = s .execute( "album.createSmart", From fa982a4cc60625d3c532f12eba5a1556bd74384e Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:47:15 -0400 Subject: [PATCH 17/65] LRC-LIB-SMARTCOLL: rule editor keeps an unreadable yes/no value From review: the editor rewrote a yes/no value that was neither yes nor no (written by an agent or an older version) to true on every frame, so the dialog never showed the problem and OK silently saved "Yes", turning a rule that matched nothing into one that matches. The dropdown now shows such a value as written and changes it only when the user picks Yes or No. Until then the dialog reports the problem and OK refuses it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/rules_editor.rs | 16 ++++++++++------ crates/ui-egui/src/tests_masking.rs | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index 1ee1872d..b85d7b3b 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -131,13 +131,17 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & ); } (Some(Kind::Bool), _) => { - // the catalog's reading of the value ("false" is no), stored back as a plain boolean - let mut b = bool_value(v).unwrap_or(true); - egui::ComboBox::from_id_salt(format!("{salt}-bool")).width(60.0).selected_text(crate::i18n::bool_text(b)).show_ui(ui, |ui| { - ui.selectable_value(&mut b, true, crate::i18n::bool_text(true)); - ui.selectable_value(&mut b, false, crate::i18n::bool_text(false)); + // the catalog's reading of the value ("false" is no); one that is neither yes nor no is + // shown as written and kept until a choice replaces it, so the dialog reports it + let cur = bool_value(v); + let text = cur.map_or_else(|| v.to_string(), |b| crate::i18n::bool_text(b).to_string()); + egui::ComboBox::from_id_salt(format!("{salt}-bool")).width(60.0).selected_text(text).show_ui(ui, |ui| { + for b in [true, false] { + if ui.selectable_label(cur == Some(b), crate::i18n::bool_text(b)).clicked() { + *v = json!(b); + } + } }); - *v = json!(b); } (Some(Kind::Date), _) => text_value(ui, v, 120.0, "2026-04 or 2026-04-12", salt), _ => text_value(ui, v, 140.0, "", salt), diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index cbafe8f1..fd64b0a9 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -324,6 +324,22 @@ fn smart_album_field_menu_groups_fields_in_submenus() { assert!(!has(&h, "ruleFieldItem:rating:rules-0") && !has(&h, "ruleFieldItem:filePath:rules-0"), "picking a field closes the menu"); } +/// A yes/no rule whose value isn't yes or no (written by an agent or an older version) stays as +/// it is in the editor: the dialog shows the problem and OK doesn't quietly save it as Yes. +#[test] +fn smart_album_editor_keeps_an_unreadable_yes_no_value() { + let mut h = detail("panel.edit"); + exec(&mut h, "dialog.smartAlbum", json!({"name": "Odd"})); + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &mut h.app.ui.dialog else { panic!("no rule editor") }; + rules.rules[0] = serde_json::from_value(json!({"field": "edited", "op": "is", "value": "maybe"})).unwrap(); + h.settle(SETTLE); + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") }; + let Rule::Field { value, .. } = &rules.rules[0] else { panic!("not a field rule") }; + assert_eq!(value, &json!("maybe"), "drawing the editor doesn't rewrite the value"); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert!(h.app.session.catalog.albums().all(|a| a.name != "Odd"), "OK refuses it: {r}"); +} + #[test] fn g_toggles_grids_and_shift_g_starts_guided_upright() { let mut h = detail("panel.edit"); From c42fc7ac24eaced821b474571b92fa699d260046 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:49:50 -0400 Subject: [PATCH 18/65] =?UTF-8?q?LRC-LIB-SMARTCOLL:=20Japanese=20yes/no=20?= =?UTF-8?q?rule=20values=20read=20=E3=81=82=E3=82=8A=20/=20=E3=81=AA?= =?UTF-8?q?=E3=81=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yes/no rule values used the shared "Yes" / "No" messages, which Japanese translates as the answers はい / いいえ. As the value of a condition ("編集あり … いいえ") that reads oddly; あり / なし ("has" / "has none") is natural. Messages are keyed by their English text, so the same English word can't carry two Japanese translations. A small context lookup fixes that: tr_ctx_in tries "|" first and falls back to the plain message. Only ja.json adds "smart album value|Yes" / "|No"; the shared Yes / No (はい / いいえ) stay as they are elsewhere, and every other language keeps its plain Ja / Sí / Да. A test rejects contextual keys that name no real value, so a typo can't silently fall back. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/ja.json | 4 +++- crates/ui-egui/src/i18n.rs | 30 +++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index d38fe90f..77337861 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1741,5 +1741,7 @@ "Portrait (tall)": "縦長", "Square": "正方形", "In Color": "カラー", - "No Label": "ラベルなし" + "No Label": "ラベルなし", + "smart album value|Yes": "あり", + "smart album value|No": "なし" } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 0f08e244..31ad3da5 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -164,6 +164,16 @@ pub fn tr(source: &str) -> &str { tr_in(language(), source) } +/// [`tr`] for a message whose translation depends on where it appears: a catalog entry keyed +/// `"|"` wins, else the plain message. A language adds the contextual entry only +/// where its shared translation reads wrongly there (Japanese はい / いいえ as a rule's value). +fn tr_ctx_in<'a>(language: Locale, context: &str, source: &'a str) -> &'a str { + match language.catalog().get(&format!("{context}|{source}")) { + Some(translation) => translation.as_str(), + None => tr_in(language, source), + } +} + /// Catalog values are `'static` (they live in the embedded file), so a translation can be handed /// out for as long as the `'static` key it was looked up by. fn tr_in(language: Locale, source: &str) -> &str { @@ -333,8 +343,11 @@ pub fn bool_text(b: bool) -> &'static str { bool_text_in(language(), b) } +/// The context of a smart-album yes/no value ([`tr_ctx_in`]). +const BOOL_CONTEXT: &str = "smart album value"; + fn bool_text_in(language: Locale, b: bool) -> &'static str { - tr_in(language, lightcraft_catalog::rules::bool_label(b)) + tr_ctx_in(language, BOOL_CONTEXT, lightcraft_catalog::rules::bool_label(b)) } /// A rule summary for display, keeping free-text rule values verbatim. @@ -661,6 +674,21 @@ mod tests { // yes/no fields read Yes / No, never true / false assert_eq!((bool_text_in(Locale::En, true), bool_text_in(Locale::En, false)), ("Yes", "No")); assert_eq!(bool_text_in(Locale::De, false), "Nein"); + // Japanese answers はい / いいえ don't read as a rule's value: its own wording, あり / なし, + // through a context, while the shared Yes / No keep their meaning everywhere else + assert_eq!((bool_text_in(Locale::Ja, true), bool_text_in(Locale::Ja, false)), ("あり", "なし")); + assert_eq!((Locale::Ja.tr("Yes"), Locale::Ja.tr("No")), ("はい", "いいえ")); + assert_eq!(tr_ctx_in(Locale::Ja, "nope", "Yes"), "はい", "no contextual entry: the plain message"); + assert_eq!(tr_ctx_in(Locale::En, BOOL_CONTEXT, "Yes"), "Yes"); + // a contextual entry names a real value (a typo would silently fall back to はい / いいえ) + let values = [lightcraft_catalog::rules::bool_label(true), lightcraft_catalog::rules::bool_label(false)]; + for language in Locale::ALL { + for key in language.catalog().keys() { + if let Some(value) = key.strip_prefix(BOOL_CONTEXT).and_then(|k| k.strip_prefix('|')) { + assert!(values.contains(&value), "{}: {key:?} isn't a yes/no value", language.code()); + } + } + } for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { for label in [lightcraft_catalog::rules::bool_label(true), lightcraft_catalog::rules::bool_label(false)] { assert!(language.catalog().contains_key(label), "{} lacks {label:?}", language.code()); From 451e3cd28c79f30e8cdf94342dc4540c6b0ca433 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:49:50 -0400 Subject: [PATCH 19/65] LRC-LIB-SMARTCOLL: document message contexts for translators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit localization.md explains "|" catalog keys and the one context today (smart album value); localization-ja.md notes あり / なし. Co-Authored-By: Claude Opus 5.5 --- docs/localization-ja.md | 4 ++++ docs/localization.md | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/docs/localization-ja.md b/docs/localization-ja.md index bcfb8a8d..b92a444e 100644 --- a/docs/localization-ja.md +++ b/docs/localization-ja.md @@ -24,5 +24,9 @@ LightCraft は日本語・簡体字中国語・英語(English)の表示に 英語の単複数語尾を日本語で省略する場合、対応する文字列引数は `{:.0}` で空にします。 `LIGHTCRAFT_LANGUAGE=ja lightcraft-cli snapshot ...` で日本語の画面を描画できます。 +スマートアルバムの「はい/いいえ」で答える条件(編集あり、切り抜き済みなど)の値は、文脈付きキー +`"smart album value|Yes"`・`"smart album value|No"` で「あり/なし」と表示します。共有の +`"Yes"`・`"No"`(はい/いいえ)は他の画面でそのまま使います([`localization.md`](localization.md) の文脈の説明を参照)。 + 表示・フォント・言語の切り替え・設定の保存・コマンドIDの保持は `cargo test -p lightcraft-ui-egui i18n::tests` で検証します(グリフの検査は `CRAFT_FONTS_DIR` 指定時のみ実行)。 diff --git a/docs/localization.md b/docs/localization.md index 123dcaba..f82529a9 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -49,6 +49,14 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h text unchanged when there is no entry; `tr_format!` handles text with runtime values. Both are presentation-only: command ids, file names and user-entered metadata are never translated. +A few messages take a **context**, for a word whose shared translation reads wrongly in one place. +The catalog may then carry `"|"`, which wins there; without it the plain message +is used, so only the languages that need it add one. Today the one context is `smart album value`: +the Yes / No of a yes/no rule (Has Edits, Cropped…). Japanese uses it because はい / いいえ answer a +question rather than describe a photo (`"smart album value|Yes": "あり"`, `"smart album value|No": +"なし"`); every other language uses its plain Yes / No. A contextual key that names no real value +fails the i18n tests. + `tr_format!` is generated by `crates/ui-egui/build.rs`: one arm per English message, which formats the active language's translation with `format!` (English is the fallback arm). Every catalog's format string is therefore checked by Rust against the call site's own arguments: From 65516b721c8659265096dd51ff0f09caa9a91398 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:54:03 -0400 Subject: [PATCH 20/65] LRC-LIB-SMARTCOLL: Japanese yes/no field labels are plain nouns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the values now あり / なし, the labels "Has Edits" (編集あり) and "Has GPS" (GPS情報あり) read "edits: yes … none". They are only used as smart-album field labels, so they become 編集 and GPS情報 ("編集 … あり / なし"). The untranslated In a Stack and Cropped become スタック and 切り抜き. A test requires every yes/no field to have a Japanese label without あり / なし. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/ja.json | 8 +++++--- crates/ui-egui/src/i18n.rs | 8 ++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 77337861..de1a87de 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -503,8 +503,8 @@ "HDR panorama": "HDRパノラマ", "HDR panoramas": "HDRパノラマ", "HDR with Last Settings": "前回の設定でHDR合成", - "Has Edits": "編集あり", - "Has GPS": "GPS情報あり", + "Has Edits": "編集", + "Has GPS": "GPS情報", "High Contrast B&W": "ハイコントラスト白黒", "Home": "ホーム", "Hours": "時間", @@ -1743,5 +1743,7 @@ "In Color": "カラー", "No Label": "ラベルなし", "smart album value|Yes": "あり", - "smart album value|No": "なし" + "smart album value|No": "なし", + "In a Stack": "スタック", + "Cropped": "切り抜き" } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 31ad3da5..817a18e0 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -680,6 +680,14 @@ mod tests { assert_eq!((Locale::Ja.tr("Yes"), Locale::Ja.tr("No")), ("はい", "いいえ")); assert_eq!(tr_ctx_in(Locale::Ja, "nope", "Yes"), "はい", "no contextual entry: the plain message"); assert_eq!(tr_ctx_in(Locale::En, BOOL_CONTEXT, "Yes"), "Yes"); + // and the yes/no fields are named by plain nouns, so "編集 … なし" doesn't read "edits: yes … none" + for (field, label, kind) in FIELDS { + if *kind != Kind::Bool { + continue; + } + let ja = Locale::Ja.catalog().get(*label).unwrap_or_else(|| panic!("ja lacks the {field} label {label:?}")); + assert!(!ja.contains("あり") && !ja.contains("なし"), "{field}: {ja}"); + } // a contextual entry names a real value (a typo would silently fall back to はい / いいえ) let values = [lightcraft_catalog::rules::bool_label(true), lightcraft_catalog::rules::bool_label(false)]; for language in Locale::ALL { From 5865b4c0199200d7aeb6188a7b8ecab872cd43cc Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:54:11 -0400 Subject: [PATCH 21/65] LRC-LIB-SMARTCOLL: localization-ja example uses the new field labels Co-Authored-By: Claude Opus 5.5 --- docs/localization-ja.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/localization-ja.md b/docs/localization-ja.md index b92a444e..8d406884 100644 --- a/docs/localization-ja.md +++ b/docs/localization-ja.md @@ -24,7 +24,7 @@ LightCraft は日本語・簡体字中国語・英語(English)の表示に 英語の単複数語尾を日本語で省略する場合、対応する文字列引数は `{:.0}` で空にします。 `LIGHTCRAFT_LANGUAGE=ja lightcraft-cli snapshot ...` で日本語の画面を描画できます。 -スマートアルバムの「はい/いいえ」で答える条件(編集あり、切り抜き済みなど)の値は、文脈付きキー +スマートアルバムの「はい/いいえ」で答える条件(編集、切り抜きなど)の値は、文脈付きキー `"smart album value|Yes"`・`"smart album value|No"` で「あり/なし」と表示します。共有の `"Yes"`・`"No"`(はい/いいえ)は他の画面でそのまま使います([`localization.md`](localization.md) の文脈の説明を参照)。 From 9bac3abb4b1321b5553c39f172924e41635aa5cd Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:58:36 -0400 Subject: [PATCH 22/65] LRC-LIB-SMARTCOLL: Megapixels rule counts the cropped photo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Megapixels used the original width × height, while Long Edge, Short Edge and Aspect Ratio measure the photo as shown (oriented and cropped). One rule set could disagree with itself: a 24 MP photo cropped to a 4000 × 4000 square had a long edge of 4000 but still counted as 24 MP. Megapixels now uses Photo::shown_size too, so that crop is 16 MP. Rotation doesn't change it. Behaviour change: a smart album with a Megapixels rule can now leave out heavily cropped photos it used to include. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/rules.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 69d4a629..1db2880a 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -495,7 +495,10 @@ impl Rule { num_op(op, Some(if field == "longEdge" { w.max(h) } else { w.min(h) }.round()), value) } "aspect" => (aspect(p.shown_size()) == Some(want.as_str())) == (op == "is"), - "megapixels" => num_op(op, Some(p.width as f64 * p.height as f64 / 1e6), value), + "megapixels" => { + let (w, h) = p.shown_size(); + num_op(op, Some(w * h / 1e6), value) + } "sharpness" => num_op(op, p.analysis.map(|a| a.sharpness as f64), value), "bestOfGroup" => yes(p.analysis.is_some_and(|a| a.best || a.group.is_none())), "album" => { @@ -824,7 +827,7 @@ mod tests { } /// Size follows the photo as shown: rotated a quarter turn a landscape frame is a portrait, and - /// a crop changes its edges. Long / Short Edge are in pixels. + /// a crop changes its edges and its megapixels. Long / Short Edge are in pixels. #[test] fn size_rules_follow_orientation_and_crop() { let cat = Catalog::new(); @@ -833,6 +836,7 @@ mod tests { rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) }; assert!(m(&p, "aspect", "is", json!("landscape")) && m(&p, "longEdge", "is", json!(6000)) && m(&p, "shortEdge", "is", json!(4000))); + assert!(m(&p, "megapixels", "is", json!(24))); let mut s = (*p.develop).clone(); s.orientation = lightcraft_geom::Orientation::Rotate90; p.develop = std::sync::Arc::new(s.clone()); @@ -841,11 +845,14 @@ mod tests { s.crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 1.0 / 6.0, 1.0, 5.0 / 6.0); p.develop = std::sync::Arc::new(s); assert!(m(&p, "aspect", "is", json!("square")) && m(&p, "longEdge", "is", json!(4000))); + assert!(m(&p, "megapixels", "is", json!(16)), "a 4000 × 4000 crop is 16 MP, not the sensor's 24"); + assert!(m(&p, "megapixels", "lt", json!(20)) && !m(&p, "megapixels", "gte", json!(24))); assert!(m(&p, "aspect", "isNot", json!("portrait"))); let empty = Photo::new(PhotoId(9), Source::Demo { scene: 0 }, "x.jpg", "JPEG", 0, 0, "2026-09-20T10:00:00"); assert!( !m(&empty, "aspect", "is", json!("landscape")) && !m(&empty, "aspect", "is", json!("square")) && m(&empty, "longEdge", "is", json!(0)) ); + assert!(m(&empty, "megapixels", "is", json!(0))); } /// Any Searchable Text also finds the state / province, the alt text, a person on a face and From 62aef59178987d19066e774a10773f108e9f3b1a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 07:58:36 -0400 Subject: [PATCH 23/65] LRC-LIB-SMARTCOLL: document Megapixels measuring the cropped photo Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 2 +- docs/whats-new.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/parity.md b/docs/parity.md index 5edb96e5..03f96f92 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge and aspect ratio of the oriented, cropped photo; megapixels), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0 (anything else is an error) | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0 (anything else is an error) | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | diff --git a/docs/whats-new.md b/docs/whats-new.md index 47ce0440..d2519954 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -9,6 +9,8 @@ - New fields: Keyword Count ("2 or more", "exactly 2"…), People and People Count (named faces), Shutter Speed (written as 1/250), File Extension, Video Duration, Copy Name, In a Stack, Alt Text, City, State / Province, Country, Long Edge, Short Edge, Aspect Ratio (landscape / portrait / square), Cropped and Treatment (in color / black & white). + Size rules measure the photo as shown: Megapixels now counts the cropped photo, like Long Edge, Short Edge and + Aspect Ratio (a 24 MP photo cropped to a square is 16 MP). - Choice values read as words in every language: "Public Domain", "Picked", "Black & White" rather than ids like `publicDomain` (rules and agents keep using the ids; `album.ruleFields` adds `choiceLabels`). - Yes/no fields (Has Edits, Cropped, Has GPS…) read Yes / No instead of true / false. A rule sent as `"false"` now From a08d0f6b03cfee22ce8fec01088511e12d0fd663 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 08:07:42 -0400 Subject: [PATCH 24/65] =?UTF-8?q?LRC-LIB-SMARTCOLL:=20"in=20the=20last=20N?= =?UTF-8?q?"=20can't=20overflow;=20shift=5Fiso=20stays=20in=20years=200?= =?UTF-8?q?=E2=80=939999?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crash fix. A huge or negative "in the last N" from an agent or a saved file ({"n": -1e30}) panicked with "attempt to negate with overflow" in debug builds. Release builds wrapped instead, and the rule matched the wrong photos. - last_secs: a zero, negative, NaN or unreadable N is None (the rule matches nothing, either way round). Spans are capped at 10,000 years, which means "ever": every dated photo is in it and none is outside it. - dates::shift_iso: checked_add, and a result outside years 0000–9999 (which iso_seconds can't read back) is None. It never overflows, whatever the seconds. Regression tests cover both and panicked before the fix. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/dates.rs | 20 ++++++++++++++++--- crates/catalog/src/rules.rs | 40 ++++++++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs index 06d5c2c0..cde5adf8 100644 --- a/crates/catalog/src/dates.rs +++ b/crates/catalog/src/dates.rs @@ -40,11 +40,17 @@ pub fn weekday(iso: &str) -> Option<&'static str> { } /// Shift an ISO time by `secs`, keeping anything after the seconds (fractions, zone) as it was. -/// A date without a time gets one. `None` if it doesn't parse. +/// A date without a time gets one. `None` if it doesn't parse, or if the result leaves years +/// 0000–9999 (an ISO date here has four year digits, so it couldn't be read back); `secs` may be +/// anything, it never overflows. pub fn shift_iso(iso: &str, secs: i64) -> Option { - let t = iso_seconds(iso)?; + let t = iso_seconds(iso)?.checked_add(secs)?; + let (first, last) = (iso_seconds("0000-01-01T00:00:00")?, iso_seconds("9999-12-31T23:59:59")?); + if !(first..=last).contains(&t) { + return None; + } let tail = iso.trim().get(19..).unwrap_or(""); - Some(format!("{}{tail}", civil(t + secs))) + Some(format!("{}{tail}", civil(t))) } /// Normalize user input to `YYYY-MM-DDTHH:MM:SS` (accepts a space instead of `T`, missing @@ -204,6 +210,14 @@ mod tests { assert_eq!(shift_iso("2026-12-31T23:30:00", 3600).as_deref(), Some("2027-01-01T00:30:00")); assert_eq!(shift_iso("2026-03-01T00:00:00.25+02:00", -1).as_deref(), Some("2026-02-28T23:59:59.25+02:00")); assert_eq!(shift_iso("nope", 5), None); + // hostile shifts never overflow, and a result outside years 0000–9999 (which no ISO date + // here can read back) is refused rather than stored + for secs in [i64::MAX, i64::MIN, i64::MAX / 2, -(i64::MAX / 2)] { + assert_eq!(shift_iso("2026-01-01T00:00:00", secs), None, "{secs}"); + } + assert_eq!(shift_iso("9999-12-31T23:59:59", 1), None); + assert_eq!(shift_iso("0000-01-01T00:00:00", -1), None); + assert_eq!(shift_iso("9999-12-31T23:59:58", 1).as_deref(), Some("9999-12-31T23:59:59")); assert_eq!(normalize_iso("2026-04-01 10:05").as_deref(), Some("2026-04-01T10:05:00")); assert_eq!(normalize_iso("2026-02-30"), None); } diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 1db2880a..9035a673 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -340,7 +340,12 @@ fn shutter_value(value: &Value) -> Value { } } -/// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days. +/// The longest "in the last…" span: 10,000 years, beyond any capture date ("ever"). +const MAX_LAST_SECS: f64 = 10_000.0 * 365.25 * 86_400.0; + +/// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days, in +/// seconds. `None` for a zero, negative or unreadable `n`; a longer span than [`MAX_LAST_SECS`] +/// is that (so the arithmetic on it can't overflow). fn last_secs(value: &Value) -> Option { let (n, unit) = match value { Value::Object(o) => (o.get("n").and_then(number)?, o.get("unit").and_then(Value::as_str).unwrap_or("days")), @@ -354,7 +359,11 @@ fn last_secs(value: &Value) -> Option { "years" => 365.25 * day, _ => day, }; - Some((n * per) as i64) + let secs = n * per; + if secs.is_nan() || secs <= 0.0 { + return None; + } + Some(secs.min(MAX_LAST_SECS) as i64) } fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool { @@ -386,7 +395,8 @@ fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool { }, "inLast" | "notInLast" => { let Some(secs) = last_secs(value) else { return false }; - let from = crate::dates::shift_iso(&now(), -secs).unwrap_or_default(); + // a span reaching before year 0 is "ever": every dated photo is in it + let Some(from) = crate::dates::shift_iso(&now(), -secs) else { return op == "inLast" }; (h >= from.as_str()) == (op == "inLast") } _ => false, @@ -938,6 +948,30 @@ mod tests { assert_eq!(bad.problems(), vec!["`edited` is yes or no, not \"maybe\"".to_string()]); } + /// "In the last N" never overflows, whatever N an agent or a saved file holds: a zero, + /// negative or unreadable N matches nothing (either way round); an N beyond 10,000 years means + /// "ever", so every dated photo is in it and none is outside it. + #[test] + fn in_the_last_survives_hostile_counts() { + set_now(Some("2026-09-01T00:00:00".into())); + let cat = Catalog::new(); + let p = photo(1); // captured 2026-08-14 + let m = |op: &str, n: serde_json::Value, unit: &str| { + rs(json!({"rules": [{"field": "captureDate", "op": op, "value": {"n": n, "unit": unit}}]})).matches(&p, &cat) + }; + for n in [json!(0), json!(-5), json!(-1e30), json!(-9.3e18), json!("soon")] { + for op in ["inLast", "notInLast"] { + assert!(!m(op, n.clone(), "years"), "{op} {n}"); + } + } + for n in [json!(1e30), json!(9.3e18), json!(u64::MAX), json!(20_000)] { + assert!(m("inLast", n.clone(), "years"), "{n} years is ever"); + assert!(!m("notInLast", n.clone(), "years"), "{n}"); + } + assert!(m("inLast", json!(1e30), "hours") && m("inLast", json!(30), "days")); + set_now(None); + } + /// 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] From 7ba1470940bc588734affb959b5c54daacfa240b Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 08:07:42 -0400 Subject: [PATCH 25/65] LR-LIB-CAPTURETIME: refuse capture-time shifts beyond 10,000 years Crash fix. photo.setCaptureTime turned `shift` / `hours` into seconds with a saturating cast and added them with plain `+`. A shift of 1e30 seconds overflowed. Only the last-resort panic guard caught it, reporting "failed unexpectedly: attempt to add with overflow". A large but not overflowing shift reached the catalog as a date like -114079456599-08-23. A shift that isn't a number or is more than 10,000 years either way is now refused up front ("the shift is more than 10,000 years") and nothing changes. The regression test requires that message, so a caught panic no longer passes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/manage.rs | 8 +++++++- crates/engine/src/tests_library.rs | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs index 41b43cab..7232ecdb 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -189,7 +189,13 @@ pub fn specs() -> Vec { } // photos without a capture time start from their import time let base = |s: &crate::Session, id| s.catalog.photo(id).map(|p| p.date().to_string()).unwrap_or_default(); - let mut delta = (f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0).round() as i64; + // seconds; a shift beyond 10,000 years is a mistake (and would overflow), not a date + const MAX_SHIFT: f64 = 10_000.0 * 365.25 * 86_400.0; + let shift = f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0; + if shift.is_nan() || shift.abs() > MAX_SHIFT { + return Err(bad(c, "the shift is more than 10,000 years")); + } + let mut delta = shift.round() as i64; let mut each: Option = None; if let Some(t) = str_param(p, "time") { let t = normalize_iso(t).ok_or_else(|| bad(c, format!("`{t}` is not a date (YYYY-MM-DDTHH:MM:SS)")))?; diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 9dd0e6b8..bc98b803 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -470,3 +470,24 @@ fn a_library_open_elsewhere_is_refused() { assert_eq!(other.catalog.to_snapshot(), expect); let _ = std::fs::remove_dir_all(&dir); } + +/// A capture-time shift from an agent can't overflow or write a date no one can read back: a +/// shift of more than 10,000 years is refused and nothing changes; an ordinary one still works. +#[test] +fn capture_time_shift_is_bounded() { + let mut s = crate::Session::with_demo(); + let id = s.catalog.photos().next().unwrap().id; + let before = s.catalog.photo(id).unwrap().captured.clone(); + for p in [ + serde_json::json!({"ids": [id.0], "shift": 1e30}), + serde_json::json!({"ids": [id.0], "hours": -1e15}), + serde_json::json!({"ids": [id.0], "shift": 9.3e18}), + ] { + let r = s.execute("photo.setCaptureTime", &p); + // a real refusal, not the panic guard catching an overflow ("failed unexpectedly") + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("10,000 years")), "{p}: {r:?}"); + assert_eq!(s.catalog.photo(id).unwrap().captured, before, "{p} changed nothing"); + } + s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [id.0], "hours": 1})).unwrap(); + assert_ne!(s.catalog.photo(id).unwrap().captured, before); +} From b5fe62e42697aec69aac51769ac51d1588d4b48d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 08:21:10 -0400 Subject: [PATCH 26/65] LRC-LIB-SMARTCOLL: check every smart-album rule value against its field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RuleSet::problems only checked that a field and operator exist (and, lately, yes/no values). Any other value was accepted and silently matched nothing or everything: "Rating ≥ abc", a date of "banana" or "2026-13", "Color Label isn't pink" (everything), "Title contains ''" (everything), "in the last 0 days", an album that is gone. RuleSet::check(&Catalog) -> Vec replaces it. Each Problem has the rule's position (path [1, 0]; shown as "rule 2.1: …"), so the editor can point at the row. It checks: - numbers (rating 0–5), shutter speeds (1/250, 2) - dates: 2026, 2026-08, 2026-08-14 or a full time; real dates only - between: two bounds; dates in order (2026-08-14 … 2026-08 is fine) - in the last: N > 0 and a known unit - choices: the ids, the flag's picked / rejected, a colour label's custom name (from the catalog), copyright-status spellings - text and keyword / people rules: something to look for, or "is empty" - album: exists and is a plain album - no empty groups (an empty rule list at the top stays "all photos") album.createSmart, album.setRules and library.filter report every problem. The editor's date defaults move from "" (which, as "between", matched every dated photo) to the current year, and its test now requires every default to be valid, except text and album rules, which wait for input. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/rules.rs | 237 ++++++++++++++++++++-- crates/engine/src/cmd/library.rs | 17 +- crates/engine/src/tests_library.rs | 22 ++ crates/ui-egui/src/panels/dialogs.rs | 2 +- crates/ui-egui/src/panels/rules_editor.rs | 24 ++- 5 files changed, 276 insertions(+), 26 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 9035a673..6701b858 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -146,6 +146,130 @@ pub fn field_group(field: &str) -> Option<&'static str> { FIELD_GROUPS.iter().find(|g| g.1.contains(&field)).map(|g| g.0) } +/// A rule that can't mean anything ([`RuleSet::check`]): where it is and what is wrong. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Problem { + /// Positions from the top rule list down: `[1, 0]` is the first rule of the second rule's group. + pub path: Vec, + pub message: String, +} + +impl std::fmt::Display for Problem { + /// `rule 2.1: rating is 0–5, not 9` (counting from 1, as people do). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let at: Vec = self.path.iter().map(|i| (i.saturating_add(1)).to_string()).collect(); + write!(f, "rule {}: {}", at.join("."), self.message) + } +} + +/// What is wrong with one rule, if anything ([`RuleSet::check`]). +fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog) -> Option { + let Some(kind) = field_kind(field) else { return Some(format!("unknown field `{field}`")) }; + if !ops_for(kind).iter().any(|o| o.0 == op) { + return Some(format!("`{field}` has no operator `{op}`")); + } + if matches!(op, "isEmpty" | "isNotEmpty") { + return None; + } + match kind { + Kind::Bool => bool_value(value).is_none().then(|| format!("`{field}` is yes or no, not {value}")), + Kind::Number if op == "between" => match value { + Value::Array(a) if a.len() == 2 => a.iter().find_map(|v| number_problem(field, v, cat)), + _ => Some(format!("`{field}` between needs two values, not {value}")), + }, + Kind::Number => number_problem(field, value, cat), + Kind::Date => date_problem(field, op, value), + Kind::Choice(choices) => choice_problem(field, value, choices, cat), + Kind::Text | Kind::Keywords => match value { + Value::String(s) if s.trim().is_empty() => Some(format!("`{field}` needs something to look for (or use “is empty”)")), + Value::Null => Some(format!("`{field}` needs something to look for (or use “is empty”)")), + Value::String(_) | Value::Number(_) => None, + other => Some(format!("`{field}` needs text, not {other}")), + }, + } +} + +fn number_problem(field: &str, v: &Value, cat: &Catalog) -> Option { + if field == "shutterSpeed" { + return shutter_value(v).is_null().then(|| format!("`shutterSpeed` needs a time like 1/250 or 2, not {v}")); + } + let Some(n) = number(v).filter(|n| n.is_finite()) else { return Some(format!("`{field}` needs a number, not {v}")) }; + match field { + "rating" if !(0.0..=5.0).contains(&n) => Some(format!("rating is 0–5, not {v}")), + "album" => { + let album = (n >= 0.0 && n.fract() == 0.0).then(|| cat.album(crate::AlbumId(n as u64))).flatten(); + match album { + None => Some(format!("no album {v}")), + Some(a) if a.is_smart() => Some(format!("album {v} is a smart album; only plain albums can be tested")), + Some(_) => None, + } + } + _ => None, + } +} + +/// A date as rules take it: `2026`, `2026-08`, `2026-08-14` or a full ISO time, and a real one +/// (no month 13, no 30 February). +fn is_rule_date(s: &str) -> bool { + let s = s.trim(); + let full = match s.len() { + 4 => format!("{s}-01-01"), + 7 => format!("{s}-01"), + _ => s.to_string(), + }; + s.len() >= 4 && crate::dates::normalize_iso(&full).is_some() +} + +fn date_problem(field: &str, op: &str, value: &Value) -> Option { + let date = |v: &Value| match v.as_str() { + Some(s) if is_rule_date(s) => None, + _ => Some(format!("`{field}` needs a date like 2026, 2026-08 or 2026-08-14, not {v}")), + }; + match op { + "inLast" | "notInLast" => { + let (n, unit) = match value { + Value::Object(o) => (o.get("n").and_then(number), o.get("unit").cloned().unwrap_or(json_str("days"))), + v => (number(v), json_str("days")), + }; + if !n.is_some_and(|n| n.is_finite() && n > 0.0) { + return Some(format!("`{field}` needs a number of hours, days, weeks, months or years more than 0")); + } + match unit.as_str() { + Some("hours" | "days" | "weeks" | "months" | "years") => None, + _ => Some(format!("`{field}`: unknown unit {unit} (hours, days, weeks, months or years)")), + } + } + "between" => match value { + Value::Array(a) if a.len() == 2 => a.iter().find_map(date).or_else(|| { + let bound = |i: usize| a.get(i).and_then(Value::as_str).unwrap_or("").trim(); + let (from, to) = (bound(0), bound(1)); + (from > to && !from.starts_with(to)).then(|| format!("`{field}`: the first date is after the second")) + }), + _ => Some(format!("`{field}` between needs two dates, not {value}")), + }, + _ => date(value), + } +} + +fn json_str(s: &str) -> Value { + Value::String(s.to_string()) +} + +fn choice_problem(field: &str, value: &Value, choices: &[(&str, &str)], cat: &Catalog) -> Option { + let known = value.as_str().is_some_and(|s| { + choices.iter().any(|c| c.0.eq_ignore_ascii_case(s.trim())) + || match field { + "flag" => matches!(s.trim().to_lowercase().as_str(), "picked" | "rejected"), + // a colour's name or the custom name given to it + "label" => cat.label_from_name(s).is_some(), + "copyrightStatus" => crate::CopyrightStatus::parse(s).is_some(), + _ => false, + } + }); + let ids: Vec<&str> = choices.iter().map(|c| c.0).collect(); + (!known).then(|| format!("`{field}` is one of {}, not {value}", ids.join(", "))) +} + /// What a yes/no rule's value means: `true`, `"yes"`, `1` (or `1.0`) or no value is yes; `false`, /// `"no"`, `0` is no (strings in any case); `None` for anything else, which matches nothing. pub fn bool_value(value: &Value) -> Option { @@ -541,23 +665,32 @@ impl RuleSet { }) } - /// Unknown fields or operators (for command validation), as readable messages. - pub fn problems(&self) -> Vec { + /// The rules that can't mean anything, in order: an unknown field, an operator the field + /// doesn't have, a value that isn't one of the field's (a number, a date, a choice, yes or + /// no, a plain album…), text with nothing to look for, an empty group. Commands refuse a rule + /// set with problems; the editor shows them. `cat` knows the albums and colour-label names. + pub fn check(&self, cat: &Catalog) -> Vec { let mut out = Vec::new(); - for r in &self.rules { - match r { - Rule::Group { group } => out.extend(group.problems()), - Rule::Field { field, op, value } => match field_kind(field) { - None => out.push(format!("unknown field `{field}`")), - Some(k) if !ops_for(k).iter().any(|o| o.0 == op) => out.push(format!("`{field}` has no operator `{op}`")), - Some(Kind::Bool) if bool_value(value).is_none() => out.push(format!("`{field}` is yes or no, not {value}")), - _ => {} - }, - } - } + self.check_into(cat, &mut Vec::new(), &mut out); out } + fn check_into(&self, cat: &Catalog, path: &mut Vec, out: &mut Vec) { + for (i, rule) in self.rules.iter().enumerate() { + path.push(i); + match rule { + Rule::Group { group } if group.rules.is_empty() => out.push(Problem { path: path.clone(), message: "an empty group".into() }), + Rule::Group { group } => group.check_into(cat, path, out), + Rule::Field { field, op, value } => { + if let Some(message) = field_problem(field, op, value, cat) { + out.push(Problem { path: path.clone(), message }); + } + } + } + path.pop(); + } + } + /// A short readable summary ("rating is ≥ 3 and keywords contains travel"). pub fn describe(&self) -> String { let join = match self.mode { @@ -679,7 +812,8 @@ mod tests { #[test] fn problems_and_description() { let r = rs(json!({"rules": [{"field": "rating", "op": "contains", "value": 1}, {"group": {"rules": [{"field": "nope", "op": "is"}]}}]})); - assert_eq!(r.problems(), vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]); + let messages: Vec = r.check(&Catalog::new()).into_iter().map(|p| p.message).collect(); + assert_eq!(messages, vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]); let r = rs( json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}), ); @@ -945,7 +1079,8 @@ mod tests { let r = rs(json!({"rules": [{"field": "edited", "op": "is", "value": true}, {"field": "cropped", "op": "is", "value": "false"}]})); assert_eq!(r.describe(), "has edits is yes and cropped is no"); let bad = rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})); - assert_eq!(bad.problems(), vec!["`edited` is yes or no, not \"maybe\"".to_string()]); + let messages: Vec = bad.check(&cat).into_iter().map(|p| p.message).collect(); + assert_eq!(messages, vec!["`edited` is yes or no, not \"maybe\"".to_string()]); } /// "In the last N" never overflows, whatever N an agent or a saved file holds: a zero, @@ -972,6 +1107,78 @@ mod tests { set_now(None); } + /// Every rule's value is checked against its field, so a rule that can't mean anything is + /// reported instead of quietly matching nothing (or everything). Each problem says which rule + /// it is about: "rule 2", or "rule 2.1" inside a group. + #[test] + fn values_are_checked_against_their_field() { + use crate::{Album, AlbumId, Op}; + let mut cat = Catalog::new(); + cat.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap(); + let smart = Album { smart: Some(Box::default()), ..Album::new(AlbumId(2), "Best") }; + cat.apply(Op::AddAlbum { album: smart }).unwrap(); + cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Client".into()) }).unwrap(); + let check = |rule: serde_json::Value| rs(json!({"rules": [rule]})).check(&cat); + let ok = |rule: serde_json::Value| assert!(check(rule.clone()).is_empty(), "{rule}: {:?}", check(rule.clone())); + let bad = |rule: serde_json::Value, want: &str| { + let p = check(rule.clone()); + assert!(p.iter().any(|p| p.message.contains(want)), "{rule}: wanted {want:?}, got {p:?}"); + }; + let r = |field: &str, op: &str, value: serde_json::Value| json!({"field": field, "op": op, "value": value}); + // numbers + ok(r("rating", "gte", json!(3))); + ok(r("rating", "gte", json!("4"))); + ok(r("rating", "between", json!([1, 5]))); + ok(r("aperture", "lte", json!("f/4"))); + ok(r("focalLength", "is", json!("50mm"))); + bad(r("rating", "gte", json!("abc")), "needs a number"); + bad(r("rating", "gte", json!(7)), "0–5"); + bad(r("iso", "between", json!([100])), "two"); + bad(r("iso", "between", json!("100")), "two"); + ok(r("shutterSpeed", "lte", json!("1/60"))); + bad(r("shutterSpeed", "lte", json!("fast")), "1/250"); + // dates + for d in ["2026", "2026-08", "2026-08-14", "2026-08-14T10:00:00"] { + ok(r("captureDate", "is", json!(d))); + } + ok(r("captureDate", "between", json!(["2026-01", "2026-12"]))); + for d in [json!("banana"), json!("2026-13"), json!("2026-02-30"), json!(""), json!(2026)] { + bad(r("captureDate", "after", d), "needs a date"); + } + bad(r("captureDate", "between", json!(["2026-12", "2026-01"])), "after the second"); + bad(r("captureDate", "between", json!(["", "2026"])), "needs a date"); + ok(r("captureDate", "inLast", json!({"n": 2, "unit": "weeks"}))); + ok(r("captureDate", "inLast", json!(7))); + bad(r("captureDate", "inLast", json!({"n": 0, "unit": "days"})), "more than 0"); + bad(r("captureDate", "notInLast", json!({"n": 3, "unit": "fortnights"})), "fortnights"); + ok(r("captureDate", "isEmpty", json!(null))); + // choices: the ids, the flag's aliases and a colour label's custom name + ok(r("kind", "is", json!("raw"))); + ok(r("flag", "is", json!("picked"))); + ok(r("label", "is", json!("client"))); + bad(r("label", "is", json!("pink")), "pink"); + bad(r("kind", "isNot", json!("photo")), "photo"); + // text needs something to look for + ok(r("title", "contains", json!("kite"))); + ok(r("title", "isEmpty", json!(null))); + bad(r("title", "contains", json!(" ")), "something to look for"); + bad(r("keywords", "startsWith", json!("")), "something to look for"); + // albums: a plain album that exists + ok(r("album", "is", json!(1))); + bad(r("album", "is", json!(999)), "no album 999"); + bad(r("album", "is", json!(2)), "smart album"); + // groups: an empty one is a mistake; problems inside one say where they are + let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"match": "any", "rules": []}}]})).check(&cat); + assert_eq!(p.iter().map(ToString::to_string).collect::>(), vec!["rule 2: an empty group".to_string()]); + let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"rules": [r("iso", "is", json!(100)), r("rating", "is", json!(9))]}}]})) + .check(&cat); + assert_eq!(p.len(), 1); + assert_eq!(p[0].path, vec![1, 1]); + assert_eq!(p[0].to_string(), "rule 2.2: rating is 0–5, not 9"); + // an empty rule list at the top is "all photos" (or none, for any), not a mistake + assert!(rs(json!({"rules": []})).check(&cat).is_empty()); + } + /// 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 0a7a3208..7d6c30ae 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -331,8 +331,8 @@ pub fn specs() -> Vec { return Err(bad("library.filter", "a folder is already shown (library.source): show another source first")); } // checked like a smart album's rules: an unknown field or a value that can't match is an error, not an empty grid - if let Some(problems) = f.rule_set.as_ref().map(|rs| rs.problems()).filter(|p| !p.is_empty()) { - return Err(bad("library.filter", problems.join("; "))); + if let Some(problems) = f.rule_set.as_ref().map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) { + return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::>().join("; "))); } s.filter = f; Ok(json!({"count": s.visible().len()})) @@ -813,7 +813,7 @@ pub fn specs() -> Vec { return Err(bad("album.createSmart", "empty name")); } let rules = match p.get("rules") { - Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart")?, + Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog)?, None => view_rules(s), }; let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); @@ -861,7 +861,7 @@ pub fn specs() -> Vec { let replace = bool_or(p, "replace", false); let folder = cur.library_folder.clone(); let base = if replace { Default::default() } else { cur }; - let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")?; + let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog)?; // the rules dialog has no folder field: replacing its rules keeps the folder // unless the call says (`libraryFolder: null`) to drop it if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() { @@ -1278,12 +1278,13 @@ pub fn specs() -> Vec { } /// `base` with a partial Filter (JSON) merged on top. -fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result { +fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str, cat: &lightcraft_catalog::Catalog) -> Result { let mut v = serde_json::to_value(base).unwrap_or_default(); lightcraft_develop::presets::deep_merge(&mut v, patch); let f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?; - if let Some(problem) = f.rule_set.as_ref().and_then(|r| r.problems().into_iter().next()) { - return Err(bad(c, problem)); + let problems = f.rule_set.as_ref().map(|r| r.check(cat)).unwrap_or_default(); + if !problems.is_empty() { + return Err(bad(c, problems.iter().map(ToString::to_string).collect::>().join("; "))); } Ok(f) } @@ -1319,7 +1320,7 @@ fn view_rules(s: &Session) -> lightcraft_catalog::Filter { } } } - merge_rules(&base, &Value::Object(patch), "").unwrap_or(base) + merge_rules(&base, &Value::Object(patch), "", &s.catalog).unwrap_or(base) } None => { let mut f = s.source.to_filter(&s.filter, &s.catalog); diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index bc98b803..8d5d5598 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -491,3 +491,25 @@ fn capture_time_shift_is_bounded() { s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [id.0], "hours": 1})).unwrap(); assert_ne!(s.catalog.photo(id).unwrap().captured, before); } + +/// Values are checked too: an agent's rule set with a date that isn't one, or a rating of 9, is +/// refused with the rule's position, by album.createSmart and library.filter alike. +#[test] +fn smart_rule_values_are_checked_by_commands() { + let mut s = crate::Session::with_demo(); + let r = s.execute( + "album.createSmart", + &serde_json::json!({"name": "Odd", "rules": {"ruleSet": {"rules": [ + {"field": "rating", "op": "gte", "value": 3}, + {"field": "captureDate", "op": "is", "value": "banana"} + ]}}}), + ); + let e = r.expect_err("a date that isn't one").to_string(); + assert!(e.contains("rule 2:") && e.contains("needs a date"), "{e}"); + assert!(s.catalog.albums().all(|a| a.name != "Odd")); + let e = s + .execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "gte", "value": 9}]}})) + .expect_err("rating 9") + .to_string(); + assert!(e.contains("rule 1: rating is 0–5, not 9"), "{e}"); +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index f2bdd668..e6e8ce62 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -245,7 +245,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| { crate::panels::rules_editor::edit(ui, rules, "rules", 0); }); - let problems = rules.problems(); + let problems: Vec = rules.check(&app.session.catalog).iter().map(ToString::to_string).collect(); // the folder an album made from a folder view carries is not in the editor, but it counts let folder = id.and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id))).and_then(|a| a.smart.as_deref().and_then(|f| f.library_folder.clone())); let f = lightcraft_catalog::Filter { rule_set: Some(rules.clone()), library_folder: folder, ..Default::default() }; diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index b85d7b3b..8c802c48 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -14,7 +14,13 @@ pub fn default_value(field: &str, op: &str) -> Value { match (field_kind(field), op) { (_, "isEmpty" | "isNotEmpty") => Value::Null, (Some(Kind::Date), "inLast" | "notInLast") => json!({"n": 30, "unit": "days"}), - (Some(Kind::Date), "between") => json!(["", ""]), + // this year (the catalog's clock): a date that means something, not an empty one that + // matches nothing ("is") or every date ("between") + (Some(Kind::Date), "between") => { + let year = this_year(); + json!([format!("{year}-01"), format!("{year}-12")]) + } + (Some(Kind::Date), _) => json!(this_year()), (Some(Kind::Number), "between") if field == "shutterSpeed" => json!(["1/1000", "1/125"]), (Some(Kind::Number), _) if field == "shutterSpeed" => json!("1/250"), (Some(Kind::Number), "between") => json!([0, 0]), @@ -25,6 +31,11 @@ pub fn default_value(field: &str, op: &str) -> Value { } } +/// The current year, `2026`. +fn this_year() -> String { + lightcraft_catalog::rules::now().get(..4).unwrap_or("2026").to_string() +} + /// A new rule (Rating ≥ 3). pub fn new_rule() -> Rule { Rule::Field { field: "rating".into(), op: "gte".into(), value: json!(3) } @@ -297,7 +308,11 @@ mod tests { mode: Match::All, rules: vec![Rule::Field { field: field.to_string(), op: op.to_string(), value: default_value(field, op) }], }; - assert!(rs.problems().is_empty(), "{field} {op}: {:?}", rs.problems()); + // only a text rule (nothing to look for yet) and Album (no album picked yet) wait for + // input; every other default is a rule that means something + let problems = rs.check(&cat); + let waits = matches!(kind, Kind::Text | Kind::Keywords) || *field == "album"; + assert!(problems.is_empty() || waits && !matches!(*op, "isEmpty" | "isNotEmpty"), "{field} {op}: {problems:?}"); let _ = rs.matches(&p, &cat); let v = default_value(field, op); let one = |v: &Value| match kind { @@ -315,6 +330,11 @@ mod tests { } } } + // a new date rule starts at this year, not at "" (and a between doesn't match every date) + lightcraft_catalog::rules::set_now(Some("2026-10-09T12:00:00".into())); + assert_eq!(default_value("captureDate", "is"), json!("2026")); + assert_eq!(default_value("captureDate", "between"), json!(["2026-01", "2026-12"])); + lightcraft_catalog::rules::set_now(None); assert_eq!(default_value("shutterSpeed", "gte"), json!("1/250")); assert_eq!(default_value("shutterSpeed", "between"), json!(["1/1000", "1/125"])); } From b0eba68303de5835b4e71fdd7b33ba4a1886a58e Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 08:21:10 -0400 Subject: [PATCH 27/65] LRC-LIB-SMARTCOLL: document rule value checks Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 2 +- docs/whats-new.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/parity.md b/docs/parity.md index 03f96f92..d9ee69ef 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0 (anything else is an error) | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, a full time; no 30 February), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. Not yet: the editor marks only the first problem, not its row | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | diff --git a/docs/whats-new.md b/docs/whats-new.md index d2519954..db6dc725 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -13,6 +13,10 @@ Aspect Ratio (a 24 MP photo cropped to a square is 16 MP). - Choice values read as words in every language: "Public Domain", "Picked", "Black & White" rather than ids like `publicDomain` (rules and agents keep using the ids; `album.ruleFields` adds `choiceLabels`). +- Rule values are checked: a rating of 9, a date like 2026-13 or "banana", a "between" whose dates are the wrong + way round, "in the last 0 days", a colour label that doesn't exist, "contains" with nothing to look for, an + album that is gone or smart, or an empty group is reported with its rule ("rule 2.1: rating is 0–5, not 9") + instead of quietly matching nothing or everything. New date rules start at the current year. - Yes/no fields (Has Edits, Cropped, Has GPS…) read Yes / No instead of true / false. A rule sent as `"false"` now means no (it used to count as yes), and a value that is neither yes nor no is refused. - Any Searchable Text also finds the state / province, alt text, people and a virtual copy's name. From 3c80695e97292b45002a68a0267413d13e1f1eca Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 08:31:20 -0400 Subject: [PATCH 28/65] LRC-LIB-SMARTCOLL: rule check and matcher agree on units, ratings and label names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review of the value checks (false rejections and one silent mismatch): - In-the-last units: one table (unit_secs) for check and matcher. Any case, singular or plural; no unit means days. An unknown unit is refused and now matches nothing, where the matcher used to read it as days, so "week" meant one day. - Rating: refused only when no rating 0–5 could meet the rule, tested with the matcher's own comparison ("is 9", "≥ 7", "between 6 and 9"). "< 6" is broad but valid. Message: "no rating 0–5 is ≥ 7". - Catalog::label_from_name compares with Unicode lowercase, as rules do, so a custom label "Été" is found as "été". - Empty text hints at the operator that fits: "isn't empty" for isn't / doesn't contain, "is empty" otherwise. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/lib.rs | 4 +- crates/catalog/src/rules.rs | 118 +++++++++++++++++++++-------- crates/engine/src/tests_library.rs | 2 +- 3 files changed, 91 insertions(+), 33 deletions(-) diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 884ec658..5017423e 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -373,7 +373,9 @@ impl Catalog { /// The label a name stands for: a custom name first, then a colour's own name (any case). pub fn label_from_name(&self, name: &str) -> Option { let name = name.trim(); - self.label_names.iter().find(|(_, n)| n.trim().eq_ignore_ascii_case(name)).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name)) + // any case, accents included (Été / été), as rules compare names + let name_lower = name.to_lowercase(); + self.label_names.iter().find(|(_, n)| n.trim().to_lowercase() == name_lower).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name)) } // ---- writes diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 6701b858..702b2191 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -173,17 +173,27 @@ fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog) -> Option< } match kind { Kind::Bool => bool_value(value).is_none().then(|| format!("`{field}` is yes or no, not {value}")), - Kind::Number if op == "between" => match value { - Value::Array(a) if a.len() == 2 => a.iter().find_map(|v| number_problem(field, v, cat)), - _ => Some(format!("`{field}` between needs two values, not {value}")), - }, - Kind::Number => number_problem(field, value, cat), + Kind::Number => { + let each = match value { + Value::Array(a) if op == "between" && a.len() == 2 => a.iter().find_map(|v| number_problem(field, v, cat)), + _ if op == "between" => Some(format!("`{field}` between needs two values, not {value}")), + v => number_problem(field, v, cat), + }; + // a rating rule no rating could meet ("is 9", "≥ 7"); "< 6" is fine, if broad + each.or_else(|| { + let label = ops_for(kind).iter().find(|o| o.0 == op).map_or(op, |o| o.1); + (field == "rating" && !(0..=5).any(|r| num_op(op, Some(f64::from(r)), value))).then(|| format!("no rating 0–5 {label} {value}")) + }) + } Kind::Date => date_problem(field, op, value), Kind::Choice(choices) => choice_problem(field, value, choices, cat), Kind::Text | Kind::Keywords => match value { - Value::String(s) if s.trim().is_empty() => Some(format!("`{field}` needs something to look for (or use “is empty”)")), - Value::Null => Some(format!("`{field}` needs something to look for (or use “is empty”)")), - Value::String(_) | Value::Number(_) => None, + Value::String(s) if !s.trim().is_empty() => None, + Value::String(_) | Value::Null => { + let instead = if matches!(op, "isNot" | "notContains") { "isn't empty" } else { "is empty" }; + Some(format!("`{field}` needs something to look for (or use “{instead}”)")) + } + Value::Number(_) => None, other => Some(format!("`{field}` needs text, not {other}")), }, } @@ -195,7 +205,6 @@ fn number_problem(field: &str, v: &Value, cat: &Catalog) -> Option { } let Some(n) = number(v).filter(|n| n.is_finite()) else { return Some(format!("`{field}` needs a number, not {v}")) }; match field { - "rating" if !(0.0..=5.0).contains(&n) => Some(format!("rating is 0–5, not {v}")), "album" => { let album = (n >= 0.0 && n.fract() == 0.0).then(|| cat.album(crate::AlbumId(n as u64))).flatten(); match album { @@ -228,16 +237,13 @@ fn date_problem(field: &str, op: &str, value: &Value) -> Option { match op { "inLast" | "notInLast" => { let (n, unit) = match value { - Value::Object(o) => (o.get("n").and_then(number), o.get("unit").cloned().unwrap_or(json_str("days"))), - v => (number(v), json_str("days")), + Value::Object(o) => (o.get("n").and_then(number), o.get("unit").cloned().unwrap_or(Value::Null)), + v => (number(v), Value::Null), }; if !n.is_some_and(|n| n.is_finite() && n > 0.0) { return Some(format!("`{field}` needs a number of hours, days, weeks, months or years more than 0")); } - match unit.as_str() { - Some("hours" | "days" | "weeks" | "months" | "years") => None, - _ => Some(format!("`{field}`: unknown unit {unit} (hours, days, weeks, months or years)")), - } + unit_secs(&unit).is_none().then(|| format!("`{field}`: unknown unit {unit} (hours, days, weeks, months or years)")) } "between" => match value { Value::Array(a) if a.len() == 2 => a.iter().find_map(date).or_else(|| { @@ -251,10 +257,6 @@ fn date_problem(field: &str, op: &str, value: &Value) -> Option { } } -fn json_str(s: &str) -> Value { - Value::String(s.to_string()) -} - fn choice_problem(field: &str, value: &Value, choices: &[(&str, &str)], cat: &Catalog) -> Option { let known = value.as_str().is_some_and(|s| { choices.iter().any(|c| c.0.eq_ignore_ascii_case(s.trim())) @@ -464,6 +466,26 @@ fn shutter_value(value: &Value) -> Value { } } +/// Seconds in one "in the last…" unit: hours, days, weeks, months or years, in any case, singular +/// or plural; none means days. `None` for anything else, which the check refuses and the matcher +/// matches nothing with. +fn unit_secs(unit: &Value) -> Option { + let day = 86_400.0; + let name = match unit { + Value::Null => return Some(day), + Value::String(s) => s.trim().to_lowercase(), + _ => return None, + }; + match name.strip_suffix('s').unwrap_or(&name) { + "hour" => Some(3600.0), + "day" => Some(day), + "week" => Some(7.0 * day), + "month" => Some(30.4375 * day), + "year" => Some(365.25 * day), + _ => None, + } +} + /// The longest "in the last…" span: 10,000 years, beyond any capture date ("ever"). const MAX_LAST_SECS: f64 = 10_000.0 * 365.25 * 86_400.0; @@ -472,17 +494,10 @@ const MAX_LAST_SECS: f64 = 10_000.0 * 365.25 * 86_400.0; /// is that (so the arithmetic on it can't overflow). fn last_secs(value: &Value) -> Option { let (n, unit) = match value { - Value::Object(o) => (o.get("n").and_then(number)?, o.get("unit").and_then(Value::as_str).unwrap_or("days")), - v => (number(v)?, "days"), - }; - let day = 86_400.0; - let per = match unit { - "hours" => 3600.0, - "weeks" => 7.0 * day, - "months" => 30.4375 * day, - "years" => 365.25 * day, - _ => day, + Value::Object(o) => (o.get("n").and_then(number)?, o.get("unit").unwrap_or(&Value::Null)), + v => (number(v)?, &Value::Null), }; + let per = unit_secs(unit)?; let secs = n * per; if secs.is_nan() || secs <= 0.0 { return None; @@ -1132,7 +1147,7 @@ mod tests { ok(r("aperture", "lte", json!("f/4"))); ok(r("focalLength", "is", json!("50mm"))); bad(r("rating", "gte", json!("abc")), "needs a number"); - bad(r("rating", "gte", json!(7)), "0–5"); + bad(r("rating", "gte", json!(7)), "no rating 0–5 is ≥ 7"); bad(r("iso", "between", json!([100])), "two"); bad(r("iso", "between", json!("100")), "two"); ok(r("shutterSpeed", "lte", json!("1/60"))); @@ -1174,11 +1189,52 @@ mod tests { .check(&cat); assert_eq!(p.len(), 1); assert_eq!(p[0].path, vec![1, 1]); - assert_eq!(p[0].to_string(), "rule 2.2: rating is 0–5, not 9"); + assert_eq!(p[0].to_string(), "rule 2.2: no rating 0–5 is 9"); // an empty rule list at the top is "all photos" (or none, for any), not a mistake assert!(rs(json!({"rules": []})).check(&cat).is_empty()); } + /// The check and the matcher agree. Units are any case, singular or plural, and missing means + /// days; an unknown unit is refused and matches nothing (it used to count as days, so "week" was + /// one day). A rating rule is refused only when no rating 0–5 could meet it. A colour label's + /// custom name matches in any case, accents included. Empty text hints at the right operator. + #[test] + fn check_and_matcher_agree() { + use crate::Op; + set_now(Some("2026-09-01T00:00:00".into())); + let mut cat = Catalog::new(); + cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Été".into()) }).unwrap(); + let p = photo(1); // captured 2026-08-14 (18 days before now), red label, rating 4 + let rule = |field: &str, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": field, "op": op, "value": value}]})); + let valid = |r: &RuleSet| r.check(&cat).is_empty(); + for unit in [json!("Days"), json!("DAY"), json!("days"), json!(null)] { + let r = rule("captureDate", "inLast", json!({"n": 30, "unit": unit})); + assert!(valid(&r) && r.matches(&p, &cat), "{unit}"); + } + let r = rule("captureDate", "inLast", json!({"n": 30})); + assert!(valid(&r) && r.matches(&p, &cat), "no unit is days"); + let week = rule("captureDate", "inLast", json!({"n": 1, "unit": "week"})); + assert!(valid(&week) && !week.matches(&p, &cat), "a week, not a day: 18 days ago is outside it"); + assert!(rule("captureDate", "inLast", json!({"n": 3, "unit": "Weeks"})).matches(&p, &cat)); + let odd = rule("captureDate", "inLast", json!({"n": 30, "unit": "fortnights"})); + assert!(!valid(&odd) && !odd.matches(&p, &cat), "an unknown unit is refused and matches nothing"); + // rating: only a rule no rating could meet + for (op, v) in [("lt", json!(6)), ("gt", json!(-1)), ("isNot", json!(9)), ("between", json!([3, 8]))] { + assert!(valid(&rule("rating", op, v.clone())), "{op} {v}"); + } + for (op, v) in [("is", json!(9)), ("gte", json!(7)), ("gt", json!(5)), ("lt", json!(0)), ("between", json!([6, 9]))] { + assert!(!valid(&rule("rating", op, v.clone())), "{op} {v}"); + } + // a custom label name, any case, accents too + let r = rule("label", "is", json!("été")); + assert!(valid(&r) && r.matches(&p, &cat)); + // "isn't" with nothing points at "isn't empty", the others at "is empty" + let hint = |op: &str| rule("title", op, json!("")).check(&cat).first().map(|p| p.message.clone()).unwrap_or_default(); + assert!(hint("isNot").contains("isn't empty"), "{}", hint("isNot")); + assert!(hint("contains").contains("“is empty”"), "{}", hint("contains")); + set_now(None); + } + /// 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/tests_library.rs b/crates/engine/src/tests_library.rs index 8d5d5598..fce8ef84 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -511,5 +511,5 @@ fn smart_rule_values_are_checked_by_commands() { .execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "gte", "value": 9}]}})) .expect_err("rating 9") .to_string(); - assert!(e.contains("rule 1: rating is 0–5, not 9"), "{e}"); + assert!(e.contains("rule 1: no rating 0–5 is ≥ 9"), "{e}"); } From 2d29dde4d0ace4557a41425a1b2a6805bc14c244 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 08:31:20 -0400 Subject: [PATCH 29/65] LRC-LIB-SMARTCOLL: a stale album rule no longer drops the filter bar from the view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review: view_rules merged the filter bar's settings onto a smart album's rules through merge_rules, which now also checks the rules, and fell back to the bare album rules on any problem. A smart album testing an album that was later deleted therefore lost the filter bar (e.g. Rating ≥ 3) from Create Smart Album from Filter and the dialog's count, without a word. merge_filter does the merge alone; merge_rules is merge_filter plus the check, for album.createSmart / setRules. view_rules uses merge_filter. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 14 +++++++++++--- crates/engine/src/tests_organize.rs | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 7d6c30ae..820f511e 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1278,10 +1278,16 @@ pub fn specs() -> Vec { } /// `base` with a partial Filter (JSON) merged on top. -fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str, cat: &lightcraft_catalog::Catalog) -> Result { +/// `patch` merged onto `base`, unchecked (see [`merge_rules`]). +fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result { let mut v = serde_json::to_value(base).unwrap_or_default(); lightcraft_develop::presets::deep_merge(&mut v, patch); - let f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?; + serde_json::from_value(v).map_err(|e| bad(c, e.to_string())) +} + +/// `patch` merged onto `base`, refused when its rule set has problems (`RuleSet::check`). +fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str, cat: &lightcraft_catalog::Catalog) -> Result { + let f = merge_filter(base, patch, c)?; let problems = f.rule_set.as_ref().map(|r| r.check(cat)).unwrap_or_default(); if !problems.is_empty() { return Err(bad(c, problems.iter().map(ToString::to_string).collect::>().join("; "))); @@ -1320,7 +1326,9 @@ fn view_rules(s: &Session) -> lightcraft_catalog::Filter { } } } - merge_rules(&base, &Value::Object(patch), "", &s.catalog).unwrap_or(base) + // the view as it is: a rule that no longer checks (its album was deleted) just matches + // nothing there, and mustn't cost the filter bar's settings + merge_filter(&base, &Value::Object(patch), "").unwrap_or(base) } None => { let mut f = s.source.to_filter(&s.filter, &s.catalog); diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index 7fb24806..519a2dc6 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -79,6 +79,22 @@ fn smart_album_from_smart_album_view_keeps_its_rules() { assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id))); } +/// Saving the view of a smart album keeps the filter bar's settings even when the album holds a +/// rule that no longer checks (it tests an album that has since been deleted). +#[test] +fn smart_album_view_keeps_the_filter_bar_over_a_stale_rule() { + let mut s = Session::with_demo(); + let gone = s.execute("album.create", &json!({"name": "Gone", "addSelected": false})).unwrap()["id"].as_u64().unwrap(); + let rules = json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": gone}]}}); + let id = s.execute("album.createSmart", &json!({"name": "Not in Gone", "rules": rules})).unwrap()["id"].as_u64().unwrap(); + s.execute("album.delete", &json!({"id": gone})).unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap(); + s.execute("library.filter", &json!({"rating": 3})).unwrap(); + let r = s.view_rules(); + assert_eq!(r.rating, 3, "the filter bar's rating survives"); + assert!(r.rule_set.is_some(), "and so do the album's own rules"); +} + #[test] fn virtual_copies_are_independent_and_persist() { let dir = temp_dir("vc"); From 7b7807b499a55c2cb7cc89ffbca4d6a9e3d362e8 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 08:43:31 -0400 Subject: [PATCH 30/65] LRC-LIB-SMARTCOLL: rule editor marks bad rules, blocks OK and picks albums from a list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog showed only the first problem, in place of the match count, without saying which rule it was about. OK stayed clickable, and an edit renamed the album before its rules were refused. - Each rule with a problem gets the message right under its row, in the caution colour; inside groups too, and on an empty group's frame. The rules_editor::Env carries the problems from RuleSet::check, and each row finds its own by path. Markers register `ruleProblem:`. - The summary reads "Fix the marked rules to save this album." and OK is disabled while any problem remains. - confirm_dialog refuses problems before anything changes (not even the name), and the control channel's ui.dialog.confirm keeps the editor open on that error, as the button does. - Album is its own field kind: is / isn't only (it was a number field that offered ≥ and between). It picks its album from a list of the plain albums (`ruleAlbum:` / `ruleAlbumItem::`) and starts with none chosen ("choose an album"). album.ruleFields reports kind "album". - Found while testing: the number editor's spinner clamped a stored value outside its range as soon as it was drawn, so "Rating is 9" quietly became "Rating is 5". It now uses clamp_existing_to_range(false): the range limits dragging, and a stored value stays as it is, marked by the check. New labels (Choose an album…, No albums yet, Fix the marked rules…) are translated in all seven locales. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/rules.rs | 47 ++++++++++------ crates/engine/src/cmd/library.rs | 3 +- crates/engine/src/tests_library.rs | 2 + crates/ui-egui/locales/de.json | 5 +- crates/ui-egui/locales/es.json | 5 +- crates/ui-egui/locales/ja.json | 5 +- crates/ui-egui/locales/pt-br.json | 5 +- crates/ui-egui/locales/ru.json | 5 +- crates/ui-egui/locales/zh-hans.json | 5 +- crates/ui-egui/locales/zh-hant.json | 5 +- crates/ui-egui/src/control.rs | 8 ++- crates/ui-egui/src/panels/dialogs.rs | 33 +++++++---- crates/ui-egui/src/panels/rules_editor.rs | 64 +++++++++++++++++++--- crates/ui-egui/src/tests_masking.rs | 67 +++++++++++++++++++++++ 14 files changed, 215 insertions(+), 44 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 702b2191..d8d75f11 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -48,6 +48,8 @@ pub enum Kind { Keywords, Number, Date, + /// A plain album, by id: in it or not. + Album, /// One of a fixed set: (id, label). Rules store the id; people see the label. Choice(&'static [(&'static str, &'static str)]), Bool, @@ -65,7 +67,7 @@ pub const FIELDS: &[(&str, &str, Kind)] = &[ ), ("text", "Any Searchable Text", Kind::Text), // Source - ("album", "Album", Kind::Number), + ("album", "Album", Kind::Album), ("virtualCopy", "Virtual Copy", Kind::Bool), ("copyName", "Copy Name", Kind::Text), ("stacked", "In a Stack", Kind::Bool), @@ -173,11 +175,12 @@ fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog) -> Option< } match kind { Kind::Bool => bool_value(value).is_none().then(|| format!("`{field}` is yes or no, not {value}")), + Kind::Album => album_problem(value, cat), Kind::Number => { let each = match value { - Value::Array(a) if op == "between" && a.len() == 2 => a.iter().find_map(|v| number_problem(field, v, cat)), + Value::Array(a) if op == "between" && a.len() == 2 => a.iter().find_map(|v| number_problem(field, v)), _ if op == "between" => Some(format!("`{field}` between needs two values, not {value}")), - v => number_problem(field, v, cat), + v => number_problem(field, v), }; // a rating rule no rating could meet ("is 9", "≥ 7"); "< 6" is fine, if broad each.or_else(|| { @@ -199,21 +202,23 @@ fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog) -> Option< } } -fn number_problem(field: &str, v: &Value, cat: &Catalog) -> Option { +fn number_problem(field: &str, v: &Value) -> Option { if field == "shutterSpeed" { return shutter_value(v).is_null().then(|| format!("`shutterSpeed` needs a time like 1/250 or 2, not {v}")); } - let Some(n) = number(v).filter(|n| n.is_finite()) else { return Some(format!("`{field}` needs a number, not {v}")) }; - match field { - "album" => { - let album = (n >= 0.0 && n.fract() == 0.0).then(|| cat.album(crate::AlbumId(n as u64))).flatten(); - match album { - None => Some(format!("no album {v}")), - Some(a) if a.is_smart() => Some(format!("album {v} is a smart album; only plain albums can be tested")), - Some(_) => None, - } - } - _ => None, + number(v).filter(|n| n.is_finite()).is_none().then(|| format!("`{field}` needs a number, not {v}")) +} + +/// An Album rule's value: an album that exists and holds photos itself (not a smart album). +fn album_problem(v: &Value, cat: &Catalog) -> Option { + if v.is_null() { + return Some("choose an album".into()); + } + let id = number(v).filter(|n| n.is_finite() && *n >= 0.0 && n.fract() == 0.0); + match id.and_then(|n| cat.album(crate::AlbumId(n as u64))) { + None => Some(format!("no album {v}")), + Some(a) if a.is_smart() => Some(format!("album {v} is a smart album; only plain albums can be tested")), + Some(_) => None, } } @@ -342,7 +347,7 @@ pub fn ops_for(kind: Kind) -> &'static [(&'static str, &'static str)] { ("notInLast", "isn't in the last"), ("isEmpty", "is unknown"), ], - Kind::Choice(_) => &[("is", "is"), ("isNot", "isn't")], + Kind::Choice(_) | Kind::Album => &[("is", "is"), ("isNot", "isn't")], Kind::Bool => &[("is", "is")], } } @@ -1235,6 +1240,16 @@ mod tests { set_now(None); } + /// An album is in it or not: Album offers "is" and "isn't", never "≥" or "between". + #[test] + fn album_rules_are_is_or_isnt() { + assert_eq!(field_kind("album"), Some(Kind::Album)); + let ops: Vec<&str> = ops_for(Kind::Album).iter().map(|o| o.0).collect(); + assert_eq!(ops, ["is", "isNot"]); + let r = rs(json!({"rules": [{"field": "album", "op": "gte", "value": 1}]})); + assert_eq!(r.check(&Catalog::new()).first().map(|p| p.message.as_str()), Some("`album` has no operator `gte`")); + } + /// 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 820f511e..eef94db6 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -823,7 +823,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? (ids), choiceLabels? ({id: label})}] for ruleSet rules, in menu order", always, |_, _| { + cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind (text|keywords|number|date|choice|bool|album: an album id), 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() @@ -832,6 +832,7 @@ pub fn specs() -> Vec { Kind::Text => "text", Kind::Keywords => "keywords", Kind::Number => "number", + Kind::Album => "album", Kind::Date => "date", Kind::Choice(_) => "choice", Kind::Bool => "bool", diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index fce8ef84..b937d68d 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -260,6 +260,8 @@ fn smart_album_rule_sets() { 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 album = fields.as_array().unwrap().iter().find(|f| f["field"] == "album").unwrap(); + assert_eq!((album["kind"].as_str(), album["ops"].as_array().map(Vec::len)), (Some("album"), Some(2)), "an album id, is / isn't"); 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 936a11ec..2ae0af11 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1896,5 +1896,8 @@ "Portrait (tall)": "Hochformat", "Square": "Quadratisch", "In Color": "Farbig", - "No Label": "Ohne Markierung" + "No Label": "Ohne Markierung", + "Choose an album…": "Album wählen…", + "No albums yet": "Noch keine Alben", + "Fix the marked rules to save this album.": "Korrigiere die markierten Regeln, um dieses Album zu speichern." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 475744d2..812841ab 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1741,5 +1741,8 @@ "Portrait (tall)": "Vertical (retrato)", "Square": "Cuadrada", "In Color": "En color", - "No Label": "Sin etiqueta" + "No Label": "Sin etiqueta", + "Choose an album…": "Elige un álbum…", + "No albums yet": "Aún no hay álbumes", + "Fix the marked rules to save this album.": "Corrige las reglas marcadas para guardar este álbum." } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index de1a87de..84dce170 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1745,5 +1745,8 @@ "smart album value|Yes": "あり", "smart album value|No": "なし", "In a Stack": "スタック", - "Cropped": "切り抜き" + "Cropped": "切り抜き", + "Choose an album…": "アルバムを選択…", + "No albums yet": "アルバムはまだありません", + "Fix the marked rules to save this album.": "このアルバムを保存するには、印の付いた条件を修正してください。" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2bac81d4..230ca8af 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1741,5 +1741,8 @@ "Portrait (tall)": "Retrato (vertical)", "Square": "Quadrada", "In Color": "Colorida", - "No Label": "Sem rótulo" + "No Label": "Sem rótulo", + "Choose an album…": "Escolha um álbum…", + "No albums yet": "Ainda não há álbuns", + "Fix the marked rules to save this album.": "Corrija as regras marcadas para salvar este álbum." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index cd36a04e..4a8da93b 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1413,5 +1413,8 @@ "Portrait (tall)": "Книжная", "Square": "Квадратная", "In Color": "Цветное", - "No Label": "Без метки" + "No Label": "Без метки", + "Choose an album…": "Выберите альбом…", + "No albums yet": "Альбомов пока нет", + "Fix the marked rules to save this album.": "Исправьте отмеченные условия, чтобы сохранить альбом." } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index e1d579ac..f9c093fd 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1741,5 +1741,8 @@ "Portrait (tall)": "纵向", "Square": "正方形", "In Color": "彩色", - "No Label": "无标签" + "No Label": "无标签", + "Choose an album…": "选择相册…", + "No albums yet": "还没有相册", + "Fix the marked rules to save this album.": "请修正标记的规则以保存此相册。" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index dc80a7b3..1c13a96e 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1741,5 +1741,8 @@ "Portrait (tall)": "直向", "Square": "正方形", "In Color": "彩色", - "No Label": "無標籤" + "No Label": "無標籤", + "Choose an album…": "選擇相簿…", + "No albums yet": "還沒有相簿", + "Fix the marked rules to save this album.": "請修正標記的規則以儲存此相簿。" } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index dcd3d1f8..3512d8f7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -310,8 +310,12 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest "ui.dialog.confirm" => match app.ui.dialog.take() { Some(d) => { let r = crate::panels::dialogs::confirm_dialog(app, &d); - // the import review stays open on an error, as with its button - if (r.is_err() && matches!(d, crate::state::Dialog::Import { .. } | crate::state::Dialog::SamModel { .. })) + // the import review and the rule editor stay open on an error, as with their buttons + if (r.is_err() + && matches!( + d, + crate::state::Dialog::Import { .. } | crate::state::Dialog::SamModel { .. } | crate::state::Dialog::SmartRules { .. } + )) || (r.is_ok() && crate::panels::dialogs::keeps_open(app, &d)) { app.ui.dialog = Some(d); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index e6e8ce62..3d429444 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -242,22 +242,28 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)); crate::widgets::register(ui.ctx(), "field:smartName", r.rect); ui.add_space(6.0); + // checked before drawing, so each row marks its own problem + let env = crate::panels::rules_editor::Env { + problems: rules.check(&app.session.catalog), + albums: app.session.catalog.albums().filter(|a| !a.is_smart() && !a.folder).map(|a| (a.id.0, a.name.clone())).collect(), + }; egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| { - crate::panels::rules_editor::edit(ui, rules, "rules", 0); + crate::panels::rules_editor::edit(ui, rules, "rules", 0, &[], &env); }); - let problems: Vec = rules.check(&app.session.catalog).iter().map(ToString::to_string).collect(); + let problems = rules.check(&app.session.catalog); // the folder an album made from a folder view carries is not in the editor, but it counts let folder = id.and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id))).and_then(|a| a.smart.as_deref().and_then(|f| f.library_folder.clone())); let f = lightcraft_catalog::Filter { rule_set: Some(rules.clone()), library_folder: folder, ..Default::default() }; let n = if problems.is_empty() { app.session.catalog.query(&f, &Default::default()).len() } else { 0 }; ui.add_space(4.0); - ui.label( - egui::RichText::new(match problems.first() { - Some(p) => p.clone(), - None => crate::i18n::tr_format!("{n} photo{} match · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n), - }) - .color(t.text_dim), - ); + if problems.is_empty() { + ui.label( + egui::RichText::new(crate::i18n::tr_format!("{n} photo{} match · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n)) + .color(t.text_dim), + ); + } else { + ui.label(egui::RichText::new(crate::i18n::tr("Fix the marked rules to save this album.")).color(t.caution)); + } } Dialog::NewSmartAlbum { name, .. } => { let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)); @@ -833,7 +839,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { _ => "OK", }; // installing waits for the licence to be accepted - let can_confirm = informational || !matches!(&dlg, Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. }); + let can_confirm = informational + || !matches!(&dlg, Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. }) + // smart-album rules that can't mean anything wait to be fixed + && !matches!(&dlg, Dialog::SmartRules { rules, .. } if !rules.check(&app.session.catalog).is_empty()); let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, egui::Button::new(crate::i18n::tr(ok)))); if let Some(r) = &r { crate::widgets::register(ui.ctx(), "button:dialogOk", r.rect); @@ -1075,6 +1084,10 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { + // refused before anything changes (not even the name) + if let Some(problem) = rules.check(&app.session.catalog).first() { + return Err(problem.to_string()); + } let name = if name.trim().is_empty() { "Smart Album".to_string() } else { name.trim().to_string() }; match id { Some(id) => { diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index 8c802c48..2cf09c29 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -2,7 +2,7 @@ //! value), with nested groups. Field and operator lists come from `lightcraft_catalog::rules`. use egui::RichText; -use lightcraft_catalog::rules::{FIELD_GROUPS, Kind, TOP_LEVEL_FIELDS, bool_value, field_kind, field_label, ops_for}; +use lightcraft_catalog::rules::{FIELD_GROUPS, Kind, Problem, TOP_LEVEL_FIELDS, bool_value, field_kind, field_label, ops_for}; use lightcraft_catalog::{Match, Rule, RuleSet}; use serde_json::{Value, json}; @@ -27,6 +27,8 @@ pub fn default_value(field: &str, op: &str) -> Value { (Some(Kind::Number), _) => json!(if field == "rating" { 3 } else { 0 }), (Some(Kind::Choice(c)), _) => json!(c.first().map_or("", |c| c.0)), (Some(Kind::Bool), _) => json!(true), + // picked from the list + (Some(Kind::Album), _) => Value::Null, _ => json!(""), } } @@ -77,18 +79,49 @@ fn number_value(ui: &mut egui::Ui, v: &mut Value, field: &str) { "longEdge" | "shortEdge" => (0.0, 100_000.0, 10.0), _ => (0.0, 100_000.0, 0.5), }; - let whole = matches!(field, "rating" | "iso" | "album" | "keywordCount" | "personCount" | "longEdge" | "shortEdge"); - let dv = egui::DragValue::new(&mut n).range(lo..=hi).speed(speed); + let whole = matches!(field, "rating" | "iso" | "keywordCount" | "personCount" | "longEdge" | "shortEdge"); + // the range limits dragging; a stored value outside it (a rating of 9 from an agent) is shown + // as it is, marked by the check, not quietly clamped to something that matches + let dv = egui::DragValue::new(&mut n).range(lo..=hi).clamp_existing_to_range(false).speed(speed); let dv = if whole { dv.fixed_decimals(0) } else { dv.max_decimals(2) }; if ui.add(dv).changed() { *v = if whole { json!(n.round() as i64) } else { json!(n) }; } } +/// What the editor shows besides the rules: the problems `RuleSet::check` found in them (each +/// row marks its own) and the albums an Album rule can test. +#[derive(Default)] +pub struct Env { + pub problems: Vec, + /// The plain albums: (id, name), in the sidebar's order. + pub albums: Vec<(u64, String)>, +} + +/// An Album rule's value: one of the plain albums, picked by name. +fn album_value(ui: &mut egui::Ui, v: &mut Value, salt: &str, env: &Env) { + let cur = v.as_u64(); + let text = env.albums.iter().find(|a| Some(a.0) == cur).map_or_else(|| crate::i18n::tr("Choose an album…").to_string(), |a| a.1.clone()); + let r = egui::ComboBox::from_id_salt(format!("{salt}-album")).width(160.0).selected_text(text).show_ui(ui, |ui| { + for (id, name) in &env.albums { + let r = ui.selectable_label(cur == Some(*id), name); + register(ui.ctx(), format!("ruleAlbumItem:{id}:{salt}"), r.rect); + if r.clicked() { + *v = json!(id); + } + } + if env.albums.is_empty() { + ui.label(crate::i18n::tr("No albums yet")); + } + }); + register(ui.ctx(), format!("ruleAlbum:{salt}"), r.response.rect); +} + /// The value editor for one rule. -fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &str) { +fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &str, env: &Env) { match (field_kind(field), op) { (_, "isEmpty" | "isNotEmpty") => {} + (Some(Kind::Album), _) => album_value(ui, v, salt, env), (Some(Kind::Date), "inLast" | "notInLast") => { if !v.is_object() { *v = default_value(field, op); @@ -209,8 +242,18 @@ fn field_menu(ui: &mut egui::Ui, salt: &str, field: &mut String, op: &mut String *value = default_value(field, op); } -/// Edit `rs` in place; `salt` keeps widget ids apart between groups. -pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) { +/// A rule's problem, shown under it in the caution colour (`ruleProblem:` for tests and +/// agents). +fn problem_note(ui: &mut egui::Ui, problem: Option<&Problem>, salt: &str) { + let Some(p) = problem else { return }; + let t = Tokens::get(ui.ctx()); + let r = ui.label(RichText::new(format!("⚠ {}", p.message)).color(t.caution).small()); + register(ui.ctx(), format!("ruleProblem:{salt}"), r.rect); +} + +/// Edit `rs` in place; `salt` keeps widget ids apart between groups, `path` is where `rs` sits +/// (empty at the top) so each row finds its own problem in `env`. +pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize, path: &[usize], env: &Env) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { ui.label(RichText::new(crate::i18n::tr("Match")).color(t.text_label)); @@ -221,6 +264,8 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) { let mut insert: Option<(usize, Rule)> = None; for (i, rule) in rs.rules.iter_mut().enumerate() { let rsalt = format!("{salt}-{i}"); + let here: Vec = path.iter().copied().chain([i]).collect(); + let problem = env.problems.iter().find(|p| p.path == here); match rule { Rule::Group { group } => { egui::Frame::group(ui.style()).show(ui, |ui| { @@ -228,8 +273,9 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) { if ui.small_button("−").on_hover_text(crate::i18n::tr("Remove this group")).clicked() { remove = Some(i); } - ui.vertical(|ui| edit(ui, group, &rsalt, depth + 1)); + ui.vertical(|ui| edit(ui, group, &rsalt, depth + 1, &here, env)); }); + problem_note(ui, problem, &rsalt); }); } Rule::Field { field, op, value } => { @@ -248,7 +294,7 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) { } } }); - value_editor(ui, field, op, value, &rsalt); + value_editor(ui, field, op, value, &rsalt, env); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let add = ui.small_button("+").on_hover_text(crate::i18n::tr("Add a rule (⌥-click: a group)")); register(ui.ctx(), format!("button:ruleAdd-{rsalt}"), add.rect); @@ -266,6 +312,7 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) { } }); }); + problem_note(ui, problem, &rsalt); } } } @@ -320,6 +367,7 @@ mod tests { 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(), + Kind::Album => v.is_null(), Kind::Text | Kind::Keywords | Kind::Date => v.is_string(), }; match *op { diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index fd64b0a9..3fba77f3 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -340,6 +340,73 @@ fn smart_album_editor_keeps_an_unreadable_yes_no_value() { assert!(h.app.session.catalog.albums().all(|a| a.name != "Odd"), "OK refuses it: {r}"); } +/// The rule editor marks each rule that can't mean anything, inside groups too, and keeps OK +/// disabled until they are fixed; the control channel's confirm is refused the same way. +#[test] +fn smart_album_editor_marks_bad_rules_and_blocks_ok() { + let mut h = detail("panel.edit"); + exec(&mut h, "dialog.smartAlbum", json!({"name": "Checked"})); + let set = |h: &mut Headless, rules: serde_json::Value| { + let Some(crate::state::Dialog::SmartRules { rules: r, .. }) = &mut h.app.ui.dialog else { panic!("no rule editor") }; + *r = serde_json::from_value(json!({"rules": rules})).unwrap(); + h.settle(SETTLE); + }; + let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id); + set( + &mut h, + json!([ + {"field": "rating", "op": "gte", "value": 3}, + {"field": "captureDate", "op": "is", "value": "banana"}, + {"group": {"match": "any", "rules": [{"field": "iso", "op": "is", "value": 100}, {"field": "rating", "op": "is", "value": 9}]}} + ]), + ); + assert!(!has(&h, "ruleProblem:rules-0"), "a good rule isn't marked"); + assert!(has(&h, "ruleProblem:rules-1"), "the bad date is marked"); + assert!(has(&h, "ruleProblem:rules-2-1") && !has(&h, "ruleProblem:rules-2-0"), "inside a group, the bad rule itself"); + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") }; + assert!(serde_json::to_string(rules).unwrap().contains(r#""value":9"#), "drawing doesn't clamp the rating to 5"); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.session.catalog.albums().all(|a| a.name != "Checked"), "OK is disabled"); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert!(h.app.session.catalog.albums().all(|a| a.name != "Checked"), "confirm refuses too: {r}"); + assert!(h.app.ui.dialog.is_some(), "and the dialog stays open with the rules"); + // fixed, the marks go and OK saves + set(&mut h, json!([{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "is", "value": "2026"}])); + assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("ruleProblem:"))); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.session.catalog.albums().any(|a| a.name == "Checked")); +} + +/// An Album rule picks its album from a list of the plain albums, not by typing an id. +#[test] +fn smart_album_editor_picks_an_album_from_a_list() { + let mut h = detail("panel.edit"); + let trip = exec(&mut h, "album.create", json!({"name": "Trip", "addSelected": false}))["id"].as_u64().unwrap(); + let smart = exec(&mut h, "album.createSmart", json!({"name": "Fives", "rules": {"rating": 5}}))["id"].as_u64().unwrap(); + exec(&mut h, "dialog.smartAlbum", json!({"name": "In Trip"})); + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &mut h.app.ui.dialog else { panic!("no rule editor") }; + rules.rules[0] = serde_json::from_value(json!({"field": "album", "op": "is", "value": 0})).unwrap(); + h.settle(SETTLE); + assert!(h.app.widgets.iter().any(|(w, _)| w == "ruleProblem:rules-0"), "no album chosen yet"); + let r = h.request("ui.clickWidget", json!({"id": "ruleAlbum:rules-0"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let has = |h: &Headless, id: String| h.app.widgets.iter().any(|(w, _)| *w == id); + assert!(has(&h, format!("ruleAlbumItem:{trip}:rules-0")), "plain albums are offered"); + assert!(!has(&h, format!("ruleAlbumItem:{smart}:rules-0")), "smart albums can't be tested"); + let r = h.request("ui.clickWidget", json!({"id": format!("ruleAlbumItem:{trip}:rules-0")}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") }; + let Rule::Field { value, .. } = &rules.rules[0] else { panic!("not a field rule") }; + assert_eq!(value, &json!(trip)); + assert!(!h.app.widgets.iter().any(|(w, _)| w == "ruleProblem:rules-0")); +} + #[test] fn g_toggles_grids_and_shift_g_starts_guided_upright() { let mut h = detail("panel.edit"); From e077172f3603b0313b64ddfd16056157be13b1a5 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 08:43:31 -0400 Subject: [PATCH 31/65] LRC-LIB-SMARTCOLL: document the rule editor's problem marks and album picker Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 2 +- docs/whats-new.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/parity.md b/docs/parity.md index d9ee69ef..17c02e18 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, a full time; no 30 February), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. Not yet: the editor marks only the first problem, not its row | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, a full time; no 30 February), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album from a list of the plain albums (is / isn't), and never rewrites a stored value it can't show (a rating of 9 isn't clamped to 5). Not yet: problem messages are English and name fields by id | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | diff --git a/docs/whats-new.md b/docs/whats-new.md index db6dc725..134de17b 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -17,6 +17,8 @@ way round, "in the last 0 days", a colour label that doesn't exist, "contains" with nothing to look for, an album that is gone or smart, or an empty group is reported with its rule ("rule 2.1: rating is 0–5, not 9") instead of quietly matching nothing or everything. New date rules start at the current year. +- The rule editor marks each rule that needs fixing right under it and keeps OK disabled until they are fixed. An + Album rule picks its album from a list (is / isn't) instead of taking an id. - Yes/no fields (Has Edits, Cropped, Has GPS…) read Yes / No instead of true / false. A rule sent as `"false"` now means no (it used to count as yes), and a value that is neither yes nor no is refused. - Any Searchable Text also finds the state / province, alt text, people and a virtual copy's name. From 850311a884639087a5b9c58e3a4593a8f9aa4b77 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 08:54:44 -0400 Subject: [PATCH 32/65] LRC-LIB-SMARTCOLL: one reading of Album rule ids; folders refused; old album operators upgraded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review: - rules::album_rule_id reads an Album rule's id as a whole number, written as 3, 3.0 or "3". The check, the matcher and the editor now agree; the picker showed a valid "1" as unset. - A folder holds albums, not photos, so an Album rule naming one matched nothing. The check now refuses it ("is a folder; choose an album in it"). - Smart albums saved while Album was a number field could use ≥, ≤ or between, which the matcher always read as "isn't". Since Album became is / isn't only, editing or filtering with them was refused. RuleSet::upgrade rewrites them to the "isn't" they meant; merge_filter (album.createSmart / setRules) and library.filter apply it before checking. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/rules.rs | 64 ++++++++++++++++++++++++++++-- crates/engine/src/cmd/library.rs | 12 +++++- crates/engine/src/tests_library.rs | 17 ++++++++ 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index d8d75f11..ad0d9746 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -209,15 +209,22 @@ fn number_problem(field: &str, v: &Value) -> Option { number(v).filter(|n| n.is_finite()).is_none().then(|| format!("`{field}` needs a number, not {v}")) } -/// An Album rule's value: an album that exists and holds photos itself (not a smart album). +/// The album an Album rule names: its id as a whole number, written as a number (`3`, `3.0`) or a +/// numeric string (`"3"`). The check, the matcher and the editor all read it this way. +pub fn album_rule_id(value: &Value) -> Option { + number(value).filter(|n| n.is_finite() && *n >= 0.0 && n.fract() == 0.0).map(|n| crate::AlbumId(n as u64)) +} + +/// An Album rule's value: an album that exists and holds photos itself (not a smart album, not a +/// folder of albums). fn album_problem(v: &Value, cat: &Catalog) -> Option { if v.is_null() { return Some("choose an album".into()); } - let id = number(v).filter(|n| n.is_finite() && *n >= 0.0 && n.fract() == 0.0); - match id.and_then(|n| cat.album(crate::AlbumId(n as u64))) { + match album_rule_id(v).and_then(|id| cat.album(id)) { None => Some(format!("no album {v}")), Some(a) if a.is_smart() => Some(format!("album {v} is a smart album; only plain albums can be tested")), + Some(a) if a.folder => Some(format!("album {v} is a folder; choose an album in it")), Some(_) => None, } } @@ -656,7 +663,7 @@ impl Rule { "sharpness" => num_op(op, p.analysis.map(|a| a.sharpness as f64), value), "bestOfGroup" => yes(p.analysis.is_some_and(|a| a.best || a.group.is_none())), "album" => { - let id = number(value).map(|v| crate::AlbumId(v as u64)); + let id = album_rule_id(value); id.is_some_and(|a| cat.album(a).is_some_and(|al| !al.is_smart()) && cat.album_contains(a, p)) == (op == "is") } _ => false, @@ -685,6 +692,19 @@ impl RuleSet { }) } + /// Bring rules saved by older versions up to date, keeping what they meant: an Album rule with + /// an operator from when Album was a number field (≥, between…) always matched as "isn't", and + /// now says so. Commands and the editor apply it before checking. + pub fn upgrade(&mut self) { + for rule in &mut self.rules { + match rule { + Rule::Group { group } => group.upgrade(), + Rule::Field { field, op, .. } if field == "album" && op != "is" && op != "isNot" => *op = "isNot".into(), + Rule::Field { .. } => {} + } + } + } + /// The rules that can't mean anything, in order: an unknown field, an operator the field /// doesn't have, a value that isn't one of the field's (a number, a date, a choice, yes or /// no, a plain album…), text with nothing to look for, an empty group. Commands refuse a rule @@ -1250,6 +1270,42 @@ mod tests { assert_eq!(r.check(&Catalog::new()).first().map(|p| p.message.as_str()), Some("`album` has no operator `gte`")); } + /// An Album rule names its album by id, written as a number or a numeric string, the same way + /// for the check, the matcher and the editor. A folder holds albums, not photos, so it can't be + /// tested. + #[test] + fn album_rule_ids() { + use crate::{Album, AlbumId, Op}; + assert_eq!(album_rule_id(&json!(3)), Some(AlbumId(3))); + assert_eq!(album_rule_id(&json!("3")), Some(AlbumId(3))); + assert_eq!(album_rule_id(&json!(3.0)), Some(AlbumId(3))); + for v in [json!(1.5), json!(-1), json!("x"), json!(null)] { + assert_eq!(album_rule_id(&v), None, "{v}"); + } + let mut cat = Catalog::new(); + cat.apply(Op::AddAlbum { album: Album { folder: true, ..Album::new(AlbumId(4), "Trips") } }).unwrap(); + let p = rs(json!({"rules": [{"field": "album", "op": "is", "value": 4}]})).check(&cat); + assert!(p.first().is_some_and(|p| p.message.contains("folder")), "{p:?}"); + } + + /// Album rules saved before Album offered only "is" / "isn't" (≥, between… from when it was a + /// number field) always meant "isn't" to the matcher; upgrading says so, so they check again. + #[test] + fn old_album_operators_upgrade_to_isnt() { + let mut r = rs(json!({"rules": [ + {"field": "album", "op": "gte", "value": 1}, + {"field": "album", "op": "is", "value": 1}, + {"field": "rating", "op": "gte", "value": 3}, + {"group": {"rules": [{"field": "album", "op": "between", "value": [1, 2]}]}} + ]})); + r.upgrade(); + let ops = serde_json::to_value(&r).unwrap().to_string(); + assert!(ops.contains(r#""field":"album","op":"isNot","value":1"#), "{ops}"); + assert!(ops.contains(r#""field":"album","op":"is","value":1"#), "{ops}"); + assert!(ops.contains(r#""field":"rating","op":"gte""#), "other fields keep theirs: {ops}"); + assert!(ops.contains(r#""field":"album","op":"isNot","value":[1,2]"#), "inside groups too: {ops}"); + } + /// 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 eef94db6..77510680 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -331,6 +331,9 @@ pub fn specs() -> Vec { return Err(bad("library.filter", "a folder is already shown (library.source): show another source first")); } // checked like a smart album's rules: an unknown field or a value that can't match is an error, not an empty grid + if let Some(rules) = f.rule_set.as_mut() { + rules.upgrade(); + } if let Some(problems) = f.rule_set.as_ref().map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) { return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::>().join("; "))); } @@ -1279,11 +1282,16 @@ pub fn specs() -> Vec { } /// `base` with a partial Filter (JSON) merged on top. -/// `patch` merged onto `base`, unchecked (see [`merge_rules`]). +/// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see +/// [`merge_rules`]). fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result { let mut v = serde_json::to_value(base).unwrap_or_default(); lightcraft_develop::presets::deep_merge(&mut v, patch); - serde_json::from_value(v).map_err(|e| bad(c, e.to_string())) + let mut f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?; + if let Some(rules) = f.rule_set.as_mut() { + rules.upgrade(); + } + Ok(f) } /// `patch` merged onto `base`, refused when its rule set has problems (`RuleSet::check`). diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index b937d68d..9b680c0f 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -515,3 +515,20 @@ fn smart_rule_values_are_checked_by_commands() { .to_string(); assert!(e.contains("rule 1: no rating 0–5 is ≥ 9"), "{e}"); } + +/// A smart album saved with an old album operator (≥, from when Album was a number field) can +/// still be edited: its rule is read as the "isn't" it always meant. +#[test] +fn old_album_operator_rules_stay_editable() { + use lightcraft_catalog::{Album, AlbumId, Op}; + let mut s = crate::Session::with_demo(); + let trip = s.execute("album.create", &serde_json::json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap(); + let rules: lightcraft_catalog::Filter = + serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "gte", "value": trip}]}})).unwrap(); + let id = s.catalog.alloc_album_id(); + s.catalog.apply(Op::AddAlbum { album: Album { smart: Some(Box::new(rules)), ..Album::new(id, "Old") } }).unwrap(); + s.execute("album.setRules", &serde_json::json!({"id": id.0, "rules": {"rating": 2}})).unwrap(); + let saved = serde_json::to_string(&s.catalog.album(AlbumId(id.0)).unwrap().smart).unwrap(); + assert!(saved.contains(r#""op":"isNot""#), "{saved}"); + s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "lte", "value": trip}]}})).unwrap(); +} From 920658c76dc249457f8fb22abffcf1f1d44db164 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 08:54:44 -0400 Subject: [PATCH 33/65] LRC-LIB-SMARTCOLL: rule editor never rewrites a rule by drawing it From review: the number spinner was fixed, but the "in the last" spinner still clamped a stored N on first draw ("0 days" became 1, hiding the problem and saving a different rule; 20,000 became 10,000). Values that weren't an {n, unit} object or a two-item "between" were replaced by the defaults as soon as they were drawn. - In the last: shown as stored ({n, unit}, any case, or a plain number of days) and written only when the N or the unit is changed, with clamp_existing_to_range(false). - Between: anything but a pair is shown as the default pair but kept until one bound is edited, so the check marks it. A slice pattern replaces the [0] / [1] indexing. - Album picker: reads the id as the catalog does; names albums with their folder ("Trips / Rome") and sorts them by name. - Opening an album's rules applies RuleSet::upgrade. - New tests: drawing leaves five unreadable values as stored and marks the three that can't match, and an edit with bad rules doesn't rename the album (neither OK nor confirm). The parity note no longer overclaims. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 11 +++- crates/ui-egui/src/panels/dialogs.rs | 15 ++++- crates/ui-egui/src/panels/rules_editor.rs | 70 +++++++++++++++-------- crates/ui-egui/src/tests_masking.rs | 41 +++++++++++++ docs/parity.md | 2 +- 5 files changed, 113 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5b79548b..f705152d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -873,7 +873,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Dialog::SmartRules { id: Some(a.id.0), name: a.name.clone(), - rules: a.smart.as_ref().and_then(|f| f.rule_set.clone()).unwrap_or_default(), + // older rules read as they always matched (RuleSet::upgrade) + rules: a + .smart + .as_ref() + .and_then(|f| f.rule_set.clone()) + .map(|mut r| { + r.upgrade(); + r + }) + .unwrap_or_default(), parent: None, }, None => Dialog::SmartRules { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 3d429444..d29fd84f 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -245,7 +245,20 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // checked before drawing, so each row marks its own problem let env = crate::panels::rules_editor::Env { problems: rules.check(&app.session.catalog), - albums: app.session.catalog.albums().filter(|a| !a.is_smart() && !a.folder).map(|a| (a.id.0, a.name.clone())).collect(), + albums: { + let cat = &app.session.catalog; + let mut albums: Vec<(u64, String)> = cat + .albums() + .filter(|a| !a.is_smart() && !a.folder) + .map(|a| { + // "Folder / Album", so albums of the same name in two folders differ + let folder = a.parent.and_then(|f| cat.album(f)).map(|f| f.name.as_str()); + (a.id.0, folder.map_or_else(|| a.name.clone(), |f| format!("{f} / {}", a.name))) + }) + .collect(); + albums.sort_by_key(|a| a.1.to_lowercase()); + albums + }, }; egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| { crate::panels::rules_editor::edit(ui, rules, "rules", 0, &[], &env); diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index 2cf09c29..3d6bdd24 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -94,14 +94,22 @@ fn number_value(ui: &mut egui::Ui, v: &mut Value, field: &str) { #[derive(Default)] pub struct Env { pub problems: Vec, - /// The plain albums: (id, name), in the sidebar's order. + /// The albums an Album rule can test: (id, name shown, with its folder when it is in one), + /// sorted by that name. pub albums: Vec<(u64, String)>, } +/// The album of `albums` an Album rule's value names, read as the catalog reads it (`3`, `"3"`). +fn chosen_album<'a>(v: &Value, albums: &'a [(u64, String)]) -> Option<&'a (u64, String)> { + let id = lightcraft_catalog::rules::album_rule_id(v)?; + albums.iter().find(|a| a.0 == id.0) +} + /// An Album rule's value: one of the plain albums, picked by name. fn album_value(ui: &mut egui::Ui, v: &mut Value, salt: &str, env: &Env) { - let cur = v.as_u64(); - let text = env.albums.iter().find(|a| Some(a.0) == cur).map_or_else(|| crate::i18n::tr("Choose an album…").to_string(), |a| a.1.clone()); + let chosen = chosen_album(v, &env.albums); + let cur = chosen.map(|a| a.0); + let text = chosen.map_or_else(|| crate::i18n::tr("Choose an album…").to_string(), |a| a.1.clone()); let r = egui::ComboBox::from_id_salt(format!("{salt}-album")).width(160.0).selected_text(text).show_ui(ui, |ui| { for (id, name) in &env.albums { let r = ui.selectable_label(cur == Some(*id), name); @@ -123,39 +131,50 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & (_, "isEmpty" | "isNotEmpty") => {} (Some(Kind::Album), _) => album_value(ui, v, salt, env), (Some(Kind::Date), "inLast" | "notInLast") => { - if !v.is_object() { - *v = default_value(field, op); - } - let mut n = v["n"].as_f64().unwrap_or(30.0); - if ui.add(egui::DragValue::new(&mut n).range(1.0..=10_000.0).speed(0.2).fixed_decimals(0)).changed() { - v["n"] = json!(n.round()); - } - let unit = v["unit"].as_str().unwrap_or("days").to_string(); + // shown as stored ({n, unit}, or a plain number of days) and only written when changed: + // drawing never turns "0 days" into 1 or 20,000 into the spinner's 10,000 + let (stored_n, stored_unit) = match &*v { + Value::Object(o) => (o.get("n").and_then(Value::as_f64), o.get("unit").and_then(Value::as_str).unwrap_or("days").to_string()), + Value::Number(n) => (n.as_f64(), "days".to_string()), + _ => (None, "days".to_string()), + }; + let mut n = stored_n.unwrap_or(30.0); + let mut unit = stored_unit.to_lowercase(); + let mut changed = + ui.add(egui::DragValue::new(&mut n).range(1.0..=10_000.0).clamp_existing_to_range(false).speed(0.2).fixed_decimals(0)).changed(); egui::ComboBox::from_id_salt(format!("{salt}-unit")).width(70.0).selected_text(crate::i18n::tr(&unit)).show_ui(ui, |ui| { for u in ["hours", "days", "weeks", "months", "years"] { - if ui.selectable_label(unit == u, crate::i18n::tr(u)).clicked() { - v["unit"] = json!(u); + if ui.selectable_label(unit.trim_end_matches('s') == u.trim_end_matches('s'), crate::i18n::tr(u)).clicked() { + unit = u.to_string(); + changed = true; } } }); + if changed { + *v = json!({"n": n.round(), "unit": unit}); + } } (Some(k), "between") => { - if v.as_array().is_none_or(|a| a.len() != 2) { - *v = default_value(field, op); - } - let Value::Array(a) = v else { return }; + // a value that isn't a pair is shown as the default pair but kept until one is edited, + // so the check can mark it + let mut pair = v.as_array().filter(|a| a.len() == 2).cloned().map_or_else(|| default_value(field, op), Value::Array); + let before = pair.clone(); + let Some([lo, hi]) = pair.as_array_mut().map(Vec::as_mut_slice) else { return }; if field == "shutterSpeed" { - text_value(ui, &mut a[0], 60.0, "1/1000", &format!("{salt}-a")); + text_value(ui, lo, 60.0, "1/1000", &format!("{salt}-a")); ui.label(crate::i18n::tr("and")); - text_value(ui, &mut a[1], 60.0, "1/125", &format!("{salt}-b")); + text_value(ui, hi, 60.0, "1/125", &format!("{salt}-b")); } else if k == Kind::Number { - number_value(ui, &mut a[0], field); + number_value(ui, lo, field); ui.label(crate::i18n::tr("and")); - number_value(ui, &mut a[1], field); + number_value(ui, hi, field); } else { - text_value(ui, &mut a[0], 86.0, "2026-01-01", &format!("{salt}-a")); + text_value(ui, lo, 86.0, "2026-01-01", &format!("{salt}-a")); ui.label(crate::i18n::tr("and")); - text_value(ui, &mut a[1], 86.0, "2026-12", &format!("{salt}-b")); + text_value(ui, hi, 86.0, "2026-12", &format!("{salt}-b")); + } + if pair != before { + *v = pair; } } // camera notation: 1/250, 0.5, 2" @@ -383,6 +402,11 @@ mod tests { assert_eq!(default_value("captureDate", "is"), json!("2026")); assert_eq!(default_value("captureDate", "between"), json!(["2026-01", "2026-12"])); lightcraft_catalog::rules::set_now(None); + // the picker shows the album a rule names, however the id is written + let albums = vec![(3, "Trip".to_string())]; + assert_eq!(chosen_album(&json!(3), &albums).map(|a| a.1.as_str()), Some("Trip")); + assert_eq!(chosen_album(&json!("3"), &albums).map(|a| a.1.as_str()), Some("Trip")); + assert_eq!(chosen_album(&json!(4), &albums), None); assert_eq!(default_value("shutterSpeed", "gte"), json!("1/250")); assert_eq!(default_value("shutterSpeed", "between"), json!(["1/1000", "1/125"])); } diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 3fba77f3..ca053707 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -407,6 +407,47 @@ fn smart_album_editor_picks_an_album_from_a_list() { assert!(!h.app.widgets.iter().any(|(w, _)| w == "ruleProblem:rules-0")); } +/// Drawing the editor never changes a rule: values it can't show as they are (in the last 0 days, +/// 20,000 days, a plain number of days, a "between" that isn't a pair) stay as stored, and the +/// ones the check refuses are marked. +#[test] +fn smart_album_editor_never_rewrites_what_it_draws() { + let mut h = detail("panel.edit"); + exec(&mut h, "dialog.smartAlbum", json!({"name": "Odd"})); + let rules = json!({"rules": [ + {"field": "captureDate", "op": "inLast", "value": {"n": 0, "unit": "days"}}, + {"field": "captureDate", "op": "inLast", "value": {"n": 20000, "unit": "Days"}}, + {"field": "captureDate", "op": "notInLast", "value": 7}, + {"field": "captureDate", "op": "between", "value": "2026"}, + {"field": "iso", "op": "between", "value": [100]} + ]}); + let Some(crate::state::Dialog::SmartRules { rules: r, .. }) = &mut h.app.ui.dialog else { panic!("no rule editor") }; + *r = serde_json::from_value(rules.clone()).unwrap(); + h.settle(SETTLE); + let Some(crate::state::Dialog::SmartRules { rules: r, .. }) = &h.app.ui.dialog else { panic!("no rule editor") }; + assert_eq!(serde_json::to_value(r).unwrap()["rules"], rules["rules"], "unchanged by drawing"); + let marked: Vec<&str> = h.app.widgets.iter().filter_map(|(w, _)| w.strip_prefix("ruleProblem:")).collect(); + assert_eq!(marked, ["rules-0", "rules-3", "rules-4"], "0 days and the half-made betweens"); +} + +/// Editing an album with rules that need fixing changes nothing, not even its name. +#[test] +fn smart_album_edit_with_bad_rules_keeps_the_name() { + let mut h = detail("panel.edit"); + let id = exec(&mut h, "album.createSmart", json!({"name": "Keep", "rules": {"rating": 3}}))["id"].as_u64().unwrap(); + exec(&mut h, "dialog.smartAlbum", json!({"id": id})); + let Some(crate::state::Dialog::SmartRules { name, rules, .. }) = &mut h.app.ui.dialog else { panic!("no rule editor") }; + *name = "Renamed".into(); + *rules = serde_json::from_value(json!({"rules": [{"field": "captureDate", "op": "is", "value": "banana"}]})).unwrap(); + h.settle(SETTLE); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let _ = h.request("ui.dialog.confirm", json!({}), T); + let album = h.app.session.catalog.album(lightcraft_catalog::AlbumId(id)).expect("album"); + assert_eq!(album.name, "Keep", "neither OK nor confirm renamed it"); +} + #[test] fn g_toggles_grids_and_shift_g_starts_guided_upright() { let mut h = detail("panel.edit"); diff --git a/docs/parity.md b/docs/parity.md index 17c02e18..d5584330 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, a full time; no 30 February), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album from a list of the plain albums (is / isn't), and never rewrites a stored value it can't show (a rating of 9 isn't clamped to 5). Not yet: problem messages are English and name fields by id | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, a full time; no 30 February), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album from a list of the plain albums, named with their folder and sorted by name (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Not yet: problem messages are English and name fields by id | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | From 31abbcee4f2179ad94ab3042e908b9e8384f298c Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 08:58:11 -0400 Subject: [PATCH 34/65] LRC-LIB-SMARTCOLL: rename and rules of a smart album in one step The rule editor renamed an album (album.rename) and then set its rules (album.setRules) as two commits. If the second failed (refused rules before the dialog checked them, or a save error), the album kept the new name without the rules, and undo needed two steps. album.setRules takes an optional `name` (blank keeps the album's) and commits the rename and the rules as one Op::Batch: both or neither, one undo step. The dialog edits through it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 10 ++++++++-- crates/engine/src/tests_library.rs | 23 +++++++++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 8 ++------ crates/ui-egui/src/tests_masking.rs | 14 ++++++++++++++ docs/whats-new.md | 2 ++ 5 files changed, 49 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 77510680..a07eb8b4 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -854,7 +854,7 @@ pub fn specs() -> Vec { "Edit Smart Album", [], None, - "{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view)}", + "{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view), name?: also rename it (blank keeps the name) — one undo step, both or neither}", always, |s, p| { let id = album_param(p, "id", "album.setRules")?; @@ -873,7 +873,13 @@ pub fn specs() -> Vec { } r }; - s.commit("Edit Smart Album", Op::SetAlbumRules { id, rules: Box::new(rules) })?; + let set = Op::SetAlbumRules { id, rules: Box::new(rules) }; + // a new name goes with the rules: one step, applied (and undone) together + let rename = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty() && s.catalog.album(id).is_some_and(|a| a.name != *n)); + match rename { + Some(name) => s.commit("Edit Smart Album", Op::Batch { ops: vec![Op::RenameAlbum { id, name: name.to_string() }, set] })?, + None => s.commit("Edit Smart Album", set)?, + }; Ok(json!({"count": s.catalog.album_count(id)})) } ), diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 9b680c0f..45b400a5 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -532,3 +532,26 @@ fn old_album_operator_rules_stay_editable() { assert!(saved.contains(r#""op":"isNot""#), "{saved}"); s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "lte", "value": trip}]}})).unwrap(); } + +/// Renaming and changing the rules is one step: both or neither, undone together. +#[test] +fn set_rules_with_a_name_is_one_step() { + let mut s = crate::Session::with_demo(); + let id = s.execute("album.createSmart", &serde_json::json!({"name": "Old", "rules": {"rating": 3}})).unwrap()["id"].as_u64().unwrap(); + let album = |s: &crate::Session| s.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().clone(); + let good = serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "gte", "value": 4}]}}); + let bad = serde_json::json!({"ruleSet": {"rules": [{"field": "captureDate", "op": "is", "value": "banana"}]}}); + // refused rules: the name stays too + assert!(s.execute("album.setRules", &serde_json::json!({"id": id, "name": "New", "replace": true, "rules": bad})).is_err()); + assert_eq!(album(&s).name, "Old"); + let undo = s.undo.len(); + s.execute("album.setRules", &serde_json::json!({"id": id, "name": "New", "replace": true, "rules": good})).unwrap(); + assert_eq!((album(&s).name.as_str(), s.undo.len()), ("New", undo + 1), "one undo step"); + assert!(album(&s).smart.unwrap().rule_set.is_some()); + s.execute("edit.undo", &serde_json::json!({})).unwrap(); + assert_eq!(album(&s).name, "Old"); + assert!(album(&s).smart.unwrap().rule_set.is_none(), "undone together"); + // a blank name keeps the album's + s.execute("album.setRules", &serde_json::json!({"id": id, "name": " ", "rules": {"rating": 5}})).unwrap(); + assert_eq!(album(&s).name, "Old"); +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d29fd84f..954cd9f5 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -1103,12 +1103,8 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { - if app.session.catalog.album(lightcraft_catalog::AlbumId(*id)).is_some_and(|a| a.name != name) { - app.run("album.rename", json!({"id": id, "name": name}))?; - } - app.run("album.setRules", json!({"id": id, "replace": true, "rules": {"ruleSet": rules}})) - } + // the name and the rules together: one undo step, both or neither + Some(id) => app.run("album.setRules", json!({"id": id, "name": name, "replace": true, "rules": {"ruleSet": rules}})), None => created_in(app, "album.createSmart", json!({"name": name, "rules": {"ruleSet": rules}, "parent": parent})), } } diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index ca053707..0a1d37b4 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -446,6 +446,20 @@ fn smart_album_edit_with_bad_rules_keeps_the_name() { let _ = h.request("ui.dialog.confirm", json!({}), T); let album = h.app.session.catalog.album(lightcraft_catalog::AlbumId(id)).expect("album"); assert_eq!(album.name, "Keep", "neither OK nor confirm renamed it"); + // fixed: OK renames it and sets the rules in one undo step + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &mut h.app.ui.dialog else { panic!("the editor stays open") }; + *rules = serde_json::from_value(json!({"rules": [{"field": "captureDate", "op": "is", "value": "2026"}]})).unwrap(); + h.settle(SETTLE); + let undo = h.app.session.undo.len(); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let album = h.app.session.catalog.album(lightcraft_catalog::AlbumId(id)).expect("album"); + assert_eq!((album.name.as_str(), h.app.session.undo.len()), ("Renamed", undo + 1)); + exec(&mut h, "edit.undo", json!({})); + let album = h.app.session.catalog.album(lightcraft_catalog::AlbumId(id)).expect("album"); + assert_eq!(album.name, "Keep", "one undo takes back the name with the rules"); + assert!(album.smart.as_ref().is_some_and(|f| f.rule_set.is_none())); } #[test] diff --git a/docs/whats-new.md b/docs/whats-new.md index 134de17b..5b97d1c8 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -17,6 +17,8 @@ way round, "in the last 0 days", a colour label that doesn't exist, "contains" with nothing to look for, an album that is gone or smart, or an empty group is reported with its rule ("rule 2.1: rating is 0–5, not 9") instead of quietly matching nothing or everything. New date rules start at the current year. +- Editing a smart album's name and rules is one step: both or neither, and one undo takes both back + (`album.setRules` takes `name`). - The rule editor marks each rule that needs fixing right under it and keeps OK disabled until they are fixed. An Album rule picks its album from a list (is / isn't) instead of taking an id. - Yes/no fields (Has Edits, Cropped, Has GPS…) read Yes / No instead of true / false. A rule sent as `"false"` now From bb50127de4fca9d64ba4a2edf001f799416caec0 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 09:05:38 -0400 Subject: [PATCH 35/65] LRC-LIB-SMARTCOLL: report saved smart albums whose rules no longer check Rules are checked when they are made, but a saved smart album can go bad later. The usual case is a rule testing an album that is then deleted; it silently matches nothing. Catalog::smart_album_problems(id) checks a saved album's rules as they are now (RuleSet::upgrade, then RuleSet::check). It is empty for a sound smart album, a plain album or no album. albums.list reports `problems` for every smart album, e.g. ["rule 1: no album 8"]. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/lib.rs | 9 +++++++++ crates/catalog/src/tests.rs | 20 ++++++++++++++++++++ crates/engine/src/cmd/query.rs | 2 ++ crates/engine/src/tests_library.rs | 16 ++++++++++++++++ 4 files changed, 47 insertions(+) diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 5017423e..c47763b1 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -330,6 +330,15 @@ impl Catalog { } } + /// What is wrong with a saved smart album's rules now (`RuleSet::check`, after + /// `RuleSet::upgrade`): typically a rule testing an album that has since been deleted. Empty + /// for a sound smart album, a plain album or no album. + pub fn smart_album_problems(&self, id: AlbumId) -> Vec { + let Some(mut rules) = self.albums.get(&id).and_then(|a| a.smart.as_ref()).and_then(|f| f.rule_set.clone()) else { return Vec::new() }; + rules.upgrade(); + rules.check(self) + } + /// The photos of an album: the stored list, or a smart album's current matches (id order). pub fn album_photos(&self, id: AlbumId) -> Vec { match self.albums.get(&id) { diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 8d34d5ef..84bfdd0d 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -456,3 +456,23 @@ fn random_sort_has_no_date_headers() { let ids = many(&mut c, 5); assert!(c.date_runs(&ids, SortKey::Random, GroupBy::Day).is_empty()); } + +/// A saved smart album whose rules no longer check (the album a rule tests was deleted) is found, +/// with its problems; a good one has none; an old album operator is read as it was meant. +#[test] +fn smart_album_problems_flag_stale_rules() { + let mut c = Catalog::new(); + c.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap(); + let smart = |id: u64, rules: serde_json::Value| { + let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": rules}})).unwrap(); + Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "Smart") } + }; + c.apply(Op::AddAlbum { album: smart(2, serde_json::json!([{"field": "album", "op": "is", "value": 1}])) }).unwrap(); + c.apply(Op::AddAlbum { album: smart(3, serde_json::json!([{"field": "album", "op": "gte", "value": 1}])) }).unwrap(); + assert!(c.smart_album_problems(AlbumId(2)).is_empty()); + assert!(c.smart_album_problems(AlbumId(3)).is_empty(), "an old operator is upgraded, not a problem"); + assert!(c.smart_album_problems(AlbumId(1)).is_empty(), "a plain album has no rules"); + c.apply(Op::RemoveAlbum { id: AlbumId(1) }).unwrap(); + let p = c.smart_album_problems(AlbumId(2)); + assert_eq!(p.iter().map(ToString::to_string).collect::>(), vec!["rule 1: no album 1".to_string()]); +} diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 824039e4..d8ff2242 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -44,6 +44,8 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Va v["smart"] = json!(true); v["rules"] = serde_json::to_value(rules).unwrap_or_default(); v["rulesText"] = json!(rules.describe()); + // rules that no longer check (an album they test was deleted…), as `rule 1: …` + v["problems"] = json!(cat.smart_album_problems(a.id).iter().map(ToString::to_string).collect::>()); } v } diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 45b400a5..921027c9 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -555,3 +555,19 @@ fn set_rules_with_a_name_is_one_step() { s.execute("album.setRules", &serde_json::json!({"id": id, "name": " ", "rules": {"rating": 5}})).unwrap(); assert_eq!(album(&s).name, "Old"); } + +/// albums.list says which smart albums have rules that no longer check, and why. +#[test] +fn album_list_reports_problems() { + let mut s = crate::Session::with_demo(); + let trip = s.execute("album.create", &serde_json::json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap(); + let rules = serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": trip}]}}); + let id = s.execute("album.createSmart", &serde_json::json!({"name": "In Trip", "rules": rules})).unwrap()["id"].as_u64().unwrap(); + let find = |s: &mut crate::Session| { + let list = s.execute("albums.list", &serde_json::json!({})).unwrap(); + list.as_array().unwrap().iter().find(|a| a["id"] == id).cloned().unwrap() + }; + assert_eq!(find(&mut s)["problems"], serde_json::json!([])); + s.execute("album.delete", &serde_json::json!({"id": trip})).unwrap(); + assert_eq!(find(&mut s)["problems"], serde_json::json!([format!("rule 1: no album {trip}")])); +} From 3cd26848f3590f8c5454bde72a9bac7c21202c80 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 09:05:38 -0400 Subject: [PATCH 36/65] LRC-LIB-SMARTCOLL: mark smart albums with problems in the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A smart album whose rules no longer check shows ⚠ before its name (`albumProblem:`). Its tooltip adds "Some rules need fixing (Edit Smart Album…):" and each problem under the rule summary, in one tooltip. i18n::problem_text is the one place problems become display text. The new line is translated in all seven locales. Also: the album menu's Edit Smart Album… built the dialog itself and skipped RuleSet::upgrade. It now goes through dialog.smartAlbum like every other way in. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 3 ++- crates/ui-egui/locales/es.json | 3 ++- crates/ui-egui/locales/ja.json | 3 ++- crates/ui-egui/locales/pt-br.json | 3 ++- crates/ui-egui/locales/ru.json | 3 ++- crates/ui-egui/locales/zh-hans.json | 3 ++- crates/ui-egui/locales/zh-hant.json | 3 ++- crates/ui-egui/src/i18n.rs | 5 +++++ crates/ui-egui/src/panels/left.rs | 24 ++++++++++++++++++++---- crates/ui-egui/src/tests_masking.rs | 18 ++++++++++++++++++ docs/parity.md | 2 +- docs/whats-new.md | 2 ++ 12 files changed, 60 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 2ae0af11..ebd52e71 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1899,5 +1899,6 @@ "No Label": "Ohne Markierung", "Choose an album…": "Album wählen…", "No albums yet": "Noch keine Alben", - "Fix the marked rules to save this album.": "Korrigiere die markierten Regeln, um dieses Album zu speichern." + "Fix the marked rules to save this album.": "Korrigiere die markierten Regeln, um dieses Album zu speichern.", + "Some rules need fixing (Edit Smart Album…):": "Einige Regeln müssen korrigiert werden (Smart-Album bearbeiten…):" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 812841ab..77e4959f 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1744,5 +1744,6 @@ "No Label": "Sin etiqueta", "Choose an album…": "Elige un álbum…", "No albums yet": "Aún no hay álbumes", - "Fix the marked rules to save this album.": "Corrige las reglas marcadas para guardar este álbum." + "Fix the marked rules to save this album.": "Corrige las reglas marcadas para guardar este álbum.", + "Some rules need fixing (Edit Smart Album…):": "Hay reglas que corregir (Editar álbum inteligente…):" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 84dce170..94f96729 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1748,5 +1748,6 @@ "Cropped": "切り抜き", "Choose an album…": "アルバムを選択…", "No albums yet": "アルバムはまだありません", - "Fix the marked rules to save this album.": "このアルバムを保存するには、印の付いた条件を修正してください。" + "Fix the marked rules to save this album.": "このアルバムを保存するには、印の付いた条件を修正してください。", + "Some rules need fixing (Edit Smart Album…):": "修正が必要な条件があります(スマートアルバムを編集…):" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 230ca8af..1abdf4e4 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1744,5 +1744,6 @@ "No Label": "Sem rótulo", "Choose an album…": "Escolha um álbum…", "No albums yet": "Ainda não há álbuns", - "Fix the marked rules to save this album.": "Corrija as regras marcadas para salvar este álbum." + "Fix the marked rules to save this album.": "Corrija as regras marcadas para salvar este álbum.", + "Some rules need fixing (Edit Smart Album…):": "Algumas regras precisam ser corrigidas (Editar álbum inteligente…):" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 4a8da93b..61fed8b4 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1416,5 +1416,6 @@ "No Label": "Без метки", "Choose an album…": "Выберите альбом…", "No albums yet": "Альбомов пока нет", - "Fix the marked rules to save this album.": "Исправьте отмеченные условия, чтобы сохранить альбом." + "Fix the marked rules to save this album.": "Исправьте отмеченные условия, чтобы сохранить альбом.", + "Some rules need fixing (Edit Smart Album…):": "Некоторые условия нужно исправить (Изменить смарт-альбом…):" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index f9c093fd..65e9b194 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1744,5 +1744,6 @@ "No Label": "无标签", "Choose an album…": "选择相册…", "No albums yet": "还没有相册", - "Fix the marked rules to save this album.": "请修正标记的规则以保存此相册。" + "Fix the marked rules to save this album.": "请修正标记的规则以保存此相册。", + "Some rules need fixing (Edit Smart Album…):": "有些规则需要修正(编辑智能相册…):" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 1c13a96e..12487b4b 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1744,5 +1744,6 @@ "No Label": "無標籤", "Choose an album…": "選擇相簿…", "No albums yet": "還沒有相簿", - "Fix the marked rules to save this album.": "請修正標記的規則以儲存此相簿。" + "Fix the marked rules to save this album.": "請修正標記的規則以儲存此相簿。", + "Some rules need fixing (Edit Smart Album…):": "有些規則需要修正(編輯智慧相簿…):" } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 817a18e0..67ae99f3 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -328,6 +328,11 @@ pub fn filter_label(filter: &lightcraft_catalog::Filter, catalog: &lightcraft_ca } } +/// What people read for a smart-album rule problem (`rule 2: …`). +pub fn problem_text(problem: &lightcraft_catalog::rules::Problem) -> String { + problem.to_string() +} + /// 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 { diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 8863e5d1..7b9584e7 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -727,7 +727,13 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare let target = app.session.target_album.filter(|t| app.session.catalog.album(*t).is_some()).or_else(|| app.session.catalog.quick_collection()); let label = if target == Some(a.id) { format!("{} +", a.name) } else { a.name.clone() }; + // a smart album whose rules no longer check (an album they test was deleted) says so + let problems = app.session.catalog.smart_album_problems(a.id); + let label = if problems.is_empty() { label } else { format!("⚠ {label}") }; let mut resp = row_sensed(app, ui, &format!("album:{}", a.id.0), icon, &label, None, Some(n), sel, indent, Sense::click_and_drag()); + if !problems.is_empty() { + crate::widgets::register(ui.ctx(), format!("albumProblem:{}", a.id.0), resp.rect); + } if resp.drag_started() { app.ui.dragging_album = Some(a.id.0); } @@ -736,7 +742,17 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare drop_target(app, ui, &resp, a); } if let Some(rules) = &a.smart { - resp = resp.on_hover_text(crate::i18n::tr_format!("Smart album: {}", crate::i18n::filter_label(rules, &app.session.catalog))); + // one tooltip: the rules, then what needs fixing in them + let mut tip = crate::i18n::tr_format!("Smart album: {}", crate::i18n::filter_label(rules, &app.session.catalog)); + if !problems.is_empty() { + tip.push_str("\n\n"); + tip.push_str(crate::i18n::tr("Some rules need fixing (Edit Smart Album…):")); + for p in &problems { + tip.push('\n'); + tip.push_str(&crate::i18n::problem_text(p)); + } + } + resp = resp.on_hover_text(tip); } if resp.clicked() { let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0})); @@ -1038,9 +1054,9 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { let _ = app.run("album.clearQuick", json!({})); } if a.is_smart() && ui.button(crate::i18n::tr("Edit Smart Album…")).clicked() { - // older smart albums keep their filter fields; the editor works on the rule set - let rules = a.smart.as_ref().and_then(|f| f.rule_set.clone()).unwrap_or_default(); - app.ui.dialog = Some(crate::state::Dialog::SmartRules { id: Some(a.id.0), name: a.name.clone(), rules, parent: None }); + // the same editor as `dialog.smartAlbum` (older smart albums keep their filter fields; + // it works on the rule set, upgraded) + let _ = app.run("dialog.smartAlbum", json!({"id": a.id.0})); } if a.is_smart() && ui.button(crate::i18n::tr("Update Rules from Current Filter")).clicked() { let _ = app.run("album.setRules", json!({"id": a.id.0, "fromView": true})); diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 0a1d37b4..b6867017 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -462,6 +462,24 @@ fn smart_album_edit_with_bad_rules_keeps_the_name() { assert!(album.smart.as_ref().is_some_and(|f| f.rule_set.is_none())); } +/// The sidebar marks a smart album whose rules no longer check, so it can be fixed. +#[test] +fn sidebar_marks_smart_albums_with_problems() { + let mut h = detail("panel.edit"); + let trip = exec(&mut h, "album.create", json!({"name": "Trip", "addSelected": false}))["id"].as_u64().unwrap(); + let rules = json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": trip}]}}); + let id = exec(&mut h, "album.createSmart", json!({"name": "In Trip", "rules": rules}))["id"].as_u64().unwrap(); + let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.widgets.iter().any(|(w, _)| *w == format!("source:album:{id}")), "the album's row is on screen"); + let marked = |h: &Headless| h.app.widgets.iter().any(|(w, _)| *w == format!("albumProblem:{id}")); + assert!(!marked(&h)); + exec(&mut h, "album.delete", json!({"id": trip})); + h.settle(SETTLE); + assert!(marked(&h), "its album is gone"); +} + #[test] fn g_toggles_grids_and_shift_g_starts_guided_upright() { let mut h = detail("panel.edit"); diff --git a/docs/parity.md b/docs/parity.md index d5584330..4a86dee5 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, a full time; no 30 February), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album from a list of the plain albums, named with their folder and sorted by name (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Not yet: problem messages are English and name fields by id | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, a full time; no 30 February), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album from a list of the plain albums, named with their folder and sorted by name (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Not yet: problem messages are English and name fields by id | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | diff --git a/docs/whats-new.md b/docs/whats-new.md index 5b97d1c8..8252f9c3 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -17,6 +17,8 @@ way round, "in the last 0 days", a colour label that doesn't exist, "contains" with nothing to look for, an album that is gone or smart, or an empty group is reported with its rule ("rule 2.1: rating is 0–5, not 9") instead of quietly matching nothing or everything. New date rules start at the current year. +- A smart album whose rules no longer check (an album they test was deleted) is marked ⚠ in the sidebar; its tooltip + says what to fix, and `albums.list` reports `problems`. - Editing a smart album's name and rules is one step: both or neither, and one undo takes both back (`album.setRules` takes `name`). - The rule editor marks each rule that needs fixing right under it and keeps OK disabled until they are fixed. An From 59fd64ea6ff87c290cb4ceeb72df3b28de22996a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 09:13:29 -0400 Subject: [PATCH 37/65] LRC-LIB-SMARTCOLL: rule problems name their field and kind of issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Problem carried only an English sentence with the field's id ("`title` needs something to look for…"), which the editor showed as-is in every language. Problem now also has `field` (None for a group) and `issue`, an Issue kind with a short English text for translation catalogs (Issue::text, Issue::ALL). The English `message` and the `rule 2.1: …` Display stay as they were for agents and logs. A test covers the field and issue of each kind of problem and that each issue reads differently. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/rules.rs | 174 ++++++++++++++++++++++++++++++------ 1 file changed, 145 insertions(+), 29 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index ad0d9746..ce98972e 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -148,11 +148,15 @@ pub fn field_group(field: &str) -> Option<&'static str> { FIELD_GROUPS.iter().find(|g| g.1.contains(&field)).map(|g| g.0) } -/// A rule that can't mean anything ([`RuleSet::check`]): where it is and what is wrong. +/// A rule that can't mean anything ([`RuleSet::check`]): where it is, what is wrong in English +/// (for agents and logs), and the field and kind of issue, so the editor can say it in any language. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Problem { /// Positions from the top rule list down: `[1, 0]` is the first rule of the second rule's group. pub path: Vec, + /// The rule's field; `None` for a group. + pub field: Option, + pub issue: Issue, pub message: String, } @@ -164,28 +168,109 @@ impl std::fmt::Display for Problem { } } +/// What kind of thing is wrong with a rule ([`Problem`]); [`Issue::text`] says it after the +/// field's name ("Title: needs something to look for"). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Issue { + UnknownField, + NoSuchOperator, + NotYesNo, + NeedsNumber, + NeedsShutterSpeed, + NeedsDate, + DatesReversed, + NeedsTwoValues, + NeedsCount, + UnknownUnit, + NotAChoice, + NeedsTextOrEmpty, + NeedsTextOrNotEmpty, + NeedsText, + NoRatingMatches, + ChooseAlbum, + NoSuchAlbum, + SmartAlbum, + FolderAlbum, + EmptyGroup, +} + +impl Issue { + pub const ALL: [Issue; 20] = [ + Issue::UnknownField, + Issue::NoSuchOperator, + Issue::NotYesNo, + Issue::NeedsNumber, + Issue::NeedsShutterSpeed, + Issue::NeedsDate, + Issue::DatesReversed, + Issue::NeedsTwoValues, + Issue::NeedsCount, + Issue::UnknownUnit, + Issue::NotAChoice, + Issue::NeedsTextOrEmpty, + Issue::NeedsTextOrNotEmpty, + Issue::NeedsText, + Issue::NoRatingMatches, + Issue::ChooseAlbum, + Issue::NoSuchAlbum, + Issue::SmartAlbum, + Issue::FolderAlbum, + Issue::EmptyGroup, + ]; + + /// The issue in a few words, to follow the field's name; a message for translation catalogs. + pub fn text(self) -> &'static str { + match self { + Issue::UnknownField => "isn't a field rules know", + Issue::NoSuchOperator => "can't be tested that way", + Issue::NotYesNo => "is yes or no", + Issue::NeedsNumber => "needs a number", + Issue::NeedsShutterSpeed => "needs a time like 1/250 or 2", + Issue::NeedsDate => "needs a date like 2026, 2026-08 or 2026-08-14", + Issue::DatesReversed => "has the first date after the second", + Issue::NeedsTwoValues => "needs two values", + Issue::NeedsCount => "needs a number more than 0", + Issue::UnknownUnit => "needs hours, days, weeks, months or years", + Issue::NotAChoice => "isn't one of the choices", + Issue::NeedsTextOrEmpty => "needs something to look for (or use “is empty”)", + Issue::NeedsTextOrNotEmpty => "needs something to look for (or use “isn't empty”)", + Issue::NeedsText => "needs text", + Issue::NoRatingMatches => "can't match any rating from 0 to 5", + Issue::ChooseAlbum => "needs an album", + Issue::NoSuchAlbum => "names an album that no longer exists", + Issue::SmartAlbum => "can't test a smart album; choose a plain album", + Issue::FolderAlbum => "can't test a folder; choose an album in it", + Issue::EmptyGroup => "This group is empty: add a rule or remove it.", + } + } +} + +/// An issue with its English message, or nothing wrong. +type Found = Option<(Issue, String)>; + /// What is wrong with one rule, if anything ([`RuleSet::check`]). -fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog) -> Option { - let Some(kind) = field_kind(field) else { return Some(format!("unknown field `{field}`")) }; +fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog) -> Found { + let Some(kind) = field_kind(field) else { return Some((Issue::UnknownField, format!("unknown field `{field}`"))) }; if !ops_for(kind).iter().any(|o| o.0 == op) { - return Some(format!("`{field}` has no operator `{op}`")); + return Some((Issue::NoSuchOperator, format!("`{field}` has no operator `{op}`"))); } if matches!(op, "isEmpty" | "isNotEmpty") { return None; } match kind { - Kind::Bool => bool_value(value).is_none().then(|| format!("`{field}` is yes or no, not {value}")), + Kind::Bool => bool_value(value).is_none().then(|| (Issue::NotYesNo, format!("`{field}` is yes or no, not {value}"))), Kind::Album => album_problem(value, cat), Kind::Number => { let each = match value { Value::Array(a) if op == "between" && a.len() == 2 => a.iter().find_map(|v| number_problem(field, v)), - _ if op == "between" => Some(format!("`{field}` between needs two values, not {value}")), + _ if op == "between" => Some((Issue::NeedsTwoValues, format!("`{field}` between needs two values, not {value}"))), v => number_problem(field, v), }; // a rating rule no rating could meet ("is 9", "≥ 7"); "< 6" is fine, if broad each.or_else(|| { let label = ops_for(kind).iter().find(|o| o.0 == op).map_or(op, |o| o.1); - (field == "rating" && !(0..=5).any(|r| num_op(op, Some(f64::from(r)), value))).then(|| format!("no rating 0–5 {label} {value}")) + (field == "rating" && !(0..=5).any(|r| num_op(op, Some(f64::from(r)), value))) + .then(|| (Issue::NoRatingMatches, format!("no rating 0–5 {label} {value}"))) }) } Kind::Date => date_problem(field, op, value), @@ -193,20 +278,24 @@ fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog) -> Option< Kind::Text | Kind::Keywords => match value { Value::String(s) if !s.trim().is_empty() => None, Value::String(_) | Value::Null => { - let instead = if matches!(op, "isNot" | "notContains") { "isn't empty" } else { "is empty" }; - Some(format!("`{field}` needs something to look for (or use “{instead}”)")) + let (issue, instead) = if matches!(op, "isNot" | "notContains") { + (Issue::NeedsTextOrNotEmpty, "isn't empty") + } else { + (Issue::NeedsTextOrEmpty, "is empty") + }; + Some((issue, format!("`{field}` needs something to look for (or use “{instead}”)"))) } Value::Number(_) => None, - other => Some(format!("`{field}` needs text, not {other}")), + other => Some((Issue::NeedsText, format!("`{field}` needs text, not {other}"))), }, } } -fn number_problem(field: &str, v: &Value) -> Option { +fn number_problem(field: &str, v: &Value) -> Found { if field == "shutterSpeed" { - return shutter_value(v).is_null().then(|| format!("`shutterSpeed` needs a time like 1/250 or 2, not {v}")); + return shutter_value(v).is_null().then(|| (Issue::NeedsShutterSpeed, format!("`shutterSpeed` needs a time like 1/250 or 2, not {v}"))); } - number(v).filter(|n| n.is_finite()).is_none().then(|| format!("`{field}` needs a number, not {v}")) + number(v).filter(|n| n.is_finite()).is_none().then(|| (Issue::NeedsNumber, format!("`{field}` needs a number, not {v}"))) } /// The album an Album rule names: its id as a whole number, written as a number (`3`, `3.0`) or a @@ -217,14 +306,14 @@ pub fn album_rule_id(value: &Value) -> Option { /// An Album rule's value: an album that exists and holds photos itself (not a smart album, not a /// folder of albums). -fn album_problem(v: &Value, cat: &Catalog) -> Option { +fn album_problem(v: &Value, cat: &Catalog) -> Found { if v.is_null() { - return Some("choose an album".into()); + return Some((Issue::ChooseAlbum, "choose an album".into())); } match album_rule_id(v).and_then(|id| cat.album(id)) { - None => Some(format!("no album {v}")), - Some(a) if a.is_smart() => Some(format!("album {v} is a smart album; only plain albums can be tested")), - Some(a) if a.folder => Some(format!("album {v} is a folder; choose an album in it")), + None => Some((Issue::NoSuchAlbum, format!("no album {v}"))), + Some(a) if a.is_smart() => Some((Issue::SmartAlbum, format!("album {v} is a smart album; only plain albums can be tested"))), + Some(a) if a.folder => Some((Issue::FolderAlbum, format!("album {v} is a folder; choose an album in it"))), Some(_) => None, } } @@ -241,10 +330,10 @@ fn is_rule_date(s: &str) -> bool { s.len() >= 4 && crate::dates::normalize_iso(&full).is_some() } -fn date_problem(field: &str, op: &str, value: &Value) -> Option { +fn date_problem(field: &str, op: &str, value: &Value) -> Found { let date = |v: &Value| match v.as_str() { Some(s) if is_rule_date(s) => None, - _ => Some(format!("`{field}` needs a date like 2026, 2026-08 or 2026-08-14, not {v}")), + _ => Some((Issue::NeedsDate, format!("`{field}` needs a date like 2026, 2026-08 or 2026-08-14, not {v}"))), }; match op { "inLast" | "notInLast" => { @@ -253,23 +342,23 @@ fn date_problem(field: &str, op: &str, value: &Value) -> Option { v => (number(v), Value::Null), }; if !n.is_some_and(|n| n.is_finite() && n > 0.0) { - return Some(format!("`{field}` needs a number of hours, days, weeks, months or years more than 0")); + return Some((Issue::NeedsCount, format!("`{field}` needs a number of hours, days, weeks, months or years more than 0"))); } - unit_secs(&unit).is_none().then(|| format!("`{field}`: unknown unit {unit} (hours, days, weeks, months or years)")) + unit_secs(&unit).is_none().then(|| (Issue::UnknownUnit, format!("`{field}`: unknown unit {unit} (hours, days, weeks, months or years)"))) } "between" => match value { Value::Array(a) if a.len() == 2 => a.iter().find_map(date).or_else(|| { let bound = |i: usize| a.get(i).and_then(Value::as_str).unwrap_or("").trim(); let (from, to) = (bound(0), bound(1)); - (from > to && !from.starts_with(to)).then(|| format!("`{field}`: the first date is after the second")) + (from > to && !from.starts_with(to)).then(|| (Issue::DatesReversed, format!("`{field}`: the first date is after the second"))) }), - _ => Some(format!("`{field}` between needs two dates, not {value}")), + _ => Some((Issue::NeedsTwoValues, format!("`{field}` between needs two dates, not {value}"))), }, _ => date(value), } } -fn choice_problem(field: &str, value: &Value, choices: &[(&str, &str)], cat: &Catalog) -> Option { +fn choice_problem(field: &str, value: &Value, choices: &[(&str, &str)], cat: &Catalog) -> Found { let known = value.as_str().is_some_and(|s| { choices.iter().any(|c| c.0.eq_ignore_ascii_case(s.trim())) || match field { @@ -281,7 +370,7 @@ fn choice_problem(field: &str, value: &Value, choices: &[(&str, &str)], cat: &Ca } }); let ids: Vec<&str> = choices.iter().map(|c| c.0).collect(); - (!known).then(|| format!("`{field}` is one of {}, not {value}", ids.join(", "))) + (!known).then(|| (Issue::NotAChoice, format!("`{field}` is one of {}, not {value}", ids.join(", ")))) } /// What a yes/no rule's value means: `true`, `"yes"`, `1` (or `1.0`) or no value is yes; `false`, @@ -719,11 +808,13 @@ impl RuleSet { for (i, rule) in self.rules.iter().enumerate() { path.push(i); match rule { - Rule::Group { group } if group.rules.is_empty() => out.push(Problem { path: path.clone(), message: "an empty group".into() }), + Rule::Group { group } if group.rules.is_empty() => { + out.push(Problem { path: path.clone(), field: None, issue: Issue::EmptyGroup, message: "an empty group".into() }) + } Rule::Group { group } => group.check_into(cat, path, out), Rule::Field { field, op, value } => { - if let Some(message) = field_problem(field, op, value, cat) { - out.push(Problem { path: path.clone(), message }); + if let Some((issue, message)) = field_problem(field, op, value, cat) { + out.push(Problem { path: path.clone(), field: Some(field.clone()), issue, message }); } } } @@ -1306,6 +1397,31 @@ mod tests { assert!(ops.contains(r#""field":"album","op":"isNot","value":[1,2]"#), "inside groups too: {ops}"); } + /// Besides its English message (for agents), a problem names its field and the kind of issue, + /// so the editor can show "Title: needs something to look for" in any language. Every issue + /// has its own short text. + #[test] + fn problems_name_their_field_and_issue() { + let cat = Catalog::new(); + let check = |rules: serde_json::Value| rs(json!({"rules": rules})).check(&cat); + let p = check(json!([{"field": "title", "op": "contains", "value": ""}])); + assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("title"), Issue::NeedsTextOrEmpty)); + let p = check(json!([{"field": "title", "op": "isNot", "value": ""}])); + assert_eq!(p[0].issue, Issue::NeedsTextOrNotEmpty); + let p = check(json!([{"field": "rating", "op": "is", "value": 9}, {"group": {"rules": []}}])); + assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("rating"), Issue::NoRatingMatches)); + assert_eq!((p[1].field.as_deref(), p[1].issue), (None, Issue::EmptyGroup)); + let p = check(json!([{"field": "captureDate", "op": "between", "value": ["2026-12", "2026-01"]}])); + assert_eq!(p[0].issue, Issue::DatesReversed); + let p = check(json!([{"field": "album", "op": "is", "value": null}])); + assert_eq!(p[0].issue, Issue::ChooseAlbum); + let mut texts: Vec<&str> = Issue::ALL.iter().map(|i| i.text()).collect(); + assert!(texts.iter().all(|t| !t.is_empty())); + texts.sort_unstable(); + texts.dedup(); + assert_eq!(texts.len(), Issue::ALL.len(), "each issue reads differently"); + } + /// 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] From f61dc889c667cb4af9952b70a0fc000584b8593f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 09:13:30 -0400 Subject: [PATCH 38/65] LRC-LIB-SMARTCOLL: show rule problems in the editor's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit i18n::problem_text shows a problem as the field's label and the issue in the current language ("Titel: braucht einen Suchbegriff (oder „ist leer“)"); problem_line puts the rule's position first ("#2 Title: …") for the sidebar's tooltip. The editor's row notes and the sidebar use them. All 20 issue texts are translated in the seven locales, each quoting its own operator names ("ist leer", «пусто», 「が空」), with あり / なし for Japanese yes/no. A test requires every issue to be translated in every language and checks English and German output. Also corrects the zh-hant sidebar tooltip to name the menu as it is (編輯智慧型相簿…). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 22 ++++++++++- crates/ui-egui/locales/es.json | 22 ++++++++++- crates/ui-egui/locales/ja.json | 22 ++++++++++- crates/ui-egui/locales/pt-br.json | 22 ++++++++++- crates/ui-egui/locales/ru.json | 22 ++++++++++- crates/ui-egui/locales/zh-hans.json | 22 ++++++++++- crates/ui-egui/locales/zh-hant.json | 22 ++++++++++- crates/ui-egui/src/i18n.rs | 47 ++++++++++++++++++++++- crates/ui-egui/src/panels/left.rs | 2 +- crates/ui-egui/src/panels/rules_editor.rs | 2 +- docs/parity.md | 2 +- docs/whats-new.md | 2 + 12 files changed, 197 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index ebd52e71..cb40d020 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1900,5 +1900,25 @@ "Choose an album…": "Album wählen…", "No albums yet": "Noch keine Alben", "Fix the marked rules to save this album.": "Korrigiere die markierten Regeln, um dieses Album zu speichern.", - "Some rules need fixing (Edit Smart Album…):": "Einige Regeln müssen korrigiert werden (Smart-Album bearbeiten…):" + "Some rules need fixing (Edit Smart Album…):": "Einige Regeln müssen korrigiert werden (Smart-Album bearbeiten…):", + "isn't a field rules know": "ist kein bekanntes Feld", + "can't be tested that way": "lässt sich so nicht prüfen", + "is yes or no": "ist Ja oder Nein", + "needs a number": "braucht eine Zahl", + "needs a time like 1/250 or 2": "braucht eine Zeit wie 1/250 oder 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "braucht ein Datum wie 2026, 2026-08 oder 2026-08-14", + "has the first date after the second": "das erste Datum liegt nach dem zweiten", + "needs two values": "braucht zwei Werte", + "needs a number more than 0": "braucht eine Zahl größer als 0", + "needs hours, days, weeks, months or years": "braucht Stunden, Tage, Wochen, Monate oder Jahre", + "isn't one of the choices": "ist keine der Auswahlmöglichkeiten", + "needs something to look for (or use “is empty”)": "braucht einen Suchbegriff (oder „ist leer“)", + "needs something to look for (or use “isn't empty”)": "braucht einen Suchbegriff (oder „ist nicht leer“)", + "needs text": "braucht Text", + "can't match any rating from 0 to 5": "passt zu keiner Bewertung von 0 bis 5", + "needs an album": "braucht ein Album", + "names an album that no longer exists": "nennt ein Album, das es nicht mehr gibt", + "can't test a smart album; choose a plain album": "kann kein Smart-Album prüfen; wähle ein normales Album", + "can't test a folder; choose an album in it": "kann keinen Ordner prüfen; wähle ein Album darin", + "This group is empty: add a rule or remove it.": "Diese Gruppe ist leer: Füge eine Regel hinzu oder entferne sie." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 77e4959f..a894ed84 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1745,5 +1745,25 @@ "Choose an album…": "Elige un álbum…", "No albums yet": "Aún no hay álbumes", "Fix the marked rules to save this album.": "Corrige las reglas marcadas para guardar este álbum.", - "Some rules need fixing (Edit Smart Album…):": "Hay reglas que corregir (Editar álbum inteligente…):" + "Some rules need fixing (Edit Smart Album…):": "Hay reglas que corregir (Editar álbum inteligente…):", + "isn't a field rules know": "no es un campo conocido", + "can't be tested that way": "no se puede comprobar así", + "is yes or no": "es Sí o No", + "needs a number": "necesita un número", + "needs a time like 1/250 or 2": "necesita un tiempo como 1/250 o 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "necesita una fecha como 2026, 2026-08 o 2026-08-14", + "has the first date after the second": "la primera fecha es posterior a la segunda", + "needs two values": "necesita dos valores", + "needs a number more than 0": "necesita un número mayor que 0", + "needs hours, days, weeks, months or years": "necesita horas, días, semanas, meses o años", + "isn't one of the choices": "no es ninguna de las opciones", + "needs something to look for (or use “is empty”)": "necesita algo que buscar (o usa «está vacío»)", + "needs something to look for (or use “isn't empty”)": "necesita algo que buscar (o usa «no está vacío»)", + "needs text": "necesita texto", + "can't match any rating from 0 to 5": "no coincide con ninguna valoración de 0 a 5", + "needs an album": "necesita un álbum", + "names an album that no longer exists": "indica un álbum que ya no existe", + "can't test a smart album; choose a plain album": "no puede comprobar un álbum inteligente; elige un álbum normal", + "can't test a folder; choose an album in it": "no puede comprobar una carpeta; elige un álbum dentro", + "This group is empty: add a rule or remove it.": "Este grupo está vacío: añade una regla o quítalo." } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 94f96729..b7fa0316 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1749,5 +1749,25 @@ "Choose an album…": "アルバムを選択…", "No albums yet": "アルバムはまだありません", "Fix the marked rules to save this album.": "このアルバムを保存するには、印の付いた条件を修正してください。", - "Some rules need fixing (Edit Smart Album…):": "修正が必要な条件があります(スマートアルバムを編集…):" + "Some rules need fixing (Edit Smart Album…):": "修正が必要な条件があります(スマートアルバムを編集…):", + "isn't a field rules know": "不明な項目です", + "can't be tested that way": "この方法では判定できません", + "is yes or no": "「あり」か「なし」を選んでください", + "needs a number": "数値が必要です", + "needs a time like 1/250 or 2": "1/250 や 2 のようなシャッター速度が必要です", + "needs a date like 2026, 2026-08 or 2026-08-14": "2026、2026-08、2026-08-14 のような日付が必要です", + "has the first date after the second": "最初の日付が2番目の日付より後です", + "needs two values": "2つの値が必要です", + "needs a number more than 0": "0より大きい数値が必要です", + "needs hours, days, weeks, months or years": "時間・日・週・月・年のいずれかが必要です", + "isn't one of the choices": "選択肢にありません", + "needs something to look for (or use “is empty”)": "検索する語句が必要です(または「が空」を使用)", + "needs something to look for (or use “isn't empty”)": "検索する語句が必要です(または「が空でない」を使用)", + "needs text": "文字列が必要です", + "can't match any rating from 0 to 5": "0〜5 のどの評価にも一致しません", + "needs an album": "アルバムを選択してください", + "names an album that no longer exists": "存在しないアルバムが指定されています", + "can't test a smart album; choose a plain album": "スマートアルバムは判定できません。通常のアルバムを選択してください", + "can't test a folder; choose an album in it": "フォルダーは判定できません。中のアルバムを選択してください", + "This group is empty: add a rule or remove it.": "このグループは空です。条件を追加するか、グループを削除してください。" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 1abdf4e4..7e06fa7a 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1745,5 +1745,25 @@ "Choose an album…": "Escolha um álbum…", "No albums yet": "Ainda não há álbuns", "Fix the marked rules to save this album.": "Corrija as regras marcadas para salvar este álbum.", - "Some rules need fixing (Edit Smart Album…):": "Algumas regras precisam ser corrigidas (Editar álbum inteligente…):" + "Some rules need fixing (Edit Smart Album…):": "Algumas regras precisam ser corrigidas (Editar álbum inteligente…):", + "isn't a field rules know": "não é um campo conhecido", + "can't be tested that way": "não pode ser testado assim", + "is yes or no": "é Sim ou Não", + "needs a number": "precisa de um número", + "needs a time like 1/250 or 2": "precisa de um tempo como 1/250 ou 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "precisa de uma data como 2026, 2026-08 ou 2026-08-14", + "has the first date after the second": "a primeira data é posterior à segunda", + "needs two values": "precisa de dois valores", + "needs a number more than 0": "precisa de um número maior que 0", + "needs hours, days, weeks, months or years": "precisa de horas, dias, semanas, meses ou anos", + "isn't one of the choices": "não é nenhuma das opções", + "needs something to look for (or use “is empty”)": "precisa de algo para procurar (ou use “está vazio”)", + "needs something to look for (or use “isn't empty”)": "precisa de algo para procurar (ou use “não está vazio”)", + "needs text": "precisa de texto", + "can't match any rating from 0 to 5": "não corresponde a nenhuma avaliação de 0 a 5", + "needs an album": "precisa de um álbum", + "names an album that no longer exists": "indica um álbum que não existe mais", + "can't test a smart album; choose a plain album": "não pode testar um álbum inteligente; escolha um álbum comum", + "can't test a folder; choose an album in it": "não pode testar uma pasta; escolha um álbum dentro dela", + "This group is empty: add a rule or remove it.": "Este grupo está vazio: adicione uma regra ou remova-o." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 61fed8b4..fd289e14 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1417,5 +1417,25 @@ "Choose an album…": "Выберите альбом…", "No albums yet": "Альбомов пока нет", "Fix the marked rules to save this album.": "Исправьте отмеченные условия, чтобы сохранить альбом.", - "Some rules need fixing (Edit Smart Album…):": "Некоторые условия нужно исправить (Изменить смарт-альбом…):" + "Some rules need fixing (Edit Smart Album…):": "Некоторые условия нужно исправить (Изменить смарт-альбом…):", + "isn't a field rules know": "неизвестное поле", + "can't be tested that way": "так проверить нельзя", + "is yes or no": "только «Да» или «Нет»", + "needs a number": "нужно число", + "needs a time like 1/250 or 2": "нужна выдержка вроде 1/250 или 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "нужна дата вроде 2026, 2026-08 или 2026-08-14", + "has the first date after the second": "первая дата позже второй", + "needs two values": "нужны два значения", + "needs a number more than 0": "нужно число больше 0", + "needs hours, days, weeks, months or years": "нужны часы, дни, недели, месяцы или годы", + "isn't one of the choices": "нет среди вариантов", + "needs something to look for (or use “is empty”)": "нужно указать, что искать (или выберите «пусто»)", + "needs something to look for (or use “isn't empty”)": "нужно указать, что искать (или выберите «не пусто»)", + "needs text": "нужен текст", + "can't match any rating from 0 to 5": "не подходит ни к одной оценке от 0 до 5", + "needs an album": "нужен альбом", + "names an album that no longer exists": "указан альбом, которого больше нет", + "can't test a smart album; choose a plain album": "нельзя проверять смарт-альбом; выберите обычный альбом", + "can't test a folder; choose an album in it": "нельзя проверять папку; выберите альбом в ней", + "This group is empty: add a rule or remove it.": "Эта группа пуста: добавьте условие или удалите её." } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 65e9b194..335867dc 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1745,5 +1745,25 @@ "Choose an album…": "选择相册…", "No albums yet": "还没有相册", "Fix the marked rules to save this album.": "请修正标记的规则以保存此相册。", - "Some rules need fixing (Edit Smart Album…):": "有些规则需要修正(编辑智能相册…):" + "Some rules need fixing (Edit Smart Album…):": "有些规则需要修正(编辑智能相册…):", + "isn't a field rules know": "不是已知的字段", + "can't be tested that way": "不能这样判断", + "is yes or no": "只能是“是”或“否”", + "needs a number": "需要一个数字", + "needs a time like 1/250 or 2": "需要类似 1/250 或 2 的快门速度", + "needs a date like 2026, 2026-08 or 2026-08-14": "需要类似 2026、2026-08 或 2026-08-14 的日期", + "has the first date after the second": "第一个日期晚于第二个", + "needs two values": "需要两个值", + "needs a number more than 0": "需要大于 0 的数字", + "needs hours, days, weeks, months or years": "需要小时、天、周、月或年", + "isn't one of the choices": "不是可选项之一", + "needs something to look for (or use “is empty”)": "需要要查找的内容(或使用“为空”)", + "needs something to look for (or use “isn't empty”)": "需要要查找的内容(或使用“不为空”)", + "needs text": "需要文本", + "can't match any rating from 0 to 5": "不匹配 0 到 5 的任何星级", + "needs an album": "需要选择相册", + "names an album that no longer exists": "指定的相册已不存在", + "can't test a smart album; choose a plain album": "无法判断智能相册;请选择普通相册", + "can't test a folder; choose an album in it": "无法判断文件夹;请选择其中的相册", + "This group is empty: add a rule or remove it.": "此组为空:请添加规则或将其删除。" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 12487b4b..1995f4e8 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1745,5 +1745,25 @@ "Choose an album…": "選擇相簿…", "No albums yet": "還沒有相簿", "Fix the marked rules to save this album.": "請修正標記的規則以儲存此相簿。", - "Some rules need fixing (Edit Smart Album…):": "有些規則需要修正(編輯智慧相簿…):" + "Some rules need fixing (Edit Smart Album…):": "有些規則需要修正(編輯智慧型相簿…):", + "isn't a field rules know": "不是已知的欄位", + "can't be tested that way": "無法這樣判斷", + "is yes or no": "只能是「是」或「否」", + "needs a number": "需要一個數字", + "needs a time like 1/250 or 2": "需要類似 1/250 或 2 的快門速度", + "needs a date like 2026, 2026-08 or 2026-08-14": "需要類似 2026、2026-08 或 2026-08-14 的日期", + "has the first date after the second": "第一個日期晚於第二個", + "needs two values": "需要兩個值", + "needs a number more than 0": "需要大於 0 的數字", + "needs hours, days, weeks, months or years": "需要小時、天、週、月或年", + "isn't one of the choices": "不是可選項之一", + "needs something to look for (or use “is empty”)": "需要要尋找的內容(或使用「為空」)", + "needs something to look for (or use “isn't empty”)": "需要要尋找的內容(或使用「不為空」)", + "needs text": "需要文字", + "can't match any rating from 0 to 5": "不符合 0 到 5 的任何評分", + "needs an album": "需要選擇相簿", + "names an album that no longer exists": "指定的相簿已不存在", + "can't test a smart album; choose a plain album": "無法判斷智慧型相簿;請選擇一般相簿", + "can't test a folder; choose an album in it": "無法判斷資料夾;請選擇其中的相簿", + "This group is empty: add a rule or remove it.": "此群組為空:請新增規則或將其移除。" } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 67ae99f3..21ce65dd 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -328,9 +328,29 @@ pub fn filter_label(filter: &lightcraft_catalog::Filter, catalog: &lightcraft_ca } } -/// What people read for a smart-album rule problem (`rule 2: …`). +/// What people read for a smart-album rule's problem, under its row: the field's name and the +/// issue, in the current language ("Title: needs something to look for"). pub fn problem_text(problem: &lightcraft_catalog::rules::Problem) -> String { - problem.to_string() + problem_text_in(language(), problem) +} + +/// [`problem_text`] with the rule's position first ("#2.1 Rating: …"), for lists away from the +/// editor (the sidebar's tooltip). +pub fn problem_line(problem: &lightcraft_catalog::rules::Problem) -> String { + problem_line_in(language(), problem) +} + +fn problem_text_in(language: Locale, problem: &lightcraft_catalog::rules::Problem) -> String { + let issue = tr_in(language, problem.issue.text()); + match problem.field.as_deref() { + Some(field) => format!("{}: {issue}", tr_in(language, lightcraft_catalog::rules::field_label(field).unwrap_or(field))), + None => issue.to_string(), + } +} + +fn problem_line_in(language: Locale, problem: &lightcraft_catalog::rules::Problem) -> String { + let at: Vec = problem.path.iter().map(|i| i.saturating_add(1).to_string()).collect(); + format!("#{} {}", at.join("."), problem_text_in(language, problem)) } /// What people read for smart-album choice `id` of `field` ("Gemeinfrei" for `publicDomain` in @@ -721,6 +741,29 @@ mod tests { } } + /// A rule's problem reads as the field's name and the issue, in the editor's language ("Titel: + /// braucht einen Suchbegriff …"); the sidebar's list puts the rule's position first. Every issue + /// is translated in every language: an English one would stand out in a translated editor. + #[test] + fn problems_read_in_every_language() { + use lightcraft_catalog::rules::Issue; + let cat = lightcraft_catalog::Catalog::new(); + let problems = |rules: serde_json::Value| { + serde_json::from_value::(serde_json::json!({"rules": rules})).unwrap().check(&cat) + }; + let p = problems(serde_json::json!([{"field": "rating", "op": "gte", "value": 3}, {"field": "title", "op": "contains", "value": ""}])); + assert_eq!(problem_text_in(Locale::En, &p[0]), "Title: needs something to look for (or use “is empty”)"); + assert_eq!(problem_text_in(Locale::De, &p[0]), "Titel: braucht einen Suchbegriff (oder „ist leer“)"); + assert_eq!(problem_line_in(Locale::En, &p[0]), "#2 Title: needs something to look for (or use “is empty”)"); + let g = problems(serde_json::json!([{"group": {"rules": []}}])); + assert_eq!(problem_text_in(Locale::En, &g[0]), "This group is empty: add a rule or remove it."); + for language in Locale::ALL.iter().filter(|l| **l != Locale::En) { + for issue in Issue::ALL { + assert!(language.catalog().contains_key(issue.text()), "{} lacks {:?}", language.code(), issue.text()); + } + } + } + /// Every command, control, rule and rename label, and every line of What's New, is a message a /// 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 diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 7b9584e7..8f843d94 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -749,7 +749,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare tip.push_str(crate::i18n::tr("Some rules need fixing (Edit Smart Album…):")); for p in &problems { tip.push('\n'); - tip.push_str(&crate::i18n::problem_text(p)); + tip.push_str(&crate::i18n::problem_line(p)); } } resp = resp.on_hover_text(tip); diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index 3d6bdd24..cae91ac2 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -266,7 +266,7 @@ fn field_menu(ui: &mut egui::Ui, salt: &str, field: &mut String, op: &mut String fn problem_note(ui: &mut egui::Ui, problem: Option<&Problem>, salt: &str) { let Some(p) = problem else { return }; let t = Tokens::get(ui.ctx()); - let r = ui.label(RichText::new(format!("⚠ {}", p.message)).color(t.caution).small()); + let r = ui.label(RichText::new(format!("⚠ {}", crate::i18n::problem_text(p))).color(t.caution).small()); register(ui.ctx(), format!("ruleProblem:{salt}"), r.rect); } diff --git a/docs/parity.md b/docs/parity.md index 4a86dee5..6aee9961 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, a full time; no 30 February), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album from a list of the plain albums, named with their folder and sorted by name (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Not yet: problem messages are English and name fields by id | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, a full time; no 30 February), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album from a list of the plain albums, named with their folder and sorted by name (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | diff --git a/docs/whats-new.md b/docs/whats-new.md index 8252f9c3..e4d12673 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -17,6 +17,8 @@ way round, "in the last 0 days", a colour label that doesn't exist, "contains" with nothing to look for, an album that is gone or smart, or an empty group is reported with its rule ("rule 2.1: rating is 0–5, not 9") instead of quietly matching nothing or everything. New date rules start at the current year. +- Rule problems are shown in your language and name the field the way the menu does ("Title: needs something to look + for"). - A smart album whose rules no longer check (an album they test was deleted) is marked ⚠ in the sidebar; its tooltip says what to fix, and `albums.list` reports `problems`. - Editing a smart album's name and rules is one step: both or neither, and one undo takes both back From 633775e5ed96eee0e2bbe87c80d724f70c46a01f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 09:20:23 -0400 Subject: [PATCH 39/65] LRC-LIB-SMARTCOLL: hints name each field's own operator; Russian operator labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review: - Keywords and People say "are empty" / "aren't empty", but their empty-text hint said "is empty". New issues NeedsNamesOrEmpty / NeedsNamesOrNotEmpty quote their operators, translated in all seven locales with each locale's own label (sind leer, están vacíos, が空…). - Russian had none of the 20 smart-album operator labels, so its editor showed every operator in English and the new hints named «пусто», which wasn't on screen. All 20 are translated now, and a test requires every operator label in every language. - Japanese and Chinese join a field and its problem with the full-width colon their catalogs use ("タイトル:…"). Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/rules.rs | 24 +++++++++++++++++++----- crates/ui-egui/locales/de.json | 4 +++- crates/ui-egui/locales/es.json | 4 +++- crates/ui-egui/locales/ja.json | 4 +++- crates/ui-egui/locales/pt-br.json | 4 +++- crates/ui-egui/locales/ru.json | 24 +++++++++++++++++++++++- crates/ui-egui/locales/zh-hans.json | 4 +++- crates/ui-egui/locales/zh-hant.json | 4 +++- crates/ui-egui/src/i18n.rs | 12 +++++++++++- 9 files changed, 71 insertions(+), 13 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index ce98972e..8eaa7f89 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -185,6 +185,9 @@ pub enum Issue { NotAChoice, NeedsTextOrEmpty, NeedsTextOrNotEmpty, + /// Keywords and People: their operators say "are empty". + NeedsNamesOrEmpty, + NeedsNamesOrNotEmpty, NeedsText, NoRatingMatches, ChooseAlbum, @@ -195,7 +198,7 @@ pub enum Issue { } impl Issue { - pub const ALL: [Issue; 20] = [ + pub const ALL: [Issue; 22] = [ Issue::UnknownField, Issue::NoSuchOperator, Issue::NotYesNo, @@ -209,6 +212,8 @@ impl Issue { Issue::NotAChoice, Issue::NeedsTextOrEmpty, Issue::NeedsTextOrNotEmpty, + Issue::NeedsNamesOrEmpty, + Issue::NeedsNamesOrNotEmpty, Issue::NeedsText, Issue::NoRatingMatches, Issue::ChooseAlbum, @@ -234,6 +239,8 @@ impl Issue { Issue::NotAChoice => "isn't one of the choices", Issue::NeedsTextOrEmpty => "needs something to look for (or use “is empty”)", Issue::NeedsTextOrNotEmpty => "needs something to look for (or use “isn't empty”)", + Issue::NeedsNamesOrEmpty => "needs something to look for (or use “are empty”)", + Issue::NeedsNamesOrNotEmpty => "needs something to look for (or use “aren't empty”)", Issue::NeedsText => "needs text", Issue::NoRatingMatches => "can't match any rating from 0 to 5", Issue::ChooseAlbum => "needs an album", @@ -278,10 +285,13 @@ fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog) -> Found { Kind::Text | Kind::Keywords => match value { Value::String(s) if !s.trim().is_empty() => None, Value::String(_) | Value::Null => { - let (issue, instead) = if matches!(op, "isNot" | "notContains") { - (Issue::NeedsTextOrNotEmpty, "isn't empty") - } else { - (Issue::NeedsTextOrEmpty, "is empty") + // the hint names the operator as the menu does: Keywords and People say "are empty" + let not = matches!(op, "isNot" | "notContains"); + let (issue, instead) = match (kind, not) { + (Kind::Keywords, false) => (Issue::NeedsNamesOrEmpty, "are empty"), + (Kind::Keywords, true) => (Issue::NeedsNamesOrNotEmpty, "aren't empty"), + (_, false) => (Issue::NeedsTextOrEmpty, "is empty"), + (_, true) => (Issue::NeedsTextOrNotEmpty, "isn't empty"), }; Some((issue, format!("`{field}` needs something to look for (or use “{instead}”)"))) } @@ -1408,6 +1418,10 @@ mod tests { assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("title"), Issue::NeedsTextOrEmpty)); let p = check(json!([{"field": "title", "op": "isNot", "value": ""}])); assert_eq!(p[0].issue, Issue::NeedsTextOrNotEmpty); + // keywords and people say "are empty", and so does their hint + let p = check(json!([{"field": "keywords", "op": "contains", "value": ""}, {"field": "person", "op": "notContains", "value": " "}])); + assert_eq!((p[0].issue, p[1].issue), (Issue::NeedsNamesOrEmpty, Issue::NeedsNamesOrNotEmpty)); + assert!(p[0].issue.text().contains("“are empty”") && p[0].message.contains("“are empty”")); let p = check(json!([{"field": "rating", "op": "is", "value": 9}, {"group": {"rules": []}}])); assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("rating"), Issue::NoRatingMatches)); assert_eq!((p[1].field.as_deref(), p[1].issue), (None, Issue::EmptyGroup)); diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index cb40d020..782e2a44 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1920,5 +1920,7 @@ "names an album that no longer exists": "nennt ein Album, das es nicht mehr gibt", "can't test a smart album; choose a plain album": "kann kein Smart-Album prüfen; wähle ein normales Album", "can't test a folder; choose an album in it": "kann keinen Ordner prüfen; wähle ein Album darin", - "This group is empty: add a rule or remove it.": "Diese Gruppe ist leer: Füge eine Regel hinzu oder entferne sie." + "This group is empty: add a rule or remove it.": "Diese Gruppe ist leer: Füge eine Regel hinzu oder entferne sie.", + "needs something to look for (or use “are empty”)": "braucht einen Suchbegriff (oder „sind leer“)", + "needs something to look for (or use “aren't empty”)": "braucht einen Suchbegriff (oder „sind nicht leer“)" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index a894ed84..61dacae4 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1765,5 +1765,7 @@ "names an album that no longer exists": "indica un álbum que ya no existe", "can't test a smart album; choose a plain album": "no puede comprobar un álbum inteligente; elige un álbum normal", "can't test a folder; choose an album in it": "no puede comprobar una carpeta; elige un álbum dentro", - "This group is empty: add a rule or remove it.": "Este grupo está vacío: añade una regla o quítalo." + "This group is empty: add a rule or remove it.": "Este grupo está vacío: añade una regla o quítalo.", + "needs something to look for (or use “are empty”)": "necesita algo que buscar (o usa «están vacíos»)", + "needs something to look for (or use “aren't empty”)": "necesita algo que buscar (o usa «no están vacíos»)" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index b7fa0316..99f0e8f2 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1769,5 +1769,7 @@ "names an album that no longer exists": "存在しないアルバムが指定されています", "can't test a smart album; choose a plain album": "スマートアルバムは判定できません。通常のアルバムを選択してください", "can't test a folder; choose an album in it": "フォルダーは判定できません。中のアルバムを選択してください", - "This group is empty: add a rule or remove it.": "このグループは空です。条件を追加するか、グループを削除してください。" + "This group is empty: add a rule or remove it.": "このグループは空です。条件を追加するか、グループを削除してください。", + "needs something to look for (or use “are empty”)": "検索する語句が必要です(または「が空」を使用)", + "needs something to look for (or use “aren't empty”)": "検索する語句が必要です(または「が空でない」を使用)" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 7e06fa7a..3fddf2c9 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1765,5 +1765,7 @@ "names an album that no longer exists": "indica um álbum que não existe mais", "can't test a smart album; choose a plain album": "não pode testar um álbum inteligente; escolha um álbum comum", "can't test a folder; choose an album in it": "não pode testar uma pasta; escolha um álbum dentro dela", - "This group is empty: add a rule or remove it.": "Este grupo está vazio: adicione uma regra ou remova-o." + "This group is empty: add a rule or remove it.": "Este grupo está vazio: adicione uma regra ou remova-o.", + "needs something to look for (or use “are empty”)": "precisa de algo para procurar (ou use “estão vazios”)", + "needs something to look for (or use “aren't empty”)": "precisa de algo para procurar (ou use “não estão vazios”)" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index fd289e14..516c2636 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1437,5 +1437,27 @@ "names an album that no longer exists": "указан альбом, которого больше нет", "can't test a smart album; choose a plain album": "нельзя проверять смарт-альбом; выберите обычный альбом", "can't test a folder; choose an album in it": "нельзя проверять папку; выберите альбом в ней", - "This group is empty: add a rule or remove it.": "Эта группа пуста: добавьте условие или удалите её." + "This group is empty: add a rule or remove it.": "Эта группа пуста: добавьте условие или удалите её.", + "needs something to look for (or use “are empty”)": "нужно указать, что искать (или выберите «пусты»)", + "needs something to look for (or use “aren't empty”)": "нужно указать, что искать (или выберите «не пусты»)", + "are empty": "пусты", + "aren't empty": "не пусты", + "contains": "содержит", + "doesn't contain": "не содержит", + "ends with": "заканчивается на", + "is": "равно", + "is <": "меньше", + "is >": "больше", + "is after": "после", + "is before": "до", + "is between": "между", + "is empty": "пусто", + "is in the last": "за последние", + "is unknown": "неизвестна", + "is ≤": "не больше", + "is ≥": "не меньше", + "isn't": "не равно", + "isn't empty": "не пусто", + "isn't in the last": "не за последние", + "starts with": "начинается с" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 335867dc..e0b44b54 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1765,5 +1765,7 @@ "names an album that no longer exists": "指定的相册已不存在", "can't test a smart album; choose a plain album": "无法判断智能相册;请选择普通相册", "can't test a folder; choose an album in it": "无法判断文件夹;请选择其中的相册", - "This group is empty: add a rule or remove it.": "此组为空:请添加规则或将其删除。" + "This group is empty: add a rule or remove it.": "此组为空:请添加规则或将其删除。", + "needs something to look for (or use “are empty”)": "需要要查找的内容(或使用“为空”)", + "needs something to look for (or use “aren't empty”)": "需要要查找的内容(或使用“不为空”)" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 1995f4e8..ec8e6344 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1765,5 +1765,7 @@ "names an album that no longer exists": "指定的相簿已不存在", "can't test a smart album; choose a plain album": "無法判斷智慧型相簿;請選擇一般相簿", "can't test a folder; choose an album in it": "無法判斷資料夾;請選擇其中的相簿", - "This group is empty: add a rule or remove it.": "此群組為空:請新增規則或將其移除。" + "This group is empty: add a rule or remove it.": "此群組為空:請新增規則或將其移除。", + "needs something to look for (or use “are empty”)": "需要要尋找的內容(或使用「為空」)", + "needs something to look for (or use “aren't empty”)": "需要要尋找的內容(或使用「不為空」)" } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 21ce65dd..b1332170 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -342,8 +342,10 @@ pub fn problem_line(problem: &lightcraft_catalog::rules::Problem) -> String { fn problem_text_in(language: Locale, problem: &lightcraft_catalog::rules::Problem) -> String { let issue = tr_in(language, problem.issue.text()); + // Japanese and Chinese catalogs join a label and its text with a full-width colon + let colon = if matches!(language.script(), "Jpan" | "Hans" | "Hant") { ":" } else { ": " }; match problem.field.as_deref() { - Some(field) => format!("{}: {issue}", tr_in(language, lightcraft_catalog::rules::field_label(field).unwrap_or(field))), + Some(field) => format!("{}{colon}{issue}", tr_in(language, lightcraft_catalog::rules::field_label(field).unwrap_or(field))), None => issue.to_string(), } } @@ -755,12 +757,20 @@ mod tests { assert_eq!(problem_text_in(Locale::En, &p[0]), "Title: needs something to look for (or use “is empty”)"); assert_eq!(problem_text_in(Locale::De, &p[0]), "Titel: braucht einen Suchbegriff (oder „ist leer“)"); assert_eq!(problem_line_in(Locale::En, &p[0]), "#2 Title: needs something to look for (or use “is empty”)"); + // Japanese and Chinese join with a full-width colon, as their catalogs do + assert_eq!(problem_text_in(Locale::Ja, &p[0]), "タイトル:検索する語句が必要です(または「が空」を使用)"); let g = problems(serde_json::json!([{"group": {"rules": []}}])); assert_eq!(problem_text_in(Locale::En, &g[0]), "This group is empty: add a rule or remove it."); + // the issues, and the operators their hints name, in every language for language in Locale::ALL.iter().filter(|l| **l != Locale::En) { for issue in Issue::ALL { assert!(language.catalog().contains_key(issue.text()), "{} lacks {:?}", language.code(), issue.text()); } + for (_, _, kind) in lightcraft_catalog::rules::FIELDS { + for (_, op) in lightcraft_catalog::rules::ops_for(*kind) { + assert!(language.catalog().contains_key(*op), "{} lacks the operator {op:?}", language.code()); + } + } } } From b8d9f82f0eec708206788e27cb58f69a02b751a4 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 09:27:11 -0400 Subject: [PATCH 40/65] LRC-LIB-SMARTCOLL: one reading of rule dates; refuse time zones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some date values passed the check but never matched. "2026-10-01 10:00" (a space, not T) and "…T10:00:00+02:00" (a zone) were accepted, while the matcher compared them as written against capture times stored as "2026-10-01T10:00:00". rules::rule_date is now the one reading for the check and the matcher: 2026, 2026-08, 2026-08-14, then T10 / T10:00 / T10:00:00, with a space working as the T, and a real date and time (25 o'clock and minute 61 are refused too). A time zone or fractions of a second can never match a local, whole-second capture time. They get their own issue, DateWithZone, translated in all seven locales, instead of matching nothing silently. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/rules.rs | 80 ++++++++++++++++++++++++----- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- docs/parity.md | 2 +- 9 files changed, 82 insertions(+), 21 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 8eaa7f89..64029fd8 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -178,6 +178,7 @@ pub enum Issue { NeedsNumber, NeedsShutterSpeed, NeedsDate, + DateWithZone, DatesReversed, NeedsTwoValues, NeedsCount, @@ -198,13 +199,14 @@ pub enum Issue { } impl Issue { - pub const ALL: [Issue; 22] = [ + pub const ALL: [Issue; 23] = [ Issue::UnknownField, Issue::NoSuchOperator, Issue::NotYesNo, Issue::NeedsNumber, Issue::NeedsShutterSpeed, Issue::NeedsDate, + Issue::DateWithZone, Issue::DatesReversed, Issue::NeedsTwoValues, Issue::NeedsCount, @@ -232,6 +234,7 @@ impl Issue { Issue::NeedsNumber => "needs a number", Issue::NeedsShutterSpeed => "needs a time like 1/250 or 2", Issue::NeedsDate => "needs a date like 2026, 2026-08 or 2026-08-14", + Issue::DateWithZone => "can't have a time zone or fractions of a second", Issue::DatesReversed => "has the first date after the second", Issue::NeedsTwoValues => "needs two values", Issue::NeedsCount => "needs a number more than 0", @@ -328,21 +331,40 @@ fn album_problem(v: &Value, cat: &Catalog) -> Found { } } -/// A date as rules take it: `2026`, `2026-08`, `2026-08-14` or a full ISO time, and a real one -/// (no month 13, no 30 February). -fn is_rule_date(s: &str) -> bool { - let s = s.trim(); - let full = match s.len() { +/// A rule date in its one reading: `2026`, `2026-08`, `2026-08-14`, then optionally `T10`, +/// `T10:00` or `T10:00:00` (a space works as the `T`), and a real date and time (no month 13, no +/// 30 February, no 25 o'clock). Capture times are local and to the second, so a time zone or +/// fractions of a second ([`Issue::DateWithZone`]) could never match; anything else is +/// [`Issue::NeedsDate`]. The check and the matcher both read dates this way. +pub fn rule_date(s: &str) -> Result { + const PATTERN: &str = "dddd-dd-ddTdd:dd:dd"; + let s: String = s.trim().char_indices().map(|(i, c)| if i == 10 && c == ' ' { 'T' } else { c }).collect(); + let shaped = |t: &str| { + matches!(t.len(), 4 | 7 | 10 | 13 | 16 | 19) + && t.chars().zip(PATTERN.chars()).all(|(c, p)| if p == 'd' { c.is_ascii_digit() } else { c == p }) + }; + if !shaped(&s) { + // a whole date and time with something after it: a zone (+02:00, -05:00, Z) or a fraction + let zoned = [19, 16, 13].iter().any(|&n| { + s.get(..n).is_some_and(shaped) + && s.get(n..).and_then(|rest| rest.chars().next()).is_some_and(|c| matches!(c, '+' | '-' | 'Z' | 'z' | '.')) + }); + return Err(if zoned { Issue::DateWithZone } else { Issue::NeedsDate }); + } + let part = |a: usize, b: usize| s.get(a..b).and_then(|t| t.parse::().ok()); + let time_ok = part(11, 13).is_none_or(|h| h < 24) && part(14, 16).is_none_or(|m| m < 60) && part(17, 19).is_none_or(|x| x < 60); + let day = match s.len() { 4 => format!("{s}-01-01"), 7 => format!("{s}-01"), - _ => s.to_string(), + _ => s.get(..10).unwrap_or("").to_string(), }; - s.len() >= 4 && crate::dates::normalize_iso(&full).is_some() + if time_ok && crate::dates::normalize_iso(&day).is_some() { Ok(s) } else { Err(Issue::NeedsDate) } } fn date_problem(field: &str, op: &str, value: &Value) -> Found { - let date = |v: &Value| match v.as_str() { - Some(s) if is_rule_date(s) => None, + let date = |v: &Value| match v.as_str().map(rule_date) { + Some(Ok(_)) => None, + Some(Err(Issue::DateWithZone)) => Some((Issue::DateWithZone, format!("`{field}` can't have a time zone or fractions of a second, not {v}"))), _ => Some((Issue::NeedsDate, format!("`{field}` needs a date like 2026, 2026-08 or 2026-08-14, not {v}"))), }; match op { @@ -358,9 +380,9 @@ fn date_problem(field: &str, op: &str, value: &Value) -> Found { } "between" => match value { Value::Array(a) if a.len() == 2 => a.iter().find_map(date).or_else(|| { - let bound = |i: usize| a.get(i).and_then(Value::as_str).unwrap_or("").trim(); + let bound = |i: usize| a.get(i).and_then(Value::as_str).and_then(|d| rule_date(d).ok()).unwrap_or_default(); let (from, to) = (bound(0), bound(1)); - (from > to && !from.starts_with(to)).then(|| (Issue::DatesReversed, format!("`{field}`: the first date is after the second"))) + (from > to && !from.starts_with(&to)).then(|| (Issue::DatesReversed, format!("`{field}`: the first date is after the second"))) }), _ => Some((Issue::NeedsTwoValues, format!("`{field}` between needs two dates, not {value}"))), }, @@ -621,7 +643,8 @@ fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool { return have.is_none_or(str::is_empty); } let Some(h) = have.filter(|h| !h.is_empty()) else { return op == "notInLast" }; - let s = |v: &Value| v.as_str().map(str::trim).unwrap_or("").to_string(); + // the rule's date as the check reads it (a space as the "T"); one it can't read as written + let s = |v: &Value| v.as_str().map(|d| rule_date(d).unwrap_or_else(|_| d.trim().to_string())).unwrap_or_default(); match op { // a prefix: 2026, 2026-04, 2026-04-12 "is" => { @@ -1436,6 +1459,37 @@ mod tests { assert_eq!(texts.len(), Issue::ALL.len(), "each issue reads differently"); } + /// A rule date reads one way for the check and the matcher: a space works as the "T" + /// ("2026-10-01 10:00" matches a photo taken then); a time zone or fractions of a second can + /// never match a capture time (stored local, to the second), so they are refused with their + /// own issue rather than accepted and silently matching nothing. + #[test] + fn rule_dates_have_one_reading() { + let cat = Catalog::new(); + let mut p = photo(1); + p.captured = Some("2026-10-01T10:00:00".into()); + let rule = |op: &str, v: serde_json::Value| rs(json!({"rules": [{"field": "captureDate", "op": op, "value": v}]})); + for (op, v) in [ + ("is", json!("2026-10-01 10:00")), + ("is", json!(" 2026-10-01T10 ")), + ("between", json!(["2026-10-01 09:00", "2026-10-01 11:00"])), + ("after", json!("2026-10-01 09:59")), + ] { + let r = rule(op, v.clone()); + assert!(r.check(&cat).is_empty() && r.matches(&p, &cat), "{op} {v}"); + } + for v in ["2026-08-14T10:00:00+02:00", "2026-08-14T10:00:00Z", "2026-08-14T10:00:00.25", "2026-08-14 10:00-05:00"] { + let p = rule("is", json!(v)).check(&cat); + assert_eq!(p.first().map(|p| p.issue), Some(Issue::DateWithZone), "{v}"); + } + for v in ["2026-8-14", "2026-08-14T1", "2026-08-14X10", "26-08-14", "2026-08-14T25:00"] { + let p = rule("is", json!(v)).check(&cat); + assert_eq!(p.first().map(|p| p.issue), Some(Issue::NeedsDate), "{v}"); + } + assert_eq!(rule_date("2026-10-01 10:00"), Ok("2026-10-01T10:00".to_string())); + assert_eq!(rule_date("2026"), Ok("2026".to_string())); + } + /// 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/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 782e2a44..81477a18 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1922,5 +1922,6 @@ "can't test a folder; choose an album in it": "kann keinen Ordner prüfen; wähle ein Album darin", "This group is empty: add a rule or remove it.": "Diese Gruppe ist leer: Füge eine Regel hinzu oder entferne sie.", "needs something to look for (or use “are empty”)": "braucht einen Suchbegriff (oder „sind leer“)", - "needs something to look for (or use “aren't empty”)": "braucht einen Suchbegriff (oder „sind nicht leer“)" + "needs something to look for (or use “aren't empty”)": "braucht einen Suchbegriff (oder „sind nicht leer“)", + "can't have a time zone or fractions of a second": "darf keine Zeitzone und keine Sekundenbruchteile haben" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 61dacae4..b397dee5 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1767,5 +1767,6 @@ "can't test a folder; choose an album in it": "no puede comprobar una carpeta; elige un álbum dentro", "This group is empty: add a rule or remove it.": "Este grupo está vacío: añade una regla o quítalo.", "needs something to look for (or use “are empty”)": "necesita algo que buscar (o usa «están vacíos»)", - "needs something to look for (or use “aren't empty”)": "necesita algo que buscar (o usa «no están vacíos»)" + "needs something to look for (or use “aren't empty”)": "necesita algo que buscar (o usa «no están vacíos»)", + "can't have a time zone or fractions of a second": "no puede tener zona horaria ni fracciones de segundo" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 99f0e8f2..8b974e66 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1771,5 +1771,6 @@ "can't test a folder; choose an album in it": "フォルダーは判定できません。中のアルバムを選択してください", "This group is empty: add a rule or remove it.": "このグループは空です。条件を追加するか、グループを削除してください。", "needs something to look for (or use “are empty”)": "検索する語句が必要です(または「が空」を使用)", - "needs something to look for (or use “aren't empty”)": "検索する語句が必要です(または「が空でない」を使用)" + "needs something to look for (or use “aren't empty”)": "検索する語句が必要です(または「が空でない」を使用)", + "can't have a time zone or fractions of a second": "タイムゾーンや秒未満の値は使えません" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 3fddf2c9..9330a93f 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1767,5 +1767,6 @@ "can't test a folder; choose an album in it": "não pode testar uma pasta; escolha um álbum dentro dela", "This group is empty: add a rule or remove it.": "Este grupo está vazio: adicione uma regra ou remova-o.", "needs something to look for (or use “are empty”)": "precisa de algo para procurar (ou use “estão vazios”)", - "needs something to look for (or use “aren't empty”)": "precisa de algo para procurar (ou use “não estão vazios”)" + "needs something to look for (or use “aren't empty”)": "precisa de algo para procurar (ou use “não estão vazios”)", + "can't have a time zone or fractions of a second": "não pode ter fuso horário nem frações de segundo" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 516c2636..dd2b1eda 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1459,5 +1459,6 @@ "isn't": "не равно", "isn't empty": "не пусто", "isn't in the last": "не за последние", - "starts with": "начинается с" + "starts with": "начинается с", + "can't have a time zone or fractions of a second": "не может содержать часовой пояс или доли секунды" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index e0b44b54..1b631961 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1767,5 +1767,6 @@ "can't test a folder; choose an album in it": "无法判断文件夹;请选择其中的相册", "This group is empty: add a rule or remove it.": "此组为空:请添加规则或将其删除。", "needs something to look for (or use “are empty”)": "需要要查找的内容(或使用“为空”)", - "needs something to look for (or use “aren't empty”)": "需要要查找的内容(或使用“不为空”)" + "needs something to look for (or use “aren't empty”)": "需要要查找的内容(或使用“不为空”)", + "can't have a time zone or fractions of a second": "不能包含时区或不足一秒的部分" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index ec8e6344..37a975a6 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1767,5 +1767,6 @@ "can't test a folder; choose an album in it": "無法判斷資料夾;請選擇其中的相簿", "This group is empty: add a rule or remove it.": "此群組為空:請新增規則或將其移除。", "needs something to look for (or use “are empty”)": "需要要尋找的內容(或使用「為空」)", - "needs something to look for (or use “aren't empty”)": "需要要尋找的內容(或使用「不為空」)" + "needs something to look for (or use “aren't empty”)": "需要要尋找的內容(或使用「不為空」)", + "can't have a time zone or fractions of a second": "不能包含時區或不足一秒的部分" } diff --git a/docs/parity.md b/docs/parity.md index 6aee9961..139dc09f 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, a full time; no 30 February), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album from a list of the plain albums, named with their folder and sorted by name (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`; no 30 February; a time zone or fractions of a second are refused, since capture times are local and to the second; `rules::rule_date` is the one reading for the check and the matcher), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album from a list of the plain albums, named with their folder and sorted by name (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | From 1a82be68ea7c7c1eac21a4c57a163ea42c2aa1c6 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 09:37:45 -0400 Subject: [PATCH 41/65] LRC-LIB-SMARTCOLL: a standalone date picker component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crates/ui-egui/src/date_picker.rs: a calendar button that opens a popup to pick a year, a month or a day. egui_extras' DatePickerButton was considered and left: its weekday names, month names, "Week", "Cancel" and "Save" are hard-coded English, it only picks whole days, and it needs a Save click and the jiff crate. - PickedDate { year, month?, day? } keeps a value's precision (2026 means the whole year): parse / iso / precision / first_day / last_day. days_in_month, a Monday-first month_grid and in_bounds (any day of the value in range) are pure and unit-tested. - DatePicker::new(id, &mut Option) with precisions(), min(), max() and today(). It opens on the value at its precision (else today, on days) and returns a response that is changed() when a date is picked. Out-of-range cells are disabled. - It follows the UI language: Year / Month / Day, month titles and labels from the date headings (date_group_label), and weekday headers (Mo…Su) translated in all seven locales with "Pick a date". A test requires them. - Widget ids for tests and agents: datePicker:, datePickerPrecision:

:, datePickerPrev/Next:, datePickerCell::. - A code-drawn Calendar icon in icons.rs (original work, covered by its attribution entry). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 10 +- crates/ui-egui/locales/es.json | 10 +- crates/ui-egui/locales/ja.json | 10 +- crates/ui-egui/locales/pt-br.json | 10 +- crates/ui-egui/locales/ru.json | 10 +- crates/ui-egui/locales/zh-hans.json | 10 +- crates/ui-egui/locales/zh-hant.json | 10 +- crates/ui-egui/src/date_picker.rs | 372 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 11 + crates/ui-egui/src/icons.rs | 9 + crates/ui-egui/src/lib.rs | 1 + 11 files changed, 456 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/date_picker.rs diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 81477a18..b9c07d95 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1923,5 +1923,13 @@ "This group is empty: add a rule or remove it.": "Diese Gruppe ist leer: Füge eine Regel hinzu oder entferne sie.", "needs something to look for (or use “are empty”)": "braucht einen Suchbegriff (oder „sind leer“)", "needs something to look for (or use “aren't empty”)": "braucht einen Suchbegriff (oder „sind nicht leer“)", - "can't have a time zone or fractions of a second": "darf keine Zeitzone und keine Sekundenbruchteile haben" + "can't have a time zone or fractions of a second": "darf keine Zeitzone und keine Sekundenbruchteile haben", + "Mo": "Mo", + "Tu": "Di", + "We": "Mi", + "Th": "Do", + "Fr": "Fr", + "Sa": "Sa", + "Su": "So", + "Pick a date": "Datum wählen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index b397dee5..1440a19d 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1768,5 +1768,13 @@ "This group is empty: add a rule or remove it.": "Este grupo está vacío: añade una regla o quítalo.", "needs something to look for (or use “are empty”)": "necesita algo que buscar (o usa «están vacíos»)", "needs something to look for (or use “aren't empty”)": "necesita algo que buscar (o usa «no están vacíos»)", - "can't have a time zone or fractions of a second": "no puede tener zona horaria ni fracciones de segundo" + "can't have a time zone or fractions of a second": "no puede tener zona horaria ni fracciones de segundo", + "Mo": "Lu", + "Tu": "Ma", + "We": "Mi", + "Th": "Ju", + "Fr": "Vi", + "Sa": "Sá", + "Su": "Do", + "Pick a date": "Elegir una fecha" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 8b974e66..918ef999 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1772,5 +1772,13 @@ "This group is empty: add a rule or remove it.": "このグループは空です。条件を追加するか、グループを削除してください。", "needs something to look for (or use “are empty”)": "検索する語句が必要です(または「が空」を使用)", "needs something to look for (or use “aren't empty”)": "検索する語句が必要です(または「が空でない」を使用)", - "can't have a time zone or fractions of a second": "タイムゾーンや秒未満の値は使えません" + "can't have a time zone or fractions of a second": "タイムゾーンや秒未満の値は使えません", + "Mo": "月", + "Tu": "火", + "We": "水", + "Th": "木", + "Fr": "金", + "Sa": "土", + "Su": "日", + "Pick a date": "日付を選択" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9330a93f..9a2ab678 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1768,5 +1768,13 @@ "This group is empty: add a rule or remove it.": "Este grupo está vazio: adicione uma regra ou remova-o.", "needs something to look for (or use “are empty”)": "precisa de algo para procurar (ou use “estão vazios”)", "needs something to look for (or use “aren't empty”)": "precisa de algo para procurar (ou use “não estão vazios”)", - "can't have a time zone or fractions of a second": "não pode ter fuso horário nem frações de segundo" + "can't have a time zone or fractions of a second": "não pode ter fuso horário nem frações de segundo", + "Mo": "Seg", + "Tu": "Ter", + "We": "Qua", + "Th": "Qui", + "Fr": "Sex", + "Sa": "Sáb", + "Su": "Dom", + "Pick a date": "Escolher uma data" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index dd2b1eda..442acb95 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1460,5 +1460,13 @@ "isn't empty": "не пусто", "isn't in the last": "не за последние", "starts with": "начинается с", - "can't have a time zone or fractions of a second": "не может содержать часовой пояс или доли секунды" + "can't have a time zone or fractions of a second": "не может содержать часовой пояс или доли секунды", + "Mo": "Пн", + "Tu": "Вт", + "We": "Ср", + "Th": "Чт", + "Fr": "Пт", + "Sa": "Сб", + "Su": "Вс", + "Pick a date": "Выбрать дату" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 1b631961..0af301ab 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1768,5 +1768,13 @@ "This group is empty: add a rule or remove it.": "此组为空:请添加规则或将其删除。", "needs something to look for (or use “are empty”)": "需要要查找的内容(或使用“为空”)", "needs something to look for (or use “aren't empty”)": "需要要查找的内容(或使用“不为空”)", - "can't have a time zone or fractions of a second": "不能包含时区或不足一秒的部分" + "can't have a time zone or fractions of a second": "不能包含时区或不足一秒的部分", + "Mo": "一", + "Tu": "二", + "We": "三", + "Th": "四", + "Fr": "五", + "Sa": "六", + "Su": "日", + "Pick a date": "选择日期" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 37a975a6..649b26a7 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1768,5 +1768,13 @@ "This group is empty: add a rule or remove it.": "此群組為空:請新增規則或將其移除。", "needs something to look for (or use “are empty”)": "需要要尋找的內容(或使用「為空」)", "needs something to look for (or use “aren't empty”)": "需要要尋找的內容(或使用「不為空」)", - "can't have a time zone or fractions of a second": "不能包含時區或不足一秒的部分" + "can't have a time zone or fractions of a second": "不能包含時區或不足一秒的部分", + "Mo": "一", + "Tu": "二", + "We": "三", + "Th": "四", + "Fr": "五", + "Sa": "六", + "Su": "日", + "Pick a date": "選擇日期" } diff --git a/crates/ui-egui/src/date_picker.rs b/crates/ui-egui/src/date_picker.rs new file mode 100644 index 00000000..ef801ac0 --- /dev/null +++ b/crates/ui-egui/src/date_picker.rs @@ -0,0 +1,372 @@ +//! A date picker: a calendar button that opens a popup to pick a year, a month or a day. +//! +//! Standalone: it edits a [`PickedDate`] (a year, optionally a month, optionally a day), so a value +//! keeps its precision (`2026` means the whole year). Callers can limit the precisions offered and +//! the dates allowed ([`DatePicker::min`] / [`DatePicker::max`]: a value is allowed when any of its +//! days falls in range), and say what "today" is. Weekday and month names follow the UI language. +//! Widget ids for tests and agents: `datePicker:` (the button), +//! `datePickerPrecision::`, `datePickerPrev:`, `datePickerNext:` and +//! `datePickerCell::`. + +use egui::{Response, RichText, Ui, vec2}; + +use crate::icons::Icon; +use crate::widgets::register; + +/// How much of a date a value names. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Precision { + Year, + Month, + Day, +} + +impl Precision { + pub const ALL: [Precision; 3] = [Precision::Year, Precision::Month, Precision::Day]; + + fn id(self) -> &'static str { + match self { + Precision::Year => "year", + Precision::Month => "month", + Precision::Day => "day", + } + } + + fn label(self) -> &'static str { + match self { + Precision::Year => "Year", + Precision::Month => "Month", + Precision::Day => "Day", + } + } +} + +/// A year, a month of it or a day of that: `2026`, `2026-08`, `2026-08-14`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PickedDate { + pub year: i32, + pub month: Option, + pub day: Option, +} + +impl PickedDate { + pub fn year(year: i32) -> Self { + PickedDate { year, month: None, day: None } + } + + /// Reads `2026`, `2026-08` or `2026-08-14` (anything after a day's `T` or space, such as a + /// time, is read past); `None` for anything else or a date that doesn't exist. + pub fn parse(s: &str) -> Option { + let s = s.trim(); + let num = |a: usize, b: usize| s.get(a..b).filter(|t| t.bytes().all(|c| c.is_ascii_digit())).and_then(|t| t.parse::().ok()); + let year = i32::try_from(num(0, 4)?).ok()?; + if s.len() == 4 { + return Some(PickedDate::year(year)); + } + let month = u8::try_from(num(5, 7)?).ok().filter(|m| (1..=12).contains(m))?; + if s.get(4..5) != Some("-") { + return None; + } + if s.len() == 7 { + return Some(PickedDate { year, month: Some(month), day: None }); + } + let day = u8::try_from(num(8, 10)?).ok().filter(|d| (1..=days_in_month(year, month)).contains(d))?; + let time = s.get(10..11); + (s.get(7..8) == Some("-") && (s.len() == 10 || matches!(time, Some("T" | " ")))).then_some(PickedDate { + year, + month: Some(month), + day: Some(day), + }) + } + + /// `2026`, `2026-08` or `2026-08-14`. + pub fn iso(&self) -> String { + match (self.month, self.day) { + (Some(m), Some(d)) => format!("{:04}-{m:02}-{d:02}", self.year), + (Some(m), None) => format!("{:04}-{m:02}", self.year), + _ => format!("{:04}", self.year), + } + } + + pub fn precision(&self) -> Precision { + match (self.month, self.day) { + (Some(_), Some(_)) => Precision::Day, + (Some(_), None) => Precision::Month, + _ => Precision::Year, + } + } + + /// The first day the value covers: (year, month, day). + pub fn first_day(&self) -> (i32, u8, u8) { + (self.year, self.month.unwrap_or(1), self.day.unwrap_or(1)) + } + + /// The last day the value covers. + pub fn last_day(&self) -> (i32, u8, u8) { + let month = self.month.unwrap_or(12); + (self.year, month, self.day.unwrap_or_else(|| days_in_month(self.year, month))) + } +} + +/// Days in `month` (1–12) of `year` in the Gregorian calendar; 0 for a month that doesn't exist. +pub fn days_in_month(year: i32, month: u8) -> u8 { + let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => 0, + } +} + +/// The days of a month laid out in weeks from Monday: `None` before the 1st and after the last +/// day, so the length is a multiple of 7. +pub fn month_grid(year: i32, month: u8) -> Vec> { + let first = format!("{year:04}-{month:02}-01"); + // 1970-01-01 was a Thursday: three days after a Monday + let lead = lightcraft_catalog::stacks::iso_seconds(&first).map_or(0, |s| (s.div_euclid(86_400) + 3).rem_euclid(7) as usize); + let mut cells: Vec> = std::iter::repeat_n(None, lead).chain((1..=days_in_month(year, month)).map(Some)).collect(); + while !cells.len().is_multiple_of(7) { + cells.push(None); + } + cells +} + +/// Whether any day of `date` lies between `min` and `max` (each optional, inclusive). +pub fn in_bounds(date: &PickedDate, min: Option<&PickedDate>, max: Option<&PickedDate>) -> bool { + min.is_none_or(|m| date.last_day() >= m.first_day()) && max.is_none_or(|m| date.first_day() <= m.last_day()) +} + +/// Weekday column headers, Monday first (messages for translation catalogs). +pub const WEEKDAY_SHORT: [&str; 7] = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]; + +/// Where the open popup is: what it picks and which page it shows. +#[derive(Clone, Copy, Debug)] +struct View { + precision: Precision, + year: i32, + month: u8, +} + +/// A calendar button that edits a [`PickedDate`]; see the module docs. +pub struct DatePicker<'a> { + id: String, + value: &'a mut Option, + precisions: &'a [Precision], + min: Option, + max: Option, + today: Option, +} + +impl<'a> DatePicker<'a> { + /// `id` keeps pickers apart (and names their widgets); `value` is what they edit (`None`: no + /// date yet, or one that couldn't be read). + pub fn new(id: impl Into, value: &'a mut Option) -> Self { + DatePicker { id: id.into(), value, precisions: &Precision::ALL, min: None, max: None, today: None } + } + + /// The precisions offered (all three by default), in the order shown. Without a value at one + /// of them, the picker opens on days when offered, else on the first. + pub fn precisions(mut self, precisions: &'a [Precision]) -> Self { + if !precisions.is_empty() { + self.precisions = precisions; + } + self + } + + /// The earliest date that can be picked. + pub fn min(mut self, min: Option) -> Self { + self.min = min; + self + } + + /// The latest date that can be picked. + pub fn max(mut self, max: Option) -> Self { + self.max = max; + self + } + + /// Where the calendar opens when there is no value, and the day it marks. + pub fn today(mut self, today: Option) -> Self { + self.today = today; + self + } + + /// The button and, while open, the popup. The response is `changed()` when a date was picked. + pub fn show(self, ui: &mut Ui) -> Response { + let DatePicker { id, value, precisions, min, max, today } = self; + let mut button = crate::widgets::icon_button(ui, &format!("datePicker:{id}"), Icon::Calendar, vec2(24.0, 22.0), false, true, "Pick a date"); + register(ui.ctx(), format!("datePicker:{id}"), button.rect); + let state = egui::Id::new(("datePicker", id.as_str())); + if button.clicked() { + // open on the value (or today), at its precision when it is one of those offered + let at = value.or(today).unwrap_or(PickedDate::year(2026)); + let fallback = if precisions.contains(&Precision::Day) { Precision::Day } else { precisions.first().copied().unwrap_or(Precision::Day) }; + let precision = value.map(|v| v.precision()).filter(|p| precisions.contains(p)).unwrap_or(fallback); + ui.data_mut(|d| d.insert_temp(state, View { precision, year: at.year, month: at.month.unwrap_or(1) })); + } + let mut picked: Option = None; + let mut view = ui.data(|d| d.get_temp::(state)).unwrap_or(View { precision: Precision::Day, year: 2026, month: 1 }); + egui::Popup::from_toggle_button_response(&button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + // as wide as a month of days, however much room the window has + ui.set_min_width(220.0); + ui.set_max_width(240.0); + if precisions.len() > 1 { + ui.horizontal(|ui| { + for p in precisions { + let r = ui.selectable_label(view.precision == *p, crate::i18n::tr(p.label())); + register(ui.ctx(), format!("datePickerPrecision:{}:{id}", p.id()), r.rect); + if r.clicked() { + view.precision = *p; + } + } + }); + ui.separator(); + } + ui.horizontal(|ui| { + let prev = ui.small_button("‹"); + register(ui.ctx(), format!("datePickerPrev:{id}"), prev.rect); + let title = match view.precision { + Precision::Day => crate::i18n::date_group_label(&format!("{:04}-{:02}", view.year, view.month), false), + Precision::Month => format!("{:04}", view.year), + Precision::Year => { + let first = view.year.div_euclid(12) * 12; + format!("{first:04}–{:04}", first + 11) + } + }; + ui.label(RichText::new(title).strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let next = ui.small_button("›"); + register(ui.ctx(), format!("datePickerNext:{id}"), next.rect); + let step = |v: &mut View, forward: bool| match v.precision { + Precision::Day => { + let m = i32::from(v.month) - 1 + if forward { 1 } else { -1 }; + v.year += m.div_euclid(12); + v.month = u8::try_from(m.rem_euclid(12) + 1).unwrap_or(1); + } + Precision::Month => v.year += if forward { 1 } else { -1 }, + Precision::Year => v.year += if forward { 12 } else { -12 }, + }; + if next.clicked() { + step(&mut view, true); + } + if prev.clicked() { + step(&mut view, false); + } + }); + }); + // four-digit years, as dates are written + view.year = view.year.clamp(0, 9999); + let cell = |ui: &mut Ui, date: PickedDate, text: String, picked: &mut Option| { + let allowed = in_bounds(&date, min.as_ref(), max.as_ref()); + let text = if today == Some(date) { RichText::new(text).strong().underline() } else { RichText::new(text) }; + let r = ui.add_enabled(allowed, egui::Button::selectable(*value == Some(date), text).min_size(vec2(26.0, 0.0))); + register(ui.ctx(), format!("datePickerCell:{}:{id}", date.iso()), r.rect); + if r.clicked() { + *picked = Some(date); + } + }; + egui::Grid::new(("datePickerGrid", id.as_str())).spacing(vec2(2.0, 2.0)).show(ui, |ui| match view.precision { + Precision::Day => { + for name in WEEKDAY_SHORT { + ui.label(RichText::new(crate::i18n::tr(name)).weak()); + } + ui.end_row(); + for week in month_grid(view.year, view.month).chunks(7) { + for d in week { + match d { + Some(d) => { + cell(ui, PickedDate { year: view.year, month: Some(view.month), day: Some(*d) }, d.to_string(), &mut picked) + } + None => { + ui.label(""); + } + } + } + ui.end_row(); + } + } + Precision::Month => { + for row in 0..3u8 { + for col in 1..=4u8 { + let m = row * 4 + col; + let date = PickedDate { year: view.year, month: Some(m), day: None }; + cell(ui, date, crate::i18n::date_group_label(&date.iso(), true), &mut picked); + } + ui.end_row(); + } + } + Precision::Year => { + let first = view.year.div_euclid(12) * 12; + for row in 0..3 { + for col in 0..4 { + let y = first + row * 4 + col; + if (0..=9999).contains(&y) { + cell(ui, PickedDate::year(y), format!("{y:04}"), &mut picked); + } + } + ui.end_row(); + } + } + }); + if picked.is_some() { + ui.close(); + } + }); + ui.data_mut(|d| d.insert_temp(state, view)); + if let Some(date) = picked { + *value = Some(date); + button.mark_changed(); + } + button + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn partial_dates_read_and_write_iso() { + let d = |s: &str| PickedDate::parse(s); + assert_eq!(d("2026"), Some(PickedDate { year: 2026, month: None, day: None })); + assert_eq!(d("2026-08"), Some(PickedDate { year: 2026, month: Some(8), day: None })); + assert_eq!(d("2026-08-14"), Some(PickedDate { year: 2026, month: Some(8), day: Some(14) })); + assert_eq!(d("2026-08-14T10:00").map(|p| p.iso()), Some("2026-08-14".to_string()), "a time is read past"); + for bad in ["", "26", "2026-13", "2026-02-30", "banana", "2026/08"] { + assert_eq!(d(bad), None, "{bad}"); + } + for s in ["2026", "2026-08", "2026-08-14", "0001-01-01", "9999-12-31"] { + assert_eq!(d(s).map(|p| p.iso()).as_deref(), Some(s)); + } + assert_eq!(d("2026-08").map(|p| p.precision()), Some(Precision::Month)); + // the first and last day a value covers, for bounds + assert_eq!(d("2026-02").map(|p| (p.first_day(), p.last_day())), Some(((2026, 2, 1), (2026, 2, 28)))); + assert_eq!(d("2028").map(|p| (p.first_day(), p.last_day())), Some(((2028, 1, 1), (2028, 12, 31)))); + } + + #[test] + fn month_lengths_and_grids() { + assert_eq!((days_in_month(2026, 2), days_in_month(2028, 2), days_in_month(1900, 2), days_in_month(2000, 2)), (28, 29, 28, 29)); + assert_eq!((days_in_month(2026, 4), days_in_month(2026, 12), days_in_month(2026, 13)), (30, 31, 0)); + // August 2026 starts on a Saturday: five blanks in a Monday-first week, then 1..=31 + let g = month_grid(2026, 8); + assert_eq!(g.len() % 7, 0); + assert_eq!(&g[..6], &[None, None, None, None, None, Some(1)]); + assert_eq!(g.iter().flatten().count(), 31); + assert_eq!(month_grid(2026, 6).first(), Some(&Some(1)), "June 2026 starts on a Monday"); + } + + #[test] + fn bounds_keep_whole_values_in_range() { + let d = |s: &str| PickedDate::parse(s).unwrap(); + let (min, max) = (Some(d("2026-03")), Some(d("2026-06-15"))); + let ok = |s: &str| in_bounds(&d(s), min.as_ref(), max.as_ref()); + assert!(ok("2026-03-01") && ok("2026-04") && ok("2026-06-15")); + assert!(!ok("2026-02-28") && !ok("2026-06-16") && !ok("2026-07")); + assert!(ok("2026-06"), "a month that reaches the bound counts"); + assert!(ok("2026") && !ok("2027") && !ok("2025")); + assert!(in_bounds(&d("1999"), None, None)); + } +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index b1332170..44502856 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -774,6 +774,17 @@ mod tests { } } + /// The date picker's own words (its tooltip, the weekday headers) are in every language, as the + /// month and day labels it borrows from the date headings are. + #[test] + fn date_picker_words_are_translated() { + for language in Locale::ALL.iter().filter(|l| **l != Locale::En) { + for word in crate::date_picker::WEEKDAY_SHORT.iter().chain(&["Pick a date"]) { + assert!(language.catalog().contains_key(*word), "{} lacks {word:?}", language.code()); + } + } + } + /// Every command, control, rule and rename label, and every line of What's New, is a message a /// 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 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index acbbba02..a74b7a0a 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -55,6 +55,7 @@ pub enum Icon { Pencil, Photos, Clock, + Calendar, Trash, Curve, ProfileGrid, @@ -359,6 +360,14 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.circle(10.0, 10.0, 7.0); pen.line(&[(10.0, 5.5), (10.0, 10.0), (13.5, 11.5)]); } + Calendar => { + // a page with a binding bar, two rings and a marked day + pen.rect(3.0, 4.5, 17.0, 17.0, 1.5); + pen.line(&[(3.0, 8.5), (17.0, 8.5)]); + pen.line(&[(7.0, 2.5), (7.0, 6.0)]); + pen.line(&[(13.0, 2.5), (13.0, 6.0)]); + pen.rect(11.0, 11.5, 14.0, 14.5, 0.5); + } Trash => { pen.line(&[(3.0, 5.5), (17.0, 5.5)]); pen.line(&[(8.0, 5.5), (8.0, 3.0), (12.0, 3.0), (12.0, 5.5)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7684f00d..c7ea2474 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ pub mod control; pub mod credits; +pub mod date_picker; pub mod export_task; pub mod headless; pub mod i18n; From 090f761d7146f9cb7106a4bc306a28ec557c2be5 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 09:38:11 -0400 Subject: [PATCH 42/65] LRC-LIB-SMARTCOLL: date rules get a calendar picker Each date field in the smart-album rule editor has a DatePicker beside its text field. It opens on the rule's date at its precision (or on today, from the session's clock, passed in rules_editor::Env), and writes what is picked as 2026, 2026-08 or 2026-08-14. Typing still works, and opening never changes a rule. In a "between", each date's picker stops at the other, so the two stay in order. Headless tests pick a day, a month and a year, move between months and year pages, keep a "between" in order from both ends, and open an unreadable date on today without rewriting it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/dialogs.rs | 1 + crates/ui-egui/src/panels/rules_editor.rs | 24 +++++- crates/ui-egui/src/tests_date_picker.rs | 90 +++++++++++++++++++++++ 4 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/tests_date_picker.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c7ea2474..8f4e0bb2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,8 @@ pub mod widgets; #[cfg(test)] mod tests_curve; #[cfg(test)] +mod tests_date_picker; +#[cfg(test)] mod tests_filmstrip; #[cfg(test)] mod tests_grid; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 954cd9f5..d540baa9 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -245,6 +245,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // checked before drawing, so each row marks its own problem let env = crate::panels::rules_editor::Env { problems: rules.check(&app.session.catalog), + today: (app.session.clock)(), albums: { let cat = &app.session.catalog; let mut albums: Vec<(u64, String)> = cat diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index cae91ac2..a29487ba 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -6,6 +6,7 @@ use lightcraft_catalog::rules::{FIELD_GROUPS, Kind, Problem, TOP_LEVEL_FIELDS, b use lightcraft_catalog::{Match, Rule, RuleSet}; use serde_json::{Value, json}; +use crate::date_picker::{DatePicker, PickedDate}; use crate::theme::Tokens; use crate::widgets::register; @@ -89,11 +90,25 @@ fn number_value(ui: &mut egui::Ui, v: &mut Value, field: &str) { } } +/// A rule date's calendar ([`DatePicker`]): opens on the date as written (or today), keeps its +/// precision, and writes what is picked as `2026`, `2026-08` or `2026-08-14`. +fn date_picker(ui: &mut egui::Ui, v: &mut Value, salt: &str, min: Option, max: Option, env: &Env) { + let mut date = v.as_str().and_then(PickedDate::parse); + let today = PickedDate::parse(&env.today); + if DatePicker::new(salt, &mut date).min(min).max(max).today(today).show(ui).changed() + && let Some(date) = date + { + *v = json!(date.iso()); + } +} + /// What the editor shows besides the rules: the problems `RuleSet::check` found in them (each /// row marks its own) and the albums an Album rule can test. #[derive(Default)] pub struct Env { pub problems: Vec, + /// Today (`YYYY-MM-DD…`, the session's clock): where a date picker opens when a rule has no date. + pub today: String, /// The albums an Album rule can test: (id, name shown, with its folder when it is in one), /// sorted by that name. pub albums: Vec<(u64, String)>, @@ -169,9 +184,13 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & ui.label(crate::i18n::tr("and")); number_value(ui, hi, field); } else { + // each date's picker stops at the other one, so the two stay in order + let (lo_date, hi_date) = (lo.as_str().and_then(PickedDate::parse), hi.as_str().and_then(PickedDate::parse)); text_value(ui, lo, 86.0, "2026-01-01", &format!("{salt}-a")); + date_picker(ui, lo, &format!("{salt}-a"), None, hi_date, env); ui.label(crate::i18n::tr("and")); text_value(ui, hi, 86.0, "2026-12", &format!("{salt}-b")); + date_picker(ui, hi, &format!("{salt}-b"), lo_date, None, env); } if pair != before { *v = pair; @@ -206,7 +225,10 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & } }); } - (Some(Kind::Date), _) => text_value(ui, v, 120.0, "2026-04 or 2026-04-12", salt), + (Some(Kind::Date), _) => { + text_value(ui, v, 120.0, "2026-04 or 2026-04-12", salt); + date_picker(ui, v, salt, None, None, env); + } _ => text_value(ui, v, 140.0, "", salt), } } diff --git a/crates/ui-egui/src/tests_date_picker.rs b/crates/ui-egui/src/tests_date_picker.rs new file mode 100644 index 00000000..63565b22 --- /dev/null +++ b/crates/ui-egui/src/tests_date_picker.rs @@ -0,0 +1,90 @@ +//! The date picker in the smart-album rule editor: picking days, months and years, moving between +//! months, and the bounds a "between" puts on each of its two dates. + +use std::time::Duration; + +use lightcraft_catalog::Rule; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn editor(rules: serde_json::Value) -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let r = h.request("engine.execute", json!({"command": "dialog.smartAlbum", "params": {"name": "Dates"}}), T); + assert_eq!(r["ok"], true, "{r}"); + let Some(crate::state::Dialog::SmartRules { rules: r, .. }) = &mut h.app.ui.dialog else { panic!("no rule editor") }; + *r = serde_json::from_value(json!({"rules": rules})).unwrap(); + h.settle(SETTLE); + h +} + +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(SETTLE); +} + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn value(h: &Headless, i: usize) -> serde_json::Value { + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") }; + let Some(Rule::Field { value, .. }) = rules.rules.get(i) else { panic!("no rule {i}") }; + value.clone() +} + +/// A day, then a month, then a year: each writes the date at that precision and closes the picker. +#[test] +fn pick_a_day_a_month_and_a_year() { + let mut h = editor(json!([{"field": "captureDate", "op": "is", "value": "2026-01-20"}])); + click(&mut h, "datePicker:rules-0"); + assert!(has(&h, "datePickerCell:2026-01-20:rules-0"), "opens on the rule's month"); + click(&mut h, "datePickerNext:rules-0"); + click(&mut h, "datePickerCell:2026-02-14:rules-0"); + assert_eq!(value(&h, 0), json!("2026-02-14")); + assert!(!has(&h, "datePickerCell:2026-02-14:rules-0"), "picking closes it"); + click(&mut h, "datePicker:rules-0"); + click(&mut h, "datePickerPrecision:month:rules-0"); + click(&mut h, "datePickerCell:2026-08:rules-0"); + assert_eq!(value(&h, 0), json!("2026-08")); + click(&mut h, "datePicker:rules-0"); + assert!(has(&h, "datePickerCell:2026-08:rules-0"), "a month opens at month precision"); + click(&mut h, "datePickerPrecision:year:rules-0"); + click(&mut h, "datePickerPrev:rules-0"); + click(&mut h, "datePickerCell:2015:rules-0"); + assert_eq!(value(&h, 0), json!("2015")); +} + +/// Each date of a "between" can't pass the other: the earlier one can't be picked after the later. +#[test] +fn between_dates_stay_in_order() { + let mut h = editor(json!([{"field": "captureDate", "op": "between", "value": ["2026-03", "2026-06"]}])); + click(&mut h, "datePicker:rules-0-a"); + click(&mut h, "datePickerCell:2026-09:rules-0-a"); + assert_eq!(value(&h, 0), json!(["2026-03", "2026-06"]), "after the later date: not taken"); + click(&mut h, "datePickerCell:2026-05:rules-0-a"); + assert_eq!(value(&h, 0), json!(["2026-05", "2026-06"])); + click(&mut h, "datePicker:rules-0-b"); + click(&mut h, "datePickerCell:2026-04:rules-0-b"); + assert_eq!(value(&h, 0), json!(["2026-05", "2026-06"]), "before the earlier date: not taken"); + click(&mut h, "datePickerCell:2026-12:rules-0-b"); + assert_eq!(value(&h, 0), json!(["2026-05", "2026-12"])); +} + +/// A rule without a readable date opens on today's month (the session's clock), and opening it +/// leaves the rule as written. +#[test] +fn an_unreadable_date_opens_on_this_month() { + let mut h = editor(json!([{"field": "captureDate", "op": "after", "value": "banana"}])); + h.app.session.clock = Box::new(|| "2026-10-09T12:00:00".to_string()); + h.settle(SETTLE); + click(&mut h, "datePicker:rules-0"); + assert!(has(&h, "datePickerCell:2026-10-09:rules-0")); + assert_eq!(value(&h, 0), json!("banana"), "opening doesn't change the rule"); +} From 04ffa7d6b734935de9d445f2701585b607b77927 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 09:38:11 -0400 Subject: [PATCH 43/65] LRC-LIB-SMARTCOLL: document the date picker and the single date reading Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 2 +- docs/whats-new.md | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/parity.md b/docs/parity.md index 139dc09f..a59a3a06 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`; no 30 February; a time zone or fractions of a second are refused, since capture times are local and to the second; `rules::rule_date` is the one reading for the check and the matcher), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album from a list of the plain albums, named with their folder and sorted by name (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`; no 30 February; a time zone or fractions of a second are refused, since capture times are local and to the second; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album from a list of the plain albums, named with their folder and sorted by name (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | diff --git a/docs/whats-new.md b/docs/whats-new.md index e4d12673..0db41edb 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -17,6 +17,9 @@ way round, "in the last 0 days", a colour label that doesn't exist, "contains" with nothing to look for, an album that is gone or smart, or an empty group is reported with its rule ("rule 2.1: rating is 0–5, not 9") instead of quietly matching nothing or everything. New date rules start at the current year. +- Date rules have a calendar button: pick a day, a month or a whole year (Year / Month / Day), in your language; the + two dates of a "between" can't be picked out of order. Typing still works, and "2026-10-01 10:00" (a space for the + T) now matches; a time zone is refused, since capture times are local. - Rule problems are shown in your language and name the field the way the menu does ("Title: needs something to look for"). - A smart album whose rules no longer check (an album they test was deleted) is marked ⚠ in the sidebar; its tooltip From 8d4e539d02527a6baa6c88790ab01a562010fd18 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 09:48:59 -0400 Subject: [PATCH 44/65] LRC-LIB-SMARTCOLL: a date with a zone straight after the day is DateWithZone From review: "2026-08-14Z" (a zone after a date with no time) was reported as "needs a date" rather than as the time-zone issue it is. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/rules.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 64029fd8..a167cce2 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -345,7 +345,7 @@ pub fn rule_date(s: &str) -> Result { }; if !shaped(&s) { // a whole date and time with something after it: a zone (+02:00, -05:00, Z) or a fraction - let zoned = [19, 16, 13].iter().any(|&n| { + let zoned = [19, 16, 13, 10].iter().any(|&n| { s.get(..n).is_some_and(shaped) && s.get(n..).and_then(|rest| rest.chars().next()).is_some_and(|c| matches!(c, '+' | '-' | 'Z' | 'z' | '.')) }); @@ -1478,7 +1478,7 @@ mod tests { let r = rule(op, v.clone()); assert!(r.check(&cat).is_empty() && r.matches(&p, &cat), "{op} {v}"); } - for v in ["2026-08-14T10:00:00+02:00", "2026-08-14T10:00:00Z", "2026-08-14T10:00:00.25", "2026-08-14 10:00-05:00"] { + for v in ["2026-08-14T10:00:00+02:00", "2026-08-14T10:00:00Z", "2026-08-14T10:00:00.25", "2026-08-14 10:00-05:00", "2026-08-14Z"] { let p = rule("is", json!(v)).check(&cat); assert_eq!(p.first().map(|p| p.issue), Some(Issue::DateWithZone), "{v}"); } From 034ef489ed488f4c624f966627871f05782f7eea Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 09:48:59 -0400 Subject: [PATCH 45/65] LRC-LIB-SMARTCOLL: date picker review fixes: Esc, paging limits, strict parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review: - Esc with the calendar open also closed the rule editor and lost its edits. The shortcut layer runs before the UI, and Esc's command (Back) closes any dialog. Shortcuts now leave Esc to an open popup (any menu or calendar). The picker marks Esc as handled for the frame (widgets::take_escape / escape_taken; the mark lasts the whole frame, since egui may run more than one pass), and the dialog's own Esc check defers to it. New headless test: Esc closes only the calendar. - Paging wrapped at the ends (‹ on January 0000 showed December 0000). step() is now a pure function that stops at years 0000 / 9999; year pages are aligned blocks of twelve, and the last one holds 9996–9999. View::open clamps a caller's year and month, so a huge `today` can't overflow. - PickedDate::parse reads a time after the day only if it is one (T10, T10:00, T10:00:00), as the rule check does; "2026-08-14Tjunk" is no longer shown as a valid day. - Today is marked in the month and year views too (holds_today). - Documented: ids must be unique among the pickers on screen; the picker picks dates, not times (picking a day replaces a typed time). The i18n test also covers Year / Month / Day. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/date_picker.rs | 123 ++++++++++++++++++---- crates/ui-egui/src/i18n.rs | 2 +- crates/ui-egui/src/panels/dialogs.rs | 5 +- crates/ui-egui/src/panels/rules_editor.rs | 3 +- crates/ui-egui/src/shortcuts.rs | 5 + crates/ui-egui/src/tests_date_picker.rs | 13 +++ crates/ui-egui/src/widgets.rs | 13 +++ docs/whats-new.md | 3 +- 8 files changed, 139 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/date_picker.rs b/crates/ui-egui/src/date_picker.rs index ef801ac0..c04c309a 100644 --- a/crates/ui-egui/src/date_picker.rs +++ b/crates/ui-egui/src/date_picker.rs @@ -71,12 +71,16 @@ impl PickedDate { return Some(PickedDate { year, month: Some(month), day: None }); } let day = u8::try_from(num(8, 10)?).ok().filter(|d| (1..=days_in_month(year, month)).contains(d))?; - let time = s.get(10..11); - (s.get(7..8) == Some("-") && (s.len() == 10 || matches!(time, Some("T" | " ")))).then_some(PickedDate { - year, - month: Some(month), - day: Some(day), - }) + // a time after the day (read past) must be one: T10, T10:00 or T10:00:00 (or a space for the T) + let time_ok = match s.len() { + 10 => true, + 13 | 16 | 19 => { + matches!(s.get(10..11), Some("T" | " ")) + && s.char_indices().skip(11).all(|(i, c)| if i == 13 || i == 16 { c == ':' } else { c.is_ascii_digit() }) + } + _ => false, + }; + (s.get(7..8) == Some("-") && time_ok).then_some(PickedDate { year, month: Some(month), day: Some(day) }) } /// `2026`, `2026-08` or `2026-08-14`. @@ -149,6 +153,42 @@ struct View { month: u8, } +impl View { + /// The view opened on `at`: years kept to 0000–9999 and months to 1–12, whatever a caller's + /// value holds. + fn open(at: PickedDate, precision: Precision) -> View { + View { precision, year: at.year.clamp(0, 9999), month: at.month.unwrap_or(1).clamp(1, 12) } + } +} + +/// The page before (`forward` false) or after `v`: a month, a year or twelve years. Paging stops at +/// years 0000 and 9999 rather than wrapping or leaving four-digit years. +fn step(v: View, forward: bool) -> View { + let (year, month) = match v.precision { + Precision::Day => { + let m = i32::from(v.month) - 1 + if forward { 1 } else { -1 }; + (v.year.saturating_add(m.div_euclid(12)), u8::try_from(m.rem_euclid(12) + 1).unwrap_or(1)) + } + Precision::Month => (v.year.saturating_add(if forward { 1 } else { -1 }), v.month), + Precision::Year => { + // pages of twelve years (2016–2027); the first and last pages hold the ends + let page = v.year.div_euclid(12).saturating_add(if forward { 1 } else { -1 }); + (page.saturating_mul(12), v.month) + } + }; + let years = 0..=9999; + let fits = match v.precision { + Precision::Year => years.contains(&year) || years.contains(&year.saturating_add(11)), + _ => years.contains(&year), + }; + if fits { View { year, month, ..v } } else { v } +} + +/// Whether picking `cell` would cover today: its day, its month or its year. +fn holds_today(cell: &PickedDate, today: Option) -> bool { + today.is_some_and(|t| t.first_day() >= cell.first_day() && t.first_day() <= cell.last_day()) +} + /// A calendar button that edits a [`PickedDate`]; see the module docs. pub struct DatePicker<'a> { id: String, @@ -160,8 +200,9 @@ pub struct DatePicker<'a> { } impl<'a> DatePicker<'a> { - /// `id` keeps pickers apart (and names their widgets); `value` is what they edit (`None`: no - /// date yet, or one that couldn't be read). + /// `id` keeps pickers apart and names their widgets, so it must be unique among the pickers + /// on screen (two with the same id share their open page); `value` is what they edit (`None`: + /// no date yet, or one that couldn't be read). pub fn new(id: impl Into, value: &'a mut Option) -> Self { DatePicker { id: id.into(), value, precisions: &Precision::ALL, min: None, max: None, today: None } } @@ -204,7 +245,7 @@ impl<'a> DatePicker<'a> { let at = value.or(today).unwrap_or(PickedDate::year(2026)); let fallback = if precisions.contains(&Precision::Day) { Precision::Day } else { precisions.first().copied().unwrap_or(Precision::Day) }; let precision = value.map(|v| v.precision()).filter(|p| precisions.contains(p)).unwrap_or(fallback); - ui.data_mut(|d| d.insert_temp(state, View { precision, year: at.year, month: at.month.unwrap_or(1) })); + ui.data_mut(|d| d.insert_temp(state, View::open(at, precision))); } let mut picked: Option = None; let mut view = ui.data(|d| d.get_temp::(state)).unwrap_or(View { precision: Precision::Day, year: 2026, month: 1 }); @@ -239,28 +280,17 @@ impl<'a> DatePicker<'a> { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let next = ui.small_button("›"); register(ui.ctx(), format!("datePickerNext:{id}"), next.rect); - let step = |v: &mut View, forward: bool| match v.precision { - Precision::Day => { - let m = i32::from(v.month) - 1 + if forward { 1 } else { -1 }; - v.year += m.div_euclid(12); - v.month = u8::try_from(m.rem_euclid(12) + 1).unwrap_or(1); - } - Precision::Month => v.year += if forward { 1 } else { -1 }, - Precision::Year => v.year += if forward { 12 } else { -12 }, - }; if next.clicked() { - step(&mut view, true); + view = step(view, true); } if prev.clicked() { - step(&mut view, false); + view = step(view, false); } }); }); - // four-digit years, as dates are written - view.year = view.year.clamp(0, 9999); let cell = |ui: &mut Ui, date: PickedDate, text: String, picked: &mut Option| { let allowed = in_bounds(&date, min.as_ref(), max.as_ref()); - let text = if today == Some(date) { RichText::new(text).strong().underline() } else { RichText::new(text) }; + let text = if holds_today(&date, today) { RichText::new(text).strong().underline() } else { RichText::new(text) }; let r = ui.add_enabled(allowed, egui::Button::selectable(*value == Some(date), text).min_size(vec2(26.0, 0.0))); register(ui.ctx(), format!("datePickerCell:{}:{id}", date.iso()), r.rect); if r.clicked() { @@ -310,6 +340,11 @@ impl<'a> DatePicker<'a> { } } }); + // Esc closes the calendar and stops there: the dialog around it keeps its edits + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + crate::widgets::take_escape(ui.ctx()); + ui.close(); + } if picked.is_some() { ui.close(); } @@ -358,6 +393,48 @@ mod tests { assert_eq!(month_grid(2026, 6).first(), Some(&Some(1)), "June 2026 starts on a Monday"); } + /// Paging stops at years 0000 and 9999 instead of wrapping (‹ on January 0000 stays there), + /// and a view opened on a year beyond them starts at the nearest end. + #[test] + fn paging_stops_at_the_ends() { + let v = |precision, year, month| View { precision, year, month }; + let at = |v: View| (v.year, v.month); + assert_eq!(at(step(v(Precision::Day, 2026, 12), true)), (2027, 1)); + assert_eq!(at(step(v(Precision::Day, 2026, 1), false)), (2025, 12)); + assert_eq!(at(step(v(Precision::Day, 0, 1), false)), (0, 1)); + assert_eq!(at(step(v(Precision::Day, 9999, 12), true)), (9999, 12)); + assert_eq!(at(step(v(Precision::Month, 0, 5), false)), (0, 5)); + assert_eq!(at(step(v(Precision::Month, 9999, 5), true)), (9999, 5)); + assert_eq!(at(step(v(Precision::Year, 5, 1), false)), (5, 1), "the first page of years"); + assert_eq!(at(step(v(Precision::Year, 9990, 1), true)), (9996, 1), "the page holding 9996–9999"); + assert_eq!(at(step(v(Precision::Year, 9996, 1), true)), (9996, 1), "the last page"); + assert_eq!(at(step(v(Precision::Year, 2026, 1), false)), (2004, 1), "the page 2004–2015"); + let huge = PickedDate { year: i32::MAX, month: Some(13), day: None }; + assert_eq!(at(View::open(huge, Precision::Day)), (9999, 12)); + assert_eq!(at(View::open(PickedDate { year: i32::MIN, month: Some(0), day: None }, Precision::Day)), (0, 1)); + } + + /// The picker reads a date the way the rule check does: a time after it must be a time. + #[test] + fn a_trailing_time_must_be_a_time() { + for ok in ["2026-08-14T10", "2026-08-14T10:00", "2026-08-14 10:00:00"] { + assert!(PickedDate::parse(ok).is_some(), "{ok}"); + } + for bad in ["2026-08-14Tjunk", "2026-08-14T", "2026-08-14T1", "2026-08-14T10:0", "2026-08-14T10:00Z"] { + assert_eq!(PickedDate::parse(bad), None, "{bad}"); + } + } + + /// Today is marked in every view: its day, its month, its year. + #[test] + fn today_is_marked_at_every_precision() { + let d = |s: &str| PickedDate::parse(s).unwrap(); + let today = Some(d("2026-10-09")); + assert!(holds_today(&d("2026-10-09"), today) && holds_today(&d("2026-10"), today) && holds_today(&d("2026"), today)); + assert!(!holds_today(&d("2026-10-08"), today) && !holds_today(&d("2026-09"), today) && !holds_today(&d("2025"), today)); + assert!(!holds_today(&d("2026"), None)); + } + #[test] fn bounds_keep_whole_values_in_range() { let d = |s: &str| PickedDate::parse(s).unwrap(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 44502856..7ba6a47e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -779,7 +779,7 @@ mod tests { #[test] fn date_picker_words_are_translated() { for language in Locale::ALL.iter().filter(|l| **l != Locale::En) { - for word in crate::date_picker::WEEKDAY_SHORT.iter().chain(&["Pick a date"]) { + for word in crate::date_picker::WEEKDAY_SHORT.iter().chain(&["Pick a date", "Year", "Month", "Day"]) { assert!(language.catalog().contains_key(*word), "{} lacks {word:?}", language.code()); } } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d540baa9..91d1af6e 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -877,8 +877,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ctx.move_to_top(w.response.layer_id); crate::widgets::register(ctx, "dialog:window", w.response.rect); } - // (while the shortcuts editor records a key, it takes Esc itself to cancel) - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + // (while the shortcuts editor records a key, it takes Esc itself to cancel; an open popup in + // the dialog, such as a date picker's calendar, takes it to close itself) + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !crate::widgets::escape_taken(ctx) { close = true; } if confirm { diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index a29487ba..7d7b2c4b 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -91,7 +91,8 @@ fn number_value(ui: &mut egui::Ui, v: &mut Value, field: &str) { } /// A rule date's calendar ([`DatePicker`]): opens on the date as written (or today), keeps its -/// precision, and writes what is picked as `2026`, `2026-08` or `2026-08-14`. +/// precision, and writes what is picked as `2026`, `2026-08` or `2026-08-14`. It picks dates, not +/// times: picking a day for "2026-08-14T10:00" leaves "2026-08-14" (a time is typed). fn date_picker(ui: &mut egui::Ui, v: &mut Value, salt: &str, min: Option, max: Option, env: &Env) { let mut date = v.as_str().and_then(PickedDate::parse); let today = PickedDate::parse(&env.today); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index cd69b2da..0ebb0808 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -296,11 +296,15 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { let keymap = &app.ui.settings.keymap; // keys the user gave to a command: the fixed bindings below (aliases, ratings) yield to them let taken: Vec<(Modifiers, Key)> = keymap.values().filter_map(|s| parse(s)).collect(); + // an open popup (a menu, a date picker's calendar) closes on Esc itself: Esc's command (Back, + // which also closes dialogs) waits until nothing is open + let popup_open = egui::Popup::is_any_open(ctx); ctx.input(|i| { for b in bindable() { if let Some(sc) = binding(keymap, b.id, b.default) && let Some((m, k)) = parse(sc) && !native(sc) + && !(k == Key::Escape && popup_open) && matches(i, m, k) { fire.push(b.id.to_string()); @@ -308,6 +312,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } for (sc, id, params) in ALIASES { if let Some((m, k)) = parse(sc).filter(|_| !native(sc)) + && !(k == Key::Escape && popup_open) && !taken.contains(&(m, k)) && matches(i, m, k) { diff --git a/crates/ui-egui/src/tests_date_picker.rs b/crates/ui-egui/src/tests_date_picker.rs index 63565b22..ba5e2811 100644 --- a/crates/ui-egui/src/tests_date_picker.rs +++ b/crates/ui-egui/src/tests_date_picker.rs @@ -88,3 +88,16 @@ fn an_unreadable_date_opens_on_this_month() { assert!(has(&h, "datePickerCell:2026-10-09:rules-0")); assert_eq!(value(&h, 0), json!("banana"), "opening doesn't change the rule"); } + +/// Esc closes the calendar only, not the rule editor around it (and its unsaved edits). +#[test] +fn escape_closes_only_the_calendar() { + let mut h = editor(json!([{"field": "captureDate", "op": "is", "value": "2026-01-20"}])); + click(&mut h, "datePicker:rules-0"); + assert!(has(&h, "datePickerCell:2026-01-20:rules-0")); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(!has(&h, "datePickerCell:2026-01-20:rules-0"), "the calendar closed"); + assert!(h.app.ui.dialog.is_some(), "the editor stays open"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c98baf41..b221f725 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -19,6 +19,19 @@ pub fn register(ctx: &egui::Context, id: impl Into, rect: Rect) { ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::new("lc-registry")).0.push((id, rect))); } +/// Esc was handled this frame by something nested (an open popup closed on it): containers such +/// as dialogs shouldn't close too. Kept for the whole frame, since egui may run it in more than one +/// pass and the popup is already closed in the later ones. +pub fn take_escape(ctx: &egui::Context) { + let frame = ctx.cumulative_frame_nr(); + ctx.data_mut(|d| d.insert_temp(egui::Id::new("lc-escape-taken"), frame)); +} + +/// Whether [`take_escape`] was called this frame. +pub fn escape_taken(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(egui::Id::new("lc-escape-taken"))) == Some(ctx.cumulative_frame_nr()) +} + pub fn take_registry(ctx: &egui::Context) -> Vec<(String, Rect)> { ctx.data_mut(|d| std::mem::take(&mut d.get_temp_mut_or_default::(egui::Id::new("lc-registry")).0)) } diff --git a/docs/whats-new.md b/docs/whats-new.md index 0db41edb..2f5799b9 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -18,7 +18,8 @@ album that is gone or smart, or an empty group is reported with its rule ("rule 2.1: rating is 0–5, not 9") instead of quietly matching nothing or everything. New date rules start at the current year. - Date rules have a calendar button: pick a day, a month or a whole year (Year / Month / Day), in your language; the - two dates of a "between" can't be picked out of order. Typing still works, and "2026-10-01 10:00" (a space for the + two dates of a "between" can't be picked out of order; Esc closes just the calendar. It picks dates, not times (a + time is typed, and picking a day replaces one). Typing still works, and "2026-10-01 10:00" (a space for the T) now matches; a time zone is refused, since capture times are local. - Rule problems are shown in your language and name the field the way the menu does ("Title: needs something to look for"). From 4e3b15ff6f178a58fc4ff18a263df9050d8bc60f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 09:59:23 -0400 Subject: [PATCH 46/65] LRC-LIB-SMARTCOLL: smart albums can test other smart albums (loops refused and survived) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Album rule may now name a smart album: "Keywords contain travel" and "Album isn't Excluded Photos" (red or rejected) leaves out whatever Excluded Photos matches, as its rules change. Smart albums used to be refused for this. Loops: - RuleSet::check_for(cat, owner) refuses an Album rule that would make the owner include itself, directly or through other smart albums (Issue::AlbumLoop). Catalog::album_reaches walks the references with a seen-set; RuleSet::albums_tested lists them. album.setRules checks for the album being edited, and smart_album_problems reports a loop saved anyway. - Matching never recurses without end: album_contains evaluates a smart album's rules through rules::evaluating, a thread-local stack of albums being evaluated with a 32-deep cap. An album already on it (a saved loop) holds nothing, and the stack is restored by a drop guard however evaluation ends. A test builds A → B → C → A and checks that every album returns and reports the loop. The issue "can't test a smart album" is replaced by the loop issue in all seven locales. The sidebar counts already refresh while any smart album uses "in the last…", so an album testing one is covered. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/lib.rs | 31 +++++++++- crates/catalog/src/rules.rs | 90 ++++++++++++++++++++++++----- crates/catalog/src/tests.rs | 73 +++++++++++++++++++++++ crates/engine/src/cmd/library.rs | 18 ++++-- crates/engine/src/tests_library.rs | 46 +++++++++++++++ crates/ui-egui/locales/de.json | 4 +- crates/ui-egui/locales/es.json | 4 +- crates/ui-egui/locales/ja.json | 4 +- crates/ui-egui/locales/pt-br.json | 4 +- crates/ui-egui/locales/ru.json | 4 +- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/locales/zh-hant.json | 4 +- 12 files changed, 249 insertions(+), 37 deletions(-) diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index c47763b1..fd15415c 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -324,19 +324,46 @@ impl Catalog { /// no smart album). pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool { match self.albums.get(&id) { - Some(Album { smart: Some(rules), .. }) => !p.deleted && rules.matches(p, self), + // guarded: a smart album testing smart albums can't loop or recurse without end + Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::evaluating(id, || rules.matches(p, self)).unwrap_or(false), Some(a) => a.photos.contains(&p.id), None => false, } } + /// Whether smart album `from` tests `to`, directly or through the smart albums it tests (the + /// rules of `from` would then change when those of `to` do). Loops in saved rules end the + /// search, they don't repeat it. + pub fn album_reaches(&self, from: AlbumId, to: AlbumId) -> bool { + let mut seen: Vec = Vec::new(); + let mut next = vec![from]; + while let Some(a) = next.pop() { + if seen.contains(&a) { + continue; + } + seen.push(a); + let tested = self + .albums + .get(&a) + .and_then(|al| al.smart.as_ref()) + .and_then(|f| f.rule_set.as_ref()) + .map(rules::RuleSet::albums_tested) + .unwrap_or_default(); + if tested.contains(&to) { + return true; + } + next.extend(tested); + } + false + } + /// What is wrong with a saved smart album's rules now (`RuleSet::check`, after /// `RuleSet::upgrade`): typically a rule testing an album that has since been deleted. Empty /// for a sound smart album, a plain album or no album. pub fn smart_album_problems(&self, id: AlbumId) -> Vec { let Some(mut rules) = self.albums.get(&id).and_then(|a| a.smart.as_ref()).and_then(|f| f.rule_set.clone()) else { return Vec::new() }; rules.upgrade(); - rules.check(self) + rules.check_for(self, Some(id)) } /// The photos of an album: the stored list, or a smart album's current matches (id order). diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index a167cce2..4f34fd5b 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -193,7 +193,7 @@ pub enum Issue { NoRatingMatches, ChooseAlbum, NoSuchAlbum, - SmartAlbum, + AlbumLoop, FolderAlbum, EmptyGroup, } @@ -220,7 +220,7 @@ impl Issue { Issue::NoRatingMatches, Issue::ChooseAlbum, Issue::NoSuchAlbum, - Issue::SmartAlbum, + Issue::AlbumLoop, Issue::FolderAlbum, Issue::EmptyGroup, ]; @@ -248,7 +248,7 @@ impl Issue { Issue::NoRatingMatches => "can't match any rating from 0 to 5", Issue::ChooseAlbum => "needs an album", Issue::NoSuchAlbum => "names an album that no longer exists", - Issue::SmartAlbum => "can't test a smart album; choose a plain album", + Issue::AlbumLoop => "would make this album include itself through other smart albums", Issue::FolderAlbum => "can't test a folder; choose an album in it", Issue::EmptyGroup => "This group is empty: add a rule or remove it.", } @@ -259,7 +259,7 @@ impl Issue { type Found = Option<(Issue, String)>; /// What is wrong with one rule, if anything ([`RuleSet::check`]). -fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog) -> Found { +fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog, owner: Option) -> Found { let Some(kind) = field_kind(field) else { return Some((Issue::UnknownField, format!("unknown field `{field}`"))) }; if !ops_for(kind).iter().any(|o| o.0 == op) { return Some((Issue::NoSuchOperator, format!("`{field}` has no operator `{op}`"))); @@ -269,7 +269,7 @@ fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog) -> Found { } match kind { Kind::Bool => bool_value(value).is_none().then(|| (Issue::NotYesNo, format!("`{field}` is yes or no, not {value}"))), - Kind::Album => album_problem(value, cat), + Kind::Album => album_problem(value, cat, owner), Kind::Number => { let each = match value { Value::Array(a) if op == "between" && a.len() == 2 => a.iter().find_map(|v| number_problem(field, v)), @@ -317,15 +317,18 @@ pub fn album_rule_id(value: &Value) -> Option { number(value).filter(|n| n.is_finite() && *n >= 0.0 && n.fract() == 0.0).map(|n| crate::AlbumId(n as u64)) } -/// An Album rule's value: an album that exists and holds photos itself (not a smart album, not a -/// folder of albums). -fn album_problem(v: &Value, cat: &Catalog) -> Found { +/// An Album rule's value: an album that exists and holds photos (a plain or a smart album, not a +/// folder of albums), and, for the rules of `owner`, not one that leads back to it (a smart album +/// testing itself, directly or through others). +fn album_problem(v: &Value, cat: &Catalog, owner: Option) -> Found { if v.is_null() { return Some((Issue::ChooseAlbum, "choose an album".into())); } match album_rule_id(v).and_then(|id| cat.album(id)) { None => Some((Issue::NoSuchAlbum, format!("no album {v}"))), - Some(a) if a.is_smart() => Some((Issue::SmartAlbum, format!("album {v} is a smart album; only plain albums can be tested"))), + Some(a) if owner.is_some_and(|o| a.id == o || cat.album_reaches(a.id, o)) => { + Some((Issue::AlbumLoop, format!("album {v} would make this album include itself"))) + } Some(a) if a.folder => Some((Issue::FolderAlbum, format!("album {v} is a folder; choose an album in it"))), Some(_) => None, } @@ -405,6 +408,41 @@ fn choice_problem(field: &str, value: &Value, choices: &[(&str, &str)], cat: &Ca (!known).then(|| (Issue::NotAChoice, format!("`{field}` is one of {}, not {value}", ids.join(", ")))) } +thread_local! { + /// The smart albums whose rules are being evaluated on this thread, outermost first. + static EVALUATING: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +/// How deep smart albums may test smart albums before the innermost counts as holding nothing. +const MAX_ALBUM_DEPTH: usize = 32; + +/// Run `f` as the evaluation of smart album `id`'s rules: `None` (so it holds nothing) when `id` +/// is already being evaluated (a loop the check would refuse, saved anyway) or the chain is +/// deeper than [`MAX_ALBUM_DEPTH`]. Loops cost one pass round them, never a stack overflow. +pub(crate) fn evaluating(id: crate::AlbumId, f: impl FnOnce() -> T) -> Option { + let entered = EVALUATING.with_borrow_mut(|stack| { + let ok = !stack.contains(&id) && stack.len() < MAX_ALBUM_DEPTH; + if ok { + stack.push(id); + } + ok + }); + if !entered { + return None; + } + // leaves the stack as it was, however `f` ends + struct Leave; + impl Drop for Leave { + fn drop(&mut self) { + EVALUATING.with_borrow_mut(|stack| { + stack.pop(); + }); + } + } + let _leave = Leave; + Some(f()) +} + /// What a yes/no rule's value means: `true`, `"yes"`, `1` (or `1.0`) or no value is yes; `false`, /// `"no"`, `0` is no (strings in any case); `None` for anything else, which matches nothing. pub fn bool_value(value: &Value) -> Option { @@ -786,7 +824,8 @@ impl Rule { "bestOfGroup" => yes(p.analysis.is_some_and(|a| a.best || a.group.is_none())), "album" => { let id = album_rule_id(value); - id.is_some_and(|a| cat.album(a).is_some_and(|al| !al.is_smart()) && cat.album_contains(a, p)) == (op == "is") + // a smart album's photos are its matches; one being evaluated (a loop) holds none + id.is_some_and(|a| cat.album_contains(a, p)) == (op == "is") } _ => false, } @@ -832,21 +871,40 @@ impl RuleSet { /// no, a plain album…), text with nothing to look for, an empty group. Commands refuse a rule /// set with problems; the editor shows them. `cat` knows the albums and colour-label names. pub fn check(&self, cat: &Catalog) -> Vec { + self.check_for(cat, None) + } + + /// [`RuleSet::check`] for the rules of smart album `owner` (`None`: a new album or a filter), + /// which also refuses an Album rule that would make `owner` include itself. + pub fn check_for(&self, cat: &Catalog, owner: Option) -> Vec { let mut out = Vec::new(); - self.check_into(cat, &mut Vec::new(), &mut out); + self.check_into(cat, owner, &mut Vec::new(), &mut out); out } - fn check_into(&self, cat: &Catalog, path: &mut Vec, out: &mut Vec) { + /// The albums the Album rules test, groups included, in order. + pub fn albums_tested(&self) -> Vec { + let mut out = Vec::new(); + for rule in &self.rules { + match rule { + Rule::Group { group } => out.extend(group.albums_tested()), + Rule::Field { field, value, .. } if field == "album" => out.extend(album_rule_id(value)), + Rule::Field { .. } => {} + } + } + out + } + + fn check_into(&self, cat: &Catalog, owner: Option, path: &mut Vec, out: &mut Vec) { for (i, rule) in self.rules.iter().enumerate() { path.push(i); match rule { Rule::Group { group } if group.rules.is_empty() => { out.push(Problem { path: path.clone(), field: None, issue: Issue::EmptyGroup, message: "an empty group".into() }) } - Rule::Group { group } => group.check_into(cat, path, out), + Rule::Group { group } => group.check_into(cat, owner, path, out), Rule::Field { field, op, value } => { - if let Some((issue, message)) = field_problem(field, op, value, cat) { + if let Some((issue, message)) = field_problem(field, op, value, cat, owner) { out.push(Problem { path: path.clone(), field: Some(field.clone()), issue, message }); } } @@ -1327,10 +1385,10 @@ mod tests { ok(r("title", "isEmpty", json!(null))); bad(r("title", "contains", json!(" ")), "something to look for"); bad(r("keywords", "startsWith", json!("")), "something to look for"); - // albums: a plain album that exists + // albums: one that exists, plain or smart ok(r("album", "is", json!(1))); bad(r("album", "is", json!(999)), "no album 999"); - bad(r("album", "is", json!(2)), "smart album"); + ok(r("album", "is", json!(2))); // groups: an empty one is a mistake; problems inside one say where they are let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"match": "any", "rules": []}}]})).check(&cat); assert_eq!(p.iter().map(ToString::to_string).collect::>(), vec!["rule 2: an empty group".to_string()]); diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 84bfdd0d..c6e25317 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -476,3 +476,76 @@ fn smart_album_problems_flag_stale_rules() { let p = c.smart_album_problems(AlbumId(2)); assert_eq!(p.iter().map(ToString::to_string).collect::>(), vec!["rule 1: no album 1".to_string()]); } + +/// A smart album can test another smart album: "Keywords contain travel" and "Album isn't Excluded +/// Photos" (red or rejected) leaves out the excluded photos, and follows that album's rules as they +/// change. +#[test] +fn a_smart_album_can_exclude_another() { + let mut c = Catalog::new(); + let smart = |id: u64, name: &str, rules: serde_json::Value| { + let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": rules})).unwrap(); + Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), name) } + }; + c.apply(Op::AddAlbum { + album: smart( + 1, + "Excluded Photos", + serde_json::json!({"match": "any", "rules": [ + {"field": "label", "op": "is", "value": "red"}, {"field": "flag", "op": "is", "value": "reject"}]}), + ), + }) + .unwrap(); + let travel = |c: &mut Catalog, name: &str, label: Option, flag: Flag| { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00"); + p.meta.keywords = vec!["travel".into()]; + p.label = label; + p.flag = flag; + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + let keep = travel(&mut c, "keep.jpg", None, Flag::Pick); + let red = travel(&mut c, "red.jpg", Some(ColorLabel::Red), Flag::None); + let rejected = travel(&mut c, "rejected.jpg", None, Flag::Reject); + let rules: RuleSet = serde_json::from_value(serde_json::json!({"rules": [ + {"field": "keywords", "op": "contains", "value": "travel"}, {"field": "album", "op": "isNot", "value": 1}]})) + .unwrap(); + assert!(rules.check(&c).is_empty(), "a smart album can be tested: {:?}", rules.check(&c)); + let matched = |c: &Catalog| c.photos().filter(|p| rules.matches(p, c)).map(|p| p.id).collect::>(); + assert_eq!(matched(&c), vec![keep]); + // "is" works the other way round + let inside: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "album", "op": "is", "value": 1}]})).unwrap(); + assert_eq!(c.photos().filter(|p| inside.matches(p, &c)).count(), 2); + let _ = (red, rejected); +} + +/// An album can't include itself, directly or through other smart albums: the check refuses the +/// loop for the album being edited, and a loop saved anyway (an older version, a hand-edited file) +/// neither recurses forever nor crashes; it is reported and its rule matches nothing. +#[test] +fn smart_album_loops_are_refused_and_survived() { + let mut c = Catalog::new(); + let refers = |to: u64| -> Filter { + serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": to}]}})).unwrap() + }; + let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "S") }; + // A → B → C + c.apply(Op::AddAlbum { album: smart(3, Filter::default()) }).unwrap(); + c.apply(Op::AddAlbum { album: smart(2, refers(3)) }).unwrap(); + c.apply(Op::AddAlbum { album: smart(1, refers(2)) }).unwrap(); + let rules = |to: u64| refers(to).rule_set.unwrap(); + assert!(rules(1).check_for(&c, Some(AlbumId(3))).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "C → A closes A → B → C → A"); + assert!(rules(3).check_for(&c, Some(AlbumId(3))).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "an album testing itself"); + assert!(rules(1).check_for(&c, None).is_empty(), "a new album can't be in a loop yet"); + assert!(rules(3).check_for(&c, Some(AlbumId(1))).is_empty(), "A testing C again is no loop"); + assert!(!c.album_reaches(AlbumId(3), AlbumId(1)) && c.album_reaches(AlbumId(1), AlbumId(3))); + // a loop saved anyway: C → A + c.apply(Op::SetAlbumRules { id: AlbumId(3), rules: Box::new(refers(1)) }).unwrap(); + let id = photo(&mut c, "a.jpg", "2026-09-01T10:00:00"); + let p = c.photo(id).unwrap().clone(); + for a in [1, 2, 3] { + let _ = c.album_contains(AlbumId(a), &p); // returns, no stack overflow + assert!(c.smart_album_problems(AlbumId(a)).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "album {a} is reported"); + } +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index a07eb8b4..a9843156 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -816,7 +816,7 @@ pub fn specs() -> Vec { return Err(bad("album.createSmart", "empty name")); } let rules = match p.get("rules") { - Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog)?, + Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog, None)?, None => view_rules(s), }; let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); @@ -865,7 +865,7 @@ pub fn specs() -> Vec { let replace = bool_or(p, "replace", false); let folder = cur.library_folder.clone(); let base = if replace { Default::default() } else { cur }; - let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog)?; + let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog, Some(id))?; // the rules dialog has no folder field: replacing its rules keeps the folder // unless the call says (`libraryFolder: null`) to drop it if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() { @@ -1300,10 +1300,18 @@ fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Re Ok(f) } -/// `patch` merged onto `base`, refused when its rule set has problems (`RuleSet::check`). -fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str, cat: &lightcraft_catalog::Catalog) -> Result { +/// `patch` merged onto `base`, refused when its rule set has problems (`RuleSet::check_for`; +/// `owner` is the smart album the rules are for, so testing an album that leads back to it is a +/// problem too). +fn merge_rules( + base: &lightcraft_catalog::Filter, + patch: &Value, + c: &str, + cat: &lightcraft_catalog::Catalog, + owner: Option, +) -> Result { let f = merge_filter(base, patch, c)?; - let problems = f.rule_set.as_ref().map(|r| r.check(cat)).unwrap_or_default(); + let problems = f.rule_set.as_ref().map(|r| r.check_for(cat, owner)).unwrap_or_default(); if !problems.is_empty() { return Err(bad(c, problems.iter().map(ToString::to_string).collect::>().join("; "))); } diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 921027c9..6e20f998 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -571,3 +571,49 @@ fn album_list_reports_problems() { s.execute("album.delete", &serde_json::json!({"id": trip})).unwrap(); assert_eq!(find(&mut s)["problems"], serde_json::json!([format!("rule 1: no album {trip}")])); } + +/// "Travel" = keywords contain travel, but not in "Excluded Photos" (red or rejected): the counts +/// follow, and Excluded Photos can't then test Travel back (a loop). +#[test] +fn smart_album_excluding_a_smart_album() { + let mut s = crate::Session::with_demo(); + let excluded = s + .execute( + "album.createSmart", + &serde_json::json!({"name": "Excluded Photos", "rules": {"ruleSet": {"match": "any", "rules": [ + {"field": "label", "op": "is", "value": "red"}, {"field": "flag", "op": "is", "value": "reject"}]}}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + let keyword = s.catalog.photos().flat_map(|p| p.meta.keywords.clone()).next().expect("a demo keyword"); + let travel = s + .execute( + "album.createSmart", + &serde_json::json!({"name": "Travel", "rules": {"ruleSet": {"rules": [ + {"field": "keywords", "op": "contains", "value": keyword}, {"field": "album", "op": "isNot", "value": excluded}]}}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + let count = |s: &crate::Session| s.catalog.album_count(lightcraft_catalog::AlbumId(travel)); + let want = |s: &crate::Session| { + s.catalog + .photos() + .filter(|p| !p.deleted && p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&keyword.to_lowercase()))) + .filter(|p| p.label != Some(lightcraft_catalog::ColorLabel::Red) && p.flag != lightcraft_catalog::Flag::Reject) + .count() + }; + assert_eq!(count(&s), want(&s)); + // reject one of Travel's photos: it leaves Travel + let before = count(&s); + let id = s.catalog.photos().find(|p| s.catalog.album_contains(lightcraft_catalog::AlbumId(travel), p)).map(|p| p.id).expect("a travel photo"); + s.execute("photo.flag", &serde_json::json!({"ids": [id.0], "flag": "reject"})).unwrap(); + assert_eq!(count(&s), before - 1); + // Excluded Photos testing Travel back would include itself + let looped = s.execute( + "album.setRules", + &serde_json::json!({"id": excluded, "rules": {"ruleSet": {"rules": [{"field": "album", "op": "is", "value": travel}]}}, "replace": true}), + ); + assert!(looped.is_err_and(|e| e.to_string().contains("would make this album include itself")), "a loop is refused"); +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index b9c07d95..74a5f1ac 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1918,7 +1918,6 @@ "can't match any rating from 0 to 5": "passt zu keiner Bewertung von 0 bis 5", "needs an album": "braucht ein Album", "names an album that no longer exists": "nennt ein Album, das es nicht mehr gibt", - "can't test a smart album; choose a plain album": "kann kein Smart-Album prüfen; wähle ein normales Album", "can't test a folder; choose an album in it": "kann keinen Ordner prüfen; wähle ein Album darin", "This group is empty: add a rule or remove it.": "Diese Gruppe ist leer: Füge eine Regel hinzu oder entferne sie.", "needs something to look for (or use “are empty”)": "braucht einen Suchbegriff (oder „sind leer“)", @@ -1931,5 +1930,6 @@ "Fr": "Fr", "Sa": "Sa", "Su": "So", - "Pick a date": "Datum wählen" + "Pick a date": "Datum wählen", + "would make this album include itself through other smart albums": "würde dieses Album über andere Smart-Alben sich selbst enthalten lassen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 1440a19d..328f92b8 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1763,7 +1763,6 @@ "can't match any rating from 0 to 5": "no coincide con ninguna valoración de 0 a 5", "needs an album": "necesita un álbum", "names an album that no longer exists": "indica un álbum que ya no existe", - "can't test a smart album; choose a plain album": "no puede comprobar un álbum inteligente; elige un álbum normal", "can't test a folder; choose an album in it": "no puede comprobar una carpeta; elige un álbum dentro", "This group is empty: add a rule or remove it.": "Este grupo está vacío: añade una regla o quítalo.", "needs something to look for (or use “are empty”)": "necesita algo que buscar (o usa «están vacíos»)", @@ -1776,5 +1775,6 @@ "Fr": "Vi", "Sa": "Sá", "Su": "Do", - "Pick a date": "Elegir una fecha" + "Pick a date": "Elegir una fecha", + "would make this album include itself through other smart albums": "haría que este álbum se incluyera a sí mismo a través de otros álbumes inteligentes" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 918ef999..192c4eaf 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1767,7 +1767,6 @@ "can't match any rating from 0 to 5": "0〜5 のどの評価にも一致しません", "needs an album": "アルバムを選択してください", "names an album that no longer exists": "存在しないアルバムが指定されています", - "can't test a smart album; choose a plain album": "スマートアルバムは判定できません。通常のアルバムを選択してください", "can't test a folder; choose an album in it": "フォルダーは判定できません。中のアルバムを選択してください", "This group is empty: add a rule or remove it.": "このグループは空です。条件を追加するか、グループを削除してください。", "needs something to look for (or use “are empty”)": "検索する語句が必要です(または「が空」を使用)", @@ -1780,5 +1779,6 @@ "Fr": "金", "Sa": "土", "Su": "日", - "Pick a date": "日付を選択" + "Pick a date": "日付を選択", + "would make this album include itself through other smart albums": "ほかのスマートアルバムを通じて、このアルバム自身を含むことになります" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9a2ab678..936fa411 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1763,7 +1763,6 @@ "can't match any rating from 0 to 5": "não corresponde a nenhuma avaliação de 0 a 5", "needs an album": "precisa de um álbum", "names an album that no longer exists": "indica um álbum que não existe mais", - "can't test a smart album; choose a plain album": "não pode testar um álbum inteligente; escolha um álbum comum", "can't test a folder; choose an album in it": "não pode testar uma pasta; escolha um álbum dentro dela", "This group is empty: add a rule or remove it.": "Este grupo está vazio: adicione uma regra ou remova-o.", "needs something to look for (or use “are empty”)": "precisa de algo para procurar (ou use “estão vazios”)", @@ -1776,5 +1775,6 @@ "Fr": "Sex", "Sa": "Sáb", "Su": "Dom", - "Pick a date": "Escolher uma data" + "Pick a date": "Escolher uma data", + "would make this album include itself through other smart albums": "faria este álbum incluir a si mesmo por meio de outros álbuns inteligentes" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 442acb95..252141a8 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1435,7 +1435,6 @@ "can't match any rating from 0 to 5": "не подходит ни к одной оценке от 0 до 5", "needs an album": "нужен альбом", "names an album that no longer exists": "указан альбом, которого больше нет", - "can't test a smart album; choose a plain album": "нельзя проверять смарт-альбом; выберите обычный альбом", "can't test a folder; choose an album in it": "нельзя проверять папку; выберите альбом в ней", "This group is empty: add a rule or remove it.": "Эта группа пуста: добавьте условие или удалите её.", "needs something to look for (or use “are empty”)": "нужно указать, что искать (или выберите «пусты»)", @@ -1468,5 +1467,6 @@ "Fr": "Пт", "Sa": "Сб", "Su": "Вс", - "Pick a date": "Выбрать дату" + "Pick a date": "Выбрать дату", + "would make this album include itself through other smart albums": "альбом включал бы сам себя через другие смарт-альбомы" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 0af301ab..ad7aae9e 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1763,7 +1763,6 @@ "can't match any rating from 0 to 5": "不匹配 0 到 5 的任何星级", "needs an album": "需要选择相册", "names an album that no longer exists": "指定的相册已不存在", - "can't test a smart album; choose a plain album": "无法判断智能相册;请选择普通相册", "can't test a folder; choose an album in it": "无法判断文件夹;请选择其中的相册", "This group is empty: add a rule or remove it.": "此组为空:请添加规则或将其删除。", "needs something to look for (or use “are empty”)": "需要要查找的内容(或使用“为空”)", @@ -1776,5 +1775,6 @@ "Fr": "五", "Sa": "六", "Su": "日", - "Pick a date": "选择日期" + "Pick a date": "选择日期", + "would make this album include itself through other smart albums": "会通过其他智能相册让此相册包含自身" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 649b26a7..2574c249 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1763,7 +1763,6 @@ "can't match any rating from 0 to 5": "不符合 0 到 5 的任何評分", "needs an album": "需要選擇相簿", "names an album that no longer exists": "指定的相簿已不存在", - "can't test a smart album; choose a plain album": "無法判斷智慧型相簿;請選擇一般相簿", "can't test a folder; choose an album in it": "無法判斷資料夾;請選擇其中的相簿", "This group is empty: add a rule or remove it.": "此群組為空:請新增規則或將其移除。", "needs something to look for (or use “are empty”)": "需要要尋找的內容(或使用「為空」)", @@ -1776,5 +1775,6 @@ "Fr": "五", "Sa": "六", "Su": "日", - "Pick a date": "選擇日期" + "Pick a date": "選擇日期", + "would make this album include itself through other smart albums": "會透過其他智慧型相簿讓此相簿包含自身" } From 5c949e01e80969ce9c33e71a1afc676caaa39804 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:05:55 -0400 Subject: [PATCH 47/65] LRC-LIB-SMARTCOLL: rule editor offers smart albums and blocks loops; Esc closes only popups - The Album rule's list offers smart albums under a "Smart Albums" heading (translated in all seven locales). It leaves out the album being edited and any smart album that tests it, since that would loop back (album_choices, Catalog::album_reaches). - The dialog checks rules for the album being edited (RuleSet::check_for), so a loop set by other means is marked under its row, OK is disabled, and confirm refuses it. - Esc with any popup open in a dialog (a dropdown, a menu, a date picker) closed the dialog with its edits. The dialog now claims Esc for an open popup before drawing (widgets::take_escape), so only the popup closes. New headless test for a dropdown, next to the calendar's. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/i18n.rs | 5 ++- crates/ui-egui/src/panels/dialogs.rs | 48 ++++++++++++++--------- crates/ui-egui/src/panels/rules_editor.rs | 23 ++++++++--- crates/ui-egui/src/tests_date_picker.rs | 13 ++++++ crates/ui-egui/src/tests_masking.rs | 33 +++++++++++++++- 12 files changed, 110 insertions(+), 33 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 74a5f1ac..fa06ae57 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1931,5 +1931,6 @@ "Sa": "Sa", "Su": "So", "Pick a date": "Datum wählen", - "would make this album include itself through other smart albums": "würde dieses Album über andere Smart-Alben sich selbst enthalten lassen" + "would make this album include itself through other smart albums": "würde dieses Album über andere Smart-Alben sich selbst enthalten lassen", + "Smart Albums": "Smart-Alben" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 328f92b8..faf2a4e2 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1776,5 +1776,6 @@ "Sa": "Sá", "Su": "Do", "Pick a date": "Elegir una fecha", - "would make this album include itself through other smart albums": "haría que este álbum se incluyera a sí mismo a través de otros álbumes inteligentes" + "would make this album include itself through other smart albums": "haría que este álbum se incluyera a sí mismo a través de otros álbumes inteligentes", + "Smart Albums": "Álbumes inteligentes" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 192c4eaf..11882d7f 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1780,5 +1780,6 @@ "Sa": "土", "Su": "日", "Pick a date": "日付を選択", - "would make this album include itself through other smart albums": "ほかのスマートアルバムを通じて、このアルバム自身を含むことになります" + "would make this album include itself through other smart albums": "ほかのスマートアルバムを通じて、このアルバム自身を含むことになります", + "Smart Albums": "スマートアルバム" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 936fa411..cefdaeb2 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1776,5 +1776,6 @@ "Sa": "Sáb", "Su": "Dom", "Pick a date": "Escolher uma data", - "would make this album include itself through other smart albums": "faria este álbum incluir a si mesmo por meio de outros álbuns inteligentes" + "would make this album include itself through other smart albums": "faria este álbum incluir a si mesmo por meio de outros álbuns inteligentes", + "Smart Albums": "Álbuns inteligentes" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 252141a8..40beb384 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1468,5 +1468,6 @@ "Sa": "Сб", "Su": "Вс", "Pick a date": "Выбрать дату", - "would make this album include itself through other smart albums": "альбом включал бы сам себя через другие смарт-альбомы" + "would make this album include itself through other smart albums": "альбом включал бы сам себя через другие смарт-альбомы", + "Smart Albums": "Смарт-альбомы" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad7aae9e..c4dcefbd 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1776,5 +1776,6 @@ "Sa": "六", "Su": "日", "Pick a date": "选择日期", - "would make this album include itself through other smart albums": "会通过其他智能相册让此相册包含自身" + "would make this album include itself through other smart albums": "会通过其他智能相册让此相册包含自身", + "Smart Albums": "智能相册" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 2574c249..6918e393 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1776,5 +1776,6 @@ "Sa": "六", "Su": "日", "Pick a date": "選擇日期", - "would make this album include itself through other smart albums": "會透過其他智慧型相簿讓此相簿包含自身" + "would make this album include itself through other smart albums": "會透過其他智慧型相簿讓此相簿包含自身", + "Smart Albums": "智慧型相簿" } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 7ba6a47e..efeaadb8 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -761,8 +761,11 @@ mod tests { assert_eq!(problem_text_in(Locale::Ja, &p[0]), "タイトル:検索する語句が必要です(または「が空」を使用)"); let g = problems(serde_json::json!([{"group": {"rules": []}}])); assert_eq!(problem_text_in(Locale::En, &g[0]), "This group is empty: add a rule or remove it."); - // the issues, and the operators their hints name, in every language + // the issues, the operators their hints name and the album picker's words, in every language for language in Locale::ALL.iter().filter(|l| **l != Locale::En) { + for word in ["Choose an album…", "No albums yet", "Smart Albums", "Fix the marked rules to save this album."] { + assert!(language.catalog().contains_key(word), "{} lacks {word:?}", language.code()); + } for issue in Issue::ALL { assert!(language.catalog().contains_key(issue.text()), "{} lacks {:?}", language.code(), issue.text()); } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 91d1af6e..22768a03 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -63,6 +63,11 @@ pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md"); pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(mut dlg) = app.ui.dialog.clone() else { return }; let at_start = dlg.clone(); + // Esc with a popup open in the dialog (a dropdown, a date picker's calendar) closes the popup + // only: claimed before the dialog is drawn, since the popup is gone once it has handled the key + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) && egui::Popup::is_any_open(ctx) { + crate::widgets::take_escape(ctx); + } let t = Tokens::get(ctx); let screen = ctx.content_rect(); // The backdrop is an area below the dialog window (a bare `Middle` layer painter would be @@ -244,27 +249,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.add_space(6.0); // checked before drawing, so each row marks its own problem let env = crate::panels::rules_editor::Env { - problems: rules.check(&app.session.catalog), + problems: rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)), today: (app.session.clock)(), - albums: { - let cat = &app.session.catalog; - let mut albums: Vec<(u64, String)> = cat - .albums() - .filter(|a| !a.is_smart() && !a.folder) - .map(|a| { - // "Folder / Album", so albums of the same name in two folders differ - let folder = a.parent.and_then(|f| cat.album(f)).map(|f| f.name.as_str()); - (a.id.0, folder.map_or_else(|| a.name.clone(), |f| format!("{f} / {}", a.name))) - }) - .collect(); - albums.sort_by_key(|a| a.1.to_lowercase()); - albums - }, + albums: album_choices(&app.session.catalog, *id, false), + smart_albums: album_choices(&app.session.catalog, *id, true), }; egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| { crate::panels::rules_editor::edit(ui, rules, "rules", 0, &[], &env); }); - let problems = rules.check(&app.session.catalog); + let problems = rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)); // the folder an album made from a folder view carries is not in the editor, but it counts let folder = id.and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id))).and_then(|a| a.smart.as_deref().and_then(|f| f.library_folder.clone())); let f = lightcraft_catalog::Filter { rule_set: Some(rules.clone()), library_folder: folder, ..Default::default() }; @@ -856,7 +849,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let can_confirm = informational || !matches!(&dlg, Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. }) // smart-album rules that can't mean anything wait to be fixed - && !matches!(&dlg, Dialog::SmartRules { rules, .. } if !rules.check(&app.session.catalog).is_empty()); + && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty()); let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, egui::Button::new(crate::i18n::tr(ok)))); if let Some(r) = &r { crate::widgets::register(ui.ctx(), "button:dialogOk", r.rect); @@ -1040,6 +1033,25 @@ fn created_in(app: &mut LightcraftApp, command: &str, params: serde_json::Value) Ok(r) } +/// The albums (`smart` false) or smart albums an Album rule in smart album `editing` can test, as +/// (id, "Folder / Name"), sorted by name: no folders, and no smart album that is `editing` itself +/// or tests it (that would loop back). +fn album_choices(cat: &lightcraft_catalog::Catalog, editing: Option, smart: bool) -> Vec<(u64, String)> { + let editing = editing.map(lightcraft_catalog::AlbumId); + let mut albums: Vec<(u64, String)> = cat + .albums() + .filter(|a| !a.folder && a.is_smart() == smart) + .filter(|a| editing.is_none_or(|e| a.id != e && !cat.album_reaches(a.id, e))) + .map(|a| { + // "Folder / Album", so albums of the same name in two folders differ + let folder = a.parent.and_then(|f| cat.album(f)).map(|f| f.name.as_str()); + (a.id.0, folder.map_or_else(|| a.name.clone(), |f| format!("{f} / {}", a.name))) + }) + .collect(); + albums.sort_by_key(|a| a.1.to_lowercase()); + albums +} + pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { match dlg { Dialog::SamModel { then, .. } => { @@ -1100,7 +1112,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { // refused before anything changes (not even the name) - if let Some(problem) = rules.check(&app.session.catalog).first() { + if let Some(problem) = rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).first() { return Err(problem.to_string()); } let name = if name.trim().is_empty() { "Smart Album".to_string() } else { name.trim().to_string() }; diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index 7d7b2c4b..571be601 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -113,6 +113,9 @@ pub struct Env { /// The albums an Album rule can test: (id, name shown, with its folder when it is in one), /// sorted by that name. pub albums: Vec<(u64, String)>, + /// The smart albums it can test, the same way: not the album being edited, nor one that tests + /// it (that would loop back). + pub smart_albums: Vec<(u64, String)>, } /// The album of `albums` an Album rule's value names, read as the catalog reads it (`3`, `"3"`). @@ -121,20 +124,30 @@ fn chosen_album<'a>(v: &Value, albums: &'a [(u64, String)]) -> Option<&'a (u64, albums.iter().find(|a| a.0 == id.0) } -/// An Album rule's value: one of the plain albums, picked by name. +/// An Album rule's value: an album or a smart album, picked by name. fn album_value(ui: &mut egui::Ui, v: &mut Value, salt: &str, env: &Env) { - let chosen = chosen_album(v, &env.albums); + let chosen = chosen_album(v, &env.albums).or_else(|| chosen_album(v, &env.smart_albums)); let cur = chosen.map(|a| a.0); let text = chosen.map_or_else(|| crate::i18n::tr("Choose an album…").to_string(), |a| a.1.clone()); let r = egui::ComboBox::from_id_salt(format!("{salt}-album")).width(160.0).selected_text(text).show_ui(ui, |ui| { - for (id, name) in &env.albums { - let r = ui.selectable_label(cur == Some(*id), name); + let mut item = |ui: &mut egui::Ui, id: u64, name: &str| { + let r = ui.selectable_label(cur == Some(id), name); register(ui.ctx(), format!("ruleAlbumItem:{id}:{salt}"), r.rect); if r.clicked() { *v = json!(id); } + }; + for (id, name) in &env.albums { + item(ui, *id, name); } - if env.albums.is_empty() { + if !env.smart_albums.is_empty() { + ui.separator(); + ui.label(RichText::new(crate::i18n::tr("Smart Albums")).weak().small()); + for (id, name) in &env.smart_albums { + item(ui, *id, name); + } + } + if env.albums.is_empty() && env.smart_albums.is_empty() { ui.label(crate::i18n::tr("No albums yet")); } }); diff --git a/crates/ui-egui/src/tests_date_picker.rs b/crates/ui-egui/src/tests_date_picker.rs index ba5e2811..0ea04f0c 100644 --- a/crates/ui-egui/src/tests_date_picker.rs +++ b/crates/ui-egui/src/tests_date_picker.rs @@ -101,3 +101,16 @@ fn escape_closes_only_the_calendar() { assert!(!has(&h, "datePickerCell:2026-01-20:rules-0"), "the calendar closed"); assert!(h.app.ui.dialog.is_some(), "the editor stays open"); } + +/// Esc with a dropdown open (here the field menu) closes the dropdown, not the editor. +#[test] +fn escape_closes_only_a_dropdown() { + let mut h = editor(json!([{"field": "rating", "op": "gte", "value": 3}])); + click(&mut h, "ruleField:rules-0"); + assert!(has(&h, "ruleFieldItem:rating:rules-0")); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(!has(&h, "ruleFieldItem:rating:rules-0"), "the menu closed"); + assert!(h.app.ui.dialog.is_some(), "the editor stays open"); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index b6867017..5cf53d1c 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -381,7 +381,7 @@ fn smart_album_editor_marks_bad_rules_and_blocks_ok() { assert!(h.app.session.catalog.albums().any(|a| a.name == "Checked")); } -/// An Album rule picks its album from a list of the plain albums, not by typing an id. +/// An Album rule picks its album from a list of the albums and smart albums, not by typing an id. #[test] fn smart_album_editor_picks_an_album_from_a_list() { let mut h = detail("panel.edit"); @@ -397,7 +397,7 @@ fn smart_album_editor_picks_an_album_from_a_list() { h.settle(SETTLE); let has = |h: &Headless, id: String| h.app.widgets.iter().any(|(w, _)| *w == id); assert!(has(&h, format!("ruleAlbumItem:{trip}:rules-0")), "plain albums are offered"); - assert!(!has(&h, format!("ruleAlbumItem:{smart}:rules-0")), "smart albums can't be tested"); + assert!(has(&h, format!("ruleAlbumItem:{smart}:rules-0")), "smart albums too"); let r = h.request("ui.clickWidget", json!({"id": format!("ruleAlbumItem:{trip}:rules-0")}), T); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); @@ -480,6 +480,35 @@ fn sidebar_marks_smart_albums_with_problems() { assert!(marked(&h), "its album is gone"); } +/// Editing "Excluded Photos" while "Travel" tests it: the list offers neither the album itself nor +/// Travel (testing it would loop back), and a rule set to Travel by other means is marked. +#[test] +fn smart_album_editor_keeps_albums_from_including_themselves() { + let mut h = detail("panel.edit"); + let trip = exec(&mut h, "album.create", json!({"name": "Trip", "addSelected": false}))["id"].as_u64().unwrap(); + let excluded = exec(&mut h, "album.createSmart", json!({"name": "Excluded Photos", "rules": {"rating": 1}}))["id"].as_u64().unwrap(); + let rules = json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": excluded}]}}); + let travel = exec(&mut h, "album.createSmart", json!({"name": "Travel", "rules": rules}))["id"].as_u64().unwrap(); + exec(&mut h, "dialog.smartAlbum", json!({"id": excluded})); + let set = |h: &mut Headless, value: serde_json::Value| { + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &mut h.app.ui.dialog else { panic!("no rule editor") }; + rules.rules = vec![serde_json::from_value(json!({"field": "album", "op": "is", "value": value})).unwrap()]; + h.settle(SETTLE); + }; + set(&mut h, json!(null)); + let r = h.request("ui.clickWidget", json!({"id": "ruleAlbum:rules-0"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let has = |h: &Headless, id: String| h.app.widgets.iter().any(|(w, _)| *w == id); + assert!(has(&h, format!("ruleAlbumItem:{trip}:rules-0"))); + assert!(!has(&h, format!("ruleAlbumItem:{excluded}:rules-0")), "not itself"); + assert!(!has(&h, format!("ruleAlbumItem:{travel}:rules-0")), "not an album that tests it"); + let _ = h.request("ui.key", json!({"key": "Escape"}), T); + h.settle(SETTLE); + set(&mut h, json!(travel)); + assert!(has(&h, "ruleProblem:rules-0".to_string()), "a loop is marked"); +} + #[test] fn g_toggles_grids_and_shift_g_starts_guided_upright() { let mut h = detail("panel.edit"); From 9dcfc6faee9f0b1d54760e7f0a54d322998a1514 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:05:55 -0400 Subject: [PATCH 48/65] LRC-LIB-SMARTCOLL: document smart albums testing smart albums Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 2 +- docs/whats-new.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/parity.md b/docs/parity.md index a59a3a06..99939d50 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, membership of smart collections. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`; no 30 February; a time zone or fractions of a second are refused, since capture times are local and to the second; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album from a list of the plain albums, named with their folder and sorted by name (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway matches nothing instead of recursing —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`; no 30 February; a time zone or fractions of a second are refused, since capture times are local and to the second; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album from a list of the plain albums, named with their folder and sorted by name (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | diff --git a/docs/whats-new.md b/docs/whats-new.md index 2f5799b9..800d6a58 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -17,6 +17,10 @@ way round, "in the last 0 days", a colour label that doesn't exist, "contains" with nothing to look for, an album that is gone or smart, or an empty group is reported with its rule ("rule 2.1: rating is 0–5, not 9") instead of quietly matching nothing or everything. New date rules start at the current year. +- A smart album can include or exclude another smart album: "Keywords contain travel" and "Album isn't Excluded Photos" + leaves out whatever Excluded Photos matches, as its rules change. The album list offers smart albums under their own + heading; a rule that would make an album include itself is refused. Esc with a dropdown or calendar open in a dialog + closes just that. - Date rules have a calendar button: pick a day, a month or a whole year (Year / Month / Day), in your language; the two dates of a "between" can't be picked out of order; Esc closes just the calendar. It picks dates, not times (a time is typed, and picking a day replaces one). Typing still works, and "2026-10-01 10:00" (a space for the From f40c7fb2049ce7098b34f2f21a54d51befa73b89 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:10:32 -0400 Subject: [PATCH 49/65] LRC-LIB-SMARTCOLL: rule summaries name albums, not ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summaries showed an Album rule's id ("album isn't 9"): the sidebar tooltip, the "Rules:" filter chip and albums.list's rulesText for agents. RuleSet::describe_with(&Catalog) and Filter::describe_with name the album ("album isn't “Excluded Photos”"). One that is gone shows as #9 (rules::album_name). The engine's rulesText and Rules chip use them, and i18n::rules_label takes the catalog for the translated summaries. Tests at all three levels: catalog (nested groups, a missing album), engine (rulesText), UI (German summary). Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/query.rs | 11 ++++++++++- crates/catalog/src/rules.rs | 22 +++++++++++++++++++++- crates/catalog/src/tests.rs | 15 +++++++++++++++ crates/engine/src/cmd/query.rs | 2 +- crates/engine/src/tests_library.rs | 4 ++++ crates/engine/src/view.rs | 2 +- crates/ui-egui/src/i18n.rs | 27 +++++++++++++++++++-------- crates/ui-egui/src/panels/chips.rs | 2 +- 8 files changed, 72 insertions(+), 13 deletions(-) diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 3e9cded5..14a36950 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -160,6 +160,15 @@ fn has_person(p: &Photo, name: &str) -> bool { impl Filter { /// Human-readable summary of the active rules (smart album tooltips, `album.list`). pub fn describe(&self) -> String { + self.describe_in(None) + } + + /// [`Filter::describe`] naming the albums its rules test (`RuleSet::describe_with`). + pub fn describe_with(&self, cat: &crate::Catalog) -> String { + self.describe_in(Some(cat)) + } + + fn describe_in(&self, cat: Option<&crate::Catalog>) -> String { let mut v: Vec = Vec::new(); if self.rating > 0 { let op = match self.rating_op { @@ -207,7 +216,7 @@ impl Filter { v.push(format!("“{}”", self.text.trim())); } if let Some(rs) = self.rule_set.as_ref().filter(|r| !r.rules.is_empty()) { - v.push(rs.describe()); + v.push(cat.map_or_else(|| rs.describe(), |c| rs.describe_with(c))); } if v.is_empty() { "all photos".into() } else { v.join(", ") } } diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 4f34fd5b..675094f9 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -443,6 +443,15 @@ pub(crate) fn evaluating(id: crate::AlbumId, f: impl FnOnce() -> T) -> Option Some(f()) } +/// How a summary names an Album rule's album: “Name”, or `#9` when there is no such album. +pub fn album_name(value: &Value, cat: Option<&Catalog>) -> String { + let id = album_rule_id(value); + match id.and_then(|id| cat.and_then(|c| c.album(id))) { + Some(a) => format!("“{}”", a.name), + None => id.map_or_else(|| value.to_string(), |id| format!("#{}", id.0)), + } +} + /// What a yes/no rule's value means: `true`, `"yes"`, `1` (or `1.0`) or no value is yes; `false`, /// `"no"`, `0` is no (strings in any case); `None` for anything else, which matches nothing. pub fn bool_value(value: &Value) -> Option { @@ -915,6 +924,16 @@ impl RuleSet { /// A short readable summary ("rating is ≥ 3 and keywords contains travel"). pub fn describe(&self) -> String { + self.describe_in(None) + } + + /// [`RuleSet::describe`] naming the albums Album rules test ("album isn't “Excluded + /// Photos”"); one that is gone shows as `#9`. + pub fn describe_with(&self, cat: &Catalog) -> String { + self.describe_in(Some(cat)) + } + + fn describe_in(&self, cat: Option<&Catalog>) -> String { let join = match self.mode { Match::All => " and ", Match::Any => " or ", @@ -924,11 +943,12 @@ impl RuleSet { .rules .iter() .map(|r| match r { - Rule::Group { group } => format!("({})", group.describe()), + Rule::Group { group } => format!("({})", group.describe_in(cat)), Rule::Field { field, op, value } => { 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 { + _ if field == "album" && cat.is_some() => album_name(value, cat), _ if field_kind(field) == Some(Kind::Bool) => { bool_value(value).map_or_else(|| value.to_string(), |b| bool_label(b).to_lowercase()) } diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index c6e25317..ec78153b 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -549,3 +549,18 @@ fn smart_album_loops_are_refused_and_survived() { assert!(c.smart_album_problems(AlbumId(a)).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "album {a} is reported"); } } + +/// Summaries name the albums Album rules test ("album isn't “Excluded Photos”"), not their ids; +/// one that is gone shows as its number. +#[test] +fn summaries_name_albums() { + let mut c = Catalog::new(); + c.apply(Op::AddAlbum { album: Album::new(AlbumId(4), "Excluded Photos") }).unwrap(); + let rules: RuleSet = serde_json::from_value(serde_json::json!({"rules": [ + {"field": "keywords", "op": "contains", "value": "travel"}, + {"group": {"match": "any", "rules": [{"field": "album", "op": "isNot", "value": 4}, {"field": "album", "op": "is", "value": 9}]}}]})) + .unwrap(); + assert_eq!(rules.describe_with(&c), "keywords contains travel and (album isn't “Excluded Photos” or album is #9)"); + let f = Filter { rule_set: Some(rules), ..Default::default() }; + assert!(f.describe_with(&c).contains("album isn't “Excluded Photos”")); +} diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index d8ff2242..45904e0b 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -43,7 +43,7 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Va if let Some(rules) = &a.smart { v["smart"] = json!(true); v["rules"] = serde_json::to_value(rules).unwrap_or_default(); - v["rulesText"] = json!(rules.describe()); + v["rulesText"] = json!(rules.describe_with(cat)); // rules that no longer check (an album they test was deleted…), as `rule 1: …` v["problems"] = json!(cat.smart_album_problems(a.id).iter().map(ToString::to_string).collect::>()); } diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 6e20f998..926482b5 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -616,4 +616,8 @@ fn smart_album_excluding_a_smart_album() { &serde_json::json!({"id": excluded, "rules": {"ruleSet": {"rules": [{"field": "album", "op": "is", "value": travel}]}}, "replace": true}), ); assert!(looped.is_err_and(|e| e.to_string().contains("would make this album include itself")), "a loop is refused"); + // agents read the album by name in the summary + let list = s.execute("albums.list", &serde_json::json!({})).unwrap(); + let travel_json = list.as_array().unwrap().iter().find(|a| a["id"] == travel).cloned().unwrap(); + assert!(travel_json["rulesText"].as_str().unwrap_or("").contains("album isn't “Excluded Photos”"), "{travel_json}"); } diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 16c9f8a0..5b32999b 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -212,7 +212,7 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec { _ => {} } if let Some(rs) = f.rule_set.as_ref().filter(|r| !r.rules.is_empty()) { - add(format!("Rules: {}", rs.describe()), json!({"ruleSet": Null})); + add(format!("Rules: {}", rs.describe_with(cat)), json!({"ruleSet": Null})); } if !f.only.is_empty() { add(format!("Only {} photos", f.only.len()), json!({"only": []})); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efeaadb8..2661095d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -318,7 +318,7 @@ pub fn color_label(catalog: &lightcraft_catalog::Catalog, label: lightcraft_cata /// A legacy smart-album filter summary, using the same display labels as filter chips. pub fn filter_label(filter: &lightcraft_catalog::Filter, catalog: &lightcraft_catalog::Catalog) -> String { if language() == Locale::En { - filter.describe() + filter.describe_with(catalog) } else { lightcraft_engine::filter_chips(filter, catalog) .iter() @@ -378,8 +378,8 @@ fn bool_text_in(language: Locale, b: bool) -> &'static str { } /// 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 { +pub fn rules_label(rules: &lightcraft_catalog::RuleSet, catalog: &lightcraft_catalog::Catalog) -> String { + fn describe(rules: &lightcraft_catalog::RuleSet, catalog: &lightcraft_catalog::Catalog, depth: usize) -> String { use lightcraft_catalog::{ Match, Rule, rules::{FIELDS, Kind, ops_for}, @@ -391,11 +391,13 @@ pub fn rules_label(rules: &lightcraft_catalog::RuleSet) -> String { .rules .iter() .map(|rule| match rule { - Rule::Group { group } => format!("({})", describe(group, depth + 1)), + Rule::Group { group } => format!("({})", describe(group, catalog, depth + 1)), Rule::Field { field, op, value } => { 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 { + // the album's name, as people know it (a user's name is never translated) + Kind::Album => lightcraft_catalog::rules::album_name(value, Some(catalog)), Kind::Choice(_) => value.as_str().map_or_else(|| value.to_string(), |id| choice_text(field, id).to_string()), Kind::Bool => lightcraft_catalog::rules::bool_value(value).map_or_else(|| value.to_string(), |b| bool_text(b).to_string()), _ if matches!(op.as_str(), "inLast" | "notInLast") => format!( @@ -417,7 +419,7 @@ pub fn rules_label(rules: &lightcraft_catalog::RuleSet) -> String { if rules.mode == Match::None { format!("{} ({text})", tr("none of")) } else { text } } // Preserve the established source-language summary. - if language() == Locale::En { rules.describe() } else { describe(rules, 0) } + if language() == Locale::En { rules.describe_with(catalog) } else { describe(rules, catalog, 0) } } #[cfg(test)] @@ -865,7 +867,16 @@ mod tests { mode: lightcraft_catalog::Match::All, 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 mut catalog = lightcraft_catalog::Catalog::new(); + catalog + .apply(lightcraft_catalog::Op::AddAlbum { album: lightcraft_catalog::Album::new(lightcraft_catalog::AlbumId(4), "Ausgeschlossen") }) + .unwrap(); + assert_eq!(rules_label(&rules, &catalog), "Stichwörter enthält Color"); + let album = lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::Match::All, + rules: vec![lightcraft_catalog::Rule::Field { field: "album".into(), op: "isNot".into(), value: serde_json::json!(4) }], + }; + assert!(rules_label(&album, &catalog).ends_with(" “Ausgeschlossen”"), "{}", rules_label(&album, &catalog)); let rules = lightcraft_catalog::RuleSet { mode: lightcraft_catalog::Match::All, rules: vec![lightcraft_catalog::Rule::Field { @@ -874,13 +885,13 @@ mod tests { value: serde_json::json!("publicDomain"), }], }; - assert!(rules_label(&rules).ends_with(" Gemeinfrei"), "{}", rules_label(&rules)); + assert!(rules_label(&rules, &catalog).ends_with(" Gemeinfrei"), "{}", rules_label(&rules, &catalog)); for value in [serde_json::json!(false), serde_json::json!("false")] { let rules = lightcraft_catalog::RuleSet { mode: lightcraft_catalog::Match::All, rules: vec![lightcraft_catalog::Rule::Field { field: "edited".into(), op: "is".into(), value }], }; - assert!(rules_label(&rules).ends_with(" Nein"), "{}", rules_label(&rules)); + assert!(rules_label(&rules, &catalog).ends_with(" Nein"), "{}", rules_label(&rules, &catalog)); } let filter = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue], diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs index 38dc01e4..d1acd15a 100644 --- a/crates/ui-egui/src/panels/chips.rs +++ b/crates/ui-egui/src/panels/chips.rs @@ -37,7 +37,7 @@ pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filt if chip.clear.get("ruleSet").is_some() && let Some(rules) = &filter.rule_set { - return format!("{}: {}", tr("Rules"), rules_label(rules)); + return format!("{}: {}", tr("Rules"), rules_label(rules, catalog)); } if chip.clear.get("label").is_some() { let mut labels = filter.labels.clone(); From 2ad901e6dc5dd5619aede9bee493902c093f8caf Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:15:12 -0400 Subject: [PATCH 50/65] LRC-LIB-SMARTCOLL: album_reaches uses a hash set; loop test describes the guard From review: the walk through smart-album references kept its seen-set in a Vec, which is quadratic over long chains. The loop test's comment now says what a saved loop does: inside it, the album being evaluated counts as holding no photos. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/lib.rs | 5 ++--- crates/catalog/src/tests.rs | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index fd15415c..10ca3617 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -335,13 +335,12 @@ impl Catalog { /// rules of `from` would then change when those of `to` do). Loops in saved rules end the /// search, they don't repeat it. pub fn album_reaches(&self, from: AlbumId, to: AlbumId) -> bool { - let mut seen: Vec = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); let mut next = vec![from]; while let Some(a) = next.pop() { - if seen.contains(&a) { + if !seen.insert(a) { continue; } - seen.push(a); let tested = self .albums .get(&a) diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index ec78153b..354d046e 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -522,7 +522,8 @@ fn a_smart_album_can_exclude_another() { /// An album can't include itself, directly or through other smart albums: the check refuses the /// loop for the album being edited, and a loop saved anyway (an older version, a hand-edited file) -/// neither recurses forever nor crashes; it is reported and its rule matches nothing. +/// neither recurses forever nor crashes: inside it, the album being evaluated counts as holding no +/// photos, and every album in it is reported. #[test] fn smart_album_loops_are_refused_and_survived() { let mut c = Catalog::new(); From d48f5eaabc77691599ce09b5cd7d22daeee8706e Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:15:12 -0400 Subject: [PATCH 51/65] LRC-LIB-SMARTCOLL: Update Rules from Current Filter refuses loops From review: album.setRules with fromView saved the current view unchecked. Showing smart album A ("Album isn't B") and updating B from that view made B test itself, which the rule check is meant to refuse. The view is still saved as it is, but a loop for the album being updated is refused (Issue::AlbumLoop), and the sidebar's menu item shows the reason instead of ignoring the result. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 17 ++++++++++++++++- crates/engine/src/tests_library.rs | 16 ++++++++++++++++ crates/ui-egui/src/panels/left.rs | 5 ++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index a9843156..caa53611 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -860,7 +860,22 @@ pub fn specs() -> Vec { let id = album_param(p, "id", "album.setRules")?; let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?; let rules = if bool_or(p, "fromView", false) { - view_rules(s) + // the view is saved as it is, but never as a loop (showing A, which tests this + // album, and saving that view here would make it test itself) + let view = view_rules(s); + let loops: Vec = view + .rule_set + .as_ref() + .map(|r| r.check_for(&s.catalog, Some(id))) + .unwrap_or_default() + .iter() + .filter(|p| p.issue == lightcraft_catalog::rules::Issue::AlbumLoop) + .map(ToString::to_string) + .collect(); + if !loops.is_empty() { + return Err(bad("album.setRules", loops.join("; "))); + } + view } else { let replace = bool_or(p, "replace", false); let folder = cur.library_folder.clone(); diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 926482b5..4dcf2ba9 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -621,3 +621,19 @@ fn smart_album_excluding_a_smart_album() { let travel_json = list.as_array().unwrap().iter().find(|a| a["id"] == travel).cloned().unwrap(); assert!(travel_json["rulesText"].as_str().unwrap_or("").contains("album isn't “Excluded Photos”"), "{travel_json}"); } + +/// "Update Rules from Current Filter" refuses a loop too: showing A ("Album isn't B") and +/// updating B from that view would make B test itself. +#[test] +fn rules_from_the_view_cant_loop() { + let mut s = crate::Session::with_demo(); + let b = s.execute("album.createSmart", &serde_json::json!({"name": "B", "rules": {"rating": 2}})).unwrap()["id"].as_u64().unwrap(); + let rules = serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": b}]}}); + let a = s.execute("album.createSmart", &serde_json::json!({"name": "A", "rules": rules})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.source", &serde_json::json!({"kind": "album", "id": a})).unwrap(); + let before = s.catalog.album(lightcraft_catalog::AlbumId(b)).unwrap().smart.clone(); + let r = s.execute("album.setRules", &serde_json::json!({"id": b, "fromView": true})); + assert!(r.is_err_and(|e| e.to_string().contains("would make this album include itself")), "refused"); + assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(b)).unwrap().smart, before, "B unchanged"); + assert!(s.catalog.smart_album_problems(lightcraft_catalog::AlbumId(b)).is_empty()); +} diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 8f843d94..5a21b54e 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1059,7 +1059,10 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { let _ = app.run("dialog.smartAlbum", json!({"id": a.id.0})); } if a.is_smart() && ui.button(crate::i18n::tr("Update Rules from Current Filter")).clicked() { - let _ = app.run("album.setRules", json!({"id": a.id.0, "fromView": true})); + // refused when the view would make the album test itself: say why + if let Err(e) = app.run("album.setRules", json!({"id": a.id.0, "fromView": true})) { + app.toast(ui.ctx(), e); + } } if !a.folder { // export: show the album, select its photos, then the dialog / a preset From 14bff3706aa042a841b7863671c4197c6a50a079 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:15:12 -0400 Subject: [PATCH 52/65] LRC-LIB-SMARTCOLL: correct the docs on smart albums testing smart albums From review: a loop saved anyway doesn't "match nothing". Inside it, the album being evaluated counts as empty, so an "isn't" rule on it matches every photo; the sidebar marks it. Also: Album rules take plain or smart albums (not "plain" only), the picker lists both, an album that is smart is no longer a problem, and a broken sentence in the parity row is mended. Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 2 +- docs/whats-new.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index 99939d50..93a8883c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway matches nothing instead of recursing —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status, Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`; no 30 February; a time zone or fractions of a second are refused, since capture times are local and to the second; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing plain album, no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album from a list of the plain albums, named with their folder and sorted by name (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`; no 30 February; a time zone or fractions of a second are refused, since capture times are local and to the second; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album from a list of the albums and smart albums, named with their folder and sorted by name (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | diff --git a/docs/whats-new.md b/docs/whats-new.md index 800d6a58..dcf3839a 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -15,7 +15,7 @@ `publicDomain` (rules and agents keep using the ids; `album.ruleFields` adds `choiceLabels`). - Rule values are checked: a rating of 9, a date like 2026-13 or "banana", a "between" whose dates are the wrong way round, "in the last 0 days", a colour label that doesn't exist, "contains" with nothing to look for, an - album that is gone or smart, or an empty group is reported with its rule ("rule 2.1: rating is 0–5, not 9") + album that is gone, or an empty group is reported with its rule ("rule 2.1: rating is 0–5, not 9") instead of quietly matching nothing or everything. New date rules start at the current year. - A smart album can include or exclude another smart album: "Keywords contain travel" and "Album isn't Excluded Photos" leaves out whatever Excluded Photos matches, as its rules change. The album list offers smart albums under their own From 872bff96ea7ac86614c405c6ab2b7d3e080000a2 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:25:10 -0400 Subject: [PATCH 53/65] Tests: neutral example names in file-path and long-name fixtures A file-path rule test and a sidebar long-name test used a real person's name as their example. They now use "Lake Como Wedding-Day" and keep what they check: a multi-word folder name, word order that must not match, and a hyphen a space must not match. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/rules.rs | 10 +++++----- crates/ui-egui/src/tests_panels.rs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 675094f9..9cea37f0 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -1012,14 +1012,14 @@ mod tests { yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]})); // file path: the whole string (spaces included), either separator, case-insensitive let mut fp = photo(3); - fp.source = Source::File { path: "D:\\Photos\\Aliah Ira Polanco-Grylls\\2026\\IMG_0042.CR2".into() }; + fp.source = Source::File { path: "D:\\Photos\\Lake Como Wedding-Day\\2026\\IMG_0042.CR2".into() }; let fpm = |v: serde_json::Value| rs(v).matches(&fp, &cat); - assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Aliah Ira Polanco-Grylls/"}]}))); - assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Ira Aliah/"}]}))); - assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Polanco Grylls"}]}))); + assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake Como Wedding-Day/"}]}))); + assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Como Lake/"}]}))); + assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Wedding Day"}]}))); assert!(fpm(json!({"rules": [{"field": "filePath", "op": "notContains", "value": "/Other/"}]}))); assert!(fpm(json!({"rules": [{"field": "filePath", "op": "startsWith", "value": "d:/photos/"}]}))); - no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Aliah/"}]})); // demo photo has no path + no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake/"}]})); // demo photo has no path yes(json!({"rules": [{"field": "filePath", "op": "isEmpty"}]})); yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]})); yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]})); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 66eb360e..d45b9976 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -991,7 +991,7 @@ fn tooltip_shown(h: &Headless) -> bool { h.view.ctx.memory(|m| m.areas().visible_layer_ids().into_iter().any(|l| l.order == egui::Order::Tooltip)) } -const LONG_NAME: &str = "Aliah Ira Polanco-Grylls and a name much longer than any sidebar row can show"; +const LONG_NAME: &str = "Lake Como Wedding-Day and a name much longer than any sidebar row can show"; /// A name never runs past the photo count: it is cut with an ellipsis, in full on hover. #[test] From 94de255e7461345d8913d01ee14148560135bf61 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:25:10 -0400 Subject: [PATCH 54/65] LRC-LIB-SMARTCOLL: a standalone album picker with a tree and search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flat dropdown doesn't scale to the hundreds of albums and smart albums a library grows. crates/ui-egui/src/album_picker.rs is a reusable picker: - A button opens a popup with a search field, focused on open. - With nothing typed it shows the sidebar's tree in the sidebar's order (entries_from uses Catalog::album_children_by_parent): folders with painted disclosure triangles and folder icons, album / smart-album icons. Folders are closed except those holding the current choice. - Typing lists the albums whose "Folder / Name" path holds every word, in any case, names starting with the query first. ↑ / ↓ move, Enter picks, Esc closes just the popup. - Albums that can't be picked stay visible but greyed, the reason on hover, instead of vanishing. - Pure, unit-tested helpers: path_label, ancestors, visible_tree, search, set_depths. Widget ids albumPicker / albumPickerSearch / albumPickerFolder / albumPickerItem. The rule editor's Album value uses it. The album being edited and albums that would loop back are blocked with a reason ("This is the album you're editing", "It tests this album, so testing it back would loop"). This also removes the stray divider the old dropdown drew above "Smart Albums" when no regular album preceded it. New strings are translated in all seven locales, and the strict i18n test lists them; the unused "Smart Albums" heading is dropped. Headless tests: browsing the tree, typing with ↑ / Enter, greyed loops. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 5 +- crates/ui-egui/locales/es.json | 5 +- crates/ui-egui/locales/ja.json | 5 +- crates/ui-egui/locales/pt-br.json | 5 +- crates/ui-egui/locales/ru.json | 5 +- crates/ui-egui/locales/zh-hans.json | 5 +- crates/ui-egui/locales/zh-hant.json | 5 +- crates/ui-egui/src/album_picker.rs | 371 ++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 10 +- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/dialogs.rs | 27 +- crates/ui-egui/src/panels/rules_editor.rs | 57 +--- crates/ui-egui/src/tests_album_picker.rs | 116 +++++++ crates/ui-egui/src/tests_masking.rs | 26 +- 14 files changed, 565 insertions(+), 80 deletions(-) create mode 100644 crates/ui-egui/src/album_picker.rs create mode 100644 crates/ui-egui/src/tests_album_picker.rs diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index fa06ae57..bc4ef39b 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1932,5 +1932,8 @@ "Su": "So", "Pick a date": "Datum wählen", "would make this album include itself through other smart albums": "würde dieses Album über andere Smart-Alben sich selbst enthalten lassen", - "Smart Albums": "Smart-Alben" + "Search albums": "Alben suchen", + "No albums match": "Kein Album passt", + "This is the album you're editing": "Das ist das Album, das du bearbeitest", + "It tests this album, so testing it back would loop": "Es prüft dieses Album; es zurückzuprüfen wäre ein Kreis" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index faf2a4e2..9e30e266 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1777,5 +1777,8 @@ "Su": "Do", "Pick a date": "Elegir una fecha", "would make this album include itself through other smart albums": "haría que este álbum se incluyera a sí mismo a través de otros álbumes inteligentes", - "Smart Albums": "Álbumes inteligentes" + "Search albums": "Buscar álbumes", + "No albums match": "Ningún álbum coincide", + "This is the album you're editing": "Es el álbum que estás editando", + "It tests this album, so testing it back would loop": "Comprueba este álbum, así que comprobarlo a su vez crearía un bucle" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 11882d7f..dfc6405a 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1781,5 +1781,8 @@ "Su": "日", "Pick a date": "日付を選択", "would make this album include itself through other smart albums": "ほかのスマートアルバムを通じて、このアルバム自身を含むことになります", - "Smart Albums": "スマートアルバム" + "Search albums": "アルバムを検索", + "No albums match": "一致するアルバムはありません", + "This is the album you're editing": "編集中のアルバムです", + "It tests this album, so testing it back would loop": "このアルバムを条件にしているため、逆に指定すると循環します" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index cefdaeb2..dd3ef316 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1777,5 +1777,8 @@ "Su": "Dom", "Pick a date": "Escolher uma data", "would make this album include itself through other smart albums": "faria este álbum incluir a si mesmo por meio de outros álbuns inteligentes", - "Smart Albums": "Álbuns inteligentes" + "Search albums": "Buscar álbuns", + "No albums match": "Nenhum álbum corresponde", + "This is the album you're editing": "É o álbum que você está editando", + "It tests this album, so testing it back would loop": "Ele testa este álbum, então testá-lo de volta criaria um ciclo" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 40beb384..2b58d95d 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1469,5 +1469,8 @@ "Su": "Вс", "Pick a date": "Выбрать дату", "would make this album include itself through other smart albums": "альбом включал бы сам себя через другие смарт-альбомы", - "Smart Albums": "Смарт-альбомы" + "Search albums": "Поиск альбомов", + "No albums match": "Нет подходящих альбомов", + "This is the album you're editing": "Это альбом, который вы изменяете", + "It tests this album, so testing it back would loop": "Он проверяет этот альбом, поэтому обратная проверка создаст цикл" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index c4dcefbd..adf2f3cf 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1777,5 +1777,8 @@ "Su": "日", "Pick a date": "选择日期", "would make this album include itself through other smart albums": "会通过其他智能相册让此相册包含自身", - "Smart Albums": "智能相册" + "Search albums": "搜索相册", + "No albums match": "没有匹配的相册", + "This is the album you're editing": "这是正在编辑的相册", + "It tests this album, so testing it back would loop": "它引用了此相册,反过来引用会形成循环" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 6918e393..3a93d1ed 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1777,5 +1777,8 @@ "Su": "日", "Pick a date": "選擇日期", "would make this album include itself through other smart albums": "會透過其他智慧型相簿讓此相簿包含自身", - "Smart Albums": "智慧型相簿" + "Search albums": "搜尋相簿", + "No albums match": "沒有符合的相簿", + "This is the album you're editing": "這是正在編輯的相簿", + "It tests this album, so testing it back would loop": "它引用了此相簿,反過來引用會形成循環" } diff --git a/crates/ui-egui/src/album_picker.rs b/crates/ui-egui/src/album_picker.rs new file mode 100644 index 00000000..7800363c --- /dev/null +++ b/crates/ui-egui/src/album_picker.rs @@ -0,0 +1,371 @@ +//! An album picker: a button that opens a popup to pick an album or a smart album, by browsing a +//! tree like the sidebar's or by typing part of its name. +//! +//! Standalone: it edits an `Option` album id over a list of [`AlbumEntry`]s, in tree order +//! ([`entries_from`] builds it from a catalog in the sidebar's order). Folders are for browsing, +//! not picking. An entry can be `blocked` with a reason: shown greyed, the reason on hover. +//! +//! With nothing typed it shows the tree, folders closed except those holding the current choice. +//! Typing lists the albums whose path ("Folder / Name") holds every word typed, names starting +//! with it first; ↑ / ↓ move through them, Enter picks, Esc closes. Widget ids for tests and agents: +//! `albumPicker:` (the button), `albumPickerSearch:`, `albumPickerFolder::` +//! and `albumPickerItem::`. + +use egui::{Response, RichText, Ui, vec2}; + +use crate::icons::Icon; +use crate::widgets::register; + +/// One row of the picker: an album, a smart album or a folder. +#[derive(Clone, Debug, PartialEq)] +pub struct AlbumEntry { + pub id: u64, + pub name: String, + pub parent: Option, + /// How deep in folders (0 at the top), from [`set_depths`]. + pub depth: usize, + pub folder: bool, + pub smart: bool, + /// Why it can't be picked, when it can't (shown on hover). + pub blocked: Option, +} + +/// Every album, smart album and folder of `cat` in the sidebar's order (folders first, the user's +/// order, else by name), each folder followed by what it holds. `blocked` says why an album can't +/// be picked, if so. +pub fn entries_from(cat: &lightcraft_catalog::Catalog, blocked: impl Fn(&lightcraft_catalog::Album) -> Option) -> Vec { + let kids = cat.album_children_by_parent(); + let mut out = Vec::new(); + // depth-first, guarded against a parent loop in a damaged catalog + let mut stack: Vec<(Option, usize)> = vec![(None, 0)]; + let mut seen = std::collections::HashSet::new(); + fn walk( + parent: Option, + kids: &std::collections::HashMap, Vec<&lightcraft_catalog::Album>>, + blocked: &dyn Fn(&lightcraft_catalog::Album) -> Option, + seen: &mut std::collections::HashSet, + out: &mut Vec, + depth: usize, + ) { + if depth > 64 { + return; + } + for a in kids.get(&parent).map(Vec::as_slice).unwrap_or_default() { + if !seen.insert(a.id) { + continue; + } + out.push(AlbumEntry { + id: a.id.0, + name: a.name.clone(), + parent: a.parent.map(|p| p.0), + depth, + folder: a.folder, + smart: a.is_smart(), + blocked: if a.folder { None } else { blocked(a) }, + }); + if a.folder { + walk(Some(a.id), kids, blocked, seen, out, depth + 1); + } + } + } + stack.clear(); + walk(None, &kids, &blocked, &mut seen, &mut out, 0); + out +} + +/// Fill in each entry's depth from its parents (for lists built by hand). +pub fn set_depths(entries: &mut [AlbumEntry]) { + let depths: Vec = entries.iter().map(|e| ancestors(entries, e.id).len()).collect(); + for (e, d) in entries.iter_mut().zip(depths) { + e.depth = d; + } +} + +/// The folders holding album `id`, nearest first; empty at the top level or for an unknown id. +pub fn ancestors(entries: &[AlbumEntry], id: u64) -> Vec { + let mut out = Vec::new(); + let mut at = entries.iter().find(|e| e.id == id).and_then(|e| e.parent); + while let Some(p) = at { + if out.contains(&p) || out.len() > 64 { + break; + } + out.push(p); + at = entries.iter().find(|e| e.id == p).and_then(|e| e.parent); + } + out +} + +/// "Folder / Sub / Name" for album `id`; empty for an unknown id. +pub fn path_label(entries: &[AlbumEntry], id: u64) -> String { + let Some(entry) = entries.iter().find(|e| e.id == id) else { return String::new() }; + let mut parts: Vec<&str> = + ancestors(entries, id).iter().rev().filter_map(|p| entries.iter().find(|e| e.id == *p)).map(|e| e.name.as_str()).collect(); + parts.push(&entry.name); + parts.join(" / ") +} + +/// The rows a tree shows with the folders in `open` open: indexes into `entries`. +pub fn visible_tree(entries: &[AlbumEntry], open: &[u64]) -> Vec { + (0..entries.len()).filter(|&i| ancestors(entries, entries[i].id).iter().all(|a| open.contains(a))).collect() +} + +/// The albums (not folders) whose path holds every word of `query`, any case: those whose own +/// name starts with the query first, then the rest, each in tree order. Indexes into `entries`. +pub fn search(entries: &[AlbumEntry], query: &str) -> Vec { + let query = query.trim().to_lowercase(); + let words: Vec<&str> = query.split_whitespace().collect(); + if words.is_empty() { + return Vec::new(); + } + let hits: Vec = (0..entries.len()) + .filter(|&i| !entries[i].folder) + .filter(|&i| { + let path = path_label(entries, entries[i].id).to_lowercase(); + words.iter().all(|w| path.contains(w)) + }) + .collect(); + let (first, rest): (Vec, Vec) = hits.into_iter().partition(|&i| entries[i].name.to_lowercase().starts_with(&query)); + first.into_iter().chain(rest).collect() +} + +/// A small filled triangle centred on `c`: pointing down (`down`), else right. +fn triangle(p: &egui::Painter, c: egui::Pos2, down: bool, color: egui::Color32) { + let pts = if down { + vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] + } else { + vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] + }; + p.add(egui::Shape::convex_polygon(pts, color, egui::Stroke::NONE)); +} + +/// What the open popup remembers between frames. +#[derive(Clone, Default)] +struct State { + query: String, + open: Vec, + /// The highlighted search result (↑ / ↓, Enter). + highlight: usize, + focus: bool, +} + +/// A button that edits an album id; see the module docs. +pub struct AlbumPicker<'a> { + id: String, + value: &'a mut Option, + entries: &'a [AlbumEntry], + width: f32, +} + +impl<'a> AlbumPicker<'a> { + /// `id` must be unique among the pickers on screen (it names their widgets and keeps their + /// state apart); `value` is the album picked (`None`: none yet); `entries` in tree order. + pub fn new(id: impl Into, value: &'a mut Option, entries: &'a [AlbumEntry]) -> Self { + AlbumPicker { id: id.into(), value, entries, width: 180.0 } + } + + /// The button's width (180 by default). + pub fn width(mut self, width: f32) -> Self { + self.width = width; + self + } + + /// The button and, while open, the popup. The response is `changed()` when an album was picked. + pub fn show(self, ui: &mut Ui) -> Response { + let AlbumPicker { id, value, entries, width } = self; + let chosen = value.filter(|v| entries.iter().any(|e| e.id == *v && !e.folder)); + let text = chosen.map_or_else(|| crate::i18n::tr("Choose an album…").to_string(), |c| path_label(entries, c)); + // the name, and a drop-down arrow painted at the right (the UI font has no arrow glyph) + let mut button = ui.add_sized(vec2(width, ui.spacing().interact_size.y), egui::Button::new(text).truncate().right_text(" ")); + let arrow_color = crate::theme::Tokens::get(ui.ctx()).icon; + triangle(ui.painter(), egui::pos2(button.rect.right() - 10.0, button.rect.center().y), true, arrow_color); + register(ui.ctx(), format!("albumPicker:{id}"), button.rect); + let key = egui::Id::new(("albumPicker", id.as_str())); + if button.clicked() { + // open on the chosen album's folders, with the search empty and ready for typing + let open = chosen.map(|c| ancestors(entries, c)).unwrap_or_default(); + ui.data_mut(|d| d.insert_temp(key, State { open, focus: true, ..Default::default() })); + } + let mut state: State = ui.data(|d| d.get_temp(key)).unwrap_or_default(); + let mut picked: Option = None; + egui::Popup::from_toggle_button_response(&button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.set_min_width(width.max(260.0)); + ui.set_max_width(width.max(260.0) + 120.0); + let search_box = + ui.add(egui::TextEdit::singleline(&mut state.query).hint_text(crate::i18n::tr("Search albums")).desired_width(f32::INFINITY)); + register(ui.ctx(), format!("albumPickerSearch:{id}"), search_box.rect); + if state.focus { + search_box.request_focus(); + state.focus = false; + } + if search_box.changed() { + state.highlight = 0; + } + let t = crate::theme::Tokens::get(ui.ctx()); + let row = |ui: &mut Ui, e: &AlbumEntry, label: &str, indent: f32, highlighted: bool, picked: &mut Option| { + ui.horizontal(|ui| { + ui.add_space(indent); + let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover()); + let icon = if e.smart { Icon::SmartAlbum } else { Icon::Album }; + crate::icons::paint(ui.painter(), r, icon, if e.blocked.is_some() { t.text_disabled } else { t.icon }); + let resp = ui.add_enabled(e.blocked.is_none(), egui::Button::selectable(Some(e.id) == chosen || highlighted, label)); + register(ui.ctx(), format!("albumPickerItem:{}:{id}", e.id), resp.rect); + let resp = match &e.blocked { + Some(why) => resp.on_disabled_hover_text(why.as_str()), + None => resp, + }; + if resp.clicked() { + *picked = Some(e.id); + } + if highlighted { + resp.scroll_to_me(None); + } + }); + }; + egui::ScrollArea::vertical().max_height(320.0).auto_shrink([false, true]).show(ui, |ui| { + if state.query.trim().is_empty() { + for i in visible_tree(entries, &state.open) { + let e = &entries[i]; + let indent = e.depth as f32 * 16.0; + if e.folder { + // a folder row folds and unfolds, like the sidebar's: triangle, icon, name + let open = state.open.contains(&e.id); + let height = ui.spacing().interact_size.y; + let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), height), egui::Sense::click()); + if r.hovered() { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + let x = rect.left() + indent; + triangle(ui.painter(), egui::pos2(x + 7.0, rect.center().y), open, if r.hovered() { t.text } else { t.text_dim }); + let icon = egui::Rect::from_center_size(egui::pos2(x + 23.0, rect.center().y), vec2(14.0, 14.0)); + crate::icons::paint(ui.painter(), icon, Icon::Folder, t.icon); + ui.painter().text( + egui::pos2(x + 36.0, rect.center().y), + egui::Align2::LEFT_CENTER, + &e.name, + egui::TextStyle::Button.resolve(ui.style()), + t.text_label, + ); + register(ui.ctx(), format!("albumPickerFolder:{}:{id}", e.id), r.rect); + if r.clicked() { + if open { + state.open.retain(|o| *o != e.id); + } else { + state.open.push(e.id); + } + } + } else { + // past the triangle column, so names line up with folder names + row(ui, e, &e.name, indent + 14.0, false, &mut picked); + } + } + if entries.iter().all(|e| e.folder) { + ui.label(RichText::new(crate::i18n::tr("No albums yet")).weak()); + } + } else { + let hits = search(entries, &state.query); + // ↑ / ↓ move through the results that can be picked, Enter picks one + let pickable: Vec = hits.iter().copied().filter(|&i| entries[i].blocked.is_none()).collect(); + let (down, up, enter) = ui.input_mut(|i| { + ( + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown), + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp), + i.consume_key(egui::Modifiers::NONE, egui::Key::Enter), + ) + }); + if down { + state.highlight = (state.highlight + 1).min(pickable.len().saturating_sub(1)); + } + if up { + state.highlight = state.highlight.saturating_sub(1); + } + let highlighted = pickable.get(state.highlight).copied(); + if enter && let Some(i) = highlighted { + picked = Some(entries[i].id); + } + for i in &hits { + let e = &entries[*i]; + row(ui, e, &path_label(entries, e.id), 0.0, highlighted == Some(*i), &mut picked); + } + if hits.is_empty() { + ui.label(RichText::new(crate::i18n::tr("No albums match")).weak()); + } + } + }); + // Esc closes the popup and stops there: the dialog around it keeps its edits + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + crate::widgets::take_escape(ui.ctx()); + ui.close(); + } + if picked.is_some() { + ui.close(); + } + }); + ui.data_mut(|d| d.insert_temp(key, state)); + if let Some(p) = picked { + *value = Some(p); + button.mark_changed(); + } + button + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn e(id: u64, name: &str, parent: Option, folder: bool, smart: bool) -> AlbumEntry { + AlbumEntry { id, name: name.into(), parent, depth: 0, folder, smart, blocked: None } + } + + /// [UTILS] (folder) > Excluded | Hidden Photos (smart), Uncurated (smart); Trip; Weddings > + /// Tuscany Wedding + fn sample() -> Vec { + let mut v = vec![ + e(1, "[UTILS]", None, true, false), + e(2, "Excluded | Hidden Photos", Some(1), false, true), + e(3, "Uncurated", Some(1), false, true), + e(4, "Trip", None, false, false), + e(5, "Weddings", None, true, false), + e(6, "Tuscany Wedding", Some(5), false, true), + ]; + set_depths(&mut v); + v + } + + #[test] + fn paths_and_depths_follow_the_folders() { + let v = sample(); + assert_eq!(path_label(&v, 2), "[UTILS] / Excluded | Hidden Photos"); + assert_eq!(path_label(&v, 4), "Trip"); + assert_eq!(path_label(&v, 99), ""); + assert_eq!(v.iter().map(|e| e.depth).collect::>(), vec![0, 1, 1, 0, 0, 1]); + assert_eq!(ancestors(&v, 6), vec![5]); + assert!(ancestors(&v, 4).is_empty()); + } + + #[test] + fn the_tree_hides_what_closed_folders_hold() { + let v = sample(); + let ids = |open: &[u64]| visible_tree(&v, open).into_iter().map(|i| v[i].id).collect::>(); + assert_eq!(ids(&[]), vec![1, 4, 5], "folders closed: only the top level"); + assert_eq!(ids(&[1]), vec![1, 2, 3, 4, 5]); + assert_eq!(ids(&[1, 5]), vec![1, 2, 3, 4, 5, 6]); + } + + /// Every word typed is in the album's path (folders included), in any case; names that start + /// with it come first; folders themselves aren't results. + #[test] + fn search_finds_albums_by_any_part_of_their_path() { + let v = sample(); + let ids = |q: &str| search(&v, q).into_iter().map(|i| v[i].id).collect::>(); + assert_eq!(ids("excl"), vec![2]); + assert_eq!(ids("UTILS"), vec![2, 3], "a folder's name finds what it holds"); + assert_eq!(ids("utils uncur"), vec![3], "every word must match"); + assert_eq!(ids("t"), vec![4, 6, 2, 3], "names starting with it first (Trip, Tuscany Wedding), then the rest, each in sidebar order"); + assert_eq!(ids(" "), Vec::::new(), "an empty search isn't a search"); + assert_eq!(ids("zzz"), Vec::::new()); + let mut accents = vec![e(7, "Été", None, false, false)]; + set_depths(&mut accents); + assert_eq!(search(&accents, "été").len(), 1); + } +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 2661095d..4ed092e5 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -765,7 +765,15 @@ mod tests { assert_eq!(problem_text_in(Locale::En, &g[0]), "This group is empty: add a rule or remove it."); // the issues, the operators their hints name and the album picker's words, in every language for language in Locale::ALL.iter().filter(|l| **l != Locale::En) { - for word in ["Choose an album…", "No albums yet", "Smart Albums", "Fix the marked rules to save this album."] { + for word in [ + "Choose an album…", + "No albums yet", + "Search albums", + "No albums match", + "This is the album you're editing", + "It tests this album, so testing it back would loop", + "Fix the marked rules to save this album.", + ] { assert!(language.catalog().contains_key(word), "{} lacks {word:?}", language.code()); } for issue in Issue::ALL { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8f4e0bb2..9a743e91 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod album_picker; pub mod control; pub mod credits; pub mod date_picker; @@ -33,6 +34,8 @@ pub mod theme; pub mod titlebar; pub mod widgets; +#[cfg(test)] +mod tests_album_picker; #[cfg(test)] mod tests_curve; #[cfg(test)] diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 22768a03..e790d8d3 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -251,8 +251,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let env = crate::panels::rules_editor::Env { problems: rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)), today: (app.session.clock)(), - albums: album_choices(&app.session.catalog, *id, false), - smart_albums: album_choices(&app.session.catalog, *id, true), + albums: album_entries(&app.session.catalog, *id), }; egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| { crate::panels::rules_editor::edit(ui, rules, "rules", 0, &[], &env); @@ -1033,23 +1032,15 @@ fn created_in(app: &mut LightcraftApp, command: &str, params: serde_json::Value) Ok(r) } -/// The albums (`smart` false) or smart albums an Album rule in smart album `editing` can test, as -/// (id, "Folder / Name"), sorted by name: no folders, and no smart album that is `editing` itself -/// or tests it (that would loop back). -fn album_choices(cat: &lightcraft_catalog::Catalog, editing: Option, smart: bool) -> Vec<(u64, String)> { +/// The albums an Album rule in smart album `editing` picks from, in the sidebar's order: the album +/// itself and any smart album that tests it (testing it back would loop) are blocked, with why. +fn album_entries(cat: &lightcraft_catalog::Catalog, editing: Option) -> Vec { let editing = editing.map(lightcraft_catalog::AlbumId); - let mut albums: Vec<(u64, String)> = cat - .albums() - .filter(|a| !a.folder && a.is_smart() == smart) - .filter(|a| editing.is_none_or(|e| a.id != e && !cat.album_reaches(a.id, e))) - .map(|a| { - // "Folder / Album", so albums of the same name in two folders differ - let folder = a.parent.and_then(|f| cat.album(f)).map(|f| f.name.as_str()); - (a.id.0, folder.map_or_else(|| a.name.clone(), |f| format!("{f} / {}", a.name))) - }) - .collect(); - albums.sort_by_key(|a| a.1.to_lowercase()); - albums + crate::album_picker::entries_from(cat, |a| match editing { + Some(e) if a.id == e => Some(crate::i18n::tr("This is the album you're editing").to_string()), + Some(e) if cat.album_reaches(a.id, e) => Some(crate::i18n::tr("It tests this album, so testing it back would loop").to_string()), + _ => None, + }) } pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index 571be601..9b8c994f 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -6,6 +6,7 @@ use lightcraft_catalog::rules::{FIELD_GROUPS, Kind, Problem, TOP_LEVEL_FIELDS, b use lightcraft_catalog::{Match, Rule, RuleSet}; use serde_json::{Value, json}; +use crate::album_picker::{AlbumEntry, AlbumPicker}; use crate::date_picker::{DatePicker, PickedDate}; use crate::theme::Tokens; use crate::widgets::register; @@ -110,48 +111,21 @@ pub struct Env { pub problems: Vec, /// Today (`YYYY-MM-DD…`, the session's clock): where a date picker opens when a rule has no date. pub today: String, - /// The albums an Album rule can test: (id, name shown, with its folder when it is in one), - /// sorted by that name. - pub albums: Vec<(u64, String)>, - /// The smart albums it can test, the same way: not the album being edited, nor one that tests - /// it (that would loop back). - pub smart_albums: Vec<(u64, String)>, + /// The albums, smart albums and folders an Album rule picks from, in the sidebar's order + /// ([`crate::album_picker::entries_from`]); the album being edited and those that would loop + /// back to it are blocked, with the reason. + pub albums: Vec, } -/// The album of `albums` an Album rule's value names, read as the catalog reads it (`3`, `"3"`). -fn chosen_album<'a>(v: &Value, albums: &'a [(u64, String)]) -> Option<&'a (u64, String)> { - let id = lightcraft_catalog::rules::album_rule_id(v)?; - albums.iter().find(|a| a.0 == id.0) -} - -/// An Album rule's value: an album or a smart album, picked by name. +/// An Album rule's value: an album or a smart album, picked from the tree or by typing +/// ([`AlbumPicker`]). The id is read as the catalog reads it (`3`, `"3"`) and written as a number. fn album_value(ui: &mut egui::Ui, v: &mut Value, salt: &str, env: &Env) { - let chosen = chosen_album(v, &env.albums).or_else(|| chosen_album(v, &env.smart_albums)); - let cur = chosen.map(|a| a.0); - let text = chosen.map_or_else(|| crate::i18n::tr("Choose an album…").to_string(), |a| a.1.clone()); - let r = egui::ComboBox::from_id_salt(format!("{salt}-album")).width(160.0).selected_text(text).show_ui(ui, |ui| { - let mut item = |ui: &mut egui::Ui, id: u64, name: &str| { - let r = ui.selectable_label(cur == Some(id), name); - register(ui.ctx(), format!("ruleAlbumItem:{id}:{salt}"), r.rect); - if r.clicked() { - *v = json!(id); - } - }; - for (id, name) in &env.albums { - item(ui, *id, name); - } - if !env.smart_albums.is_empty() { - ui.separator(); - ui.label(RichText::new(crate::i18n::tr("Smart Albums")).weak().small()); - for (id, name) in &env.smart_albums { - item(ui, *id, name); - } - } - if env.albums.is_empty() && env.smart_albums.is_empty() { - ui.label(crate::i18n::tr("No albums yet")); - } - }); - register(ui.ctx(), format!("ruleAlbum:{salt}"), r.response.rect); + let mut chosen = lightcraft_catalog::rules::album_rule_id(v).map(|a| a.0); + if AlbumPicker::new(salt, &mut chosen, &env.albums).width(200.0).show(ui).changed() + && let Some(id) = chosen + { + *v = json!(id); + } } /// The value editor for one rule. @@ -438,11 +412,6 @@ mod tests { assert_eq!(default_value("captureDate", "is"), json!("2026")); assert_eq!(default_value("captureDate", "between"), json!(["2026-01", "2026-12"])); lightcraft_catalog::rules::set_now(None); - // the picker shows the album a rule names, however the id is written - let albums = vec![(3, "Trip".to_string())]; - assert_eq!(chosen_album(&json!(3), &albums).map(|a| a.1.as_str()), Some("Trip")); - assert_eq!(chosen_album(&json!("3"), &albums).map(|a| a.1.as_str()), Some("Trip")); - assert_eq!(chosen_album(&json!(4), &albums), None); assert_eq!(default_value("shutterSpeed", "gte"), json!("1/250")); assert_eq!(default_value("shutterSpeed", "between"), json!(["1/1000", "1/125"])); } diff --git a/crates/ui-egui/src/tests_album_picker.rs b/crates/ui-egui/src/tests_album_picker.rs new file mode 100644 index 00000000..fd9121d5 --- /dev/null +++ b/crates/ui-egui/src/tests_album_picker.rs @@ -0,0 +1,116 @@ +//! The album picker in the smart-album rule editor: a tree like the sidebar's, a search that +//! narrows it as you type, keys to pick with, and albums that can't be picked shown greyed. + +use std::time::Duration; + +use lightcraft_catalog::Rule; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +fn id(v: serde_json::Value) -> u64 { + v["id"].as_u64().expect("an id") +} + +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(SETTLE); +} + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn value(h: &Headless) -> serde_json::Value { + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") }; + let Some(Rule::Field { value, .. }) = rules.rules.first() else { panic!("no rule") }; + value.clone() +} + +struct Library { + h: Headless, + utils: u64, + excluded: u64, + uncurated: u64, + trip: u64, +} + +/// [UTILS] > Excluded (smart), Uncurated (smart); Trip; and the rule editor on a new album with one +/// Album rule. +fn library() -> Library { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let utils = id(exec(&mut h, "album.create", json!({"name": "[UTILS]", "folder": true}))); + let excluded = id(exec(&mut h, "album.createSmart", json!({"name": "Excluded", "rules": {"rating": 1}, "parent": utils}))); + let uncurated = id(exec(&mut h, "album.createSmart", json!({"name": "Uncurated", "rules": {"rating": 0}, "parent": utils}))); + let trip = id(exec(&mut h, "album.create", json!({"name": "Trip", "addSelected": false}))); + exec(&mut h, "dialog.smartAlbum", json!({"name": "Travel"})); + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &mut h.app.ui.dialog else { panic!("no rule editor") }; + rules.rules = vec![serde_json::from_value(json!({"field": "album", "op": "isNot", "value": null})).unwrap()]; + h.settle(SETTLE); + Library { h, utils, excluded, uncurated, trip } +} + +/// With nothing typed, the picker shows the sidebar's tree: folders start closed and open on click. +#[test] +fn the_tree_opens_folder_by_folder() { + let Library { mut h, utils, excluded, trip, .. } = library(); + click(&mut h, "albumPicker:rules-0"); + assert!(has(&h, &format!("albumPickerFolder:{utils}:rules-0")) && has(&h, &format!("albumPickerItem:{trip}:rules-0"))); + assert!(!has(&h, &format!("albumPickerItem:{excluded}:rules-0")), "inside a closed folder"); + click(&mut h, &format!("albumPickerFolder:{utils}:rules-0")); + click(&mut h, &format!("albumPickerItem:{excluded}:rules-0")); + assert_eq!(value(&h), json!(excluded)); + assert!(!has(&h, &format!("albumPickerItem:{trip}:rules-0")), "picking closes it"); + // it opens again on the chosen album's folder + click(&mut h, "albumPicker:rules-0"); + assert!(has(&h, &format!("albumPickerItem:{excluded}:rules-0"))); +} + +/// Typing narrows the albums to those whose path holds every word; Enter picks the first, the +/// arrow keys move through them. +#[test] +fn typing_finds_and_keys_pick() { + let Library { mut h, excluded, uncurated, trip, .. } = library(); + click(&mut h, "albumPicker:rules-0"); + let r = h.request("ui.text", json!({"text": "utils"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(has(&h, &format!("albumPickerItem:{excluded}:rules-0")) && has(&h, &format!("albumPickerItem:{uncurated}:rules-0"))); + assert!(!has(&h, &format!("albumPickerItem:{trip}:rules-0")), "Trip doesn't match"); + for key in ["ArrowDown", "Enter"] { + let r = h.request("ui.key", json!({"key": key}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_eq!(value(&h), json!(uncurated), "the second match"); + assert!(h.app.ui.dialog.is_some(), "Enter picked, it didn't confirm the dialog"); +} + +/// Albums that can't be picked (the one being edited, one that would loop) are shown greyed, not +/// hidden, and clicking them does nothing. +#[test] +fn albums_that_would_loop_are_greyed() { + let Library { mut h, excluded, .. } = library(); + let rules = json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": excluded}]}}); + let travel = id(exec(&mut h, "album.createSmart", json!({"name": "Travel", "rules": rules}))); + exec(&mut h, "dialog.smartAlbum", json!({"id": excluded})); + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &mut h.app.ui.dialog else { panic!("no rule editor") }; + rules.rules = vec![serde_json::from_value(json!({"field": "album", "op": "is", "value": null})).unwrap()]; + h.settle(SETTLE); + click(&mut h, "albumPicker:rules-0"); + assert!(has(&h, &format!("albumPickerItem:{travel}:rules-0")), "shown"); + click(&mut h, &format!("albumPickerItem:{travel}:rules-0")); + assert_eq!(value(&h), json!(null), "but not picked"); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 5cf53d1c..fdf35139 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -392,13 +392,13 @@ fn smart_album_editor_picks_an_album_from_a_list() { rules.rules[0] = serde_json::from_value(json!({"field": "album", "op": "is", "value": 0})).unwrap(); h.settle(SETTLE); assert!(h.app.widgets.iter().any(|(w, _)| w == "ruleProblem:rules-0"), "no album chosen yet"); - let r = h.request("ui.clickWidget", json!({"id": "ruleAlbum:rules-0"}), T); + let r = h.request("ui.clickWidget", json!({"id": "albumPicker:rules-0"}), T); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); let has = |h: &Headless, id: String| h.app.widgets.iter().any(|(w, _)| *w == id); - assert!(has(&h, format!("ruleAlbumItem:{trip}:rules-0")), "plain albums are offered"); - assert!(has(&h, format!("ruleAlbumItem:{smart}:rules-0")), "smart albums too"); - let r = h.request("ui.clickWidget", json!({"id": format!("ruleAlbumItem:{trip}:rules-0")}), T); + assert!(has(&h, format!("albumPickerItem:{trip}:rules-0")), "plain albums are offered"); + assert!(has(&h, format!("albumPickerItem:{smart}:rules-0")), "smart albums too"); + let r = h.request("ui.clickWidget", json!({"id": format!("albumPickerItem:{trip}:rules-0")}), T); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") }; @@ -480,8 +480,8 @@ fn sidebar_marks_smart_albums_with_problems() { assert!(marked(&h), "its album is gone"); } -/// Editing "Excluded Photos" while "Travel" tests it: the list offers neither the album itself nor -/// Travel (testing it would loop back), and a rule set to Travel by other means is marked. +/// Editing "Excluded Photos" while "Travel" tests it: the list shows the album itself and Travel +/// greyed (testing Travel would loop back), and a rule set to Travel by other means is marked. #[test] fn smart_album_editor_keeps_albums_from_including_themselves() { let mut h = detail("panel.edit"); @@ -496,13 +496,19 @@ fn smart_album_editor_keeps_albums_from_including_themselves() { h.settle(SETTLE); }; set(&mut h, json!(null)); - let r = h.request("ui.clickWidget", json!({"id": "ruleAlbum:rules-0"}), T); + let r = h.request("ui.clickWidget", json!({"id": "albumPicker:rules-0"}), T); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); let has = |h: &Headless, id: String| h.app.widgets.iter().any(|(w, _)| *w == id); - assert!(has(&h, format!("ruleAlbumItem:{trip}:rules-0"))); - assert!(!has(&h, format!("ruleAlbumItem:{excluded}:rules-0")), "not itself"); - assert!(!has(&h, format!("ruleAlbumItem:{travel}:rules-0")), "not an album that tests it"); + assert!(has(&h, format!("albumPickerItem:{trip}:rules-0"))); + // itself and an album that tests it are shown, greyed: clicking them picks nothing + for blocked in [excluded, travel] { + let r = h.request("ui.clickWidget", json!({"id": format!("albumPickerItem:{blocked}:rules-0")}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") }; + assert!(matches!(&rules.rules[0], Rule::Field { value, .. } if value.is_null()), "{blocked} wasn't picked"); + } let _ = h.request("ui.key", json!({"key": "Escape"}), T); h.settle(SETTLE); set(&mut h, json!(travel)); From 4598d2c1861e69f3dbe0c9ea2facf86187a17785 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:25:35 -0400 Subject: [PATCH 55/65] LRC-LIB-SMARTCOLL: document the album picker Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 2 +- docs/whats-new.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index 93a8883c..a0622e71 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`; no 30 February; a time zone or fractions of a second are refused, since capture times are local and to the second; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album from a list of the albums and smart albums, named with their folder and sorted by name (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`; no 30 February; a time zone or fractions of a second are refused, since capture times are local and to the second; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | diff --git a/docs/whats-new.md b/docs/whats-new.md index dcf3839a..cfe83483 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -18,8 +18,9 @@ album that is gone, or an empty group is reported with its rule ("rule 2.1: rating is 0–5, not 9") instead of quietly matching nothing or everything. New date rules start at the current year. - A smart album can include or exclude another smart album: "Keywords contain travel" and "Album isn't Excluded Photos" - leaves out whatever Excluded Photos matches, as its rules change. The album list offers smart albums under their own - heading; a rule that would make an album include itself is refused. Esc with a dropdown or calendar open in a dialog + leaves out whatever Excluded Photos matches, as its rules change. The album picker shows your albums as in the sidebar + (folders open on click) or finds them as you type part of a name or folder (↑ / ↓ and Enter to pick); albums that + can't be picked, such as one that would make an album include itself, are greyed with the reason. Esc with a dropdown or calendar open in a dialog closes just that. - Date rules have a calendar button: pick a day, a month or a whole year (Year / Month / Day), in your language; the two dates of a "between" can't be picked out of order; Esc closes just the calendar. It picks dates, not times (a From a51224e6d44639b7cf71d62b0f2f482942732bc3 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:34:56 -0400 Subject: [PATCH 56/65] LRC-LIB-SMARTCOLL: smart albums testing smart albums stay linear From final review: the loop guard bounded the depth of smart albums testing smart albums, but not repeated work. Each album tested by the next one twice ("all of: album is X(k-1), album is X(k-1)") doubled the cost per level, so 25 levels meant 2^25 evaluations per photo, enough to freeze the sidebar's counts. Any agent or catalog file could build such a chain. rules::smart_album_holds remembers each smart album's answer for each photo during one outermost question, and forgets them when it ends (a drop guard, however it ends). A regression test counts a 26-deep doubled chain in well under its 2 s limit; before the fix it ran for minutes. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/lib.rs | 2 +- crates/catalog/src/rules.rs | 31 +++++++++++++++++++++++++++++++ crates/catalog/src/tests.rs | 22 ++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 10ca3617..15914442 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -325,7 +325,7 @@ impl Catalog { pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool { match self.albums.get(&id) { // guarded: a smart album testing smart albums can't loop or recurse without end - Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::evaluating(id, || rules.matches(p, self)).unwrap_or(false), + Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches(p, self)), Some(a) => a.photos.contains(&p.id), None => false, } diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 9cea37f0..1fda4b0b 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -413,6 +413,37 @@ thread_local! { static EVALUATING: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; } +thread_local! { + /// While one outermost question is answered (is this photo in album X?): what each smart album + /// it leads to answered for each photo, so an album tested many times along the way (X tests Y + /// twice, Y tests Z twice…) is worked out once, not 2^depth times. Cleared when it ends. + static ANSWERS: std::cell::RefCell> = + std::cell::RefCell::new(std::collections::HashMap::new()); +} + +/// Whether `photo` is in smart album `id`, by `matches` (its rules), remembered for the rest of the +/// outermost question and guarded by [`evaluating`] (a loop or a chain too deep holds nothing). +pub(crate) fn smart_album_holds(id: crate::AlbumId, photo: crate::PhotoId, matches: impl FnOnce() -> bool) -> bool { + if let Some(known) = ANSWERS.with_borrow(|a| a.get(&(id, photo)).copied()) { + return known; + } + // the outermost question forgets the answers when it ends, however it ends + struct Forget(bool); + impl Drop for Forget { + fn drop(&mut self) { + if self.0 { + ANSWERS.with_borrow_mut(std::collections::HashMap::clear); + } + } + } + let _forget = Forget(EVALUATING.with_borrow(Vec::is_empty)); + let answer = evaluating(id, matches); + if let Some(a) = answer { + ANSWERS.with_borrow_mut(|known| known.insert((id, photo), a)); + } + answer.unwrap_or(false) +} + /// How deep smart albums may test smart albums before the innermost counts as holding nothing. const MAX_ALBUM_DEPTH: usize = 32; diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 354d046e..57195804 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -565,3 +565,25 @@ fn summaries_name_albums() { let f = Filter { rule_set: Some(rules), ..Default::default() }; assert!(f.describe_with(&c).contains("album isn't “Excluded Photos”")); } + +/// A chain of smart albums each testing the one before twice ("all of: album is X(k-1), album is +/// X(k-1)") costs a pass per album, not 2^depth: each album's answer for a photo is worked out once +/// per question. 25 albums deep stays instant. +#[test] +fn chains_of_smart_albums_stay_linear() { + let mut c = Catalog::new(); + let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "X") }; + c.apply(Op::AddAlbum { album: smart(1, Filter::default()) }).unwrap(); + for k in 2..=26u64 { + let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [ + {"field": "album", "op": "is", "value": k - 1}, {"field": "album", "op": "is", "value": k - 1}]}})) + .unwrap(); + c.apply(Op::AddAlbum { album: smart(k, f) }).unwrap(); + } + for i in 0..20 { + photo(&mut c, &format!("p{i}.jpg"), "2026-09-01T10:00:00"); + } + let start = std::time::Instant::now(); + assert_eq!(c.album_count(AlbumId(26)), 20); + assert!(start.elapsed() < std::time::Duration::from_secs(2), "took {:?}", start.elapsed()); +} From 0a2ece4491f7a5770285e4184052602fb2ec427d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:36:11 -0400 Subject: [PATCH 57/65] LRC-LIB-SMARTCOLL: a rule that stopped checking doesn't block other changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From final review: library.filter and album.setRules (without replace) merged a change onto the current rules and re-checked all of them. Once a rule stopped checking later (the album it tests was deleted, a custom label renamed), every later change was refused: the search box, keyword clicks and the filter bar silently stopped working, since the UI doesn't show those errors, and partial edits of the album were refused. Only rules a change sets ("ruleSet" present) are checked now. Rules that went stale still show in the dialog and the sidebar's ⚠ until they're fixed. A regression test deletes the album a filter rule tests, then changes the rating, edits the album, and still gets refused for a bad new rule. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 14 ++++++++++---- crates/engine/src/tests_library.rs | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index caa53611..50e8c1bb 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -330,11 +330,14 @@ pub fn specs() -> Vec { if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder { return Err(bad("library.filter", "a folder is already shown (library.source): show another source first")); } - // checked like a smart album's rules: an unknown field or a value that can't match is an error, not an empty grid + // checked like a smart album's rules when this call sets them: an unknown field or a value + // that can't match is an error, not an empty grid. Rules that stopped checking since + // (their album was deleted) don't block other filter changes. if let Some(rules) = f.rule_set.as_mut() { rules.upgrade(); } - if let Some(problems) = f.rule_set.as_ref().map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) { + let sets_rules = p.get("ruleSet").is_some_and(|r| !r.is_null()); + if let Some(problems) = f.rule_set.as_ref().filter(|_| sets_rules).map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) { return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::>().join("; "))); } s.filter = f; @@ -1315,7 +1318,7 @@ fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Re Ok(f) } -/// `patch` merged onto `base`, refused when its rule set has problems (`RuleSet::check_for`; +/// `patch` merged onto `base`, refused when the rule set it sets has problems (`RuleSet::check_for`; /// `owner` is the smart album the rules are for, so testing an album that leads back to it is a /// problem too). fn merge_rules( @@ -1326,7 +1329,10 @@ fn merge_rules( owner: Option, ) -> Result { let f = merge_filter(base, patch, c)?; - let problems = f.rule_set.as_ref().map(|r| r.check_for(cat, owner)).unwrap_or_default(); + // only rules this change sets are checked: one that stopped checking since (its album was + // deleted) doesn't block editing the album's other settings + let sets_rules = patch.get("ruleSet").is_some_and(|r| !r.is_null()); + let problems = f.rule_set.as_ref().filter(|_| sets_rules).map(|r| r.check_for(cat, owner)).unwrap_or_default(); if !problems.is_empty() { return Err(bad(c, problems.iter().map(ToString::to_string).collect::>().join("; "))); } diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 4dcf2ba9..9475ae21 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -637,3 +637,26 @@ fn rules_from_the_view_cant_loop() { assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(b)).unwrap().smart, before, "B unchanged"); assert!(s.catalog.smart_album_problems(lightcraft_catalog::AlbumId(b)).is_empty()); } + +/// A rule that stops checking later (the album it tests is deleted) doesn't lock up what doesn't +/// touch it: the filter bar keeps working, and an album's other settings can still be edited. A +/// change to the rules themselves is still checked. +#[test] +fn a_stale_rule_doesnt_block_other_changes() { + let mut s = crate::Session::with_demo(); + let trip = s.execute("album.create", &serde_json::json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap(); + let rules = serde_json::json!({"rules": [{"field": "album", "op": "isNot", "value": trip}]}); + s.execute("library.filter", &serde_json::json!({"ruleSet": rules})).unwrap(); + let smart = + s.execute("album.createSmart", &serde_json::json!({"name": "Not Trip", "rules": {"ruleSet": rules}})).unwrap()["id"].as_u64().unwrap(); + s.execute("album.delete", &serde_json::json!({"id": trip})).unwrap(); + // the filter bar: a rating still applies on top of the stale rule + s.execute("library.filter", &serde_json::json!({"rating": 3})).unwrap(); + assert_eq!(s.filter.rating, 3); + // the album: a partial edit that leaves its rules alone + s.execute("album.setRules", &serde_json::json!({"id": smart, "rules": {"rating": 2}})).unwrap(); + // changing the rules is still checked + let bad = serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "gte", "value": 9}]}}); + assert!(s.execute("library.filter", &bad).is_err()); + assert!(s.execute("album.setRules", &serde_json::json!({"id": smart, "rules": bad})).is_err()); +} From 1c5e51faf987cd7e29e0a6e04bc29ad619f17849 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:39:13 -0400 Subject: [PATCH 58/65] LRC-LIB-SMARTCOLL: loops through a smart album's album filter count too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From final review: loop detection only followed Album rules. A smart album's own album field (Filter.album, as set by the filter bar or album.setRules {rules: {album}}) was ignored, so "album.setRules {id: A, rules: {album: A}}" was accepted and A became silently empty. On main it would have recursed to a stack overflow; the guard already stops that. Filter::albums_tested covers both, and album_reaches follows it. Catalog::album_filter_problem reports a loop through the album field; smart_album_problems (sidebar ⚠, albums.list) and album.setRules (including fromView) use it. A problem with no rule position displays without the "rule …:" / "#…" prefix. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/lib.rs | 30 ++++++++++++++++++++---------- crates/catalog/src/query.rs | 7 +++++++ crates/catalog/src/rules.rs | 3 ++- crates/catalog/src/tests.rs | 22 ++++++++++++++++++++++ crates/engine/src/cmd/library.rs | 11 ++++++++--- crates/engine/src/tests_library.rs | 10 ++++++++++ crates/ui-egui/src/i18n.rs | 8 ++++++++ 7 files changed, 77 insertions(+), 14 deletions(-) diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 15914442..0e73e258 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -341,13 +341,7 @@ impl Catalog { if !seen.insert(a) { continue; } - let tested = self - .albums - .get(&a) - .and_then(|al| al.smart.as_ref()) - .and_then(|f| f.rule_set.as_ref()) - .map(rules::RuleSet::albums_tested) - .unwrap_or_default(); + let tested = self.albums.get(&a).and_then(|al| al.smart.as_deref()).map(Filter::albums_tested).unwrap_or_default(); if tested.contains(&to) { return true; } @@ -360,9 +354,25 @@ impl Catalog { /// `RuleSet::upgrade`): typically a rule testing an album that has since been deleted. Empty /// for a sound smart album, a plain album or no album. pub fn smart_album_problems(&self, id: AlbumId) -> Vec { - let Some(mut rules) = self.albums.get(&id).and_then(|a| a.smart.as_ref()).and_then(|f| f.rule_set.clone()) else { return Vec::new() }; - rules.upgrade(); - rules.check_for(self, Some(id)) + let Some(filter) = self.albums.get(&id).and_then(|a| a.smart.as_deref()) else { return Vec::new() }; + let mut out: Vec = self.album_filter_problem(filter, Some(id)).into_iter().collect(); + if let Some(mut rules) = filter.rule_set.clone() { + rules.upgrade(); + out.extend(rules.check_for(self, Some(id))); + } + out + } + + /// A loop through `filter`'s own album field (not its rules): smart album `owner` filtered to + /// itself, or to an album that leads back to it. + pub fn album_filter_problem(&self, filter: &Filter, owner: Option) -> Option { + let (a, owner) = (filter.album?, owner?); + (a == owner || self.album_reaches(a, owner)).then(|| rules::Problem { + path: Vec::new(), + field: Some("album".into()), + issue: rules::Issue::AlbumLoop, + message: format!("its album filter (album {}) would make this album include itself", a.0), + }) } /// The photos of an album: the stored list, or a smart album's current matches (id order). diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 14a36950..d3c4e308 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -221,6 +221,13 @@ impl Filter { if v.is_empty() { "all photos".into() } else { v.join(", ") } } + /// The albums this filter tests: its album field and its rules' Album rules. + pub fn albums_tested(&self) -> Vec { + let mut out: Vec = self.album.into_iter().collect(); + out.extend(self.rule_set.as_ref().map(crate::RuleSet::albums_tested).unwrap_or_default()); + out + } + /// Whether matches depend on the clock (only "in the last…" rules do). pub fn depends_on_now(&self) -> bool { self.rule_set.as_ref().is_some_and(crate::RuleSet::depends_on_now) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 1fda4b0b..5af6de2d 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -164,7 +164,8 @@ impl std::fmt::Display for Problem { /// `rule 2.1: rating is 0–5, not 9` (counting from 1, as people do). fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let at: Vec = self.path.iter().map(|i| (i.saturating_add(1)).to_string()).collect(); - write!(f, "rule {}: {}", at.join("."), self.message) + // a problem with the filter around the rules (its album field) has no rule to point at + if at.is_empty() { write!(f, "{}", self.message) } else { write!(f, "rule {}: {}", at.join("."), self.message) } } } diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 57195804..09cf98f4 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -587,3 +587,25 @@ fn chains_of_smart_albums_stay_linear() { assert_eq!(c.album_count(AlbumId(26)), 20); assert!(start.elapsed() < std::time::Duration::from_secs(2), "took {:?}", start.elapsed()); } + +/// A smart album's own album filter (not only its rules) counts in loops: A filtered to "in A" is +/// reported, and so is A filtered to B while B's rules test A. +#[test] +fn an_album_filter_counts_in_loops() { + let mut c = Catalog::new(); + let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "S") }; + c.apply(Op::AddAlbum { album: smart(1, Filter { album: Some(AlbumId(1)), ..Default::default() }) }).unwrap(); + let loops = |c: &Catalog, id: u64| c.smart_album_problems(AlbumId(id)).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop); + assert!(loops(&c, 1), "filtered to itself"); + let tests_3: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": 3}]}})).unwrap(); + c.apply(Op::AddAlbum { album: smart(2, Filter { album: Some(AlbumId(3)), ..Default::default() }) }).unwrap(); + c.apply(Op::AddAlbum { + album: smart(3, serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": 2}]}})).unwrap()), + }) + .unwrap(); + assert!(c.album_reaches(AlbumId(3), AlbumId(2)) && c.album_reaches(AlbumId(2), AlbumId(3))); + assert!(loops(&c, 2) && loops(&c, 3)); + let _ = tests_3; + let shown: Vec = c.smart_album_problems(AlbumId(1)).iter().map(ToString::to_string).collect(); + assert!(shown.iter().all(|s| !s.starts_with("rule :")), "{shown:?}"); +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 50e8c1bb..e18bb91e 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -871,9 +871,10 @@ pub fn specs() -> Vec { .as_ref() .map(|r| r.check_for(&s.catalog, Some(id))) .unwrap_or_default() - .iter() + .into_iter() + .chain(s.catalog.album_filter_problem(&view, Some(id))) .filter(|p| p.issue == lightcraft_catalog::rules::Issue::AlbumLoop) - .map(ToString::to_string) + .map(|p| p.to_string()) .collect(); if !loops.is_empty() { return Err(bad("album.setRules", loops.join("; "))); @@ -1332,7 +1333,11 @@ fn merge_rules( // only rules this change sets are checked: one that stopped checking since (its album was // deleted) doesn't block editing the album's other settings let sets_rules = patch.get("ruleSet").is_some_and(|r| !r.is_null()); - let problems = f.rule_set.as_ref().filter(|_| sets_rules).map(|r| r.check_for(cat, owner)).unwrap_or_default(); + let mut problems = f.rule_set.as_ref().filter(|_| sets_rules).map(|r| r.check_for(cat, owner)).unwrap_or_default(); + // and a loop through the album field the change sets + if patch.get("album").is_some_and(|a| !a.is_null()) { + problems.extend(cat.album_filter_problem(&f, owner)); + } if !problems.is_empty() { return Err(bad(c, problems.iter().map(ToString::to_string).collect::>().join("; "))); } diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 9475ae21..fd07fa00 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -660,3 +660,13 @@ fn a_stale_rule_doesnt_block_other_changes() { assert!(s.execute("library.filter", &bad).is_err()); assert!(s.execute("album.setRules", &serde_json::json!({"id": smart, "rules": bad})).is_err()); } + +/// album.setRules refuses a loop through the album filter as well as through the rules. +#[test] +fn an_album_filter_loop_is_refused() { + let mut s = crate::Session::with_demo(); + let a = s.execute("album.createSmart", &serde_json::json!({"name": "A", "rules": {"rating": 2}})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("album.setRules", &serde_json::json!({"id": a, "rules": {"album": a}})); + assert!(r.is_err_and(|e| e.to_string().contains("include itself")), "refused"); + assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(a)).unwrap().smart.as_ref().and_then(|f| f.album), None); +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4ed092e5..af6045e5 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -352,6 +352,10 @@ fn problem_text_in(language: Locale, problem: &lightcraft_catalog::rules::Proble fn problem_line_in(language: Locale, problem: &lightcraft_catalog::rules::Problem) -> String { let at: Vec = problem.path.iter().map(|i| i.saturating_add(1).to_string()).collect(); + // a problem with the album filter around the rules has no rule to point at + if at.is_empty() { + return problem_text_in(language, problem); + } format!("#{} {}", at.join("."), problem_text_in(language, problem)) } @@ -761,6 +765,10 @@ mod tests { assert_eq!(problem_line_in(Locale::En, &p[0]), "#2 Title: needs something to look for (or use “is empty”)"); // Japanese and Chinese join with a full-width colon, as their catalogs do assert_eq!(problem_text_in(Locale::Ja, &p[0]), "タイトル:検索する語句が必要です(または「が空」を使用)"); + // a problem with the album filter itself has no rule position to show + let filter_loop = + lightcraft_catalog::rules::Problem { path: Vec::new(), field: Some("album".into()), issue: Issue::AlbumLoop, message: String::new() }; + assert!(problem_line_in(Locale::En, &filter_loop).starts_with("Album: "), "{}", problem_line_in(Locale::En, &filter_loop)); let g = problems(serde_json::json!([{"group": {"rules": []}}])); assert_eq!(problem_text_in(Locale::En, &g[0]), "This group is empty: add a rule or remove it."); // the issues, the operators their hints name and the album picker's words, in every language From cf496fa7336742a2a514942be9315322eba3bdaa Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:40:50 -0400 Subject: [PATCH 59/65] LRC-LIB-SMARTCOLL: rule dates accept fractions and time zones after the seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From final review: I had refused a time zone or fractions of a second in rule dates on the premise that capture times are stored local and to the second. They aren't. Imports write DateTime::to_iso, which carries EXIF SubSecTime and OffsetTime ("2026-08-14T10:00:00.250+02:00"), so a rule like "is 2026-08-14T10:00:00.250" did match those photos and was wrongly refused; saved albums holding such dates would have shown ⚠ too. rule_date now accepts, after whole seconds, a fraction (1–9 digits) and a zone (Z or ±hh:mm). Anywhere else ("2026-08-14Z") they are no date. Issue::DateWithZone and its translations are gone; such values are NeedsDate. The test matches a photo captured with a fraction and a zone, and the docs no longer say zones are refused. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/rules.rs | 72 +++++++++++++++++++---------- crates/ui-egui/locales/de.json | 1 - crates/ui-egui/locales/es.json | 1 - crates/ui-egui/locales/ja.json | 1 - crates/ui-egui/locales/pt-br.json | 1 - crates/ui-egui/locales/ru.json | 1 - crates/ui-egui/locales/zh-hans.json | 1 - crates/ui-egui/locales/zh-hant.json | 1 - docs/parity.md | 2 +- docs/whats-new.md | 2 +- 10 files changed, 50 insertions(+), 33 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index 5af6de2d..a77a2f64 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -179,7 +179,6 @@ pub enum Issue { NeedsNumber, NeedsShutterSpeed, NeedsDate, - DateWithZone, DatesReversed, NeedsTwoValues, NeedsCount, @@ -200,14 +199,13 @@ pub enum Issue { } impl Issue { - pub const ALL: [Issue; 23] = [ + pub const ALL: [Issue; 22] = [ Issue::UnknownField, Issue::NoSuchOperator, Issue::NotYesNo, Issue::NeedsNumber, Issue::NeedsShutterSpeed, Issue::NeedsDate, - Issue::DateWithZone, Issue::DatesReversed, Issue::NeedsTwoValues, Issue::NeedsCount, @@ -235,7 +233,6 @@ impl Issue { Issue::NeedsNumber => "needs a number", Issue::NeedsShutterSpeed => "needs a time like 1/250 or 2", Issue::NeedsDate => "needs a date like 2026, 2026-08 or 2026-08-14", - Issue::DateWithZone => "can't have a time zone or fractions of a second", Issue::DatesReversed => "has the first date after the second", Issue::NeedsTwoValues => "needs two values", Issue::NeedsCount => "needs a number more than 0", @@ -336,10 +333,10 @@ fn album_problem(v: &Value, cat: &Catalog, owner: Option) -> Fou } /// A rule date in its one reading: `2026`, `2026-08`, `2026-08-14`, then optionally `T10`, -/// `T10:00` or `T10:00:00` (a space works as the `T`), and a real date and time (no month 13, no -/// 30 February, no 25 o'clock). Capture times are local and to the second, so a time zone or -/// fractions of a second ([`Issue::DateWithZone`]) could never match; anything else is -/// [`Issue::NeedsDate`]. The check and the matcher both read dates this way. +/// `T10:00` or `T10:00:00` (a space works as the `T`), and after whole seconds a fraction and a +/// time zone as imported capture times carry them (`.250`, `+02:00`, `Z`); a real date and time +/// (no month 13, no 30 February, no 25 o'clock). Anything else is [`Issue::NeedsDate`]. The check +/// and the matcher both read dates this way. pub fn rule_date(s: &str) -> Result { const PATTERN: &str = "dddd-dd-ddTdd:dd:dd"; let s: String = s.trim().char_indices().map(|(i, c)| if i == 10 && c == ' ' { 'T' } else { c }).collect(); @@ -347,17 +344,31 @@ pub fn rule_date(s: &str) -> Result { matches!(t.len(), 4 | 7 | 10 | 13 | 16 | 19) && t.chars().zip(PATTERN.chars()).all(|(c, p)| if p == 'd' { c.is_ascii_digit() } else { c == p }) }; - if !shaped(&s) { - // a whole date and time with something after it: a zone (+02:00, -05:00, Z) or a fraction - let zoned = [19, 16, 13, 10].iter().any(|&n| { - s.get(..n).is_some_and(shaped) - && s.get(n..).and_then(|rest| rest.chars().next()).is_some_and(|c| matches!(c, '+' | '-' | 'Z' | 'z' | '.')) - }); - return Err(if zoned { Issue::DateWithZone } else { Issue::NeedsDate }); + // after whole seconds, a fraction and a zone as imports write them (.250, +02:00 or Z) + let tail_ok = |rest: &str| { + let after_fraction = match rest.strip_prefix('.') { + Some(f) => { + let digits = f.bytes().take_while(u8::is_ascii_digit).count(); + if !(1..=9).contains(&digits) { + return false; + } + f.get(digits..).unwrap_or("") + } + None => rest, + }; + let zone = after_fraction.as_bytes(); + match zone { + [] | [b'Z' | b'z'] => true, + [b'+' | b'-', h1, h2, b':', m1, m2] => [h1, h2, m1, m2].iter().all(|c| c.is_ascii_digit()), + _ => false, + } + }; + if !shaped(&s) && !(s.get(..19).is_some_and(shaped) && s.get(19..).is_some_and(tail_ok)) { + return Err(Issue::NeedsDate); } let part = |a: usize, b: usize| s.get(a..b).and_then(|t| t.parse::().ok()); let time_ok = part(11, 13).is_none_or(|h| h < 24) && part(14, 16).is_none_or(|m| m < 60) && part(17, 19).is_none_or(|x| x < 60); - let day = match s.len() { + let day = match s.len().min(19) { 4 => format!("{s}-01-01"), 7 => format!("{s}-01"), _ => s.get(..10).unwrap_or("").to_string(), @@ -368,7 +379,6 @@ pub fn rule_date(s: &str) -> Result { fn date_problem(field: &str, op: &str, value: &Value) -> Found { let date = |v: &Value| match v.as_str().map(rule_date) { Some(Ok(_)) => None, - Some(Err(Issue::DateWithZone)) => Some((Issue::DateWithZone, format!("`{field}` can't have a time zone or fractions of a second, not {v}"))), _ => Some((Issue::NeedsDate, format!("`{field}` needs a date like 2026, 2026-08 or 2026-08-14, not {v}"))), }; match op { @@ -1570,9 +1580,9 @@ mod tests { } /// A rule date reads one way for the check and the matcher: a space works as the "T" - /// ("2026-10-01 10:00" matches a photo taken then); a time zone or fractions of a second can - /// never match a capture time (stored local, to the second), so they are refused with their - /// own issue rather than accepted and silently matching nothing. + /// ("2026-10-01 10:00" matches a photo taken then). Imported capture times can carry fractions + /// of a second and a time zone (EXIF SubSecTime / OffsetTime: "…T10:00:00.250+02:00"), so a + /// rule may too, after the seconds; anywhere else they are no date. #[test] fn rule_dates_have_one_reading() { let cat = Catalog::new(); @@ -1588,11 +1598,25 @@ mod tests { let r = rule(op, v.clone()); assert!(r.check(&cat).is_empty() && r.matches(&p, &cat), "{op} {v}"); } - for v in ["2026-08-14T10:00:00+02:00", "2026-08-14T10:00:00Z", "2026-08-14T10:00:00.25", "2026-08-14 10:00-05:00", "2026-08-14Z"] { - let p = rule("is", json!(v)).check(&cat); - assert_eq!(p.first().map(|p| p.issue), Some(Issue::DateWithZone), "{v}"); + // a capture time with a fraction and a zone, as imports write it + let mut zoned = photo(2); + zoned.captured = Some("2026-08-14T10:00:00.250+02:00".into()); + for v in ["2026-08-14T10:00:00.250", "2026-08-14T10:00:00.250+02:00", "2026-08-14T10:00:00"] { + let r = rule("is", json!(v)); + assert!(r.check(&cat).is_empty() && r.matches(&zoned, &cat), "{v}"); } - for v in ["2026-8-14", "2026-08-14T1", "2026-08-14X10", "26-08-14", "2026-08-14T25:00"] { + assert!(rule("is", json!("2026-08-14T10:00:00Z")).check(&cat).is_empty()); + for v in [ + "2026-8-14", + "2026-08-14T1", + "2026-08-14X10", + "26-08-14", + "2026-08-14T25:00", + "2026-08-14Z", + "2026-08-14 10:00-05:00", + "2026-08-14T10:00:00.", + "2026-08-14T10:00:00+2", + ] { let p = rule("is", json!(v)).check(&cat); assert_eq!(p.first().map(|p| p.issue), Some(Issue::NeedsDate), "{v}"); } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index bc4ef39b..e1fd8b2f 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1922,7 +1922,6 @@ "This group is empty: add a rule or remove it.": "Diese Gruppe ist leer: Füge eine Regel hinzu oder entferne sie.", "needs something to look for (or use “are empty”)": "braucht einen Suchbegriff (oder „sind leer“)", "needs something to look for (or use “aren't empty”)": "braucht einen Suchbegriff (oder „sind nicht leer“)", - "can't have a time zone or fractions of a second": "darf keine Zeitzone und keine Sekundenbruchteile haben", "Mo": "Mo", "Tu": "Di", "We": "Mi", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 9e30e266..fb15fcc0 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1767,7 +1767,6 @@ "This group is empty: add a rule or remove it.": "Este grupo está vacío: añade una regla o quítalo.", "needs something to look for (or use “are empty”)": "necesita algo que buscar (o usa «están vacíos»)", "needs something to look for (or use “aren't empty”)": "necesita algo que buscar (o usa «no están vacíos»)", - "can't have a time zone or fractions of a second": "no puede tener zona horaria ni fracciones de segundo", "Mo": "Lu", "Tu": "Ma", "We": "Mi", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index dfc6405a..d0ab556c 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1771,7 +1771,6 @@ "This group is empty: add a rule or remove it.": "このグループは空です。条件を追加するか、グループを削除してください。", "needs something to look for (or use “are empty”)": "検索する語句が必要です(または「が空」を使用)", "needs something to look for (or use “aren't empty”)": "検索する語句が必要です(または「が空でない」を使用)", - "can't have a time zone or fractions of a second": "タイムゾーンや秒未満の値は使えません", "Mo": "月", "Tu": "火", "We": "水", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index dd3ef316..36dc4e5f 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1767,7 +1767,6 @@ "This group is empty: add a rule or remove it.": "Este grupo está vazio: adicione uma regra ou remova-o.", "needs something to look for (or use “are empty”)": "precisa de algo para procurar (ou use “estão vazios”)", "needs something to look for (or use “aren't empty”)": "precisa de algo para procurar (ou use “não estão vazios”)", - "can't have a time zone or fractions of a second": "não pode ter fuso horário nem frações de segundo", "Mo": "Seg", "Tu": "Ter", "We": "Qua", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 2b58d95d..9d132ab6 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1459,7 +1459,6 @@ "isn't empty": "не пусто", "isn't in the last": "не за последние", "starts with": "начинается с", - "can't have a time zone or fractions of a second": "не может содержать часовой пояс или доли секунды", "Mo": "Пн", "Tu": "Вт", "We": "Ср", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index adf2f3cf..a1ebcb62 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1767,7 +1767,6 @@ "This group is empty: add a rule or remove it.": "此组为空:请添加规则或将其删除。", "needs something to look for (or use “are empty”)": "需要要查找的内容(或使用“为空”)", "needs something to look for (or use “aren't empty”)": "需要要查找的内容(或使用“不为空”)", - "can't have a time zone or fractions of a second": "不能包含时区或不足一秒的部分", "Mo": "一", "Tu": "二", "We": "三", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 3a93d1ed..4db189ee 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1767,7 +1767,6 @@ "This group is empty: add a rule or remove it.": "此群組為空:請新增規則或將其移除。", "needs something to look for (or use “are empty”)": "需要要尋找的內容(或使用「為空」)", "needs something to look for (or use “aren't empty”)": "需要要尋找的內容(或使用「不為空」)", - "can't have a time zone or fractions of a second": "不能包含時區或不足一秒的部分", "Mo": "一", "Tu": "二", "We": "三", diff --git a/docs/parity.md b/docs/parity.md index a0622e71..8fd49b06 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`; no 30 February; a time zone or fractions of a second are refused, since capture times are local and to the second; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | diff --git a/docs/whats-new.md b/docs/whats-new.md index cfe83483..a8e85ffb 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -25,7 +25,7 @@ - Date rules have a calendar button: pick a day, a month or a whole year (Year / Month / Day), in your language; the two dates of a "between" can't be picked out of order; Esc closes just the calendar. It picks dates, not times (a time is typed, and picking a day replaces one). Typing still works, and "2026-10-01 10:00" (a space for the - T) now matches; a time zone is refused, since capture times are local. + T) now matches. - Rule problems are shown in your language and name the field the way the menu does ("Title: needs something to look for"). - A smart album whose rules no longer check (an album they test was deleted) is marked ⚠ in the sidebar; its tooltip From 11e36bf060ce4a821b1c0d21626533101cd3483f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:42:02 -0400 Subject: [PATCH 60/65] LR-LIB-CAPTURETIME: a shift past year 9999 (or before 0000) is refused, not skipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From final review: photo.setCaptureTime quietly skipped photos whose shifted date would leave years 0000–9999. It reported success, with the photo missing from `captured`. Such a shift is now refused for the whole selection with how many photos it concerns, and nothing changes. A date that doesn't read at all is still skipped, as before. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/manage.rs | 19 +++++++++++++++---- crates/engine/src/tests_library.rs | 5 +++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs index 7232ecdb..768fcf93 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -209,17 +209,28 @@ pub fn specs() -> Vec { } let mut ops = Vec::new(); let mut out = Vec::new(); + let mut out_of_range = 0usize; for id in &targets { - let new = match &each { - Some(t) => shift_iso(t, delta), - None => shift_iso(&base(s, *id), delta), + let from = match &each { + Some(t) => t.clone(), + None => base(s, *id), + }; + let Some(new) = shift_iso(&from, delta) else { + // a date that reads but would leave years 0000–9999 is refused below; one + // that doesn't read is skipped, as before + if iso_seconds(&from).is_some() { + out_of_range += 1; + } + continue; }; - let Some(new) = new else { continue }; out.push(json!(new)); if s.catalog.photo(*id).and_then(|p| p.captured.as_deref()) != Some(new.as_str()) { ops.push(Op::SetCaptured { id: *id, captured: Some(new) }); } } + if out_of_range > 0 { + return Err(bad(c, format!("the shift would take {out_of_range} photo(s) outside the years 0000–9999; nothing was changed"))); + } let n = ops.len(); if n > 0 { s.commit("Edit Capture Time", Op::Batch { ops })?; diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index fd07fa00..8a2599e5 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -492,6 +492,11 @@ fn capture_time_shift_is_bounded() { } s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [id.0], "hours": 1})).unwrap(); assert_ne!(s.catalog.photo(id).unwrap().captured, before); + // a shift that would take a photo past year 9999 says so, rather than skipping it quietly + s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [id.0], "time": "9999-12-31T12:00:00"})).unwrap(); + let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [id.0], "hours": 24})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("9999")), "{r:?}"); + assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("9999-12-31T12:00:00"), "unchanged"); } /// Values are checked too: an agent's rule set with a date that isn't one, or a rating of 9, is From a484c66c02835024af0c709aa1f74076d829e726 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:46:46 -0400 Subject: [PATCH 61/65] LRC-LIB-SMARTCOLL: album picker: fast with big libraries, keeps the keyboard, scrolls freely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From final review: - Cost: ancestors and paths were found by scanning every album for each row, which grows with the square of the album count (about 1.1 s for 20,000 albums). An id→position index built once per call or frame makes it cost the folder depth. A test lays out and searches 20,000 albums in nested folders well under its 1 s limit. - Focus: clicking a folder, or pressing Enter when nothing can be picked, took the keyboard from the search box, so typing went nowhere. Both hand it back; a headless test types after a folder click. - Scrolling: the highlighted result was scrolled into view every frame, fighting the mouse wheel. It now scrolls only when ↑ / ↓ moved to it. The headless harness stops rendering once input is handled, so this snap-back can't be reproduced there; a test I wrote passed with and without the fix and was dropped rather than kept as a false guard. - Folder rows carry widget info (name, open or not) for assistive tools. Dead code (an unused stack) and a test assertion that couldn't fail are gone. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/album_picker.rs | 112 +++++++++++++++++------ crates/ui-egui/src/tests_album_picker.rs | 14 ++- 2 files changed, 99 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/album_picker.rs b/crates/ui-egui/src/album_picker.rs index 7800363c..72ea3f22 100644 --- a/crates/ui-egui/src/album_picker.rs +++ b/crates/ui-egui/src/album_picker.rs @@ -37,7 +37,6 @@ pub fn entries_from(cat: &lightcraft_catalog::Catalog, blocked: impl Fn(&lightcr let kids = cat.album_children_by_parent(); let mut out = Vec::new(); // depth-first, guarded against a parent loop in a damaged catalog - let mut stack: Vec<(Option, usize)> = vec![(None, 0)]; let mut seen = std::collections::HashSet::new(); fn walk( parent: Option, @@ -68,14 +67,43 @@ pub fn entries_from(cat: &lightcraft_catalog::Catalog, blocked: impl Fn(&lightcr } } } - stack.clear(); walk(None, &kids, &blocked, &mut seen, &mut out, 0); out } +/// Where each id sits in `entries`, so walking up the folders costs their depth, not a scan. +fn index(entries: &[AlbumEntry]) -> std::collections::HashMap { + entries.iter().enumerate().map(|(i, e)| (e.id, i)).collect() +} + +/// [`ancestors`] with a prebuilt [`index`]. +fn ancestors_in(entries: &[AlbumEntry], by_id: &std::collections::HashMap, id: u64) -> Vec { + let mut out = Vec::new(); + let mut at = by_id.get(&id).and_then(|&i| entries.get(i)).and_then(|e| e.parent); + while let Some(p) = at { + // a parent loop in a damaged catalog ends the walk + if out.contains(&p) || out.len() > 64 { + break; + } + out.push(p); + at = by_id.get(&p).and_then(|&i| entries.get(i)).and_then(|e| e.parent); + } + out +} + +/// [`path_label`] with a prebuilt [`index`]. +fn path_in(entries: &[AlbumEntry], by_id: &std::collections::HashMap, id: u64) -> String { + let Some(entry) = by_id.get(&id).and_then(|&i| entries.get(i)) else { return String::new() }; + let mut parts: Vec<&str> = + ancestors_in(entries, by_id, id).iter().rev().filter_map(|p| by_id.get(p).and_then(|&i| entries.get(i))).map(|e| e.name.as_str()).collect(); + parts.push(&entry.name); + parts.join(" / ") +} + /// Fill in each entry's depth from its parents (for lists built by hand). pub fn set_depths(entries: &mut [AlbumEntry]) { - let depths: Vec = entries.iter().map(|e| ancestors(entries, e.id).len()).collect(); + let by_id = index(entries); + let depths: Vec = entries.iter().map(|e| ancestors_in(entries, &by_id, e.id).len()).collect(); for (e, d) in entries.iter_mut().zip(depths) { e.depth = d; } @@ -83,30 +111,19 @@ pub fn set_depths(entries: &mut [AlbumEntry]) { /// The folders holding album `id`, nearest first; empty at the top level or for an unknown id. pub fn ancestors(entries: &[AlbumEntry], id: u64) -> Vec { - let mut out = Vec::new(); - let mut at = entries.iter().find(|e| e.id == id).and_then(|e| e.parent); - while let Some(p) = at { - if out.contains(&p) || out.len() > 64 { - break; - } - out.push(p); - at = entries.iter().find(|e| e.id == p).and_then(|e| e.parent); - } - out + ancestors_in(entries, &index(entries), id) } /// "Folder / Sub / Name" for album `id`; empty for an unknown id. pub fn path_label(entries: &[AlbumEntry], id: u64) -> String { - let Some(entry) = entries.iter().find(|e| e.id == id) else { return String::new() }; - let mut parts: Vec<&str> = - ancestors(entries, id).iter().rev().filter_map(|p| entries.iter().find(|e| e.id == *p)).map(|e| e.name.as_str()).collect(); - parts.push(&entry.name); - parts.join(" / ") + path_in(entries, &index(entries), id) } /// The rows a tree shows with the folders in `open` open: indexes into `entries`. pub fn visible_tree(entries: &[AlbumEntry], open: &[u64]) -> Vec { - (0..entries.len()).filter(|&i| ancestors(entries, entries[i].id).iter().all(|a| open.contains(a))).collect() + let by_id = index(entries); + let open: std::collections::HashSet = open.iter().copied().collect(); + (0..entries.len()).filter(|&i| ancestors_in(entries, &by_id, entries[i].id).iter().all(|a| open.contains(a))).collect() } /// The albums (not folders) whose path holds every word of `query`, any case: those whose own @@ -117,10 +134,11 @@ pub fn search(entries: &[AlbumEntry], query: &str) -> Vec { if words.is_empty() { return Vec::new(); } + let by_id = index(entries); let hits: Vec = (0..entries.len()) .filter(|&i| !entries[i].folder) .filter(|&i| { - let path = path_label(entries, entries[i].id).to_lowercase(); + let path = path_in(entries, &by_id, entries[i].id).to_lowercase(); words.iter().all(|w| path.contains(w)) }) .collect(); @@ -201,7 +219,9 @@ impl<'a> AlbumPicker<'a> { state.highlight = 0; } let t = crate::theme::Tokens::get(ui.ctx()); - let row = |ui: &mut Ui, e: &AlbumEntry, label: &str, indent: f32, highlighted: bool, picked: &mut Option| { + // `scroll`: bring the row into view (the arrow keys just moved to it); never every frame, + // which would fight the mouse wheel + let row = |ui: &mut Ui, e: &AlbumEntry, label: &str, indent: f32, highlighted: bool, scroll: bool, picked: &mut Option| { ui.horizontal(|ui| { ui.add_space(indent); let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover()); @@ -216,7 +236,7 @@ impl<'a> AlbumPicker<'a> { if resp.clicked() { *picked = Some(e.id); } - if highlighted { + if scroll { resp.scroll_to_me(None); } }); @@ -246,7 +266,12 @@ impl<'a> AlbumPicker<'a> { t.text_label, ); register(ui.ctx(), format!("albumPickerFolder:{}:{id}", e.id), r.rect); + // what assistive tools announce: the folder, and whether it is open + let name = e.name.clone(); + r.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, &name)); if r.clicked() { + // the click took the keyboard from the search box: give it back + state.focus = true; if open { state.open.retain(|o| *o != e.id); } else { @@ -255,7 +280,7 @@ impl<'a> AlbumPicker<'a> { } } else { // past the triangle column, so names line up with folder names - row(ui, e, &e.name, indent + 14.0, false, &mut picked); + row(ui, e, &e.name, indent + 14.0, false, false, &mut picked); } } if entries.iter().all(|e| e.folder) { @@ -279,12 +304,25 @@ impl<'a> AlbumPicker<'a> { state.highlight = state.highlight.saturating_sub(1); } let highlighted = pickable.get(state.highlight).copied(); - if enter && let Some(i) = highlighted { - picked = Some(entries[i].id); + if enter { + match highlighted { + Some(i) => picked = Some(entries[i].id), + // Enter took the keyboard from the search box and picked nothing: give it back + None => state.focus = true, + } } + let by_id = index(entries); for i in &hits { let e = &entries[*i]; - row(ui, e, &path_label(entries, e.id), 0.0, highlighted == Some(*i), &mut picked); + row( + ui, + e, + &path_in(entries, &by_id, e.id), + 0.0, + highlighted == Some(*i), + (up || down) && highlighted == Some(*i), + &mut picked, + ); } if hits.is_empty() { ui.label(RichText::new(crate::i18n::tr("No albums match")).weak()); @@ -352,6 +390,28 @@ mod tests { assert_eq!(ids(&[1, 5]), vec![1, 2, 3, 4, 5, 6]); } + /// Big libraries stay quick: 20,000 albums in nested folders are laid out and searched in well + /// under a second (folder paths are worked out once, not per row by scanning every album). + #[test] + fn many_albums_stay_quick() { + let mut v = Vec::new(); + let mut paths = Vec::new(); + for f in 0..200u64 { + v.push(e(f * 101 + 1, &format!("Folder {f}"), None, true, false)); + for a in 0..100u64 { + v.push(e(f * 101 + 2 + a, &format!("Wedding {f}-{a}"), Some(f * 101 + 1), false, a % 2 == 0)); + paths.push(format!("folder {f} / wedding {f}-{a}")); + } + } + let want = paths.iter().filter(|p| ["folder", "7", "wedding"].iter().all(|w| p.contains(w))).count(); + let start = std::time::Instant::now(); + set_depths(&mut v); + let open: Vec = (0..200u64).map(|f| f * 101 + 1).collect(); + assert_eq!(visible_tree(&v, &open).len(), v.len()); + assert_eq!(search(&v, "folder 7 wedding").len(), want); + assert!(start.elapsed() < std::time::Duration::from_secs(1), "took {:?}", start.elapsed()); + } + /// Every word typed is in the album's path (folders included), in any case; names that start /// with it come first; folders themselves aren't results. #[test] diff --git a/crates/ui-egui/src/tests_album_picker.rs b/crates/ui-egui/src/tests_album_picker.rs index fd9121d5..f48e150b 100644 --- a/crates/ui-egui/src/tests_album_picker.rs +++ b/crates/ui-egui/src/tests_album_picker.rs @@ -95,7 +95,6 @@ fn typing_finds_and_keys_pick() { h.settle(SETTLE); } assert_eq!(value(&h), json!(uncurated), "the second match"); - assert!(h.app.ui.dialog.is_some(), "Enter picked, it didn't confirm the dialog"); } /// Albums that can't be picked (the one being edited, one that would loop) are shown greyed, not @@ -114,3 +113,16 @@ fn albums_that_would_loop_are_greyed() { click(&mut h, &format!("albumPickerItem:{travel}:rules-0")); assert_eq!(value(&h), json!(null), "but not picked"); } + +/// After opening a folder, typing still goes to the search: the box keeps the keyboard. +#[test] +fn typing_after_a_folder_click_still_searches() { + let Library { mut h, utils, excluded, trip, .. } = library(); + click(&mut h, "albumPicker:rules-0"); + click(&mut h, &format!("albumPickerFolder:{utils}:rules-0")); + assert!(has(&h, &format!("albumPickerItem:{excluded}:rules-0"))); + let r = h.request("ui.text", json!({"text": "trip"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(has(&h, &format!("albumPickerItem:{trip}:rules-0")) && !has(&h, &format!("albumPickerItem:{excluded}:rules-0")), "searching for trip"); +} From e8678bb28e873f04a6b7e02274865e005108889b Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:49:27 -0400 Subject: [PATCH 62/65] LRC-LIB-SMARTCOLL: rule dialog and sidebar problems cached per change, not per frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From final review: while the rule dialog was open it ran the rule check several times, the full query for the live count (sorting every match only to count them) and rebuilt the album list with a loop walk per album, on every repaint, including pointer moves over a picker. The sidebar re-checked every smart album's rules on every frame. Caches::rules_view keeps the dialog's problems, count and album list until the catalog revision, the rules, the language or (for "in the last…" rules) the minute changes. Caches::smart_album_problems keeps the sidebar's ⚠ problems per catalog revision. Both count their scans, like the album counts, and a test checks that unchanged frames reuse them and that rule and catalog changes don't. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 100 +++++++++++++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 22 +++--- crates/ui-egui/src/panels/left.rs | 2 +- 3 files changed, 110 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a743e91..beed233e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1068,6 +1068,12 @@ pub struct Caches { album_counts: Option<(u64, std::sync::Arc>)>, /// How often the album counts were recomputed (tests check that unchanged frames don't). pub album_count_scans: usize, + /// What the open smart-album rule dialog shows besides the rules ([`Caches::rules_view`]). + rules_view: Option<(u64, std::sync::Arc)>, + pub rules_view_scans: usize, + /// The problems of every saved smart album that has some ([`Caches::smart_album_problems`]). + smart_problems: Option<(u64, std::sync::Arc>>)>, + pub smart_problem_scans: usize, /// AI denoise: what the pump last saw, the model list and the downloads being watched. pub denoise: panels::denoise::Ui, /// Bumped when a face model is installed, removed or chosen, so Settings re-reads the list at once. @@ -1117,6 +1123,14 @@ pub struct FilterValues { pub keywords: Vec, } +/// What the smart-album rule dialog shows besides the rules themselves: their problems (each row +/// marks its own), how many photos they match and the albums an Album rule picks from. +pub struct RulesView { + pub problems: Vec, + pub count: usize, + pub albums: Vec, +} + pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 { use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher}; BuildHasherDefault::::default().hash_one(parts) @@ -1244,6 +1258,56 @@ impl Caches { /// all bump its revision) and — only while some smart album has an "in the last…" rule — /// when `now` (the session clock, ISO) enters a new minute, so such counts follow the clock /// within a minute without rescanning every frame. + /// [`RulesView`] for `rules` edited in smart album `editing` (`None`: a new album), within + /// `folder` when it was made from a folder view: worked out again only when the catalog, the + /// rules, the language or (for "in the last…" rules) the minute changes, not every frame. + pub fn rules_view( + &mut self, + cat: &lightcraft_catalog::Catalog, + rules: &lightcraft_catalog::RuleSet, + editing: Option, + folder: Option, + now: &str, + ) -> std::sync::Arc { + let relative = rules.depends_on_now(); + let minute = if relative { now.get(..16).unwrap_or(now) } else { "" }; + let k = key_of((cat.revision, serde_json::to_string(rules).unwrap_or_default(), editing, &folder, minute, i18n::language().code())); + if let Some((key, v)) = &self.rules_view + && *key == k + { + return v.clone(); + } + if relative { + lightcraft_catalog::rules::set_now(Some(now.to_string())); + } + let problems = rules.check_for(cat, editing.map(lightcraft_catalog::AlbumId)); + let filter = lightcraft_catalog::Filter { rule_set: Some(rules.clone()), library_folder: folder, ..Default::default() }; + let count = if problems.is_empty() { cat.query(&filter, &Default::default()).len() } else { 0 }; + let v = std::sync::Arc::new(RulesView { problems, count, albums: panels::dialogs::album_entries(cat, editing) }); + self.rules_view_scans += 1; + self.rules_view = Some((k, v.clone())); + v + } + + /// The problems of every saved smart album that has some (`Catalog::smart_album_problems`), + /// for the sidebar's ⚠ marks: worked out again only when the catalog changes. + pub fn smart_album_problems( + &mut self, + cat: &lightcraft_catalog::Catalog, + ) -> std::sync::Arc>> { + if let Some((rev, v)) = &self.smart_problems + && *rev == cat.revision + { + return v.clone(); + } + let v: std::sync::Arc> = std::sync::Arc::new( + cat.albums().filter(|a| a.is_smart()).map(|a| (a.id, cat.smart_album_problems(a.id))).filter(|(_, p)| !p.is_empty()).collect(), + ); + self.smart_problem_scans += 1; + self.smart_problems = Some((cat.revision, v.clone())); + v + } + pub fn album_counts( &mut self, cat: &lightcraft_catalog::Catalog, @@ -1319,6 +1383,42 @@ mod cache_tests { lightcraft_catalog::rules::set_now(None); } + /// The rule dialog's problems, live count and album list are worked out once per change to the + /// catalog or the rules, not every frame; so are the sidebar's smart-album problems. + #[test] + fn rule_dialog_and_problems_are_cached_until_something_changes() { + let mut cat = Catalog::default(); + let mut c = super::Caches::default(); + for i in 0..3u64 { + cat.apply(photo(i + 1, "2026-09-30T11:00:00", if i == 0 { 5 } else { 1 })).unwrap(); + } + let rules = + |v: u64| -> RuleSet { serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": v}]})).unwrap() }; + let now = "2026-09-30T12:00:00"; + let view = c.rules_view(&cat, &rules(3), None, None, now); + assert_eq!((view.count, view.problems.len()), (1, 0)); + for _ in 0..10 { + c.rules_view(&cat, &rules(3), None, None, now); + } + assert_eq!(c.rules_view_scans, 1, "unchanged frames reuse it"); + assert_eq!(c.rules_view(&cat, &rules(1), None, None, now).count, 3, "a rule change"); + assert_eq!(c.rules_view(&cat, &rules(9), None, None, now).problems.len(), 1); + cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap(); + assert_eq!(c.rules_view(&cat, &rules(3), None, None, now).count, 2, "a catalog change"); + assert_eq!(c.rules_view_scans, 4); + // the sidebar's problems + cat.apply(Op::AddAlbum { album: lightcraft_catalog::Album::new(AlbumId(20), "Trip") }).unwrap(); + cat.apply(smart(21, serde_json::json!({"rules": [{"field": "album", "op": "is", "value": 20}]}))).unwrap(); + assert!(c.smart_album_problems(&cat).get(&AlbumId(21)).is_none_or(Vec::is_empty)); + for _ in 0..10 { + c.smart_album_problems(&cat); + } + assert_eq!(c.smart_problem_scans, 1); + cat.apply(Op::RemoveAlbum { id: AlbumId(20) }).unwrap(); + assert!(c.smart_album_problems(&cat).get(&AlbumId(21)).is_some_and(|p| !p.is_empty()), "its album is gone"); + assert_eq!(c.smart_problem_scans, 2); + } + /// Without "in the last…" rules the clock never causes a rescan. #[test] fn absolute_rules_ignore_the_clock() { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index e790d8d3..facbb8b5 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -247,22 +247,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)); crate::widgets::register(ui.ctx(), "field:smartName", r.rect); ui.add_space(6.0); - // checked before drawing, so each row marks its own problem - let env = crate::panels::rules_editor::Env { - problems: rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)), - today: (app.session.clock)(), - albums: album_entries(&app.session.catalog, *id), - }; + // the folder an album made from a folder view carries is not in the editor, but it counts + let folder = id.and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id))).and_then(|a| a.smart.as_deref().and_then(|f| f.library_folder.clone())); + // problems, count and albums: cached until the catalog or the rules change (not per frame) + let now = (app.session.clock)(); + let view = app.caches.rules_view(&app.session.catalog, rules, *id, folder, &now); + let env = crate::panels::rules_editor::Env { problems: view.problems.clone(), today: now, albums: view.albums.clone() }; egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| { crate::panels::rules_editor::edit(ui, rules, "rules", 0, &[], &env); }); - let problems = rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)); - // the folder an album made from a folder view carries is not in the editor, but it counts - let folder = id.and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id))).and_then(|a| a.smart.as_deref().and_then(|f| f.library_folder.clone())); - let f = lightcraft_catalog::Filter { rule_set: Some(rules.clone()), library_folder: folder, ..Default::default() }; - let n = if problems.is_empty() { app.session.catalog.query(&f, &Default::default()).len() } else { 0 }; + let n = view.count; ui.add_space(4.0); - if problems.is_empty() { + if view.problems.is_empty() { ui.label( egui::RichText::new(crate::i18n::tr_format!("{n} photo{} match · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n)) .color(t.text_dim), @@ -1034,7 +1030,7 @@ fn created_in(app: &mut LightcraftApp, command: &str, params: serde_json::Value) /// The albums an Album rule in smart album `editing` picks from, in the sidebar's order: the album /// itself and any smart album that tests it (testing it back would loop) are blocked, with why. -fn album_entries(cat: &lightcraft_catalog::Catalog, editing: Option) -> Vec { +pub(crate) fn album_entries(cat: &lightcraft_catalog::Catalog, editing: Option) -> Vec { let editing = editing.map(lightcraft_catalog::AlbumId); crate::album_picker::entries_from(cat, |a| match editing { Some(e) if a.id == e => Some(crate::i18n::tr("This is the album you're editing").to_string()), diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 5a21b54e..64a727ea 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -728,7 +728,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare app.session.target_album.filter(|t| app.session.catalog.album(*t).is_some()).or_else(|| app.session.catalog.quick_collection()); let label = if target == Some(a.id) { format!("{} +", a.name) } else { a.name.clone() }; // a smart album whose rules no longer check (an album they test was deleted) says so - let problems = app.session.catalog.smart_album_problems(a.id); + let problems = app.caches.smart_album_problems(&app.session.catalog).get(&a.id).cloned().unwrap_or_default(); let label = if problems.is_empty() { label } else { format!("⚠ {label}") }; let mut resp = row_sensed(app, ui, &format!("album:{}", a.id.0), icon, &label, None, Some(n), sel, indent, Sense::click_and_drag()); if !problems.is_empty() { From 8359aaa04c65d14d8a7a55e432823fa66aeb04f7 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:49:27 -0400 Subject: [PATCH 63/65] LRC-LIB-SMARTCOLL: rule editor: calendars only on date fields; in-the-last N as text From final review: - Every non-number "between" got date pickers, so a text or choice rule written with "between" (by an agent; the check marks it) offered calendars that would write dates into it. Only date fields get them now; a test checks a "Title between" rule. - An "in the last" N stored as text ("7") showed as 30, and changing only the unit wrote 30. It is now read as the catalog reads it. - take_escape's comment no longer claims later egui passes see the key press (they don't); keying it to the frame just lets it lapse. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/rules_editor.rs | 13 +++++++++++-- crates/ui-egui/src/tests_date_picker.rs | 9 +++++++++ crates/ui-egui/src/widgets.rs | 3 +-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index 9b8c994f..006d4266 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -137,7 +137,11 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & // shown as stored ({n, unit}, or a plain number of days) and only written when changed: // drawing never turns "0 days" into 1 or 20,000 into the spinner's 10,000 let (stored_n, stored_unit) = match &*v { - Value::Object(o) => (o.get("n").and_then(Value::as_f64), o.get("unit").and_then(Value::as_str).unwrap_or("days").to_string()), + // n as the catalog reads it: a number, or one written as text ("7") + Value::Object(o) => ( + o.get("n").and_then(|n| n.as_f64().or_else(|| n.as_str().and_then(|t| t.trim().parse().ok()))), + o.get("unit").and_then(Value::as_str).unwrap_or("days").to_string(), + ), Value::Number(n) => (n.as_f64(), "days".to_string()), _ => (None, "days".to_string()), }; @@ -171,7 +175,7 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & number_value(ui, lo, field); ui.label(crate::i18n::tr("and")); number_value(ui, hi, field); - } else { + } else if k == Kind::Date { // each date's picker stops at the other one, so the two stay in order let (lo_date, hi_date) = (lo.as_str().and_then(PickedDate::parse), hi.as_str().and_then(PickedDate::parse)); text_value(ui, lo, 86.0, "2026-01-01", &format!("{salt}-a")); @@ -179,6 +183,11 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & ui.label(crate::i18n::tr("and")); text_value(ui, hi, 86.0, "2026-12", &format!("{salt}-b")); date_picker(ui, hi, &format!("{salt}-b"), lo_date, None, env); + } else { + // a field without "between" (the check marks it): its two values, as text + text_value(ui, lo, 86.0, "", &format!("{salt}-a")); + ui.label(crate::i18n::tr("and")); + text_value(ui, hi, 86.0, "", &format!("{salt}-b")); } if pair != before { *v = pair; diff --git a/crates/ui-egui/src/tests_date_picker.rs b/crates/ui-egui/src/tests_date_picker.rs index 0ea04f0c..0f701767 100644 --- a/crates/ui-egui/src/tests_date_picker.rs +++ b/crates/ui-egui/src/tests_date_picker.rs @@ -114,3 +114,12 @@ fn escape_closes_only_a_dropdown() { assert!(!has(&h, "ruleFieldItem:rating:rules-0"), "the menu closed"); assert!(h.app.ui.dialog.is_some(), "the editor stays open"); } + +/// Only date rules get calendars: a "between" on a text field (written by an agent; the check marks +/// it) shows its two values as text, without date pickers that would write dates into it. +#[test] +fn only_dates_get_calendars() { + let h = editor(json!([{"field": "title", "op": "between", "value": ["a", "b"]}])); + assert!(!has(&h, "datePicker:rules-0-a") && !has(&h, "datePicker:rules-0-b")); + assert!(has(&h, "ruleProblem:rules-0"), "and the operator is marked"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b221f725..1d618cbe 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -20,8 +20,7 @@ pub fn register(ctx: &egui::Context, id: impl Into, rect: Rect) { } /// Esc was handled this frame by something nested (an open popup closed on it): containers such -/// as dialogs shouldn't close too. Kept for the whole frame, since egui may run it in more than one -/// pass and the popup is already closed in the later ones. +/// as dialogs shouldn't close too. Keyed to the frame, so it lapses by itself on the next one. pub fn take_escape(ctx: &egui::Context) { let frame = ctx.cumulative_frame_nr(); ctx.data_mut(|d| d.insert_temp(egui::Id::new("lc-escape-taken"), frame)); From 652475d2638b3c62ae84fb60c0ad1030f76504f7 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:54:47 -0400 Subject: [PATCH 64/65] LRC-LIB-SMARTCOLL: translate every rule field and group label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From final review: the field menu mixed languages. The new fields (Copy Name, In a Stack, File Extension, Video Duration, Keyword Count, People Count, Shutter Speed, Cropped) and four group headings (Keywords & People, Description, Camera Info, Develop) were English in every locale. Russian also lacked fifteen older labels (Pick Flag, Any Searchable Text, Filename, Color Label, Has GPS…). All are translated, following each locale's existing terms (Stapel / Pila / スタック / 堆叠 for stacks, Цветовая метка as in Russian's other colour-label messages). The i18n test now requires every field and group label in every language, as it does for choice values and problem texts. pt-br's album search reads "Pesquisar", like its other searches. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 14 +++++++++++++- crates/ui-egui/locales/es.json | 14 +++++++++++++- crates/ui-egui/locales/ja.json | 12 +++++++++++- crates/ui-egui/locales/pt-br.json | 16 ++++++++++++++-- crates/ui-egui/locales/ru.json | 29 ++++++++++++++++++++++++++++- crates/ui-egui/locales/zh-hans.json | 14 +++++++++++++- crates/ui-egui/locales/zh-hant.json | 14 +++++++++++++- crates/ui-egui/src/i18n.rs | 4 ++++ 8 files changed, 109 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index e1fd8b2f..3dc2f3d0 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1934,5 +1934,17 @@ "Search albums": "Alben suchen", "No albums match": "Kein Album passt", "This is the album you're editing": "Das ist das Album, das du bearbeitest", - "It tests this album, so testing it back would loop": "Es prüft dieses Album; es zurückzuprüfen wäre ein Kreis" + "It tests this album, so testing it back would loop": "Es prüft dieses Album; es zurückzuprüfen wäre ein Kreis", + "Copy Name": "Kopiename", + "In a Stack": "In einem Stapel", + "File Extension": "Dateiendung", + "Video Duration": "Videodauer", + "Keyword Count": "Anzahl Stichwörter", + "People Count": "Anzahl Personen", + "Shutter Speed": "Belichtungszeit", + "Cropped": "Zugeschnitten", + "Keywords & People": "Stichwörter & Personen", + "Description": "Beschreibung", + "Camera Info": "Kamera-Info", + "Develop": "Bearbeitung" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index fb15fcc0..d575729c 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1779,5 +1779,17 @@ "Search albums": "Buscar álbumes", "No albums match": "Ningún álbum coincide", "This is the album you're editing": "Es el álbum que estás editando", - "It tests this album, so testing it back would loop": "Comprueba este álbum, así que comprobarlo a su vez crearía un bucle" + "It tests this album, so testing it back would loop": "Comprueba este álbum, así que comprobarlo a su vez crearía un bucle", + "Copy Name": "Nombre de la copia", + "In a Stack": "En una pila", + "File Extension": "Extensión del archivo", + "Video Duration": "Duración del vídeo", + "Keyword Count": "Número de palabras clave", + "People Count": "Número de personas", + "Shutter Speed": "Velocidad de obturación", + "Cropped": "Recortada", + "Keywords & People": "Palabras clave y personas", + "Description": "Descripción", + "Camera Info": "Información de la cámara", + "Develop": "Edición" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index d0ab556c..27d83255 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1783,5 +1783,15 @@ "Search albums": "アルバムを検索", "No albums match": "一致するアルバムはありません", "This is the album you're editing": "編集中のアルバムです", - "It tests this album, so testing it back would loop": "このアルバムを条件にしているため、逆に指定すると循環します" + "It tests this album, so testing it back would loop": "このアルバムを条件にしているため、逆に指定すると循環します", + "Copy Name": "コピー名", + "File Extension": "拡張子", + "Video Duration": "ビデオの長さ", + "Keyword Count": "キーワード数", + "People Count": "人数", + "Shutter Speed": "シャッター速度", + "Keywords & People": "キーワードと人物", + "Description": "説明", + "Camera Info": "カメラ情報", + "Develop": "編集" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 36dc4e5f..6fbb461f 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1776,8 +1776,20 @@ "Su": "Dom", "Pick a date": "Escolher uma data", "would make this album include itself through other smart albums": "faria este álbum incluir a si mesmo por meio de outros álbuns inteligentes", - "Search albums": "Buscar álbuns", + "Search albums": "Pesquisar álbuns", "No albums match": "Nenhum álbum corresponde", "This is the album you're editing": "É o álbum que você está editando", - "It tests this album, so testing it back would loop": "Ele testa este álbum, então testá-lo de volta criaria um ciclo" + "It tests this album, so testing it back would loop": "Ele testa este álbum, então testá-lo de volta criaria um ciclo", + "Copy Name": "Nome da cópia", + "In a Stack": "Em uma pilha", + "File Extension": "Extensão do arquivo", + "Video Duration": "Duração do vídeo", + "Keyword Count": "Número de palavras-chave", + "People Count": "Número de pessoas", + "Shutter Speed": "Velocidade do obturador", + "Cropped": "Recortada", + "Keywords & People": "Palavras-chave e pessoas", + "Description": "Descrição", + "Camera Info": "Informações da câmera", + "Develop": "Edição" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 9d132ab6..a00fd2a1 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1471,5 +1471,32 @@ "Search albums": "Поиск альбомов", "No albums match": "Нет подходящих альбомов", "This is the album you're editing": "Это альбом, который вы изменяете", - "It tests this album, so testing it back would loop": "Он проверяет этот альбом, поэтому обратная проверка создаст цикл" + "It tests this album, so testing it back would loop": "Он проверяет этот альбом, поэтому обратная проверка создаст цикл", + "Copy Name": "Имя копии", + "In a Stack": "В стопке", + "File Extension": "Расширение файла", + "Video Duration": "Длительность видео", + "Keyword Count": "Число ключевых слов", + "People Count": "Число людей", + "Shutter Speed": "Выдержка", + "Cropped": "Кадрировано", + "Keywords & People": "Ключевые слова и люди", + "Description": "Описание", + "Camera Info": "Сведения о камере", + "Develop": "Обработка", + "Pick Flag": "Флаг", + "Any Searchable Text": "Любой текст для поиска", + "Filename": "Имя файла", + "File Type": "Тип файла", + "File Format": "Формат файла", + "Edit Date": "Дата изменения", + "People": "Люди", + "Focal Length": "Фокусное расстояние", + "ISO Speed": "ISO", + "Has GPS": "Есть GPS", + "Has Edits": "Есть правки", + "Focus (assisted culling)": "Резкость (помощь в отборе)", + "Best of Similar Shots": "Лучший из похожих снимков", + "Date": "Дата", + "Color Label": "Цветовая метка" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index a1ebcb62..86956f49 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1779,5 +1779,17 @@ "Search albums": "搜索相册", "No albums match": "没有匹配的相册", "This is the album you're editing": "这是正在编辑的相册", - "It tests this album, so testing it back would loop": "它引用了此相册,反过来引用会形成循环" + "It tests this album, so testing it back would loop": "它引用了此相册,反过来引用会形成循环", + "Copy Name": "副本名称", + "In a Stack": "在堆叠中", + "File Extension": "文件扩展名", + "Video Duration": "视频时长", + "Keyword Count": "关键字数量", + "People Count": "人物数量", + "Shutter Speed": "快门速度", + "Cropped": "已裁剪", + "Keywords & People": "关键字与人物", + "Description": "描述", + "Camera Info": "相机信息", + "Develop": "编辑" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 4db189ee..37bfe7b6 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1779,5 +1779,17 @@ "Search albums": "搜尋相簿", "No albums match": "沒有符合的相簿", "This is the album you're editing": "這是正在編輯的相簿", - "It tests this album, so testing it back would loop": "它引用了此相簿,反過來引用會形成循環" + "It tests this album, so testing it back would loop": "它引用了此相簿,反過來引用會形成循環", + "Copy Name": "副本名稱", + "In a Stack": "在堆疊中", + "File Extension": "副檔名", + "Video Duration": "影片長度", + "Keyword Count": "關鍵字數量", + "People Count": "人物數量", + "Shutter Speed": "快門速度", + "Cropped": "已裁切", + "Keywords & People": "關鍵字與人物", + "Description": "描述", + "Camera Info": "相機資訊", + "Develop": "編輯" } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index af6045e5..4fffc63e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -773,6 +773,10 @@ mod tests { assert_eq!(problem_text_in(Locale::En, &g[0]), "This group is empty: add a rule or remove it."); // the issues, the operators their hints name and the album picker's words, in every language for language in Locale::ALL.iter().filter(|l| **l != Locale::En) { + // the field menu: every field and every group, so it never mixes languages + for label in lightcraft_catalog::rules::FIELDS.iter().map(|f| f.1).chain(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|g| g.0)) { + assert!(language.catalog().contains_key(label), "{} lacks the rule label {label:?}", language.code()); + } for word in [ "Choose an album…", "No albums yet", From b0efba015c6d57c1ba76a49c4d67d57210885144 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 10:54:47 -0400 Subject: [PATCH 65/65] LRC-LIB-SMARTCOLL: docs and comments match the code at the end of the branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From final review: the problem-message example ("rule 2.1: rating is 0–5, not 9") predated the rating check's rewording ("no rating 0–5 is 9"); two doc comments still said Album rules take a plain album; and What's New described the Album rule's old flat list. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/rules.rs | 6 +++--- docs/parity.md | 2 +- docs/whats-new.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index a77a2f64..be1fd867 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -48,7 +48,7 @@ pub enum Kind { Keywords, Number, Date, - /// A plain album, by id: in it or not. + /// An album or a smart album (not a folder), by id: in it or not. Album, /// One of a fixed set: (id, label). Rules store the id; people see the label. Choice(&'static [(&'static str, &'static str)]), @@ -161,7 +161,7 @@ pub struct Problem { } impl std::fmt::Display for Problem { - /// `rule 2.1: rating is 0–5, not 9` (counting from 1, as people do). + /// `rule 2.1: no rating 0–5 is 9` (counting from 1, as people do). fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let at: Vec = self.path.iter().map(|i| (i.saturating_add(1)).to_string()).collect(); // a problem with the filter around the rules (its album field) has no rule to point at @@ -919,7 +919,7 @@ impl RuleSet { /// The rules that can't mean anything, in order: an unknown field, an operator the field /// doesn't have, a value that isn't one of the field's (a number, a date, a choice, yes or - /// no, a plain album…), text with nothing to look for, an empty group. Commands refuse a rule + /// no, an album that exists and isn't a folder…), text with nothing to look for, an empty group. Commands refuse a rule /// set with problems; the editor shows them. `cat` knows the albums and colour-label names. pub fn check(&self, cat: &Catalog) -> Vec { self.check_for(cat, None) diff --git a/docs/parity.md b/docs/parity.md index 8fd49b06..fd5c47c2 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -744,7 +744,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | -| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: rating is 0–5, not 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | +| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | | LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | diff --git a/docs/whats-new.md b/docs/whats-new.md index a8e85ffb..faadc0de 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -15,7 +15,7 @@ `publicDomain` (rules and agents keep using the ids; `album.ruleFields` adds `choiceLabels`). - Rule values are checked: a rating of 9, a date like 2026-13 or "banana", a "between" whose dates are the wrong way round, "in the last 0 days", a colour label that doesn't exist, "contains" with nothing to look for, an - album that is gone, or an empty group is reported with its rule ("rule 2.1: rating is 0–5, not 9") + album that is gone, or an empty group is reported with its rule ("rule 2.1: no rating 0–5 is 9") instead of quietly matching nothing or everything. New date rules start at the current year. - A smart album can include or exclude another smart album: "Keywords contain travel" and "Album isn't Excluded Photos" leaves out whatever Excluded Photos matches, as its rules change. The album picker shows your albums as in the sidebar @@ -33,7 +33,7 @@ - Editing a smart album's name and rules is one step: both or neither, and one undo takes both back (`album.setRules` takes `name`). - The rule editor marks each rule that needs fixing right under it and keeps OK disabled until they are fixed. An - Album rule picks its album from a list (is / isn't) instead of taking an id. + Album rule is "is" or "isn't" an album, picked with the album picker. - Yes/no fields (Has Edits, Cropped, Has GPS…) read Yes / No instead of true / false. A rule sent as `"false"` now means no (it used to count as yes), and a value that is neither yes nor no is refused. - Any Searchable Text also finds the state / province, alt text, people and a virtual copy's name.