1use 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#[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 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
90fn 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(); 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 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 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 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 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
239fn 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
253fn letter_bit(c: char) -> u128 {
256 1u128 << (c as u32 % 128)
257}
258
259fn 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
277fn 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#[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 pub comments: bool,
322 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
332pub fn search_text(item: &Item, folders: &FolderIndex, query_len: usize) -> String {
334 search_text_in(item, folders, query_len, &SearchScope::default())
335}
336
337pub 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
402pub 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
407pub 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
420pub 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
465pub 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
472pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
491pub enum Logic {
492 #[default]
493 Or,
494 And,
495 Equal,
496}
497
498#[derive(Debug, Clone, Default, PartialEq)]
500pub struct SetFilter {
501 pub logic: Logic,
502 pub includes: Vec<String>,
503 pub excludes: Vec<String>,
504 pub empty: bool,
506 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 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#[derive(Debug, Clone, Default, PartialEq)]
544pub enum TextFilter {
545 #[default]
546 Off,
547 Has,
548 No,
549 Keywords(Vec<String>),
551}
552
553impl TextFilter {
554 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 Ratio4x3,
595 Ratio3x4,
596 Ratio16x9,
597 Ratio9x16,
598}
599
600impl Shape {
601 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
630pub 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
639pub 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
665pub 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
679pub fn medium(item: &Item) -> Option<&str> {
681 item.extra.get("medium").and_then(|v| v.as_str()).filter(|m| !m.is_empty())
682}
683
684pub const TYPE_GROUPS: [&str; 6] = ["font", "video", "audio", "youtube", "vimeo", "bilibili"];
686
687pub 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
723pub 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
734pub 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
749pub 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#[derive(Debug, Clone, Default, PartialEq)]
765pub struct Filter {
766 pub tags: SetFilter,
767 pub folders: SetFilter,
768 pub types: Vec<String>,
770 pub exclude_types: Vec<String>,
771 pub size: (Option<u64>, Option<u64>),
773 pub width: (Option<u64>, Option<u64>),
775 pub height: (Option<u64>, Option<u64>),
776 pub ratings: HashSet<u8>,
778 pub shapes: HashSet<Shape>,
779 pub custom_shape: Option<(f64, f64)>,
782 pub url: TextFilter,
783 pub note: TextFilter,
784 pub comments: TextFilter,
786 pub color: Option<ColorQuery>,
787 pub grayscale: bool,
788 pub imported: DateFilter,
790 pub created: DateFilter,
792 pub modified: DateFilter,
794 pub duration: (Option<f64>, Option<f64>),
798 pub bpm: (Option<f64>, Option<f64>),
802 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 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
877pub const DAY_MS: i64 = 24 * 60 * 60 * 1000;
882
883#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
885pub enum DateField {
886 Imported,
888 Created,
890 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
907pub enum DatePreset {
908 Today,
910 Yesterday,
912 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
944pub struct Clock {
945 pub now: i64,
946 pub today_start: i64,
947}
948
949impl Clock {
950 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
962pub 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
969pub 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#[derive(Debug, Clone, Default, PartialEq)]
980pub struct DateFilter {
981 pub presets: HashSet<DatePreset>,
982 pub range: Option<(u64, u64)>,
985 pub months: HashSet<(i32, u32)>,
987 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 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 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
1018pub 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#[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
1054pub fn duration(item: &Item) -> Option<f64> {
1056 item.extra.get("duration").and_then(|v| v.as_f64())
1057}
1058
1059pub fn bpm(item: &Item) -> Option<f64> {
1061 item.extra.get("bpm").and_then(|v| v.as_f64()).filter(|b| *b != 0.0)
1062}
1063
1064pub 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
1073pub fn camera(item: &Item) -> Option<&str> {
1078 item.extra.get("rawMetas")?.get("camera")?.as_str().filter(|c| !c.is_empty())
1079}
1080
1081fn js_cmp(a: &str, b: &str) -> Ordering {
1083 a.encode_utf16().cmp(b.encode_utf16())
1084}
1085
1086pub 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
1095pub 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
1102pub fn font_family(item: &Item) -> Option<&str> {
1104 font_name(item, "fontFamily")
1105}
1106
1107pub 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
1115pub fn font_key(item: &Item) -> Option<String> {
1118 Some(format!("{}_.{}", font_name(item, "postScriptName")?, item.ext))
1119}
1120
1121pub 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
1130pub 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
1148pub fn font_dirs() -> Vec<PathBuf> {
1150 eagle_font_dir().into_iter().chain(falcon_font_dir()).collect()
1151}
1152
1153pub fn installed_font_keys_in(dirs: &[PathBuf]) -> HashSet<String> {
1156 dirs.iter().flat_map(|d| installed_font_keys(d)).collect()
1157}
1158
1159pub 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#[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#[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 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 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 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
1266pub 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
1282pub 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
1287pub 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#[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 Duration,
1362 Random,
1364}
1365
1366impl OrderBy {
1367 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 pub fn is_time(self) -> bool {
1400 matches!(self, OrderBy::Import | OrderBy::Btime | OrderBy::Mtime)
1401 }
1402}
1403
1404pub 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
1442pub 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 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
1470pub 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 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 assert_eq!(s("tiger", "a tiger here"), Some(0));
1518 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 assert_eq!(s("creme", "crème brûlée"), Some(1));
1526 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 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"]))); assert!(f.matches(&v(&["c"]))); 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 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 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 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 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)); assert!(!f.matches(b + DAY_MS as u64 + 1));
1661 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 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(¬ed).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(¬ed).is_none());
1808 let kw = |w: &[&str]| f(TextFilter::Keywords(w.iter().map(|s| s.to_string()).collect()));
1809 assert!(kw(&["car", "SKY"]).check(¬ed).is_some(), "each keyword may sit in a different comment");
1810 assert!(kw(&["car", "boat"]).check(¬ed).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 assert!((string_score("hello", "he") - 0.71).abs() < 1e-9);
1868 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}