Skip to main content

falcon_library/
manual.rs

1//! Hand-arranged ("manual") item order inside a folder.
2//!
3//! Eagle keeps the order in each item's `order` map: `order[<folder id>]` is
4//! a decimal string. The folder's `MANUAL` sort compares these strings as
5//! strings, largest first, and an item without an entry sorts by its
6//! `modificationTime` (as a string) instead. Dragging items writes new
7//! values halfway between the neighbours they land between, computed in
8//! exact decimal arithmetic (division rounded to 20 places) and written in
9//! the shortest form, as a big-number library's `toPrecision()` does.
10
11use std::cmp::Ordering;
12
13use num_bigint::BigInt;
14use serde_json::Value;
15
16use crate::error::Result;
17use crate::library::Library;
18use crate::model::{FolderOrder, Item};
19
20/// Decimal places kept by a division, as Eagle's big-number library does.
21const DIV_PLACES: u32 = 20;
22
23/// An exact decimal: `m / 10^s`, kept without trailing zeros.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub(crate) struct Dec {
26    m: BigInt,
27    s: u32,
28}
29
30fn pow10(n: u32) -> BigInt {
31    BigInt::from(10u8).pow(n)
32}
33
34impl Dec {
35    fn new(m: BigInt, s: u32) -> Dec {
36        let mut d = Dec { m, s };
37        let ten = BigInt::from(10u8);
38        let zero = BigInt::default();
39        while d.s > 0 && &d.m % &ten == zero {
40            d.m /= &ten;
41            d.s -= 1;
42        }
43        if d.m == zero {
44            d.s = 0;
45        }
46        d
47    }
48
49    pub(crate) fn from_int(v: i64) -> Dec {
50        Dec::new(BigInt::from(v), 0)
51    }
52
53    /// Parses `-1.5`, `.5`, `12`, `3e-7` and the like.
54    pub(crate) fn parse(text: &str) -> Option<Dec> {
55        let t = text.trim();
56        let (neg, t) = match t.strip_prefix('-') {
57            Some(rest) => (true, rest),
58            None => (false, t.strip_prefix('+').unwrap_or(t)),
59        };
60        let (num, exp) = match t.find(['e', 'E']) {
61            Some(i) => (&t[..i], t[i + 1..].parse::<i64>().ok()?),
62            None => (t, 0),
63        };
64        let (int, frac) = num.split_once('.').unwrap_or((num, ""));
65        if int.is_empty() && frac.is_empty() || !int.bytes().chain(frac.bytes()).all(|b| b.is_ascii_digit()) {
66            return None;
67        }
68        let digits = format!("{int}{frac}");
69        let mut m: BigInt = digits.parse().ok()?;
70        if neg {
71            m = -m;
72        }
73        let scale = frac.len() as i64 - exp;
74        if scale >= 0 {
75            Some(Dec::new(m, u32::try_from(scale).ok()?))
76        } else {
77            Some(Dec::new(m * pow10(u32::try_from(-scale).ok()?), 0))
78        }
79    }
80
81    /// A JavaScript number as `new BigNumber(n)` reads it: through its
82    /// shortest decimal form, float noise included.
83    pub(crate) fn from_js_number(v: f64) -> Option<Dec> {
84        v.is_finite().then(|| Dec::parse(&format!("{v}"))).flatten()
85    }
86
87    fn aligned(&self, o: &Dec) -> (BigInt, BigInt, u32) {
88        let s = self.s.max(o.s);
89        (&self.m * pow10(s - self.s), &o.m * pow10(s - o.s), s)
90    }
91
92    pub(crate) fn plus(&self, o: &Dec) -> Dec {
93        let (a, b, s) = self.aligned(o);
94        Dec::new(a + b, s)
95    }
96
97    pub(crate) fn minus(&self, o: &Dec) -> Dec {
98        let (a, b, s) = self.aligned(o);
99        Dec::new(a - b, s)
100    }
101
102    pub(crate) fn times_int(&self, k: i64) -> Dec {
103        Dec::new(&self.m * BigInt::from(k), self.s)
104    }
105
106    /// Division by a positive integer, rounded to 20 places, halves away
107    /// from zero.
108    pub(crate) fn div_int(&self, n: u64) -> Dec {
109        let num = &self.m * pow10(DIV_PLACES.saturating_sub(self.s));
110        let den = BigInt::from(n) * pow10(self.s.saturating_sub(DIV_PLACES));
111        let neg = num < BigInt::default();
112        let abs = if neg { -num } else { num };
113        let (q, r) = (&abs / &den, &abs % &den);
114        let q = if r * 2u8 >= den { q + 1u8 } else { q };
115        Dec::new(if neg { -q } else { q }, DIV_PLACES)
116    }
117
118    pub(crate) fn is_zero(&self) -> bool {
119        self.m == BigInt::default()
120    }
121
122    /// The shortest string, in exponent form when the leading digit sits at
123    /// 10^-7 or below or 10^21 or above (the library's defaults).
124    pub(crate) fn to_precision(&self) -> String {
125        if self.is_zero() {
126            return "0".into();
127        }
128        let neg = self.m < BigInt::default();
129        let digits = self.m.magnitude().to_string();
130        let e = digits.len() as i64 - 1 - i64::from(self.s);
131        let body = if e <= -7 || e >= 21 {
132            let (first, rest) = digits.split_at(1);
133            let rest = rest.trim_end_matches('0');
134            let dot = if rest.is_empty() { String::new() } else { format!(".{rest}") };
135            format!("{first}{dot}e{}{}", if e < 0 { "-" } else { "+" }, e.abs())
136        } else if self.s == 0 {
137            digits
138        } else if digits.len() > self.s as usize {
139            let (int, frac) = digits.split_at(digits.len() - self.s as usize);
140            format!("{int}.{frac}")
141        } else {
142            format!("0.{}{digits}", "0".repeat(self.s as usize - digits.len()))
143        };
144        if neg { format!("-{body}") } else { body }
145    }
146}
147
148/// An `order` entry as the text Eagle's sort compares: strings as they are,
149/// numbers in JavaScript's shortest form. Empty, zero and non-scalar
150/// entries count as missing, as they are falsy in JavaScript.
151fn entry_text(v: &Value) -> Option<String> {
152    match v {
153        Value::String(s) if !s.is_empty() => Some(s.clone()),
154        Value::Number(n) if n.as_f64() != Some(0.0) => Some(match n.as_f64() {
155            Some(f) if n.is_f64() => format!("{f}"),
156            _ => n.to_string(),
157        }),
158        _ => None,
159    }
160}
161
162impl Item {
163    /// The item's raw `order[folder]` text, if it has a usable one.
164    pub fn manual_order_text(&self, folder: &str) -> Option<String> {
165        self.extra.get("order").and_then(|o| o.get(folder)).and_then(entry_text)
166    }
167
168    /// The item's manual position value in `folder` (`order[folder]`), as a
169    /// number; `None` when it has no entry. Larger values come first in an
170    /// ascending manual sort.
171    pub fn manual_order(&self, folder: &str) -> Option<f64> {
172        self.manual_order_text(folder).and_then(|t| t.trim().parse::<f64>().ok())
173    }
174
175    /// The text Eagle's `MANUAL` sort compares for this item: its entry, or
176    /// `modificationTime` when it has none.
177    pub fn manual_sort_key(&self, folder: &str) -> String {
178        self.manual_order_text(folder).unwrap_or_else(|| self.modification_time.to_string())
179    }
180
181    /// Sets `order[folder]`. A new `order` map goes at the end of the
182    /// record, a new folder key at the end of the map; existing keys keep
183    /// their place.
184    pub fn set_manual_order(&mut self, folder: &str, value: &str) {
185        let slot = self.extra.entry("order").or_insert_with(|| Value::Object(Default::default()));
186        if !slot.is_object() {
187            *slot = Value::Object(Default::default());
188        }
189        if let Value::Object(m) = slot {
190            m.insert(folder.to_string(), Value::String(value.to_string()));
191        }
192    }
193
194    /// The decimal this item stands at in `folder` for drag arithmetic.
195    fn manual_dec(&self, folder: &str) -> Dec {
196        self.manual_order_text(folder).and_then(|t| Dec::parse(&t)).unwrap_or_else(|| Dec::from_int(self.modification_time as i64))
197    }
198}
199
200/// Eagle's `MANUAL` comparison: the sort keys compared as strings, larger
201/// first. Reverse the result for a descending folder.
202pub fn manual_cmp(a: &Item, b: &Item, folder: &str) -> Ordering {
203    b.manual_sort_key(folder).cmp(&a.manual_sort_key(folder))
204}
205
206impl Library {
207    /// Moves `moved` items, as one block in their current manual order, to
208    /// just before `before` (or to the end with `None`) in `folder`'s manual
209    /// order, as dragging in Eagle's grid does. New `order` values sit
210    /// between the neighbours; if two other items share a value, all values
211    /// are first nudged apart. Changed items are saved with
212    /// [`Library::update_item`], and a folder not yet sorted by hand is
213    /// switched to an ascending manual sort.
214    ///
215    /// The order is worked out over all untrashed items in the folder (Eagle
216    /// uses the items currently shown). Moving into an otherwise empty
217    /// folder changes nothing, as Eagle has no neighbour to place against.
218    pub fn move_items_manually(&mut self, folder: &str, moved: &[String], before: Option<&str>) -> Result<()> {
219        let Some(f) = self.all_folders().into_iter().find(|f| f.id == folder) else {
220            return Err(self.invalid(format!("no folder {folder}")));
221        };
222        let is_manual = f.order() == Some(FolderOrder::Manual);
223        let increase = if is_manual { f.sort_increase.unwrap_or(true) } else { true };
224        for id in moved {
225            match self.item(id) {
226                Some(i) if i.folders.iter().any(|x| x == folder) && !i.is_deleted => {}
227                _ => return Err(self.invalid(format!("item {id} is not in folder {folder}"))),
228            }
229        }
230        if before.is_some_and(|b| moved.iter().any(|m| m == b)) {
231            return Err(self.invalid("cannot move items before one of themselves"));
232        }
233
234        let mut view: Vec<&Item> = self.items().iter().filter(|i| !i.is_deleted && i.folders.iter().any(|x| x == folder)).collect();
235        crate::search::sort_manual(&mut view, folder, increase);
236        let (selected, rest): (Vec<&Item>, Vec<&Item>) = view.into_iter().partition(|i| moved.contains(&i.id));
237        let mut selected: Vec<Item> = selected.into_iter().cloned().collect();
238        let mut rest: Vec<Item> = rest.into_iter().cloned().collect();
239        let at = match before {
240            Some(b) => Some(rest.iter().position(|i| i.id == b).ok_or_else(|| self.invalid(format!("item {b} is not in folder {folder}")))?),
241            None => None,
242        };
243        if selected.is_empty() || rest.is_empty() {
244            return Ok(());
245        }
246
247        // Two items with the same value: nudge every value apart first.
248        let mut changed: Vec<Item> = Vec::new();
249        let mut seen = std::collections::HashSet::new();
250        if rest.iter().filter_map(|i| i.manual_order_text(folder)).any(|t| !seen.insert(t)) {
251            let precision = rest.len().to_string().len() as i32 + 1;
252            let step = 1.0 / 10f64.powi(precision);
253            for (i, item) in rest.iter_mut().enumerate() {
254                let Some(old) = item.manual_order_text(folder).and_then(|t| Dec::parse(&t)) else { continue };
255                // Eagle reads its direction flag before setting it here, so
256                // values always step down.
257                let Some(delta) = Dec::from_js_number(step * i as f64) else { continue };
258                item.set_manual_order(folder, &old.minus(&delta).to_precision());
259                changed.push(item.clone());
260            }
261        }
262
263        let one = Dec::from_int(1);
264        let n = selected.len() as u64;
265        let (middle, step) = match at {
266            Some(0) => {
267                let target = rest[0].manual_dec(folder);
268                let prev = if increase { target.plus(&one) } else { target.minus(&one) };
269                let middle = prev.plus(&target).div_int(2);
270                let step = middle.minus(&target).div_int(n);
271                (middle, step)
272            }
273            Some(k) => {
274                let (prev, target) = (rest[k - 1].manual_dec(folder), rest[k].manual_dec(folder));
275                let middle = prev.plus(&target).div_int(2);
276                let step = middle.minus(&target).div_int(n);
277                (middle, step)
278            }
279            None => {
280                let prev = rest[rest.len() - 1].manual_dec(folder);
281                let target = if increase { prev.minus(&one) } else { prev.plus(&one) };
282                let middle = target.plus(&prev).div_int(2);
283                let step = middle.minus(&target).div_int(n);
284                (middle, step)
285            }
286        };
287        if !step.is_zero() {
288            for (index, item) in selected.iter_mut().rev().enumerate() {
289                item.set_manual_order(folder, &middle.plus(&step.times_int(index as i64)).to_precision());
290            }
291            changed.extend(selected);
292        }
293        for item in changed {
294            self.update_item(item)?;
295        }
296        if !is_manual {
297            self.set_folder_order(folder, Some(FolderOrder::Manual), true)?;
298        }
299        Ok(())
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn d(s: &str) -> Dec {
308        Dec::parse(s).unwrap()
309    }
310
311    #[test]
312    fn decimals_print_like_to_precision() {
313        assert_eq!(d("1789479235636").to_precision(), "1789479235636");
314        assert_eq!(d("1789479235636.50").to_precision(), "1789479235636.5");
315        assert_eq!(d("-0.25").to_precision(), "-0.25");
316        assert_eq!(d("0.0000001").to_precision(), "1e-7");
317        assert_eq!(d("0.0000012").to_precision(), "0.0000012");
318        assert_eq!(d("1.5e-8").to_precision(), "1.5e-8");
319        assert_eq!(d("123e19").to_precision(), "1.23e+21");
320        assert_eq!(d("0").to_precision(), "0");
321        assert_eq!(Dec::parse("abc"), None);
322        assert_eq!(Dec::parse(""), None);
323    }
324
325    #[test]
326    fn division_keeps_twenty_places_rounding_half_away() {
327        assert_eq!(d("1").div_int(3).to_precision(), "0.33333333333333333333");
328        assert_eq!(d("2").div_int(3).to_precision(), "0.66666666666666666667");
329        assert_eq!(d("-2").div_int(3).to_precision(), "-0.66666666666666666667");
330        assert_eq!(d("3579000000001").div_int(2).to_precision(), "1789500000000.5");
331    }
332
333    #[test]
334    fn js_numbers_bring_their_float_noise() {
335        assert_eq!(Dec::from_js_number(0.01 * 57.0).unwrap().to_precision(), "0.5700000000000001");
336        assert_eq!(Dec::from_js_number(0.01 * 3.0).unwrap().to_precision(), "0.03");
337    }
338
339    fn record(id: &str, mtime: u64, extra: &str) -> Item {
340        Item::from_json_str(&format!(
341            r#"{{"id":"{id}","name":"{id}","size":1,"btime":1,"mtime":1,"ext":"tex","tags":[],"folders":["F"],"isDeleted":false,"url":"","annotation":"","modificationTime":{mtime},"lastModified":1{extra}}}"#
342        ))
343        .unwrap()
344    }
345
346    /// A library with folder `F` holding the given 0×0 records.
347    fn library(dir: &std::path::Path, items: Vec<Item>) -> (Library, String) {
348        let mut lib = Library::create(dir.join("T.library")).unwrap();
349        let f = lib.create_folder(None, "F").unwrap();
350        for mut item in items {
351            item.folders = vec![f.clone()];
352            std::fs::create_dir_all(item.info_dir(lib.root())).unwrap();
353            lib.register_item(item).unwrap();
354        }
355        (lib, f)
356    }
357
358    fn view(lib: &Library, f: &str) -> Vec<String> {
359        let mut v: Vec<&Item> = lib.items().iter().filter(|i| i.folders.iter().any(|x| x == f)).collect();
360        crate::search::sort_manual(&mut v, f, true);
361        v.iter().map(|i| i.id.clone()).collect()
362    }
363
364    fn ids(list: &[&str]) -> Vec<String> {
365        list.iter().map(|s| s.to_string()).collect()
366    }
367
368    #[test]
369    fn manual_sort_compares_text_largest_first() {
370        let tmp = tempfile::tempdir().unwrap();
371        let mut b = record("B", 1000, "");
372        b.set_manual_order("X", "999");
373        let (lib, f) = library(tmp.path(), vec![record("A", 2000, ""), b]);
374        // B has no entry for F, so its modificationTime counts.
375        assert_eq!(view(&lib, &f), ids(&["A", "B"]));
376        let b = lib.item("B").unwrap();
377        assert_eq!((b.manual_order("X"), b.manual_order(&f)), (Some(999.0), None));
378        // As text, "999" sorts above "1000".
379        let mut x: Vec<&Item> = lib.items().iter().collect();
380        crate::search::sort_manual(&mut x, "X", true);
381        assert_eq!(x[0].id, "B");
382    }
383
384    #[test]
385    fn dropping_between_neighbours_takes_the_midpoint() {
386        let tmp = tempfile::tempdir().unwrap();
387        let (mut lib, f) = library(tmp.path(), vec![record("A", 1000, ""), record("B", 2000, ""), record("C", 3000, "")]);
388        assert_eq!(view(&lib, &f), ids(&["C", "B", "A"]));
389        lib.move_items_manually(&f, &ids(&["A"]), Some("B")).unwrap();
390        assert_eq!(lib.item("A").unwrap().manual_order_text(&f).as_deref(), Some("2500"));
391        assert_eq!(view(&lib, &f), ids(&["C", "A", "B"]));
392        let folder = lib.all_folders().into_iter().find(|x| x.id == f).unwrap();
393        assert_eq!((folder.order(), folder.sort_increase), (Some(FolderOrder::Manual), Some(true)));
394
395        // Two items to the front: above C by halves of the gap to C + 1.
396        lib.move_items_manually(&f, &ids(&["B", "A"]), Some("C")).unwrap();
397        assert_eq!(view(&lib, &f), ids(&["A", "B", "C"]));
398        assert_eq!(lib.item("B").unwrap().manual_order_text(&f).as_deref(), Some("3000.5"));
399        assert_eq!(lib.item("A").unwrap().manual_order_text(&f).as_deref(), Some("3000.75"));
400
401        // To the end: below the last item.
402        lib.move_items_manually(&f, &ids(&["A"]), None).unwrap();
403        assert_eq!(view(&lib, &f), ids(&["B", "C", "A"]));
404        assert_eq!(lib.item("A").unwrap().manual_order_text(&f).as_deref(), Some("2999.5"));
405
406        // The values land on disk, and the record round-trips.
407        let reopened = Library::open(lib.root()).unwrap();
408        assert_eq!(view(&reopened, &f), ids(&["B", "C", "A"]));
409        let text = std::fs::read_to_string(reopened.item("A").unwrap().info_dir(reopened.root()).join("metadata.json")).unwrap();
410        assert!(text.ends_with(&format!(r#","order":{{"{f}":"2999.5"}}}}"#)), "{text}");
411        assert!(!text.contains("width"), "0×0 records stay without dimensions: {text}");
412
413        assert!(lib.move_items_manually(&f, &ids(&["A"]), Some("A")).is_err());
414        assert!(lib.move_items_manually(&f, &ids(&["nope"]), None).is_err());
415    }
416
417    #[test]
418    fn three_way_split_rounds_to_twenty_places() {
419        let tmp = tempfile::tempdir().unwrap();
420        let (mut lib, f) = library(tmp.path(), vec![record("A", 1000, ""), record("B", 1001, ""), record("C", 3000, ""), record("D", 3001, ""), record("E", 3002, "")]);
421        // View: E D C B A. Put C, D, E between B and A.
422        lib.move_items_manually(&f, &ids(&["C", "D", "E"]), Some("A")).unwrap();
423        assert_eq!(view(&lib, &f), ids(&["B", "E", "D", "C", "A"]));
424        let text = |id: &str| lib.item(id).unwrap().manual_order_text(&f).unwrap();
425        assert_eq!(text("C"), "1000.5");
426        assert_eq!(text("D"), "1000.66666666666666666667");
427        assert_eq!(text("E"), "1000.83333333333333333334");
428    }
429
430    #[test]
431    fn duplicate_values_are_nudged_apart_first() {
432        let tmp = tempfile::tempdir().unwrap();
433        let mut a = record("A", 1, "");
434        let mut b = record("B", 2, "");
435        a.set_manual_order("F0", "5000");
436        b.set_manual_order("F0", "5000");
437        let (mut lib, f) = library(tmp.path(), vec![a, b, record("C", 9000, ""), record("D", 1000, "")]);
438        for id in ["A", "B"] {
439            let mut item = lib.item(id).unwrap().clone();
440            item.extra.remove("order");
441            item.set_manual_order(&f, "5000");
442            lib.update_item(item).unwrap();
443        }
444        // View: C(9000) A B(5000, tie keeps id order) D(1000). Move D first.
445        assert_eq!(view(&lib, &f), ids(&["C", "A", "B", "D"]));
446        lib.move_items_manually(&f, &ids(&["D"]), Some("C")).unwrap();
447        // Rest is C, A, B; three items -> steps of 0.01 times the index.
448        let text = |id: &str| lib.item(id).unwrap().manual_order_text(&f).unwrap();
449        assert_eq!(text("A"), "4999.99");
450        assert_eq!(text("B"), "4999.98");
451        assert_eq!(text("D"), "9000.5");
452        assert_eq!(view(&lib, &f), ids(&["D", "C", "A", "B"]));
453    }
454
455    #[test]
456    fn descending_folders_place_against_the_other_side() {
457        let tmp = tempfile::tempdir().unwrap();
458        let (mut lib, f) = library(tmp.path(), vec![record("A", 1000, ""), record("B", 2000, "")]);
459        lib.set_folder_order(&f, Some(FolderOrder::Manual), false).unwrap();
460        // Shown ascending by value: A B. Move B in front of A.
461        lib.move_items_manually(&f, &ids(&["B"]), Some("A")).unwrap();
462        assert_eq!(lib.item("B").unwrap().manual_order_text(&f).as_deref(), Some("999.5"));
463        let folder = lib.all_folders().into_iter().find(|x| x.id == f).unwrap();
464        assert_eq!(folder.sort_increase, Some(false), "a manual folder keeps its direction");
465    }
466
467    #[test]
468    fn existing_order_maps_keep_their_place() {
469        let json = r#"{"id":"A","name":"a","size":1,"btime":1,"mtime":1,"ext":"md","tags":[],"folders":["F1"],"isDeleted":false,"url":"","annotation":"","modificationTime":3,"order":{"F1":"1789479235636.5"},"lastModified":4}"#;
470        let mut item = Item::from_json_str(json).unwrap();
471        assert_eq!(serde_json::to_string(&item).unwrap(), json);
472        assert_eq!(item.manual_order("F1"), Some(1789479235636.5));
473        item.set_manual_order("F1", "7");
474        item.set_manual_order("F2", "8");
475        let out = serde_json::to_string(&item).unwrap();
476        assert!(out.contains(r#""modificationTime":3,"order":{"F1":"7","F2":"8"},"lastModified":4}"#), "{out}");
477    }
478}