Skip to main content

falcon_library/
smart_draft.rs

1//! The smart folder dialog's editable form of `conditions`, kept free of
2//! any UI so it can be tested on its own.
3//!
4//! [`drafts`] turns stored conditions into [`ConditionDraft`]s and
5//! [`conditions`] turns them back. Whatever the user didn't change comes
6//! back byte for byte: an untouched condition or rule is the stored JSON
7//! itself, and an edited one is the stored object with only the changed
8//! keys replaced, so key order and keys falcon doesn't know survive. A rule
9//! the dialog can't show (an unknown property, or a method its property
10//! doesn't have) is kept verbatim as [`RuleDraft::Unsupported`].
11
12use chrono::{Local, NaiveDate, TimeZone};
13use serde_json::{Map, Value, json};
14
15use crate::search;
16
17/// How a property's value is entered and stored.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Kind {
20    /// A string.
21    Text,
22    /// `[a, b]`, `b` only used by `between`.
23    Number,
24    /// `[a, b]` with a `unit` of `kb` or `mb`.
25    Size,
26    /// `[a, b]` with a `unit` of `s`, `m` or `h`.
27    Duration,
28    /// `[ms]`, `[ms, ms]` for `between`, `[days]` for `within`.
29    Date,
30    /// A list of tag names.
31    Tags,
32    /// A list of folder ids.
33    Folders,
34    /// An extension or type group.
35    Type,
36    /// `"1"`..`"5"` or `"none"`; stars as text for `contain`.
37    Rating,
38    /// A shape name; `custom` adds `width` and `height`.
39    Shape,
40    /// `#RRGGBB`.
41    Color,
42    /// No value (`fontActivated`).
43    Font,
44}
45
46/// A property the dialog offers.
47#[derive(Debug, Clone, Copy)]
48pub struct Property {
49    pub key: &'static str,
50    pub label: &'static str,
51    pub kind: Kind,
52    /// Eagle's method names, the first being the default.
53    pub methods: &'static [&'static str],
54}
55
56const TEXT: &[&str] = &["contain", "uncontain", "startWith", "endWith", "equal", "empty", "not-empty", "regex"];
57const NUMBER: &[&str] = &[">", ">=", "=", "<", "<=", "between"];
58const DATE: &[&str] = &["before", "after", "between", "on", "within"];
59const SET: &[&str] = &["intersection", "union", "equal", "identity", "empty", "not-empty"];
60const IS: &[&str] = &["equal", "unequal"];
61
62const fn p(key: &'static str, label: &'static str, kind: Kind, methods: &'static [&'static str]) -> Property {
63    Property { key, label, kind, methods }
64}
65
66/// Every property the evaluator knows, in the dialog's order.
67pub const PROPERTIES: &[Property] = &[
68    p("name", "Name", Kind::Text, TEXT),
69    p("type", "Type", Kind::Type, IS),
70    p("tags", "Tags", Kind::Tags, SET),
71    p("folders", "Folders", Kind::Folders, SET),
72    p("folderName", "Folder name", Kind::Text, TEXT),
73    p("color", "Colour", Kind::Color, &["similar", "accuracy", "grayscale"]),
74    p("createTime", "Date added", Kind::Date, DATE),
75    p("mtime", "Date modified", Kind::Date, DATE),
76    p("btime", "Date created", Kind::Date, DATE),
77    p("url", "URL", Kind::Text, TEXT),
78    p("annotation", "Notes", Kind::Text, TEXT),
79    p("width", "Width", Kind::Number, NUMBER),
80    p("height", "Height", Kind::Number, NUMBER),
81    p("fileSize", "File size", Kind::Size, NUMBER),
82    p("shape", "Shape", Kind::Shape, IS),
83    p("rating", "Rating", Kind::Rating, &["equal", "unequal", "contain"]),
84    p("comments", "Comments", Kind::Text, TEXT),
85    p("duration", "Duration", Kind::Duration, NUMBER),
86    p("bpm", "BPM", Kind::Number, NUMBER),
87    p("fontActivated", "Font", Kind::Font, &["activate", "deactivate"]),
88    p("camera", "Camera", Kind::Text, TEXT),
89    p("iso", "ISO", Kind::Number, NUMBER),
90    p("aperture", "Aperture (f/)", Kind::Number, NUMBER),
91    p("focalLength", "Focal length (mm)", Kind::Number, NUMBER),
92    p("shutter", "Shutter (1/x s)", Kind::Number, NUMBER),
93    p("timestamp", "Date taken", Kind::Date, DATE),
94];
95
96/// Shape values, with falcon's labels.
97pub const SHAPES: &[(&str, &str)] = &[
98    ("landscape", "Landscape"),
99    ("portrait", "Portrait"),
100    ("square", "Square"),
101    ("panoramic-landscape", "Wide landscape"),
102    ("panoramic-portrait", "Tall portrait"),
103    ("custom", "Ratio W:H"),
104];
105
106/// Rating values for `equal` and `unequal`.
107pub const RATINGS: &[(&str, &str)] = &[("none", "Unrated"), ("1", "1 star"), ("2", "2 stars"), ("3", "3 stars"), ("4", "4 stars"), ("5", "5 stars")];
108
109pub fn property(key: &str) -> Option<&'static Property> {
110    PROPERTIES.iter().find(|p| p.key == key)
111}
112
113/// Units of a property with one, the first being the default when a rule
114/// has none (what Eagle's evaluator assumes).
115pub fn units(kind: Kind) -> &'static [&'static str] {
116    match kind {
117        Kind::Size => &["mb", "kb"],
118        Kind::Duration => &["s", "m", "h"],
119        _ => &[],
120    }
121}
122
123/// Falcon's wording for a method of a property kind.
124pub fn method_label(kind: Kind, method: &str) -> &'static str {
125    match (kind, method) {
126        (_, "contain") if kind == Kind::Rating => "is one of",
127        (_, "contain") => "contains",
128        (_, "uncontain") => "doesn't contain",
129        (_, "startWith") => "starts with",
130        (_, "endWith") => "ends with",
131        (Kind::Tags | Kind::Folders, "equal") => "are exactly",
132        (_, "equal") => "is",
133        (_, "unequal") => "is not",
134        (_, "empty") => "is empty",
135        (_, "not-empty") => "isn't empty",
136        (_, "regex") => "matches regex",
137        (_, ">") => ">",
138        (_, ">=") => "≥",
139        (_, "=") => "=",
140        (_, "<") => "<",
141        (_, "<=") => "≤",
142        (_, "between") => "between",
143        (_, "before") => "before",
144        (_, "after") => "after",
145        (_, "on") => "on",
146        (_, "within") => "in the last",
147        (_, "intersection") => "include all of",
148        (_, "union") => "include any of",
149        (_, "identity") => "include none of",
150        (_, "similar") => "similar to",
151        (_, "accuracy") => "very close to",
152        (_, "grayscale") => "is greyscale",
153        (_, "activate") => "is installed",
154        (_, "deactivate") => "isn't installed",
155        _ => "?",
156    }
157}
158
159/// Whether a method takes no value.
160pub fn no_value(kind: Kind, method: &str) -> bool {
161    kind == Kind::Font || matches!(method, "empty" | "not-empty" | "grayscale")
162}
163
164/// A rule's editable fields, all as text.
165#[derive(Debug, Clone, PartialEq)]
166pub struct RuleFields {
167    pub property: &'static str,
168    pub method: &'static str,
169    /// The value, or the first number, date (`YYYY-MM-DD`, local) or day
170    /// count. Lists are comma-separated.
171    pub value: String,
172    /// The second number or date, for `between`.
173    pub value2: String,
174    /// `""` for a property without units.
175    pub unit: &'static str,
176    /// The custom shape's ratio.
177    pub width: String,
178    pub height: String,
179}
180
181impl Default for RuleFields {
182    /// Eagle's new rule: name contains nothing yet.
183    fn default() -> Self {
184        RuleFields::new("name")
185    }
186}
187
188impl RuleFields {
189    /// A fresh rule on `property` with its default method and value.
190    pub fn new(property: &'static str) -> RuleFields {
191        let prop = self::property(property).unwrap_or(&PROPERTIES[0]);
192        let value = match prop.kind {
193            Kind::Shape => "landscape",
194            Kind::Rating => "5",
195            Kind::Color => "#0087EF",
196            _ => "",
197        };
198        RuleFields {
199            property: prop.key,
200            method: prop.methods[0],
201            value: value.into(),
202            value2: String::new(),
203            unit: units(prop.kind).first().copied().unwrap_or(""),
204            width: String::new(),
205            height: String::new(),
206        }
207    }
208
209    pub fn kind(&self) -> Kind {
210        property(self.property).map_or(Kind::Text, |p| p.kind)
211    }
212
213    /// Switches to another property, resetting the method and value as
214    /// Eagle's dialog does.
215    pub fn set_property(&mut self, property: &'static str) {
216        if self.property != property {
217            *self = RuleFields::new(property);
218        }
219    }
220
221    /// Switches the method. A date rule moving to or from `within` swaps a
222    /// date for a day count, so its value starts over (30 days, as in
223    /// Eagle); other values carry over.
224    pub fn set_method(&mut self, method: &'static str) {
225        if self.kind() == Kind::Date && (method == "within") != (self.method == "within") {
226            self.value = if method == "within" { "30".into() } else { String::new() };
227            self.value2.clear();
228        }
229        self.method = method;
230    }
231
232    /// Reads a stored rule, or `None` when the dialog can't show it.
233    fn from_json(r: &Value) -> Option<RuleFields> {
234        let prop = property(r.get("property")?.as_str()?)?;
235        let m = r.get("method")?.as_str()?;
236        let method = *prop.methods.iter().find(|x| **x == m)?;
237        let v = r.get("value").unwrap_or(&Value::Null);
238        let item = |i: usize| v.as_array().and_then(|a| a.get(i));
239        let num_at = |i: usize| item(i).map(num_text);
240        let text = |v: &Value| match v {
241            Value::String(s) => Some(s.clone()),
242            Value::Null => Some(String::new()),
243            _ => None,
244        };
245        let (mut value, mut value2) = (String::new(), String::new());
246        match prop.kind {
247            Kind::Number | Kind::Size | Kind::Duration => {
248                if !v.is_array() {
249                    return None;
250                }
251                value = num_at(0).unwrap_or_default();
252                value2 = num_at(1).unwrap_or_default();
253            }
254            Kind::Date => match v {
255                Value::Array(_) if method == "within" => value = num_at(0).unwrap_or_default(),
256                Value::Array(_) => {
257                    value = item(0).map(date_text).unwrap_or_default();
258                    value2 = item(1).map(date_text).unwrap_or_default();
259                }
260                // Eagle's dialog leaves "" while a range is being picked.
261                Value::String(s) if s.is_empty() => {}
262                Value::Null => {}
263                _ => return None,
264            },
265            Kind::Tags | Kind::Folders => match v {
266                Value::Array(a) => {
267                    let names: Option<Vec<&str>> = a.iter().map(Value::as_str).collect();
268                    value = names?.join(", ");
269                }
270                Value::Null => {}
271                _ => return None,
272            },
273            Kind::Rating if method == "contain" && v.is_array() => {
274                let stars: Option<Vec<String>> = v.as_array()?.iter().map(|x| x.as_str().map(str::to_string).or_else(|| x.as_u64().map(|n| n.to_string()))).collect();
275                value = stars?.concat();
276            }
277            Kind::Rating => value = text(v).or_else(|| v.as_u64().map(|n| n.to_string()))?,
278            Kind::Font => {}
279            Kind::Text | Kind::Type | Kind::Shape | Kind::Color => value = text(v)?,
280        }
281        let unit = match r.get("unit").and_then(Value::as_str) {
282            Some(u) => units(prop.kind).iter().find(|x| **x == u).copied(),
283            None => units(prop.kind).first().copied(),
284        };
285        // A unit falcon can't show (Eagle reads it as MB or seconds) would be
286        // rewritten on edit, so keep such a rule as it is.
287        let unit = if units(prop.kind).is_empty() { "" } else { unit? };
288        let dim = |k: &str| r.get(k).map(num_text).unwrap_or_default();
289        Some(RuleFields { property: prop.key, method, value, value2, unit, width: dim("width"), height: dim("height") })
290    }
291
292    /// The rule's `value` in Eagle's form.
293    fn value_json(&self) -> Result<Value, String> {
294        let label = property(self.property).map_or(self.property, |p| p.label);
295        let number = |s: &str| -> Result<Value, String> { parse_number(s).map(js_number).ok_or_else(|| format!("{label} needs a number")) };
296        let date = |s: &str| -> Result<Value, String> { parse_date(s).map(|ms| json!(ms)).ok_or_else(|| format!("{label} needs a date as YYYY-MM-DD")) };
297        let list = || json!(self.value.split(',').map(str::trim).filter(|x| !x.is_empty()).collect::<Vec<_>>());
298        Ok(match self.kind() {
299            Kind::Number | Kind::Size | Kind::Duration => {
300                let b = if self.method == "between" || !self.value2.trim().is_empty() { number(&self.value2)? } else { json!(0) };
301                json!([number(&self.value)?, b])
302            }
303            Kind::Date => match self.method {
304                "within" => json!([number(&self.value)?]),
305                "between" => json!([date(&self.value)?, date(&self.value2)?]),
306                _ => json!([date(&self.value)?]),
307            },
308            Kind::Tags | Kind::Folders => list(),
309            Kind::Type => json!(self.value.trim()),
310            Kind::Color if self.method != "grayscale" && !is_hex(self.value.trim()) => return Err("Colour needs a value like #FF0000".into()),
311            Kind::Color => json!(self.value.trim()),
312            Kind::Text | Kind::Rating | Kind::Shape | Kind::Font => json!(self.value),
313        })
314    }
315
316    /// Which reading of the value text a method uses, so a method change that
317    /// alters it rebuilds the value.
318    fn form(&self) -> u8 {
319        match (self.kind(), self.method) {
320            (Kind::Date, "within") => 1,
321            (Kind::Date, "between") => 2,
322            (Kind::Rating, "contain") => 1,
323            _ => 0,
324        }
325    }
326
327    /// Writes the fields into `rule`, touching only what differs from
328    /// `before` (all of it for a new rule).
329    fn write(&self, rule: &mut Map<String, Value>, before: Option<&RuleFields>) -> Result<(), String> {
330        let same_prop = before.is_some_and(|b| b.property == self.property);
331        set(rule, "property", json!(self.property), before.is_none_or(|b| b.property != self.property));
332        set(rule, "method", json!(self.method), before.is_none_or(|b| b.method != self.method));
333        let value_changed = !same_prop || before.is_some_and(|b| b.value != self.value || b.value2 != self.value2 || b.form() != self.form());
334        if self.kind() == Kind::Font {
335            // No value; a new rule carries Eagle's empty one.
336            if !rule.contains_key("value") {
337                rule.insert("value".into(), json!(""));
338            }
339        } else if value_changed || before.is_none() {
340            rule.insert("value".into(), self.value_json()?);
341        }
342        if !self.unit.is_empty() {
343            set(rule, "unit", json!(self.unit), !same_prop || before.is_some_and(|b| b.unit != self.unit));
344        }
345        if self.kind() == Kind::Shape && self.value == "custom" {
346            let ratio = |s: &str| parse_number(s).filter(|n| *n > 0.0).map(js_number).ok_or_else(|| "Ratio W:H needs two numbers above 0".to_string());
347            let (w, h) = (ratio(&self.width)?, ratio(&self.height)?);
348            let moved = !same_prop || before.is_some_and(|b| b.width != self.width || b.height != self.height || b.value != self.value);
349            set(rule, "width", w, moved);
350            set(rule, "height", h, moved);
351        }
352        Ok(())
353    }
354}
355
356/// Sets `key` when `when` holds or the key is missing; an existing key keeps
357/// its place.
358fn set(m: &mut Map<String, Value>, key: &str, v: Value, when: bool) {
359    if when || !m.contains_key(key) {
360        m.insert(key.into(), v);
361    }
362}
363
364/// One rule row in the dialog.
365#[derive(Debug, Clone, PartialEq)]
366pub enum RuleDraft {
367    /// A rule the dialog edits. `stored` holds the rule as read, with its
368    /// fields then, for writing back only what changed.
369    Edit { fields: RuleFields, stored: Option<Box<(RuleFields, Value)>> },
370    /// A rule the dialog can't show, kept exactly as stored.
371    Unsupported(Value),
372}
373
374impl Default for RuleDraft {
375    fn default() -> Self {
376        RuleDraft::Edit { fields: RuleFields::default(), stored: None }
377    }
378}
379
380impl RuleDraft {
381    pub fn from_json(r: &Value) -> RuleDraft {
382        match RuleFields::from_json(r) {
383            Some(f) => RuleDraft::Edit { fields: f.clone(), stored: Some(Box::new((f, r.clone()))) },
384            None => RuleDraft::Unsupported(r.clone()),
385        }
386    }
387
388    pub fn to_json(&self) -> Result<Value, String> {
389        match self {
390            RuleDraft::Unsupported(v) => Ok(v.clone()),
391            RuleDraft::Edit { fields, stored: Some(s) } if s.0 == *fields => Ok(s.1.clone()),
392            RuleDraft::Edit { fields, stored } => {
393                let (mut rule, before) = match stored.as_deref() {
394                    Some((b, Value::Object(m))) => (m.clone(), Some(b)),
395                    _ => (Map::new(), None),
396                };
397                fields.write(&mut rule, before)?;
398                Ok(Value::Object(rule))
399            }
400        }
401    }
402}
403
404/// One condition in the dialog: its rules combined by `any` (Eagle's
405/// `match: "OR"`, else `"AND"`), the result inverted by `negate` (Eagle's
406/// `boolean: "FALSE"`).
407#[derive(Debug, Clone, PartialEq)]
408pub struct ConditionDraft {
409    pub any: bool,
410    pub negate: bool,
411    pub rules: Vec<RuleDraft>,
412    stored: Option<Value>,
413}
414
415impl Default for ConditionDraft {
416    /// Eagle's new condition: any of one empty name rule, not inverted.
417    fn default() -> Self {
418        ConditionDraft { any: true, negate: false, rules: vec![RuleDraft::default()], stored: None }
419    }
420}
421
422impl ConditionDraft {
423    pub fn from_json(c: &Value) -> ConditionDraft {
424        ConditionDraft {
425            any: c.get("match").and_then(Value::as_str) == Some("OR"),
426            negate: c.get("boolean").and_then(Value::as_str) == Some("FALSE"),
427            rules: c.get("rules").and_then(Value::as_array).map(|r| r.iter().map(RuleDraft::from_json).collect()).unwrap_or_default(),
428            stored: Some(c.clone()),
429        }
430    }
431
432    /// The condition in Eagle's form. A new one is `{rules, match, boolean}`
433    /// as Eagle's dialog builds it; a stored one changes only the keys that
434    /// differ.
435    pub fn to_json(&self) -> Result<Value, String> {
436        let list: Vec<Value> = self.rules.iter().map(RuleDraft::to_json).collect::<Result<_, _>>()?;
437        let mode = json!(if self.any { "OR" } else { "AND" });
438        let boolean = json!(if self.negate { "FALSE" } else { "TRUE" });
439        let Some(Value::Object(stored)) = &self.stored else {
440            return Ok(json!({"rules": list, "match": mode, "boolean": boolean}));
441        };
442        let mut c = stored.clone();
443        let was = ConditionDraft::from_json(&self.stored.clone().unwrap_or_default());
444        // A missing or unreadable `rules` stays as it is while still empty.
445        let same = match c.get("rules") {
446            Some(Value::Array(a)) => *a == list,
447            _ => list.is_empty(),
448        };
449        if !same {
450            c.insert("rules".into(), Value::Array(list));
451        }
452        if was.any != self.any {
453            c.insert("match".into(), mode);
454        }
455        if was.negate != self.negate {
456            c.insert("boolean".into(), boolean);
457        }
458        Ok(Value::Object(c))
459    }
460}
461
462/// The dialog's form of a smart folder's `conditions`.
463pub fn drafts(conditions: &[Value]) -> Vec<ConditionDraft> {
464    conditions.iter().map(ConditionDraft::from_json).collect()
465}
466
467/// `conditions` back from the dialog, or the first value that can't be
468/// stored (as a message for the user).
469pub fn conditions(drafts: &[ConditionDraft]) -> Result<Vec<Value>, String> {
470    drafts.iter().map(ConditionDraft::to_json).collect()
471}
472
473/// A number as JavaScript would write it: whole numbers without `.0`.
474fn js_number(n: f64) -> Value {
475    if n.fract() == 0.0 && n.abs() < 9_007_199_254_740_992.0 { json!(n as i64) } else { json!(n) }
476}
477
478fn num_text(v: &Value) -> String {
479    match v {
480        Value::Number(n) => n.to_string(),
481        Value::String(s) => s.clone(),
482        _ => String::new(),
483    }
484}
485
486fn parse_number(s: &str) -> Option<f64> {
487    s.trim().parse::<f64>().ok().filter(|n| n.is_finite())
488}
489
490fn is_hex(s: &str) -> bool {
491    s.strip_prefix('#').is_some_and(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))
492}
493
494/// A stored ms timestamp as a local `YYYY-MM-DD`.
495fn date_text(v: &Value) -> String {
496    v.as_f64()
497        .and_then(|ms| Local.timestamp_millis_opt(ms as i64).single())
498        .map(|d| d.format("%Y-%m-%d").to_string())
499        .unwrap_or_default()
500}
501
502/// A local `YYYY-MM-DD` as the ms of its midnight, as Eagle's date picker
503/// stores a day.
504fn parse_date(s: &str) -> Option<u64> {
505    let d = NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok()?;
506    use chrono::Datelike;
507    search::local_day_start(d.year(), d.month(), d.day())
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    fn round_trip(conditions: &Value) -> Value {
515        let list = conditions.as_array().unwrap();
516        Value::Array(super::conditions(&drafts(list)).unwrap())
517    }
518
519    #[test]
520    fn untouched_conditions_come_back_verbatim() {
521        let c = json!([
522            {"match": "AND", "rules": [{"property": "name", "method": "contain", "value": "x", "extra": 1}]},
523            {"boolean": "FALSE", "match": "OR", "note": "kept", "rules": [
524                {"property": "mystery", "method": "equal", "value": {"a": 1}},
525                {"property": "fileSize", "method": ">", "value": [1.50, 0], "unit": "kb"},
526                {"property": "width", "method": "teleport", "value": [1, 2]},
527                {"property": "createTime", "method": "between", "value": [1_700_000_123_456_u64, 1_700_100_000_000_u64]}
528            ]},
529            {"rules": []},
530            {"match": "OR"}
531        ]);
532        assert_eq!(round_trip(&c), c);
533        let d = drafts(c.as_array().unwrap());
534        assert!(!d[0].any && d[1].any && d[1].negate && !d[0].negate);
535        assert!(matches!(d[1].rules[0], RuleDraft::Unsupported(_)), "unknown property");
536        assert!(matches!(d[1].rules[2], RuleDraft::Unsupported(_)), "unknown method");
537        assert!(matches!(&d[1].rules[1], RuleDraft::Edit { fields, .. } if fields.unit == "kb" && fields.value == "1.5"));
538    }
539
540    #[test]
541    fn edits_touch_only_what_changed() {
542        let c = json!([{"note": 1, "match": "AND", "rules": [
543            {"x": true, "property": "createTime", "method": "on", "value": [1_700_000_123_456_u64]},
544            {"property": "width", "method": ">", "value": [480, 7]}
545        ]}]);
546        let mut d = drafts(c.as_array().unwrap());
547        d[0].negate = true;
548        let RuleDraft::Edit { fields, .. } = &mut d[0].rules[0] else { panic!() };
549        fields.method = "before"; // same date text: the stored ms stays
550        let RuleDraft::Edit { fields, .. } = &mut d[0].rules[1] else { panic!() };
551        fields.value = "640".into();
552        let out = Value::Array(conditions(&d).unwrap());
553        assert_eq!(
554            out,
555            json!([{"note": 1, "match": "AND", "rules": [
556                {"x": true, "property": "createTime", "method": "before", "value": [1_700_000_123_456_u64]},
557                {"property": "width", "method": ">", "value": [640, 7]}
558            ], "boolean": "FALSE"}])
559        );
560        // Switching on → within reads the text as days, so it must be redone.
561        let mut d = drafts(c.as_array().unwrap());
562        let RuleDraft::Edit { fields, .. } = &mut d[0].rules[0] else { panic!() };
563        fields.method = "within";
564        assert!(conditions(&d).is_err());
565        let RuleDraft::Edit { fields, .. } = &mut d[0].rules[0] else { panic!() };
566        fields.value = "7".into();
567        assert_eq!(conditions(&d).unwrap()[0]["rules"][0]["value"], json!([7]));
568        // The dialog's method switch starts the day count at 30.
569        let mut d = drafts(c.as_array().unwrap());
570        let RuleDraft::Edit { fields, .. } = &mut d[0].rules[0] else { panic!() };
571        fields.set_method("within");
572        assert_eq!(conditions(&d).unwrap()[0]["rules"][0]["value"], json!([30]));
573        let RuleDraft::Edit { fields, .. } = &mut d[0].rules[0] else { panic!() };
574        fields.set_method("after");
575        assert!(conditions(&d).is_err(), "back to a date, which must be entered");
576    }
577
578    #[test]
579    fn new_conditions_and_rules_take_eagles_shape() {
580        let mut c = ConditionDraft { any: false, ..Default::default() };
581        c.rules.push(RuleDraft::Edit { fields: RuleFields { value: "2".into(), ..RuleFields::new("fileSize") }, stored: None });
582        c.rules.push(RuleDraft::Edit { fields: RuleFields { value: "1.5".into(), unit: "m", ..RuleFields::new("duration") }, stored: None });
583        c.rules.push(RuleDraft::Edit { fields: RuleFields { value: "custom".into(), width: "16".into(), height: "9".into(), ..RuleFields::new("shape") }, stored: None });
584        c.rules.push(RuleDraft::Edit { fields: RuleFields::new("fontActivated"), stored: None });
585        c.rules.push(RuleDraft::Edit { fields: RuleFields { value: "a, b ,".into(), ..RuleFields::new("tags") }, stored: None });
586        c.rules.push(RuleDraft::Edit { fields: RuleFields { method: "between", value: "2024-03-10".into(), value2: "2024-03-12".into(), ..RuleFields::new("timestamp") }, stored: None });
587        let text = serde_json::to_string(&c.to_json().unwrap()).unwrap();
588        let day = |d: u32| search::local_day_start(2024, 3, d).unwrap();
589        assert_eq!(
590            text,
591            format!(
592                concat!(
593                    r#"{{"rules":[{{"property":"name","method":"contain","value":""}},"#,
594                    r#"{{"property":"fileSize","method":">","value":[2,0],"unit":"mb"}},"#,
595                    r#"{{"property":"duration","method":">","value":[1.5,0],"unit":"m"}},"#,
596                    r#"{{"property":"shape","method":"equal","value":"custom","width":16,"height":9}},"#,
597                    r#"{{"property":"fontActivated","method":"activate","value":""}},"#,
598                    r#"{{"property":"tags","method":"intersection","value":["a","b"]}},"#,
599                    r#"{{"property":"timestamp","method":"between","value":[{},{}]}}],"match":"AND","boolean":"TRUE"}}"#
600                ),
601                day(10),
602                day(12)
603            )
604        );
605    }
606
607    /// A smart folder made in Eagle, with two conditions, a NOT flag, a rule
608    /// falcon can't show and keys it doesn't know, survives the dialog and
609    /// `update_smart_folder` byte for byte apart from what was edited.
610    #[test]
611    fn eagle_smart_folder_survives_an_edit() {
612        let tmp = tempfile::tempdir().unwrap();
613        let mut lib = crate::Library::create(tmp.path().join("S.library")).unwrap();
614        let stored = json!([
615            {"rules": [
616                {"property": "name", "method": "regex", "value": "^ref", "$$extra": "x"},
617                {"property": "someFutureProperty", "method": "near", "value": [1, {"deep": true}]}
618            ], "match": "OR", "boolean": "TRUE"},
619            {"rules": [
620                {"property": "fileSize", "method": "between", "value": [0.5, 2.25], "unit": "kb"},
621                {"property": "tags", "method": "union", "value": ["draft", "wip"]}
622            ], "match": "AND", "boolean": "FALSE", "future": 3}
623        ]);
624        let id = lib.create_smart_folder("Refs", stored.as_array().unwrap().clone()).unwrap();
625        let file = lib.root().join("metadata.json");
626        // Everything but the modification times, which move on every save.
627        let strip = |s: &str| {
628            let mut v: Value = serde_json::from_str(s).unwrap();
629            v.as_object_mut().unwrap().shift_remove("modificationTime");
630            for f in v["smartFolders"].as_array_mut().unwrap() {
631                f.as_object_mut().unwrap().shift_remove("modificationTime");
632            }
633            serde_json::to_string(&v).unwrap()
634        };
635        let before = std::fs::read_to_string(&file).unwrap();
636        let load = |lib: &crate::Library| drafts(&lib.smart_folders()[0].conditions);
637
638        // Saving untouched writes the same bytes.
639        lib.update_smart_folder(&id, "Refs", conditions(&load(&lib)).unwrap()).unwrap();
640        assert_eq!(strip(&std::fs::read_to_string(&file).unwrap()), strip(&before));
641
642        // Editing one tag list and the NOT flag changes only those.
643        let mut d = load(&lib);
644        assert!(matches!(d[0].rules[1], RuleDraft::Unsupported(_)) && d[1].negate && !d[0].negate);
645        d[1].negate = false;
646        let RuleDraft::Edit { fields, .. } = &mut d[1].rules[1] else { panic!() };
647        fields.value.push_str(", todo");
648        lib.update_smart_folder(&id, "Refs", conditions(&d).unwrap()).unwrap();
649        let want = before.replace(r#"["draft","wip"]"#, r#"["draft","wip","todo"]"#).replace(r#""boolean":"FALSE""#, r#""boolean":"TRUE""#);
650        assert_ne!(want, before);
651        assert_eq!(strip(&std::fs::read_to_string(&file).unwrap()), strip(&want));
652
653        // Deleting the unsupported rule is the only way it goes.
654        let mut d = load(&lib);
655        d[0].rules.remove(1);
656        lib.update_smart_folder(&id, "Refs", conditions(&d).unwrap()).unwrap();
657        assert_eq!(lib.smart_folders()[0].conditions[0]["rules"], json!([{"property": "name", "method": "regex", "value": "^ref", "$$extra": "x"}]));
658    }
659
660    #[test]
661    fn bad_values_are_refused() {
662        let bad = |f: RuleFields| RuleDraft::Edit { fields: f, stored: None }.to_json().is_err();
663        assert!(bad(RuleFields { value: "abc".into(), ..RuleFields::new("width") }));
664        assert!(bad(RuleFields { method: "between", value: "1".into(), ..RuleFields::new("height") }));
665        assert!(bad(RuleFields { value: "red".into(), ..RuleFields::new("color") }));
666        assert!(bad(RuleFields { value: "10/3/2024".into(), ..RuleFields::new("btime") }));
667        assert!(bad(RuleFields { value: "custom".into(), width: "0".into(), height: "1".into(), ..RuleFields::new("shape") }));
668        assert!(!bad(RuleFields::new("name")), "an empty text value is stored, as in Eagle");
669        assert!(!bad(RuleFields { method: "grayscale", value: String::new(), ..RuleFields::new("color") }));
670    }
671
672    #[test]
673    fn every_property_reads_back_its_own_output() {
674        for p in PROPERTIES {
675            for m in p.methods {
676                let mut f = RuleFields { method: m, ..RuleFields::new(p.key) };
677                match (p.kind, *m) {
678                    (Kind::Number | Kind::Size | Kind::Duration, _) => (f.value, f.value2) = ("3".into(), "4".into()),
679                    (Kind::Date, "within") => f.value = "7".into(),
680                    (Kind::Date, "between") => (f.value, f.value2) = ("2024-01-02".into(), "2024-01-05".into()),
681                    (Kind::Date, _) => f.value = "2024-01-02".into(),
682                    (Kind::Tags | Kind::Folders, _) => f.value = "a, b".into(),
683                    _ => {}
684                }
685                let v = RuleDraft::Edit { fields: f.clone(), stored: None }.to_json().unwrap();
686                let RuleDraft::Edit { fields: back, .. } = RuleDraft::from_json(&v) else { panic!("{v}") };
687                assert_eq!(back, f, "{v}");
688            }
689        }
690    }
691}