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