1use crate::contacts::{self, Contact};
30use crate::lines;
31use crate::phones::{self, Region};
32use serde::{Deserialize, Serialize};
33use std::collections::{BTreeMap, BTreeSet};
34use std::path::{Path, PathBuf};
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
38pub struct Removed {
39 pub kind: String,
41 pub value: String,
42 pub kept: String,
43}
44
45pub fn email_key(value: &str) -> String {
47 let value = value.trim();
48 let value = value.get(..7).filter(|s| s.eq_ignore_ascii_case("mailto:")).map_or(value, |_| &value[7..]);
49 value.trim().to_lowercase()
50}
51
52pub fn name_key(name: &str) -> String {
55 let mut folded = String::new();
56 for c in name.chars() {
57 match c {
58 'ß' => folded.push_str("ss"),
59 'æ' | 'Æ' => folded.push_str("ae"),
60 'œ' | 'Œ' => folded.push_str("oe"),
61 _ => folded.push(latin(crate::text::fold_char(c))),
62 }
63 }
64 let mut words: Vec<&str> = folded.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
65 words.sort_unstable();
66 words.join(" ")
67}
68
69fn latin(c: char) -> char {
71 match c {
72 'ñ' | 'ń' | 'ň' => 'n',
73 'ã' | 'å' | 'ā' | 'ą' | 'ă' => 'a',
74 'õ' | 'ø' | 'ō' | 'ő' => 'o',
75 'ł' | 'ľ' | 'ĺ' => 'l',
76 'ś' | 'š' | 'ş' | 'ș' => 's',
77 'ć' | 'č' => 'c',
78 'ź' | 'ż' | 'ž' => 'z',
79 'ř' => 'r',
80 'ě' | 'ę' | 'ē' => 'e',
81 'ů' | 'ű' | 'ū' => 'u',
82 'ý' => 'y',
83 'ğ' => 'g',
84 'ı' | 'ī' => 'i',
85 'đ' | 'ď' => 'd',
86 'ť' | 'ț' => 't',
87 _ => c,
88 }
89}
90
91fn group_of(line: &str) -> Option<&str> {
93 let head = line.split([';', ':']).next().unwrap_or("");
94 head.rsplit_once('.').map(|(group, _)| group)
95}
96
97fn same_group(line: &str, group: &str) -> bool {
98 group_of(line).is_some_and(|g| g.eq_ignore_ascii_case(group))
99}
100
101fn is_companion(line: &str) -> bool {
104 let name = lines::name(line);
105 name.starts_with("X-AB") && name != "X-ABRELATEDNAMES" && name != "X-ABDATE"
106}
107
108fn informative(kind: &str) -> bool {
110 !matches!(kind, "internet" | "voice" | "pref" | "x400") && !kind.starts_with("x-")
111}
112
113fn says(card: &[String], i: usize) -> usize {
116 let line = contacts::split_line(&card[i]);
117 let kinds = contacts::types_of(&line).iter().filter(|k| informative(k)).count();
118 let labelled = group_of(&card[i]).is_some_and(|g| card.iter().any(|l| same_group(l, g) && is_companion(l)));
119 kinds + usize::from(labelled) + usize::from(contacts::is_preferred(&line))
120}
121
122fn with_kinds(line: &str, kinds: &[String], first: bool, version4: bool) -> String {
126 if kinds.is_empty() && !first {
127 return line.to_string();
128 }
129 let value = lines::value(line);
130 let Some(colon) = line.len().checked_sub(value.len() + 1).filter(|&at| line.as_bytes().get(at) == Some(&b':')) else { return line.to_string() };
131 let head = &line[..colon];
132 let mut parts: Vec<String> = Vec::new();
134 let (mut quoted, mut start) = (false, 0);
135 for (i, c) in head.char_indices() {
136 match c {
137 '"' => quoted = !quoted,
138 ';' if !quoted => {
139 parts.push(head[start..i].to_string());
140 start = i + 1;
141 }
142 _ => {}
143 }
144 }
145 parts.push(head[start..].to_string());
146 let mut kinds: Vec<String> = kinds.to_vec();
147 if first && !version4 {
148 kinds.push("pref".into());
149 }
150 let typed = parts.iter().position(|p| p.split_once('=').is_some_and(|(n, _)| n.trim().eq_ignore_ascii_case("TYPE")));
151 if !kinds.is_empty() {
152 match typed {
153 Some(at) => {
154 let (name, written) = parts[at].split_once('=').map(|(n, v)| (n.to_string(), v.to_string())).unwrap_or_default();
155 let lower = written.chars().filter(|c| c.is_alphabetic()).all(char::is_lowercase);
156 let more = kinds.iter().map(|k| if lower { k.to_lowercase() } else { k.to_uppercase() }).collect::<Vec<_>>().join(",");
157 let joined = match written.strip_suffix('"') {
158 Some(open) => format!("{open},{more}\""),
159 None if written.is_empty() => more,
160 None => format!("{written},{more}"),
161 };
162 parts[at] = format!("{name}={joined}");
163 }
164 None => {
165 let more = kinds.iter().map(|k| if version4 { k.to_lowercase() } else { k.to_uppercase() }).collect::<Vec<_>>().join(",");
166 parts.insert(1, format!("TYPE={more}"));
167 }
168 }
169 }
170 if first && version4 {
171 parts.push("PREF=1".into());
172 }
173 format!("{}:{value}", parts.join(";"))
174}
175
176pub fn clean(text: &str, region: Option<&Region>) -> Option<(String, Vec<Removed>)> {
180 let card = lines::unfold(text);
181 let version4 = card.iter().any(|l| l.eq_ignore_ascii_case("VERSION:4.0"));
182 let mut out: Vec<Option<String>> = card.iter().cloned().map(Some).collect();
183 let mut removed: Vec<Removed> = Vec::new();
184 let mut gone: Vec<usize> = Vec::new();
185 for (property, kind) in [("TEL", "phone"), ("EMAIL", "email")] {
186 let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
188 for (i, line) in card.iter().enumerate().filter(|(_, l)| lines::name(l) == property) {
189 let value = contacts::shown(line);
190 if value.is_empty() {
191 continue;
192 }
193 let key = if property == "TEL" { phones::key(&value, region) } else { email_key(&value) };
194 match groups.iter_mut().find(|(k, _)| *k == key) {
195 Some((_, members)) => members.push(i),
196 None => groups.push((key, vec![i])),
197 }
198 }
199 for (_, members) in groups.into_iter().filter(|(_, m)| m.len() > 1) {
200 let keeper = members
202 .iter()
203 .copied()
204 .max_by_key(|&i| (property == "TEL" && phones::is_international(&contacts::shown(&card[i])), says(&card, i), std::cmp::Reverse(i)))
205 .unwrap_or(members[0]);
206 let kept_line = contacts::split_line(&card[keeper]);
207 let kept_kinds = contacts::types_of(&kept_line);
208 let mut more: Vec<String> = Vec::new();
209 for &i in members.iter().filter(|&&i| i != keeper) {
210 for kind in contacts::types_of(&contacts::split_line(&card[i])) {
211 if informative(&kind) && !kept_kinds.contains(&kind) && !more.contains(&kind) {
212 more.push(kind);
213 }
214 }
215 }
216 let first = !contacts::is_preferred(&kept_line) && members.iter().any(|&i| contacts::is_preferred(&contacts::split_line(&card[i])));
217 let mut line = with_kinds(&card[keeper], &more, first, version4);
218 if group_of(&card[keeper]).is_none()
220 && let Some(group) = members.iter().filter(|&&i| i != keeper).find_map(|&i| group_of(&card[i]).filter(|g| card.iter().any(|l| same_group(l, g) && is_companion(l))))
221 {
222 line = format!("{group}.{line}");
223 }
224 let kept_value = contacts::shown(&card[keeper]);
225 for &i in members.iter().filter(|&&i| i != keeper) {
226 out[i] = None;
227 gone.push(i);
228 removed.push(Removed { kind: kind.to_string(), value: contacts::shown(&card[i]), kept: kept_value.clone() });
229 }
230 out[keeper] = Some(line);
231 }
232 }
233 if removed.is_empty() {
234 return None;
235 }
236 for &i in &gone {
238 let Some(group) = group_of(&card[i]) else { continue };
239 let still_used = out.iter().flatten().any(|l| same_group(l, group) && !is_companion(l));
240 if !still_used {
241 for (j, line) in card.iter().enumerate() {
242 if same_group(line, group) {
243 out[j] = None;
244 }
245 }
246 }
247 }
248 Some((finish(out.into_iter().flatten().collect(), Vec::new()), removed))
249}
250
251fn finish(lines_kept: Vec<String>, added: Vec<String>) -> String {
253 let mut kept: Vec<String> = lines_kept.into_iter().filter(|l| lines::name(l) != "REV").collect();
254 let end = kept.iter().rposition(|l| lines::name(l) == "END").unwrap_or(kept.len());
255 let mut more = added;
256 more.push(format!("REV:{}", contacts::now_stamp()));
257 kept.splice(end..end, more);
258 lines::fold(&kept)
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
263pub struct Cleaning {
264 pub key: String,
266 pub name: String,
267 pub removed: Vec<Removed>,
268}
269
270pub fn cleanings(contacts: &[Contact], region: Option<&Region>) -> Vec<Cleaning> {
274 contacts
275 .iter()
276 .filter(|c| !c.read_only)
277 .filter(|c| {
278 let repeats = |keys: Vec<String>| keys.iter().collect::<BTreeSet<_>>().len() < keys.len();
279 repeats(c.phones.iter().map(|p| phones::key(&p.value, region)).collect()) || repeats(c.emails.iter().map(|e| email_key(&e.value)).collect())
280 })
281 .filter_map(|c| {
282 let text = std::fs::read_to_string(&c.key).ok()?;
283 let (_, removed) = clean(&text, region)?;
284 Some(Cleaning { key: c.key.clone(), name: c.name.clone(), removed })
285 })
286 .collect()
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
291pub struct Pair {
292 pub first: String,
294 pub second: String,
295 pub same_name: bool,
296 pub phones: Vec<String>,
298 pub emails: Vec<String>,
300}
301
302impl Pair {
303 fn strength(&self) -> (usize, usize) {
305 let kinds = usize::from(self.same_name) + usize::from(!self.phones.is_empty()) + usize::from(!self.emails.is_empty());
306 (kinds, self.phones.len() + self.emails.len())
307 }
308}
309
310const AT_MOST: usize = 4;
313
314fn identity(contact: &Contact) -> &str {
316 if contact.uid.trim().is_empty() { &contact.key } else { contact.uid.trim() }
317}
318
319pub fn pairs(contacts: &[Contact], region: Option<&Region>, apart: &NotTheSame) -> Vec<Pair> {
323 let open: Vec<&Contact> = contacts.iter().filter(|c| !c.read_only).collect();
324 let mut said: BTreeMap<(u8, String), Vec<(usize, String)>> = BTreeMap::new();
326 for (i, contact) in open.iter().enumerate() {
327 let mut own: BTreeSet<(u8, String)> = BTreeSet::new();
328 let mut say = |kind: u8, key: String, written: &str| {
329 if own.insert((kind, key.clone())) {
330 said.entry((kind, key)).or_default().push((i, written.to_string()));
331 }
332 };
333 let name = name_key(&contact.name);
334 if !name.is_empty() && !contact.name.contains('@') {
336 say(0, name, &contact.name);
337 }
338 for phone in &contact.phones {
339 let key = phones::key(&phone.value, region);
340 if phones::is_whole(&key) {
341 say(1, key, &phone.value);
342 }
343 }
344 for email in &contact.emails {
345 let key = email_key(&email.value);
346 if key.contains('@') {
347 say(2, key, &email.value);
348 }
349 }
350 }
351 let mut found: BTreeMap<(usize, usize), Pair> = BTreeMap::new();
352 for ((kind, _), cards) in said.iter().filter(|(_, cards)| (2..=AT_MOST).contains(&cards.len())) {
353 for (x, (a, written)) in cards.iter().enumerate() {
354 for (b, _) in &cards[x + 1..] {
355 let pair = found.entry((*a, *b)).or_insert_with(|| Pair { first: open[*a].key.clone(), second: open[*b].key.clone(), same_name: false, phones: Vec::new(), emails: Vec::new() });
356 match *kind {
357 0 => pair.same_name = true,
358 1 => pair.phones.push(written.clone()),
359 _ => pair.emails.push(written.clone()),
360 }
361 }
362 }
363 }
364 let mut pairs: Vec<((usize, usize), Pair)> = found
365 .into_iter()
366 .filter(|((a, b), _)| {
367 let (first, second) = (identity(open[*a]), identity(open[*b]));
368 let (first, second) = if first == second { (open[*a].key.as_str(), open[*b].key.as_str()) } else { (first, second) };
370 !apart.contains(first, second)
371 })
372 .collect();
373 pairs.sort_by(|(at_a, a), (at_b, b)| b.strength().cmp(&a.strength()).then(at_a.cmp(at_b)));
374 pairs.into_iter().map(|(_, pair)| pair).collect()
375}
376
377#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
379pub struct NotTheSame {
380 #[serde(default)]
382 pub pairs: BTreeSet<[String; 2]>,
383}
384
385impl NotTheSame {
386 pub fn default_path() -> PathBuf {
388 crate::config::state_dir().join("contacts-not-the-same.toml")
389 }
390
391 pub fn load(path: &Path) -> NotTheSame {
392 std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
393 }
394
395 pub fn save(&self, path: &Path) -> Result<(), String> {
397 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
398 if let Some(parent) = path.parent() {
399 std::fs::create_dir_all(parent).map_err(fail)?;
400 }
401 let text = toml::to_string(self).map_err(|e| e.to_string())?;
402 let temporary = path.with_extension("toml.new");
403 std::fs::write(&temporary, text).map_err(fail)?;
404 std::fs::rename(&temporary, path).map_err(fail)
405 }
406
407 fn ordered(a: &str, b: &str) -> [String; 2] {
408 if a <= b { [a.to_string(), b.to_string()] } else { [b.to_string(), a.to_string()] }
409 }
410
411 pub fn contains(&self, a: &str, b: &str) -> bool {
412 self.pairs.contains(&NotTheSame::ordered(a, b))
413 }
414
415 pub fn insert(&mut self, a: &str, b: &str) {
416 self.pairs.insert(NotTheSame::ordered(a, b));
417 }
418
419 pub fn remember(&mut self, first: &Contact, second: &Contact) {
421 let (a, b) = (identity(first), identity(second));
422 if a == b { self.insert(&first.key, &second.key) } else { self.insert(a, b) }
423 }
424}
425
426const SINGLE: &[&str] = &["ORG", "TITLE", "ROLE", "BDAY", "ANNIVERSARY", "PHOTO", "LOGO", "GENDER", "TZ", "GEO", "X-ANNIVERSARY", "X-SPOUSE", "X-MANAGER", "X-ASSISTANT"];
429
430const SEVERAL: &[&str] = &[
432 "ADR", "URL", "IMPP", "RELATED", "NICKNAME", "LANG", "KEY", "CALURI", "CALADRURI", "FBURL", "X-SOCIALPROFILE", "X-ABRELATEDNAMES", "X-ABDATE", "X-AIM", "X-ICQ", "X-JABBER", "X-MSN", "X-YAHOO", "X-SKYPE", "X-SKYPE-USERNAME",
433 "X-GOOGLE-TALK", "X-TWITTER",
434];
435
436fn comparable(property: &str, value: &str) -> String {
439 let folded: String = crate::text::fold(value).into_iter().collect();
440 let words = folded.split_whitespace().collect::<Vec<_>>().join(" ");
441 if property == "URL" {
442 let bare = words.trim_start_matches("https://").trim_start_matches("http://").trim_start_matches("www.");
443 return bare.trim_end_matches('/').to_string();
444 }
445 words
446}
447
448fn as_vcard3_line(line: &str) -> Option<String> {
450 let card = lines::fold(&["BEGIN:VCARD".to_string(), "VERSION:4.0".to_string(), line.to_string(), "END:VCARD".to_string()]);
451 lines::unfold(&contacts::as_vcard3(&card)).into_iter().find(|l| !matches!(lines::name(l).as_str(), "BEGIN" | "VERSION" | "END"))
452}
453
454pub fn merge(lead: &str, other: &str, region: Option<&Region>) -> String {
463 let mine = lines::unfold(lead);
464 let theirs = lines::unfold(other);
465 let version4 = |card: &[String]| card.iter().any(|l| l.eq_ignore_ascii_case("VERSION:4.0"));
466 let into_three = !version4(&mine) && version4(&theirs);
467 let has = |property: &str| mine.iter().any(|l| lines::name(l) == property);
468 let has_value = |property: &str, value: &str| mine.iter().filter(|l| lines::name(l) == property).any(|l| comparable(property, &contacts::shown(l)) == comparable(property, value));
469 let mut carried: Vec<usize> = Vec::new();
471 for (i, line) in theirs.iter().enumerate() {
472 if is_companion(line) && group_of(line).is_some() {
473 continue;
474 }
475 let property = lines::name(line);
476 let comes = match property.as_str() {
477 "TEL" | "EMAIL" => !contacts::shown(line).is_empty(),
479 p if SINGLE.contains(&p) => !has(p),
480 p if SEVERAL.contains(&p) => !contacts::shown(line).is_empty() && !has_value(p, &contacts::shown(line)),
481 _ => false,
482 };
483 if comes {
484 carried.push(i);
485 }
486 }
487 let groups: Vec<String> = carried.iter().filter_map(|&i| group_of(&theirs[i]).map(|g| g.to_ascii_lowercase())).collect();
489 for (i, line) in theirs.iter().enumerate() {
490 if is_companion(line) && group_of(line).is_some_and(|g| groups.contains(&g.to_ascii_lowercase())) {
491 carried.push(i);
492 }
493 }
494 carried.sort_unstable();
495 let mut taken: BTreeSet<String> = mine.iter().filter_map(|l| group_of(l)).map(|g| g.to_ascii_lowercase()).collect();
497 let mut renamed: BTreeMap<String, String> = BTreeMap::new();
498 let mut added: Vec<String> = Vec::new();
499 for &i in &carried {
500 let mut line = theirs[i].clone();
501 if let Some(group) = group_of(&line).map(str::to_string) {
502 let new = match renamed.get(&group.to_ascii_lowercase()) {
503 Some(new) => new.clone(),
504 None => {
505 let new = (1..).map(|n| format!("item{n}")).find(|g| !taken.contains(g)).unwrap_or_default();
506 taken.insert(new.clone());
507 renamed.insert(group.to_ascii_lowercase(), new.clone());
508 new
509 }
510 };
511 line = format!("{new}{}", &line[group.len()..]);
512 }
513 if into_three {
514 match as_vcard3_line(&line) {
515 Some(three) => line = three,
516 None => continue,
517 }
518 }
519 added.push(line);
520 }
521 let mut out: Vec<Option<String>> = mine.iter().cloned().map(Some).collect();
522 let ours = contacts::categories_of(&mine);
524 let mut united = ours.clone();
525 for name in contacts::categories_of(&theirs) {
526 if !united.iter().any(|n| contacts::category_key(n) == contacts::category_key(&name)) {
527 united.push(name);
528 }
529 }
530 let mut first_lines: Vec<String> = Vec::new();
531 if united != ours {
532 replace(&mine, &mut out, &mut first_lines, "CATEGORIES", contacts::categories_line(&united));
533 }
534 let note_of = |card: &[String]| card.iter().filter(|l| lines::name(l) == "NOTE").map(|l| contacts::shown(l)).filter(|n| !n.is_empty()).collect::<Vec<_>>().join("\n");
536 let (our_note, their_note) = (note_of(&mine), note_of(&theirs));
537 let folded = |text: &str| comparable("NOTE", text);
538 let joined = if their_note.is_empty() || folded(&our_note).contains(&folded(&their_note)) {
539 our_note.clone()
540 } else if our_note.is_empty() || folded(&their_note).contains(&folded(&our_note)) {
541 their_note.clone()
542 } else {
543 format!("{our_note}\n\n{their_note}")
544 };
545 if joined != our_note {
546 replace(&mine, &mut out, &mut first_lines, "NOTE", format!("NOTE:{}", lines::escape(&joined)));
547 }
548 first_lines.extend(added);
549 let text = finish(out.into_iter().flatten().collect(), first_lines);
550 clean(&text, region).map_or(text, |(cleaned, _)| cleaned)
551}
552
553fn replace(card: &[String], out: &mut [Option<String>], added: &mut Vec<String>, property: &str, line: String) {
555 let present: Vec<usize> = card.iter().enumerate().filter(|(_, l)| lines::name(l) == property).map(|(i, _)| i).collect();
556 for &i in &present {
557 out[i] = None;
558 }
559 match present.first() {
560 Some(&i) => out[i] = Some(line),
561 None => added.push(line),
562 }
563}
564
565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568pub struct Change {
569 pub path: PathBuf,
570 pub before: Option<String>,
571 pub after: Option<String>,
572}
573
574#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
576pub struct Done {
577 #[serde(skip)]
579 pub id: String,
580 pub at: i64,
582 pub kind: String,
584 pub names: Vec<String>,
586 pub changes: Vec<Change>,
587}
588
589pub const KEPT_DAYS: i64 = 30;
591
592pub fn undo_dir() -> PathBuf {
594 crate::config::state_dir().join("contacts-undo")
595}
596
597fn is_id(id: &str) -> bool {
599 !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
600}
601
602fn put(path: &Path, text: Option<&str>) -> Result<(), String> {
603 match text {
604 Some(text) => crate::vdir::write_item(path, text),
605 None => match std::fs::remove_file(path) {
606 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(format!("{}: {e}", path.display())),
607 _ => Ok(()),
608 },
609 }
610}
611
612pub fn apply(dir: &Path, kind: &str, names: Vec<String>, changes: Vec<Change>, now: i64) -> Result<Done, String> {
615 let fail = |e: std::io::Error| format!("{}: {e}", dir.display());
616 std::fs::create_dir_all(dir).map_err(fail)?;
617 let id = std::iter::once(format!("{now}-{kind}")).chain((2..1000).map(|n| format!("{now}-{kind}-{n}"))).find(|id| !dir.join(format!("{id}.json")).exists()).ok_or_else(|| format!("{}: full", dir.display()))?;
618 let done = Done { id: id.clone(), at: now, kind: kind.to_string(), names, changes };
619 let record = dir.join(format!("{id}.json"));
620 let temporary = dir.join(format!("{id}.json.new"));
621 std::fs::write(&temporary, serde_json::to_string(&done).map_err(|e| e.to_string())?).map_err(fail)?;
622 std::fs::rename(&temporary, &record).map_err(fail)?;
623 for (n, change) in done.changes.iter().enumerate() {
624 if let Err(e) = put(&change.path, change.after.as_deref()) {
625 for earlier in done.changes[..n].iter().rev() {
626 let _ = put(&earlier.path, earlier.before.as_deref());
627 }
628 let _ = std::fs::remove_file(&record);
629 return Err(e);
630 }
631 }
632 Ok(done)
633}
634
635pub fn done(dir: &Path, now: i64) -> Vec<Done> {
638 let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
639 let mut found: Vec<Done> = Vec::new();
640 let mut old: Vec<String> = Vec::new();
641 for path in entries.filter_map(Result::ok).map(|e| e.path()) {
642 let Some(id) = path.file_name().and_then(|n| n.to_str()).and_then(|n| n.strip_suffix(".json")).map(str::to_string) else { continue };
643 let Some(mut record) = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str::<Done>(&t).ok()) else { continue };
644 if now - record.at > KEPT_DAYS * 86_400 {
645 let _ = std::fs::remove_file(&path);
646 old.push(id);
647 continue;
648 }
649 record.id = id;
650 found.push(record);
651 }
652 if let Ok(entries) = std::fs::read_dir(dir) {
654 for path in entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "vcf")) {
655 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
656 let age = std::fs::metadata(&path).ok().and_then(|m| m.modified().ok()).and_then(|m| m.elapsed().ok()).map_or(0, |d| d.as_secs());
657 if old.iter().any(|id| name.starts_with(&format!("{id}."))) || age > (KEPT_DAYS as u64) * 86_400 {
658 let _ = std::fs::remove_file(&path);
659 }
660 }
661 }
662 found.sort_by(|a, b| b.at.cmp(&a.at).then(b.id.cmp(&a.id)));
663 found
664}
665
666pub fn undo(dir: &Path, id: &str, root: &Path) -> Result<(Vec<PathBuf>, usize), String> {
672 if !is_id(id) {
673 return Err(format!("{id}: not something done here"));
674 }
675 let record = dir.join(format!("{id}.json"));
676 let done: Done = std::fs::read_to_string(&record).ok().and_then(|t| serde_json::from_str(&t).ok()).ok_or_else(|| format!("{id}: nothing to undo"))?;
677 let mut touched = Vec::new();
678 let mut aside = 0;
679 for (n, change) in done.changes.iter().enumerate().rev() {
680 let inside = change.path.starts_with(root) && !change.path.components().any(|c| c == std::path::Component::ParentDir);
681 if !inside || change.path.parent().is_some_and(|p| !p.is_dir()) {
682 continue;
683 }
684 let now = std::fs::read_to_string(&change.path).ok();
685 if now.is_some() && now != change.after && now != change.before {
686 std::fs::write(dir.join(format!("{id}.{n}.vcf")), now.unwrap_or_default()).map_err(|e| format!("{}: {e}", dir.display()))?;
687 aside += 1;
688 }
689 put(&change.path, change.before.as_deref())?;
690 touched.push(change.path.clone());
691 }
692 let _ = std::fs::remove_file(&record);
693 Ok((touched, aside))
694}
695
696pub fn clean_files(paths: &[PathBuf], region: Option<&Region>, dir: &Path, now: i64) -> Result<Option<Done>, String> {
699 let mut changes = Vec::new();
700 let mut names = Vec::new();
701 for path in paths {
702 let Ok(text) = std::fs::read_to_string(path) else { continue };
703 if let Some((cleaned, _)) = clean(&text, region) {
704 names.push(contacts::display_name(&lines::unfold(&text)));
705 changes.push(Change { path: path.clone(), before: Some(text), after: Some(cleaned) });
706 }
707 }
708 if changes.is_empty() {
709 return Ok(None);
710 }
711 apply(dir, "clean", names, changes, now).map(Some)
712}
713
714pub fn merge_files(lead: &Path, other: &Path, region: Option<&Region>, dir: &Path, now: i64) -> Result<Done, String> {
717 if lead == other {
718 return Err(format!("{}: the same card", lead.display()));
719 }
720 let read = |path: &Path| std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()));
721 let (lead_text, other_text) = (read(lead)?, read(other)?);
722 let merged = merge(&lead_text, &other_text, region);
723 let names = vec![contacts::display_name(&lines::unfold(&lead_text)), contacts::display_name(&lines::unfold(&other_text))];
724 let changes = vec![Change { path: lead.to_path_buf(), before: Some(lead_text), after: Some(merged) }, Change { path: other.to_path_buf(), before: Some(other_text), after: None }];
725 apply(dir, "merge", names, changes, now)
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731 use crate::vdir::{Collection, Kind};
732
733 fn france() -> Option<&'static Region> {
734 phones::region_named("FR")
735 }
736
737 fn book(read_only: bool) -> Collection {
738 Collection { kind: Kind::Contacts, account: "a".into(), id: "contacts".into(), dir: PathBuf::from("/tmp"), name: "Contacts".into(), color: None, read_only, components: vec![] }
739 }
740
741 fn contact_of(text: &str, file: &str) -> Contact {
743 contacts::contact(&lines::unfold(text), Path::new(file), &book(false))
744 }
745
746 fn card(lines_in: &[&str]) -> String {
747 let mut all = vec!["BEGIN:VCARD", "VERSION:3.0"];
748 all.extend_from_slice(lines_in);
749 all.push("END:VCARD");
750 lines::fold(&all.iter().map(|l| l.to_string()).collect::<Vec<_>>())
751 }
752
753 fn without_rev(text: &str) -> Vec<String> {
754 lines::unfold(text).into_iter().filter(|l| lines::name(l) != "REV").collect()
755 }
756
757 #[test]
758 fn one_number_written_twice_on_a_card() {
759 let text = card(&["UID:a", "FN:Lou Exemple", "TEL;TYPE=CELL:04 65 71 12 34", "TEL:+33 4 65 71 12 34", "TEL;TYPE=HOME:05 36 49 00 00", "TEL;TYPE=WORK,PREF:0465711234", "X-OTHER:kept"]);
760 let (cleaned, removed) = clean(&text, france()).unwrap();
761 assert_eq!(without_rev(&cleaned), ["BEGIN:VCARD", "VERSION:3.0", "UID:a", "FN:Lou Exemple", "TEL;TYPE=CELL,WORK,PREF:+33 4 65 71 12 34", "TEL;TYPE=HOME:05 36 49 00 00", "X-OTHER:kept", "END:VCARD"]);
763 assert!(cleaned.contains("\r\nREV:"), "{cleaned}");
764 assert_eq!(
765 removed,
766 [
767 Removed { kind: "phone".into(), value: "04 65 71 12 34".into(), kept: "+33 4 65 71 12 34".into() },
768 Removed { kind: "phone".into(), value: "0465711234".into(), kept: "+33 4 65 71 12 34".into() }
769 ]
770 );
771 assert_eq!(clean(&cleaned, france()), None);
773 let two = card(&["FN:A", "TEL:04 65 71 12 34", "TEL:+33 4 65 71 12 34"]);
775 assert_eq!(clean(&two, None), None);
776 assert_eq!(clean(&two, france()).map(|(_, r)| r.len()), Some(1));
777 }
778
779 #[test]
780 fn addresses_twice_case_aside_and_apple_labels() {
781 let text = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Lou\r\nEMAIL;TYPE=home:lou@example.org\r\nitem1.EMAIL:Lou@Example.org\r\nitem1.X-ABLabel:club\r\nEMAIL;PREF=1:LOU@example.ORG\r\nEMAIL:other@example.org\r\nEND:VCARD\r\n";
783 let (cleaned, removed) = clean(text, None).unwrap();
784 assert_eq!(removed.len(), 2);
785 let lines = without_rev(&cleaned);
786 assert!(lines.contains(&"item1.EMAIL:Lou@Example.org".to_string()) || lines.iter().any(|l| l.starts_with("item1.EMAIL;TYPE=home")), "{lines:?}");
787 assert!(lines.contains(&"item1.X-ABLabel:club".to_string()), "the label stays with a line: {lines:?}");
788 assert_eq!(lines.iter().filter(|l| lines::name(l) == "EMAIL").count(), 2, "{lines:?}");
789 let grouped = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Lou\r\nitem1.TEL:+33 4 65 71 12 34\r\nitem1.X-ABLabel:atelier\r\nitem2.TEL:04 65 71 12 34\r\nitem2.X-ABLabel:vieux\r\nEND:VCARD\r\n";
791 let (cleaned, _) = clean(grouped, france()).unwrap();
792 assert_eq!(without_rev(&cleaned), ["BEGIN:VCARD", "VERSION:3.0", "FN:Lou", "item1.TEL:+33 4 65 71 12 34", "item1.X-ABLabel:atelier", "END:VCARD"]);
793 assert_eq!(clean(&card(&["FN:A", "EMAIL:a@example.org", "TEL:+33 4 65 71 12 34"]), france()), None);
795 }
796
797 #[test]
798 fn kinds_added_in_the_case_written() {
799 assert_eq!(with_kinds("TEL;TYPE=cell:+33 6", &["work".into()], false, false), "TEL;TYPE=cell,work:+33 6");
800 assert_eq!(with_kinds("TEL;TYPE=\"cell,voice\":+33 6", &["work".into()], true, true), "TEL;TYPE=\"cell,voice,work\";PREF=1:+33 6");
801 assert_eq!(with_kinds("item1.TEL:+33 6", &["cell".into()], true, false), "item1.TEL;TYPE=CELL,PREF:+33 6");
802 assert_eq!(with_kinds("TEL:+33 6", &[], false, false), "TEL:+33 6");
803 }
804
805 #[test]
806 fn names_compared_as_people_write_them() {
807 assert_eq!(name_key("Jean Dupont"), name_key("DUPONT, Jean"));
808 assert_eq!(name_key(" Jean Dupont "), name_key("Dupont Jean"));
809 assert_eq!(name_key("Zoë Ångström"), name_key("zoe angstrom"));
810 assert_eq!(name_key("Jean-Pierre Lætitia"), name_key("jean pierre laetitia"));
811 assert_ne!(name_key("Jean Dupont"), name_key("Jeanne Dupont"));
812 assert_eq!(email_key(" mailto:Jane@Example.ORG "), "jane@example.org");
813 }
814
815 #[test]
816 fn pairs_by_number_address_and_name() {
817 let people = [
818 contact_of(&card(&["UID:a", "FN:Jean Dupont", "TEL:04 65 71 12 34", "EMAIL:jean@example.org"]), "/tmp/a.vcf"),
819 contact_of(&card(&["UID:b", "FN:DUPONT Jean", "TEL:+33 4 65 71 12 34"]), "/tmp/b.vcf"),
820 contact_of(&card(&["UID:c", "FN:J. D.", "EMAIL:JEAN@example.org"]), "/tmp/c.vcf"),
821 contact_of(&card(&["UID:d", "FN:Marie Exemple", "TEL:3631"]), "/tmp/d.vcf"),
822 contact_of(&card(&["UID:e", "FN:Paul Exemple", "TEL:36 31"]), "/tmp/e.vcf"),
823 ];
824 let found = pairs(&people, france(), &NotTheSame::default());
825 assert_eq!(found.len(), 2, "{found:?}");
827 assert_eq!((found[0].first.as_str(), found[0].second.as_str(), found[0].same_name), ("/tmp/a.vcf", "/tmp/b.vcf", true));
828 assert_eq!(found[0].phones, ["04 65 71 12 34"]);
829 assert_eq!((found[1].first.as_str(), found[1].second.as_str(), found[1].emails.clone()), ("/tmp/a.vcf", "/tmp/c.vcf", vec!["jean@example.org".to_string()]));
830 let mut apart = NotTheSame::default();
832 apart.remember(&people[1], &people[0]);
833 let found = pairs(&people, france(), &apart);
834 assert_eq!(found.len(), 1);
835 assert_eq!(found[0].second, "/tmp/c.vcf");
836 let mut read_only = people.to_vec();
838 read_only[2].read_only = true;
839 assert!(pairs(&read_only, france(), &apart).is_empty());
840 let office: Vec<Contact> = (0..5).map(|n| contact_of(&card(&[&format!("UID:o{n}"), &format!("FN:Person {n}"), "TEL:+33 1 99 00 67 89"]), &format!("/tmp/o{n}.vcf"))).collect();
842 assert!(pairs(&office, france(), &NotTheSame::default()).is_empty());
843 }
844
845 #[test]
846 fn not_the_same_remembered() {
847 let dir = std::env::temp_dir().join(format!("sioul-not-same-{}", std::process::id()));
848 let _ = std::fs::remove_dir_all(&dir);
849 let path = dir.join("contacts-not-the-same.toml");
850 let mut apart = NotTheSame::load(&path);
851 assert!(apart.pairs.is_empty());
852 apart.insert("uid-b", "uid-a");
853 apart.save(&path).unwrap();
854 let again = NotTheSame::load(&path);
855 assert!(again.contains("uid-a", "uid-b") && again.contains("uid-b", "uid-a") && !again.contains("uid-a", "uid-c"));
856 assert_eq!(again, apart);
857 let (a, b) = (contact_of(&card(&["UID:same", "FN:A"]), "/tmp/x.vcf"), contact_of(&card(&["UID:same", "FN:A"]), "/tmp/y.vcf"));
859 let mut copies = NotTheSame::default();
860 copies.remember(&a, &b);
861 assert!(copies.contains("/tmp/y.vcf", "/tmp/x.vcf"));
862 let _ = std::fs::remove_dir_all(&dir);
863 }
864
865 #[test]
866 fn merging_keeps_everything_once() {
867 let lead = card(&[
868 "UID:lead",
869 "FN:Jean Dupont",
870 "N:Dupont;Jean;;;",
871 "TEL;TYPE=CELL:04 65 71 12 34",
872 "EMAIL:jean@example.org",
873 "item1.URL:https://example.org/",
874 "item1.X-ABLabel:site",
875 "CATEGORIES:Amis",
876 "NOTE:Met at the market",
877 "X-LEAD:kept",
878 ]);
879 let other = card(&[
880 "UID:other",
881 "FN:J. Dupont",
882 "ORG:Exemple SARL",
883 "BDAY:1984-05-12",
884 "TEL;TYPE=WORK:+33 4 65 71 12 34",
885 "TEL:05 36 49 00 00",
886 "EMAIL:JEAN@example.org",
887 "EMAIL:jd@example.net",
888 "item1.ADR:;;1 rue de l'Exemple;Lyon;;69000;France",
889 "item1.X-ABLabel:atelier",
890 "URL:http://www.example.org",
891 "CATEGORIES:amis,Club\\, échecs",
892 "NOTE:Plays chess",
893 "PHOTO;ENCODING=b;TYPE=JPEG:AAAA",
894 "X-OTHER-APP:not carried",
895 ]);
896 let merged = merge(&lead, &other, france());
897 let lines = without_rev(&merged);
898 let has = |l: &str| lines.iter().any(|x| x == l);
899 assert!(has("FN:Jean Dupont") && has("N:Dupont;Jean;;;") && has("UID:lead") && has("X-LEAD:kept") && has("item1.URL:https://example.org/") && has("item1.X-ABLabel:site"), "{lines:#?}");
901 assert!(has("TEL;TYPE=WORK,CELL:+33 4 65 71 12 34") && has("TEL:05 36 49 00 00"), "{lines:#?}");
903 assert_eq!(lines.iter().filter(|l| lines::name(l) == "TEL").count(), 2, "{lines:#?}");
904 assert!(has("EMAIL:jean@example.org") && has("EMAIL:jd@example.net"), "{lines:#?}");
906 assert_eq!(lines.iter().filter(|l| lines::name(l) == "EMAIL").count(), 2, "{lines:#?}");
907 assert!(has("item2.ADR:;;1 rue de l'Exemple;Lyon;;69000;France") && has("item2.X-ABLabel:atelier"), "{lines:#?}");
909 assert_eq!(lines.iter().filter(|l| lines::name(l) == "URL").count(), 1, "{lines:#?}");
910 assert!(has("ORG:Exemple SARL") && has("BDAY:1984-05-12") && has("PHOTO;ENCODING=b;TYPE=JPEG:AAAA"), "{lines:#?}");
912 assert!(has("CATEGORIES:Amis,Club\\, échecs") && has("NOTE:Met at the market\\n\\nPlays chess"), "{lines:#?}");
913 assert!(!has("X-OTHER-APP:not carried") && !has("FN:J. Dupont") && !has("UID:other"), "{lines:#?}");
915 assert!(contacts::parse(&merged).is_some());
916 assert_eq!(without_rev(&merge(&merged, &other, france())), lines);
918 }
919
920 #[test]
921 fn a_vcard4_card_merged_into_a_vcard3_one() {
922 let lead = card(&["UID:lead", "FN:Lou"]);
923 let other = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:o\r\nFN:Lou\r\nEMAIL;PREF=1:lou@example.org\r\nGENDER:F\r\nPHOTO:data:image/png;base64,iVBORw0K\r\nEND:VCARD\r\n";
924 let lines = without_rev(&merge(&lead, other, None));
925 assert!(lines.contains(&"EMAIL;TYPE=pref:lou@example.org".to_string()) && lines.contains(&"PHOTO;ENCODING=b;TYPE=PNG:iVBORw0K".to_string()), "{lines:#?}");
926 assert!(!lines.iter().any(|l| l.starts_with("GENDER")), "{lines:#?}");
927 }
928
929 #[test]
930 fn undone_as_it_was() {
931 let base = std::env::temp_dir().join(format!("sioul-undo-{}", std::process::id()));
932 let _ = std::fs::remove_dir_all(&base);
933 let (books, dir) = (base.join("contacts/local/book"), base.join("undo"));
934 std::fs::create_dir_all(&books).unwrap();
935 let (a, b, c) = (books.join("a.vcf"), books.join("b.vcf"), books.join("c.vcf"));
936 let text_a = card(&["UID:a", "FN:Jean Dupont", "TEL:04 65 71 12 34", "TEL:+33 4 65 71 12 34"]);
937 let text_b = card(&["UID:b", "FN:J. Dupont", "EMAIL:jean@example.org"]);
938 let text_c = card(&["UID:c", "FN:Marie", "EMAIL:m@example.org", "EMAIL:M@example.org"]);
939 for (path, text) in [(&a, &text_a), (&b, &text_b), (&c, &text_c)] {
940 std::fs::write(path, text).unwrap();
941 }
942 let now = 1_791_000_000;
944 let cleaned = clean_files(&[a.clone(), c.clone()], france(), &dir, now).unwrap().unwrap();
945 assert_eq!((cleaned.kind.as_str(), cleaned.names.clone(), cleaned.changes.len()), ("clean", vec!["Jean Dupont".to_string(), "Marie".to_string()], 2));
946 assert_eq!(cleaned.changes[0].before.as_deref(), Some(text_a.as_str()));
947 assert!(!std::fs::read_to_string(&a).unwrap().contains("04 65 71 12 34"));
948 let merged = merge_files(&a, &b, france(), &dir, now + 60).unwrap();
950 assert!(!b.exists() && std::fs::read_to_string(&a).unwrap().contains("jean@example.org"));
951 assert_eq!(merged.changes[1], Change { path: b.clone(), before: Some(text_b.clone()), after: None });
952 let listed = done(&dir, now + 120);
953 assert_eq!(listed.iter().map(|d| d.kind.as_str()).collect::<Vec<_>>(), ["merge", "clean"]);
954 std::fs::write(&a, card(&["UID:a", "FN:Jean Dupont", "NOTE:changed since"])).unwrap();
956 let (touched, aside) = undo(&dir, &listed[0].id, &base.join("contacts")).unwrap();
957 assert_eq!((touched.len(), aside), (2, 1));
958 assert_eq!(std::fs::read_to_string(&b).unwrap(), text_b);
959 assert!(std::fs::read_to_string(&a).unwrap().contains("TEL:+33 4 65 71 12 34") && !std::fs::read_to_string(&a).unwrap().contains("jean@"));
960 assert!(std::fs::read_to_string(dir.join(format!("{}.1.vcf", listed[0].id))).is_ok_and(|t| t.contains("changed since")) || std::fs::read_to_string(dir.join(format!("{}.0.vcf", listed[0].id))).is_ok_and(|t| t.contains("changed since")));
961 undo(&dir, &listed[1].id, &base.join("contacts")).unwrap();
963 assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&c).unwrap()), (text_a.clone(), text_c.clone()));
964 assert!(done(&dir, now + 120).is_empty());
965 assert!(undo(&dir, "../x", &base.join("contacts")).is_err());
967 clean_files(&[c.clone()], france(), &dir, now).unwrap().unwrap();
969 assert_eq!(done(&dir, now + 29 * 86_400).len(), 1);
970 assert!(done(&dir, now + 31 * 86_400).is_empty());
971 let _ = std::fs::remove_dir_all(&base);
972 }
973}