1use serde::ser::SerializeMap;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use serde_json::{Map, Number, Value};
8
9pub type Extra = Map<String, Value>;
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct LibraryMetadata {
16 pub folders: Vec<Folder>,
18 pub smart_folders: Vec<Value>,
20 pub quick_access: Vec<Value>,
22 pub tags_groups: Vec<TagGroup>,
24 pub modification_time: u64,
26 pub application_version: String,
28 #[serde(flatten)]
30 pub extra: Extra,
31}
32
33#[derive(Debug, Clone, PartialEq, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct Folder {
43 pub id: String,
45 pub name: String,
47 pub description: String,
49 pub children: Vec<Folder>,
51 pub modification_time: u64,
54 pub tags: Vec<String>,
56 pub extend_tags: Option<Vec<String>>,
59 pub icon: Option<String>,
61 pub icon_color: Option<String>,
63 pub pinyin: Option<String>,
65 pub password: String,
68 pub password_tips: String,
70 pub cover_id: Option<String>,
72 pub order_by: Option<String>,
75 pub sort_increase: Option<bool>,
77 #[serde(flatten)]
79 pub extra: Extra,
80}
81
82impl Serialize for Folder {
83 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
84 let mut m = s.serialize_map(None)?;
85 m.serialize_entry("id", &self.id)?;
86 m.serialize_entry("name", &self.name)?;
87 m.serialize_entry("description", &self.description)?;
88 m.serialize_entry("children", &self.children)?;
89 m.serialize_entry("modificationTime", &self.modification_time)?;
90 m.serialize_entry("tags", &self.tags)?;
91 if let Some(v) = &self.extend_tags {
92 m.serialize_entry("extendTags", v)?;
93 }
94 if let Some(v) = &self.icon {
95 m.serialize_entry("icon", v)?;
96 }
97 if let Some(v) = &self.icon_color {
98 m.serialize_entry("iconColor", v)?;
99 }
100 if let Some(v) = &self.pinyin {
101 m.serialize_entry("pinyin", v)?;
102 }
103 m.serialize_entry("password", &self.password)?;
104 m.serialize_entry("passwordTips", &self.password_tips)?;
105 if let Some(v) = &self.cover_id {
106 m.serialize_entry("coverId", v)?;
107 }
108 if let Some(v) = &self.order_by {
109 m.serialize_entry("orderBy", v)?;
110 }
111 if let Some(v) = &self.sort_increase {
112 m.serialize_entry("sortIncrease", v)?;
113 }
114 for (k, v) in &self.extra {
115 m.serialize_entry(k, v)?;
116 }
117 m.end()
118 }
119}
120
121impl Folder {
122 pub fn new(id: impl Into<String>, name: impl Into<String>, modification_time: u64) -> Folder {
124 Folder {
125 id: id.into(),
126 name: name.into(),
127 description: String::new(),
128 children: Vec::new(),
129 modification_time,
130 tags: Vec::new(),
131 extend_tags: None,
132 icon: None,
133 icon_color: None,
134 pinyin: None,
135 password: String::new(),
136 password_tips: String::new(),
137 cover_id: None,
138 order_by: None,
139 sort_increase: None,
140 extra: Extra::new(),
141 }
142 }
143
144 pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Folder)) {
146 f(self);
147 for c in &self.children {
148 c.walk(f);
149 }
150 }
151
152 pub fn is_locked(&self) -> bool {
154 !self.password.is_empty()
155 }
156
157 pub fn order(&self) -> Option<FolderOrder> {
159 self.order_by.as_deref().and_then(FolderOrder::parse)
160 }
161}
162
163pub const ICON_COLORS: [&str; 8] = ["red", "orange", "yellow", "green", "aqua", "blue", "purple", "pink"];
166
167pub const FOLDER_ICONS: &[&str] = &[
171 "library", "box", "grid", "layer", "briefcase", "photo", "photos", "video", "film", "film2", "film3", "music",
172 "book", "book2", "bookshelf", "keynote", "camera", "aperture", "attachment", "scissors", "palette", "wrench",
173 "helmet", "life-buoy", "graph", "graph2", "tableware", "cog", "bachelor-cap", "cones", "dribbble", "email",
174 "business-card", "coffee", "cart", "lightbulb", "inspiration", "thumb-up", "thumb-down", "like", "unlike", "star",
175 "hot", "upload", "download", "paid", "free", "medical", "shield", "search", "shortcuts", "recycle", "excalmation",
176 "question", "coin1", "coin2", "coin3", "coin4", "wallet", "watch", "clock", "calendar-week", "calendar-month",
177 "website", "phone", "tablet", "desktop", "tv", "cpu", "safari", "chrome", "pie", "bar-chart", "line-chart", "2d",
178 "3d", "contrast", "texture", "transition", "animation", "spectrogram", "font-sans", "font-sans-serif",
179 "font-handwritten", "flag", "earth", "pin", "pin-check", "map", "road", "motor", "rocket", "airplan", "ship",
180 "train", "car", "truck", "water-drop", "sun", "moon", "tree", "mountain", "cloud", "bathtub", "door", "bed",
181 "cabinet", "sofa", "home", "store", "house", "game", "weapon", "armor", "award2", "award", "award3", "crown",
182 "rophy", "gift", "facebook", "twitter", "instagram", "eye", "bear", "dog", "cat", "man", "woman", "group", "smile",
183 "meh", "frown", "die", "baby", "kid", "angel", "demon", "hand", "brain", "number0", "number1", "number2", "number3",
184 "number4", "number5", "number6", "number7", "number8", "number9", "number10", "number11", "number12", "number13",
185 "number14", "number15", "number16", "number17", "number18", "number19", "number20",
186];
187
188#[derive(Debug, Clone, PartialEq, Deserialize)]
192pub struct TagGroup {
193 pub id: String,
195 pub name: String,
197 pub tags: Vec<String>,
199 pub color: Option<String>,
201 pub description: Option<String>,
203 #[serde(flatten)]
205 pub extra: Extra,
206}
207
208impl Serialize for TagGroup {
209 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
210 let mut m = s.serialize_map(None)?;
211 m.serialize_entry("id", &self.id)?;
212 m.serialize_entry("name", &self.name)?;
213 m.serialize_entry("tags", &self.tags)?;
214 if let Some(v) = &self.color {
215 m.serialize_entry("color", v)?;
216 }
217 if let Some(v) = &self.description {
218 m.serialize_entry("description", v)?;
219 }
220 for (k, v) in &self.extra {
221 m.serialize_entry(k, v)?;
222 }
223 m.end()
224 }
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum FolderOrder {
230 By(crate::search::OrderBy),
232 Duration,
234 Random,
236 Manual,
238}
239
240impl From<crate::search::OrderBy> for FolderOrder {
241 fn from(o: crate::search::OrderBy) -> Self {
242 match o {
243 crate::search::OrderBy::Duration => FolderOrder::Duration,
244 crate::search::OrderBy::Random => FolderOrder::Random,
245 o => FolderOrder::By(o),
246 }
247 }
248}
249
250impl FolderOrder {
251 pub fn as_str(self) -> &'static str {
253 use crate::search::OrderBy as O;
254 match self {
255 FolderOrder::By(O::Import) => "IMPORT",
256 FolderOrder::By(O::Name) => "NAME",
257 FolderOrder::By(O::Ext) => "EXT",
258 FolderOrder::By(O::Resolution) => "RESOLUTION",
259 FolderOrder::By(O::FileSize) => "FILESIZE",
260 FolderOrder::By(O::Rating) => "RATING",
261 FolderOrder::By(O::Btime) => "BTIME",
262 FolderOrder::By(O::Mtime) => "MTIME",
263 FolderOrder::By(O::Tags) => "TAGS",
264 FolderOrder::Duration | FolderOrder::By(O::Duration) => "DURATION",
265 FolderOrder::Random | FolderOrder::By(O::Random) => "RANDOM",
266 FolderOrder::Manual => "MANUAL",
267 }
268 }
269
270 pub fn parse(s: &str) -> Option<FolderOrder> {
272 use crate::search::OrderBy as O;
273 Some(match s {
274 "IMPORT" => O::Import.into(),
275 "NAME" => O::Name.into(),
276 "EXT" => O::Ext.into(),
277 "RESOLUTION" => O::Resolution.into(),
278 "FILESIZE" => O::FileSize.into(),
279 "RATING" => O::Rating.into(),
280 "BTIME" => O::Btime.into(),
281 "MTIME" => O::Mtime.into(),
282 "TAGS" => O::Tags.into(),
283 "DURATION" => FolderOrder::Duration,
284 "RANDOM" => FolderOrder::Random,
285 "MANUAL" => FolderOrder::Manual,
286 _ => return None,
287 })
288 }
289}
290
291#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
293#[serde(rename_all = "camelCase")]
294pub struct Palette {
295 pub color: [u8; 3],
297 pub ratio: Number,
300 #[serde(flatten)]
302 pub extra: Extra,
303}
304
305#[derive(Debug, Clone, PartialEq, Deserialize)]
307#[serde(rename_all = "camelCase", from = "ItemRepr")]
308pub struct Item {
309 pub id: String,
311 pub name: String,
313 pub size: u64,
315 pub btime: u64,
317 pub mtime: u64,
319 pub ext: String,
321 pub tags: Vec<String>,
323 pub folders: Vec<String>,
325 pub is_deleted: bool,
327 pub url: String,
329 pub annotation: String,
331 pub modification_time: u64,
333 pub remove_thumbnail: Option<bool>,
335 pub no_thumbnail: Option<bool>,
337 pub height: u64,
339 pub width: u64,
341 pub last_modified: u64,
343 pub palettes: Vec<Palette>,
345 pub key_order: KeyOrder,
352 pub extra: Extra,
354}
355
356#[derive(Debug, Clone, Default)]
359pub struct KeyOrder(pub Vec<String>);
360
361impl PartialEq for KeyOrder {
362 fn eq(&self, _: &Self) -> bool {
363 true
364 }
365}
366
367impl std::ops::Deref for KeyOrder {
368 type Target = Vec<String>;
369 fn deref(&self) -> &Vec<String> {
370 &self.0
371 }
372}
373
374impl std::ops::DerefMut for KeyOrder {
375 fn deref_mut(&mut self) -> &mut Vec<String> {
376 &mut self.0
377 }
378}
379
380#[derive(Deserialize)]
383#[serde(rename_all = "camelCase")]
384struct ItemRepr {
385 id: String,
386 #[serde(default)]
387 name: String,
388 #[serde(default)]
389 size: u64,
390 #[serde(default)]
391 btime: u64,
392 #[serde(default)]
393 mtime: u64,
394 #[serde(default)]
395 ext: String,
396 #[serde(default)]
397 tags: Vec<String>,
398 #[serde(default)]
399 folders: Vec<String>,
400 #[serde(default)]
401 is_deleted: bool,
402 #[serde(default)]
403 url: String,
404 #[serde(default)]
405 annotation: String,
406 #[serde(default)]
407 modification_time: u64,
408 remove_thumbnail: Option<bool>,
409 no_thumbnail: Option<bool>,
410 #[serde(default)]
411 height: u64,
412 #[serde(default)]
413 width: u64,
414 #[serde(default)]
415 last_modified: u64,
416 #[serde(default)]
417 palettes: Vec<Palette>,
418 #[serde(flatten)]
419 extra: Extra,
420}
421
422impl From<ItemRepr> for Item {
423 fn from(r: ItemRepr) -> Self {
424 Item {
425 id: r.id,
426 name: r.name,
427 size: r.size,
428 btime: r.btime,
429 mtime: r.mtime,
430 ext: r.ext,
431 tags: r.tags,
432 folders: r.folders,
433 is_deleted: r.is_deleted,
434 url: r.url,
435 annotation: r.annotation,
436 modification_time: r.modification_time,
437 remove_thumbnail: r.remove_thumbnail,
438 no_thumbnail: r.no_thumbnail,
439 height: r.height,
440 width: r.width,
441 last_modified: r.last_modified,
442 palettes: r.palettes,
443 key_order: KeyOrder::default(),
444 extra: r.extra,
445 }
446 }
447}
448
449#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
454#[serde(rename_all = "camelCase")]
455pub struct Comment {
456 #[serde(default)]
458 pub id: String,
459 pub x: Option<Number>,
462 pub y: Option<Number>,
464 pub width: Option<Number>,
466 pub height: Option<Number>,
468 pub duration: Option<Number>,
470 #[serde(default)]
472 pub annotation: String,
473 pub last_modified: Option<u64>,
475 #[serde(flatten)]
477 pub extra: Extra,
478}
479
480impl Serialize for Comment {
481 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
482 let mut m = s.serialize_map(None)?;
483 m.serialize_entry("id", &self.id)?;
484 for (k, v) in [("x", &self.x), ("y", &self.y), ("width", &self.width), ("height", &self.height), ("duration", &self.duration)] {
485 if let Some(v) = v {
486 m.serialize_entry(k, v)?;
487 }
488 }
489 m.serialize_entry("annotation", &self.annotation)?;
490 if let Some(v) = &self.last_modified {
491 m.serialize_entry("lastModified", v)?;
492 }
493 for (k, v) in &self.extra {
494 m.serialize_entry(k, v)?;
495 }
496 m.end()
497 }
498}
499
500impl Comment {
501 pub fn region(x: u64, y: u64, width: u64, height: u64, annotation: &str) -> Comment {
503 Comment { x: Some(x.into()), y: Some(y.into()), width: Some(width.into()), height: Some(height.into()), annotation: annotation.into(), ..Default::default() }
504 }
505
506 pub fn at_time(seconds: f64, annotation: &str) -> Comment {
508 Comment { duration: Number::from_f64(seconds), annotation: annotation.into(), ..Default::default() }
509 }
510}
511
512impl Item {
513 pub fn comments(&self) -> Vec<Comment> {
516 let Some(Value::Array(list)) = self.extra.get("comments") else { return Vec::new() };
517 list.iter().filter_map(|c| serde_json::from_value(c.clone()).ok()).collect()
518 }
519
520 pub fn set_comments(&mut self, comments: &[Comment]) {
523 let value = serde_json::to_value(comments).unwrap_or_else(|_| Value::Array(Vec::new()));
524 match self.extra.get_mut("comments") {
525 Some(slot) => *slot = value,
526 None => {
527 self.extra.insert("comments".into(), value);
528 }
529 }
530 }
531
532 pub fn from_json_str(s: &str) -> serde_json::Result<Item> {
534 let value: Value = serde_json::from_str(s)?;
535 let key_order = value.as_object().map(|m| m.keys().cloned().collect()).unwrap_or_default();
536 let mut item: Item = serde_json::from_value(value)?;
537 item.key_order = KeyOrder(key_order);
538 Ok(item)
539 }
540
541 fn entries(&self) -> Vec<(String, Value)> {
545 let v = |x: &dyn erased::ToValue| x.to_value();
546 let mut out: Vec<(String, Value, bool)> = vec![
547 ("id".into(), v(&self.id), false),
548 ("name".into(), v(&self.name), self.name.is_empty()),
549 ("size".into(), v(&self.size), self.size == 0),
550 ("btime".into(), v(&self.btime), self.btime == 0),
551 ("mtime".into(), v(&self.mtime), self.mtime == 0),
552 ("ext".into(), v(&self.ext), self.ext.is_empty()),
553 ("tags".into(), v(&self.tags), self.tags.is_empty()),
554 ("folders".into(), v(&self.folders), self.folders.is_empty()),
555 ("isDeleted".into(), v(&self.is_deleted), !self.is_deleted),
556 ("url".into(), v(&self.url), self.url.is_empty()),
557 ("annotation".into(), v(&self.annotation), self.annotation.is_empty()),
558 ("modificationTime".into(), v(&self.modification_time), self.modification_time == 0),
559 ];
560 if let Some(x) = &self.remove_thumbnail {
561 out.push(("removeThumbnail".into(), v(x), false));
562 }
563 if let Some(x) = &self.no_thumbnail {
564 out.push(("noThumbnail".into(), v(x), false));
565 }
566 out.push(("height".into(), v(&self.height), self.height == 0));
567 out.push(("width".into(), v(&self.width), self.width == 0));
568 out.push(("lastModified".into(), v(&self.last_modified), self.last_modified == 0));
569 out.push(("palettes".into(), v(&self.palettes), self.palettes.is_empty()));
570 let fresh = self.key_order.is_empty();
571 let mut out: Vec<(String, Value)> = out
572 .into_iter()
573 .filter(|(k, _, default)| fresh || !default || self.key_order.contains(k))
574 .map(|(k, val, _)| (k, val))
575 .collect();
576 out.extend(self.extra.iter().map(|(k, val)| (k.clone(), val.clone())));
577 if !fresh {
578 let rank = |k: &str| self.key_order.iter().position(|x| x == k).unwrap_or(usize::MAX);
580 out.sort_by_key(|(k, _)| rank(k)); }
582 out
583 }
584}
585
586impl Serialize for Item {
587 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
588 let mut m = s.serialize_map(None)?;
589 for (k, v) in self.entries() {
590 m.serialize_entry(&k, &v)?;
591 }
592 m.end()
593 }
594}
595
596mod erased {
598 pub trait ToValue {
599 fn to_value(&self) -> serde_json::Value;
600 }
601 impl<T: serde::Serialize> ToValue for T {
602 fn to_value(&self) -> serde_json::Value {
603 serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
604 }
605 }
606}
607
608#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
611pub struct SavedFilter {
612 pub name: String,
614 pub rule: Value,
618 #[serde(flatten)]
620 pub extra: Extra,
621}
622
623#[derive(Debug, Clone, Default, PartialEq)]
625pub struct SavedFilterQuery {
626 pub filter: crate::search::Filter,
628 pub keyword: Option<String>,
630 pub ai: SavedAiRules,
632}
633
634#[derive(Debug, Clone, Default, PartialEq)]
644pub struct SavedAiRules {
645 pub image: Option<Value>,
646 pub semantic: Option<Value>,
647}
648
649impl SavedAiRules {
650 pub fn from_rule(rule: &Value) -> SavedAiRules {
652 let read = |key: &str| rule.get(key).filter(|v| v.as_object().is_none_or(|o| !o.is_empty())).cloned();
653 SavedAiRules { image: read("image"), semantic: read("semantic") }
654 }
655
656 pub fn for_text(text: &str) -> SavedAiRules {
658 SavedAiRules { semantic: Some(serde_json::json!({"value": text})), ..Default::default() }
659 }
660
661 pub fn for_item(id: &str) -> SavedAiRules {
663 SavedAiRules { image: Some(serde_json::json!({"itemId": id})), ..Default::default() }
664 }
665
666 fn on<'a>(rule: &'a Option<Value>, field: &str) -> Option<&'a Value> {
668 rule.as_ref()?.get(field).filter(|v| !v.is_null() && v.as_str().is_none_or(|s| !s.trim().is_empty()))
669 }
670
671 pub fn image_on(&self) -> bool {
672 Self::on(&self.image, "itemId").is_some()
673 }
674
675 pub fn semantic_on(&self) -> bool {
676 Self::on(&self.semantic, "value").is_some()
677 }
678
679 pub fn is_active(&self) -> bool {
680 self.image_on() || self.semantic_on()
681 }
682
683 pub fn image_item(&self) -> Option<&str> {
685 Self::on(&self.image, "itemId")?.as_str()
686 }
687
688 pub fn semantic_text(&self) -> Option<&str> {
690 Self::on(&self.semantic, "value")?.as_str()
691 }
692
693 pub fn has_unreadable(&self) -> bool {
695 (self.image_on() && self.image_item().is_none()) || (self.semantic_on() && self.semantic_text().is_none())
696 }
697
698 pub fn write_to(&self, rule: &mut Value) {
701 if let Some(image) = &self.image {
702 rule["image"] = image.clone();
703 }
704 if let Some(semantic) = &self.semantic {
705 rule["semantic"] = semantic.clone();
706 }
707 }
708}
709
710impl SavedFilter {
711 pub fn to_filter(&self) -> SavedFilterQuery {
717 use crate::search::{font_dirs, installed_font_keys_in};
718 self.to_filter_with_fonts(|| installed_font_keys_in(&font_dirs()))
719 }
720
721 pub fn to_filter_with_fonts(&self, font_keys: impl FnOnce() -> std::collections::HashSet<String>) -> SavedFilterQuery {
724 use crate::search::{ColorQuery, DateFilter, Filter, FontFilter, SetFilter, Shape, TextFilter};
725 let r = &self.rule;
726 let get = |path: &[&str]| path.iter().try_fold(r, |v, k| v.get(*k));
727 let truthy = |path: &[&str]| get(path).is_some_and(|v| v.as_bool() == Some(true));
728 let keys = |path: &[&str]| -> Vec<String> {
729 get(path).and_then(Value::as_object).map(|m| m.iter().filter(|(_, v)| !v.is_null() && v.as_bool() != Some(false)).map(|(k, _)| k.clone()).collect()).unwrap_or_default()
730 };
731 let strings = |path: &[&str]| -> Vec<String> {
732 get(path).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default()
733 };
734 let number = |path: &[&str]| -> Option<f64> {
736 get(path).and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())))
737 };
738 let mut out = SavedFilterQuery::default();
739 let mut f = Filter::default();
740 out.ai = SavedAiRules::from_rule(r);
741
742 f.types = keys(&["type", "includes"]);
743 f.exclude_types = keys(&["type", "excludes"]);
744 f.tags = SetFilter { includes: strings(&["tag", "includes"]), excludes: strings(&["tag", "excludes"]), empty: truthy(&["tag", "no"]), ..Default::default() };
745 let (inc, exc) = (keys(&["folder", "includes"]), keys(&["folder", "excludes"]));
746 f.folders = SetFilter {
747 empty: inc.iter().any(|k| k == "NoFolders"),
748 exclude_empty: exc.iter().any(|k| k == "NoFolders"),
749 includes: inc.into_iter().filter(|k| k != "NoFolders").collect(),
750 excludes: exc.into_iter().filter(|k| k != "NoFolders").collect(),
751 ..Default::default()
752 };
753
754 let unit = if get(&["file", "unit"]).and_then(Value::as_str) == Some("mb") { 1_048_576.0 } else { 1024.0 };
755 f.size = (number(&["file", "min"]).map(|v| (v * unit) as u64), number(&["file", "max"]).map(|v| (v * unit) as u64));
756 let px = |k: &str| number(&["resolution", k]).map(|v| v as u64);
757 f.width = (px("minW"), px("maxW"));
758 f.height = (px("minH"), px("maxH"));
759
760 for star in 0..=5u8 {
761 if truthy(&["rating", &star.to_string()]) {
762 f.ratings.insert(star);
763 }
764 }
765 for (key, shape) in [
766 ("portrait", Shape::Portrait),
767 ("landscape", Shape::Landscape),
768 ("square", Shape::Square),
769 ("panoramicPortrait", Shape::PanoramicPortrait),
770 ("panoramicLandscape", Shape::PanoramicLandscape),
771 ("43", Shape::Ratio4x3),
772 ("34", Shape::Ratio3x4),
773 ("169", Shape::Ratio16x9),
774 ("916", Shape::Ratio9x16),
775 ] {
776 if truthy(&["shape", key]) {
777 f.shapes.insert(shape);
778 }
779 }
780 if truthy(&["shape", "custom"]) {
781 f.custom_shape = Some((number(&["shape", "width"]).unwrap_or(0.0), number(&["shape", "height"]).unwrap_or(0.0)));
782 }
783
784 if let Some(rgb) = get(&["color", "value"]).and_then(Value::as_array).filter(|a| a.len() >= 3) {
785 let c = |i: usize| rgb[i].as_f64().unwrap_or(0.0).clamp(0.0, 255.0) as u8;
786 f.color = Some(ColorQuery { rgb: [c(0), c(1), c(2)], accuracy: number(&["color", "accuracy"]).unwrap_or(20.0) });
787 }
788 f.grayscale = truthy(&["color", "gray"]);
789
790 let text = |k: &str| -> TextFilter {
791 if truthy(&[k, "has"]) {
792 match get(&[k, "keywords"]).and_then(Value::as_str).filter(|s| !s.is_empty()) {
793 Some(words) => TextFilter::Keywords(words.split(',').map(str::to_string).collect()),
794 None => TextFilter::Has,
795 }
796 } else if truthy(&[k, "no"]) {
797 TextFilter::No
798 } else {
799 TextFilter::Off
800 }
801 };
802 f.url = text("url");
803 f.note = text("note");
804 f.comments = text("annotation");
805
806 let date = |k: &str, range_keys: &[&str]| -> DateFilter {
810 let mut d = DateFilter::default();
811 for (key, p) in DATE_PRESET_KEYS {
812 if truthy(&[k, key]) {
813 d.presets.insert(p);
814 }
815 }
816 if truthy(&[k, "usingRange"]) {
817 let ends = range_keys.iter().find_map(|rk| get(&[rk, "range"]).and_then(Value::as_array)).filter(|a| a.len() >= 2);
820 let end = |v: &Value| v.as_f64().filter(|t| *t > 0.0).map_or(0, |t| t as u64);
821 d.range = Some(ends.map_or((0, 0), |a| (end(&a[0]), end(&a[1]))));
822 }
823 d.months = keys(&[k, "selectedMonths"]).iter().filter_map(|m| parse_month_key(m)).collect();
824 d
825 };
826 f.imported = date("import", &["import"]);
827 f.modified = date("mtime", &["mtime", "import"]);
828 f.created = date("btime", &["btime"]);
829
830 let per = match get(&["duration", "unit"]).and_then(Value::as_str) {
833 Some("h") => 3600.0,
834 Some("m") => 60.0,
835 _ => 1.0,
836 };
837 f.duration = (number(&["duration", "min"]).map(|v| v * per), number(&["duration", "max"]).map(|v| v * per));
838 f.bpm = (number(&["bpm", "min"]), number(&["bpm", "max"]));
839 f.cameras = keys(&["camera"]).into_iter().collect();
840 if truthy(&["font", "activated"]) {
842 f.font = FontFilter::Activated(font_keys());
843 } else if truthy(&["font", "deactivated"]) {
844 f.font = FontFilter::Deactivated(font_keys());
845 }
846
847 out.filter = f;
848 out.keyword = r.get("keyword").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string);
849 out
850 }
851
852 pub fn rule_from_filter(filter: &crate::search::Filter, keyword: Option<&str>, folder_name: impl Fn(&str) -> Option<String>) -> Value {
860 use crate::search::{DateFilter, FontFilter, Shape, TextFilter};
861 let mut r = default_filter_rule();
862 let obj = |list: &[String]| Value::Object(list.iter().map(|t| (t.clone(), Value::Bool(true))).collect());
863 r["type"]["includes"] = obj(&filter.types);
864 r["type"]["excludes"] = obj(&filter.exclude_types);
865 r["tag"]["no"] = filter.tags.empty.into();
866 r["tag"]["includes"] = filter.tags.includes.clone().into();
867 r["tag"]["excludes"] = filter.tags.excludes.clone().into();
868 let folder = |id: &str| {
869 let mut m = Map::new();
870 m.insert("id".into(), id.into());
871 if let Some(n) = folder_name(id) {
872 m.insert("name".into(), n.into());
873 }
874 Value::Object(m)
875 };
876 let mut inc: Map<String, Value> = filter.folders.includes.iter().map(|id| (id.clone(), folder(id))).collect();
877 if filter.folders.empty {
878 inc.insert("NoFolders".into(), serde_json::json!({"id": "NoFolders"}));
879 }
880 r["folder"]["includes"] = Value::Object(inc);
881 let mut exc: Map<String, Value> = filter.folders.excludes.iter().map(|id| (id.clone(), folder(id))).collect();
882 if filter.folders.exclude_empty {
883 exc.insert("NoFolders".into(), serde_json::json!({"id": "NoFolders"}));
884 }
885 r["folder"]["excludes"] = Value::Object(exc);
886 for star in &filter.ratings {
887 r["rating"][star.to_string()] = true.into();
888 }
889 for s in &filter.shapes {
890 let key = match s {
891 Shape::Portrait => "portrait",
892 Shape::Landscape => "landscape",
893 Shape::Square => "square",
894 Shape::PanoramicPortrait => "panoramicPortrait",
895 Shape::PanoramicLandscape => "panoramicLandscape",
896 Shape::Ratio4x3 => "43",
897 Shape::Ratio3x4 => "34",
898 Shape::Ratio16x9 => "169",
899 Shape::Ratio9x16 => "916",
900 };
901 r["shape"][key] = true.into();
902 }
903 if let Some((w, h)) = filter.custom_shape {
904 r["shape"]["custom"] = true.into();
905 r["shape"]["width"] = js_number(w);
906 r["shape"]["height"] = js_number(h);
907 }
908 if let Some(c) = &filter.color {
909 let color = r["color"].as_object_mut().expect("default color rule");
910 color.shift_insert(1, "value".into(), serde_json::json!(c.rgb));
911 color["accuracy"] = js_number(c.accuracy);
912 }
913 r["color"]["gray"] = filter.grayscale.into();
914 let kb = |b: u64| js_number(b as f64 / 1024.0);
916 let file = r["file"].as_object_mut().expect("default file rule");
917 if let Some(b) = filter.size.0 {
918 file.shift_insert(0, "min".into(), kb(b));
919 }
920 if let Some(b) = filter.size.1 {
921 let at = usize::from(filter.size.0.is_some());
922 file.shift_insert(at, "max".into(), kb(b));
923 }
924 for (k, v) in [("minW", filter.width.0), ("maxW", filter.width.1), ("minH", filter.height.0), ("maxH", filter.height.1)] {
925 if let Some(v) = v {
926 r["resolution"][k] = v.into();
927 }
928 }
929 let text = |r: &mut Value, k: &str, t: &TextFilter| match t {
930 TextFilter::Off => {}
931 TextFilter::Has => r[k]["has"] = true.into(),
932 TextFilter::No => r[k]["no"] = true.into(),
933 TextFilter::Keywords(words) => {
934 r[k]["has"] = true.into();
935 r[k]["keywords"] = words.join(",").into();
936 }
937 };
938 for (k, v) in [("min", filter.bpm.0), ("max", filter.bpm.1)] {
939 if let Some(v) = v {
940 r["bpm"][k] = js_number(v);
941 }
942 }
943 text(&mut r, "annotation", &filter.comments);
944 text(&mut r, "url", &filter.url);
945 text(&mut r, "note", &filter.note);
946
947 let date = |rule: &mut Value, d: &DateFilter, range: Option<(u64, u64)>| {
950 for (key, p) in DATE_PRESET_KEYS {
951 if d.presets.contains(&p) {
952 rule[key] = true.into();
953 }
954 }
955 if d.range.is_some() {
956 rule["usingRange"] = true.into();
957 }
958 if let Some((a, b)) = range {
959 let m = rule.as_object_mut().expect("default date rule");
960 let at = m.keys().position(|k| k == "type").unwrap_or(m.len());
961 m.shift_insert(at, "range".into(), serde_json::json!([a, b]));
962 }
963 let mut months: Vec<_> = d.months.iter().copied().collect();
964 months.sort_unstable();
965 rule["selectedMonths"] = Value::Object(months.into_iter().map(|(y, m)| (format!("{y:04}/{m:02}"), Value::Bool(true))).collect());
966 };
967 let shared = filter.imported.range.or(filter.modified.range);
970 date(&mut r["import"], &filter.imported, shared);
971 if shared.is_some() {
972 r["import"]["type"] = "range".into();
973 }
974 let own = filter.modified.range.filter(|m| Some(*m) != shared);
975 date(&mut r["mtime"], &filter.modified, own);
976
977 if filter.duration != (None, None) {
979 let bounds = [filter.duration.0, filter.duration.1];
980 let whole = |per: f64| bounds.iter().flatten().all(|s| (s / per).fract() == 0.0);
981 let (unit, per) = if whole(3600.0) {
982 ("h", 3600.0)
983 } else if whole(60.0) {
984 ("m", 60.0)
985 } else {
986 ("s", 1.0)
987 };
988 let d = r["duration"].as_object_mut().expect("default duration rule");
989 let mut at = 0;
990 for (k, v) in [("min", filter.duration.0), ("max", filter.duration.1)] {
991 if let Some(s) = v {
992 d.shift_insert(at, k.into(), js_number(s / per));
993 at += 1;
994 }
995 }
996 d["unit"] = unit.into();
997 }
998 let mut cameras: Vec<&String> = filter.cameras.iter().collect();
999 cameras.sort();
1000 r["camera"] = Value::Object(cameras.into_iter().map(|c| (c.clone(), Value::Bool(true))).collect());
1001 match filter.font {
1002 FontFilter::Off => {}
1003 FontFilter::Activated(_) => r["font"]["activated"] = true.into(),
1004 FontFilter::Deactivated(_) => r["font"]["deactivated"] = true.into(),
1005 }
1006 if filter.created.is_active() {
1007 let mut btime = default_filter_rule()["import"].take();
1008 date(&mut btime, &filter.created, filter.created.range);
1009 r["btime"] = btime;
1010 }
1011 if let Some(k) = keyword.filter(|k| !k.is_empty()) {
1012 r["keyword"] = k.into();
1013 }
1014 r
1015 }
1016}
1017
1018const DATE_PRESET_KEYS: [(&str, crate::search::DatePreset); 6] = {
1020 use crate::search::DatePreset as P;
1021 [("today", P::Today), ("yesterday", P::Yesterday), ("last7day", P::Last7Days), ("last30day", P::Last30Days), ("last90day", P::Last90Days), ("last365day", P::Last365Days)]
1022};
1023
1024fn parse_month_key(key: &str) -> Option<(i32, u32)> {
1026 let (y, m) = key.split_once('/')?;
1027 let m: u32 = m.parse().ok()?;
1028 let year = y.parse().ok()?;
1029 (1..=12).contains(&m).then_some((year, m))
1030}
1031
1032fn js_number(v: f64) -> Value {
1034 if v.is_finite() && v.fract() == 0.0 && v.abs() < 9_007_199_254_740_992.0 {
1035 Value::from(v as i64)
1036 } else {
1037 Value::from(v)
1038 }
1039}
1040
1041fn default_filter_rule() -> Value {
1044 let day = || {
1045 serde_json::json!({
1046 "today": false, "yesterday": false, "last7day": false, "last30day": false, "last90day": false,
1047 "last365day": false, "usingRange": false, "type": "undefined", "selectedMonths": {}
1048 })
1049 };
1050 let has = || serde_json::json!({"has": false, "no": false});
1051 serde_json::json!({
1052 "type": {"includes": {}, "excludes": {}},
1053 "tag": {"no": false, "includes": [], "excludes": []},
1054 "folder": {"includes": {}, "excludes": {}},
1055 "font": {"activated": false, "deactivated": false},
1056 "camera": {},
1057 "import": day(),
1058 "mtime": day(),
1059 "rating": {"0": false, "1": false, "2": false, "3": false, "4": false, "5": false},
1060 "color": {"gray": false, "accuracy": 20},
1061 "shape": {
1062 "34": false, "43": false, "169": false, "916": false, "portrait": false, "landscape": false,
1063 "square": false, "panoramicPortrait": false, "panoramicLandscape": false, "custom": false,
1064 "width": 4, "height": 3
1065 },
1066 "resolution": {},
1067 "file": {"unit": "kb"},
1068 "duration": {"unit": "s"},
1069 "bpm": {},
1070 "annotation": has(),
1071 "note": has(),
1072 "url": has(),
1073 "image": {},
1074 "semantic": {}
1075 })
1076}
1077
1078#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1080#[serde(rename_all = "camelCase")]
1081pub struct Tags {
1082 pub history_tags: Vec<String>,
1084 pub starred_tags: Vec<String>,
1086 #[serde(flatten)]
1088 pub extra: Extra,
1089}
1090
1091#[derive(Debug, Clone, Default, PartialEq)]
1093pub struct MtimeIndex {
1094 pub entries: Vec<(String, u64)>,
1096 pub all: Option<u64>,
1098}
1099
1100impl Serialize for MtimeIndex {
1101 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1102 let mut m = s.serialize_map(None)?;
1103 for (k, v) in &self.entries {
1104 m.serialize_entry(k, v)?;
1105 }
1106 if let Some(all) = &self.all {
1107 m.serialize_entry("all", all)?;
1108 }
1109 m.end()
1110 }
1111}
1112
1113impl<'de> Deserialize<'de> for MtimeIndex {
1114 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1115 let map = Map::<String, Value>::deserialize(d)?;
1116 let mut out = MtimeIndex::default();
1117 for (k, v) in map {
1118 let n = v
1119 .as_u64()
1120 .ok_or_else(|| serde::de::Error::custom(format!("mtime for {k} is not an integer")))?;
1121 if k == "all" {
1122 out.all = Some(n);
1123 } else {
1124 out.entries.push((k, n));
1125 }
1126 }
1127 Ok(out)
1128 }
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133 use super::*;
1134
1135 const EPUB: &str = r#"{"id":"MU2PQF5G9ZVDB","name":"book","size":46784743,"btime":1789479226928,"mtime":1789479229513,"ext":"epub","tags":[],"folders":[],"isDeleted":true,"url":"","annotation":"","modificationTime":1789479235636,"noPreview":true,"deletedTime":1789479244672,"lastModified":1789479244751}"#;
1137 const TEX: &str = r#"{"id":"MPLM58NXPSPTR","name":"main","size":1200,"btime":1,"mtime":2,"ext":"tex","tags":[],"folders":["F1"],"isDeleted":false,"url":"","annotation":"","modificationTime":3,"lastModified":4}"#;
1138 const WIDTH_FIRST: &str = r#"{"id":"A","name":"n","size":1,"btime":1,"mtime":1,"ext":"png","tags":[],"folders":[],"isDeleted":false,"url":"","annotation":"","modificationTime":1,"width":10,"height":20,"lastModified":1,"palettes":[]}"#;
1139
1140 #[test]
1141 fn items_without_dimensions_round_trip() {
1142 for json in [EPUB, TEX, WIDTH_FIRST] {
1143 let item = Item::from_json_str(json).unwrap();
1144 assert_eq!(serde_json::to_string(&item).unwrap(), json);
1145 }
1146 let item = Item::from_json_str(EPUB).unwrap();
1147 assert_eq!((item.width, item.height, item.palettes.len()), (0, 0, 0));
1148 assert!(item.is_deleted);
1149 }
1150
1151 #[test]
1152 fn values_set_later_are_written_even_if_the_file_lacked_them() {
1153 let mut item = Item::from_json_str(TEX).unwrap();
1154 item.width = 640;
1155 item.tags.push("paper".into());
1156 let out = serde_json::to_string(&item).unwrap();
1157 assert!(out.contains(r#""tags":["paper"]"#), "{out}");
1158 assert!(out.ends_with(r#""lastModified":4,"width":640}"#), "{out}");
1159 }
1160
1161 #[test]
1162 fn new_items_use_eagles_key_order() {
1163 let mut item = Item::from_json_str(TEX).unwrap();
1164 item.key_order.clear();
1165 let out = serde_json::to_string(&item).unwrap();
1166 assert!(out.ends_with(r#""modificationTime":3,"height":0,"width":0,"lastModified":4,"palettes":[]}"#), "{out}");
1167 }
1168
1169 #[test]
1170 fn only_id_is_required() {
1171 assert!(Item::from_json_str(r#"{"id":"X"}"#).is_ok());
1172 assert!(Item::from_json_str(r#"{"name":"X"}"#).is_err());
1173 }
1174
1175 #[test]
1176 fn folder_with_every_optional_key_round_trips() {
1177 let json = r#"{"id":"MVP1BZ7QBLH3P","name":"Logos","description":"d","children":[{"id":"MVP1BZ7UEGUE3","name":"Red","description":"","children":[],"modificationTime":2,"tags":[],"password":"","passwordTips":""}],"modificationTime":1,"tags":["a"],"extendTags":["a"],"icon":"palette","iconColor":"red","pinyin":"Logos","password":"MTIz","passwordTips":"hint","coverId":"MVP1BZ81EWQ4O","orderBy":"RANDOM","sortIncrease":false}"#;
1178 let f: Folder = serde_json::from_str(json).unwrap();
1179 assert!(f.extra.is_empty());
1180 assert_eq!(f.order(), Some(FolderOrder::Random));
1181 assert_eq!(f.icon_color.as_deref(), Some("red"));
1182 assert_eq!(serde_json::to_string(&f).unwrap(), json);
1183 }
1184
1185 #[test]
1186 fn folder_order_names_round_trip() {
1187 for o in crate::search::OrderBy::ALL {
1188 let f = FolderOrder::from(o);
1189 assert_eq!(FolderOrder::parse(f.as_str()), Some(f));
1190 }
1191 for s in ["DURATION", "RANDOM", "MANUAL"] {
1192 assert_eq!(FolderOrder::parse(s).unwrap().as_str(), s);
1193 }
1194 assert_eq!(FolderOrder::parse("nope"), None);
1195 assert_eq!(FOLDER_ICONS.len(), 161);
1196 }
1197
1198 const DEFAULT_RULE: &str = r#"{"type":{"includes":{},"excludes":{}},"tag":{"no":false,"includes":[],"excludes":[]},"folder":{"includes":{},"excludes":{}},"font":{"activated":false,"deactivated":false},"camera":{},"import":{"today":false,"yesterday":false,"last7day":false,"last30day":false,"last90day":false,"last365day":false,"usingRange":false,"type":"undefined","selectedMonths":{}},"mtime":{"today":false,"yesterday":false,"last7day":false,"last30day":false,"last90day":false,"last365day":false,"usingRange":false,"type":"undefined","selectedMonths":{}},"rating":{"0":false,"1":false,"2":false,"3":false,"4":false,"5":false},"color":{"gray":false,"accuracy":20},"shape":{"34":false,"43":false,"169":false,"916":false,"portrait":false,"landscape":false,"square":false,"panoramicPortrait":false,"panoramicLandscape":false,"custom":false,"width":4,"height":3},"resolution":{},"file":{"unit":"kb"},"duration":{"unit":"s"},"bpm":{},"annotation":{"has":false,"no":false},"note":{"has":false,"no":false},"url":{"has":false,"no":false},"image":{},"semantic":{}}"#;
1200
1201 #[test]
1202 fn empty_filter_builds_eagles_default_rule() {
1203 let rule = SavedFilter::rule_from_filter(&crate::search::Filter::default(), None, |_| None);
1204 assert_eq!(rule.to_string(), DEFAULT_RULE);
1205 let saved = SavedFilter { name: "x".into(), rule, extra: Extra::new() };
1206 assert_eq!(saved.to_filter(), SavedFilterQuery::default());
1207 }
1208
1209 #[test]
1210 fn saved_filter_reads_eagle_rules() {
1211 let rule = DEFAULT_RULE
1212 .replace(r#""type":{"includes":{}"#, r#""type":{"includes":{"png":true,"video":true}"#)
1213 .replace(r#""tag":{"no":false,"includes":[]"#, r#""tag":{"no":true,"includes":["red"]"#)
1214 .replace(r#""folder":{"includes":{}"#, r#""folder":{"includes":{"F1":{"id":"F1","name":"One","images":[]},"NoFolders":{"id":"NoFolders"}}"#)
1215 .replace(r#""rating":{"0":false,"1":false"#, r#""rating":{"0":true,"1":false"#)
1216 .replace(r#""color":{"gray":false,"accuracy":20}"#, r#""color":{"gray":false,"value":[255,0,10],"accuracy":10}"#)
1217 .replace(r#""square":false"#, r#""square":true"#)
1218 .replace(r#""169":false"#, r#""169":true"#)
1219 .replace(r#""resolution":{}"#, r#""resolution":{"minW":"100","maxH":800}"#)
1220 .replace(r#""file":{"unit":"kb"}"#, r#""file":{"min":1.5,"unit":"mb"}"#)
1221 .replace(r#""url":{"has":false,"no":false}"#, r#""url":{"has":true,"no":false,"keywords":"dribbble,shot"}"#)
1222 .replace(r#""note":{"has":false,"no":false}"#, r#""note":{"has":false,"no":true}"#)
1223 .replace(r#""import":{"today":false"#, r#""import":{"today":true"#)
1224 .replace(r#""semantic":{}}"#, r#""semantic":{},"keyword":"logo"}"#);
1225 let json = format!(r#"{{"name":"Mine","rule":{rule}}}"#);
1226 let saved: SavedFilter = serde_json::from_str(&json).unwrap();
1227 assert_eq!(serde_json::to_string(&saved).unwrap(), json, "round trip");
1228
1229 let q = saved.to_filter();
1230 let f = &q.filter;
1231 assert_eq!(q.keyword.as_deref(), Some("logo"));
1232 assert_eq!(f.types, ["png", "video"]);
1233 assert_eq!((f.tags.includes.clone(), f.tags.empty), (vec!["red".to_string()], true));
1234 assert_eq!((f.folders.includes.clone(), f.folders.empty), (vec!["F1".to_string()], true));
1235 assert!(f.ratings.contains(&0) && f.ratings.len() == 1);
1236 assert_eq!(f.color.map(|c| (c.rgb, c.accuracy)), Some(([255, 0, 10], 10.0)));
1237 assert!(f.shapes.contains(&crate::search::Shape::Square));
1238 assert_eq!((f.width, f.height), ((Some(100), None), (None, Some(800))));
1239 assert_eq!(f.size, (Some(1_572_864), None));
1240 assert_eq!(f.url, crate::search::TextFilter::Keywords(vec!["dribbble".into(), "shot".into()]));
1241 assert_eq!(f.note, crate::search::TextFilter::No);
1242 assert!(f.shapes.contains(&crate::search::Shape::Ratio16x9));
1243 assert_eq!(q.ai, SavedAiRules::default());
1244 assert_eq!(f.imported.presets, [crate::search::DatePreset::Today].into());
1245 }
1246
1247 fn saved(rule: Value) -> SavedFilter {
1248 SavedFilter { name: "n".into(), rule, extra: Extra::new() }
1249 }
1250
1251 fn ai_rule(image: &str, semantic: &str) -> String {
1253 DEFAULT_RULE.replace(r#""image":{},"semantic":{}"#, &format!(r#""image":{image},"semantic":{semantic}"#))
1254 }
1255
1256 #[test]
1257 fn ai_rules_are_read_from_the_fields_that_turn_them_on() {
1258 let read = |image: &str, semantic: &str| saved(serde_json::from_str(&ai_rule(image, semantic)).unwrap()).to_filter().ai;
1259 let both = read(r#"{"itemId":"ABC"}"#, r#"{"value":"black cat"}"#);
1260 assert_eq!((both.image_item(), both.semantic_text()), (Some("ABC"), Some("black cat")));
1261 assert!(both.is_active() && !both.has_unreadable());
1262
1263 for off in [read("{}", "{}"), read(r#"{"itemId":null}"#, r#"{"value":null}"#), read(r#"{"itemId":""}"#, r#"{"value":" "}"#), read(r#"{"other":1}"#, r#"{"other":1}"#)] {
1264 assert!(!off.is_active(), "{off:?}");
1265 assert!(!off.has_unreadable(), "{off:?}");
1266 }
1267
1268 let odd = read(r#"{"itemId":7}"#, r#"{"value":{"text":"cat"}}"#);
1270 assert!(odd.is_active() && odd.has_unreadable());
1271 assert_eq!((odd.image_item(), odd.semantic_text()), (None, None));
1272 }
1273
1274 #[test]
1278 fn ai_rules_come_back_byte_for_byte() {
1279 for (image, semantic) in [
1280 ("{}", "{}"),
1281 (r#"{"itemId":"ABC"}"#, "{}"),
1282 ("{}", r#"{"value":"black cat"}"#),
1283 (r#"{"itemId":"ABC","zeta":[1,2],"alpha":{"b":1,"a":0.5}}"#, r#"{"z":true,"value":"black cat","a":null}"#),
1284 (r#"{"itemId":7}"#, r#"{"value":{"text":"cat"}}"#),
1285 (r#"{"itemId":null,"base64":"AAAA"}"#, r#"{"value":""}"#),
1286 ] {
1287 let text = ai_rule(image, semantic);
1288 let q = saved(serde_json::from_str(&text).unwrap()).to_filter();
1289 let mut rule = SavedFilter::rule_from_filter(&q.filter, None, |_| None);
1290 q.ai.write_to(&mut rule);
1291 assert_eq!(rule.to_string(), text);
1292 }
1293 }
1294
1295 #[test]
1296 fn a_new_search_saves_in_the_same_shape() {
1297 let mut rule = SavedFilter::rule_from_filter(&crate::search::Filter::default(), Some("logo"), |_| None);
1298 SavedAiRules::for_text("black cat").write_to(&mut rule);
1299 SavedAiRules::for_item("ABC").write_to(&mut rule);
1300 let text = ai_rule(r#"{"itemId":"ABC"}"#, r#"{"value":"black cat"}"#);
1301 assert_eq!(rule.to_string(), format!(r#"{},"keyword":"logo"}}"#, &text[..text.len() - 1]));
1302 let back = saved(rule).to_filter();
1303 assert_eq!((back.ai.image_item(), back.ai.semantic_text(), back.keyword.as_deref()), (Some("ABC"), Some("black cat"), Some("logo")));
1304 }
1305
1306 #[test]
1308 fn every_filter_field_survives_a_save() {
1309 use crate::search::{ColorQuery, DateFilter, DatePreset, Filter, FontFilter, SetFilter, Shape, TextFilter};
1310 let fonts: std::collections::HashSet<String> = ["Inter-Regular_.ttf".to_string()].into();
1311 let dates = DateFilter { presets: [DatePreset::Yesterday, DatePreset::Last90Days].into(), range: Some((1_700_000_000_000, 1_700_086_400_000)), months: [(2024, 3), (2025, 11)].into(), clock: None };
1312 let cases: Vec<(&str, Filter)> = vec![
1313 ("tags", Filter { tags: SetFilter { includes: vec!["a".into()], excludes: vec!["b".into()], empty: true, ..Default::default() }, ..Default::default() }),
1314 ("folders", Filter { folders: SetFilter { includes: vec!["F1".into()], excludes: vec!["F2".into()], empty: true, ..Default::default() }, ..Default::default() }),
1315 ("uncategorized excluded", Filter { folders: SetFilter { excludes: vec!["F2".into()], exclude_empty: true, ..Default::default() }, ..Default::default() }),
1316 ("types", Filter { types: vec!["png".into(), "video".into()], ..Default::default() }),
1317 ("exclude_types", Filter { exclude_types: vec!["font".into()], ..Default::default() }),
1318 ("size", Filter { size: (Some(1024), Some(3 * 1_048_576)), ..Default::default() }),
1319 ("width", Filter { width: (Some(10), Some(20)), ..Default::default() }),
1320 ("height", Filter { height: (None, Some(30)), ..Default::default() }),
1321 ("ratings", Filter { ratings: [0, 4].into(), ..Default::default() }),
1322 ("shapes", Filter { shapes: [Shape::Portrait, Shape::PanoramicLandscape].into(), ..Default::default() }),
1323 ("ratios", Filter { shapes: [Shape::Ratio4x3, Shape::Ratio3x4, Shape::Ratio16x9, Shape::Ratio9x16].into(), ..Default::default() }),
1324 ("custom shape", Filter { custom_shape: Some((2.35, 1.0)), ..Default::default() }),
1325 ("url", Filter { url: TextFilter::Keywords(vec!["a".into(), "b".into()]), ..Default::default() }),
1326 ("note", Filter { note: TextFilter::No, ..Default::default() }),
1327 ("comments", Filter { comments: TextFilter::Has, ..Default::default() }),
1328 ("comment keywords", Filter { comments: TextFilter::Keywords(vec!["red".into(), "car".into()]), ..Default::default() }),
1329 ("color", Filter { color: Some(ColorQuery { rgb: [9, 8, 7], accuracy: 12.5 }), ..Default::default() }),
1330 ("grayscale", Filter { grayscale: true, ..Default::default() }),
1331 ("imported", Filter { imported: dates.clone(), ..Default::default() }),
1332 ("modified", Filter { modified: dates.clone(), ..Default::default() }),
1333 ("created", Filter { created: dates.clone(), ..Default::default() }),
1334 ("both ranges", Filter { imported: dates.clone(), modified: DateFilter { range: Some((5, 6)), ..Default::default() }, ..Default::default() }),
1335 ("unfinished range", Filter { modified: DateFilter { range: Some((0, 0)), ..Default::default() }, ..Default::default() }),
1336 ("duration s", Filter { duration: (Some(90.5), None), ..Default::default() }),
1337 ("duration m", Filter { duration: (Some(60.0), Some(5400.0)), ..Default::default() }),
1338 ("duration h", Filter { duration: (None, Some(7200.0)), ..Default::default() }),
1339 ("bpm", Filter { bpm: (Some(90.0), Some(128.5)), ..Default::default() }),
1340 ("cameras", Filter { cameras: ["Canon EOS R5".to_string(), "NIKON Z 6".to_string()].into(), ..Default::default() }),
1341 ("font activated", Filter { font: FontFilter::Activated(fonts.clone()), ..Default::default() }),
1342 ("font deactivated", Filter { font: FontFilter::Deactivated(fonts.clone()), ..Default::default() }),
1343 ];
1344 for (name, f) in cases {
1345 let rule = SavedFilter::rule_from_filter(&f, None, |_| None);
1346 let back = saved(rule.clone()).to_filter_with_fonts(|| fonts.clone());
1347 assert_eq!(back.filter, f, "{name}: {rule}");
1348 assert_eq!(back.ai, SavedAiRules::default(), "{name}");
1349 }
1350 }
1351
1352 #[test]
1353 fn new_rules_are_written_in_eagles_shape() {
1354 use crate::search::{DateFilter, DatePreset, Filter, FontFilter};
1355 let f = Filter {
1356 imported: DateFilter { presets: [DatePreset::Today].into(), range: Some((10, 20)), ..Default::default() },
1357 modified: DateFilter { months: [(2024, 3)].into(), ..Default::default() },
1358 duration: (Some(60.0), Some(5400.0)),
1359 cameras: ["X100V".to_string()].into(),
1360 font: FontFilter::Deactivated(Default::default()),
1361 ..Default::default()
1362 };
1363 let text = SavedFilter::rule_from_filter(&f, None, |_| None).to_string();
1364 let day = |k: &str, today: bool, using: bool, range: &str, ty: &str, months: &str| {
1365 format!(r#""{k}":{{"today":{today},"yesterday":false,"last7day":false,"last30day":false,"last90day":false,"last365day":false,"usingRange":{using},{range}"type":"{ty}","selectedMonths":{{{months}}}}}"#)
1366 };
1367 for part in [
1368 r#""font":{"activated":false,"deactivated":true},"camera":{"X100V":true}"#.to_string(),
1369 day("import", true, true, r#""range":[10,20],"#, "range", ""),
1370 day("mtime", false, false, "", "undefined", r#""2024/03":true"#),
1371 r#""duration":{"min":1,"max":90,"unit":"m"}"#.to_string(),
1372 ] {
1373 assert!(text.contains(&part), "{part}\n{text}");
1374 }
1375 assert!(!text.contains("btime"), "no Date Created rule, no key");
1376 }
1377
1378 #[test]
1379 fn reads_eagles_date_duration_camera_and_font_rules() {
1380 use crate::search::{DateFilter, DatePreset, FontFilter};
1381 let rule = DEFAULT_RULE
1384 .replace(r#""font":{"activated":false,"deactivated":false}"#, r#""font":{"activated":true,"deactivated":false}"#)
1385 .replace(r#""camera":{}"#, r#""camera":{"ILCE-7M3":true,"DMC-GH5":true}"#)
1386 .replace(
1387 r#""import":{"today":false,"yesterday":false,"last7day":false,"last30day":false,"last90day":false,"last365day":false,"usingRange":false,"type":"undefined","selectedMonths":{}}"#,
1388 r#""import":{"today":false,"yesterday":false,"last7day":true,"last30day":false,"last90day":false,"last365day":false,"usingRange":false,"type":"range","selectedMonths":{"2023/12":true},"model":"2024-01-01 to 2024-01-31","range":[1704067200000,1706659200000]}"#,
1389 )
1390 .replace(r#""mtime":{"today":false"#, r#""mtime":{"today":true"#)
1391 .replace(r#""usingRange":false,"type":"undefined","selectedMonths":{}},"rating""#, r#""usingRange":true,"type":"undefined","selectedMonths":{}},"rating""#)
1392 .replace(r#""duration":{"unit":"s"}"#, r#""duration":{"unit":"m","min":"1.5","max":"10"}"#);
1393 assert_eq!(rule.matches("ILCE").count() + rule.matches("last7day\":true").count() + rule.matches("\"today\":true").count() + rule.matches("\"usingRange\":true").count() + rule.matches("\"min\":\"1.5\"").count() + rule.matches("\"activated\":true").count(), 6, "{rule}");
1394 let q = saved(serde_json::from_str(&rule).unwrap()).to_filter_with_fonts(|| ["A_.otf".to_string()].into());
1395 let f = &q.filter;
1396 assert_eq!(q.ai, SavedAiRules::default());
1397 assert_eq!(f.imported, DateFilter { presets: [DatePreset::Last7Days].into(), months: [(2023, 12)].into(), ..Default::default() });
1398 assert_eq!(f.modified, DateFilter { presets: [DatePreset::Today].into(), range: Some((1_704_067_200_000, 1_706_659_200_000)), ..Default::default() });
1399 assert_eq!(f.created, DateFilter::default());
1400 assert_eq!(f.duration, (Some(90.0), Some(600.0)));
1401 assert_eq!(f.cameras, ["ILCE-7M3".to_string(), "DMC-GH5".to_string()].into());
1402 assert_eq!(f.font, FontFilter::Activated(["A_.otf".to_string()].into()));
1403
1404 let off = saved(serde_json::from_str(DEFAULT_RULE).unwrap()).to_filter_with_fonts(|| panic!("font keys read for an inactive rule"));
1405 assert_eq!(off.filter, Default::default());
1406 }
1407
1408 #[test]
1409 fn filters_survive_a_save() {
1410 use crate::search::{ColorQuery, Filter, SetFilter, Shape, TextFilter};
1411 let mut f = Filter {
1412 tags: SetFilter { includes: vec!["a".into()], excludes: vec!["b".into()], ..Default::default() },
1413 folders: SetFilter { includes: vec!["F1".into()], empty: true, ..Default::default() },
1414 types: vec!["jpg".into()],
1415 exclude_types: vec!["font".into()],
1416 size: (Some(2048), Some(1_048_576)),
1417 width: (Some(10), None),
1418 height: (None, Some(20)),
1419 url: TextFilter::Has,
1420 note: TextFilter::Keywords(vec!["x".into()]),
1421 color: Some(ColorQuery { rgb: [1, 2, 3], accuracy: 20.0 }),
1422 grayscale: true,
1423 bpm: (Some(90.0), Some(128.5)),
1424 ..Default::default()
1425 };
1426 f.ratings.insert(5);
1427 f.shapes.insert(Shape::PanoramicPortrait);
1428 let rule = SavedFilter::rule_from_filter(&f, Some("cat"), |id| (id == "F1").then(|| "One".to_string()));
1429 let text = rule.to_string();
1430 assert!(text.contains(r#""color":{"gray":true,"value":[1,2,3],"accuracy":20}"#), "{text}");
1431 assert!(text.contains(r#""file":{"min":2,"max":1024,"unit":"kb"}"#), "{text}");
1432 assert!(text.contains(r#""folder":{"includes":{"F1":{"id":"F1","name":"One"},"NoFolders":{"id":"NoFolders"}}"#), "{text}");
1433 assert!(text.ends_with(r#""keyword":"cat"}"#));
1434 assert!(text.contains(r#""bpm":{"min":90,"max":128.5}"#), "{text}");
1435 let back = SavedFilter { name: "n".into(), rule, extra: Extra::new() }.to_filter();
1436 assert_eq!(back.filter, f);
1437 assert_eq!(back.keyword.as_deref(), Some("cat"));
1438 assert_eq!(back.ai, SavedAiRules::default());
1439 }
1440
1441 #[test]
1442 fn comments_round_trip_and_keep_their_place() {
1443 let image = r#"{"id":"M1","x":12,"y":30.5,"width":200,"height":80.25,"annotation":"look<br>here","lastModified":1793005755402}"#;
1444 let video = r#"{"id":"M2","duration":3.733,"annotation":"cut","lastModified":1793005755403,"extra":true}"#;
1445 for json in [image, video] {
1446 let c: Comment = serde_json::from_str(json).unwrap();
1447 assert_eq!(serde_json::to_string(&c).unwrap(), json);
1448 }
1449 let base = r#"{"id":"X","name":"n","size":1,"btime":0,"mtime":0,"ext":"png","tags":[],"folders":[],"isDeleted":false,"url":"","annotation":"","modificationTime":1,"height":1,"width":1,"lastModified":1,"palettes":[]"#;
1450 let text = format!(r#"{base},"comments":[{image}],"star":3}}"#);
1451 let mut item = Item::from_json_str(&text).unwrap();
1452 let mut list = item.comments();
1453 assert_eq!(list[0].width, Some(200.into()));
1454 item.set_comments(&list);
1455 assert_eq!(serde_json::to_string(&item).unwrap(), text, "untouched comments round-trip in place");
1456 list.push(Comment::at_time(1.5, "x"));
1457 item.set_comments(&list);
1458 assert!(serde_json::to_string(&item).unwrap().ends_with(r#""duration":1.5,"annotation":"x"}],"star":3}"#));
1459
1460 let mut bare = Item::from_json_str(&format!("{base}}}")).unwrap();
1461 assert!(bare.comments().is_empty());
1462 bare.set_comments(&[Comment::region(1, 2, 30, 40, "")]);
1463 assert!(serde_json::to_string(&bare).unwrap().ends_with(r#""palettes":[],"comments":[{"id":"","x":1,"y":2,"width":30,"height":40,"annotation":""}]}"#));
1464 }
1465
1466 #[test]
1467 fn tag_groups_round_trip() {
1468 for json in [
1469 r#"{"id":"LZ1Q2W3E4R5TY","name":"Mood","tags":["calm","loud"]}"#,
1470 r#"{"id":"LZ1Q2W3E4R5TY","name":"Mood","tags":[],"color":"aqua","description":""}"#,
1471 r#"{"id":"LZ1Q2W3E4R5TY","name":"Mood","tags":[],"description":"x","later":1}"#,
1472 ] {
1473 let g: TagGroup = serde_json::from_str(json).unwrap();
1474 assert_eq!(serde_json::to_string(&g).unwrap(), json);
1475 }
1476 }
1477}