Skip to main content

sioul_app/
duplicates.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Duplicates in the contacts, behind the Contacts page's "Duplicates": what
5//! the core finds (`sioul_core::duplicates`) said in words, and what the
6//! person chooses: these cards cleaned, this pair merged or kept apart, what
7//! was done undone. Each change goes to the address books' folders at once
8//! and their accounts' sync sends it; nothing changes without a click.
9
10use 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
24/// The country numbers written without one are read as: the settings', else the system's.
25pub(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
33/// The cards looked at: every one, those waiting for their deletion aside.
34fn 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
39/// Looks may finish out of order: only the newest is shown.
40static LOOKS: AtomicU64 = AtomicU64::new(0);
41
42/// Looks for duplicates on a thread; the page reads them in `duplicates`.
43pub(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/// What the Duplicates view shows, in words.
62#[derive(Debug, Serialize)]
63struct DuplicatesView {
64    /// "Country of numbers written without one: France…".
65    region: String,
66    /// "Two cards hold a number or an address twice…", or that none does.
67    within: String,
68    cleanings: Vec<CleaningView>,
69    /// "Three pairs of cards may be one person…", or that none may.
70    between: String,
71    pairs: Vec<PairView>,
72    /// What was done lately, the newest first, to undo it.
73    done: Vec<DoneView>,
74    /// "Each is kept thirty days…".
75    kept: String,
76}
77
78#[derive(Debug, Serialize)]
79struct CleaningView {
80    key: String,
81    name: String,
82    /// "04 65 71 12 34, the same as +33 4 65 71 12 34", a line each.
83    lines: Vec<String>,
84}
85
86/// One of a pair's cards, as the view compares them.
87#[derive(Debug, Default, Serialize)]
88struct Side {
89    key: String,
90    name: String,
91    photo: String,
92    /// "In Contacts".
93    book: String,
94    /// Role and organisation: "Accountant · Exemple SARL".
95    work: String,
96    phones: Vec<String>,
97    emails: Vec<String>,
98    /// Each postal address on one line.
99    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    /// "Both have the same name, the number 04 65 71 12 34."
110    share: String,
111    /// The side whose name is offered first: the fuller name.
112    lead: &'static str,
113}
114
115#[derive(Debug, Serialize)]
116struct DoneView {
117    id: String,
118    /// "Merged: Jean Dupont, J. Dupont".
119    said: String,
120    /// When, as the mail says dates.
121    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
143/// The view, from what the core found.
144fn 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            // The fuller name first: "Jean Dupont" before "J. Dupont".
169            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
201/// A card Sioul may change: in its address books, not read-only.
202fn writable(key: &str) -> Option<PathBuf> {
203    pim::ours(key).filter(|path| pim::collection_of(path).is_some_and(|c| !c.read_only))
204}
205
206/// After a change: each account concerned syncs now, the pages and the duplicates again.
207fn 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
217/// Takes the duplicates off these cards (a JSON list of their files), one
218/// write each; returns what went wrong, else "".
219pub(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
236/// Merges two cards, `lead` keeping its name; returns {"key"} (the card kept) or {"error"}.
237pub(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
250/// Two cards are not one person: never offered again here; returns what went wrong, else "".
251pub(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
265/// Puts the cards back as they were before what was done (`id`); returns what went wrong, else "".
266pub(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        // The fuller name is offered first.
309        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        // In French, in words, and without a country.
312        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}