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": "會透過其他智慧型相簿讓此相簿包含自身" }