LRC-LIB-SMARTCOLL: smart-album rule editor — all / any / none, nested groups, 26 fields
lightcraft-catalog gains rules.rs: RuleSet {match: all|any|none, rules: [{field, op, value} |
{group}]} with text, keyword, number, date (is / before / after / between / in the last N
hours…years) and choice operators over 26 fields (rating, flag, label, type, edits, keywords,
any text, filename, title, camera, lens, location, dates, ISO, aperture, focal length,
megapixels, GPS, virtual copy, album…). Filter.ruleSet: smart albums and library.filter use it;
"in the last" counts from the session clock. album.ruleFields lists fields and operators for
agents; rules are validated.
UI: File ▸ New Smart Album… / album context menu ▸ Edit Smart Album… open the rule editor
(field / operator / value rows with per-kind editors, + / − / ⌥+ for groups, live match count).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b8344aa5eb
commit
a3d487d22d
|
|
@ -12,6 +12,7 @@ pub mod journal;
|
|||
pub mod keywords;
|
||||
pub mod model;
|
||||
pub mod query;
|
||||
pub mod rules;
|
||||
pub mod stacks;
|
||||
pub mod store;
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ pub use keywords::KeywordNode;
|
|||
use lightcraft_develop::DevelopSettings;
|
||||
pub use model::*;
|
||||
pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey};
|
||||
pub use rules::{Match, Rule, RuleSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use store::{FsStore, MemStore, Store};
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ pub struct Filter {
|
|||
/// A folder on disk: its files only (browsed ones too); `subfolders` includes everything below.
|
||||
pub folder: Option<String>,
|
||||
pub subfolders: bool,
|
||||
/// Smart-album rules (all / any / none, nested groups; see [`crate::rules`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rule_set: Option<crate::RuleSet>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -156,6 +159,9 @@ impl Filter {
|
|||
if !self.text.trim().is_empty() {
|
||||
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());
|
||||
}
|
||||
if v.is_empty() { "all photos".into() } else { v.join(", ") }
|
||||
}
|
||||
|
||||
|
|
@ -190,6 +196,11 @@ impl Filter {
|
|||
if self.edited.is_some_and(|e| e != p.is_edited()) {
|
||||
return false;
|
||||
}
|
||||
if let Some(rs) = &self.rule_set
|
||||
&& !rs.matches(p, cat)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(want) = &self.merged {
|
||||
match merged_kind(&p.file_name) {
|
||||
Some(k) if want == "any" || want == k => {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,466 @@
|
|||
//! Smart-album rules: a list of conditions matched all / any / none, with nested groups.
|
||||
//!
|
||||
//! Each [`Rule`] is `{field, op, value}`; [`FIELDS`] lists the fields with their kind, which
|
||||
//! decides the operators ([`ops_for`]). Text compares case-insensitively; dates are ISO strings
|
||||
//! compared by prefix; "in the last N days/weeks/months/years" is relative to [`now`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{Catalog, Photo};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Match {
|
||||
#[default]
|
||||
All,
|
||||
Any,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuleSet {
|
||||
#[serde(default, rename = "match")]
|
||||
pub mode: Match,
|
||||
#[serde(default)]
|
||||
pub rules: Vec<Rule>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Rule {
|
||||
/// A nested group with its own match mode.
|
||||
Group { group: RuleSet },
|
||||
Field {
|
||||
field: String,
|
||||
op: String,
|
||||
#[serde(default)]
|
||||
value: Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// How a field's value is compared.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Text,
|
||||
/// Keywords: text ops over each keyword (any keyword matches; `isEmpty` = none).
|
||||
Keywords,
|
||||
Number,
|
||||
Date,
|
||||
/// One of a fixed set (`choices`).
|
||||
Choice(&'static [&'static str]),
|
||||
Bool,
|
||||
}
|
||||
|
||||
/// The fields rules can test: (id, label, kind).
|
||||
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),
|
||||
("fileName", "Filename", Kind::Text),
|
||||
("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),
|
||||
("captureDate", "Capture Date", Kind::Date),
|
||||
("importDate", "Import Date", Kind::Date),
|
||||
("editDate", "Edit Date", Kind::Date),
|
||||
("iso", "ISO Speed", Kind::Number),
|
||||
("aperture", "Aperture", Kind::Number),
|
||||
("focalLength", "Focal Length", Kind::Number),
|
||||
("megapixels", "Megapixels", Kind::Number),
|
||||
("hasGps", "Has GPS", Kind::Bool),
|
||||
("virtualCopy", "Virtual Copy", Kind::Bool),
|
||||
("album", "Album", Kind::Number),
|
||||
];
|
||||
|
||||
/// The operators for a field kind: (id, label).
|
||||
pub fn ops_for(kind: Kind) -> &'static [(&'static str, &'static str)] {
|
||||
match kind {
|
||||
Kind::Text => &[
|
||||
("contains", "contains"),
|
||||
("notContains", "doesn't contain"),
|
||||
("is", "is"),
|
||||
("isNot", "isn't"),
|
||||
("startsWith", "starts with"),
|
||||
("endsWith", "ends with"),
|
||||
("isEmpty", "is empty"),
|
||||
("isNotEmpty", "isn't empty"),
|
||||
],
|
||||
Kind::Keywords => &[
|
||||
("contains", "contains"),
|
||||
("notContains", "doesn't contain"),
|
||||
("is", "is"),
|
||||
("startsWith", "starts with"),
|
||||
("isEmpty", "are empty"),
|
||||
("isNotEmpty", "aren't empty"),
|
||||
],
|
||||
Kind::Number => {
|
||||
&[("is", "is"), ("isNot", "isn't"), ("gte", "is ≥"), ("lte", "is ≤"), ("gt", "is >"), ("lt", "is <"), ("between", "is between")]
|
||||
}
|
||||
Kind::Date => &[
|
||||
("is", "is"),
|
||||
("after", "is after"),
|
||||
("before", "is before"),
|
||||
("between", "is between"),
|
||||
("inLast", "is in the last"),
|
||||
("notInLast", "isn't in the last"),
|
||||
("isEmpty", "is unknown"),
|
||||
],
|
||||
Kind::Choice(_) => &[("is", "is"), ("isNot", "isn't")],
|
||||
Kind::Bool => &[("is", "is")],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn field_kind(field: &str) -> Option<Kind> {
|
||||
FIELDS.iter().find(|f| f.0 == field).map(|f| f.2)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static NOW: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Set the time "in the last…" rules count back from (the engine's clock; tests).
|
||||
pub fn set_now(iso: Option<String>) {
|
||||
NOW.with(|n| *n.borrow_mut() = iso);
|
||||
}
|
||||
|
||||
/// The current time as ISO 8601: [`set_now`]'s, else the system clock (UTC).
|
||||
pub fn now() -> String {
|
||||
if let Some(n) = NOW.with(|n| n.borrow().clone()) {
|
||||
return n;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
|
||||
crate::dates::civil(secs)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
"2026-01-01T00:00:00".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn lower(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.trim().to_lowercase(),
|
||||
Value::Null => String::new(),
|
||||
other => other.to_string().to_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_op(op: &str, have: &str, want: &str) -> bool {
|
||||
let have = have.trim().to_lowercase();
|
||||
match op {
|
||||
"contains" => want.split_whitespace().all(|w| have.contains(w)),
|
||||
"notContains" => !want.split_whitespace().any(|w| have.contains(w)),
|
||||
"is" => have == want,
|
||||
"isNot" => have != want,
|
||||
"startsWith" => have.starts_with(want),
|
||||
"endsWith" => have.ends_with(want),
|
||||
"isEmpty" => have.is_empty(),
|
||||
"isNotEmpty" => !have.is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn number(v: &Value) -> Option<f64> {
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64(),
|
||||
Value::String(s) => s.trim().trim_start_matches("f/").trim_end_matches("mm").trim().parse().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn num_op(op: &str, have: Option<f64>, value: &Value) -> bool {
|
||||
if op == "between" {
|
||||
let (a, b) = match value {
|
||||
Value::Array(v) if v.len() == 2 => (number(&v[0]), number(&v[1])),
|
||||
_ => (None, None),
|
||||
};
|
||||
return matches!((have, a, b), (Some(h), Some(a), Some(b)) if h >= a.min(b) && h <= a.max(b));
|
||||
}
|
||||
let (Some(h), Some(w)) = (have, number(value)) else { return op == "isNot" && have.is_none() };
|
||||
match op {
|
||||
"is" => (h - w).abs() < 1e-6,
|
||||
"isNot" => (h - w).abs() >= 1e-6,
|
||||
"gte" => h >= w - 1e-9,
|
||||
"lte" => h <= w + 1e-9,
|
||||
"gt" => h > w,
|
||||
"lt" => h < w,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days.
|
||||
fn last_secs(value: &Value) -> Option<i64> {
|
||||
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,
|
||||
};
|
||||
Some((n * per) as i64)
|
||||
}
|
||||
|
||||
fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool {
|
||||
if op == "isEmpty" {
|
||||
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();
|
||||
match op {
|
||||
// a prefix: 2026, 2026-04, 2026-04-12
|
||||
"is" => {
|
||||
let w = s(value);
|
||||
!w.is_empty() && h.starts_with(&w)
|
||||
}
|
||||
"after" => {
|
||||
let w = s(value);
|
||||
!w.is_empty() && h > w.as_str() && !h.starts_with(&w)
|
||||
}
|
||||
"before" => {
|
||||
let w = s(value);
|
||||
!w.is_empty() && h < w.as_str()
|
||||
}
|
||||
"between" => match value {
|
||||
Value::Array(v) if v.len() == 2 => {
|
||||
let (a, b) = (s(&v[0]), s(&v[1]));
|
||||
h >= a.as_str() && (h <= b.as_str() || h.starts_with(&b))
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
"inLast" | "notInLast" => {
|
||||
let Some(secs) = last_secs(value) else { return false };
|
||||
let from = crate::dates::shift_iso(&now(), -secs).unwrap_or_default();
|
||||
(h >= from.as_str()) == (op == "inLast")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Rule {
|
||||
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
|
||||
let (field, op, value) = match self {
|
||||
Rule::Group { group } => return group.matches(p, cat),
|
||||
Rule::Field { field, op, value } => (field.as_str(), op.as_str(), value),
|
||||
};
|
||||
let m = &p.meta;
|
||||
let want = lower(value);
|
||||
let text = |have: &str| text_op(op, have, &want);
|
||||
match field {
|
||||
"rating" => num_op(op, Some(p.rating as f64), value),
|
||||
"flag" => {
|
||||
let f = format!("{:?}", p.flag).to_lowercase();
|
||||
(f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is")
|
||||
}
|
||||
"label" => {
|
||||
let l = p.label.map(|l| format!("{l:?}").to_lowercase()).unwrap_or_else(|| "none".into());
|
||||
// custom label names count too
|
||||
let named = p.label.is_some_and(|l| cat.label_name(l).to_lowercase() == want);
|
||||
(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),
|
||||
"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))),
|
||||
},
|
||||
"text" => {
|
||||
let all = [
|
||||
p.file_name.as_str(),
|
||||
&m.title,
|
||||
&m.caption,
|
||||
&m.camera,
|
||||
&m.lens,
|
||||
&m.location,
|
||||
&m.city,
|
||||
&m.country,
|
||||
&p.format,
|
||||
&m.keywords.join(" "),
|
||||
]
|
||||
.join(" ");
|
||||
text(&all)
|
||||
}
|
||||
"fileName" => text(&p.file_name),
|
||||
"format" => text(&p.format),
|
||||
"title" => text(&m.title),
|
||||
"caption" => text(&m.caption),
|
||||
"camera" => text(&m.camera),
|
||||
"lens" => text(&m.lens),
|
||||
"location" => text(&[m.location.as_str(), &m.city, &m.state, &m.country].join(" ")),
|
||||
"creator" => text(&m.creator),
|
||||
"copyright" => text(&m.copyright),
|
||||
"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),
|
||||
"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),
|
||||
"megapixels" => num_op(op, Some(p.width as f64 * p.height as f64 / 1e6), value),
|
||||
"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")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuleSet {
|
||||
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
|
||||
if self.rules.is_empty() {
|
||||
return self.mode != Match::Any;
|
||||
}
|
||||
match self.mode {
|
||||
Match::All => self.rules.iter().all(|r| r.matches(p, cat)),
|
||||
Match::Any => self.rules.iter().any(|r| r.matches(p, cat)),
|
||||
Match::None => !self.rules.iter().any(|r| r.matches(p, cat)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unknown fields or operators (for command validation), as readable messages.
|
||||
pub fn problems(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for r in &self.rules {
|
||||
match r {
|
||||
Rule::Group { group } => out.extend(group.problems()),
|
||||
Rule::Field { field, op, .. } => 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}`")),
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A short readable summary ("rating is ≥ 3 and keywords contains travel").
|
||||
pub fn describe(&self) -> String {
|
||||
let join = match self.mode {
|
||||
Match::All => " and ",
|
||||
Match::Any => " or ",
|
||||
Match::None => " nor ",
|
||||
};
|
||||
let parts: Vec<String> = self
|
||||
.rules
|
||||
.iter()
|
||||
.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 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::Null => String::new(),
|
||||
Value::Object(o) => format!(
|
||||
"{} {}",
|
||||
o.get("n").map(|n| n.to_string()).unwrap_or_default(),
|
||||
o.get("unit").and_then(Value::as_str).unwrap_or("days")
|
||||
),
|
||||
Value::Array(a) => a.iter().map(|x| x.as_str().map_or_else(|| x.to_string(), str::to_string)).collect::<Vec<_>>().join(" – "),
|
||||
other => other.to_string(),
|
||||
};
|
||||
format!("{label} {op} {v}").trim().to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let s = parts.join(join);
|
||||
if self.mode == Match::None { format!("none of: {s}") } else { s }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ColorLabel, Flag, PhotoId, Source};
|
||||
use serde_json::json;
|
||||
|
||||
fn photo(id: u64) -> Photo {
|
||||
let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, "IMG_0042.CR2", "CR2", 6000, 4000, "2026-09-20T10:00:00");
|
||||
p.rating = 4;
|
||||
p.flag = Flag::Pick;
|
||||
p.label = Some(ColorLabel::Red);
|
||||
p.captured = Some("2026-08-14T18:30:00".into());
|
||||
p.meta.keywords = vec!["travel|italy|rome".into(), "food".into()];
|
||||
p.meta.camera = "Model X2".into();
|
||||
p.meta.iso = Some(1600);
|
||||
p.meta.aperture = Some(2.8);
|
||||
p
|
||||
}
|
||||
|
||||
fn rs(v: serde_json::Value) -> RuleSet {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_ops_and_groups() {
|
||||
let cat = Catalog::new();
|
||||
let p = photo(1);
|
||||
let yes = |v: serde_json::Value| assert!(rs(v.clone()).matches(&p, &cat), "{v}");
|
||||
let no = |v: serde_json::Value| assert!(!rs(v.clone()).matches(&p, &cat), "{v}");
|
||||
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "flag", "op": "is", "value": "pick"}]}));
|
||||
no(json!({"rules": [{"field": "rating", "op": "gt", "value": 4}]}));
|
||||
yes(json!({"match": "any", "rules": [{"field": "rating", "op": "is", "value": 1}, {"field": "label", "op": "is", "value": "red"}]}));
|
||||
no(json!({"match": "none", "rules": [{"field": "label", "op": "is", "value": "red"}]}));
|
||||
yes(json!({"rules": [{"field": "keywords", "op": "is", "value": "italy"}]}));
|
||||
yes(
|
||||
json!({"rules": [{"field": "keywords", "op": "contains", "value": "ROME"}, {"field": "keywords", "op": "notContains", "value": "beach"}]}),
|
||||
);
|
||||
no(json!({"rules": [{"field": "keywords", "op": "isEmpty"}]}));
|
||||
yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]}));
|
||||
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"}]}));
|
||||
yes(json!({"rules": [{"field": "megapixels", "op": "gte", "value": 24}]}));
|
||||
yes(
|
||||
json!({"rules": [{"field": "captureDate", "op": "is", "value": "2026-08"}, {"field": "captureDate", "op": "before", "value": "2026-09-01"}]}),
|
||||
);
|
||||
yes(json!({"rules": [{"field": "captureDate", "op": "between", "value": ["2026-08-01", "2026-08"]}]}));
|
||||
no(json!({"rules": [{"field": "captureDate", "op": "after", "value": "2026-08"}]}));
|
||||
yes(json!({"rules": [{"field": "kind", "op": "isNot", "value": "video"}, {"field": "edited", "op": "is", "value": false}]}));
|
||||
// nested: rating ≥ 4 and (label is blue or keywords contain food)
|
||||
yes(json!({"rules": [{"field": "rating", "op": "gte", "value": 4}, {"group": {"match": "any", "rules": [
|
||||
{"field": "label", "op": "is", "value": "blue"}, {"field": "keywords", "op": "contains", "value": "food"}]}}]}));
|
||||
// in the last N days, relative to now
|
||||
set_now(Some("2026-09-01T00:00:00".into()));
|
||||
yes(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 30, "unit": "days"}}]}));
|
||||
no(json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "weeks"}}]}));
|
||||
yes(json!({"rules": [{"field": "captureDate", "op": "notInLast", "value": 7}]}));
|
||||
set_now(None);
|
||||
// an empty rule list: all → everything, any → nothing
|
||||
yes(json!({"rules": []}));
|
||||
no(json!({"match": "any", "rules": []}));
|
||||
}
|
||||
|
||||
#[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 r = rs(
|
||||
json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}),
|
||||
);
|
||||
assert_eq!(r.describe(), "rating is ≥ 3 or capture date is in the last 2 weeks");
|
||||
// every field has a kind with operators
|
||||
for (f, _, k) in FIELDS {
|
||||
assert!(!ops_for(*k).is_empty(), "{f}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -441,7 +441,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"New Smart Album…",
|
||||
[],
|
||||
None,
|
||||
"{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album), parent?: folderId} — without `rules`, saves the current view (source + filter)",
|
||||
"{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album, ruleSet: {match: all|any|none, rules: [{field, op, value} | {group: ruleSet}]} — see album.ruleFields), parent?: folderId} — without `rules`, saves the current view (source + filter)",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string();
|
||||
|
|
@ -459,6 +459,27 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
Ok(json!({"id": id.0, "count": s.catalog.album_count(id)}))
|
||||
}
|
||||
),
|
||||
cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind, ops: [{op, label}], choices?}] for ruleSet rules", always, |_, _| {
|
||||
use lightcraft_catalog::rules::{FIELDS, Kind, ops_for};
|
||||
Ok(json!(FIELDS
|
||||
.iter()
|
||||
.map(|(id, label, kind)| {
|
||||
let k = match kind {
|
||||
Kind::Text => "text",
|
||||
Kind::Keywords => "keywords",
|
||||
Kind::Number => "number",
|
||||
Kind::Date => "date",
|
||||
Kind::Choice(_) => "choice",
|
||||
Kind::Bool => "bool",
|
||||
};
|
||||
let mut v = json!({"field": id, "label": label, "kind": k, "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
|
||||
if let Kind::Choice(c) = kind {
|
||||
v["choices"] = json!(c);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect::<Vec<_>>()))
|
||||
}),
|
||||
cmd!(
|
||||
"album.setRules",
|
||||
"Edit Smart Album",
|
||||
|
|
@ -689,7 +710,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
|
||||
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 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));
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from
|
||||
|
|
|
|||
|
|
@ -420,6 +420,8 @@ impl Session {
|
|||
pub fn visible(&mut self) -> &[PhotoId] {
|
||||
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse));
|
||||
if self.visible_key.as_ref() != Some(&key) {
|
||||
// "in the last N days" rules count back from the session's clock
|
||||
lightcraft_catalog::rules::set_now(Some((self.clock)()));
|
||||
let mut f = self.source.to_filter(&self.filter, &self.catalog);
|
||||
if self.source == LibrarySource::Folder {
|
||||
// no folder chosen: nothing (an empty path matches nothing)
|
||||
|
|
|
|||
|
|
@ -152,3 +152,40 @@ fn profile_favorites_and_recent_survive_reopen() {
|
|||
assert!(list.as_array().unwrap().iter().any(|p| p["id"] == "lc.bw.sepia" && p["favorite"] == true));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Smart albums with a rule set: all / any / none, nested groups, validation, live updates.
|
||||
#[test]
|
||||
fn smart_album_rule_sets() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
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));
|
||||
let bad = s.execute(
|
||||
"album.createSmart",
|
||||
&serde_json::json!({"name": "Bad", "rules": {"ruleSet": {"rules": [{"field": "rating", "op": "contains", "value": 1}]}}}),
|
||||
);
|
||||
assert!(bad.is_err(), "operators are checked");
|
||||
let r = s
|
||||
.execute(
|
||||
"album.createSmart",
|
||||
&serde_json::json!({"name": "Good ones", "rules": {"ruleSet": {"match": "all", "rules": [
|
||||
{"field": "rating", "op": "gte", "value": 4},
|
||||
{"group": {"match": "none", "rules": [{"field": "flag", "op": "is", "value": "reject"}]}}
|
||||
]}}}),
|
||||
)
|
||||
.unwrap();
|
||||
let id = lightcraft_catalog::AlbumId(r["id"].as_u64().unwrap());
|
||||
let want = s.catalog.photos().filter(|p| !p.deleted && p.rating >= 4 && p.flag != lightcraft_catalog::Flag::Reject).count();
|
||||
assert!(want > 0);
|
||||
assert_eq!(s.catalog.album_count(id), want);
|
||||
// live: a new 5-star photo joins
|
||||
let other = s.catalog.photos().find(|p| p.rating < 4 && !p.deleted).unwrap().id;
|
||||
s.execute("photo.rate", &serde_json::json!({"ids": [other.0], "rating": 5})).unwrap();
|
||||
assert_eq!(s.catalog.album_count(id), want + 1);
|
||||
// the same rules work as a library filter (agents: library.filter {ruleSet})
|
||||
s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "is", "value": 5}]}})).unwrap();
|
||||
assert!(s.visible_cloned().iter().all(|id| s.catalog.photo(*id).unwrap().rating == 5));
|
||||
let list = s.execute("albums.list", &serde_json::json!({})).unwrap();
|
||||
let a = list.as_array().unwrap().iter().find(|a| a["name"] == "Good ones").unwrap().clone();
|
||||
assert!(a.to_string().contains("rating is ≥ 4"), "{a}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
|||
"---",
|
||||
"dialog.newAlbum",
|
||||
"dialog.newFolder",
|
||||
"dialog.smartAlbum",
|
||||
"dialog.newSmartAlbum",
|
||||
"---",
|
||||
"file.importPresets",
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
("brush.featherMore", "Increase Brush Feather", Some("Shift+]"), "Window>Tools"),
|
||||
("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"),
|
||||
("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"),
|
||||
("dialog.smartAlbum", "New Smart Album…", None, "File"),
|
||||
("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"),
|
||||
("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"),
|
||||
("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"),
|
||||
|
|
@ -521,6 +522,26 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
|||
app.ui.dialog = Some(Dialog::AutoStack { gap: p.get("gap").and_then(Value::as_f64).unwrap_or(60.0) as f32 });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"dialog.smartAlbum" => {
|
||||
// {id?}: edit that smart album's rules; without: a new one starting at Rating ≥ 3
|
||||
let album = p
|
||||
.get("id")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id)).filter(|a| a.is_smart()).cloned());
|
||||
app.ui.dialog = Some(match album {
|
||||
Some(a) => 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(),
|
||||
},
|
||||
None => Dialog::SmartRules {
|
||||
id: None,
|
||||
name: p.get("name").and_then(Value::as_str).unwrap_or("").into(),
|
||||
rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() },
|
||||
},
|
||||
});
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"dialog.newSmartAlbum" => {
|
||||
app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into() });
|
||||
Ok(Value::Null)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
Dialog::RenameKeyword { .. } => "Rename Keyword",
|
||||
Dialog::MergeKeywords { .. } => "Merge Keywords",
|
||||
Dialog::NewSmartAlbum { .. } => "Create Smart Album",
|
||||
Dialog::SmartRules { id: None, .. } => "New Smart Album",
|
||||
Dialog::SmartRules { .. } => "Edit Smart Album",
|
||||
Dialog::AutoStack { .. } => "Auto-Stack by Capture Time",
|
||||
Dialog::CreatePreset { .. } => "Create Preset",
|
||||
Dialog::CopySettings { .. } => "Choose Edit Settings to Copy",
|
||||
|
|
@ -48,7 +50,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
.resizable(false)
|
||||
.frame(frame)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.default_width(if matches!(dlg, Dialog::Import { .. }) { 760.0 } else { 380.0 })
|
||||
.default_width(match dlg {
|
||||
Dialog::Import { .. } => 760.0,
|
||||
Dialog::SmartRules { .. } => 680.0,
|
||||
_ => 380.0,
|
||||
})
|
||||
.show(ctx, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
match &mut dlg {
|
||||
|
|
@ -67,6 +73,25 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
egui::RichText::new(format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"])).color(t.text_dim),
|
||||
);
|
||||
}
|
||||
Dialog::SmartRules { name, rules, .. } => {
|
||||
let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY));
|
||||
crate::widgets::register(ui.ctx(), "field:smartName", r.rect);
|
||||
ui.add_space(6.0);
|
||||
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 f = lightcraft_catalog::Filter { rule_set: Some(rules.clone()), ..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 => format!("{n} photo{} match · updates automatically as photos change", if n == 1 { "" } else { "s" }),
|
||||
})
|
||||
.color(t.text_dim),
|
||||
);
|
||||
}
|
||||
Dialog::NewSmartAlbum { name } => {
|
||||
let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY));
|
||||
r.request_focus();
|
||||
|
|
@ -654,6 +679,18 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
|||
Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})),
|
||||
Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})),
|
||||
Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})),
|
||||
Dialog::SmartRules { id, name, rules } => {
|
||||
let name = if name.trim().is_empty() { "Smart Album".to_string() } else { name.trim().to_string() };
|
||||
match id {
|
||||
Some(id) => {
|
||||
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}}))
|
||||
}
|
||||
None => app.run("album.createSmart", json!({"name": name, "rules": {"ruleSet": rules}})),
|
||||
}
|
||||
}
|
||||
Dialog::NewSmartAlbum { name } => app.run("album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }})),
|
||||
Dialog::CreatePreset { name, group, groups } => app.run(
|
||||
"preset.create",
|
||||
|
|
|
|||
|
|
@ -95,6 +95,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
if ui.button("Create Album…").clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false });
|
||||
}
|
||||
if ui.button("Create Smart Album…").clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::SmartRules {
|
||||
id: None,
|
||||
name: String::new(),
|
||||
rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() },
|
||||
});
|
||||
}
|
||||
if ui.button("Create Smart Album from Filter…").clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new() });
|
||||
}
|
||||
|
|
@ -287,6 +294,11 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) {
|
|||
if !a.folder && !a.is_smart() && ui.button("Add Selected Photos").clicked() {
|
||||
let _ = app.run("album.addPhotos", json!({"id": a.id.0}));
|
||||
}
|
||||
if a.is_smart() && ui.button("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 });
|
||||
}
|
||||
if a.is_smart() && ui.button("Update Rules from Current Filter").clicked() {
|
||||
let _ = app.run("album.setRules", json!({"id": a.id.0, "fromView": true}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub mod masking;
|
|||
pub mod presets;
|
||||
pub mod profiles;
|
||||
pub mod right;
|
||||
pub mod rules_editor;
|
||||
pub mod settings;
|
||||
pub mod strip;
|
||||
pub mod topbar;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
//! The smart-album rule editor: match all / any / none of a list of rules (field, operator,
|
||||
//! 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::{Match, Rule, RuleSet};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// A sensible starting value for `field` / `op`.
|
||||
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!(["", ""]),
|
||||
(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::Bool), _) => json!(true),
|
||||
_ => json!(""),
|
||||
}
|
||||
}
|
||||
|
||||
/// A new rule (Rating ≥ 3).
|
||||
pub fn new_rule() -> Rule {
|
||||
Rule::Field { field: "rating".into(), op: "gte".into(), value: json!(3) }
|
||||
}
|
||||
|
||||
fn match_combo(ui: &mut egui::Ui, salt: &str, m: &mut Match) {
|
||||
let label = |m: Match| match m {
|
||||
Match::All => "all",
|
||||
Match::Any => "any",
|
||||
Match::None => "none",
|
||||
};
|
||||
egui::ComboBox::from_id_salt(format!("{salt}-match")).width(64.0).selected_text(label(*m)).show_ui(ui, |ui| {
|
||||
for x in [Match::All, Match::Any, Match::None] {
|
||||
ui.selectable_value(m, x, label(x));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn text_value(ui: &mut egui::Ui, v: &mut Value, width: f32, hint: &str, salt: &str) {
|
||||
let mut s = match &*v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut s).hint_text(hint).desired_width(width));
|
||||
register(ui.ctx(), format!("field:{salt}"), r.rect);
|
||||
if r.changed() {
|
||||
*v = json!(s);
|
||||
}
|
||||
}
|
||||
|
||||
fn number_value(ui: &mut egui::Ui, v: &mut Value, field: &str) {
|
||||
let mut n = v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok())).unwrap_or(0.0);
|
||||
let (lo, hi, speed) = match field {
|
||||
"rating" => (0.0, 5.0, 0.05),
|
||||
"iso" => (0.0, 409_600.0, 10.0),
|
||||
"aperture" => (0.0, 64.0, 0.05),
|
||||
_ => (0.0, 100_000.0, 0.5),
|
||||
};
|
||||
let whole = matches!(field, "rating" | "iso" | "album");
|
||||
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() {
|
||||
*v = if whole { json!(n.round() as i64) } else { json!(n) };
|
||||
}
|
||||
}
|
||||
|
||||
/// The value editor for one rule.
|
||||
fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: &str) {
|
||||
match (field_kind(field), op) {
|
||||
(_, "isEmpty" | "isNotEmpty") => {}
|
||||
(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();
|
||||
egui::ComboBox::from_id_salt(format!("{salt}-unit")).width(70.0).selected_text(&unit).show_ui(ui, |ui| {
|
||||
for u in ["hours", "days", "weeks", "months", "years"] {
|
||||
if ui.selectable_label(unit == u, u).clicked() {
|
||||
v["unit"] = json!(u);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
(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 };
|
||||
if k == Kind::Number {
|
||||
number_value(ui, &mut a[0], field);
|
||||
ui.label("and");
|
||||
number_value(ui, &mut a[1], field);
|
||||
} else {
|
||||
text_value(ui, &mut a[0], 86.0, "2026-01-01", &format!("{salt}-a"));
|
||||
ui.label("and");
|
||||
text_value(ui, &mut a[1], 86.0, "2026-12", &format!("{salt}-b"));
|
||||
}
|
||||
}
|
||||
(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(&cur).show_ui(ui, |ui| {
|
||||
for x in c.iter() {
|
||||
if ui.selectable_label(cur == *x, *x).clicked() {
|
||||
*v = json!(x);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
(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(if b { "true" } else { "false" }).show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut b, true, "true");
|
||||
ui.selectable_value(&mut b, false, "false");
|
||||
});
|
||||
*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),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("Match").color(t.text_label));
|
||||
match_combo(ui, salt, &mut rs.mode);
|
||||
ui.label(RichText::new("of the following rules:").color(t.text_label));
|
||||
});
|
||||
let mut remove = None;
|
||||
let mut insert: Option<(usize, Rule)> = None;
|
||||
for (i, rule) in rs.rules.iter_mut().enumerate() {
|
||||
let rsalt = format!("{salt}-{i}");
|
||||
match rule {
|
||||
Rule::Group { group } => {
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.small_button("−").on_hover_text("Remove this group").clicked() {
|
||||
remove = Some(i);
|
||||
}
|
||||
ui.vertical(|ui| edit(ui, group, &rsalt, depth + 1));
|
||||
});
|
||||
});
|
||||
}
|
||||
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(label).show_ui(ui, |ui| {
|
||||
for (id, l, k) in FIELDS {
|
||||
if ui.selectable_label(field == id, *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);
|
||||
}
|
||||
}
|
||||
});
|
||||
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(op_label).show_ui(ui, |ui| {
|
||||
for (id, l) in ops_for(kind) {
|
||||
if ui.selectable_label(op == id, *l).clicked() && op != id {
|
||||
let was_special = matches!(op.as_str(), "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty");
|
||||
*op = id.to_string();
|
||||
if was_special || matches!(*id, "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty") {
|
||||
*value = default_value(field, op);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
value_editor(ui, field, op, value, &rsalt);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let add = ui.small_button("+").on_hover_text("Add a rule (⌥-click: a group)");
|
||||
register(ui.ctx(), format!("button:ruleAdd-{rsalt}"), add.rect);
|
||||
if add.clicked() {
|
||||
let group = ui.input(|i| i.modifiers.alt) && depth < 3;
|
||||
insert = Some((
|
||||
i + 1,
|
||||
if group { Rule::Group { group: RuleSet { mode: Match::Any, rules: vec![new_rule()] } } } else { new_rule() },
|
||||
));
|
||||
}
|
||||
let del = ui.small_button("−").on_hover_text("Remove this rule");
|
||||
register(ui.ctx(), format!("button:ruleRemove-{rsalt}"), del.rect);
|
||||
if del.clicked() {
|
||||
remove = Some(i);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(i) = remove {
|
||||
rs.rules.remove(i);
|
||||
}
|
||||
if let Some((i, r)) = insert {
|
||||
rs.rules.insert(i.min(rs.rules.len()), r);
|
||||
}
|
||||
if rs.rules.is_empty() || depth == 0 {
|
||||
ui.horizontal(|ui| {
|
||||
let add = ui.small_button("+ Rule");
|
||||
register(ui.ctx(), format!("button:ruleAddEnd-{salt}"), add.rect);
|
||||
if add.clicked() {
|
||||
rs.rules.push(new_rule());
|
||||
}
|
||||
if depth < 3 && ui.small_button("+ Group").clicked() {
|
||||
rs.rules.push(Rule::Group { group: RuleSet { mode: Match::Any, rules: vec![new_rule()] } });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -347,6 +347,12 @@ pub enum Dialog {
|
|||
NewSmartAlbum {
|
||||
name: String,
|
||||
},
|
||||
/// Create (`id` None) or edit a smart album's rules.
|
||||
SmartRules {
|
||||
id: Option<u64>,
|
||||
name: String,
|
||||
rules: lightcraft_catalog::RuleSet,
|
||||
},
|
||||
/// `groups`: the settings groups the preset includes (`SettingsGroup` ids).
|
||||
CreatePreset {
|
||||
name: String,
|
||||
|
|
|
|||
|
|
@ -254,3 +254,29 @@ fn develop_photo_keywords(h: &Headless) -> Vec<String> {
|
|||
let id = h.app.session.active().expect("active photo");
|
||||
h.app.session.catalog.photo(id).unwrap().meta.keywords.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_album_rule_editor_creates_and_edits() {
|
||||
let mut h = detail("panel.edit");
|
||||
exec(&mut h, "dialog.smartAlbum", json!({"name": "Keepers"}));
|
||||
// the editor starts with Rating ≥ 3; "+" adds a second rule
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:ruleAdd-rules-0"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let Some(crate::state::Dialog::SmartRules { rules, .. }) = &mut h.app.ui.dialog else { panic!("no rule editor") };
|
||||
assert_eq!(rules.rules.len(), 2);
|
||||
rules.rules[1] = serde_json::from_value(json!({"field": "flag", "op": "isNot", "value": "reject"})).unwrap();
|
||||
let r = h.request("ui.dialog.confirm", json!({}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let a = h.app.session.catalog.albums().find(|a| a.name == "Keepers").expect("album").clone();
|
||||
let n = h.app.session.catalog.photos().filter(|p| !p.deleted && p.rating >= 3 && p.flag != lightcraft_catalog::Flag::Reject).count();
|
||||
assert_eq!(h.app.session.catalog.album_count(a.id), n);
|
||||
// edit: back to one rule
|
||||
exec(&mut h, "dialog.smartAlbum", json!({"id": a.id.0}));
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:ruleRemove-rules-1"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let r = h.request("ui.dialog.confirm", json!({}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let n3 = h.app.session.catalog.photos().filter(|p| !p.deleted && p.rating >= 3).count();
|
||||
assert_eq!(h.app.session.catalog.album_count(a.id), n3);
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||
| X. Cross-cutting behaviours (BEHAV) | 13 | 0 | 4 | 1 | 8/8 (100%) | 5/5 (100%) |
|
||||
| Y. Menus | 70 | 7 | 8 | 8 | 46/47 (98%) | 19/23 (83%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 68 | 8 | 4 | 1 | 49/52 (94%) | 17/23 (74%) |
|
||||
| Lightroom Classic extras | 15 | 15 | 49 | 9 | — | 14/21 (67%) |
|
||||
| **Total** | 330 | 46 | 124 | 37 | 193/198 (97%) | 117/144 (81%) |
|
||||
| Lightroom Classic extras | 16 | 14 | 49 | 9 | — | 15/21 (71%) |
|
||||
| **Total** | 331 | 45 | 124 | 37 | 193/198 (97%) | 118/144 (82%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **70.6%** of 500 in-scope rows — P0 98.7% of 198 · P1 89.2% of 144 · P2 18.4% of 158.
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **70.7%** of 500 in-scope rows — P0 98.7% of 198 · P1 89.6% of 144 · P2 18.4% of 158.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
|
|
@ -695,7 +695,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
|||
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) |
|
||||
| LRC-LIB-FOLDERS | Disk folder tree | P1 | 🟡 | `cmd:library.browse`, `crates/ui-egui/src/panels/left.rs` (`local_section`) | standard places + the browsed folder + Browse Folder…, breadcrumb navigation upwards; no expandable folder tree, no folder rename/move |
|
||||
| LRC-LIB-COLLECTIONS | Collections & sets | P1 | 🟡 | `cmd:album.create` | albums + folders; no smart / quick / target collections |
|
||||
| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | 🟡 | `cmd:album.createSmart`, `crates/catalog/src/query.rs` | the filter fields + date range; no rule editor, any/none groups or operators beyond ≥/=/≤ |
|
||||
| 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), 26 fields (rating, flag, label, type, edits, keywords, any text, filename, format, title, caption, camera, lens, location, creator, copyright, 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-PUBLISH | Publish services | P2 | ⬜ | | |
|
||||
| LRC-LIB-FILTERBAR | Library filter bar | P1 | 🟡 | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; no lock or multi-select columns |
|
||||
| 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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue