1use crate::backend::{QtThread, Shared, json, load_config, say, tell, tr};
11use crate::{mail, pim};
12use cxx_qt_lib::QString;
13use serde::Serialize;
14use sioul_core::contacts::{self, Contact};
15use sioul_core::duplicates::{self, Cleaning, Done, NotTheSame, Pair};
16use sioul_core::i18n::Translator;
17use sioul_core::phones::{self, Region};
18use sioul_core::vdir::Kind;
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22use std::sync::atomic::{AtomicU64, Ordering};
23
24pub(crate) fn region() -> Option<&'static Region> {
26 phones::chosen(load_config().contacts.region.as_deref(), &tr().text("qt-locale", None))
27}
28
29fn now() -> i64 {
30 jiff::Timestamp::now().as_second()
31}
32
33fn everyone(shared: &Shared) -> Vec<Contact> {
35 let (removed, _) = mail::hidden_pim(shared);
36 contacts::all().into_iter().filter(|c| !removed.contains(Path::new(&c.key))).collect()
37}
38
39static LOOKS: AtomicU64 = AtomicU64::new(0);
41
42pub(crate) fn find(qt: &QtThread, shared: &Arc<Shared>) {
44 let look = LOOKS.fetch_add(1, Ordering::Relaxed) + 1;
45 let (qt, shared) = (qt.clone(), Arc::clone(shared));
46 std::thread::spawn(move || {
47 let everyone = everyone(&shared);
48 let region = region();
49 let cleanings = duplicates::cleanings(&everyone, region);
50 let pairs = duplicates::pairs(&everyone, region, &NotTheSame::load(&NotTheSame::default_path()));
51 let done = duplicates::done(&duplicates::undo_dir(), now());
52 let text = json(&view(&everyone, &cleanings, &pairs, &done, region, tr()));
53 let _ = qt.queue(move |mut sioul| {
54 if LOOKS.load(Ordering::Relaxed) == look {
55 sioul.as_mut().set_duplicates(QString::from(&text));
56 }
57 });
58 });
59}
60
61#[derive(Debug, Serialize)]
63struct DuplicatesView {
64 region: String,
66 within: String,
68 cleanings: Vec<CleaningView>,
69 between: String,
71 pairs: Vec<PairView>,
72 done: Vec<DoneView>,
74 kept: String,
76}
77
78#[derive(Debug, Serialize)]
79struct CleaningView {
80 key: String,
81 name: String,
82 lines: Vec<String>,
84}
85
86#[derive(Debug, Default, Serialize)]
88struct Side {
89 key: String,
90 name: String,
91 photo: String,
92 book: String,
94 work: String,
96 phones: Vec<String>,
97 emails: Vec<String>,
98 addresses: Vec<String>,
100 categories: Vec<String>,
101 birthday: String,
102 notes: String,
103}
104
105#[derive(Debug, Serialize)]
106struct PairView {
107 first: Side,
108 second: Side,
109 share: String,
111 lead: &'static str,
113}
114
115#[derive(Debug, Serialize)]
116struct DoneView {
117 id: String,
118 said: String,
120 when: String,
122}
123
124fn side(contact: Option<&Contact>, tr: &Translator) -> Side {
125 let Some(c) = contact else { return Side::default() };
126 let mut book = sioul_core::i18n::args();
127 book.set("book", c.book.clone());
128 Side {
129 key: c.key.clone(),
130 name: c.name.clone(),
131 photo: c.photo.clone(),
132 book: tr.text("dup-in-book", Some(&book)),
133 work: [c.title.as_str(), c.org.as_str()].iter().filter(|t| !t.is_empty()).copied().collect::<Vec<_>>().join(" · "),
134 phones: c.phones.iter().map(|p| p.value.clone()).collect(),
135 emails: c.emails.iter().map(|e| e.value.clone()).collect(),
136 addresses: c.addresses.iter().map(|a| a.value.replace('\n', ", ")).collect(),
137 categories: c.categories.clone(),
138 birthday: c.birthday.clone(),
139 notes: c.notes.clone(),
140 }
141}
142
143fn view(everyone: &[Contact], cleanings: &[Cleaning], pairs: &[Pair], done: &[Done], region: Option<&Region>, tr: &Translator) -> DuplicatesView {
145 let text = |id: &str, pairs: &[(&str, String)]| {
146 let mut args = sioul_core::i18n::args();
147 for (key, value) in pairs {
148 args.set(key.to_string(), value.clone());
149 }
150 tr.text(id, Some(&args))
151 };
152 let counted = |id: &str, n: usize| tr.text(id, Some(&tr.counted(n)));
153 let region = match region {
154 Some(r) => text("dup-region", &[("country", tr.text(&format!("country-{}", r.code.to_ascii_lowercase()), None))]),
155 None => tr.text("dup-region-none", None),
156 };
157 let by_key: BTreeMap<&str, &Contact> = everyone.iter().map(|c| (c.key.as_str(), c)).collect();
158 let pairs: Vec<PairView> = pairs
159 .iter()
160 .map(|p| {
161 let mut what: Vec<String> = Vec::new();
162 if p.same_name {
163 what.push(tr.text("dup-share-name", None));
164 }
165 what.extend(p.phones.iter().map(|v| text("dup-share-phone", &[("value", v.clone())])));
166 what.extend(p.emails.iter().map(|v| text("dup-share-email", &[("value", v.clone())])));
167 let (first, second) = (side(by_key.get(p.first.as_str()).copied(), tr), side(by_key.get(p.second.as_str()).copied(), tr));
168 let fuller = |name: &str| (name.split_whitespace().filter(|w| w.trim_end_matches('.').chars().count() > 1).count(), name.chars().count());
170 let lead = if fuller(&second.name) > fuller(&first.name) { "second" } else { "first" };
171 PairView { first, second, share: text("dup-share", &[("what", what.join(", "))]), lead }
172 })
173 .collect();
174 DuplicatesView {
175 region,
176 within: if cleanings.is_empty() { tr.text("dup-within-none", None) } else { counted("dup-within-some", cleanings.len()) },
177 cleanings: cleanings
178 .iter()
179 .map(|c| CleaningView { key: c.key.clone(), name: c.name.clone(), lines: c.removed.iter().map(|r| text("dup-removed", &[("value", r.value.clone()), ("kept", r.kept.clone())])).collect() })
180 .collect(),
181 between: if pairs.is_empty() { tr.text("dup-pairs-none", None) } else { counted("dup-pairs-some", pairs.len()) },
182 pairs,
183 done: done
184 .iter()
185 .map(|d| {
186 let names = d.names.join(", ");
187 let said = if d.kind == "merge" {
188 text("dup-done-merge", &[("names", names)])
189 } else {
190 let mut args = tr.counted(d.changes.len());
191 args.set("names", names);
192 tr.text("dup-done-clean", Some(&args))
193 };
194 DoneView { id: d.id.clone(), said, when: sioul_core::view::date(tr, Some(d.at)) }
195 })
196 .collect(),
197 kept: tr.text("dup-done-help", None),
198 }
199}
200
201fn writable(key: &str) -> Option<PathBuf> {
203 pim::ours(key).filter(|path| pim::collection_of(path).is_some_and(|c| !c.read_only))
204}
205
206fn changed(qt: &QtThread, shared: &Arc<Shared>, paths: &[PathBuf]) {
208 let accounts: BTreeSet<String> = paths.iter().filter_map(|p| pim::account_of(p)).collect();
209 for account in accounts {
210 pim::nudge(shared, &account);
211 }
212 pim::show_pim(qt, shared);
213 crate::work::show_work(qt, shared);
214 find(qt, shared);
215}
216
217pub(crate) fn clean(qt: &QtThread, shared: &Arc<Shared>, keys: &str) -> String {
220 let keys: Vec<String> = serde_json::from_str(keys).unwrap_or_default();
221 let paths: Vec<PathBuf> = keys.iter().filter_map(|k| writable(k)).collect();
222 match duplicates::clean_files(&paths, region(), &duplicates::undo_dir(), now()) {
223 Ok(Some(done)) => {
224 changed(qt, shared, &done.changes.iter().map(|c| c.path.clone()).collect::<Vec<_>>());
225 tell(qt, shared, say("dup-cleaned", &[("n", done.changes.len().to_string()), ("count", tr().count(done.changes.len(), false, false)), ("countf", tr().count(done.changes.len(), false, true))]));
226 String::new()
227 }
228 Ok(None) => {
229 find(qt, shared);
230 tr().text("dup-changed", None)
231 }
232 Err(e) => e,
233 }
234}
235
236pub(crate) fn merge(qt: &QtThread, shared: &Arc<Shared>, lead: &str, other: &str) -> String {
238 let fail = |e: String| serde_json::json!({ "error": e }).to_string();
239 let (Some(lead), Some(other)) = (writable(lead), writable(other)) else { return fail(tr().text("contact-read-only", None)) };
240 match duplicates::merge_files(&lead, &other, region(), &duplicates::undo_dir(), now()) {
241 Ok(done) => {
242 changed(qt, shared, &[lead.clone(), other]);
243 tell(qt, shared, say("dup-merged", &[("names", done.names.join(", "))]));
244 serde_json::json!({ "key": lead.display().to_string() }).to_string()
245 }
246 Err(e) => fail(e),
247 }
248}
249
250pub(crate) fn not_same(qt: &QtThread, shared: &Arc<Shared>, first: &str, second: &str) -> String {
252 let everyone = everyone(shared);
253 let (Some(a), Some(b)) = (everyone.iter().find(|c| c.key == first), everyone.iter().find(|c| c.key == second)) else { return tr().text("contact-gone", None) };
254 let path = NotTheSame::default_path();
255 let mut apart = NotTheSame::load(&path);
256 apart.remember(a, b);
257 if let Err(e) = apart.save(&path) {
258 return e;
259 }
260 tell(qt, shared, say("dup-kept-apart", &[("names", format!("{}, {}", a.name, b.name))]));
261 find(qt, shared);
262 String::new()
263}
264
265pub(crate) fn undo(qt: &QtThread, shared: &Arc<Shared>, id: &str) -> String {
267 let dir = duplicates::undo_dir();
268 match duplicates::undo(&dir, id, &Kind::Contacts.root()) {
269 Ok((touched, aside)) => {
270 changed(qt, shared, &touched);
271 let line = if aside > 0 { say("dup-undone-aside", &[("folder", dir.display().to_string())]) } else { tr().text("undo-done", None) };
272 tell(qt, shared, line);
273 String::new()
274 }
275 Err(e) => e,
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 fn contact(key: &str, name: &str, phones: &[&str]) -> Contact {
284 Contact {
285 key: key.into(),
286 uid: key.into(),
287 name: name.into(),
288 book: "Contacts".into(),
289 phones: phones.iter().map(|p| contacts::Labeled { label: String::new(), value: p.to_string() }).collect(),
290 ..Contact::default()
291 }
292 }
293
294 #[test]
295 fn the_view_says_it_in_words() {
296 let tr = Translator::new("en");
297 let everyone = [contact("/a.vcf", "J. Dupont", &["04 65 71 12 34"]), contact("/b.vcf", "Jean Dupont", &["+33 4 65 71 12 34"])];
298 let france = phones::region_named("FR");
299 let pairs = duplicates::pairs(&everyone, france, &NotTheSame::default());
300 let cleanings = vec![Cleaning { key: "/a.vcf".into(), name: "J. Dupont".into(), removed: vec![duplicates::Removed { kind: "phone".into(), value: "04 65 71 12 34".into(), kept: "+33 4 65 71 12 34".into() }] }];
301 let done = vec![Done { id: "1-merge".into(), at: 1_791_000_000, kind: "merge".into(), names: vec!["Jean Dupont".into(), "J. Dupont".into()], changes: Vec::new() }];
302 let shown = view(&everyone, &cleanings, &pairs, &done, france, &tr);
303 assert_eq!(shown.region, "Country of numbers written without one: France. The settings (⚙) change it.");
304 assert_eq!(shown.within, "One card holds a number or an address twice. Ticked, it keeps one of each:");
305 assert_eq!(shown.cleanings[0].lines, ["04 65 71 12 34, the same as +33 4 65 71 12 34"]);
306 assert_eq!(shown.between, "One pair of cards may be one person.");
307 assert_eq!(shown.pairs[0].share, "Both have the number 04 65 71 12 34.");
308 assert_eq!((shown.pairs[0].first.name.as_str(), shown.pairs[0].lead), ("J. Dupont", "second"));
310 assert_eq!((shown.pairs[0].first.book.as_str(), shown.done[0].said.as_str()), ("In Contacts", "Merged: Jean Dupont, J. Dupont"));
311 let fr = Translator::new("fr");
313 let many: Vec<Cleaning> = (0..3).map(|n| Cleaning { key: format!("/{n}.vcf"), name: String::new(), removed: Vec::new() }).collect();
314 let shown = view(&[], &many, &[], &[], None, &fr);
315 assert!(shown.within.starts_with("Trois fiches contiennent"), "{}", shown.within);
316 assert!(shown.region.starts_with("Les numéros écrits sans leur pays"), "{}", shown.region);
317 assert_eq!(shown.between, "Aucune paire de fiches ne semble être une seule personne.");
318 }
319}