Skip to main content

falcon_library/
model.rs

1//! On-disk types for an Eagle library. Field order mirrors Eagle's own key
2//! order so that compact serialization is byte-identical. Unknown keys are
3//! kept in `extra` and written after the known ones.
4
5use serde::ser::SerializeMap;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use serde_json::{Map, Number, Value};
8
9/// Unknown JSON keys, preserved verbatim.
10pub type Extra = Map<String, Value>;
11
12/// Top-level `metadata.json`: the folder tree and library-wide settings.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct LibraryMetadata {
16    /// Root folders.
17    pub folders: Vec<Folder>,
18    /// Smart folders (shape not yet observed; kept as raw JSON).
19    pub smart_folders: Vec<Value>,
20    /// Quick-access entries (raw JSON).
21    pub quick_access: Vec<Value>,
22    /// Tag groups, in sidebar order.
23    pub tags_groups: Vec<TagGroup>,
24    /// Last modification time, ms since epoch.
25    pub modification_time: u64,
26    /// Version of Eagle that wrote the library.
27    pub application_version: String,
28    /// Unknown keys.
29    #[serde(flatten)]
30    pub extra: Extra,
31}
32
33/// A node in the folder tree.
34///
35/// Eagle writes the keys in this order: `id`, `name`, `description`,
36/// `children`, `modificationTime`, `tags`, `extendTags`, `icon`, `iconColor`,
37/// `pinyin`, `password`, `passwordTips`, `coverId`, then `orderBy` and
38/// `sortIncrease` when the folder has its own sort. Optional keys are left
39/// out when unset, as `JSON.stringify` drops `undefined`.
40#[derive(Debug, Clone, PartialEq, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct Folder {
43    /// Folder id.
44    pub id: String,
45    /// Display name.
46    pub name: String,
47    /// Free-form description.
48    pub description: String,
49    /// Child folders.
50    pub children: Vec<Folder>,
51    /// Creation time, ms since epoch. Eagle sets it when the folder is
52    /// created or duplicated and leaves it alone on later edits.
53    pub modification_time: u64,
54    /// Tags attached to the folder.
55    pub tags: Vec<String>,
56    /// The folder's tags plus its ancestors'. A runtime cache Eagle's writer
57    /// strips; kept only so a file holding it round-trips.
58    pub extend_tags: Option<Vec<String>>,
59    /// Icon name from [`FOLDER_ICONS`]; absent for the default folder icon.
60    pub icon: Option<String>,
61    /// Icon colour from [`ICON_COLORS`]; absent for the default colour.
62    pub icon_color: Option<String>,
63    /// Romanised name for search. Also a runtime cache Eagle's writer strips.
64    pub pinyin: Option<String>,
65    /// Base64 of the password's Latin-1 bytes, or empty when unset. See
66    /// [`Folder::is_locked`].
67    pub password: String,
68    /// Password hint (empty when unset).
69    pub password_tips: String,
70    /// Id of the item shown as the folder's cover.
71    pub cover_id: Option<String>,
72    /// The folder's own sort key (see [`FolderOrder`]); absent to follow the
73    /// global sort.
74    pub order_by: Option<String>,
75    /// Ascending when true; written alongside `orderBy`.
76    pub sort_increase: Option<bool>,
77    /// Unknown keys.
78    #[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    /// A new, empty folder in the shape Eagle writes.
123    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    /// Visits this folder and all descendants, depth first.
145    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    /// Whether the folder has a password.
153    pub fn is_locked(&self) -> bool {
154        !self.password.is_empty()
155    }
156
157    /// The folder's own sort, if it has one Eagle knows.
158    pub fn order(&self) -> Option<FolderOrder> {
159        self.order_by.as_deref().and_then(FolderOrder::parse)
160    }
161}
162
163/// Icon colours Eagle offers for folders, smart folders and tag groups, in
164/// menu order. No colour key means the default (grey).
165pub const ICON_COLORS: [&str; 8] = ["red", "orange", "yellow", "green", "aqua", "blue", "purple", "pink"];
166
167/// Icon names Eagle's folder and smart-folder icon picker offers, in picker
168/// order (spellings as Eagle stores them). No `icon` key means the default
169/// folder icon.
170pub 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/// A tag group from `tagsGroups` in the library `metadata.json`. Eagle
189/// writes `id`, `name`, `tags`, then `color` when set and `description` when
190/// defined.
191#[derive(Debug, Clone, PartialEq, Deserialize)]
192pub struct TagGroup {
193    /// Group id, in the same format as item ids.
194    pub id: String,
195    /// Display name.
196    pub name: String,
197    /// Member tags, in the group's order.
198    pub tags: Vec<String>,
199    /// Colour from [`ICON_COLORS`].
200    pub color: Option<String>,
201    /// Description (may be present and empty).
202    pub description: Option<String>,
203    /// Unknown keys.
204    #[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/// A folder's or smart folder's own sort key, stored as `orderBy`.
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum FolderOrder {
230    /// One of the keys the main list also sorts by.
231    By(crate::search::OrderBy),
232    /// Video and audio length.
233    Duration,
234    /// Shuffled on every visit.
235    Random,
236    /// Hand-arranged order, kept per folder in each item's `order` map.
237    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    /// Eagle's string for this key.
252    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    /// Parses Eagle's string; `None` for anything unknown.
271    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/// One colour of an item's palette.
292#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
293#[serde(rename_all = "camelCase")]
294pub struct Palette {
295    /// RGB triple.
296    pub color: [u8; 3],
297    /// Share of the image; Eagle writes integers or decimals, so the exact
298    /// JSON number is kept.
299    pub ratio: Number,
300    /// Unknown keys.
301    #[serde(flatten)]
302    pub extra: Extra,
303}
304
305/// An item's `images/<ID>.info/metadata.json`.
306#[derive(Debug, Clone, PartialEq, Deserialize)]
307#[serde(rename_all = "camelCase", from = "ItemRepr")]
308pub struct Item {
309    /// Item id.
310    pub id: String,
311    /// Name without extension.
312    pub name: String,
313    /// Original file size in bytes.
314    pub size: u64,
315    /// Creation time, ms since epoch.
316    pub btime: u64,
317    /// File modification time, ms since epoch.
318    pub mtime: u64,
319    /// File extension without the dot.
320    pub ext: String,
321    /// Tags.
322    pub tags: Vec<String>,
323    /// Ids of the folders containing the item.
324    pub folders: Vec<String>,
325    /// Whether the item is in the trash.
326    pub is_deleted: bool,
327    /// Source URL.
328    pub url: String,
329    /// Annotation text.
330    pub annotation: String,
331    /// Last modification time of the record, ms since epoch.
332    pub modification_time: u64,
333    /// Present only on some items.
334    pub remove_thumbnail: Option<bool>,
335    /// Present only on some items.
336    pub no_thumbnail: Option<bool>,
337    /// Pixel height.
338    pub height: u64,
339    /// Pixel width.
340    pub width: u64,
341    /// Last modification time of the content, ms since epoch.
342    pub last_modified: u64,
343    /// Dominant colours.
344    pub palettes: Vec<Palette>,
345    /// The keys of the `metadata.json` this item was read from, in file
346    /// order (empty for new items). Writing follows this order, and a field
347    /// the file didn't have is left out while it still holds its default,
348    /// so items round-trip byte for byte. Eagle leaves out `width`,
349    /// `height` and `palettes` for files it can't preview, and writes keys
350    /// such as `noPreview` and `deletedTime` mid-record.
351    pub key_order: KeyOrder,
352    /// Unknown keys.
353    pub extra: Extra,
354}
355
356/// An item's original key order. Only formatting, so it never makes two
357/// items unequal.
358#[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/// Deserialization shape for [`Item`]; `key_order` is filled in afterwards.
381/// Every field but `id` may be missing.
382#[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/// One entry of an item's `comments`: a region note on an image or a
450/// timestamped note on a video or audio file. Eagle writes `id`, then `x`,
451/// `y`, `width`, `height` (image) or `duration` (video), then `annotation`
452/// and `lastModified`.
453#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
454#[serde(rename_all = "camelCase")]
455pub struct Comment {
456    /// Comment id, in the item id format.
457    #[serde(default)]
458    pub id: String,
459    /// Region left edge in the original image's pixels (may be fractional
460    /// after a drag).
461    pub x: Option<Number>,
462    /// Region top edge in original-image pixels.
463    pub y: Option<Number>,
464    /// Region width in original-image pixels.
465    pub width: Option<Number>,
466    /// Region height in original-image pixels.
467    pub height: Option<Number>,
468    /// Playback position in seconds, for video and audio notes.
469    pub duration: Option<Number>,
470    /// The note as HTML from Eagle's editable box (`<br>` for line breaks).
471    #[serde(default)]
472    pub annotation: String,
473    /// Creation time, ms since epoch. Eagle does not bump it on edits.
474    pub last_modified: Option<u64>,
475    /// Unknown keys.
476    #[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    /// A region note on an image, in original-image pixels.
502    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    /// A note at `seconds` into a video or audio file.
507    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    /// The item's comments (`comments` in `extra`), empty when it has none.
514    /// Entries that are not objects are skipped.
515    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    /// Replaces the item's comments. The `comments` key keeps its place in
521    /// `extra`, or is added at the end, as Eagle adds it.
522    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    /// Parses an item's `metadata.json`, recording its key order.
533    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    /// The fields to write, in Eagle's order for new items: known fields,
542    /// then unknown keys. Known fields the source file lacked are dropped
543    /// while they hold their default.
544    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            // Keys from the file first, in file order; anything new after.
579            let rank = |k: &str| self.key_order.iter().position(|x| x == k).unwrap_or(usize::MAX);
580            out.sort_by_key(|(k, _)| rank(k)); // stable: new keys keep their order
581        }
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
596/// Field-to-JSON conversion used by [`Item::entries`].
597mod 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/// One entry of `saved-filters.json`: a name and a snapshot of the filter
609/// panel's rules. Entries have no id; Eagle finds them by position.
610#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
611pub struct SavedFilter {
612    /// Display name.
613    pub name: String,
614    /// The panel's rule object at save time (see [`SavedFilter::to_filter`]),
615    /// plus `keyword` when a search was active. Kept as raw JSON so every
616    /// rule Eagle knows survives.
617    pub rule: Value,
618    /// Unknown keys.
619    #[serde(flatten)]
620    pub extra: Extra,
621}
622
623/// A saved filter read into falcon's search types.
624#[derive(Debug, Clone, Default, PartialEq)]
625pub struct SavedFilterQuery {
626    /// The rules falcon's filter can express.
627    pub filter: crate::search::Filter,
628    /// The search text saved with the filter.
629    pub keyword: Option<String>,
630    /// The AI rules, which AI Search applies rather than the filter.
631    pub ai: SavedAiRules,
632}
633
634/// A saved filter's `image` and `semantic` rules, each kept as the whole
635/// value it was read as, so that fields falcon doesn't know come back
636/// unchanged when the filter is saved again.
637///
638/// What is known of them is the field that turns each on: `image.itemId`
639/// (read as the id of the item to look like) and `semantic.value` (read as
640/// the description). Both readings are falcon's, from the names; no file
641/// written by Eagle with these rules has been seen. A value that isn't a
642/// usable string is still kept, and counts as a rule that can't be read.
643#[derive(Debug, Clone, Default, PartialEq)]
644pub struct SavedAiRules {
645    pub image: Option<Value>,
646    pub semantic: Option<Value>,
647}
648
649impl SavedAiRules {
650    /// Reads the rules from a rule object. An empty object is no rule.
651    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    /// The rules for a search by description.
657    pub fn for_text(text: &str) -> SavedAiRules {
658        SavedAiRules { semantic: Some(serde_json::json!({"value": text})), ..Default::default() }
659    }
660
661    /// The rules for a search by an item in the library.
662    pub fn for_item(id: &str) -> SavedAiRules {
663        SavedAiRules { image: Some(serde_json::json!({"itemId": id})), ..Default::default() }
664    }
665
666    /// A rule's turning-on field: not null, and not a blank string.
667    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    /// The item to look like, when the rule is on and can be read.
684    pub fn image_item(&self) -> Option<&str> {
685        Self::on(&self.image, "itemId")?.as_str()
686    }
687
688    /// The description to search by, when the rule is on and can be read.
689    pub fn semantic_text(&self) -> Option<&str> {
690        Self::on(&self.semantic, "value")?.as_str()
691    }
692
693    /// A rule is on but its value can't be searched by.
694    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    /// Puts the rules in a rule object, in the places their keys hold. Keys
699    /// with no rule are left as they are.
700    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    /// Reads the saved rules into a [`crate::search::Filter`]. Eagle does not
712    /// save whether tags and folders combine with OR, AND or EQUAL (that
713    /// setting lives in the panel), so they come back as OR, the panel's
714    /// starting value. A font rule reads the activated fonts from Eagle's
715    /// font folder and falcon's ([`crate::search::font_dirs`]).
716    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    /// [`SavedFilter::to_filter`] with the activated font keys supplied;
722    /// `font_keys` is only called when the font rule is on.
723    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        // `$.isNumeric` accepts numbers and numeric strings.
735        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        // Dates. Eagle's Date Modified range reads `import.range` (its panel
807        // has one picker for both); falcon writes `mtime.range` only when
808        // the two ranges differ. `btime` (Date Created) is falcon's own key.
809        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                // Eagle passes nothing through a range that lacks an end,
818                // which `DateFilter` does for a zero end.
819                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        // Eagle multiplies each bound by the unit, then truncates (as the
831        // filter does).
832        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        // Eagle tests `activated` first.
841        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    /// Builds the rule object Eagle's panel would save for `filter`: its
853    /// full default rule set (as `JSON.stringify` writes it, integer-like
854    /// keys first) with falcon's rules filled in. `folder_name` looks up a
855    /// folder's name; Eagle stores whole folder objects under
856    /// `folder.includes`, of which it only reads `id` back, so falcon writes
857    /// `{id, name}`. The tag and folder logic is not part of the rule, and
858    /// the AI rules are added with [`SavedAiRules::write_to`].
859    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        // Sizes go in KB, which Eagle multiplies back by 1024.
915        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        // Dates: presets, then the range in the place Eagle's defaults give
948        // it, then months.
949        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        // Eagle's panel stores one picked range, in `import`, and its
968        // Date Modified rule reads it from there.
969        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        // Each bound in the largest unit that keeps it whole.
978        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
1018/// The date presets under their rule keys, in Eagle's order.
1019const 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
1024/// A `selectedMonths` key, `YYYY/MM`.
1025fn 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
1032/// A number as JavaScript prints it: whole values without a `.0`.
1033fn 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
1041/// Eagle's default filter rules after `JSON.parse(JSON.stringify(..))`:
1042/// `undefined` values gone and integer-like keys first.
1043fn 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/// `tags.json`.
1079#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1080#[serde(rename_all = "camelCase")]
1081pub struct Tags {
1082    /// Recently used tags.
1083    pub history_tags: Vec<String>,
1084    /// Starred tags.
1085    pub starred_tags: Vec<String>,
1086    /// Unknown keys.
1087    #[serde(flatten)]
1088    pub extra: Extra,
1089}
1090
1091/// `mtime.json`: item id to modification time, followed by an `"all"` count.
1092#[derive(Debug, Clone, Default, PartialEq)]
1093pub struct MtimeIndex {
1094    /// `(item id, mtime)` in file order.
1095    pub entries: Vec<(String, u64)>,
1096    /// The trailing `"all"` count, if present.
1097    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    // Records shaped like real ones from users' libraries.
1136    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    /// Eagle's default rules as its save writes them.
1199    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    /// The default rule with these objects as its AI rules.
1252    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        // On, but not something that can be searched by.
1269        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    /// Reading the rules and writing them into a fresh rule gives back the
1275    /// same bytes, whatever else the objects hold and whether or not they
1276    /// are on, and an empty filter's rule stays Eagle's default.
1277    #[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    /// Filter -> rule -> Filter for every rule `Filter` has, one at a time.
1307    #[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        // As Eagle saves them: text-field strings, the shared picked range in
1382        // `import` (with `model`), and a Date Modified rule using it.
1383        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}