Skip to main content

falcon_library/
search.rs

1//! Keyword search, filter-panel rules, colour matching and sorting, following
2//! Eagle's behaviour (see the Search and filters chapter of the docs).
3//!
4//! Like Eagle, everything is a linear pass over the items; there is no index.
5
6use std::cmp::Ordering;
7use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9
10use chrono::{Datelike, Local, TimeZone};
11
12use crate::model::{Folder, Item};
13
14// ---------------------------------------------------------------------------
15// Keyword query
16
17/// A parsed search-box query.
18///
19/// Eagle flattens the expression tree into three lists: every AND term must
20/// match, every OR group must have one member match, and no NOT term may
21/// match. Nested groups are flattened the same lossy way, so
22/// `dog || (cat black)` is the OR of `dog` and the literal `cat black`.
23#[derive(Debug, Clone, Default, PartialEq)]
24pub struct Query {
25    pub all: Vec<String>,
26    pub any: Vec<Vec<String>>,
27    pub none: Vec<String>,
28    /// The whole query, lower-cased; matched as one substring if the terms
29    /// fail.
30    pub raw: String,
31}
32
33#[derive(Debug, Clone, PartialEq)]
34enum Token {
35    Word(String),
36    Open,
37    Close,
38    Or,
39}
40
41fn tokenize(q: &str) -> Vec<Token> {
42    let mut out = Vec::new();
43    let mut chars = q.chars().peekable();
44    while let Some(&c) = chars.peek() {
45        match c {
46            c if c.is_whitespace() => {
47                chars.next();
48            }
49            '(' => {
50                chars.next();
51                out.push(Token::Open);
52            }
53            ')' => {
54                chars.next();
55                out.push(Token::Close);
56            }
57            '"' | '\'' => {
58                chars.next();
59                let mut s = String::new();
60                for d in chars.by_ref() {
61                    if d == c {
62                        break;
63                    }
64                    s.push(d);
65                }
66                if !s.is_empty() {
67                    out.push(Token::Word(s));
68                }
69            }
70            _ => {
71                let mut s = String::new();
72                while let Some(&d) = chars.peek() {
73                    if d.is_whitespace() || d == '(' || d == ')' {
74                        break;
75                    }
76                    s.push(d);
77                    chars.next();
78                }
79                if s == "||" || s.eq_ignore_ascii_case("or") {
80                    out.push(Token::Or);
81                } else {
82                    out.push(Token::Word(s));
83                }
84            }
85        }
86    }
87    out
88}
89
90/// One element of a flattened sequence: a term, or a parenthesised group
91/// collapsed to its words joined by spaces.
92fn flatten_group(tokens: &[Token], i: &mut usize) -> String {
93    let mut words = Vec::new();
94    while *i < tokens.len() {
95        match &tokens[*i] {
96            Token::Close => {
97                *i += 1;
98                break;
99            }
100            Token::Open => {
101                *i += 1;
102                words.push(flatten_group(tokens, i));
103            }
104            Token::Word(w) => {
105                words.push(w.clone());
106                *i += 1;
107            }
108            Token::Or => *i += 1,
109        }
110    }
111    words.join(" ")
112}
113
114impl Query {
115    pub fn parse(q: &str) -> Query {
116        let tokens = tokenize(q);
117        let mut terms: Vec<String> = Vec::new();
118        let mut ors: Vec<bool> = Vec::new(); // ors[k]: an OR sits before terms[k]
119        let mut pending_or = false;
120        let mut i = 0;
121        while i < tokens.len() {
122            match &tokens[i] {
123                Token::Or => {
124                    pending_or = true;
125                    i += 1;
126                    continue;
127                }
128                Token::Open => {
129                    i += 1;
130                    let g = flatten_group(&tokens, &mut i);
131                    if !g.is_empty() {
132                        terms.push(g);
133                        ors.push(pending_or);
134                    }
135                }
136                Token::Close => i += 1,
137                Token::Word(w) => {
138                    terms.push(w.clone());
139                    ors.push(pending_or);
140                    i += 1;
141                }
142            }
143            pending_or = false;
144        }
145
146        let mut query = Query { raw: q.trim().to_lowercase(), ..Default::default() };
147        let mut k = 0;
148        while k < terms.len() {
149            // Collect a run joined by ORs.
150            let mut run = vec![terms[k].to_lowercase()];
151            while k + 1 < terms.len() && ors[k + 1] {
152                k += 1;
153                run.push(terms[k].to_lowercase());
154            }
155            k += 1;
156            if run.len() > 1 {
157                query.any.push(run);
158            } else {
159                let t = run.pop().unwrap();
160                match t.strip_prefix('-') {
161                    Some(neg) if !neg.is_empty() => query.none.push(neg.to_string()),
162                    _ => query.all.push(t),
163                }
164            }
165        }
166        query
167    }
168
169    pub fn is_empty(&self) -> bool {
170        self.all.is_empty() && self.any.is_empty() && self.none.is_empty()
171    }
172
173    /// Whether `text` (already lower-cased) satisfies the query.
174    pub fn matches(&self, text: &str) -> bool {
175        if self.is_empty() {
176            return true;
177        }
178        if self.none.iter().any(|n| text.contains(n.as_str())) {
179            return false;
180        }
181        let terms = self.all.iter().all(|t| text.contains(t.as_str()))
182            && self.any.iter().all(|g| g.iter().any(|t| text.contains(t.as_str())));
183        terms || (!self.raw.is_empty() && text.contains(&self.raw))
184    }
185
186    /// Typo-tolerant match (falcon's own, not Eagle's). `Some(0)` when
187    /// [`Query::matches`] passes; otherwise `Some(cost)`, the total number of
188    /// edits needed for every AND term and one member of every OR group to
189    /// match the start of some word in `text`, or `None` if that's too many.
190    /// NOT terms stay exact. See the Search chapter for the rules.
191    pub fn fuzzy_score(&self, text: &str) -> Option<usize> {
192        if self.matches(text) {
193            return Some(0);
194        }
195        if self.none.iter().any(|n| text.contains(n.as_str())) {
196            return None;
197        }
198        let words = || text.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty());
199        let mut buf: Vec<char> = Vec::new();
200        let mut cost = |t: &String| -> Option<usize> {
201            if text.contains(t.as_str()) {
202                return Some(0);
203            }
204            let budget = typo_budget(t);
205            if budget == 0 {
206                return None;
207            }
208            let t: Vec<char> = t.chars().map(fold).collect();
209            let mut best: Option<usize> = None;
210            for w in words() {
211                // Each term letter the word lacks entirely costs at least one
212                // edit, which rules out most words before the full distance.
213                let mask = w.chars().fold(0, |m, c| m | letter_bit(fold(c)));
214                if t.iter().filter(|&&c| mask & letter_bit(c) == 0).count() > budget {
215                    continue;
216                }
217                buf.clear();
218                buf.extend(w.chars().map(fold));
219                if let Some(d) = prefix_distance(&t, &buf, best.map_or(budget, |b| b.saturating_sub(1)).min(budget)) {
220                    best = Some(best.map_or(d, |b| b.min(d)));
221                    if d == 0 {
222                        break;
223                    }
224                }
225            }
226            best
227        };
228        let mut total = 0;
229        for t in &self.all {
230            total += cost(t)?;
231        }
232        for g in &self.any {
233            total += g.iter().filter_map(&mut cost).min()?;
234        }
235        Some(total.max(1))
236    }
237}
238
239/// Edits a term may need under fuzzy matching: none for terms under 4
240/// characters, phrases and terms with digits (a typo in a number usually
241/// means a different number), 1 for 4–7 characters, 2 from 8.
242fn typo_budget(term: &str) -> usize {
243    if term.chars().any(|c| c.is_whitespace() || c.is_ascii_digit()) {
244        return 0;
245    }
246    match term.chars().count() {
247        0..=3 => 0,
248        4..=7 => 1,
249        _ => 2,
250    }
251}
252
253/// A bit per letter (non-ASCII letters share bits, which only weakens the
254/// filter that uses it, never makes it wrong).
255fn letter_bit(c: char) -> u128 {
256    1u128 << (c as u32 % 128)
257}
258
259/// Folds common Latin accents so `cafe` and `café` compare equal.
260fn fold(c: char) -> char {
261    match c {
262        'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'ā' => 'a',
263        'ç' | 'ć' | 'č' => 'c',
264        'è' | 'é' | 'ê' | 'ë' | 'ē' | 'ę' => 'e',
265        'ì' | 'í' | 'î' | 'ï' | 'ī' => 'i',
266        'ñ' | 'ń' => 'n',
267        'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' | 'ō' => 'o',
268        'ù' | 'ú' | 'û' | 'ü' | 'ū' => 'u',
269        'ý' | 'ÿ' => 'y',
270        'ś' | 'š' => 's',
271        'ź' | 'ż' | 'ž' => 'z',
272        'ł' => 'l',
273        _ => c,
274    }
275}
276
277/// The smallest optimal-string-alignment distance (Levenshtein plus adjacent
278/// swaps) between `term` and either the whole `word` or a prefix of it at
279/// least as long as `term`, if it is at most `max`. Shorter prefixes are
280/// left out so that deleting letters from the term can't match unrelated
281/// words (`tigr` would otherwise match `tiramisu` through `tir`).
282fn prefix_distance(term: &[char], word: &[char], max: usize) -> Option<usize> {
283    let (m, n) = (term.len(), word.len());
284    if n + max < m {
285        return None;
286    }
287    let mut prev2: Vec<usize> = Vec::new();
288    let mut prev: Vec<usize> = (0..=n).collect();
289    for i in 1..=m {
290        let mut cur = vec![i; n + 1];
291        for j in 1..=n {
292            let sub = prev[j - 1] + usize::from(term[i - 1] != word[j - 1]);
293            let mut d = sub.min(prev[j] + 1).min(cur[j - 1] + 1);
294            if i > 1 && j > 1 && term[i - 1] == word[j - 2] && term[i - 2] == word[j - 1] {
295                d = d.min(prev2[j - 2] + 1);
296            }
297            cur[j] = d;
298        }
299        if cur.iter().all(|&d| d > max) {
300            return None;
301        }
302        prev2 = std::mem::replace(&mut prev, cur);
303    }
304    let best = (m.min(n)..=n).filter(|&j| j >= m || j == n).map(|j| prev[j]).min()?;
305    (best <= max).then_some(best)
306}
307
308/// Which parts of an item the search box looks at: Eagle's Search Scope
309/// menu. Everything is on by default. An item's text content, camera and
310/// font names are always searched, whatever the scope.
311#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
312#[serde(default)]
313pub struct SearchScope {
314    pub name: bool,
315    pub folder_name: bool,
316    pub folder_desc: bool,
317    pub ext: bool,
318    pub tags: bool,
319    pub url: bool,
320    /// Region and video comments.
321    pub comments: bool,
322    /// The item's note (`annotation`).
323    pub note: bool,
324}
325
326impl Default for SearchScope {
327    fn default() -> Self {
328        SearchScope { name: true, folder_name: true, folder_desc: true, ext: true, tags: true, url: true, comments: true, note: true }
329    }
330}
331
332/// [`search_text_in`] with every part of the scope on.
333pub fn search_text(item: &Item, folders: &FolderIndex, query_len: usize) -> String {
334    search_text_in(item, folders, query_len, &SearchScope::default())
335}
336
337/// The lower-cased text Eagle searches for an item: its text content
338/// (`text`, kept for text files) and camera (`rawMetas.camera`), then, as
339/// `scope` allows, the name, `.ext`, url (for queries of two or more
340/// characters), note and comments; a font's full name (the `zh` entry
341/// first, else `en`) and, for queries of three or more characters, its
342/// whole `fontMetas` as JSON; then tags and the name and description of
343/// each folder the item is in.
344pub fn search_text_in(item: &Item, folders: &FolderIndex, query_len: usize, scope: &SearchScope) -> String {
345    let mut s = String::with_capacity(128);
346    let mut push = |x: &str| {
347        s.push(' ');
348        s.push_str(x);
349    };
350    if let Some(t) = item.extra.get("text").and_then(|v| v.as_str()) {
351        push(t);
352    }
353    if let Some(c) = camera(item) {
354        push(c);
355    }
356    if scope.name {
357        push(&item.name);
358    }
359    if scope.ext && !item.ext.is_empty() {
360        push(&format!(".{}", item.ext));
361    }
362    if scope.url && query_len >= 2 {
363        push(&item.url);
364    }
365    if scope.note {
366        push(&item.annotation);
367    }
368    if scope.comments {
369        for t in comment_texts(item) {
370            push(t);
371        }
372    }
373    if type_group(&item.ext) == Some("font")
374        && let Some(metas) = item.extra.get("fontMetas")
375    {
376        let full = |lang: &str| metas.get("fullName").and_then(|m| m.get(lang)).and_then(|v| v.as_str()).filter(|n| !n.is_empty());
377        push(full("zh").or_else(|| full("en")).unwrap_or(""));
378        if query_len > 2 && metas.get("postScriptName").is_some_and(|v| !v.is_null()) {
379            push(&metas.to_string());
380        }
381    }
382    if scope.tags {
383        for t in &item.tags {
384            push(t);
385        }
386    }
387    if scope.folder_name || scope.folder_desc {
388        for f in &item.folders {
389            if let Some((name, desc)) = folders.get(f) {
390                if scope.folder_name {
391                    push(name);
392                }
393                if scope.folder_desc {
394                    push(desc);
395                }
396            }
397        }
398    }
399    s.to_lowercase()
400}
401
402/// The text of each of an item's comments (`comments[].annotation`).
403pub fn comment_texts(item: &Item) -> impl Iterator<Item = &str> {
404    item.extra.get("comments").and_then(|v| v.as_array()).into_iter().flatten().filter_map(|c| c.get("annotation").and_then(|a| a.as_str()))
405}
406
407/// Folder id to (name, description).
408pub type FolderIndex = HashMap<String, (String, String)>;
409
410pub fn folder_index(roots: &[Folder]) -> FolderIndex {
411    let mut out = HashMap::new();
412    for r in roots {
413        r.walk(&mut |f| {
414            out.insert(f.id.clone(), (f.name.clone(), f.description.clone()));
415        });
416    }
417    out
418}
419
420// ---------------------------------------------------------------------------
421// Name ranking (filter searches and Go to Folder)
422
423/// How well `word` matches `text` by Eagle's quick-search score (the
424/// string_score method): 1 for identical strings, 0 when the letters of
425/// `word` do not all appear in order, otherwise a score rewarding letters
426/// that follow each other, start a word or keep their case, and a shared
427/// first letter. Lengths and positions are in UTF-16 units, as in
428/// JavaScript.
429pub fn string_score(text: &str, word: &str) -> f64 {
430    if text == word {
431        return 1.0;
432    }
433    if word.is_empty() {
434        return 0.0;
435    }
436    let s: Vec<u16> = text.encode_utf16().collect();
437    let ls: Vec<u16> = text.to_lowercase().encode_utf16().collect();
438    let w: Vec<u16> = word.encode_utf16().collect();
439    let lw: Vec<u16> = word.to_lowercase().encode_utf16().collect();
440    let mut running = 0.0;
441    let mut start = 0usize;
442    for (i, &c) in lw.iter().enumerate() {
443        let Some(found) = ls.get(start..).and_then(|rest| rest.iter().position(|&x| x == c)).map(|p| p + start) else {
444            return 0.0;
445        };
446        let mut score = if found == start {
447            0.7
448        } else {
449            let after_space = found > 0 && s.get(found - 1) == Some(&(' ' as u16));
450            if after_space { 0.9 } else { 0.1 }
451        };
452        if s.get(found).is_some() && s.get(found) == w.get(i) {
453            score += 0.1;
454        }
455        running += score;
456        start = found + 1;
457    }
458    let mut total = 0.5 * (running / s.len() as f64 + running / w.len() as f64);
459    if lw.first() == ls.first() && total < 0.85 {
460        total += 0.15;
461    }
462    total
463}
464
465/// A name as Eagle compares it when ranking: decomposed, without
466/// combining accents, lower-cased.
467pub fn rank_key(name: &str) -> String {
468    use unicode_normalization::UnicodeNormalization;
469    name.nfkd().filter(|c| !('\u{300}'..='\u{36f}').contains(c)).collect::<String>().to_lowercase()
470}
471
472/// Ranks `names` against a typed keyword as Eagle's tag and folder filter
473/// searches and Go to Folder do: the keyword loses its spaces, both sides
474/// go through [`rank_key`], names scoring 0 are dropped and the rest are
475/// ordered best first (ties keep their order). Returns indices into
476/// `names`. (Eagle also scores the pinyin of Chinese names; falcon does
477/// not.)
478pub fn rank_names<'a>(names: impl IntoIterator<Item = &'a str>, keyword: &str) -> Vec<usize> {
479    let key = rank_key(keyword).replace(' ', "");
480    let mut scored: Vec<(usize, f64)> =
481        names.into_iter().enumerate().map(|(n, name)| (n, string_score(&rank_key(name), &key))).filter(|(_, sc)| *sc > 0.0).collect();
482    scored.sort_by(|a, b| b.1.total_cmp(&a.1));
483    scored.into_iter().map(|(n, _)| n).collect()
484}
485
486// ---------------------------------------------------------------------------
487// Filter panel
488
489/// How the tag and folder filters combine their lists.
490#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
491pub enum Logic {
492    #[default]
493    Or,
494    And,
495    Equal,
496}
497
498/// Include/exclude lists for tags or folder ids.
499#[derive(Debug, Clone, Default, PartialEq)]
500pub struct SetFilter {
501    pub logic: Logic,
502    pub includes: Vec<String>,
503    pub excludes: Vec<String>,
504    /// Also match items with an empty list (`tag.no` / `NoFolders`).
505    pub empty: bool,
506    /// Exclude items with an empty list (the folder filter's excluded
507    /// Uncategorized, `folder.excludes.NoFolders`).
508    pub exclude_empty: bool,
509}
510
511impl SetFilter {
512    pub fn is_active(&self) -> bool {
513        !self.includes.is_empty() || !self.excludes.is_empty() || self.empty || self.exclude_empty
514    }
515
516    /// Eagle's semantics, including its OR quirk: under OR an item with any
517    /// included value passes even if it also has an excluded one.
518    pub fn matches(&self, values: &[String]) -> bool {
519        if !self.is_active() {
520            return true;
521        }
522        let has = |v: &String| values.contains(v);
523        let excluding = !self.excludes.is_empty() || self.exclude_empty;
524        let excluded = self.excludes.iter().any(has) || (self.exclude_empty && values.is_empty());
525        let base = match self.logic {
526            Logic::Or => self.includes.iter().any(has) || (excluding && !excluded),
527            Logic::And | Logic::Equal => {
528                let ok = self.includes.iter().all(has) && !excluded;
529                let ok = ok && (self.logic != Logic::Equal || values.len() == self.includes.len());
530                ok && (!self.includes.is_empty() || excluding)
531            }
532        };
533        match (self.empty, self.logic) {
534            (true, Logic::Or) => base || values.is_empty(),
535            (true, _) if self.includes.is_empty() && !excluding => values.is_empty(),
536            (true, _) => base && values.is_empty(),
537            (false, _) => base,
538        }
539    }
540}
541
542/// `has` / `no` / keywords for the url and note filters.
543#[derive(Debug, Clone, Default, PartialEq)]
544pub enum TextFilter {
545    #[default]
546    Off,
547    Has,
548    No,
549    /// Non-empty and containing every keyword (case-insensitive).
550    Keywords(Vec<String>),
551}
552
553impl TextFilter {
554    /// The rule over a list of texts, as Eagle's comment filter reads an
555    /// item's comments: `Has` needs one, `No` none, and every keyword must
556    /// occur (case-insensitively) in one of them.
557    pub fn matches_any<'a>(&self, values: impl Iterator<Item = &'a str>) -> bool {
558        match self {
559            TextFilter::Off => true,
560            TextFilter::Has => values.count() > 0,
561            TextFilter::No => values.count() == 0,
562            TextFilter::Keywords(k) => {
563                let v: Vec<String> = values.map(str::to_lowercase).collect();
564                k.iter().all(|w| {
565                    let w = w.to_lowercase();
566                    v.iter().any(|t| t.contains(&w))
567                })
568            }
569        }
570    }
571
572    fn matches(&self, value: &str) -> bool {
573        match self {
574            TextFilter::Off => true,
575            TextFilter::Has => !value.is_empty(),
576            TextFilter::No => value.is_empty(),
577            TextFilter::Keywords(k) => {
578                let v = value.to_lowercase();
579                !value.is_empty() && k.iter().all(|w| v.contains(&w.to_lowercase()))
580            }
581        }
582    }
583}
584
585#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
586pub enum Shape {
587    Landscape,
588    Portrait,
589    Square,
590    PanoramicLandscape,
591    PanoramicPortrait,
592    /// Exactly 4:3, 3:4, 16:9 or 9:16 (width over height compared as
593    /// floating point, as Eagle does, so 1366 × 768 is not 16:9).
594    Ratio4x3,
595    Ratio3x4,
596    Ratio16x9,
597    Ratio9x16,
598}
599
600impl Shape {
601    /// Every shape in the order of Eagle's Shape filter.
602    pub const ALL: [Shape; 9] = [
603        Shape::Landscape,
604        Shape::Portrait,
605        Shape::Square,
606        Shape::PanoramicLandscape,
607        Shape::PanoramicPortrait,
608        Shape::Ratio4x3,
609        Shape::Ratio3x4,
610        Shape::Ratio16x9,
611        Shape::Ratio9x16,
612    ];
613
614    pub fn matches(self, w: u64, h: u64) -> bool {
615        let (w, h) = (w as f64, h as f64);
616        match self {
617            Shape::Landscape => w > h,
618            Shape::Portrait => h > w,
619            Shape::Square => w == h,
620            Shape::PanoramicLandscape => w > h && w / h >= 2.5,
621            Shape::PanoramicPortrait => w < h && h / w >= 2.5,
622            Shape::Ratio4x3 => w / h == 4.0 / 3.0,
623            Shape::Ratio3x4 => w / h == 3.0 / 4.0,
624            Shape::Ratio16x9 => w / h == 16.0 / 9.0,
625            Shape::Ratio9x16 => w / h == 9.0 / 16.0,
626        }
627    }
628}
629
630/// Eagle's custom W:H shape: the exact ratio, or every item while either
631/// side is unset (0).
632pub fn custom_shape_matches((rw, rh): (f64, f64), w: u64, h: u64) -> bool {
633    if rw == 0.0 || rh == 0.0 || rw.is_nan() || rh.is_nan() {
634        return true;
635    }
636    w as f64 / h as f64 == rw / rh
637}
638
639/// How many items the Shape filter's options would find, as Eagle counts
640/// them: only items with a width that are not audio or fonts; each falls
641/// in one of the five orientations (panoramic ones are not also counted
642/// as horizontal or vertical) and at most one of the four ratios.
643pub fn shape_counts<'a>(items: impl IntoIterator<Item = &'a Item>) -> HashMap<Shape, usize> {
644    let mut out: HashMap<Shape, usize> = HashMap::new();
645    for i in items {
646        if i.width == 0 || matches!(type_group(&i.ext), Some("audio" | "font")) {
647            continue;
648        }
649        let (w, h) = (i.width as f64, i.height as f64);
650        let orientation = if w > h {
651            if w / h >= 2.5 { Shape::PanoramicLandscape } else { Shape::Landscape }
652        } else if w < h {
653            if h / w >= 2.5 { Shape::PanoramicPortrait } else { Shape::Portrait }
654        } else {
655            Shape::Square
656        };
657        *out.entry(orientation).or_default() += 1;
658        if let Some(r) = [Shape::Ratio4x3, Shape::Ratio3x4, Shape::Ratio16x9, Shape::Ratio9x16].into_iter().find(|r| r.matches(i.width, i.height)) {
659            *out.entry(r).or_default() += 1;
660        }
661    }
662    out
663}
664
665/// Eagle's file-type groups for the type filter.
666pub fn type_group(ext: &str) -> Option<&'static str> {
667    Some(match ext {
668        "ts" | "3gp" | "360" | "afx" | "vap" | "eva" | "mp4" | "mov" | "m4v" | "webm" | "mkv" | "avi" | "wmv"
669        | "mpg" | "mts" | "flv" | "m2ts" | "f4v" => "video",
670        e if crate::media::is_audio(e) => "audio",
671        "ttf" | "otf" | "ttc" | "woff" | "woff2" => "font",
672        "ppt" | "pptx" | "potx" => "powerpoint",
673        "doc" | "docx" => "word",
674        "xls" | "xlsx" => "excel",
675        _ => return None,
676    })
677}
678
679/// The web-video site of a bookmark (`medium`: youtube, vimeo, bilibili).
680pub fn medium(item: &Item) -> Option<&str> {
681    item.extra.get("medium").and_then(|v| v.as_str()).filter(|m| !m.is_empty())
682}
683
684/// The type filter's built-in groups, after the extensions in its list.
685pub const TYPE_GROUPS: [&str; 6] = ["font", "video", "audio", "youtube", "vimeo", "bilibili"];
686
687/// Whether an item passes the type filter's included types, in Eagle's
688/// order and with its early answers: its own extension wins; otherwise
689/// the first included `video` or `audio` group decides by itself (so
690/// including both video and audio lets only videos through), a `url`
691/// entry takes bookmarks without a site, a site name its bookmarks, and
692/// the Office groups and `font` follow.
693pub fn type_included(includes: &[String], item: &Item) -> bool {
694    let has = |t: &str| includes.iter().any(|x| x == t);
695    let ext = item.ext.as_str();
696    let group = type_group(ext);
697    if has(ext) {
698        return true;
699    }
700    if has("video") {
701        return group == Some("video");
702    }
703    let site = medium(item);
704    if has("url") && ext == "url" && site.is_none() {
705        return true;
706    }
707    for s in ["youtube", "vimeo", "bilibili"] {
708        if has(s) && ext == "url" && site == Some(s) {
709            return true;
710        }
711    }
712    if has("audio") {
713        return group == Some("audio");
714    }
715    for g in ["powerpoint", "word", "excel"] {
716        if has(g) && group == Some(g) {
717            return true;
718        }
719    }
720    has("font") && group == Some("font")
721}
722
723/// Whether one of the type filter's excluded types covers an item.
724pub fn type_excluded(excludes: &[String], item: &Item) -> bool {
725    let has = |t: &str| excludes.iter().any(|x| x == t);
726    let ext = item.ext.as_str();
727    let site = medium(item);
728    has(ext)
729        || type_group(ext).is_some_and(has)
730        || (ext == "url" && site.is_none() && has("url"))
731        || (ext == "url" && site.is_some_and(has))
732}
733
734/// How many items each option of the type filter covers: by extension, or
735/// by site for web-video bookmarks, as Eagle counts them, plus the font,
736/// video and audio groups (which Eagle lists but never counts, so its
737/// panel hides them).
738pub fn type_counts<'a>(items: impl IntoIterator<Item = &'a Item>) -> HashMap<String, usize> {
739    let mut out: HashMap<String, usize> = HashMap::new();
740    for i in items {
741        *out.entry(medium(i).unwrap_or(&i.ext).to_string()).or_default() += 1;
742        if let Some(g @ ("font" | "video" | "audio")) = type_group(&i.ext) {
743            *out.entry(g.to_string()).or_default() += 1;
744        }
745    }
746    out
747}
748
749/// The type filter's options: the extensions present, in code-unit order,
750/// then the built-in groups not already listed.
751pub fn type_options<'a>(items: impl IntoIterator<Item = &'a Item>) -> Vec<String> {
752    let exts: HashSet<&str> = items.into_iter().map(|i| i.ext.as_str()).collect();
753    let mut out: Vec<String> = exts.into_iter().map(String::from).collect();
754    out.sort_by(|a, b| js_cmp(a, b));
755    for g in TYPE_GROUPS {
756        if !out.iter().any(|x| x == g) {
757            out.push(g.to_string());
758        }
759    }
760    out
761}
762
763/// The filter panel's rules. Every active rule must pass.
764#[derive(Debug, Clone, Default, PartialEq)]
765pub struct Filter {
766    pub tags: SetFilter,
767    pub folders: SetFilter,
768    /// Extensions or group names to include / exclude.
769    pub types: Vec<String>,
770    pub exclude_types: Vec<String>,
771    /// Inclusive byte range.
772    pub size: (Option<u64>, Option<u64>),
773    /// Inclusive pixel ranges.
774    pub width: (Option<u64>, Option<u64>),
775    pub height: (Option<u64>, Option<u64>),
776    /// Ratings to match (0 = unrated), OR-ed.
777    pub ratings: HashSet<u8>,
778    pub shapes: HashSet<Shape>,
779    /// The custom W:H shape when it is ticked (see [`custom_shape_matches`]);
780    /// OR-ed with `shapes`.
781    pub custom_shape: Option<(f64, f64)>,
782    pub url: TextFilter,
783    pub note: TextFilter,
784    /// Region and video comments (Eagle's `annotation` rule).
785    pub comments: TextFilter,
786    pub color: Option<ColorQuery>,
787    pub grayscale: bool,
788    /// Date Imported (`modificationTime`).
789    pub imported: DateFilter,
790    /// Date Created (`btime`); not in Eagle's panel, only in its smart folders.
791    pub created: DateFilter,
792    /// Date Modified (`mtime`).
793    pub modified: DateFilter,
794    /// Inclusive range in seconds; each bound is truncated to whole seconds
795    /// as Eagle's `parseInt` does. Items without a `duration` fail an active
796    /// range.
797    pub duration: (Option<f64>, Option<f64>),
798    /// Inclusive tempo range in BPM; each bound is truncated to a whole
799    /// number as Eagle's `parseInt` does. Items without a `bpm` (or with
800    /// `0`) fail an active range.
801    pub bpm: (Option<f64>, Option<f64>),
802    /// Camera names (`rawMetas.camera`), matched exactly, OR-ed.
803    pub cameras: HashSet<String>,
804    pub font: FontFilter,
805}
806
807impl Filter {
808    pub fn is_active(&self) -> bool {
809        *self != Filter::default()
810    }
811
812    /// Whether `item` passes every rule; for a colour rule also returns the
813    /// distance Eagle sorts by.
814    pub fn check(&self, item: &Item) -> Option<f64> {
815        if !self.types.is_empty() && !type_included(&self.types, item) {
816            return None;
817        }
818        if type_excluded(&self.exclude_types, item) {
819            return None;
820        }
821        let within = |r: (Option<u64>, Option<u64>), v: u64| r.0.is_none_or(|a| v >= a) && r.1.is_none_or(|b| v <= b);
822        if !within(self.size, item.size) || !within(self.width, item.width) || !within(self.height, item.height) {
823            return None;
824        }
825        if !self.ratings.is_empty() {
826            let star = item.extra.get("star").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
827            if !self.ratings.contains(&star) {
828                return None;
829            }
830        }
831        if (!self.shapes.is_empty() || self.custom_shape.is_some())
832            && !self.shapes.iter().any(|s| s.matches(item.width, item.height))
833            && !self.custom_shape.is_some_and(|r| custom_shape_matches(r, item.width, item.height))
834        {
835            return None;
836        }
837        if !self.url.matches(&item.url) || !self.note.matches(&item.annotation) || !self.comments.matches_any(comment_texts(item)) {
838            return None;
839        }
840        if self.grayscale && !is_grayscale(item) {
841            return None;
842        }
843        if !self.tags.matches(&item.tags) || !self.folders.matches(&item.folders) {
844            return None;
845        }
846        if !self.imported.matches(DateField::Imported.value(item))
847            || !self.created.matches(DateField::Created.value(item))
848            || !self.modified.matches(DateField::Modified.value(item))
849        {
850            return None;
851        }
852        if self.duration != (None, None) {
853            let d = duration(item)?;
854            if self.duration.0.is_some_and(|a| d < a.trunc()) || self.duration.1.is_some_and(|b| d > b.trunc()) {
855                return None;
856            }
857        }
858        if self.bpm != (None, None) {
859            let b = bpm(item)?;
860            if self.bpm.0.is_some_and(|a| b < a.trunc()) || self.bpm.1.is_some_and(|z| b > z.trunc()) {
861                return None;
862            }
863        }
864        if !self.cameras.is_empty() && !camera(item).is_some_and(|c| self.cameras.contains(c)) {
865            return None;
866        }
867        if !self.font.matches(item) {
868            return None;
869        }
870        match &self.color {
871            Some(c) => c.distance(item),
872            None => Some(0.0),
873        }
874    }
875}
876
877// ---------------------------------------------------------------------------
878// Dates
879
880/// One day in milliseconds; Eagle steps days by this, ignoring DST.
881pub const DAY_MS: i64 = 24 * 60 * 60 * 1000;
882
883/// Which timestamp a date rule reads.
884#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
885pub enum DateField {
886    /// `modificationTime`, the import time.
887    Imported,
888    /// `btime`, falling back to `modificationTime` when 0.
889    Created,
890    /// `mtime`, falling back to `modificationTime` when 0.
891    Modified,
892}
893
894impl DateField {
895    pub fn value(self, item: &Item) -> u64 {
896        let or_import = |t: u64| if t == 0 { item.modification_time } else { t };
897        match self {
898            DateField::Imported => item.modification_time,
899            DateField::Created => or_import(item.btime),
900            DateField::Modified => or_import(item.mtime),
901        }
902    }
903}
904
905/// Eagle's date presets. Several selected presets are OR-ed.
906#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
907pub enum DatePreset {
908    /// After local midnight today.
909    Today,
910    /// Between local midnight yesterday and today (both exclusive).
911    Yesterday,
912    /// Less than N × 24 h before now.
913    Last7Days,
914    Last30Days,
915    Last90Days,
916    Last365Days,
917}
918
919impl DatePreset {
920    pub const ALL: [DatePreset; 6] = [
921        DatePreset::Today,
922        DatePreset::Yesterday,
923        DatePreset::Last7Days,
924        DatePreset::Last30Days,
925        DatePreset::Last90Days,
926        DatePreset::Last365Days,
927    ];
928
929    pub fn matches(self, t: i64, clock: Clock) -> bool {
930        let within = |days: i64| clock.now - t < days * DAY_MS;
931        match self {
932            DatePreset::Today => t > clock.today_start,
933            DatePreset::Yesterday => t < clock.today_start && t > clock.today_start - DAY_MS,
934            DatePreset::Last7Days => within(7),
935            DatePreset::Last30Days => within(30),
936            DatePreset::Last90Days => within(90),
937            DatePreset::Last365Days => within(365),
938        }
939    }
940}
941
942/// The current time and the start of the local day, in ms since the epoch.
943#[derive(Debug, Clone, Copy, PartialEq, Eq)]
944pub struct Clock {
945    pub now: i64,
946    pub today_start: i64,
947}
948
949impl Clock {
950    /// Reads the system clock and time zone.
951    pub fn local() -> Clock {
952        let now = Local::now();
953        let today_start = now
954            .date_naive()
955            .and_hms_opt(0, 0, 0)
956            .and_then(|m| Local.from_local_datetime(&m).earliest())
957            .map_or(now.timestamp_millis(), |d| d.timestamp_millis());
958        Clock { now: now.timestamp_millis(), today_start }
959    }
960}
961
962/// Local midnight at the start of `year-month-day`, in ms; for building
963/// [`DateFilter::range`] from a date picker.
964pub fn local_day_start(year: i32, month: u32, day: u32) -> Option<u64> {
965    let d = chrono::NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(0, 0, 0)?;
966    Local.from_local_datetime(&d).earliest().and_then(|d| u64::try_from(d.timestamp_millis()).ok())
967}
968
969/// The local `(year, month)` of a timestamp in ms.
970pub fn local_month(t: u64) -> Option<(i32, u32)> {
971    let d = Local.timestamp_millis_opt(i64::try_from(t).ok()?).single()?;
972    Some((d.year(), d.month()))
973}
974
975/// One date rule of the filter panel (Date Imported, Date Modified).
976///
977/// Presets and the range are OR-ed with each other; the month list is a
978/// separate rule that must also pass.
979#[derive(Debug, Clone, Default, PartialEq)]
980pub struct DateFilter {
981    pub presets: HashSet<DatePreset>,
982    /// Days picked in the calendar: the local midnights starting the first
983    /// and the last day. The last day is included whole (Eagle adds 24 h).
984    pub range: Option<(u64, u64)>,
985    /// Calendar months in local time, `(year, month 1..=12)`.
986    pub months: HashSet<(i32, u32)>,
987    /// The clock the presets compare with; `None` reads the system clock.
988    pub clock: Option<Clock>,
989}
990
991impl DateFilter {
992    pub fn is_active(&self) -> bool {
993        !self.presets.is_empty() || self.range.is_some() || !self.months.is_empty()
994    }
995
996    /// Whether a timestamp (ms) passes this rule.
997    pub fn matches(&self, t: u64) -> bool {
998        if !self.months.is_empty() && !local_month(t).is_some_and(|m| self.months.contains(&m)) {
999            return false;
1000        }
1001        if self.presets.is_empty() && self.range.is_none() {
1002            return true;
1003        }
1004        let t = t as i64;
1005        // Eagle ignores a range unless both ends are set (non-zero).
1006        let in_range = self.range.is_some_and(|(a, b)| a > 0 && b > 0 && a as i64 <= t && t <= b as i64 + DAY_MS);
1007        if in_range {
1008            return true;
1009        }
1010        if self.presets.is_empty() {
1011            return false;
1012        }
1013        let clock = self.clock.unwrap_or_else(Clock::local);
1014        self.presets.iter().any(|p| p.matches(t, clock))
1015    }
1016}
1017
1018/// How many items fall in each local month, newest month first; the options
1019/// of a date rule's month list.
1020pub fn month_counts<'a>(items: impl IntoIterator<Item = &'a Item>, field: DateField) -> Vec<((i32, u32), usize)> {
1021    let mut counts: HashMap<(i32, u32), usize> = HashMap::new();
1022    for i in items {
1023        if let Some(m) = local_month(field.value(i)) {
1024            *counts.entry(m).or_default() += 1;
1025        }
1026    }
1027    let mut out: Vec<_> = counts.into_iter().collect();
1028    out.sort_by_key(|a| std::cmp::Reverse(a.0));
1029    out
1030}
1031
1032// ---------------------------------------------------------------------------
1033// Duration
1034
1035/// Units of Eagle's duration fields.
1036#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1037pub enum DurationUnit {
1038    #[default]
1039    Seconds,
1040    Minutes,
1041    Hours,
1042}
1043
1044impl DurationUnit {
1045    pub fn to_seconds(self, v: f64) -> f64 {
1046        match self {
1047            DurationUnit::Seconds => v,
1048            DurationUnit::Minutes => v * 60.0,
1049            DurationUnit::Hours => v * 3600.0,
1050        }
1051    }
1052}
1053
1054/// An item's `duration` in seconds (audio and video).
1055pub fn duration(item: &Item) -> Option<f64> {
1056    item.extra.get("duration").and_then(|v| v.as_f64())
1057}
1058
1059/// An item's `bpm` (audio tempo), when it has a non-zero one.
1060pub fn bpm(item: &Item) -> Option<f64> {
1061    item.extra.get("bpm").and_then(|v| v.as_f64()).filter(|b| *b != 0.0)
1062}
1063
1064/// The shortest and longest duration among `items`, for the duration
1065/// fields' placeholders.
1066pub fn duration_range<'a>(items: impl IntoIterator<Item = &'a Item>) -> Option<(f64, f64)> {
1067    items.into_iter().filter_map(duration).fold(None, |acc, d| match acc {
1068        None => Some((d, d)),
1069        Some((a, b)) => Some((a.min(d), b.max(d))),
1070    })
1071}
1072
1073// ---------------------------------------------------------------------------
1074// Camera and font
1075
1076/// The camera Eagle stored for a RAW photo (`rawMetas.camera`).
1077pub fn camera(item: &Item) -> Option<&str> {
1078    item.extra.get("rawMetas")?.get("camera")?.as_str().filter(|c| !c.is_empty())
1079}
1080
1081/// JavaScript's default string order (UTF-16 code units).
1082fn js_cmp(a: &str, b: &str) -> Ordering {
1083    a.encode_utf16().cmp(b.encode_utf16())
1084}
1085
1086/// The camera filter's options: every distinct camera, sorted as Eagle sorts
1087/// them (plain code-unit order, so case-sensitive).
1088pub fn distinct_cameras<'a>(items: impl IntoIterator<Item = &'a Item>) -> Vec<String> {
1089    let set: HashSet<&str> = items.into_iter().filter_map(camera).collect();
1090    let mut out: Vec<String> = set.into_iter().map(String::from).collect();
1091    out.sort_by(|a, b| js_cmp(a, b));
1092    out
1093}
1094
1095/// First value of one of a font's `fontMetas` name maps, such as
1096/// `postScriptName` or `fontFamily` (Eagle takes the first language key).
1097pub fn font_name<'a>(item: &'a Item, key: &str) -> Option<&'a str> {
1098    let m = item.extra.get("fontMetas")?.get(key)?.as_object()?;
1099    m.values().next()?.as_str()
1100}
1101
1102/// The font's family name (`fontMetas.fontFamily`).
1103pub fn font_family(item: &Item) -> Option<&str> {
1104    font_name(item, "fontFamily")
1105}
1106
1107/// Every distinct font family, in natural order.
1108pub fn distinct_font_families<'a>(items: impl IntoIterator<Item = &'a Item>) -> Vec<String> {
1109    let set: HashSet<&str> = items.into_iter().filter_map(font_family).collect();
1110    let mut out: Vec<String> = set.into_iter().map(String::from).collect();
1111    out.sort_by(|a, b| natural_cmp(a, b));
1112    out
1113}
1114
1115/// The key Eagle looks up to tell whether a font is activated:
1116/// `<postScriptName>_.<ext>`.
1117pub fn font_key(item: &Item) -> Option<String> {
1118    Some(format!("{}_.{}", font_name(item, "postScriptName")?, item.ext))
1119}
1120
1121/// The folder Eagle copies activated fonts into (macOS:
1122/// `~/Library/Fonts/EagleApp/`).
1123pub fn eagle_font_dir() -> Option<PathBuf> {
1124    if !cfg!(target_os = "macos") {
1125        return None;
1126    }
1127    crate::paths::home_dir().map(|h| h.join("Library/Fonts/EagleApp"))
1128}
1129
1130/// The folder falcon copies the fonts it activates into (macOS:
1131/// `~/Library/Fonts/Falcon/`; on Windows `Falcon\Fonts` in `%LOCALAPPDATA%`;
1132/// elsewhere `fonts/Falcon` in the user's data folder, which fontconfig
1133/// reads). falcon never writes to Eagle's folder, which the user's Eagle
1134/// manages; it only reads it.
1135pub fn falcon_font_dir() -> Option<PathBuf> {
1136    if cfg!(target_os = "macos") {
1137        return crate::paths::home_dir().map(|h| h.join("Library/Fonts/Falcon"));
1138    }
1139    #[cfg(windows)]
1140    return Some(crate::paths::local_data_dir()?.join("Falcon").join("Fonts"));
1141    #[cfg(not(windows))]
1142    {
1143        let data = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).or_else(|| Some(crate::paths::home_dir()?.join(".local/share")))?;
1144        Some(data.join("fonts/Falcon"))
1145    }
1146}
1147
1148/// The folders whose fonts count as activated: Eagle's and falcon's.
1149pub fn font_dirs() -> Vec<PathBuf> {
1150    eagle_font_dir().into_iter().chain(falcon_font_dir()).collect()
1151}
1152
1153/// Keys of the activated fonts across `dirs` (see [`installed_font_keys`]):
1154/// a font in any of them counts as activated.
1155pub fn installed_font_keys_in(dirs: &[PathBuf]) -> HashSet<String> {
1156    dirs.iter().flat_map(|d| installed_font_keys(d)).collect()
1157}
1158
1159/// Keys of the activated fonts in `dir`, built from file names the way Eagle
1160/// does: `<base>_<.ext lower-cased>`. The base only loses the extension when
1161/// the file name ends with the lower-cased form, as with Node's `basename`.
1162pub fn installed_font_keys(dir: &Path) -> HashSet<String> {
1163    let Ok(entries) = std::fs::read_dir(dir) else { return HashSet::new() };
1164    entries.flatten().map(|e| font_file_key(&e.file_name().to_string_lossy())).collect()
1165}
1166
1167fn font_file_key(file: &str) -> String {
1168    let ext = Path::new(file).extension().map(|x| format!(".{}", x.to_string_lossy().to_lowercase())).unwrap_or_default();
1169    let base = file.strip_suffix(ext.as_str()).filter(|b| !b.is_empty()).unwrap_or(file);
1170    format!("{base}_{ext}")
1171}
1172
1173/// The font filter: activated (installed through Eagle) or not. The sets
1174/// hold [`installed_font_keys`]. Items that are not fonts fail both.
1175#[derive(Debug, Clone, Default, PartialEq)]
1176pub enum FontFilter {
1177    #[default]
1178    Off,
1179    Activated(HashSet<String>),
1180    Deactivated(HashSet<String>),
1181}
1182
1183impl FontFilter {
1184    pub fn matches(&self, item: &Item) -> bool {
1185        let installed = |set: &HashSet<String>| font_key(item).map(|k| set.contains(&k));
1186        match self {
1187            FontFilter::Off => true,
1188            FontFilter::Activated(set) => installed(set) == Some(true),
1189            FontFilter::Deactivated(set) => installed(set) == Some(false),
1190        }
1191    }
1192}
1193
1194// ---------------------------------------------------------------------------
1195// Colour
1196
1197/// A colour-search query: the colour and Eagle's accuracy (5..=40, default
1198/// 20; lower is stricter).
1199#[derive(Debug, Clone, Copy, PartialEq)]
1200pub struct ColorQuery {
1201    pub rgb: [u8; 3],
1202    pub accuracy: f64,
1203}
1204
1205impl ColorQuery {
1206    pub fn new(rgb: [u8; 3]) -> ColorQuery {
1207        ColorQuery { rgb, accuracy: 20.0 }
1208    }
1209
1210    /// Eagle's match on the two dominant palette colours, then on any
1211    /// colour marked as primary; `Some(distance)` when the item matches.
1212    pub fn distance(&self, item: &Item) -> Option<f64> {
1213        let p = &item.palettes;
1214        let ratio = |i: usize| p.get(i).and_then(|c| c.ratio.as_f64()).unwrap_or(0.0);
1215        if p.is_empty() || ratio(0) < 25.0 {
1216            return None;
1217        }
1218        if p.iter().take(2).any(|c| c.color == self.rgb) {
1219            return Some(0.01);
1220        }
1221        let q = lab(self.rgb);
1222        let acc = self.accuracy;
1223        let near = |c: [u8; 3]| {
1224            let l = lab(c);
1225            (delta_e00(q, l) < acc && delta_e76(q, l) < acc + 50.0).then(|| delta_e76(q, l))
1226        };
1227        if ratio(0) > 33.0 {
1228            if let Some(d) = near(p[0].color) {
1229                return Some(d);
1230            }
1231            // A mostly white background: try the second colour instead.
1232            // (Inferred nesting; read flat, Eagle's branch is unreachable.)
1233            if ratio(0) > 50.0
1234                && p.len() > 1
1235                && delta_e00(lab([255, 255, 255]), lab(p[0].color)) < 5.0
1236                && ratio(1) > 8.0
1237                && let Some(d) = near(p[1].color)
1238            {
1239                return Some(d + 5.0);
1240            }
1241        }
1242        if ratio(1) > 33.0
1243            && p.len() > 1
1244            && let Some(d) = near(p[1].color)
1245        {
1246            return Some(d);
1247        }
1248        // A colour marked as the item's primary one (`marked`, set from the
1249        // inspector's palette) matches within a fixed ΔE2000 of 30, whatever
1250        // its share or the accuracy, as in Eagle.
1251        p.iter().filter(|c| c.extra.get(crate::item_ops::MARKED_KEY).and_then(serde_json::Value::as_bool) == Some(true)).find_map(|c| {
1252            let l = lab(c.color);
1253            (delta_e00(q, l) < 30.0).then(|| delta_e76(q, l))
1254        })
1255    }
1256}
1257
1258fn is_grayscale(item: &Item) -> bool {
1259    !item.palettes.is_empty()
1260        && item.palettes.iter().filter(|c| c.ratio.as_f64().unwrap_or(0.0) >= 0.02).all(|c| {
1261            let [r, g, b] = c.color.map(i32::from);
1262            (r - g).abs() < 8 && (r - b).abs() < 8 && (g - b).abs() < 8
1263        })
1264}
1265
1266/// sRGB to CIELAB (D65), rounded to integers like the `color-convert`
1267/// package Eagle uses.
1268pub fn lab(rgb: [u8; 3]) -> [f64; 3] {
1269    let lin = |c: u8| {
1270        let c = c as f64 / 255.0;
1271        if c > 0.04045 { ((c + 0.055) / 1.055).powf(2.4) } else { c / 12.92 }
1272    };
1273    let (r, g, b) = (lin(rgb[0]), lin(rgb[1]), lin(rgb[2]));
1274    let x = (r * 0.4124 + g * 0.3576 + b * 0.1805) * 100.0 / 95.047;
1275    let y = (r * 0.2126 + g * 0.7152 + b * 0.0722) * 100.0 / 100.0;
1276    let z = (r * 0.0193 + g * 0.1192 + b * 0.9505) * 100.0 / 108.883;
1277    let f = |t: f64| if t > 0.008856 { t.cbrt() } else { 7.787 * t + 16.0 / 116.0 };
1278    let (fx, fy, fz) = (f(x), f(y), f(z));
1279    [(116.0 * fy - 16.0).round(), (500.0 * (fx - fy)).round(), (200.0 * (fy - fz)).round()]
1280}
1281
1282/// CIE76 colour difference.
1283pub fn delta_e76(a: [f64; 3], b: [f64; 3]) -> f64 {
1284    ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
1285}
1286
1287/// CIEDE2000 colour difference (kL = kC = kH = 1).
1288pub fn delta_e00(x: [f64; 3], y: [f64; 3]) -> f64 {
1289    use std::f64::consts::PI;
1290    let (l1, a1, b1) = (x[0], x[1], x[2]);
1291    let (l2, a2, b2) = (y[0], y[1], y[2]);
1292    let rad = |d: f64| d * PI / 180.0;
1293    let deg = |r: f64| r * 180.0 / PI;
1294    let c1 = (a1 * a1 + b1 * b1).sqrt();
1295    let c2 = (a2 * a2 + b2 * b2).sqrt();
1296    let cbar = (c1 + c2) / 2.0;
1297    let g = 0.5 * (1.0 - (cbar.powi(7) / (cbar.powi(7) + 25f64.powi(7))).sqrt());
1298    let (a1p, a2p) = ((1.0 + g) * a1, (1.0 + g) * a2);
1299    let (c1p, c2p) = ((a1p * a1p + b1 * b1).sqrt(), (a2p * a2p + b2 * b2).sqrt());
1300    let hue = |b: f64, a: f64| {
1301        if b == 0.0 && a == 0.0 {
1302            0.0
1303        } else {
1304            let h = deg(b.atan2(a));
1305            if h < 0.0 { h + 360.0 } else { h }
1306        }
1307    };
1308    let (h1p, h2p) = (hue(b1, a1p), hue(b2, a2p));
1309    let dl = l2 - l1;
1310    let dc = c2p - c1p;
1311    let dh = if c1p * c2p == 0.0 {
1312        0.0
1313    } else if (h2p - h1p).abs() <= 180.0 {
1314        h2p - h1p
1315    } else if h2p - h1p > 180.0 {
1316        h2p - h1p - 360.0
1317    } else {
1318        h2p - h1p + 360.0
1319    };
1320    let dhh = 2.0 * (c1p * c2p).sqrt() * rad(dh / 2.0).sin();
1321    let lbar = (l1 + l2) / 2.0;
1322    let cbarp = (c1p + c2p) / 2.0;
1323    let hbarp = if c1p * c2p == 0.0 {
1324        h1p + h2p
1325    } else if (h1p - h2p).abs() <= 180.0 {
1326        (h1p + h2p) / 2.0
1327    } else if h1p + h2p < 360.0 {
1328        (h1p + h2p + 360.0) / 2.0
1329    } else {
1330        (h1p + h2p - 360.0) / 2.0
1331    };
1332    let t = 1.0 - 0.17 * rad(hbarp - 30.0).cos() + 0.24 * rad(2.0 * hbarp).cos() + 0.32 * rad(3.0 * hbarp + 6.0).cos()
1333        - 0.20 * rad(4.0 * hbarp - 63.0).cos();
1334    let dtheta = 30.0 * (-((hbarp - 275.0) / 25.0).powi(2)).exp();
1335    let rc = 2.0 * (cbarp.powi(7) / (cbarp.powi(7) + 25f64.powi(7))).sqrt();
1336    let sl = 1.0 + 0.015 * (lbar - 50.0).powi(2) / (20.0 + (lbar - 50.0).powi(2)).sqrt();
1337    let sc = 1.0 + 0.045 * cbarp;
1338    let sh = 1.0 + 0.015 * cbarp * t;
1339    let rt = -rad(2.0 * dtheta).sin() * rc;
1340    ((dl / sl).powi(2) + (dc / sc).powi(2) + (dhh / sh).powi(2) + rt * (dc / sc) * (dhh / sh)).sqrt()
1341}
1342
1343// ---------------------------------------------------------------------------
1344// Sorting
1345
1346/// Eagle's `orderBy` values.
1347#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
1348pub enum OrderBy {
1349    #[default]
1350    Import,
1351    Name,
1352    Ext,
1353    Resolution,
1354    FileSize,
1355    Rating,
1356    Btime,
1357    Mtime,
1358    Tags,
1359    /// Video and audio length in whole seconds; items without one count
1360    /// as 0.
1361    Duration,
1362    /// Shuffled: [`sort`] keeps the incoming order and the caller shuffles.
1363    Random,
1364}
1365
1366impl OrderBy {
1367    /// Every key of the global sort menu, in Eagle's order.
1368    pub const ALL: [OrderBy; 11] = [
1369        OrderBy::Import,
1370        OrderBy::Btime,
1371        OrderBy::Mtime,
1372        OrderBy::Name,
1373        OrderBy::Ext,
1374        OrderBy::FileSize,
1375        OrderBy::Resolution,
1376        OrderBy::Rating,
1377        OrderBy::Tags,
1378        OrderBy::Duration,
1379        OrderBy::Random,
1380    ];
1381
1382    pub fn label(self) -> &'static str {
1383        match self {
1384            OrderBy::Import => "Date Imported",
1385            OrderBy::Btime => "Date Created",
1386            OrderBy::Mtime => "Date Modified",
1387            OrderBy::Name => "Title",
1388            OrderBy::Ext => "Extension",
1389            OrderBy::FileSize => "File Size",
1390            OrderBy::Resolution => "Dimensions",
1391            OrderBy::Rating => "Rating",
1392            OrderBy::Tags => "Tags",
1393            OrderBy::Duration => "Duration",
1394            OrderBy::Random => "Random",
1395        }
1396    }
1397
1398    /// Whether this key sorts newest-first before the direction flag.
1399    pub fn is_time(self) -> bool {
1400        matches!(self, OrderBy::Import | OrderBy::Btime | OrderBy::Mtime)
1401    }
1402}
1403
1404/// Case-insensitive comparison with numbers compared by value, like
1405/// `Intl.Collator(..., {numeric: true, sensitivity: 'base'})`.
1406pub fn natural_cmp(a: &str, b: &str) -> Ordering {
1407    let (mut x, mut y) = (a.chars().peekable(), b.chars().peekable());
1408    loop {
1409        match (x.peek().copied(), y.peek().copied()) {
1410            (None, None) => return Ordering::Equal,
1411            (None, _) => return Ordering::Less,
1412            (_, None) => return Ordering::Greater,
1413            (Some(c), Some(d)) if c.is_ascii_digit() && d.is_ascii_digit() => {
1414                let mut n = String::new();
1415                while let Some(c) = x.peek().copied().filter(char::is_ascii_digit) {
1416                    n.push(c);
1417                    x.next();
1418                }
1419                let mut m = String::new();
1420                while let Some(d) = y.peek().copied().filter(char::is_ascii_digit) {
1421                    m.push(d);
1422                    y.next();
1423                }
1424                let (n, m) = (n.trim_start_matches('0'), m.trim_start_matches('0'));
1425                let o = n.len().cmp(&m.len()).then_with(|| n.cmp(m));
1426                if o != Ordering::Equal {
1427                    return o;
1428                }
1429            }
1430            (Some(c), Some(d)) => {
1431                let o = c.to_lowercase().cmp(d.to_lowercase());
1432                if o != Ordering::Equal {
1433                    return o;
1434                }
1435                x.next();
1436                y.next();
1437            }
1438        }
1439    }
1440}
1441
1442/// Sorts items as Eagle does: the key's own direction (time keys newest
1443/// first, others ascending), reversed when `increase` is false. The sort is
1444/// stable, so ties keep their incoming order.
1445pub fn sort(items: &mut [&Item], order: OrderBy, increase: bool) {
1446    let star = |i: &Item| i.extra.get("star").and_then(|v| v.as_u64()).unwrap_or(0);
1447    let or_import = |t: u64, i: &Item| if t == 0 { i.modification_time } else { t };
1448    items.sort_by(|a, b| match order {
1449        OrderBy::Import => b.modification_time.cmp(&a.modification_time),
1450        OrderBy::Btime => or_import(b.btime, b).cmp(&or_import(a.btime, a)),
1451        OrderBy::Mtime => or_import(b.mtime, b).cmp(&or_import(a.mtime, a)),
1452        OrderBy::Name => natural_cmp(&a.name, &b.name),
1453        OrderBy::Ext => natural_cmp(&a.ext, &b.ext),
1454        OrderBy::Resolution => (a.width * a.height).cmp(&(b.width * b.height)),
1455        OrderBy::FileSize => a.size.cmp(&b.size),
1456        OrderBy::Rating => star(a).cmp(&star(b)),
1457        OrderBy::Tags => natural_cmp(a.tags.first().map_or("", |s| s), b.tags.first().map_or("", |s| s)),
1458        // Eagle: `parseInt(duration) || 0`, shortest first.
1459        OrderBy::Duration => {
1460            let secs = |i: &Item| duration(i).filter(|d| d.is_finite()).map_or(0, |d| d.trunc() as i64);
1461            secs(a).cmp(&secs(b))
1462        }
1463        OrderBy::Random => Ordering::Equal,
1464    });
1465    if !increase {
1466        items.reverse();
1467    }
1468}
1469
1470/// Sorts items by their hand-arranged order in `folder` (a folder's
1471/// `MANUAL` sort): `order[folder]` compared as text, larger first, with
1472/// `modificationTime` standing in for items that have no entry, as Eagle
1473/// does. Reversed when `increase` is false. Stable.
1474pub fn sort_manual(items: &mut [&Item], folder: &str, increase: bool) {
1475    let mut keyed: Vec<(String, &Item)> = items.iter().map(|i| (i.manual_sort_key(folder), *i)).collect();
1476    keyed.sort_by(|a, b| b.0.cmp(&a.0));
1477    for (slot, (_, item)) in items.iter_mut().zip(keyed) {
1478        *slot = item;
1479    }
1480    if !increase {
1481        items.reverse();
1482    }
1483}
1484
1485#[cfg(test)]
1486mod tests {
1487    use super::*;
1488
1489    #[test]
1490    fn query_syntax() {
1491        let q = Query::parse(r#"cat "black dog" -bird"#);
1492        assert_eq!(q.all, ["cat", "black dog"]);
1493        assert_eq!(q.none, ["bird"]);
1494        let q = Query::parse("red OR blue || green tree");
1495        assert_eq!(q.any, [vec!["red", "blue", "green"]]);
1496        assert_eq!(q.all, ["tree"]);
1497        // Eagle's lossy flattening of nested groups.
1498        let q = Query::parse("dog || (cat black)");
1499        assert_eq!(q.any, [vec!["dog", "cat black"]]);
1500    }
1501
1502    #[test]
1503    fn query_matching() {
1504        let q = Query::parse("cat -bird");
1505        assert!(q.matches("a cat here"));
1506        assert!(!q.matches("a cat and a bird"));
1507        assert!(Query::parse("-bird").matches("nothing"));
1508        assert!(Query::parse("red || blue").matches("blue sky"));
1509        assert!(!Query::parse("red || blue").matches("green"));
1510        assert!(Query::parse("").matches("x"));
1511    }
1512
1513    #[test]
1514    fn fuzzy_matching() {
1515        let s = |q: &str, t: &str| Query::parse(q).fuzzy_score(t);
1516        // Exact hits cost nothing.
1517        assert_eq!(s("tiger", "a tiger here"), Some(0));
1518        // One typo, a swap, a missing letter, a typo in a prefix.
1519        assert_eq!(s("tigre", "a tiger here"), Some(1));
1520        assert_eq!(s("tgier", "a tiger here"), Some(1));
1521        assert_eq!(s("tigr", "a tiger here"), Some(1));
1522        assert_eq!(s("photgraph", "photography_01.jpg"), Some(1));
1523        assert_eq!(s("landscpae mountian", "mountain landscape"), Some(2));
1524        // Accents fold.
1525        assert_eq!(s("creme", "crème brûlée"), Some(1));
1526        // Too many edits, short terms, digits and phrases stay exact.
1527        assert_eq!(s("tgr", "a tiger here"), None);
1528        assert_eq!(s("tigr", "tiramisu"), None);
1529        assert_eq!(s("img_2035", "img_2034"), None);
1530        assert_eq!(s("\"black dgo\"", "black dog"), None);
1531        // Every AND term must match; one OR member is enough; NOT is exact.
1532        assert_eq!(s("tigre zebar", "a tiger here"), None);
1533        assert_eq!(s("zebar || tigre", "a tiger here"), Some(1));
1534        assert_eq!(s("tigre -here", "a tiger here"), None);
1535    }
1536
1537    #[test]
1538    fn prefix_distance_rules() {
1539        let c = |s: &str| s.chars().collect::<Vec<_>>();
1540        assert_eq!(prefix_distance(&c("abcd"), &c("abcd"), 1), Some(0));
1541        assert_eq!(prefix_distance(&c("abdc"), &c("abcd"), 1), Some(1));
1542        assert_eq!(prefix_distance(&c("abcx"), &c("abcdefgh"), 1), Some(1));
1543        assert_eq!(prefix_distance(&c("abcde"), &c("abcd"), 1), Some(1));
1544        assert_eq!(prefix_distance(&c("abcdef"), &c("abcd"), 1), None);
1545        assert_eq!(prefix_distance(&c("xycd"), &c("abcd"), 1), None);
1546    }
1547
1548    #[test]
1549    fn set_filter_or_quirk() {
1550        let v = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
1551        let f = SetFilter { includes: v(&["a"]), excludes: v(&["b"]), ..Default::default() };
1552        assert!(f.matches(&v(&["a", "b"]))); // included wins under OR
1553        assert!(f.matches(&v(&["c"]))); // lacks the excluded one
1554        assert!(!f.matches(&v(&["b"])));
1555        let f = SetFilter { logic: Logic::And, includes: v(&["a", "b"]), ..Default::default() };
1556        assert!(f.matches(&v(&["a", "b", "c"])));
1557        assert!(!f.matches(&v(&["a"])));
1558        let f = SetFilter { logic: Logic::Equal, includes: v(&["a", "b"]), ..Default::default() };
1559        assert!(!f.matches(&v(&["a", "b", "c"])));
1560        let f = SetFilter { empty: true, ..Default::default() };
1561        assert!(f.matches(&[]));
1562        assert!(!f.matches(&v(&["a"])));
1563    }
1564
1565    #[test]
1566    fn excluding_the_empty_list() {
1567        let v = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
1568        let f = SetFilter { exclude_empty: true, ..Default::default() };
1569        assert!(f.is_active());
1570        assert!(f.matches(&v(&["F1"])) && !f.matches(&[]));
1571        // Under OR, lacking every excluded value is enough, as in Eagle.
1572        let f = SetFilter { includes: v(&["F1"]), exclude_empty: true, ..Default::default() };
1573        assert!(f.matches(&v(&["F1"])) && !f.matches(&[]) && f.matches(&v(&["F2"])));
1574        let f = SetFilter { logic: Logic::And, excludes: v(&["F2"]), exclude_empty: true, ..Default::default() };
1575        assert!(f.matches(&v(&["F1"])) && !f.matches(&[]) && !f.matches(&v(&["F2"])));
1576    }
1577
1578    #[test]
1579    fn color_math_matches_references() {
1580        // Reference values from the CIEDE2000 paper (Sharma et al., pair 1).
1581        let d = delta_e00([50.0, 2.6772, -79.7751], [50.0, 0.0, -82.7485]);
1582        assert!((d - 2.0425).abs() < 1e-4, "{d}");
1583        assert_eq!(lab([255, 255, 255]), [100.0, 0.0, 0.0]);
1584        assert_eq!(lab([255, 0, 0]), [53.0, 80.0, 67.0]);
1585    }
1586
1587    fn item(extra: serde_json::Value) -> Item {
1588        let mut v = serde_json::json!({
1589            "id": "A", "name": "a", "size": 1, "btime": 0, "mtime": 0, "ext": "jpg", "tags": [], "folders": [],
1590            "isDeleted": false, "url": "", "annotation": "", "modificationTime": 0, "height": 1, "width": 1,
1591            "lastModified": 0, "palettes": []
1592        });
1593        for (k, x) in extra.as_object().unwrap() {
1594            v[k] = x.clone();
1595        }
1596        serde_json::from_value(v).unwrap()
1597    }
1598
1599    #[test]
1600    fn a_marked_primary_colour_matches_past_the_dominant_ones() {
1601        let palettes = |marked: bool| {
1602            item(serde_json::json!({"palettes": [
1603                {"color": [250, 250, 250], "ratio": 60},
1604                {"color": [20, 20, 20], "ratio": 30},
1605                {"color": [200, 30, 30], "ratio": 5, "marked": marked},
1606            ]}))
1607        };
1608        let red = ColorQuery::new([210, 40, 40]);
1609        assert_eq!(red.distance(&palettes(false)), None);
1610        let d = red.distance(&palettes(true)).expect("the marked red matches");
1611        assert!(d > 0.0 && d < 30.0, "{d}");
1612        // Far colours stay out even when marked; the strict accuracy
1613        // doesn't apply to the marked colour.
1614        assert_eq!(ColorQuery::new([30, 30, 220]).distance(&palettes(true)), None);
1615        assert!(ColorQuery { rgb: [210, 40, 40], accuracy: 5.0 }.distance(&palettes(true)).is_some());
1616    }
1617
1618    const NOW: i64 = 1_760_000_000_000;
1619    const MIDNIGHT: i64 = NOW - 10 * 3_600_000;
1620
1621    fn dates(presets: &[DatePreset]) -> DateFilter {
1622        DateFilter {
1623            presets: presets.iter().copied().collect(),
1624            clock: Some(Clock { now: NOW, today_start: MIDNIGHT }),
1625            ..Default::default()
1626        }
1627    }
1628
1629    #[test]
1630    fn date_presets() {
1631        let at = |t: i64| t as u64;
1632        let today = dates(&[DatePreset::Today]);
1633        assert!(today.matches(at(MIDNIGHT + 1)));
1634        assert!(!today.matches(at(MIDNIGHT)));
1635        let yesterday = dates(&[DatePreset::Yesterday]);
1636        assert!(yesterday.matches(at(MIDNIGHT - 1)));
1637        assert!(!yesterday.matches(at(MIDNIGHT - DAY_MS)));
1638        assert!(!yesterday.matches(at(MIDNIGHT + 1)));
1639        let week = dates(&[DatePreset::Last7Days]);
1640        assert!(week.matches(at(NOW - 7 * DAY_MS + 1)));
1641        assert!(!week.matches(at(NOW - 7 * DAY_MS)));
1642        for (p, n) in [(DatePreset::Last30Days, 30), (DatePreset::Last90Days, 90), (DatePreset::Last365Days, 365)] {
1643            let f = dates(&[p]);
1644            assert!(f.matches(at(NOW - n * DAY_MS + 1)) && !f.matches(at(NOW - n * DAY_MS)), "{p:?}");
1645        }
1646        // Presets are OR-ed.
1647        let either = dates(&[DatePreset::Today, DatePreset::Yesterday]);
1648        assert!(either.matches(at(MIDNIGHT - 5)) && either.matches(at(MIDNIGHT + 5)));
1649        assert!(!either.matches(at(MIDNIGHT - 2 * DAY_MS)));
1650    }
1651
1652    #[test]
1653    fn date_range_and_months() {
1654        let a = local_day_start(2024, 3, 1).unwrap();
1655        let b = local_day_start(2024, 3, 10).unwrap();
1656        let f = DateFilter { range: Some((a, b)), ..Default::default() };
1657        assert!(f.matches(a));
1658        assert!(!f.matches(a - 1));
1659        assert!(f.matches(b + DAY_MS as u64)); // the last day is whole, end inclusive
1660        assert!(!f.matches(b + DAY_MS as u64 + 1));
1661        // An unset end makes Eagle ignore the range.
1662        assert!(!DateFilter { range: Some((a, 0)), ..Default::default() }.matches(a));
1663
1664        let mid_march = local_day_start(2024, 3, 15).unwrap() + 12 * 3_600_000;
1665        let f = DateFilter { months: [(2024, 3)].into(), ..Default::default() };
1666        assert!(f.matches(mid_march));
1667        assert!(!f.matches(local_day_start(2024, 4, 15).unwrap()));
1668        // Months and presets must both pass.
1669        let f = DateFilter { months: [(2024, 3)].into(), ..dates(&[DatePreset::Today]) };
1670        assert!(!f.matches(mid_march));
1671    }
1672
1673    #[test]
1674    fn date_fields_in_filter() {
1675        let i = item(serde_json::json!({"modificationTime": NOW - 1000, "mtime": 0, "btime": 5}));
1676        assert_eq!(DateField::Modified.value(&i), (NOW - 1000) as u64);
1677        assert_eq!(DateField::Created.value(&i), 5);
1678        let f = Filter { imported: dates(&[DatePreset::Today]), ..Default::default() };
1679        assert!(f.check(&i).is_some());
1680        let f = Filter { created: dates(&[DatePreset::Today]), ..Default::default() };
1681        assert!(f.check(&i).is_none());
1682        let f = Filter { modified: dates(&[DatePreset::Today]), ..Default::default() };
1683        assert!(f.check(&i).is_some(), "mtime 0 falls back to the import time");
1684        let counts = month_counts([&i, &i], DateField::Imported);
1685        assert_eq!(counts.len(), 1);
1686        assert_eq!(counts[0].1, 2);
1687    }
1688
1689    #[test]
1690    fn duration_rule() {
1691        let clip = item(serde_json::json!({"ext": "mp3", "duration": 90.7}));
1692        let still = item(serde_json::json!({}));
1693        let f = |min: Option<f64>, max: Option<f64>| Filter { duration: (min, max), ..Default::default() };
1694        assert!(f(Some(90.0), None).check(&clip).is_some());
1695        assert!(f(Some(90.9), None).check(&clip).is_some(), "bounds are truncated");
1696        assert!(f(None, Some(DurationUnit::Minutes.to_seconds(1.5))).check(&clip).is_none());
1697        assert!(f(None, Some(DurationUnit::Minutes.to_seconds(2.0))).check(&clip).is_some());
1698        assert!(f(Some(0.0), None).check(&still).is_none());
1699        assert_eq!(DurationUnit::Hours.to_seconds(1.0), 3600.0);
1700        assert_eq!(duration_range([&clip, &still]), Some((90.7, 90.7)));
1701    }
1702
1703    #[test]
1704    fn bpm_rule() {
1705        let song = item(serde_json::json!({"ext": "mp3", "bpm": 119.985}));
1706        let still = item(serde_json::json!({}));
1707        let f = |min: Option<f64>, max: Option<f64>| Filter { bpm: (min, max), ..Default::default() };
1708        assert!(f(Some(100.0), Some(120.0)).check(&song).is_some());
1709        assert!(f(None, Some(119.9)).check(&song).is_none(), "119.9 truncates to 119");
1710        assert!(f(Some(120.7), None).check(&song).is_none(), "120.7 truncates to 120");
1711        assert!(f(Some(0.0), None).check(&still).is_none(), "no bpm fails an active range");
1712        assert!(Filter::default().check(&still).is_some());
1713        assert!(f(Some(1.0), None).check(&item(serde_json::json!({"bpm": 0}))).is_none());
1714    }
1715
1716    #[test]
1717    fn camera_rule_and_options() {
1718        let nikon = item(serde_json::json!({"ext": "nef", "rawMetas": {"camera": "Nikon D850"}}));
1719        let canon = item(serde_json::json!({"ext": "cr2", "rawMetas": {"camera": "Canon EOS R5"}}));
1720        let lower = item(serde_json::json!({"ext": "cr2", "rawMetas": {"camera": "canon eos r5"}}));
1721        let none = item(serde_json::json!({}));
1722        let f = Filter { cameras: ["Canon EOS R5".to_string()].into(), ..Default::default() };
1723        assert!(f.check(&canon).is_some());
1724        assert!(f.check(&lower).is_none(), "exact, case-sensitive");
1725        assert!(f.check(&nikon).is_none() && f.check(&none).is_none());
1726        assert_eq!(distinct_cameras([&nikon, &canon, &lower, &none, &canon]), ["Canon EOS R5", "Nikon D850", "canon eos r5"]);
1727    }
1728
1729    #[test]
1730    fn font_rule_and_options() {
1731        let font = item(serde_json::json!({"ext": "ttf", "fontMetas": {
1732            "fontFamily": {"en": "Inter"}, "postScriptName": {"en": "Inter-Regular", "zh": "x"}}}));
1733        let other = item(serde_json::json!({"ext": "otf", "fontMetas": {"fontFamily": {"en": "Arial"}, "postScriptName": {"en": "ArialMT"}}}));
1734        let photo = item(serde_json::json!({}));
1735        assert_eq!(font_key(&font).as_deref(), Some("Inter-Regular_.ttf"));
1736        let installed: HashSet<String> = [font_file_key("Inter-Regular.TTF"), font_file_key("Inter-Regular.ttf")].into();
1737        assert!(installed.contains("Inter-Regular_.ttf"));
1738        assert!(installed.contains("Inter-Regular.TTF_.ttf"), "Node's basename keeps a differently cased extension");
1739        let on = Filter { font: FontFilter::Activated(installed.clone()), ..Default::default() };
1740        let off = Filter { font: FontFilter::Deactivated(installed), ..Default::default() };
1741        assert!(on.check(&font).is_some() && off.check(&font).is_none());
1742        assert!(on.check(&other).is_none() && off.check(&other).is_some());
1743        assert!(on.check(&photo).is_none() && off.check(&photo).is_none());
1744        assert_eq!(distinct_font_families([&other, &font, &photo, &font]), ["Arial", "Inter"]);
1745    }
1746
1747    #[test]
1748    fn fonts_in_either_folder_count_as_installed() {
1749        let tmp = tempfile::tempdir().unwrap();
1750        let (eagle, falcon) = (tmp.path().join("EagleApp"), tmp.path().join("Falcon"));
1751        std::fs::create_dir_all(&eagle).unwrap();
1752        std::fs::create_dir_all(&falcon).unwrap();
1753        std::fs::write(eagle.join("ArialMT.otf"), b"").unwrap();
1754        std::fs::write(falcon.join("Inter-Regular.ttf"), b"").unwrap();
1755        let keys = installed_font_keys_in(&[eagle.clone(), falcon, tmp.path().join("missing")]);
1756        assert!(keys.contains("ArialMT_.otf") && keys.contains("Inter-Regular_.ttf"));
1757        assert_eq!(keys.len(), 2, "a missing folder adds nothing");
1758        assert_eq!(installed_font_keys_in(&[eagle]).len(), 1);
1759        assert!(installed_font_keys_in(&[]).is_empty());
1760        if cfg!(target_os = "macos") {
1761            let dirs = font_dirs();
1762            assert!(dirs[0].ends_with("Library/Fonts/EagleApp") && dirs[1].ends_with("Library/Fonts/Falcon"));
1763        }
1764    }
1765
1766    #[test]
1767    fn search_text_parts_and_scope() {
1768        let mut folders = FolderIndex::new();
1769        folders.insert("F".into(), ("Trips".into(), "Summer holidays".into()));
1770        let photo = item(serde_json::json!({
1771            "name": "Beach", "url": "https://example.org", "annotation": "sunset note", "tags": ["sand"], "folders": ["F"],
1772            "comments": [{"id": "c", "annotation": "Look at the Boat", "x": 0}],
1773            "rawMetas": {"camera": "Nikon Z6"}
1774        }));
1775        let text = search_text(&photo, &folders, 5);
1776        for part in ["beach", ".jpg", "example.org", "sunset note", "look at the boat", "nikon z6", "sand", "trips", "summer holidays"] {
1777            assert!(text.contains(part), "{part} in {text}");
1778        }
1779        assert!(!search_text(&photo, &folders, 1).contains("example.org"), "urls need two characters");
1780        let off = SearchScope { comments: false, tags: false, folder_desc: false, name: false, ..Default::default() };
1781        let text = search_text_in(&photo, &folders, 5, &off);
1782        assert!(!text.contains("boat") && !text.contains("sand") && !text.contains("summer") && !text.contains("beach"));
1783        assert!(text.contains("trips") && text.contains("nikon z6"), "folder names stay, and the camera is always searched");
1784
1785        let doc = item(serde_json::json!({"ext": "txt", "text": "Chapter One"}));
1786        let none = SearchScope { name: false, folder_name: false, folder_desc: false, ext: false, tags: false, url: false, comments: false, note: false };
1787        assert!(search_text_in(&doc, &folders, 3, &none).contains("chapter one"), "text content is always searched");
1788
1789        let font = item(serde_json::json!({"ext": "ttf", "fontMetas": {
1790            "fullName": {"en": "Inter Bold"}, "postScriptName": {"en": "Inter-Bold"}}}));
1791        assert!(search_text(&font, &folders, 2).contains("inter bold"));
1792        assert!(!search_text(&font, &folders, 2).contains("inter-bold"), "the metadata needs three characters");
1793        assert!(search_text(&font, &folders, 3).contains("inter-bold"));
1794        let zh = item(serde_json::json!({"ext": "otf", "fontMetas": {"fullName": {"en": "Song", "zh": "宋体"}}}));
1795        let t = search_text(&zh, &folders, 3);
1796        assert!(t.contains("宋体") && !t.contains("song"), "the zh name wins; no postScriptName, no JSON");
1797    }
1798
1799    #[test]
1800    fn comment_rule() {
1801        let noted = item(serde_json::json!({"comments": [{"annotation": "Red Car"}, {"annotation": "blue sky"}]}));
1802        let bare = item(serde_json::json!({}));
1803        let empty = item(serde_json::json!({"comments": []}));
1804        let f = |t: TextFilter| Filter { comments: t, ..Default::default() };
1805        assert!(f(TextFilter::Has).check(&noted).is_some() && f(TextFilter::Has).check(&bare).is_none());
1806        assert!(f(TextFilter::No).check(&bare).is_some() && f(TextFilter::No).check(&empty).is_some());
1807        assert!(f(TextFilter::No).check(&noted).is_none());
1808        let kw = |w: &[&str]| f(TextFilter::Keywords(w.iter().map(|s| s.to_string()).collect()));
1809        assert!(kw(&["car", "SKY"]).check(&noted).is_some(), "each keyword may sit in a different comment");
1810        assert!(kw(&["car", "boat"]).check(&noted).is_none());
1811        assert!(kw(&["car"]).check(&bare).is_none());
1812    }
1813
1814    #[test]
1815    fn ratio_and_custom_shapes() {
1816        let sized = |w: u64, h: u64| item(serde_json::json!({"width": w, "height": h}));
1817        let f = |shapes: &[Shape], custom: Option<(f64, f64)>| Filter { shapes: shapes.iter().copied().collect(), custom_shape: custom, ..Default::default() };
1818        assert!(f(&[Shape::Ratio16x9], None).check(&sized(1920, 1080)).is_some());
1819        assert!(f(&[Shape::Ratio16x9], None).check(&sized(1366, 768)).is_none(), "exact ratios only");
1820        assert!(f(&[Shape::Ratio9x16], None).check(&sized(1080, 1920)).is_some());
1821        assert!(f(&[Shape::Ratio4x3, Shape::Ratio3x4], None).check(&sized(600, 800)).is_some());
1822        assert!(f(&[], Some((2.0, 1.0))).check(&sized(400, 200)).is_some());
1823        assert!(f(&[], Some((2.0, 1.0))).check(&sized(400, 201)).is_none());
1824        assert!(f(&[], Some((0.0, 3.0))).check(&sized(1, 7)).is_some(), "an unset side lets everything through");
1825        assert!(f(&[Shape::Square], Some((2.0, 1.0))).check(&sized(5, 5)).is_some(), "OR-ed with the other shapes");
1826
1827        let items = [sized(1920, 1080), sized(3000, 1000), sized(800, 600), sized(10, 10), item(serde_json::json!({"ext": "mp3", "width": 9, "height": 3})), sized(0, 0)];
1828        let c = shape_counts(items.iter());
1829        assert_eq!(c.get(&Shape::Landscape), Some(&2));
1830        assert_eq!(c.get(&Shape::PanoramicLandscape), Some(&1));
1831        assert_eq!(c.get(&Shape::Square), Some(&1));
1832        assert_eq!((c.get(&Shape::Ratio16x9), c.get(&Shape::Ratio4x3)), (Some(&1), Some(&1)));
1833        assert_eq!(c.get(&Shape::Portrait), None);
1834    }
1835
1836    #[test]
1837    fn type_rule_follows_eagles_order() {
1838        let of = |ext: &str| item(serde_json::json!({"ext": ext}));
1839        let site = |m: &str| item(serde_json::json!({"ext": "url", "medium": m}));
1840        let v = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
1841        assert!(type_included(&v(&["png"]), &of("png")));
1842        assert!(type_included(&v(&["video"]), &of("mp4")) && !type_included(&v(&["video"]), &of("png")));
1843        assert!(!type_included(&v(&["video", "audio"]), &of("mp3")), "video decides alone, as in Eagle");
1844        assert!(type_included(&v(&["video", "mp3"]), &of("mp3")), "an extension still wins");
1845        assert!(type_included(&v(&["audio", "font"]), &of("wav")) && !type_included(&v(&["audio", "font"]), &of("ttf")));
1846        assert!(type_included(&v(&["font"]), &of("woff2")));
1847        assert!(type_included(&v(&["youtube"]), &site("youtube")) && !type_included(&v(&["youtube"]), &site("vimeo")));
1848        assert!(type_included(&v(&["url"]), &site("vimeo")), "the url extension itself matches");
1849        assert!(type_included(&v(&["word"]), &of("docx")));
1850        assert!(type_excluded(&v(&["video"]), &of("mov")) && !type_excluded(&v(&["video"]), &of("png")));
1851        assert!(type_excluded(&v(&["bilibili"]), &site("bilibili")) && !type_excluded(&v(&["bilibili"]), &of("url")));
1852        assert!(type_excluded(&v(&["url"]), &of("url")) && type_excluded(&v(&["url"]), &site("youtube")));
1853
1854        let items = [of("png"), of("png"), of("mp4"), site("youtube"), of("url"), of("TTF")];
1855        let c = type_counts(items.iter());
1856        assert_eq!((c["png"], c["mp4"], c["video"], c["youtube"], c["url"]), (2, 1, 1, 1, 1));
1857        assert!(!c.contains_key("audio"));
1858        assert_eq!(type_options(items.iter()), ["TTF", "mp4", "png", "url", "font", "video", "audio", "youtube", "vimeo", "bilibili"]);
1859    }
1860
1861    #[test]
1862    fn string_scores() {
1863        assert_eq!(string_score("hello", "hello"), 1.0);
1864        assert_eq!(string_score("hello", ""), 0.0);
1865        assert_eq!(string_score("hello", "hz"), 0.0);
1866        // "he": 0.7+0.1 each -> running 1.6; 0.5*(1.6/5 + 1.6/2) = 0.56; +0.15.
1867        assert!((string_score("hello", "he") - 0.71).abs() < 1e-9);
1868        // An acronym letter after a space scores like two consecutive ones.
1869        assert!(string_score("hello world", "hw") > string_score("hello xworld", "hw"));
1870        assert!(string_score("Hello", "h") < string_score("hello", "h"), "same case scores more");
1871    }
1872
1873    #[test]
1874    fn names_rank_best_first() {
1875        let names = ["Travel Photos", "photos", "Phones", "Café Art", "misc"];
1876        let r = rank_names(names, "photos");
1877        assert_eq!(r[0], 1, "the exact name first");
1878        assert!(r.contains(&0) && !r.contains(&2) && !r.contains(&4));
1879        assert_eq!(rank_names(names, "cafe"), [3], "accents fold");
1880        assert_eq!(rank_names(names, "travel ph")[0], 0, "spaces in the keyword are dropped");
1881        assert!(rank_names(names, "").is_empty());
1882    }
1883
1884    #[test]
1885    fn duration_sort_counts_missing_as_zero() {
1886        let clip = |name: &str, d: Option<f64>| {
1887            let mut v = serde_json::json!({"name": name});
1888            if let Some(d) = d {
1889                v["duration"] = d.into();
1890            }
1891            item(v)
1892        };
1893        let items = [clip("a", Some(30.9)), clip("b", None), clip("c", Some(5.5)), clip("d", Some(30.1))];
1894        let mut refs: Vec<&Item> = items.iter().collect();
1895        sort(&mut refs, OrderBy::Duration, true);
1896        assert_eq!(refs.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(), ["b", "c", "a", "d"], "whole seconds, stable");
1897        sort(&mut refs, OrderBy::Duration, false);
1898        assert_eq!(refs.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(), ["d", "a", "c", "b"]);
1899        let mut refs: Vec<&Item> = items.iter().collect();
1900        sort(&mut refs, OrderBy::Random, true);
1901        assert_eq!(refs[0].name, "a", "random leaves the order to the caller");
1902    }
1903
1904    #[test]
1905    fn natural_order() {
1906        assert_eq!(natural_cmp("img2", "img10"), Ordering::Less);
1907        assert_eq!(natural_cmp("Apple", "apple"), Ordering::Equal);
1908        assert_eq!(natural_cmp("b", "A"), Ordering::Greater);
1909    }
1910}