LRC-LIB-SMARTCOLL: smart albums can test other smart albums (loops refused and survived)
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 <noreply@anthropic.com>
This commit is contained in:
parent
034ef489ed
commit
4e3b15ff6f
|
|
@ -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<AlbumId> = 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<rules::Problem> {
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -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<crate::AlbumId>) -> 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<crate::AlbumId> {
|
|||
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<crate::AlbumId>) -> 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<Vec<crate::AlbumId>> = 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<T>(id: crate::AlbumId, f: impl FnOnce() -> T) -> Option<T> {
|
||||
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<bool> {
|
||||
|
|
@ -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<Problem> {
|
||||
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<crate::AlbumId>) -> Vec<Problem> {
|
||||
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<usize>, out: &mut Vec<Problem>) {
|
||||
/// The albums the Album rules test, groups included, in order.
|
||||
pub fn albums_tested(&self) -> Vec<crate::AlbumId> {
|
||||
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<crate::AlbumId>, path: &mut Vec<usize>, out: &mut Vec<Problem>) {
|
||||
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<_>>(), vec!["rule 2: an empty group".to_string()]);
|
||||
|
|
|
|||
|
|
@ -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<_>>(), 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; 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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -816,7 +816,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", &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<CommandSpec> {
|
|||
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<lightcraft_catalog::Filter> {
|
||||
/// `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<AlbumId>,
|
||||
) -> Result<lightcraft_catalog::Filter> {
|
||||
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::<Vec<_>>().join("; ")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "ほかのスマートアルバムを通じて、このアルバム自身を含むことになります"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "альбом включал бы сам себя через другие смарт-альбомы"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "会通过其他智能相册让此相册包含自身"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "會透過其他智慧型相簿讓此相簿包含自身"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue