Skip to main content

falcon_library/
smart.rs

1//! Smart folders: saved rule sets in the library `metadata.json` (see the
2//! Smart folders chapter of the docs). Entries stay raw JSON in [`LibraryMetadata`] so they
3//! round-trip untouched; this module reads, evaluates and edits them.
4//!
5//! [`LibraryMetadata`]: crate::model::LibraryMetadata
6
7use std::cell::{OnceCell, RefCell};
8use std::collections::{HashMap, HashSet};
9use std::path::PathBuf;
10
11use chrono::{Local, NaiveDate, TimeZone};
12use fancy_regex::{Regex, RegexBuilder};
13use serde_json::{Map, Value, json};
14
15use crate::edit::{display_name, now_ms};
16use crate::error::Result;
17use crate::ids;
18use crate::library::Library;
19use crate::model::{FolderOrder, Item};
20use crate::search::{self, FolderIndex};
21
22/// A smart folder, read from its JSON entry.
23#[derive(Debug, Clone, PartialEq)]
24pub struct SmartFolder {
25    pub id: String,
26    pub name: String,
27    pub conditions: Vec<Value>,
28    pub children: Vec<SmartFolder>,
29    /// Icon name from [`crate::model::FOLDER_ICONS`], if not the default.
30    pub icon: Option<String>,
31    /// Colour from [`crate::model::ICON_COLORS`], if not the default.
32    pub icon_color: Option<String>,
33    pub description: String,
34    /// The smart folder's own sort, as Eagle's `orderBy` string.
35    pub order_by: Option<String>,
36    pub sort_increase: Option<bool>,
37}
38
39impl SmartFolder {
40    fn from_value(v: &Value) -> Option<SmartFolder> {
41        let text = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
42        Some(SmartFolder {
43            id: v.get("id")?.as_str()?.to_string(),
44            name: text("name").unwrap_or_default(),
45            icon: text("icon"),
46            icon_color: text("iconColor"),
47            description: text("description").unwrap_or_default(),
48            order_by: text("orderBy"),
49            sort_increase: v.get("sortIncrease").and_then(Value::as_bool),
50            conditions: v.get("conditions").and_then(Value::as_array).cloned().unwrap_or_default(),
51            children: v
52                .get("children")
53                .and_then(Value::as_array)
54                .map(|c| c.iter().filter_map(SmartFolder::from_value).collect())
55                .unwrap_or_default(),
56        })
57    }
58
59    /// Whether this is a smart folder group: an entry with no conditions,
60    /// which shows the union of its children. Eagle stores no flag; its
61    /// "new group" command simply makes an entry with `conditions: []`.
62    pub fn is_group(&self) -> bool {
63        self.conditions.is_empty()
64    }
65
66    /// Visits this smart folder and its descendants with their ancestry
67    /// (outermost first).
68    pub fn walk<'a>(&'a self, path: &mut Vec<&'a SmartFolder>, f: &mut impl FnMut(&'a SmartFolder, &[&'a SmartFolder])) {
69        f(self, path);
70        path.push(self);
71        for c in &self.children {
72            c.walk(path, f);
73        }
74        path.pop();
75    }
76}
77
78/// What evaluating rules needs besides the item: the folder index (for
79/// `folderName`), the activated fonts (for `fontActivated`, read on first
80/// use from Eagle's font folder and falcon's) and a cache of compiled regexes.
81pub struct Context<'a> {
82    pub folders: &'a FolderIndex,
83    font_dirs: Vec<PathBuf>,
84    fonts: OnceCell<HashSet<String>>,
85    /// Compiled patterns, case-sensitive in `[0]` and case-insensitive in
86    /// `[1]`; `None` for a pattern that doesn't compile.
87    regexes: [RefCell<HashMap<String, Option<Regex>>>; 2],
88}
89
90impl<'a> Context<'a> {
91    /// A context reading activated fonts from Eagle's font folder, as
92    /// Eagle does, and from falcon's ([`search::font_dirs`]): a font in
93    /// either counts as installed.
94    pub fn new(folders: &'a FolderIndex) -> Context<'a> {
95        Context::with_font_dirs(folders, search::font_dirs())
96    }
97
98    /// A context reading activated fonts from `dir` (none when `None`).
99    pub fn with_font_dir(folders: &'a FolderIndex, dir: Option<PathBuf>) -> Context<'a> {
100        Context::with_font_dirs(folders, dir.into_iter().collect())
101    }
102
103    /// A context reading activated fonts from every folder in `dirs`.
104    pub fn with_font_dirs(folders: &'a FolderIndex, dirs: Vec<PathBuf>) -> Context<'a> {
105        Context { folders, font_dirs: dirs, fonts: OnceCell::new(), regexes: Default::default() }
106    }
107
108    fn fonts(&self) -> &HashSet<String> {
109        self.fonts.get_or_init(|| search::installed_font_keys_in(&self.font_dirs))
110    }
111
112    /// Whether `pattern` matches somewhere in `text`. A pattern that doesn't
113    /// compile never matches, nor does one whose backtracking runs past the
114    /// limit on this text.
115    fn test(&self, pattern: &str, ignore_case: bool, text: &str) -> bool {
116        let mut cache = self.regexes[usize::from(ignore_case)].borrow_mut();
117        if !cache.contains_key(pattern) {
118            let re = RegexBuilder::new(pattern).case_insensitive(ignore_case).delegate_size_limit(1 << 20).backtrack_limit(100_000).build().ok();
119            cache.insert(pattern.to_string(), re);
120        }
121        cache[pattern].as_ref().is_some_and(|re| re.is_match(text).unwrap_or(false))
122    }
123}
124
125/// Whether a live item is in a smart folder: it must pass the folder's
126/// conditions and every ancestor's. A folder without conditions shows the
127/// union of its children (nothing if it has none), as Eagle's view does.
128pub fn contains(item: &Item, folder: &SmartFolder, ancestors: &[&SmartFolder], cx: &Context) -> bool {
129    if item.is_deleted {
130        return false;
131    }
132    if !ancestors.iter().all(|a| a.conditions.is_empty() || passes(item, &a.conditions, cx)) {
133        return false;
134    }
135    if folder.conditions.is_empty() {
136        let mut path: Vec<&SmartFolder> = ancestors.to_vec();
137        path.push(folder);
138        return folder.children.iter().any(|c| contains(item, c, &path, cx));
139    }
140    passes(item, &folder.conditions, cx)
141}
142
143/// All conditions AND-ed; `boolean: "FALSE"` negates one. A condition
144/// Eagle can't evaluate (no `rules`, or a rule with an unknown property
145/// that is reached) makes Eagle's whole test fail, negated or not.
146fn passes(item: &Item, conditions: &[Value], cx: &Context) -> bool {
147    conditions.iter().all(|c| {
148        let negate = c.get("boolean").and_then(Value::as_str) == Some("FALSE");
149        condition(item, c, cx).is_some_and(|hit| hit != negate)
150    })
151}
152
153/// One condition, in Eagle's order: `AND` stops at the first miss, `OR` at
154/// the first hit, and anything else needs every rule to pass. No rules
155/// passes.
156fn condition(item: &Item, c: &Value, cx: &Context) -> Option<bool> {
157    let mode = c.get("match").and_then(Value::as_str);
158    let mut all = true;
159    for r in c.get("rules")?.as_array()? {
160        let hit = rule(item, r, cx)?;
161        if !hit {
162            all = false;
163            if mode == Some("AND") {
164                return Some(false);
165            }
166        } else if mode == Some("OR") {
167            return Some(true);
168        }
169    }
170    Some(all)
171}
172
173/// One rule, or `None` for a property Eagle doesn't know.
174fn rule(item: &Item, r: &Value, cx: &Context) -> Option<bool> {
175    let prop = r.get("property").and_then(Value::as_str).unwrap_or("");
176    let method = r.get("method").and_then(Value::as_str).unwrap_or("");
177    let value = r.get("value").cloned().unwrap_or(Value::Null);
178    let pair = |i: usize| value.as_array().and_then(|a| a.get(i));
179    Some(match prop {
180        "name" => text_rule(&item.name, method, &value, cx),
181        "url" => text_rule(&item.url, method, &value, cx),
182        "annotation" => text_rule(&item.annotation, method, &value, cx),
183        "camera" => search::camera(item).is_some_and(|c| text_rule(c, method, &value, cx)),
184        "comments" => comments_rule(item, method, &value, cx),
185        "folderName" => item.folders.iter().any(|f| text_rule(cx.folders.get(f).map_or("", |(name, _)| name), method, &value, cx)),
186        "bpm" => search::bpm(item).is_some_and(|b| number_rule(b, method, &value)),
187        "width" => number_rule(item.width as f64, method, &value),
188        "height" => number_rule(item.height as f64, method, &value),
189        "fileSize" => {
190            let unit = r.get("unit").and_then(Value::as_str).unwrap_or("mb");
191            // Eagle treats any unit other than kb as MB.
192            let div = if unit == "kb" { 1024.0 } else { 1_048_576.0 };
193            number_rule(item.size as f64 / div, method, &value)
194        }
195        "duration" => search::duration(item).filter(|d| *d != 0.0).is_some_and(|d| {
196            // Seconds unless the unit is `h` or `m`.
197            let d = match r.get("unit").and_then(Value::as_str) {
198                Some("h") => d / 3600.0,
199                Some("m") => d / 60.0,
200                _ => d,
201            };
202            number_rule(d, method, &value)
203        }),
204        "iso" => raw_meta(item, "isoSpeed").and_then(parse_int).is_some_and(|iso| compare(iso, method, pair(0).and_then(parse_int), pair(1).and_then(parse_int))),
205        // The first number in `f/2.8` or `50.0 mm`.
206        "aperture" | "focalLength" => raw_meta(item, prop).and_then(|v| nth_number(v, 0)).is_some_and(|x| compare(x, method, pair(0).and_then(parse_float), pair(1).and_then(parse_float))),
207        // The second number in `1/250.0 sec`: the exposure's denominator.
208        "shutter" => raw_meta(item, "shutter").and_then(|v| nth_number(v, 1)).is_some_and(|x| compare(x, method, pair(0).and_then(parse_float), pair(1).and_then(parse_float))),
209        "timestamp" => raw_meta(item, "timestamp").and_then(parse_int).filter(|t| *t != 0.0).is_some_and(|t| date_rule(t, method, pair(0).and_then(parse_int), pair(1).and_then(parse_int))),
210        "createTime" => date_value(item.modification_time, method, &value),
211        "mtime" => date_value(if item.mtime > 0 { item.mtime } else { item.modification_time }, method, &value),
212        "btime" => date_value(if item.btime > 0 { item.btime } else { item.modification_time }, method, &value),
213        "tags" => set_rule(&item.tags, method, &value, true),
214        "folders" => set_rule(&item.folders, method, &value, false),
215        "fontActivated" => {
216            // Eagle's key for an activated font; see [`search::font_key`].
217            let Some(name) = search::font_name(item, "postScriptName").filter(|n| !n.is_empty()) else { return Some(false) };
218            let on = cx.fonts().contains(&format!("{name}_.{}", item.ext));
219            match method {
220                "activate" => on,
221                "deactivate" => !on,
222                _ => false,
223            }
224        }
225        "type" => {
226            let v = value.as_str().unwrap_or("");
227            let hit = item.ext == v || search::type_group(&item.ext) == Some(v.trim_end_matches('s')) || (v == "presentation" && matches!(item.ext.as_str(), "ppt" | "key" | "pptx"));
228            if method == "unequal" { !hit } else { hit }
229        }
230        "rating" => {
231            // Eagle reads no star, 0 and "none" all as "unrated".
232            let star = item.extra.get("star").and_then(Value::as_f64).filter(|s| *s != 0.0);
233            let want = parse_int(&value).filter(|v| *v != 0.0);
234            match method {
235                // Eagle's dialog stores the stars as text ("35"); any "none"
236                // in it matches every item, as in Eagle.
237                "contain" => match &value {
238                    Value::String(s) => s.contains("none") || star.is_some_and(|st| s.contains(&(st as i64).to_string())),
239                    Value::Array(a) => a.iter().any(|v| v == "none") || star.is_some_and(|st| a.iter().any(|v| parse_int(v) == Some(st))),
240                    _ => false,
241                },
242                "equal" => star == want,
243                "unequal" => star != want,
244                _ => false,
245            }
246        }
247        "shape" => {
248            let shape = shape_of(item.width, item.height);
249            let hit = match value.as_str() {
250                Some("custom") => {
251                    let rw = r.get("width").and_then(Value::as_f64);
252                    let rh = r.get("height").and_then(Value::as_f64);
253                    matches!((rw, rh), (Some(a), Some(b)) if item.height > 0 && a / b == item.width as f64 / item.height as f64)
254                }
255                Some(v) => shape == Some(v),
256                None => false,
257            };
258            if method == "unequal" { !hit } else { hit }
259        }
260        "color" => match method {
261            "grayscale" => search::Filter { grayscale: true, ..Default::default() }.check(item).is_some(),
262            _ => {
263                let Some(rgb) = value.as_str().and_then(hex) else { return Some(false) };
264                let acc = if method == "accuracy" { 10.0 } else { 20.0 };
265                smart_color(item, rgb, acc)
266            }
267        },
268        _ => return None,
269    })
270}
271
272/// The stricter colour test smart folders use.
273fn smart_color(item: &Item, rgb: [u8; 3], acc: f64) -> bool {
274    let p = &item.palettes;
275    let ratio = |i: usize| p.get(i).and_then(|c| c.ratio.as_f64()).unwrap_or(0.0);
276    if p.is_empty() || ratio(0) < 33.0 {
277        return false;
278    }
279    if p[0].color.iter().zip(rgb).any(|(a, b)| (i32::from(*a) - i32::from(b)).abs() > 96) {
280        return false;
281    }
282    if p.iter().take(2).any(|c| c.color == rgb) {
283        return true;
284    }
285    // Only the two dominant colours, each over a third of the image: no
286    // white-background fallback and no primary (`marked`) colour, unlike
287    // the filter bar's colour search.
288    let q = search::lab(rgb);
289    p.iter().take(2).enumerate().any(|(i, c)| {
290        let l = search::lab(c.color);
291        ratio(i) > 33.0 && search::delta_e00(q, l) < acc && search::delta_e76(q, l) < acc + 50.0
292    })
293}
294
295fn hex(s: &str) -> Option<[u8; 3]> {
296    let s = s.trim_start_matches('#');
297    let v = u32::from_str_radix(s, 16).ok().filter(|_| s.len() == 6)?;
298    Some([(v >> 16) as u8, (v >> 8) as u8, v as u8])
299}
300
301fn shape_of(w: u64, h: u64) -> Option<&'static str> {
302    if w == 0 || h == 0 {
303        return None;
304    }
305    let (wf, hf) = (w as f64, h as f64);
306    Some(if w > h {
307        if wf / hf >= 2.5 { "panoramic-landscape" } else { "landscape" }
308    } else if w < h {
309        if hf / wf >= 2.5 { "panoramic-portrait" } else { "portrait" }
310    } else {
311        "square"
312    })
313}
314
315/// A text rule on lower-cased text with a lower-cased value, as Eagle's
316/// name, URL, note, folder name and camera rules do. An empty value only
317/// works with `empty` and `not-empty`.
318fn text_rule(text: &str, method: &str, value: &Value, cx: &Context) -> bool {
319    let v = value.as_str().unwrap_or("").to_lowercase();
320    if v.is_empty() && !matches!(method, "empty" | "not-empty") {
321        return false;
322    }
323    string_method(&text.to_lowercase(), method, &v, cx)
324}
325
326/// Eagle's string methods. `startWith` and `endWith` build a
327/// case-insensitive regex from `^` + value and value + `$`, so the value is
328/// a pattern there too. Eagle runs JavaScript `RegExp`; falcon runs the
329/// `fancy-regex` crate, which has look-around and backreferences as
330/// JavaScript does and is Unicode-aware for `\w`, `\d`, `\b` and case
331/// folding where JavaScript without the `u` flag is ASCII-only. Note
332/// that Eagle lower-cases the pattern along with the value for the
333/// lower-cased rules, so `\D` becomes `\d`; falcon does the same.
334fn string_method(text: &str, method: &str, v: &str, cx: &Context) -> bool {
335    match method {
336        "equal" => text == v,
337        "contain" => text.contains(v),
338        "uncontain" => !text.contains(v),
339        "startWith" => cx.test(&format!("^{v}"), true, text),
340        "endWith" => cx.test(&format!("{v}$"), true, text),
341        "empty" => text.is_empty(),
342        "not-empty" => !text.is_empty(),
343        "regex" => cx.test(v, false, text),
344        _ => false,
345    }
346}
347
348/// The comments rule: `empty` and `not-empty` look at the list; the other
349/// methods test every comment's text joined together, case-sensitively
350/// (Eagle doesn't lower-case here) and without refusing an empty value.
351fn comments_rule(item: &Item, method: &str, value: &Value, cx: &Context) -> bool {
352    let list = item.extra.get("comments").and_then(Value::as_array);
353    match method {
354        "empty" => list.is_none_or(|l| l.is_empty()),
355        "not-empty" => list.is_some_and(|l| !l.is_empty()),
356        _ => list.is_some_and(|l| {
357            let text: String = l.iter().filter_map(|c| c.get("annotation").and_then(Value::as_str)).collect();
358            string_method(&text, method, value.as_str().unwrap_or(""), cx)
359        }),
360    }
361}
362
363/// A numeric rule with Eagle's `[a, b]` value (`b` only for `between`).
364fn number_rule(x: f64, method: &str, value: &Value) -> bool {
365    let n = |i: usize| value.as_array().and_then(|a| a.get(i)).and_then(Value::as_f64);
366    compare(x, method, n(0), n(1))
367}
368
369fn compare(x: f64, method: &str, a: Option<f64>, b: Option<f64>) -> bool {
370    let Some(a) = a else { return false };
371    match method {
372        "=" => x == a,
373        ">=" => x >= a,
374        "<=" => x <= a,
375        ">" => x > a,
376        "<" => x < a,
377        "between" => b.is_some_and(|b| x >= a && x <= b),
378        _ => false,
379    }
380}
381
382/// A truthy entry of the item's `rawMetas` (photo metadata).
383fn raw_meta<'a>(item: &'a Item, key: &str) -> Option<&'a Value> {
384    item.extra.get("rawMetas")?.get(key).filter(|v| match v {
385        Value::Null => false,
386        Value::Bool(b) => *b,
387        Value::Number(n) => n.as_f64() != Some(0.0),
388        Value::String(s) => !s.is_empty(),
389        _ => true,
390    })
391}
392
393/// JavaScript's `parseInt` for a number or a string: leading whitespace, a
394/// sign and decimal digits; `None` for NaN.
395fn parse_int(v: &Value) -> Option<f64> {
396    match v {
397        Value::Number(n) => n.as_f64().map(f64::trunc),
398        Value::String(s) => {
399            let s = s.trim_start();
400            let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
401            let len = digits.bytes().take_while(u8::is_ascii_digit).count();
402            let n: f64 = digits[..len].parse().ok()?;
403            Some(if s.starts_with('-') { -n } else { n })
404        }
405        _ => None,
406    }
407}
408
409/// JavaScript's `parseFloat` for a number or a string (the longest leading
410/// decimal number); `None` for NaN.
411fn parse_float(v: &Value) -> Option<f64> {
412    match v {
413        Value::Number(n) => n.as_f64(),
414        Value::String(s) => {
415            let s = s.trim_start();
416            (1..=s.len()).rev().filter(|&i| s.is_char_boundary(i)).find_map(|i| {
417                let p = &s[..i];
418                // Rust also parses "inf", "nan" and a trailing "e"-less exponent
419                // form JavaScript doesn't; keep to digits, sign, dot and exponent.
420                (p.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'+' | b'-' | b'.' | b'e' | b'E')) && p.bytes().any(|b| b.is_ascii_digit()))
421                    .then(|| p.parse::<f64>().ok())
422                    .flatten()
423            })
424        }
425        _ => None,
426    }
427}
428
429/// The `n`th number (`[+-]?\d+(\.\d+)?`) in a string value, as Eagle reads
430/// aperture, focal length and shutter text. Not a string: `None`.
431fn nth_number(v: &Value, n: usize) -> Option<f64> {
432    let s = v.as_str()?.as_bytes();
433    let digits = |i: usize| s[i..].iter().take_while(|b| b.is_ascii_digit()).count();
434    let mut found = 0;
435    let mut i = 0;
436    while i < s.len() {
437        let sign = usize::from(matches!(s[i], b'+' | b'-'));
438        let int = if i + sign < s.len() { digits(i + sign) } else { 0 };
439        if int == 0 {
440            i += 1;
441            continue;
442        }
443        let mut end = i + sign + int;
444        if end + 1 < s.len() && s[end] == b'.' && s[end + 1].is_ascii_digit() {
445            end += 1 + digits(end + 1);
446        }
447        if found == n {
448            return std::str::from_utf8(&s[i..end]).ok()?.parse().ok();
449        }
450        found += 1;
451        i = end;
452    }
453    None
454}
455
456const DAY: f64 = 86_400_000.0;
457
458/// A date rule on a timestamp in ms with Eagle's `[a, b]` value (`a` in
459/// days for `within`).
460fn date_value(t: u64, method: &str, value: &Value) -> bool {
461    let n = |i: usize| value.as_array().and_then(|a| a.get(i)).and_then(Value::as_f64);
462    t != 0 && date_rule(t as f64, method, n(0), n(1))
463}
464
465fn date_rule(t: f64, method: &str, a: Option<f64>, b: Option<f64>) -> bool {
466    let Some(a) = a else { return false };
467    match method {
468        // Same local calendar day, as Eagle's `toDateString()` compares.
469        "on" => local_day(t).is_some_and(|d| local_day(a) == Some(d)),
470        "before" => t <= a + DAY,
471        "after" => t >= a,
472        "between" => b.is_some_and(|b| t >= a && t <= b + DAY),
473        "within" => t + a * DAY >= Local::now().timestamp_millis() as f64,
474        _ => false,
475    }
476}
477
478fn local_day(ms: f64) -> Option<NaiveDate> {
479    Local.timestamp_millis_opt(ms as i64).single().map(|d| d.date_naive())
480}
481
482fn set_rule(list: &[String], method: &str, value: &Value, tags: bool) -> bool {
483    let want: Vec<&str> = value.as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
484    let has = |w: &&str| list.iter().any(|x| x == w);
485    match method {
486        "union" => want.iter().any(has),
487        "intersection" => (if tags { !want.is_empty() } else { true }) && want.iter().all(has),
488        "equal" => want.iter().all(has) && list.len() == want.len() && (!tags || !want.is_empty()),
489        "identity" => !want.iter().any(has),
490        "empty" => list.is_empty(),
491        "not-empty" => !list.is_empty(),
492        _ => false,
493    }
494}
495
496/// How many live items a smart folder with `conditions` would show inside
497/// `parent` (its ancestors' conditions apply too), for the dialog's live
498/// count while the rules are edited, as Eagle's dialog counts them. Empty
499/// conditions count nothing, as a group with no children shows nothing.
500pub fn count_draft(items: &[Item], folders: &FolderIndex, roots: &[SmartFolder], parent: Option<&str>, conditions: &[Value]) -> usize {
501    let mut ancestors: Vec<SmartFolder> = Vec::new();
502    if let Some(pid) = parent {
503        for r in roots {
504            r.walk(&mut Vec::new(), &mut |f, path| {
505                if f.id == pid {
506                    ancestors = path.iter().map(|p| (*p).clone()).chain(std::iter::once(f.clone())).collect();
507                }
508            });
509        }
510    }
511    let draft = SmartFolder {
512        id: String::new(),
513        name: String::new(),
514        conditions: conditions.to_vec(),
515        children: Vec::new(),
516        icon: None,
517        icon_color: None,
518        description: String::new(),
519        order_by: None,
520        sort_increase: None,
521    };
522    let refs: Vec<&SmartFolder> = ancestors.iter().collect();
523    let cx = Context::new(folders);
524    items.iter().filter(|i| contains(i, &draft, &refs, &cx)).count()
525}
526
527impl Library {
528    /// The library's smart folders, top level first.
529    pub fn smart_folders(&self) -> Vec<SmartFolder> {
530        self.metadata.smart_folders.iter().filter_map(SmartFolder::from_value).collect()
531    }
532
533    /// Ids of live items in the smart folder `id`.
534    pub fn smart_folder_items(&self, id: &str) -> Vec<&Item> {
535        let roots = self.smart_folders();
536        let mut found: Option<(SmartFolder, Vec<SmartFolder>)> = None;
537        for r in &roots {
538            r.walk(&mut Vec::new(), &mut |f, path| {
539                if f.id == id {
540                    found = Some((f.clone(), path.iter().map(|p| (*p).clone()).collect()));
541                }
542            });
543        }
544        let Some((folder, ancestors)) = found else { return Vec::new() };
545        let refs: Vec<&SmartFolder> = ancestors.iter().collect();
546        let fx = search::folder_index(&self.metadata.folders);
547        let cx = Context::new(&fx);
548        self.items().iter().filter(|i| contains(i, &folder, &refs, &cx)).collect()
549    }
550
551    /// Adds a top-level smart folder at the end and returns its id.
552    pub fn create_smart_folder(&mut self, name: &str, conditions: Vec<Value>) -> Result<String> {
553        self.create_smart_folder_in(None, name, conditions)
554    }
555
556    /// Adds a smart folder at the end of `parent`'s children (or at the top
557    /// level) and returns its id. A top-level entry takes the shape Eagle's
558    /// save rebuilds top-level entries into (`id`, `name`, `description`,
559    /// `modificationTime`, `conditions`, `children`). Eagle writes nested
560    /// entries as its dialog built them, with the parent's id added
561    /// (`id`, `name`, `conditions`, `modificationTime`, `children`, `parent`).
562    pub fn create_smart_folder_in(&mut self, parent: Option<&str>, name: &str, conditions: Vec<Value>) -> Result<String> {
563        let id = ids::new_id();
564        let (name, now) = (display_name(name), now_ms());
565        match parent {
566            None => self.metadata.smart_folders.push(json!({
567                "id": id, "name": name, "description": "", "modificationTime": now, "conditions": conditions, "children": []
568            })),
569            Some(p) => {
570                let entry = json!({
571                    "id": id, "name": name, "conditions": conditions, "modificationTime": now, "children": [], "parent": p
572                });
573                let Some(parent) = find_smart(&mut self.metadata.smart_folders, p) else { return Err(self.no_smart(p)) };
574                children_of(parent).push(entry);
575            }
576        }
577        self.save_metadata()?;
578        Ok(id)
579    }
580
581    /// Adds a smart folder group (no conditions, the `grid` icon) at the end
582    /// of `parent`'s children or of the top level, and returns its id.
583    /// Eagle's command only makes top-level groups, which its save writes
584    /// as `id`, `icon`, `name`, `description`, `modificationTime`,
585    /// `conditions`, `children`. A nested group keeps the key order of
586    /// Eagle's new group object with `parent` added, as a group dragged into
587    /// another smart folder would be written.
588    pub fn create_smart_folder_group(&mut self, parent: Option<&str>, name: &str) -> Result<String> {
589        let id = ids::new_id();
590        let (name, now) = (display_name(name), now_ms());
591        match parent {
592            None => self.metadata.smart_folders.push(json!({
593                "id": id, "icon": "grid", "name": name, "description": "", "modificationTime": now, "conditions": [], "children": []
594            })),
595            Some(p) => {
596                let entry = json!({
597                    "id": id, "name": name, "modificationTime": now, "children": [], "conditions": [], "icon": "grid", "parent": p
598                });
599                let Some(parent) = find_smart(&mut self.metadata.smart_folders, p) else { return Err(self.no_smart(p)) };
600                children_of(parent).push(entry);
601            }
602        }
603        self.save_metadata()?;
604        Ok(id)
605    }
606
607    /// Replaces a smart folder's name and conditions (at any depth) and
608    /// stamps its `modificationTime`, as Eagle's edit dialog does.
609    pub fn update_smart_folder(&mut self, id: &str, name: &str, conditions: Vec<Value>) -> Result<()> {
610        if let Some(v) = find_smart(&mut self.metadata.smart_folders, id) {
611            v["name"] = json!(display_name(name));
612            v["conditions"] = json!(conditions);
613            v["modificationTime"] = json!(now_ms());
614        }
615        self.save_metadata()
616    }
617
618    /// Renames a smart folder (at any depth) in place, as Eagle's sidebar
619    /// rename does: only `name` changes (cut to 1024 characters), and
620    /// `modificationTime` is left alone.
621    pub fn rename_smart_folder(&mut self, id: &str, name: &str) -> Result<()> {
622        let Some(v) = find_smart(&mut self.metadata.smart_folders, id) else { return Err(self.no_smart(id)) };
623        v["name"] = json!(display_name(name));
624        self.save_metadata()
625    }
626
627    /// Removes a smart folder (at any depth) with its children.
628    pub fn delete_smart_folder(&mut self, id: &str) -> Result<()> {
629        remove_smart(&mut self.metadata.smart_folders, id);
630        self.save_metadata()
631    }
632
633    /// Moves a smart folder under `parent` (or to the top level) at
634    /// position `index` among its new siblings, counted after it has left
635    /// its old place; past the end appends. A nested entry gets `parent`
636    /// set to its parent's id and a top-level one loses it, as Eagle's drag
637    /// does. Moving into itself or a descendant is refused. Children keep
638    /// narrowing their new ancestors when evaluated.
639    pub fn move_smart_folder(&mut self, id: &str, parent: Option<&str>, index: usize) -> Result<()> {
640        let Some(entry) = find_smart(&mut self.metadata.smart_folders, id) else { return Err(self.no_smart(id)) };
641        if let Some(p) = parent {
642            if find_smart(std::slice::from_mut(entry), p).is_some() {
643                return Err(self.invalid("cannot move a smart folder into itself"));
644            }
645            if find_smart(&mut self.metadata.smart_folders, p).is_none() {
646                return Err(self.no_smart(p));
647            }
648        }
649        let mut entry = remove_smart(&mut self.metadata.smart_folders, id).ok_or_else(|| self.no_smart(id))?;
650        if let Some(m) = entry.as_object_mut() {
651            match parent {
652                Some(p) => {
653                    m.insert("parent".into(), json!(p));
654                }
655                None => {
656                    m.shift_remove("parent");
657                }
658            }
659        }
660        let siblings = match parent {
661            None => &mut self.metadata.smart_folders,
662            Some(p) => children_of(find_smart(&mut self.metadata.smart_folders, p).expect("parent checked above")),
663        };
664        siblings.insert(index.min(siblings.len()), entry);
665        self.save_metadata()
666    }
667
668    /// Sets a smart folder's description (at any depth). An existing
669    /// `description` is replaced where it is; a missing one goes right after
670    /// `name`. `modificationTime` is left alone, as Eagle leaves it.
671    pub fn set_smart_folder_description(&mut self, id: &str, description: &str) -> Result<()> {
672        let Some(m) = find_smart(&mut self.metadata.smart_folders, id).and_then(Value::as_object_mut) else { return Err(self.no_smart(id)) };
673        let after_name = m.keys().position(|k| k == "name").map_or(m.len(), |i| i + 1);
674        put(m, "description", Some(json!(description)), Some(after_name));
675        self.save_metadata()
676    }
677
678    /// Sets a smart folder's icon and colour; `None` removes the key. Pass
679    /// the current value to keep one. A new key goes where Eagle's save
680    /// puts it on a top-level entry (right after `id`), or at the end of a
681    /// nested one. `modificationTime` is left alone.
682    pub fn set_smart_folder_icon(&mut self, id: &str, icon: Option<&str>, color: Option<&str>) -> Result<()> {
683        self.check_color(color)?;
684        let top = self.metadata.smart_folders.iter().any(|v| v.get("id").and_then(Value::as_str) == Some(id));
685        let Some(m) = find_smart(&mut self.metadata.smart_folders, id).and_then(Value::as_object_mut) else { return Err(self.no_smart(id)) };
686        put(m, "icon", icon.map(|s| json!(s)), top.then_some(1));
687        let after_icon = usize::from(m.contains_key("icon")) + 1;
688        put(m, "iconColor", color.map(|s| json!(s)), top.then_some(after_icon));
689        self.save_metadata()
690    }
691
692    /// Sets a smart folder's own sort, or clears it (`orderBy` and
693    /// `sortIncrease` both go).
694    pub fn set_smart_folder_order(&mut self, id: &str, order: Option<FolderOrder>, increase: bool) -> Result<()> {
695        let Some(m) = find_smart(&mut self.metadata.smart_folders, id).and_then(Value::as_object_mut) else { return Err(self.no_smart(id)) };
696        put(m, "orderBy", order.map(|o| json!(o.as_str())), None);
697        put(m, "sortIncrease", order.map(|_| json!(increase)), None);
698        self.save_metadata()
699    }
700
701    /// Duplicates a smart folder with its children, as Eagle's Duplicate
702    /// does, and returns the copy's id. The copy is a deep copy of the
703    /// entry (same name, rules, icon and sort, same `modificationTime`)
704    /// placed just before the original, with new ids throughout; nested
705    /// copies point their `parent` at their new parent.
706    pub fn duplicate_smart_folder(&mut self, id: &str) -> Result<String> {
707        fn fresh(entry: &mut Value) {
708            let id = ids::new_id();
709            entry["id"] = json!(id);
710            if let Some(kids) = entry.get_mut("children").and_then(Value::as_array_mut) {
711                for k in kids {
712                    fresh(k);
713                    k["parent"] = json!(id);
714                }
715            }
716        }
717        let parent = self.smart_parent(id).ok_or_else(|| self.no_smart(id))?;
718        let mut copy = find_smart(&mut self.metadata.smart_folders, id).expect("found above").clone();
719        fresh(&mut copy);
720        let new_id = copy["id"].as_str().expect("just set").to_string();
721        let siblings = match parent {
722            None => &mut self.metadata.smart_folders,
723            Some(p) => children_of(find_smart(&mut self.metadata.smart_folders, &p).expect("parent exists")),
724        };
725        let at = siblings.iter().position(|v| v.get("id").and_then(Value::as_str) == Some(id)).expect("in its parent");
726        siblings.insert(at, copy);
727        self.save_metadata()?;
728        Ok(new_id)
729    }
730
731    /// The parent of a smart folder: `Some(None)` at the top level, `None`
732    /// when there is no such smart folder.
733    fn smart_parent(&self, id: &str) -> Option<Option<String>> {
734        fn go(list: &[Value], parent: Option<&str>, id: &str) -> Option<Option<String>> {
735            list.iter().find_map(|v| {
736                let vid = v.get("id").and_then(Value::as_str);
737                if vid == Some(id) {
738                    return Some(parent.map(str::to_string));
739                }
740                go(v.get("children").and_then(Value::as_array).map_or(&[], Vec::as_slice), vid, id)
741            })
742        }
743        go(&self.metadata.smart_folders, None, id)
744    }
745
746    fn no_smart(&self, id: &str) -> crate::Error {
747        self.invalid(format!("no smart folder {id}"))
748    }
749}
750
751/// Sets `key` in place if present, else inserts it at `at` (or the end);
752/// `None` removes it.
753fn put(m: &mut Map<String, Value>, key: &str, value: Option<Value>, at: Option<usize>) {
754    match value {
755        None => {
756            m.shift_remove(key);
757        }
758        Some(v) if m.contains_key(key) => m[key] = v,
759        Some(v) => {
760            let at = at.unwrap_or(m.len()).min(m.len());
761            m.shift_insert(at, key.to_string(), v);
762        }
763    }
764}
765
766fn find_smart<'a>(list: &'a mut [Value], id: &str) -> Option<&'a mut Value> {
767    for v in list {
768        if v.get("id").and_then(Value::as_str) == Some(id) {
769            return Some(v);
770        }
771        if let Some(c) = v.get_mut("children").and_then(Value::as_array_mut)
772            && let Some(found) = find_smart(c, id)
773        {
774            return Some(found);
775        }
776    }
777    None
778}
779
780fn remove_smart(list: &mut Vec<Value>, id: &str) -> Option<Value> {
781    if let Some(pos) = list.iter().position(|v| v.get("id").and_then(Value::as_str) == Some(id)) {
782        return Some(list.remove(pos));
783    }
784    list.iter_mut().find_map(|v| v.get_mut("children").and_then(Value::as_array_mut).and_then(|c| remove_smart(c, id)))
785}
786
787/// An entry's `children`, created (at the end) when missing.
788fn children_of(entry: &mut Value) -> &mut Vec<Value> {
789    if !entry.get("children").is_some_and(Value::is_array) {
790        entry["children"] = json!([]);
791    }
792    entry["children"].as_array_mut().expect("just ensured")
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798
799    fn item(json: Value) -> Item {
800        let mut base = json!({
801            "id": "X", "name": "Red Logo", "size": 2_097_152, "btime": 0, "mtime": 0, "ext": "png",
802            "tags": ["logo", "red"], "folders": ["F1"], "isDeleted": false, "url": "https://a.example/x",
803            "annotation": "", "modificationTime": 1, "height": 100, "width": 300, "lastModified": 1,
804            "palettes": [{"color": [250, 20, 20], "ratio": 60}]
805        });
806        if let (Value::Object(b), Value::Object(o)) = (&mut base, json) {
807            b.extend(o);
808        }
809        Item::from_json_str(&base.to_string()).unwrap()
810    }
811
812    fn folder(conditions: Value) -> SmartFolder {
813        SmartFolder::from_value(&json!({"id": "S", "name": "s", "conditions": conditions, "children": []})).unwrap()
814    }
815
816    #[test]
817    fn a_draft_counts_live_items_inside_its_parent() {
818        let items = vec![
819            item(json!({"id": "A"})),
820            item(json!({"id": "B", "name": "Blue Logo", "tags": ["logo"]})),
821            item(json!({"id": "C", "name": "Red Mark", "tags": ["red"]})),
822            item(json!({"id": "D", "isDeleted": true})),
823        ];
824        let roots = vec![SmartFolder::from_value(&json!({"id": "P", "name": "p", "children": [], "conditions": [
825            {"match": "AND", "rules": [{"property": "tags", "method": "union", "value": ["red"]}]}
826        ]})).unwrap()];
827        let fx = FolderIndex::new();
828        let logo = [json!({"match": "AND", "rules": [{"property": "name", "method": "contain", "value": "logo"}]})];
829        assert_eq!(count_draft(&items, &fx, &roots, None, &logo), 2);
830        assert_eq!(count_draft(&items, &fx, &roots, Some("P"), &logo), 1);
831        assert_eq!(count_draft(&items, &fx, &roots, Some("gone"), &logo), 2);
832        assert_eq!(count_draft(&items, &fx, &roots, None, &[]), 0);
833    }
834
835    #[test]
836    fn rules_and_conditions() {
837        let it = item(json!({}));
838        let fx = FolderIndex::new();
839        let cx = Context::with_font_dir(&fx, None);
840        let yes = |c: Value| contains(&it, &folder(c), &[], &cx);
841        assert!(yes(json!([{"match": "AND", "rules": [
842            {"property": "name", "method": "contain", "value": "logo"},
843            {"property": "type", "method": "equal", "value": "png"},
844            {"property": "fileSize", "method": ">=", "value": [2, 0], "unit": "mb"}
845        ]}])));
846        assert!(!yes(json!([{"match": "AND", "rules": [{"property": "tags", "method": "intersection", "value": ["logo", "blue"]}]}])));
847        assert!(yes(json!([{"match": "OR", "rules": [
848            {"property": "tags", "method": "union", "value": ["blue"]},
849            {"property": "shape", "method": "equal", "value": "panoramic-landscape"}
850        ]}])));
851        assert!(!yes(json!([{"match": "OR", "boolean": "FALSE", "rules": [{"property": "name", "method": "startWith", "value": "red"}]}])));
852        assert!(yes(json!([{"rules": [{"property": "color", "method": "similar", "value": "#FF1010"}]}])));
853        assert!(!yes(json!([{"rules": [{"property": "color", "method": "similar", "value": "#1010FF"}]}])));
854        assert!(yes(json!([{"rules": [{"property": "rating", "method": "equal", "value": "none"}]}])));
855        assert!(!yes(json!([{"rules": [{"property": "name", "method": "contain", "value": ""}]}])));
856        assert!(!yes(json!([{"rules": [{"property": "bpm", "method": ">=", "value": [0, 0]}]}])), "no bpm never matches");
857        let song = item(json!({"ext": "mp3", "bpm": 128}));
858        let rule = |m: &str, v: Value| contains(&song, &folder(json!([{"rules": [{"property": "bpm", "method": m, "value": v}]}])), &[], &cx);
859        assert!(rule("between", json!([120, 130])) && rule("=", json!([128, 0])) && !rule("<", json!([128, 0])));
860    }
861
862    /// Whether `it` passes a single rule, with activated fonts read from `fonts`.
863    fn one(it: &Item, rule: Value, fonts: Option<PathBuf>) -> bool {
864        let fx = FolderIndex::new();
865        let cx = Context::with_font_dir(&fx, fonts);
866        contains(it, &folder(json!([{"match": "AND", "rules": [rule]}])), &[], &cx)
867    }
868
869    #[test]
870    fn comments_rule() {
871        let it = item(json!({"comments": [{"id": "c1", "annotation": "Fix the Logo"}, {"id": "c2", "annotation": " later"}]}));
872        let r = |m: &str, v: &str| one(&it, json!({"property": "comments", "method": m, "value": v}), None);
873        assert!(r("contain", "Logo later") && !r("contain", "logo"), "joined text, case-sensitive");
874        assert!(r("uncontain", "logo") && r("equal", "Fix the Logo later"));
875        assert!(r("startWith", "fix") && r("endWith", "LATER"), "begins/ends ignore case");
876        assert!(r("regex", "L.go\\s") && !r("regex", "l.go"), "regex is case-sensitive");
877        assert!(r("not-empty", "") && !r("empty", "") && r("contain", ""), "an empty value is not refused");
878        let none = item(json!({}));
879        let empty = item(json!({"comments": []}));
880        let c = |it: &Item, m: &str| one(it, json!({"property": "comments", "method": m, "value": ""}), None);
881        assert!(c(&none, "empty") && c(&empty, "empty") && !c(&none, "not-empty"));
882        assert!(!c(&none, "contain") && c(&empty, "contain"), "no list fails, an empty list joins to empty text");
883    }
884
885    #[test]
886    fn camera_and_exposure_rules() {
887        let photo = item(json!({"rawMetas": {"timestamp": 1_700_000_000_000_u64, "camera": "Canon EOS R5", "isoSpeed": 400, "focalLength": "50.0 mm", "shutter": "1/250.0 sec", "aperture": "f/2.8"}}));
888        let plain = item(json!({}));
889        let r = |it: &Item, p: &str, m: &str, v: Value| one(it, json!({"property": p, "method": m, "value": v}), None);
890        assert!(r(&photo, "camera", "contain", json!("eos")) && r(&photo, "camera", "startWith", json!("CANON")));
891        assert!(r(&photo, "camera", "regex", json!("r\\d$")) && !r(&photo, "camera", "equal", json!("canon")));
892        assert!(!r(&plain, "camera", "empty", json!("")), "no camera fails every method, as in Eagle");
893        assert!(r(&photo, "iso", ">", json!([100, 0])) && r(&photo, "iso", "=", json!(["400", 0])) && !r(&photo, "iso", "<", json!([400, 0])));
894        assert!(r(&photo, "iso", "between", json!([400, 800])) && !r(&plain, "iso", ">", json!([0, 0])));
895        assert!(r(&photo, "aperture", "=", json!([2.8, 0])) && r(&photo, "aperture", "<", json!(["4", 0])));
896        assert!(r(&photo, "focalLength", ">=", json!([50, 0])) && !r(&photo, "focalLength", ">", json!([50, 0])));
897        assert!(r(&photo, "shutter", "=", json!([250, 0])), "the shutter's denominator");
898        let long = item(json!({"rawMetas": {"shutter": "2.0 sec", "isoSpeed": "100 ISO"}}));
899        assert!(!r(&long, "shutter", ">", json!([0, 0])), "a whole-second exposure has no second number");
900        assert!(r(&long, "iso", "=", json!([100, 0])), "parseInt reads the leading digits");
901        let day = 1_700_000_000_000_u64;
902        assert!(r(&photo, "timestamp", "on", json!([day + 1000])) && r(&photo, "timestamp", "after", json!([day])));
903        assert!(r(&photo, "timestamp", "between", json!([day - 1, day])) && !r(&photo, "timestamp", "before", json!([day - 2 * 86_400_000])));
904        assert!(!r(&plain, "timestamp", "after", json!([0])));
905    }
906
907    #[test]
908    fn duration_rule() {
909        let clip = item(json!({"ext": "mp4", "duration": 90.0}));
910        let r = |m: &str, v: Value, unit: &str| one(&clip, json!({"property": "duration", "method": m, "value": v, "unit": unit}), None);
911        assert!(r("=", json!([90, 0]), "s") && r("=", json!([1.5, 0]), "m") && r("<", json!([1, 0]), "h"));
912        assert!(r("between", json!([1, 2]), "m") && !r(">", json!([90, 0]), "s"));
913        assert!(r("<=", json!([90, 0]), "other"), "an unknown unit is seconds");
914        let still = item(json!({}));
915        assert!(!one(&still, json!({"property": "duration", "method": ">=", "value": [0, 0], "unit": "s"}), None));
916    }
917
918    #[test]
919    fn font_activated_rule() {
920        let dir = tempfile::tempdir().unwrap();
921        std::fs::write(dir.path().join("ArialMT.ttf"), b"").unwrap();
922        let font = |ps: &str| item(json!({"ext": "ttf", "fontMetas": {"postScriptName": {"en": ps}}}));
923        let r = |it: &Item, m: &str, d: Option<PathBuf>| one(it, json!({"property": "fontActivated", "method": m}), d);
924        let d = || Some(dir.path().to_path_buf());
925        assert!(r(&font("ArialMT"), "activate", d()) && !r(&font("ArialMT"), "deactivate", d()));
926        assert!(!r(&font("Other"), "activate", d()) && r(&font("Other"), "deactivate", d()));
927        assert!(r(&font("ArialMT"), "deactivate", None), "no font folder: nothing is activated");
928        assert!(!r(&item(json!({})), "deactivate", d()), "not a font fails both");
929        // Eagle's folder and falcon's both count.
930        let falcon = tempfile::tempdir().unwrap();
931        std::fs::write(falcon.path().join("Inter-Bold.otf"), b"").unwrap();
932        let otf = |ps: &str| item(json!({"ext": "otf", "fontMetas": {"postScriptName": {"en": ps}}}));
933        let fx = FolderIndex::new();
934        let both = |it: &Item, m: &str| {
935            let cx = Context::with_font_dirs(&fx, vec![dir.path().to_path_buf(), falcon.path().to_path_buf()]);
936            contains(it, &folder(json!([{"match": "AND", "rules": [{"property": "fontActivated", "method": m}]}])), &[], &cx)
937        };
938        assert!(both(&font("ArialMT"), "activate"), "in Eagle's folder");
939        assert!(both(&otf("Inter-Bold"), "activate") && !both(&otf("Inter-Bold"), "deactivate"), "in falcon's folder");
940        assert!(both(&otf("Other"), "deactivate"));
941    }
942
943    #[test]
944    fn regex_and_anchored_methods() {
945        let it = item(json!({"name": "Report 2024.v2"}));
946        let r = |m: &str, v: &str| one(&it, json!({"property": "name", "method": m, "value": v}), None);
947        assert!(r("regex", "^report \\d{4}") && !r("regex", "^\\d"), "a real regex, on lower-cased text");
948        assert!(r("regex", "REPORT"), "the pattern is lower-cased too");
949        assert!(!r("regex", "(") && !r("regex", "(?<=x"), "a pattern that doesn't compile never matches");
950        assert!(r("regex", "(?<=report )\\d{4}") && !r("regex", "(?<!report )\\d{4}"), "look-behind, as JavaScript's");
951        assert!(r("regex", "\\d(?=\\.v)") && !r("regex", "4(?=\\d)"), "look-ahead");
952        assert!(r("regex", "(\\d)\\d\\1") && !r("regex", "(\\d)\\1"), "backreferences: 202 repeats its 2");
953        assert!(r("startWith", "rep") && r("endWith", "v\\d") && r("startWith", "r.p"), "begins/ends take patterns");
954        assert!(!r("regex", ""), "an empty value is refused");
955    }
956
957    #[test]
958    fn on_compares_local_days() {
959        use chrono::{Local, TimeZone};
960        let noon = Local.with_ymd_and_hms(2024, 3, 10, 12, 0, 0).single().unwrap().timestamp_millis() as u64;
961        let late = Local.with_ymd_and_hms(2024, 3, 10, 23, 30, 0).single().unwrap().timestamp_millis() as u64;
962        let early = Local.with_ymd_and_hms(2024, 3, 10, 0, 15, 0).single().unwrap().timestamp_millis() as u64;
963        let next = Local.with_ymd_and_hms(2024, 3, 11, 0, 15, 0).single().unwrap().timestamp_millis() as u64;
964        let at = |t: u64| item(json!({"modificationTime": t}));
965        let on = |t: u64, day: u64| one(&at(t), json!({"property": "createTime", "method": "on", "value": [day]}), None);
966        assert!(on(late, noon) && on(early, noon) && on(noon, late), "any time on the same local day");
967        assert!(!on(next, noon) && !on(noon, next));
968    }
969
970    #[test]
971    fn conditions_follow_eagles_order() {
972        let it = item(json!({}));
973        let fx = FolderIndex::new();
974        let cx = Context::with_font_dir(&fx, None);
975        let yes = |c: Value| contains(&it, &folder(c), &[], &cx);
976        let unknown = json!({"property": "mystery", "method": "equal", "value": "x"});
977        let hit = json!({"property": "name", "method": "contain", "value": "logo"});
978        let miss = json!({"property": "name", "method": "contain", "value": "zzz"});
979        assert!(yes(json!([{"match": "OR", "rules": []}])), "no rules passes");
980        assert!(yes(json!([{"match": "OR", "rules": [hit, unknown]}])), "OR stops at the first hit");
981        assert!(!yes(json!([{"match": "AND", "rules": [hit, unknown]}])), "an unknown property that is reached fails");
982        assert!(!yes(json!([{"match": "AND", "boolean": "FALSE", "rules": [hit, unknown]}])), "even when negated");
983        assert!(yes(json!([{"match": "AND", "boolean": "FALSE", "rules": [miss, unknown]}])), "AND stops at the first miss");
984        assert!(!yes(json!([{"match": "OR"}])), "a condition without rules fails");
985    }
986
987    #[test]
988    fn rating_rule() {
989        let rated = item(json!({"star": 3}));
990        let unrated = item(json!({}));
991        let r = |it: &Item, m: &str, v: Value| one(it, json!({"property": "rating", "method": m, "value": v}), None);
992        assert!(r(&rated, "equal", json!("3")) && r(&unrated, "equal", json!("none")) && !r(&rated, "equal", json!("none")));
993        assert!(r(&rated, "unequal", json!("none")) && !r(&unrated, "unequal", json!("none")));
994        assert!(r(&rated, "contain", json!("35")) && !r(&rated, "contain", json!("45")) && !r(&unrated, "contain", json!("12345")));
995        assert!(r(&unrated, "contain", json!("none")) && r(&rated, "contain", json!("1none")), "any none matches everything");
996        assert!(r(&rated, "contain", json!(["3", "5"])));
997    }
998
999    #[test]
1000    fn children_narrow_their_parent() {
1001        let it = item(json!({}));
1002        let parent = folder(json!([{"rules": [{"property": "type", "method": "equal", "value": "jpg"}]}]));
1003        let child = folder(json!([{"rules": [{"property": "name", "method": "contain", "value": "red"}]}]));
1004        let fx = FolderIndex::new();
1005        let cx = Context::with_font_dir(&fx, None);
1006        assert!(contains(&it, &child, &[], &cx));
1007        assert!(!contains(&it, &child, &[&parent], &cx));
1008    }
1009
1010    #[test]
1011    fn groups_show_the_union_of_their_children() {
1012        let png = item(json!({}));
1013        let jpg = item(json!({"id": "Y", "ext": "jpg", "name": "Blue"}));
1014        let fx = FolderIndex::new();
1015        let cx = Context::with_font_dir(&fx, None);
1016        let by = |ext: &str| folder(json!([{"rules": [{"property": "type", "method": "equal", "value": ext}]}]));
1017        let group = SmartFolder { id: "G".into(), children: vec![by("png"), by("jpg")], ..folder(json!([])) };
1018        assert!(group.is_group() && !by("png").is_group());
1019        assert!(contains(&png, &group, &[], &cx) && contains(&jpg, &group, &[], &cx));
1020        let empty = folder(json!([]));
1021        assert!(!contains(&png, &empty, &[], &cx), "a group without children shows nothing");
1022        // A group inside a group unions its own children.
1023        let outer = SmartFolder { id: "O".into(), children: vec![SmartFolder { children: vec![by("jpg")], ..folder(json!([])) }], ..folder(json!([])) };
1024        assert!(contains(&jpg, &outer, &[], &cx) && !contains(&png, &outer, &[], &cx));
1025        let trashed = item(json!({"isDeleted": true}));
1026        assert!(!contains(&trashed, &group, &[], &cx));
1027    }
1028
1029    #[test]
1030    fn groups_are_written_in_eagles_shape() {
1031        let tmp = tempfile::tempdir().unwrap();
1032        let mut lib = Library::create(tmp.path().join("G.library")).unwrap();
1033        let g = lib.create_smart_folder_group(None, "Mine").unwrap();
1034        let inner = lib.create_smart_folder_group(Some(&g), "Inner").unwrap();
1035        let png = lib.create_smart_folder_in(Some(&g), "PNG", vec![json!({"match": "AND", "rules": [{"property": "type", "method": "equal", "value": "png"}]})]).unwrap();
1036        assert!(lib.create_smart_folder_group(Some("NOPE"), "x").is_err());
1037        let text = std::fs::read_to_string(lib.root().join("metadata.json")).unwrap();
1038        assert!(text.contains(&format!(r#"{{"id":"{g}","icon":"grid","name":"Mine","description":"","modificationTime":"#)), "{text}");
1039        assert!(text.contains(&format!(r#""conditions":[],"children":[{{"id":"{inner}","name":"Inner","modificationTime":"#)), "{text}");
1040        assert!(text.contains(&format!(r#""children":[],"conditions":[],"icon":"grid","parent":"{g}"}}"#)), "{text}");
1041        let sf = lib.smart_folders();
1042        assert!(sf[0].is_group() && sf[0].children[0].is_group() && !sf[0].children[1].is_group());
1043        assert_eq!(sf[0].children[1].id, png);
1044        assert!(lib.smart_folder_items(&g).is_empty(), "an empty library has nothing to show");
1045
1046        let before = std::fs::read_to_string(lib.root().join("metadata.json")).unwrap();
1047        lib.rename_smart_folder(&inner, "Renamed").unwrap();
1048        let after = std::fs::read_to_string(lib.root().join("metadata.json")).unwrap();
1049        // The library's own modificationTime moves on every save (and can
1050        // land on a later millisecond), so compare everything but that.
1051        let strip = |s: &str| {
1052            let mut v: serde_json::Value = serde_json::from_str(s).unwrap();
1053            v.as_object_mut().unwrap().shift_remove("modificationTime");
1054            v.to_string() // keeps key order, so reordering still fails
1055        };
1056        assert_eq!(strip(&after), strip(&before.replace(r#""name":"Inner""#, r#""name":"Renamed""#)), "only the name changes");
1057        assert!(lib.rename_smart_folder("NOPE", "x").is_err());
1058    }
1059
1060    #[test]
1061    fn duplicating_a_smart_folder() {
1062        let tmp = tempfile::tempdir().unwrap();
1063        let mut lib = Library::create(tmp.path().join("S.library")).unwrap();
1064        let rule = json!([{"match": "AND", "rules": [{"property": "name", "method": "contain", "value": "logo"}]}]);
1065        let first = lib.create_smart_folder("First", vec![]).unwrap();
1066        let top = lib.create_smart_folder("Logos", rule.as_array().unwrap().clone()).unwrap();
1067        let kid = lib.create_smart_folder_in(Some(&top), "Blue", vec![]).unwrap();
1068        lib.create_smart_folder_in(Some(&kid), "Deep", vec![]).unwrap();
1069        lib.set_smart_folder_icon(&top, Some("star"), Some("red")).unwrap();
1070        let original = lib.metadata.smart_folders[1].clone();
1071
1072        let copy = lib.duplicate_smart_folder(&top).unwrap();
1073        let got = Library::open(lib.root()).unwrap();
1074        let list = &got.metadata.smart_folders;
1075        let ids: Vec<&str> = list.iter().map(|v| v["id"].as_str().unwrap()).collect();
1076        assert_eq!(ids, [first.as_str(), copy.as_str(), top.as_str()], "just before the original, as Eagle puts it");
1077        assert_eq!(list[2], original, "the original is untouched");
1078        let dup = &list[1];
1079        // Everything but the ids (and the parents that follow them) is the same.
1080        let strip = |v: &Value| {
1081            let mut v = v.clone();
1082            fn go(v: &mut Value) {
1083                if let Some(m) = v.as_object_mut() {
1084                    m.shift_remove("id");
1085                    m.shift_remove("parent");
1086                }
1087                for k in v.get_mut("children").and_then(Value::as_array_mut).into_iter().flatten() {
1088                    go(k);
1089                }
1090            }
1091            go(&mut v);
1092            v
1093        };
1094        assert_eq!(strip(dup), strip(&original));
1095        let dup_kid = &dup["children"][0];
1096        assert_ne!(dup_kid["id"], json!(kid));
1097        assert_eq!(dup_kid["parent"], json!(copy));
1098        assert_eq!(dup_kid["children"][0]["parent"], dup_kid["id"]);
1099
1100        // A nested one is copied among its siblings.
1101        let kid_copy = lib.duplicate_smart_folder(&kid).unwrap();
1102        let roots = lib.smart_folders();
1103        let parent = roots.iter().find(|f| f.id == top).unwrap();
1104        assert_eq!(parent.children.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), [kid_copy.as_str(), kid.as_str()]);
1105        assert!(lib.duplicate_smart_folder("NOPE").is_err());
1106    }
1107
1108    #[test]
1109    fn smart_folder_description_keeps_key_order() {
1110        let tmp = tempfile::tempdir().unwrap();
1111        let mut lib = Library::create(tmp.path().join("D.library")).unwrap();
1112        let top = lib.create_smart_folder("Top", vec![]).unwrap();
1113        let kid = lib.create_smart_folder_in(Some(&top), "Kid", vec![]).unwrap();
1114        let keys = |lib: &Library, i: usize| lib.metadata.smart_folders[i].as_object().unwrap().keys().cloned().collect::<Vec<_>>();
1115        // Folders made by falcon have the key; Eagle's older ones may not.
1116        lib.metadata.smart_folders[0].as_object_mut().unwrap().shift_remove("description");
1117        let before = keys(&lib, 0);
1118        let stamp = lib.metadata.smart_folders[0]["modificationTime"].clone();
1119
1120        // Missing: inserted right after the name.
1121        lib.set_smart_folder_description(&top, "first").unwrap();
1122        let mut want = before.clone();
1123        want.insert(want.iter().position(|k| k == "name").unwrap() + 1, "description".into());
1124        assert_eq!(keys(&lib, 0), want);
1125        assert_eq!(lib.smart_folders()[0].description, "first");
1126        assert_eq!(lib.metadata.smart_folders[0]["modificationTime"], stamp);
1127
1128        // Present: replaced where it is.
1129        lib.set_smart_folder_description(&top, "second").unwrap();
1130        assert_eq!(keys(&lib, 0), want);
1131        assert_eq!(lib.smart_folders()[0].description, "second");
1132
1133        // Nested.
1134        lib.set_smart_folder_description(&kid, "deep").unwrap();
1135        assert_eq!(lib.smart_folders()[0].children[0].description, "deep");
1136        assert_eq!(Library::open(lib.root()).unwrap().smart_folders()[0].children[0].description, "deep");
1137
1138        assert!(lib.set_smart_folder_description("NOPE", "x").is_err());
1139    }
1140}