# Conflicts:
#	crates/catalog/src/tests.rs
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	crates/ui-egui/src/lib.rs
#	docs/parity.md
This commit is contained in:
Brandon Thomas
2026-10-09 20:54:53 -04:00
43 changed files with 4422 additions and 219 deletions
Generated
+1
View File
@@ -2998,6 +2998,7 @@ version = "0.4.0"
dependencies = [
"crc32fast",
"lightcraft-develop",
"lightcraft-geom",
"lightcraft-meta",
"log",
"proptest",
+1
View File
@@ -19,4 +19,5 @@ log = { workspace = true }
web-time = { workspace = true }
[dev-dependencies]
lightcraft-geom = { workspace = true }
proptest = { workspace = true }
+17 -3
View File
@@ -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<String> {
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);
}
+49 -2
View File
@@ -329,12 +329,57 @@ 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::smart_album_holds(id, p.id, || rules.matches(p, self)),
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: std::collections::HashSet<AlbumId> = std::collections::HashSet::new();
let mut next = vec![from];
while let Some(a) = next.pop() {
if !seen.insert(a) {
continue;
}
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;
}
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<rules::Problem> {
let Some(filter) = self.albums.get(&id).and_then(|a| a.smart.as_deref()) else { return Vec::new() };
let mut out: Vec<rules::Problem> = 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<AlbumId>) -> Option<rules::Problem> {
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).
pub fn album_photos(&self, id: AlbumId) -> Vec<PhotoId> {
match self.albums.get(&id) {
@@ -378,7 +423,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<ColorLabel> {
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
+51
View File
@@ -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<f64> {
let s = s.trim().trim_end_matches(['s', '"']).trim();
match s.split_once('/') {
Some((n, d)) => Some(n.trim().parse::<f64>().ok()? / d.trim().parse::<f64>().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)]
@@ -377,6 +388,46 @@ impl Photo {
d.process = self.develop.process;
d
}
/// The file name's extension without the dot, as written (`CR2` for `IMG_0042.CR2`); empty
/// when the name has none (`README`, a dot file such as `.hidden`).
pub fn extension(&self) -> &str {
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
/// 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.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.
pub fn keyword_count(&self) -> usize {
let mut seen: Vec<String> = 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
+18 -2
View File
@@ -154,12 +154,21 @@ 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 {
/// 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<String> = Vec::new();
if self.rating > 0 {
let op = match self.rating_op {
@@ -207,11 +216,18 @@ 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(", ") }
}
/// The albums this filter tests: its album field and its rules' Album rules.
pub fn albums_tested(&self) -> Vec<crate::AlbumId> {
let mut out: Vec<crate::AlbumId> = 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)
File diff suppressed because it is too large Load Diff
+153
View File
@@ -476,6 +476,159 @@ fn set_embedded_lens_is_undoable_and_journaled() {
assert_eq!(c.photo(a).unwrap().embedded_lens, None);
}
/// 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<_>>(), 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<ColorLabel>, 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::<Vec<_>>();
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: 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();
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");
}
}
/// 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”"));
}
/// 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());
}
/// 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<String> = c.smart_album_problems(AlbumId(1)).iter().map(ToString::to_string).collect();
assert!(shown.iter().all(|s| !s.starts_with("rule :")), "{shown:?}");
}
#[test]
fn undated_photos_group_under_unknown_date_and_sort_together() {
let mut c = Catalog::new();
+77 -14
View File
@@ -362,6 +362,16 @@ pub fn specs() -> Vec<CommandSpec> {
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 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();
}
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::<Vec<_>>().join("; ")));
}
s.filter = f;
Ok(json!({"count": s.visible().len()}))
}
@@ -848,7 +858,7 @@ pub fn specs() -> Vec<CommandSpec> {
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)?,
None => view_rules(s),
};
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
@@ -858,8 +868,8 @@ 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};
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()
.map(|(id, label, kind)| {
@@ -867,13 +877,15 @@ pub fn specs() -> Vec<CommandSpec> {
Kind::Text => "text",
Kind::Keywords => "keywords",
Kind::Number => "number",
Kind::Album => "album",
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<_>>()});
let mut v = json!({"field": id, "label": label, "kind": k, "group": field_group(id), "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::<Vec<_>>()});
if let Kind::Choice(c) = kind {
v["choices"] = json!(c);
v["choices"] = json!(c.iter().map(|c| c.0).collect::<Vec<_>>());
v["choiceLabels"] = c.iter().map(|(id, label)| (id.to_string(), json!(label))).collect::<serde_json::Map<_, _>>().into();
}
v
})
@@ -884,18 +896,34 @@ pub fn specs() -> Vec<CommandSpec> {
"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")?;
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<String> = view
.rule_set
.as_ref()
.map(|r| r.check_for(&s.catalog, Some(id)))
.unwrap_or_default()
.into_iter()
.chain(s.catalog.album_filter_problem(&view, Some(id)))
.filter(|p| p.issue == lightcraft_catalog::rules::Issue::AlbumLoop)
.map(|p| p.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();
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, 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() {
@@ -903,7 +931,13 @@ pub fn specs() -> Vec<CommandSpec> {
}
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)}))
}
),
@@ -1343,12 +1377,39 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// `base` with a partial Filter (JSON) merged on top.
fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
/// `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<lightcraft_catalog::Filter> {
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 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 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(
base: &lightcraft_catalog::Filter,
patch: &Value,
c: &str,
cat: &lightcraft_catalog::Catalog,
owner: Option<AlbumId>,
) -> Result<lightcraft_catalog::Filter> {
let f = merge_filter(base, patch, c)?;
// 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 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::<Vec<_>>().join("; ")));
}
Ok(f)
}
@@ -1384,7 +1445,9 @@ fn view_rules(s: &Session) -> lightcraft_catalog::Filter {
}
}
}
merge_rules(&base, &Value::Object(patch), "").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);
+22 -5
View File
@@ -189,7 +189,13 @@ pub fn specs() -> Vec<CommandSpec> {
}
// 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<String> = 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)")))?;
@@ -207,17 +213,28 @@ pub fn specs() -> Vec<CommandSpec> {
}
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 })?;
+3 -1
View File
@@ -43,7 +43,9 @@ 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::<Vec<_>>());
}
v
}
+1 -11
View File
@@ -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<f64> {
let s = s.trim().trim_end_matches(['s', '"']).trim();
match s.split_once('/') {
Some((n, d)) => Some(n.trim().parse::<f64>().ok()? / d.trim().parse::<f64>().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<Metadata> {
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)
+235
View File
@@ -252,11 +252,43 @@ 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));
// 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 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}]}}}),
);
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");
// 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",
&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",
@@ -440,3 +472,206 @@ 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);
// 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
/// 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: 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();
}
/// 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");
}
/// 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}")]));
}
/// "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");
// 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}");
}
/// "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());
}
/// 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());
}
/// 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);
}
+16
View File
@@ -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");
+1 -1
View File
@@ -212,7 +212,7 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
_ => {}
}
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": []}));
+60 -1
View File
@@ -1893,5 +1893,64 @@
"Decrease Rating": "Bewertung verringern",
"Increase Rating": "Bewertung erhöhen",
"Click to type a value": "Klicken, um einen Wert einzugeben",
"Angle": "Winkel"
"Angle": "Winkel",
"Video": "Video",
"Copyrighted": "Urheberrechtlich geschützt",
"Public Domain": "Gemeinfrei",
"Unknown": "Unbekannt",
"Landscape (wide)": "Querformat",
"Portrait (tall)": "Hochformat",
"Square": "Quadratisch",
"In Color": "Farbig",
"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.",
"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 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“)",
"Mo": "Mo",
"Tu": "Di",
"We": "Mi",
"Th": "Do",
"Fr": "Fr",
"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",
"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",
"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"
}
+60 -1
View File
@@ -1738,5 +1738,64 @@
"Decrease Rating": "Reducir valoración",
"Increase Rating": "Aumentar valoración",
"Click to type a value": "Hacer clic para escribir un valor",
"Angle": "Ángulo"
"Angle": "Ángulo",
"Video": "Vídeo",
"Copyrighted": "Con copyright",
"Public Domain": "Dominio público",
"Unknown": "Desconocido",
"Landscape (wide)": "Horizontal (apaisada)",
"Portrait (tall)": "Vertical (retrato)",
"Square": "Cuadrada",
"In Color": "En color",
"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.",
"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 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»)",
"Mo": "Lu",
"Tu": "Ma",
"We": "Mi",
"Th": "Ju",
"Fr": "Vi",
"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",
"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",
"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"
}
+64 -3
View File
@@ -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": "時間",
@@ -1738,5 +1738,66 @@
"Decrease Rating": "評価を下げる",
"Increase Rating": "評価を上げる",
"Click to type a value": "クリックして値を入力",
"Angle": "角度"
"Angle": "角度",
"Video": "動画",
"Copyrighted": "著作権あり",
"Public Domain": "パブリックドメイン",
"Unknown": "不明",
"Landscape (wide)": "横長",
"Portrait (tall)": "縦長",
"Square": "正方形",
"In Color": "カラー",
"No Label": "ラベルなし",
"smart album value|Yes": "あり",
"smart album value|No": "なし",
"In a Stack": "スタック",
"Cropped": "切り抜き",
"Choose an album…": "アルバムを選択…",
"No albums yet": "アルバムはまだありません",
"Fix the marked rules to save this 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 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”)": "検索する語句が必要です(または「が空でない」を使用)",
"Mo": "月",
"Tu": "火",
"We": "水",
"Th": "木",
"Fr": "金",
"Sa": "土",
"Su": "日",
"Pick a date": "日付を選択",
"would make this album include itself through other smart albums": "ほかのスマートアルバムを通じて、このアルバム自身を含むことになります",
"Search albums": "アルバムを検索",
"No albums match": "一致するアルバムはありません",
"This is the album you're editing": "編集中のアルバムです",
"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": "編集"
}
+60 -1
View File
@@ -1738,5 +1738,64 @@
"Decrease Rating": "Diminuir avaliação",
"Increase Rating": "Aumentar avaliação",
"Click to type a value": "Clique para digitar um valor",
"Angle": "Ângulo"
"Angle": "Ângulo",
"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",
"In Color": "Colorida",
"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.",
"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 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”)",
"Mo": "Seg",
"Tu": "Ter",
"We": "Qua",
"Th": "Qui",
"Fr": "Sex",
"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",
"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",
"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"
}
+96 -1
View File
@@ -1411,5 +1411,100 @@
"Decrease Rating": "Уменьшить оценку",
"Increase Rating": "Увеличить оценку",
"Click to type a value": "Щёлкните, чтобы ввести значение",
"Angle": "Угол"
"Angle": "Угол",
"Raw": "RAW",
"Video": "Видео",
"Copyrighted": "Защищено авторским правом",
"Public Domain": "Общественное достояние",
"Unknown": "Неизвестно",
"Landscape (wide)": "Альбомная",
"Portrait (tall)": "Книжная",
"Square": "Квадратная",
"In Color": "Цветное",
"No Label": "Без метки",
"Choose an album…": "Выберите альбом…",
"No albums yet": "Альбомов пока нет",
"Fix the marked rules to save this 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 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”)": "нужно указать, что искать (или выберите «не пусты»)",
"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": "начинается с",
"Mo": "Пн",
"Tu": "Вт",
"We": "Ср",
"Th": "Чт",
"Fr": "Пт",
"Sa": "Сб",
"Su": "Вс",
"Pick a date": "Выбрать дату",
"would make this album include itself through other smart albums": "альбом включал бы сам себя через другие смарт-альбомы",
"Search albums": "Поиск альбомов",
"No albums match": "Нет подходящих альбомов",
"This is the album you're editing": "Это альбом, который вы изменяете",
"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": "Цветовая метка"
}
+60 -1
View File
@@ -1738,5 +1738,64 @@
"Decrease Rating": "降低星级",
"Increase Rating": "提高星级",
"Click to type a value": "单击以输入数值",
"Angle": "角度"
"Angle": "角度",
"Video": "视频",
"Copyrighted": "受版权保护",
"Public Domain": "公共领域",
"Unknown": "未知",
"Landscape (wide)": "横向",
"Portrait (tall)": "纵向",
"Square": "正方形",
"In Color": "彩色",
"No Label": "无标签",
"Choose an album…": "选择相册…",
"No albums yet": "还没有相册",
"Fix the marked rules to save this 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 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”)": "需要要查找的内容(或使用“不为空”)",
"Mo": "一",
"Tu": "二",
"We": "三",
"Th": "四",
"Fr": "五",
"Sa": "六",
"Su": "日",
"Pick a date": "选择日期",
"would make this album include itself through other smart albums": "会通过其他智能相册让此相册包含自身",
"Search albums": "搜索相册",
"No albums match": "没有匹配的相册",
"This is the album you're editing": "这是正在编辑的相册",
"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": "编辑"
}
+60 -1
View File
@@ -1738,5 +1738,64 @@
"Decrease Rating": "降低評分",
"Increase Rating": "提高評分",
"Click to type a value": "點擊以輸入數值",
"Angle": "角度"
"Angle": "角度",
"Video": "影片",
"Copyrighted": "受著作權保護",
"Public Domain": "公有領域",
"Unknown": "未知",
"Landscape (wide)": "橫向",
"Portrait (tall)": "直向",
"Square": "正方形",
"In Color": "彩色",
"No Label": "無標籤",
"Choose an album…": "選擇相簿…",
"No albums yet": "還沒有相簿",
"Fix the marked rules to save this 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 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”)": "需要要尋找的內容(或使用「不為空」)",
"Mo": "一",
"Tu": "二",
"We": "三",
"Th": "四",
"Fr": "五",
"Sa": "六",
"Su": "日",
"Pick a date": "選擇日期",
"would make this album include itself through other smart albums": "會透過其他智慧型相簿讓此相簿包含自身",
"Search albums": "搜尋相簿",
"No albums match": "沒有符合的相簿",
"This is the album you're editing": "這是正在編輯的相簿",
"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": "編輯"
}
+431
View File
@@ -0,0 +1,431 @@
//! 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<u64>` 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:<id>` (the button), `albumPickerSearch:<id>`, `albumPickerFolder:<folder>:<id>`
//! and `albumPickerItem:<album>:<id>`.
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<u64>,
/// 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<String>,
}
/// 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<String>) -> Vec<AlbumEntry> {
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 seen = std::collections::HashSet::new();
fn walk(
parent: Option<lightcraft_catalog::AlbumId>,
kids: &std::collections::HashMap<Option<lightcraft_catalog::AlbumId>, Vec<&lightcraft_catalog::Album>>,
blocked: &dyn Fn(&lightcraft_catalog::Album) -> Option<String>,
seen: &mut std::collections::HashSet<lightcraft_catalog::AlbumId>,
out: &mut Vec<AlbumEntry>,
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);
}
}
}
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<u64, usize> {
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<u64, usize>, id: u64) -> Vec<u64> {
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<u64, usize>, 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 by_id = index(entries);
let depths: Vec<usize> = 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;
}
}
/// 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<u64> {
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 {
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<usize> {
let by_id = index(entries);
let open: std::collections::HashSet<u64> = 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
/// name starts with the query first, then the rest, each in tree order. Indexes into `entries`.
pub fn search(entries: &[AlbumEntry], query: &str) -> Vec<usize> {
let query = query.trim().to_lowercase();
let words: Vec<&str> = query.split_whitespace().collect();
if words.is_empty() {
return Vec::new();
}
let by_id = index(entries);
let hits: Vec<usize> = (0..entries.len())
.filter(|&i| !entries[i].folder)
.filter(|&i| {
let path = path_in(entries, &by_id, entries[i].id).to_lowercase();
words.iter().all(|w| path.contains(w))
})
.collect();
let (first, rest): (Vec<usize>, Vec<usize>) = 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<u64>,
/// 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<u64>,
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<String>, value: &'a mut Option<u64>, 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<u64> = 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());
// `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<u64>| {
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 scroll {
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);
// 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 {
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, 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<usize> = 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 {
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_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());
}
}
});
// 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<u64>, 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<AlbumEntry> {
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<_>>(), 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::<Vec<_>>();
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]);
}
/// 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<u64> = (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]
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::<Vec<_>>();
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::<u64>::new(), "an empty search isn't a search");
assert_eq!(ids("zzz"), Vec::<u64>::new());
let mut accents = vec![e(7, "Été", None, false, false)];
set_depths(&mut accents);
assert_eq!(search(&accents, "été").len(), 1);
}
}
+6 -2
View File
@@ -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);
+449
View File
@@ -0,0 +1,449 @@
//! 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:<id>` (the button),
//! `datePickerPrecision:<year|month|day>:<id>`, `datePickerPrev:<id>`, `datePickerNext:<id>` and
//! `datePickerCell:<iso>:<id>`.
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<u8>,
pub day: Option<u8>,
}
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<Self> {
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::<u32>().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))?;
// 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`.
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<Option<u8>> {
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<Option<u8>> = 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,
}
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<PickedDate>) -> 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,
value: &'a mut Option<PickedDate>,
precisions: &'a [Precision],
min: Option<PickedDate>,
max: Option<PickedDate>,
today: Option<PickedDate>,
}
impl<'a> DatePicker<'a> {
/// `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<String>, value: &'a mut Option<PickedDate>) -> 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<PickedDate>) -> Self {
self.min = min;
self
}
/// The latest date that can be picked.
pub fn max(mut self, max: Option<PickedDate>) -> 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<PickedDate>) -> 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::open(at, precision)));
}
let mut picked: Option<PickedDate> = None;
let mut view = ui.data(|d| d.get_temp::<View>(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);
if next.clicked() {
view = step(view, true);
}
if prev.clicked() {
view = step(view, false);
}
});
});
let cell = |ui: &mut Ui, date: PickedDate, text: String, picked: &mut Option<PickedDate>| {
let allowed = in_bounds(&date, min.as_ref(), max.as_ref());
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() {
*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();
}
}
});
// 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();
}
});
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");
}
/// 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();
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));
}
}
+218 -9
View File
@@ -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
/// `"<context>|<source>"` 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 {
@@ -308,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()
@@ -318,9 +328,62 @@ pub fn filter_label(filter: &lightcraft_catalog::Filter, catalog: &lightcraft_ca
}
}
/// 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_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());
// 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!("{}{colon}{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<String> = 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))
}
/// 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))
}
/// 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)
}
/// 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_ctx_in(language, BOOL_CONTEXT, 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 {
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},
@@ -332,14 +395,15 @@ 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 {
Kind::Choice(_) | Kind::Bool => {
tr(value.as_str().unwrap_or(if value.as_bool() == Some(true) { "true" } else { "false" })).to_string()
}
// 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!(
"{} {}",
value.get("n").unwrap_or(&serde_json::Value::Null),
@@ -359,7 +423,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)]
@@ -627,6 +691,125 @@ mod tests {
}
}
/// 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");
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");
// 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");
// 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 {
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());
}
}
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());
}
}
}
}
}
/// 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::<lightcraft_catalog::RuleSet>(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”)");
// 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
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",
"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 {
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());
}
}
}
}
/// 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", "Year", "Month", "Day"]) {
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
@@ -640,6 +823,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();
@@ -703,7 +887,32 @@ 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 {
field: "copyrightStatus".into(),
op: "is".into(),
value: serde_json::json!("publicDomain"),
}],
};
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, &catalog).ends_with(" Nein"), "{}", rules_label(&rules, &catalog));
}
let filter = lightcraft_catalog::Filter {
labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue],
..Default::default()
+9
View File
@@ -55,6 +55,7 @@ pub enum Icon {
Pencil,
Photos,
Clock,
Calendar,
Trash,
Curve,
ProfileGrid,
@@ -361,6 +362,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)]);
+106
View File
@@ -6,8 +6,10 @@
#![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;
pub mod export_task;
pub mod headless;
pub mod i18n;
@@ -35,8 +37,12 @@ pub mod widgets;
#[cfg(test)]
mod tests_crop_rotate;
#[cfg(test)]
mod tests_album_picker;
#[cfg(test)]
mod tests_curve;
#[cfg(test)]
mod tests_date_picker;
#[cfg(test)]
mod tests_filmstrip;
#[cfg(test)]
mod tests_grid;
@@ -1082,6 +1088,12 @@ pub struct Caches {
album_counts: Option<(u64, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>>)>,
/// 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<RulesView>)>,
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<std::collections::HashMap<lightcraft_catalog::AlbumId, Vec<lightcraft_catalog::rules::Problem>>>)>,
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.
@@ -1131,6 +1143,14 @@ pub struct FilterValues {
pub keywords: Vec<String>,
}
/// 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<lightcraft_catalog::rules::Problem>,
pub count: usize,
pub albums: Vec<album_picker::AlbumEntry>,
}
pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 {
use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher};
BuildHasherDefault::<DefaultHasher>::default().hash_one(parts)
@@ -1258,6 +1278,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<u64>,
folder: Option<String>,
now: &str,
) -> std::sync::Arc<RulesView> {
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<std::collections::HashMap<lightcraft_catalog::AlbumId, Vec<lightcraft_catalog::rules::Problem>>> {
if let Some((rev, v)) = &self.smart_problems
&& *rev == cat.revision
{
return v.clone();
}
let v: std::sync::Arc<std::collections::HashMap<_, _>> = 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,
@@ -1333,6 +1403,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() {
+10 -1
View File
@@ -887,7 +887,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
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(),
// 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 {
+1 -1
View File
@@ -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();
+45 -22
View File
@@ -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
@@ -242,22 +247,25 @@ 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);
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();
// 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 };
// 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 n = view.count;
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 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),
);
} 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));
@@ -836,7 +844,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 { 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);
@@ -857,8 +868,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 {
@@ -1019,6 +1031,17 @@ fn created_in(app: &mut LightcraftApp, command: &str, params: serde_json::Value)
Ok(r)
}
/// 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.
pub(crate) fn album_entries(cat: &lightcraft_catalog::Catalog, editing: Option<u64>) -> Vec<crate::album_picker::AlbumEntry> {
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()),
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<serde_json::Value, String> {
match dlg {
Dialog::SamModel { then, .. } => {
@@ -1078,14 +1101,14 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
app.run("photo.analyze", p)
}
Dialog::SmartRules { id, name, rules, parent } => {
// refused before anything changes (not even the name)
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() };
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}}))
}
// 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})),
}
}
+24 -5
View File
@@ -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.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() {
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_line(p));
}
}
resp = resp.on_hover_text(tip);
}
if resp.clicked() {
let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0}));
@@ -1038,12 +1054,15 @@ 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}));
// 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
+258 -59
View File
@@ -2,10 +2,12 @@
//! 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, Problem, TOP_LEVEL_FIELDS, bool_value, field_kind, field_label, ops_for};
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;
@@ -14,15 +16,30 @@ 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]),
(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),
// picked from the list
(Some(Kind::Album), _) => Value::Null,
_ => json!(""),
}
}
/// 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) }
@@ -60,81 +77,221 @@ 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),
"longEdge" | "shortEdge" => (0.0, 100_000.0, 10.0),
_ => (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 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) };
}
}
/// 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`. 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<PickedDate>, max: Option<PickedDate>, 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<Problem>,
/// Today (`YYYY-MM-DD…`, the session's clock): where a date picker opens when a rule has no date.
pub today: 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<AlbumEntry>,
}
/// 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 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.
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);
}
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 {
// 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()),
};
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 };
if k == Kind::Number {
number_value(ui, &mut a[0], field);
// 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, lo, 60.0, "1/1000", &format!("{salt}-a"));
ui.label(crate::i18n::tr("and"));
number_value(ui, &mut a[1], field);
text_value(ui, hi, 60.0, "1/125", &format!("{salt}-b"));
} else if k == Kind::Number {
number_value(ui, lo, field);
ui.label(crate::i18n::tr("and"));
number_value(ui, hi, field);
} 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"));
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);
} else {
text_value(ui, &mut a[0], 86.0, "2026-01-01", &format!("{salt}-a"));
// 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, &mut a[1], 86.0, "2026-12", &format!("{salt}-b"));
text_value(ui, hi, 86.0, "", &format!("{salt}-b"));
}
if pair != before {
*v = pair;
}
}
// 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();
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), _) => {
// 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);
}
}
});
}
(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"));
});
*v = json!(b);
(Some(Kind::Date), _) => {
text_value(ui, v, 120.0, "2026-04 or 2026-04-12", salt);
date_picker(ui, v, salt, None, None, env);
}
(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) {
/// 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(&current.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);
}
/// A rule's problem, shown under it in the caution colour (`ruleProblem:<salt>` 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!("⚠ {}", crate::i18n::problem_text(p))).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));
@@ -145,6 +302,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<usize> = 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| {
@@ -152,27 +311,14 @@ 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 } => {
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| {
@@ -186,7 +332,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);
@@ -204,6 +350,7 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize) {
}
});
});
problem_note(ui, problem, &rsalt);
}
}
}
@@ -226,3 +373,55 @@ 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 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();
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) }],
};
// 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 {
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(),
Kind::Album => v.is_null(),
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}"),
}
}
}
// 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"]));
}
}
+5
View File
@@ -308,11 +308,15 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
let grid = library_grid(app);
// 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)
&& let Some(id) = grid_bracket_command(keymap, grid, b.id, (m, k))
{
@@ -321,6 +325,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)
{
+128
View File
@@ -0,0 +1,128 @@
//! 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");
}
/// 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");
}
/// 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");
}
+125
View File
@@ -0,0 +1,125 @@
//! 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");
}
/// 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");
}
/// 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");
}
/// 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");
}
+220
View File
@@ -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);
@@ -500,6 +501,225 @@ 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");
// 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);
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");
}
/// 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}");
}
/// 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 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");
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": "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!("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") };
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"));
}
/// 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");
// 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()));
}
/// 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");
}
/// 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");
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": "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!("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));
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");
+1 -1
View File
@@ -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]
+12
View File
@@ -19,6 +19,18 @@ pub fn register(ctx: &egui::Context, id: impl Into<String>, rect: Rect) {
ctx.data_mut(|d| d.get_temp_mut_or_default::<Registry>(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. 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));
}
/// Whether [`take_escape`] was called this frame.
pub fn escape_taken(ctx: &egui::Context) -> bool {
ctx.data(|d| d.get_temp::<u64>(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::<Registry>(egui::Id::new("lc-registry")).0))
}
+4
View File
@@ -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` 指定時のみ実行)。
+8
View File
@@ -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 `"<context>|<English>"`, 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:
+2 -2
View File
@@ -158,7 +158,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` | Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:<id>`; 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 |
@@ -745,7 +745,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/<host>`), NFS (`/Network/Servers/<host>`), 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 — 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 |
+36
View File
@@ -2,6 +2,42 @@
## 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 (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`).
- 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: 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
(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
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.
- 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
(`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 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.
### Masking
- Drag anywhere inside the selected radial gradient's ellipse to move it, including rotated
ellipses and radial components in compound masks. Edge and rotation handles keep their existing