1use std::cmp::Ordering;
8use std::collections::HashSet;
9
10use crate::error::Result;
11use crate::library::Library;
12use crate::model::Item;
13
14const MERGED_NOTE_LIMIT: usize = 4096;
16
17#[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 pub tags: Vec<String>,
29 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
41fn 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 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#[derive(Debug, Clone, PartialEq)]
87pub struct Merge {
88 pub keep: String,
89 pub trash: Vec<String>,
90 pub name: Option<String>,
92 pub url: String,
93 pub note: String,
94 pub folders: Vec<String>,
95 pub tags: Vec<String>,
96 pub star: Option<u64>,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Default)]
102pub struct MergeReport {
103 pub kept: usize,
104 pub trashed: usize,
105 pub bytes: u64,
107}
108
109impl Library {
110 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 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
151fn 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
158pub 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 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}