Skip to main content

falcon_library/
merge.rs

1//! Merging duplicate items, as Eagle's duplicate panel does: one item of
2//! each group is kept and takes the name, link, note, folders, tags and
3//! rating chosen for the group; the others go to the Trash. Nothing is
4//! deleted, and the kept item is saved like any other edit, so an open
5//! undo transaction can put everything back.
6
7use std::cmp::Ordering;
8use std::collections::HashSet;
9
10use crate::error::Result;
11use crate::library::Library;
12use crate::model::Item;
13
14/// Eagle cuts a joined note to this many characters.
15const MERGED_NOTE_LIMIT: usize = 4096;
16
17/// What a group's items offer, for the merge review: each distinct
18/// non-empty name, link and note in the group's order, every folder and
19/// tag, a note joining all notes when there are several, and the highest
20/// rating.
21#[derive(Debug, Clone, PartialEq, Default)]
22pub struct Choices {
23    pub names: Vec<String>,
24    pub urls: Vec<String>,
25    pub notes: Vec<String>,
26    pub folders: Vec<String>,
27    /// Sorted by name, ignoring case, as Eagle lists them.
28    pub tags: Vec<String>,
29    /// All notes joined by line breaks, cut to 4096 characters; only when
30    /// the items have more than one distinct note.
31    pub merged_note: Option<String>,
32    pub star: Option<u64>,
33}
34
35fn push_unique(list: &mut Vec<String>, s: &str) {
36    if !s.is_empty() && !list.iter().any(|x| x == s) {
37        list.push(s.to_string());
38    }
39}
40
41/// An item's rating (1 to 5), if it has one.
42fn star(item: &Item) -> Option<u64> {
43    item.extra.get("star").and_then(|v| v.as_u64()).filter(|&s| s > 0)
44}
45
46impl Choices {
47    pub fn of(items: &[&Item]) -> Choices {
48        let mut c = Choices::default();
49        for item in items {
50            push_unique(&mut c.names, &item.name);
51            push_unique(&mut c.urls, &item.url);
52            push_unique(&mut c.notes, &item.annotation);
53            for f in &item.folders {
54                push_unique(&mut c.folders, f);
55            }
56            for t in &item.tags {
57                push_unique(&mut c.tags, t);
58            }
59            c.star = c.star.max(star(item));
60        }
61        c.tags.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b)));
62        if c.notes.len() > 1 {
63            c.merged_note = Some(c.notes.join("\n").chars().take(MERGED_NOTE_LIMIT).collect());
64        }
65        c
66    }
67
68    /// Eagle's starting values: the first name, link and note, every
69    /// folder and tag, and the highest rating.
70    pub fn defaults(&self, keep: &str, trash: Vec<String>) -> Merge {
71        Merge {
72            keep: keep.to_string(),
73            trash,
74            name: self.names.first().cloned(),
75            url: self.urls.first().cloned().unwrap_or_default(),
76            note: self.notes.first().cloned().unwrap_or_default(),
77            folders: self.folders.clone(),
78            tags: self.tags.clone(),
79            star: self.star,
80        }
81    }
82}
83
84/// One group's merge: the item kept, the items trashed, and the values
85/// the kept item ends up with.
86#[derive(Debug, Clone, PartialEq)]
87pub struct Merge {
88    pub keep: String,
89    pub trash: Vec<String>,
90    /// The kept item's new name (`None` keeps its own).
91    pub name: Option<String>,
92    pub url: String,
93    pub note: String,
94    pub folders: Vec<String>,
95    pub tags: Vec<String>,
96    /// The rating; `None` leaves the kept item's own, as Eagle does.
97    pub star: Option<u64>,
98}
99
100/// What [`Library::merge_duplicates`] did.
101#[derive(Debug, Clone, Copy, PartialEq, Default)]
102pub struct MergeReport {
103    pub kept: usize,
104    pub trashed: usize,
105    /// Bytes of the trashed originals.
106    pub bytes: u64,
107}
108
109impl Library {
110    /// Applies merges: each kept item takes its merge's values, and the
111    /// others move to the Trash. Items that are gone, already trashed, or
112    /// kept by some merge are never trashed, so a mistake can't lose the
113    /// copy meant to stay.
114    pub fn merge_duplicates(&mut self, merges: &[Merge]) -> Result<MergeReport> {
115        let keeps: HashSet<&str> = merges.iter().map(|m| m.keep.as_str()).collect();
116        let mut report = MergeReport::default();
117        let mut trash = Vec::new();
118        for m in merges {
119            let Some(mut item) = self.item(&m.keep).cloned() else { continue };
120            if let Some(name) = m.name.as_ref().filter(|n| !n.trim().is_empty()) {
121                item.name = name.clone();
122            }
123            item.url = m.url.clone();
124            item.annotation = m.note.clone();
125            item.folders = m.folders.clone();
126            item.tags = m.tags.clone();
127            if let Some(s) = m.star {
128                item.extra.insert("star".into(), s.into());
129            }
130            // A kept item that was in the Trash comes back, as it does
131            // when Eagle saves it.
132            item.is_deleted = false;
133            self.update_item(item)?;
134            report.kept += 1;
135            for id in &m.trash {
136                if keeps.contains(id.as_str()) || trash.contains(id) {
137                    continue;
138                }
139                if let Some(it) = self.item(id).filter(|i| !i.is_deleted) {
140                    report.bytes += it.size;
141                    trash.push(id.clone());
142                }
143            }
144        }
145        report.trashed = trash.len();
146        self.set_trashed(&trash, true)?;
147        Ok(report)
148    }
149}
150
151/// Where Eagle ranks a format when picking the best of similar pictures;
152/// formats not listed come last.
153fn format_rank(ext: &str) -> usize {
154    const ORDER: [&str; 10] = ["png", "bmp", "jpg", "jpeg", "webp", "avif", "jxl", "heic", "heif", "jfif"];
155    ORDER.iter().position(|e| ext.eq_ignore_ascii_case(e)).unwrap_or(ORDER.len())
156}
157
158/// Orders similar pictures best first, as Eagle picks the copy to keep:
159/// the most pixels, then for the same format the bigger file, else the
160/// better format (PNG, BMP, JPG, JPEG, WebP, AVIF, JXL, HEIC, HEIF, JFIF).
161/// The sort is stable, so ties keep their order.
162pub fn best_first(items: &mut [&Item]) {
163    items.sort_by(|a, b| {
164        let area = |i: &Item| i.width.saturating_mul(i.height);
165        area(b).cmp(&area(a)).then_with(|| {
166            if a.ext.eq_ignore_ascii_case(&b.ext) {
167                b.size.cmp(&a.size)
168            } else {
169                match (format_rank(&a.ext), format_rank(&b.ext)) {
170                    (x, y) if x == y => Ordering::Equal,
171                    (x, y) => x.cmp(&y),
172                }
173            }
174        })
175    });
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use serde_json::json;
182
183    fn item(v: serde_json::Value) -> Item {
184        let mut base = json!({"id": "x", "name": "x", "ext": "png", "size": 1, "width": 10, "height": 10, "tags": [], "folders": [], "isDeleted": false, "url": "", "annotation": "", "modificationTime": 0, "lastModified": 0});
185        for (k, val) in v.as_object().unwrap() {
186            base[k] = val.clone();
187        }
188        serde_json::from_value(base).unwrap()
189    }
190
191    #[test]
192    fn choices_collect_each_value_once() {
193        let a = item(json!({"id": "a", "name": "cat", "url": "https://a", "annotation": "first", "folders": ["F1"], "tags": ["b", "A"], "star": 2}));
194        let b = item(json!({"id": "b", "name": "cat copy", "url": "", "annotation": "second", "folders": ["F2", "F1"], "tags": ["A", "c"], "star": 4}));
195        let c = item(json!({"id": "c", "name": "cat", "annotation": "first"}));
196        let ch = Choices::of(&[&a, &b, &c]);
197        assert_eq!(ch.names, ["cat", "cat copy"]);
198        assert_eq!(ch.urls, ["https://a"]);
199        assert_eq!(ch.notes, ["first", "second"]);
200        assert_eq!(ch.folders, ["F1", "F2"]);
201        assert_eq!(ch.tags, ["A", "b", "c"]);
202        assert_eq!(ch.merged_note.as_deref(), Some("first\nsecond"));
203        assert_eq!(ch.star, Some(4));
204        let m = ch.defaults("b", vec!["a".into(), "c".into()]);
205        assert_eq!((m.name.as_deref(), m.url.as_str(), m.note.as_str(), m.star), (Some("cat"), "https://a", "first", Some(4)));
206        // One note: no joined note; no ratings: none.
207        let solo = Choices::of(&[&c, &c]);
208        assert_eq!(solo.merged_note, None);
209        assert_eq!(solo.star, None);
210        let long = item(json!({"id": "l", "annotation": "é".repeat(5000)}));
211        assert_eq!(Choices::of(&[&a, &long]).merged_note.unwrap().chars().count(), MERGED_NOTE_LIMIT);
212    }
213
214    #[test]
215    fn the_best_copy_comes_first() {
216        let big = item(json!({"id": "big", "ext": "jpg", "width": 200, "height": 100, "size": 5}));
217        let png = item(json!({"id": "png", "ext": "png", "width": 100, "height": 100, "size": 1}));
218        let jpg = item(json!({"id": "jpg", "ext": "jpg", "width": 100, "height": 100, "size": 9}));
219        let jpg_small = item(json!({"id": "jpg2", "ext": "JPG", "width": 100, "height": 100, "size": 3}));
220        let gif = item(json!({"id": "gif", "ext": "gif", "width": 100, "height": 100, "size": 99}));
221        let mut v = vec![&gif, &jpg_small, &png, &jpg, &big];
222        best_first(&mut v);
223        let ids: Vec<&str> = v.iter().map(|i| i.id.as_str()).collect();
224        assert_eq!(ids, ["big", "png", "jpg", "jpg2", "gif"]);
225    }
226}