From 4e3b15ff6f178a58fc4ff18a263df9050d8bc60f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 09:59:23 -0400 Subject: [PATCH] LRC-LIB-SMARTCOLL: smart albums can test other smart albums (loops refused and survived) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Album rule may now name a smart album: "Keywords contain travel" and "Album isn't Excluded Photos" (red or rejected) leaves out whatever Excluded Photos matches, as its rules change. Smart albums used to be refused for this. Loops: - RuleSet::check_for(cat, owner) refuses an Album rule that would make the owner include itself, directly or through other smart albums (Issue::AlbumLoop). Catalog::album_reaches walks the references with a seen-set; RuleSet::albums_tested lists them. album.setRules checks for the album being edited, and smart_album_problems reports a loop saved anyway. - Matching never recurses without end: album_contains evaluates a smart album's rules through rules::evaluating, a thread-local stack of albums being evaluated with a 32-deep cap. An album already on it (a saved loop) holds nothing, and the stack is restored by a drop guard however evaluation ends. A test builds A → B → C → A and checks that every album returns and reports the loop. The issue "can't test a smart album" is replaced by the loop issue in all seven locales. The sidebar counts already refresh while any smart album uses "in the last…", so an album testing one is covered. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/lib.rs | 31 +++++++++- crates/catalog/src/rules.rs | 90 ++++++++++++++++++++++++----- crates/catalog/src/tests.rs | 73 +++++++++++++++++++++++ crates/engine/src/cmd/library.rs | 18 ++++-- crates/engine/src/tests_library.rs | 46 +++++++++++++++ crates/ui-egui/locales/de.json | 4 +- crates/ui-egui/locales/es.json | 4 +- crates/ui-egui/locales/ja.json | 4 +- crates/ui-egui/locales/pt-br.json | 4 +- crates/ui-egui/locales/ru.json | 4 +- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/locales/zh-hant.json | 4 +- 12 files changed, 249 insertions(+), 37 deletions(-) diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index c47763b1..fd15415c 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -324,19 +324,46 @@ impl Catalog { /// no smart album). pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool { match self.albums.get(&id) { - Some(Album { smart: Some(rules), .. }) => !p.deleted && rules.matches(p, self), + // guarded: a smart album testing smart albums can't loop or recurse without end + Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::evaluating(id, || rules.matches(p, self)).unwrap_or(false), Some(a) => a.photos.contains(&p.id), None => false, } } + /// Whether smart album `from` tests `to`, directly or through the smart albums it tests (the + /// rules of `from` would then change when those of `to` do). Loops in saved rules end the + /// search, they don't repeat it. + pub fn album_reaches(&self, from: AlbumId, to: AlbumId) -> bool { + let mut seen: Vec = Vec::new(); + let mut next = vec![from]; + while let Some(a) = next.pop() { + if seen.contains(&a) { + continue; + } + seen.push(a); + let tested = self + .albums + .get(&a) + .and_then(|al| al.smart.as_ref()) + .and_then(|f| f.rule_set.as_ref()) + .map(rules::RuleSet::albums_tested) + .unwrap_or_default(); + if tested.contains(&to) { + return true; + } + next.extend(tested); + } + false + } + /// What is wrong with a saved smart album's rules now (`RuleSet::check`, after /// `RuleSet::upgrade`): typically a rule testing an album that has since been deleted. Empty /// for a sound smart album, a plain album or no album. pub fn smart_album_problems(&self, id: AlbumId) -> Vec { let Some(mut rules) = self.albums.get(&id).and_then(|a| a.smart.as_ref()).and_then(|f| f.rule_set.clone()) else { return Vec::new() }; rules.upgrade(); - rules.check(self) + rules.check_for(self, Some(id)) } /// The photos of an album: the stored list, or a smart album's current matches (id order). diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index a167cce2..4f34fd5b 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -193,7 +193,7 @@ pub enum Issue { NoRatingMatches, ChooseAlbum, NoSuchAlbum, - SmartAlbum, + AlbumLoop, FolderAlbum, EmptyGroup, } @@ -220,7 +220,7 @@ impl Issue { Issue::NoRatingMatches, Issue::ChooseAlbum, Issue::NoSuchAlbum, - Issue::SmartAlbum, + Issue::AlbumLoop, Issue::FolderAlbum, Issue::EmptyGroup, ]; @@ -248,7 +248,7 @@ impl Issue { Issue::NoRatingMatches => "can't match any rating from 0 to 5", Issue::ChooseAlbum => "needs an album", Issue::NoSuchAlbum => "names an album that no longer exists", - Issue::SmartAlbum => "can't test a smart album; choose a plain album", + Issue::AlbumLoop => "would make this album include itself through other smart albums", Issue::FolderAlbum => "can't test a folder; choose an album in it", Issue::EmptyGroup => "This group is empty: add a rule or remove it.", } @@ -259,7 +259,7 @@ impl Issue { type Found = Option<(Issue, String)>; /// What is wrong with one rule, if anything ([`RuleSet::check`]). -fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog) -> Found { +fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog, owner: Option) -> Found { let Some(kind) = field_kind(field) else { return Some((Issue::UnknownField, format!("unknown field `{field}`"))) }; if !ops_for(kind).iter().any(|o| o.0 == op) { return Some((Issue::NoSuchOperator, format!("`{field}` has no operator `{op}`"))); @@ -269,7 +269,7 @@ fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog) -> Found { } match kind { Kind::Bool => bool_value(value).is_none().then(|| (Issue::NotYesNo, format!("`{field}` is yes or no, not {value}"))), - Kind::Album => album_problem(value, cat), + Kind::Album => album_problem(value, cat, owner), Kind::Number => { let each = match value { Value::Array(a) if op == "between" && a.len() == 2 => a.iter().find_map(|v| number_problem(field, v)), @@ -317,15 +317,18 @@ pub fn album_rule_id(value: &Value) -> Option { number(value).filter(|n| n.is_finite() && *n >= 0.0 && n.fract() == 0.0).map(|n| crate::AlbumId(n as u64)) } -/// An Album rule's value: an album that exists and holds photos itself (not a smart album, not a -/// folder of albums). -fn album_problem(v: &Value, cat: &Catalog) -> Found { +/// An Album rule's value: an album that exists and holds photos (a plain or a smart album, not a +/// folder of albums), and, for the rules of `owner`, not one that leads back to it (a smart album +/// testing itself, directly or through others). +fn album_problem(v: &Value, cat: &Catalog, owner: Option) -> Found { if v.is_null() { return Some((Issue::ChooseAlbum, "choose an album".into())); } match album_rule_id(v).and_then(|id| cat.album(id)) { None => Some((Issue::NoSuchAlbum, format!("no album {v}"))), - Some(a) if a.is_smart() => Some((Issue::SmartAlbum, format!("album {v} is a smart album; only plain albums can be tested"))), + Some(a) if owner.is_some_and(|o| a.id == o || cat.album_reaches(a.id, o)) => { + Some((Issue::AlbumLoop, format!("album {v} would make this album include itself"))) + } Some(a) if a.folder => Some((Issue::FolderAlbum, format!("album {v} is a folder; choose an album in it"))), Some(_) => None, } @@ -405,6 +408,41 @@ fn choice_problem(field: &str, value: &Value, choices: &[(&str, &str)], cat: &Ca (!known).then(|| (Issue::NotAChoice, format!("`{field}` is one of {}, not {value}", ids.join(", ")))) } +thread_local! { + /// The smart albums whose rules are being evaluated on this thread, outermost first. + static EVALUATING: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +/// How deep smart albums may test smart albums before the innermost counts as holding nothing. +const MAX_ALBUM_DEPTH: usize = 32; + +/// Run `f` as the evaluation of smart album `id`'s rules: `None` (so it holds nothing) when `id` +/// is already being evaluated (a loop the check would refuse, saved anyway) or the chain is +/// deeper than [`MAX_ALBUM_DEPTH`]. Loops cost one pass round them, never a stack overflow. +pub(crate) fn evaluating(id: crate::AlbumId, f: impl FnOnce() -> T) -> Option { + let entered = EVALUATING.with_borrow_mut(|stack| { + let ok = !stack.contains(&id) && stack.len() < MAX_ALBUM_DEPTH; + if ok { + stack.push(id); + } + ok + }); + if !entered { + return None; + } + // leaves the stack as it was, however `f` ends + struct Leave; + impl Drop for Leave { + fn drop(&mut self) { + EVALUATING.with_borrow_mut(|stack| { + stack.pop(); + }); + } + } + let _leave = Leave; + Some(f()) +} + /// What a yes/no rule's value means: `true`, `"yes"`, `1` (or `1.0`) or no value is yes; `false`, /// `"no"`, `0` is no (strings in any case); `None` for anything else, which matches nothing. pub fn bool_value(value: &Value) -> Option { @@ -786,7 +824,8 @@ impl Rule { "bestOfGroup" => yes(p.analysis.is_some_and(|a| a.best || a.group.is_none())), "album" => { let id = album_rule_id(value); - id.is_some_and(|a| cat.album(a).is_some_and(|al| !al.is_smart()) && cat.album_contains(a, p)) == (op == "is") + // a smart album's photos are its matches; one being evaluated (a loop) holds none + id.is_some_and(|a| cat.album_contains(a, p)) == (op == "is") } _ => false, } @@ -832,21 +871,40 @@ impl RuleSet { /// no, a plain album…), text with nothing to look for, an empty group. Commands refuse a rule /// set with problems; the editor shows them. `cat` knows the albums and colour-label names. pub fn check(&self, cat: &Catalog) -> Vec { + self.check_for(cat, None) + } + + /// [`RuleSet::check`] for the rules of smart album `owner` (`None`: a new album or a filter), + /// which also refuses an Album rule that would make `owner` include itself. + pub fn check_for(&self, cat: &Catalog, owner: Option) -> Vec { let mut out = Vec::new(); - self.check_into(cat, &mut Vec::new(), &mut out); + self.check_into(cat, owner, &mut Vec::new(), &mut out); out } - fn check_into(&self, cat: &Catalog, path: &mut Vec, out: &mut Vec) { + /// The albums the Album rules test, groups included, in order. + pub fn albums_tested(&self) -> Vec { + let mut out = Vec::new(); + for rule in &self.rules { + match rule { + Rule::Group { group } => out.extend(group.albums_tested()), + Rule::Field { field, value, .. } if field == "album" => out.extend(album_rule_id(value)), + Rule::Field { .. } => {} + } + } + out + } + + fn check_into(&self, cat: &Catalog, owner: Option, path: &mut Vec, out: &mut Vec) { for (i, rule) in self.rules.iter().enumerate() { path.push(i); match rule { Rule::Group { group } if group.rules.is_empty() => { out.push(Problem { path: path.clone(), field: None, issue: Issue::EmptyGroup, message: "an empty group".into() }) } - Rule::Group { group } => group.check_into(cat, path, out), + Rule::Group { group } => group.check_into(cat, owner, path, out), Rule::Field { field, op, value } => { - if let Some((issue, message)) = field_problem(field, op, value, cat) { + if let Some((issue, message)) = field_problem(field, op, value, cat, owner) { out.push(Problem { path: path.clone(), field: Some(field.clone()), issue, message }); } } @@ -1327,10 +1385,10 @@ mod tests { ok(r("title", "isEmpty", json!(null))); bad(r("title", "contains", json!(" ")), "something to look for"); bad(r("keywords", "startsWith", json!("")), "something to look for"); - // albums: a plain album that exists + // albums: one that exists, plain or smart ok(r("album", "is", json!(1))); bad(r("album", "is", json!(999)), "no album 999"); - bad(r("album", "is", json!(2)), "smart album"); + ok(r("album", "is", json!(2))); // groups: an empty one is a mistake; problems inside one say where they are let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"match": "any", "rules": []}}]})).check(&cat); assert_eq!(p.iter().map(ToString::to_string).collect::>(), vec!["rule 2: an empty group".to_string()]); diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 84bfdd0d..c6e25317 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -476,3 +476,76 @@ fn smart_album_problems_flag_stale_rules() { let p = c.smart_album_problems(AlbumId(2)); assert_eq!(p.iter().map(ToString::to_string).collect::>(), vec!["rule 1: no album 1".to_string()]); } + +/// A smart album can test another smart album: "Keywords contain travel" and "Album isn't Excluded +/// Photos" (red or rejected) leaves out the excluded photos, and follows that album's rules as they +/// change. +#[test] +fn a_smart_album_can_exclude_another() { + let mut c = Catalog::new(); + let smart = |id: u64, name: &str, rules: serde_json::Value| { + let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": rules})).unwrap(); + Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), name) } + }; + c.apply(Op::AddAlbum { + album: smart( + 1, + "Excluded Photos", + serde_json::json!({"match": "any", "rules": [ + {"field": "label", "op": "is", "value": "red"}, {"field": "flag", "op": "is", "value": "reject"}]}), + ), + }) + .unwrap(); + let travel = |c: &mut Catalog, name: &str, label: Option, flag: Flag| { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00"); + p.meta.keywords = vec!["travel".into()]; + p.label = label; + p.flag = flag; + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + let keep = travel(&mut c, "keep.jpg", None, Flag::Pick); + let red = travel(&mut c, "red.jpg", Some(ColorLabel::Red), Flag::None); + let rejected = travel(&mut c, "rejected.jpg", None, Flag::Reject); + let rules: RuleSet = serde_json::from_value(serde_json::json!({"rules": [ + {"field": "keywords", "op": "contains", "value": "travel"}, {"field": "album", "op": "isNot", "value": 1}]})) + .unwrap(); + assert!(rules.check(&c).is_empty(), "a smart album can be tested: {:?}", rules.check(&c)); + let matched = |c: &Catalog| c.photos().filter(|p| rules.matches(p, c)).map(|p| p.id).collect::>(); + assert_eq!(matched(&c), vec![keep]); + // "is" works the other way round + let inside: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "album", "op": "is", "value": 1}]})).unwrap(); + assert_eq!(c.photos().filter(|p| inside.matches(p, &c)).count(), 2); + let _ = (red, rejected); +} + +/// An album can't include itself, directly or through other smart albums: the check refuses the +/// loop for the album being edited, and a loop saved anyway (an older version, a hand-edited file) +/// neither recurses forever nor crashes; it is reported and its rule matches nothing. +#[test] +fn smart_album_loops_are_refused_and_survived() { + let mut c = Catalog::new(); + let refers = |to: u64| -> Filter { + serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": to}]}})).unwrap() + }; + let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "S") }; + // A → B → C + c.apply(Op::AddAlbum { album: smart(3, Filter::default()) }).unwrap(); + c.apply(Op::AddAlbum { album: smart(2, refers(3)) }).unwrap(); + c.apply(Op::AddAlbum { album: smart(1, refers(2)) }).unwrap(); + let rules = |to: u64| refers(to).rule_set.unwrap(); + assert!(rules(1).check_for(&c, Some(AlbumId(3))).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "C → A closes A → B → C → A"); + assert!(rules(3).check_for(&c, Some(AlbumId(3))).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "an album testing itself"); + assert!(rules(1).check_for(&c, None).is_empty(), "a new album can't be in a loop yet"); + assert!(rules(3).check_for(&c, Some(AlbumId(1))).is_empty(), "A testing C again is no loop"); + assert!(!c.album_reaches(AlbumId(3), AlbumId(1)) && c.album_reaches(AlbumId(1), AlbumId(3))); + // a loop saved anyway: C → A + c.apply(Op::SetAlbumRules { id: AlbumId(3), rules: Box::new(refers(1)) }).unwrap(); + let id = photo(&mut c, "a.jpg", "2026-09-01T10:00:00"); + let p = c.photo(id).unwrap().clone(); + for a in [1, 2, 3] { + let _ = c.album_contains(AlbumId(a), &p); // returns, no stack overflow + assert!(c.smart_album_problems(AlbumId(a)).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "album {a} is reported"); + } +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index a07eb8b4..a9843156 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -816,7 +816,7 @@ pub fn specs() -> Vec { return Err(bad("album.createSmart", "empty name")); } let rules = match p.get("rules") { - Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog)?, + Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog, None)?, None => view_rules(s), }; let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); @@ -865,7 +865,7 @@ pub fn specs() -> Vec { let replace = bool_or(p, "replace", false); let folder = cur.library_folder.clone(); let base = if replace { Default::default() } else { cur }; - let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog)?; + let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog, Some(id))?; // the rules dialog has no folder field: replacing its rules keeps the folder // unless the call says (`libraryFolder: null`) to drop it if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() { @@ -1300,10 +1300,18 @@ fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Re Ok(f) } -/// `patch` merged onto `base`, refused when its rule set has problems (`RuleSet::check`). -fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str, cat: &lightcraft_catalog::Catalog) -> Result { +/// `patch` merged onto `base`, refused when its rule set has problems (`RuleSet::check_for`; +/// `owner` is the smart album the rules are for, so testing an album that leads back to it is a +/// problem too). +fn merge_rules( + base: &lightcraft_catalog::Filter, + patch: &Value, + c: &str, + cat: &lightcraft_catalog::Catalog, + owner: Option, +) -> Result { let f = merge_filter(base, patch, c)?; - let problems = f.rule_set.as_ref().map(|r| r.check(cat)).unwrap_or_default(); + let problems = f.rule_set.as_ref().map(|r| r.check_for(cat, owner)).unwrap_or_default(); if !problems.is_empty() { return Err(bad(c, problems.iter().map(ToString::to_string).collect::>().join("; "))); } diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 921027c9..6e20f998 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -571,3 +571,49 @@ fn album_list_reports_problems() { s.execute("album.delete", &serde_json::json!({"id": trip})).unwrap(); assert_eq!(find(&mut s)["problems"], serde_json::json!([format!("rule 1: no album {trip}")])); } + +/// "Travel" = keywords contain travel, but not in "Excluded Photos" (red or rejected): the counts +/// follow, and Excluded Photos can't then test Travel back (a loop). +#[test] +fn smart_album_excluding_a_smart_album() { + let mut s = crate::Session::with_demo(); + let excluded = s + .execute( + "album.createSmart", + &serde_json::json!({"name": "Excluded Photos", "rules": {"ruleSet": {"match": "any", "rules": [ + {"field": "label", "op": "is", "value": "red"}, {"field": "flag", "op": "is", "value": "reject"}]}}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + let keyword = s.catalog.photos().flat_map(|p| p.meta.keywords.clone()).next().expect("a demo keyword"); + let travel = s + .execute( + "album.createSmart", + &serde_json::json!({"name": "Travel", "rules": {"ruleSet": {"rules": [ + {"field": "keywords", "op": "contains", "value": keyword}, {"field": "album", "op": "isNot", "value": excluded}]}}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + let count = |s: &crate::Session| s.catalog.album_count(lightcraft_catalog::AlbumId(travel)); + let want = |s: &crate::Session| { + s.catalog + .photos() + .filter(|p| !p.deleted && p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&keyword.to_lowercase()))) + .filter(|p| p.label != Some(lightcraft_catalog::ColorLabel::Red) && p.flag != lightcraft_catalog::Flag::Reject) + .count() + }; + assert_eq!(count(&s), want(&s)); + // reject one of Travel's photos: it leaves Travel + let before = count(&s); + let id = s.catalog.photos().find(|p| s.catalog.album_contains(lightcraft_catalog::AlbumId(travel), p)).map(|p| p.id).expect("a travel photo"); + s.execute("photo.flag", &serde_json::json!({"ids": [id.0], "flag": "reject"})).unwrap(); + assert_eq!(count(&s), before - 1); + // Excluded Photos testing Travel back would include itself + let looped = s.execute( + "album.setRules", + &serde_json::json!({"id": excluded, "rules": {"ruleSet": {"rules": [{"field": "album", "op": "is", "value": travel}]}}, "replace": true}), + ); + assert!(looped.is_err_and(|e| e.to_string().contains("would make this album include itself")), "a loop is refused"); +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index b9c07d95..74a5f1ac 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1918,7 +1918,6 @@ "can't match any rating from 0 to 5": "passt zu keiner Bewertung von 0 bis 5", "needs an album": "braucht ein Album", "names an album that no longer exists": "nennt ein Album, das es nicht mehr gibt", - "can't test a smart album; choose a plain album": "kann kein Smart-Album prüfen; wähle ein normales Album", "can't test a folder; choose an album in it": "kann keinen Ordner prüfen; wähle ein Album darin", "This group is empty: add a rule or remove it.": "Diese Gruppe ist leer: Füge eine Regel hinzu oder entferne sie.", "needs something to look for (or use “are empty”)": "braucht einen Suchbegriff (oder „sind leer“)", @@ -1931,5 +1930,6 @@ "Fr": "Fr", "Sa": "Sa", "Su": "So", - "Pick a date": "Datum wählen" + "Pick a date": "Datum wählen", + "would make this album include itself through other smart albums": "würde dieses Album über andere Smart-Alben sich selbst enthalten lassen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 1440a19d..328f92b8 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1763,7 +1763,6 @@ "can't match any rating from 0 to 5": "no coincide con ninguna valoración de 0 a 5", "needs an album": "necesita un álbum", "names an album that no longer exists": "indica un álbum que ya no existe", - "can't test a smart album; choose a plain album": "no puede comprobar un álbum inteligente; elige un álbum normal", "can't test a folder; choose an album in it": "no puede comprobar una carpeta; elige un álbum dentro", "This group is empty: add a rule or remove it.": "Este grupo está vacío: añade una regla o quítalo.", "needs something to look for (or use “are empty”)": "necesita algo que buscar (o usa «están vacíos»)", @@ -1776,5 +1775,6 @@ "Fr": "Vi", "Sa": "Sá", "Su": "Do", - "Pick a date": "Elegir una fecha" + "Pick a date": "Elegir una fecha", + "would make this album include itself through other smart albums": "haría que este álbum se incluyera a sí mismo a través de otros álbumes inteligentes" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 918ef999..192c4eaf 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1767,7 +1767,6 @@ "can't match any rating from 0 to 5": "0〜5 のどの評価にも一致しません", "needs an album": "アルバムを選択してください", "names an album that no longer exists": "存在しないアルバムが指定されています", - "can't test a smart album; choose a plain album": "スマートアルバムは判定できません。通常のアルバムを選択してください", "can't test a folder; choose an album in it": "フォルダーは判定できません。中のアルバムを選択してください", "This group is empty: add a rule or remove it.": "このグループは空です。条件を追加するか、グループを削除してください。", "needs something to look for (or use “are empty”)": "検索する語句が必要です(または「が空」を使用)", @@ -1780,5 +1779,6 @@ "Fr": "金", "Sa": "土", "Su": "日", - "Pick a date": "日付を選択" + "Pick a date": "日付を選択", + "would make this album include itself through other smart albums": "ほかのスマートアルバムを通じて、このアルバム自身を含むことになります" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9a2ab678..936fa411 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1763,7 +1763,6 @@ "can't match any rating from 0 to 5": "não corresponde a nenhuma avaliação de 0 a 5", "needs an album": "precisa de um álbum", "names an album that no longer exists": "indica um álbum que não existe mais", - "can't test a smart album; choose a plain album": "não pode testar um álbum inteligente; escolha um álbum comum", "can't test a folder; choose an album in it": "não pode testar uma pasta; escolha um álbum dentro dela", "This group is empty: add a rule or remove it.": "Este grupo está vazio: adicione uma regra ou remova-o.", "needs something to look for (or use “are empty”)": "precisa de algo para procurar (ou use “estão vazios”)", @@ -1776,5 +1775,6 @@ "Fr": "Sex", "Sa": "Sáb", "Su": "Dom", - "Pick a date": "Escolher uma data" + "Pick a date": "Escolher uma data", + "would make this album include itself through other smart albums": "faria este álbum incluir a si mesmo por meio de outros álbuns inteligentes" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 442acb95..252141a8 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1435,7 +1435,6 @@ "can't match any rating from 0 to 5": "не подходит ни к одной оценке от 0 до 5", "needs an album": "нужен альбом", "names an album that no longer exists": "указан альбом, которого больше нет", - "can't test a smart album; choose a plain album": "нельзя проверять смарт-альбом; выберите обычный альбом", "can't test a folder; choose an album in it": "нельзя проверять папку; выберите альбом в ней", "This group is empty: add a rule or remove it.": "Эта группа пуста: добавьте условие или удалите её.", "needs something to look for (or use “are empty”)": "нужно указать, что искать (или выберите «пусты»)", @@ -1468,5 +1467,6 @@ "Fr": "Пт", "Sa": "Сб", "Su": "Вс", - "Pick a date": "Выбрать дату" + "Pick a date": "Выбрать дату", + "would make this album include itself through other smart albums": "альбом включал бы сам себя через другие смарт-альбомы" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 0af301ab..ad7aae9e 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1763,7 +1763,6 @@ "can't match any rating from 0 to 5": "不匹配 0 到 5 的任何星级", "needs an album": "需要选择相册", "names an album that no longer exists": "指定的相册已不存在", - "can't test a smart album; choose a plain album": "无法判断智能相册;请选择普通相册", "can't test a folder; choose an album in it": "无法判断文件夹;请选择其中的相册", "This group is empty: add a rule or remove it.": "此组为空:请添加规则或将其删除。", "needs something to look for (or use “are empty”)": "需要要查找的内容(或使用“为空”)", @@ -1776,5 +1775,6 @@ "Fr": "五", "Sa": "六", "Su": "日", - "Pick a date": "选择日期" + "Pick a date": "选择日期", + "would make this album include itself through other smart albums": "会通过其他智能相册让此相册包含自身" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 649b26a7..2574c249 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1763,7 +1763,6 @@ "can't match any rating from 0 to 5": "不符合 0 到 5 的任何評分", "needs an album": "需要選擇相簿", "names an album that no longer exists": "指定的相簿已不存在", - "can't test a smart album; choose a plain album": "無法判斷智慧型相簿;請選擇一般相簿", "can't test a folder; choose an album in it": "無法判斷資料夾;請選擇其中的相簿", "This group is empty: add a rule or remove it.": "此群組為空:請新增規則或將其移除。", "needs something to look for (or use “are empty”)": "需要要尋找的內容(或使用「為空」)", @@ -1776,5 +1775,6 @@ "Fr": "五", "Sa": "六", "Su": "日", - "Pick a date": "選擇日期" + "Pick a date": "選擇日期", + "would make this album include itself through other smart albums": "會透過其他智慧型相簿讓此相簿包含自身" }