Skip to main content

sioul_core/
contacts.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Contacts: one vCard per person (RFC 6350; 3.0 from RFC 2426 too), as the
5//! address books on disk hold them (`vdir`).
6//!
7//! Sioul shows and edits the fields people use: the name, addresses, phone
8//! numbers, the organisation, postal addresses, the birthday, notes and web
9//! sites. Whatever else a card holds (a photo, another application's fields)
10//! is kept as it was when Sioul writes it back. New cards are written in
11//! vCard 3.0, which every server and phone reads.
12
13use crate::lines;
14use crate::vdir::{self, Collection, Kind};
15use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17
18/// A value with what it is for: "work", "home", "cell"…; empty when the card does not say.
19#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
20pub struct Labeled {
21    #[serde(default)]
22    pub label: String,
23    pub value: String,
24}
25
26/// A contact as the window shows it.
27#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
28pub struct Contact {
29    /// The file, to open and change it.
30    pub key: String,
31    pub uid: String,
32    pub name: String,
33    pub emails: Vec<Labeled>,
34    pub phones: Vec<Labeled>,
35    pub org: String,
36    pub title: String,
37    /// Each postal address on its lines.
38    pub addresses: Vec<Labeled>,
39    /// As the card writes it: "1984-05-12", or "--05-12" without the year.
40    pub birthday: String,
41    pub notes: String,
42    pub urls: Vec<String>,
43    /// The address book it is in.
44    pub book: String,
45    pub read_only: bool,
46    /// Its CATEGORIES ("Famille", "Amis"…), what Nextcloud Contacts shows as
47    /// groups: every CATEGORIES line of the card, each name once
48    /// (`category_key`), as the card writes it. The page shows them and
49    /// filters by them, and the sender lists can name them
50    /// (`category:Amis`, porch.rs's `Senders`): their mail then stands as
51    /// their category says, unless an address of theirs has its own entry.
52    pub categories: Vec<String>,
53    /// Its picture to show: an embedded one as a file in the cache ("file://…"),
54    /// else the web address the card gives; empty without one.
55    pub photo: String,
56}
57
58/// What the contact form gives back.
59#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
60pub struct ContactEdit {
61    pub name: String,
62    #[serde(default)]
63    pub emails: Vec<Labeled>,
64    #[serde(default)]
65    pub phones: Vec<Labeled>,
66    #[serde(default)]
67    pub org: String,
68    #[serde(default)]
69    pub title: String,
70    #[serde(default)]
71    pub addresses: Vec<Labeled>,
72    #[serde(default)]
73    pub birthday: String,
74    #[serde(default)]
75    pub notes: String,
76    #[serde(default)]
77    pub urls: Vec<String>,
78    /// Its categories, all of them; none (left out: an import, a sender added)
79    /// leaves the card's as they are.
80    #[serde(default)]
81    pub categories: Option<Vec<String>>,
82}
83
84/// Reads one card with calcard, to tell whether it is valid vCard.
85pub fn parse(text: &str) -> Option<calcard::vcard::VCard> {
86    calcard::vcard::VCard::parse(text).ok()
87}
88
89/// One contact from its file.
90pub fn read(path: &Path, book: &Collection) -> Option<Contact> {
91    let text = std::fs::read_to_string(path).ok()?;
92    let lines = lines::unfold(&text);
93    lines.iter().any(|l| lines::name(l) == "BEGIN").then(|| contact(&lines, path, book))
94}
95
96/// A content line taken apart: its group, name and parameters; the value stays in the line.
97pub(crate) struct Line<'a> {
98    pub(crate) name: String,
99    /// Upper-case parameter names with their values; vCard 2.1's bare values ("TEL;CELL") as TYPE.
100    pub(crate) params: Vec<(String, String)>,
101    pub(crate) value: &'a str,
102}
103
104pub(crate) fn split_line(line: &str) -> Line<'_> {
105    let value = lines::value(line);
106    // Before the colon; a line without one (a broken card) is all head, never cut inside a character.
107    let head = line.len().checked_sub(value.len() + 1).filter(|&at| line.as_bytes().get(at) == Some(&b':')).map_or(line, |at| &line[..at]);
108    let mut parts = Vec::new();
109    let mut quoted = false;
110    let mut start = 0;
111    for (i, c) in head.char_indices() {
112        match c {
113            '"' => quoted = !quoted,
114            ';' if !quoted => {
115                parts.push(&head[start..i]);
116                start = i + 1;
117            }
118            _ => {}
119        }
120    }
121    parts.push(&head[start..]);
122    let mut params = Vec::new();
123    for param in parts.iter().skip(1) {
124        match param.split_once('=') {
125            Some((name, value)) => params.push((name.trim().to_ascii_uppercase(), value.trim().trim_matches('"').to_string())),
126            None => params.push(("TYPE".to_string(), param.trim().to_string())),
127        }
128    }
129    Line { name: lines::name(line), params, value }
130}
131
132/// Every TYPE value of a line, lower case: "TYPE=HOME,PREF" and "TYPE=home;TYPE=pref" alike.
133pub(crate) fn types_of(line: &Line) -> Vec<String> {
134    line.params.iter().filter(|(n, _)| n == "TYPE").flat_map(|(_, v)| v.split(',').map(|t| t.trim().to_lowercase())).filter(|t| !t.is_empty()).collect()
135}
136
137/// "work", "home", "cell"…; "internet", "voice" and "pref" say nothing.
138pub(crate) fn label_of(line: &Line) -> String {
139    let mut labels: Vec<String> = Vec::new();
140    for kind in types_of(line) {
141        if !matches!(kind.as_str(), "internet" | "voice" | "pref" | "x400") && !kind.starts_with("x-") && !labels.contains(&kind) {
142            labels.push(kind);
143        }
144    }
145    labels.join(", ")
146}
147
148/// Whether the card marks this value as the one to use first (TYPE=PREF, PREF=1).
149pub(crate) fn is_preferred(line: &Line) -> bool {
150    types_of(line).iter().any(|t| t == "pref") || line.params.iter().any(|(n, _)| n == "PREF")
151}
152
153/// The parts of a structured value (N, ADR, ORG), split at unescaped semicolons, unescaped.
154fn components(value: &str) -> Vec<String> {
155    split_unescaped(value, ';')
156}
157
158/// A value split at the separators that are not escaped (`A\,B` stays one), each part unescaped.
159fn split_unescaped(value: &str, separator: char) -> Vec<String> {
160    let mut parts = Vec::new();
161    let mut current = String::new();
162    let mut escaped = false;
163    for c in value.chars() {
164        if escaped {
165            current.push('\\');
166            current.push(c);
167            escaped = false;
168        } else if c == '\\' {
169            escaped = true;
170        } else if c == separator {
171            parts.push(lines::unescape(&current));
172            current.clear();
173        } else {
174            current.push(c);
175        }
176    }
177    parts.push(lines::unescape(&current));
178    parts
179}
180
181/// What two category names are compared by: trimmed, case and accents folded
182/// (`text::fold`), so "Amis", " amis" and "AMIS" are one category. The sender
183/// lists match categories the same way (porch.rs).
184pub fn category_key(name: &str) -> String {
185    crate::text::fold(name.trim()).into_iter().collect()
186}
187
188/// A card's categories: every CATEGORIES line, its names split at unescaped
189/// commas, each name once (`category_key`), as first written.
190pub(crate) fn categories_of(card: &[String]) -> Vec<String> {
191    let mut names: Vec<String> = Vec::new();
192    for line in card.iter().filter(|l| lines::name(l) == "CATEGORIES") {
193        for name in split_unescaped(lines::value(line), ',') {
194            let name = name.trim();
195            if !name.is_empty() && !names.iter().any(|n| category_key(n) == category_key(name)) {
196                names.push(name.to_string());
197            }
198        }
199    }
200    names
201}
202
203/// The one CATEGORIES line of these names, each escaped (`A\,B`).
204pub(crate) fn categories_line(names: &[String]) -> String {
205    format!("CATEGORIES:{}", names.iter().map(|n| lines::escape(n.trim())).collect::<Vec<_>>().join(","))
206}
207
208/// What the window shows of a line, by property: the same reading for showing and for editing.
209pub(crate) fn shown(line: &str) -> String {
210    let parsed = split_line(line);
211    match parsed.name.as_str() {
212        "ADR" => address_lines(&components(parsed.value)),
213        "ORG" => components(parsed.value).into_iter().map(|c| c.trim().to_string()).filter(|c| !c.is_empty()).collect::<Vec<_>>().join(", "),
214        "BDAY" => birthday_of(&parsed),
215        _ => lines::unescape(parsed.value).trim().to_string(),
216    }
217}
218
219/// The name a card is shown by: its FN, else its structured name, else its
220/// organisation, else its first e-mail address.
221pub(crate) fn display_name(card: &[String]) -> String {
222    let all = |property: &'static str| card.iter().filter(move |l| lines::name(l) == property);
223    let first = |property: &'static str| all(property).map(|l| shown(l)).find(|v| !v.is_empty()).unwrap_or_default();
224    let structured_name = all("N")
225        .next()
226        .map(|l| {
227            let parts = components(split_line(l).value);
228            // Family; given; additional; prefixes; suffixes → "prefixes given additional family suffixes".
229            let pick = |i: usize| parts.get(i).cloned().unwrap_or_default().replace(',', " ");
230            [pick(3), pick(1), pick(2), pick(0), pick(4)].into_iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect::<Vec<_>>().join(" ")
231        })
232        .unwrap_or_default();
233    [all("FN").next().map(|l| shown(l)).unwrap_or_default(), structured_name, all("ORG").next().map(|l| shown(l)).unwrap_or_default(), first("EMAIL")]
234        .into_iter()
235        .find(|n| !n.trim().is_empty())
236        .unwrap_or_default()
237}
238
239/// The contact a card's lines stand for.
240pub(crate) fn contact(card: &[String], path: &Path, book: &Collection) -> Contact {
241    let all = |property: &'static str| card.iter().filter(move |l| lines::name(l) == property);
242    let first = |property: &'static str| all(property).next().map(|l| shown(l)).unwrap_or_default();
243    let labeled = |property: &'static str| {
244        all(property).map(|l| Labeled { label: label_of(&split_line(l)), value: shown(l) }).filter(|l| !l.value.is_empty()).collect::<Vec<_>>()
245    };
246    let emails = labeled("EMAIL");
247    let org = first("ORG");
248    Contact {
249        key: path.display().to_string(),
250        uid: first("UID"),
251        name: display_name(card),
252        emails,
253        phones: labeled("TEL"),
254        org,
255        title: first("TITLE"),
256        addresses: labeled("ADR"),
257        birthday: first("BDAY"),
258        notes: all("NOTE").map(|l| shown(l)).filter(|n| !n.is_empty()).collect::<Vec<_>>().join("\n"),
259        urls: all("URL").map(|l| shown(l)).filter(|u| !u.is_empty()).collect(),
260        categories: categories_of(card),
261        book: book.name.clone(),
262        read_only: book.read_only,
263        photo: photo_of(card),
264    }
265}
266
267/// A card's picture, to show: an embedded photo (vCard 3.0 `ENCODING=b`,
268/// vCard 4.0 `data:` address) written once into the cache, named by what it
269/// holds, so the same picture is one file; a web address (https only) as it is.
270fn photo_of(card: &[String]) -> String {
271    photo_in(card, &crate::config::cache_dir().join("photos"))
272}
273
274fn photo_in(card: &[String], folder: &Path) -> String {
275    let Some(line) = card.iter().find(|l| lines::name(l) == "PHOTO") else { return String::new() };
276    let parsed = split_line(line);
277    let value = parsed.value.trim();
278    let embedded = parsed.params.iter().any(|(k, v)| k == "ENCODING" && (v.eq_ignore_ascii_case("b") || v.eq_ignore_ascii_case("base64")));
279    let (bytes, kind) = match value.strip_prefix("data:").and_then(|d| d.split_once(";base64,")) {
280        Some((media, data)) => (lines::base64_decode(data), media.rsplit('/').next().unwrap_or("jpeg").to_ascii_lowercase()),
281        None if embedded => (lines::base64_decode(value), parsed.params.iter().find(|(k, _)| k == "TYPE").map_or("jpeg".to_string(), |(_, v)| v.to_ascii_lowercase())),
282        None if value.starts_with("https://") => return value.to_string(),
283        None => return String::new(),
284    };
285    let Some(bytes) = bytes.filter(|b| !b.is_empty()) else { return String::new() };
286    let kind: String = kind.chars().filter(char::is_ascii_alphanumeric).take(5).collect();
287    let path = folder.join(format!("{}.{}", vdir::content_hash(&bytes), if kind.is_empty() { "jpeg" } else { &kind }));
288    if !path.exists() {
289        let written = path.parent().map(std::fs::create_dir_all).transpose().and_then(|_| std::fs::write(&path, &bytes));
290        if written.is_err() {
291            return String::new();
292        }
293    }
294    // "file:///C:/…" on Windows, not "file://C:\…", which shows nothing.
295    crate::notes::file_url(&path)
296}
297
298/// ADR's seven parts (box, extended, street, locality, region, code, country) on lines:
299/// the street, then "code locality", the region, the country.
300fn address_lines(parts: &[String]) -> String {
301    let pick = |i: usize| parts.get(i).map(|s| s.trim().to_string()).unwrap_or_default();
302    let city = [pick(5), pick(3)].into_iter().filter(|p| !p.is_empty()).collect::<Vec<_>>().join(" ");
303    [pick(0), pick(1), pick(2), city, pick(4), pick(6)].into_iter().filter(|p| !p.is_empty()).collect::<Vec<_>>().join("\n")
304}
305
306/// Whether a line is "code locality": its first word four or five digits
307/// ("69000 Lyon Cedex 03", "1000 Bruxelles"), not a street number ("1 allée…").
308fn is_code_line(line: &str) -> bool {
309    let first = line.split_whitespace().next().unwrap_or("");
310    (4..=5).contains(&first.len()) && first.chars().all(|c| c.is_ascii_digit())
311}
312
313/// Lines back into ADR's parts, for an address typed or changed here: a line
314/// whose first word is a postal code is "code locality"; after it, a last line
315/// is the country; the lines before make the street.
316fn address_parts(text: &str) -> Vec<String> {
317    let mut lines: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
318    let code_line = lines.iter().rposition(|l| is_code_line(l));
319    let country = match code_line {
320        Some(position) if position + 1 < lines.len() => lines.pop().unwrap_or("").to_string(),
321        None if lines.len() >= 3 => lines.pop().unwrap_or("").to_string(),
322        _ => String::new(),
323    };
324    let (mut code, mut locality) = (String::new(), String::new());
325    if let Some(position) = lines.iter().rposition(|l| is_code_line(l)) {
326        let line = lines.remove(position);
327        match line.split_once(' ') {
328            Some((c, l)) => (code, locality) = (c.to_string(), l.trim().to_string()),
329            None => code = line.to_string(),
330        }
331    } else if lines.len() >= 2 {
332        locality = lines.pop().unwrap_or("").to_string();
333    }
334    vec![String::new(), String::new(), lines.join(", "), locality, String::new(), code, country]
335}
336
337/// An address as it reads back once written: its lines, their parts found.
338pub fn tidy_address(text: &str) -> String {
339    address_lines(&address_parts(text))
340}
341
342/// "1984-05-12", or "--05-12" without the year: from "19840512", "1984-05-12T00:00:00",
343/// and Apple's way of saying "no year" (X-APPLE-OMIT-YEAR).
344fn birthday_of(line: &Line) -> String {
345    let raw = lines::unescape(line.value);
346    let digits: String = raw.split('T').next().unwrap_or("").chars().filter(|c| c.is_ascii_digit() || *c == '-').collect();
347    let compact: String = digits.replace('-', "");
348    let omitted = line.params.iter().find(|(n, _)| n == "X-APPLE-OMIT-YEAR").map(|(_, v)| v.clone());
349    let (year, rest) = if digits.starts_with("--") {
350        (None, compact.as_str())
351    } else if compact.len() >= 8 {
352        (Some(&compact[..4]), &compact[4..])
353    } else {
354        return raw.trim().to_string();
355    };
356    let (month, day) = (rest.get(..2).unwrap_or(""), rest.get(2..4).unwrap_or(""));
357    match year.filter(|y| omitted.as_deref() != Some(*y)) {
358        Some(year) => format!("{year}-{month}-{day}"),
359        None => format!("--{month}-{day}"),
360    }
361}
362
363/// The card's text with what the form changed, and nothing else: an unchanged
364/// value keeps its line byte for byte (its group, its PREF, its spelling), so
365/// what another application wrote is never rewritten for nothing. A card
366/// nothing changed in comes back exactly as it was.
367pub fn apply(text: &str, edit: &ContactEdit) -> String {
368    let original = lines::unfold(text);
369    let mut out: Vec<Option<String>> = original.iter().cloned().map(Some).collect();
370    let mut added: Vec<String> = Vec::new();
371    let mut changed = false;
372    let name = edit.name.trim();
373    // Single values: kept when they read the same.
374    for (property, wanted, write) in [
375        ("FN", name.to_string(), format!("FN:{}", lines::escape(name))),
376        ("ORG", edit.org.trim().to_string(), format!("ORG:{}", lines::escape(edit.org.trim()))),
377        ("TITLE", edit.title.trim().to_string(), format!("TITLE:{}", lines::escape(edit.title.trim()))),
378        ("BDAY", edit.birthday.trim().to_string(), format!("BDAY:{}", one_line(edit.birthday.trim()))),
379        ("NOTE", edit.notes.trim().to_string(), format!("NOTE:{}", lines::escape(edit.notes.trim()))),
380    ] {
381        let present: Vec<usize> = original.iter().enumerate().filter(|(_, l)| lines::name(l) == property).map(|(i, _)| i).collect();
382        let current = present.iter().map(|&i| shown(&original[i])).filter(|v| !v.is_empty()).collect::<Vec<_>>().join("\n");
383        if current == wanted {
384            continue;
385        }
386        changed = true;
387        for &i in &present {
388            out[i] = None;
389        }
390        if property == "FN" {
391            // The structured name follows a new name.
392            for (i, line) in original.iter().enumerate() {
393                if lines::name(line) == "N" {
394                    out[i] = None;
395                }
396            }
397            added.push(format!("N:{}", lines::structured(&structured_name(name))));
398        }
399        if !wanted.is_empty() {
400            match present.first() {
401                Some(&i) => out[i] = Some(write),
402                None => added.push(write),
403            }
404        }
405    }
406    // Several values: each kept, relabelled, added or removed, by its value.
407    let emails: Vec<(String, String)> = edit.emails.iter().map(|e| (e.label.clone(), e.value.trim().to_string())).collect();
408    let phones: Vec<(String, String)> = edit.phones.iter().map(|e| (e.label.clone(), e.value.trim().to_string())).collect();
409    let addresses: Vec<(String, String)> = edit.addresses.iter().map(|e| (e.label.clone(), e.value.trim().to_string())).collect();
410    let urls: Vec<(String, String)> = edit.urls.iter().map(|u| (String::new(), u.trim().to_string())).collect();
411    for (property, wanted) in [("EMAIL", emails), ("TEL", phones), ("ADR", addresses), ("URL", urls)] {
412        changed |= merge(&original, &mut out, &mut added, property, &wanted);
413    }
414    // Categories: as they are, unless the form gives others; then one line.
415    if let Some(wanted) = &edit.categories {
416        changed |= set_categories(&original, &mut out, &mut added, wanted);
417    }
418    if !changed {
419        return text.to_string();
420    }
421    let mut kept: Vec<String> = out.into_iter().flatten().filter(|l| lines::name(l) != "REV").collect();
422    added.push(format!("REV:{}", now_stamp()));
423    // Before END:VCARD.
424    let end = kept.iter().rposition(|l| lines::name(l) == "END").unwrap_or(kept.len());
425    kept.splice(end..end, added);
426    lines::fold(&kept)
427}
428
429/// The card's categories made these (`category_key` once each): its CATEGORIES
430/// lines untouched when they say the same, else one line where the first was.
431/// Returns whether anything changed.
432fn set_categories(original: &[String], out: &mut [Option<String>], added: &mut Vec<String>, wanted: &[String]) -> bool {
433    let mut names: Vec<String> = Vec::new();
434    for name in wanted.iter().map(|n| n.trim()).filter(|n| !n.is_empty()) {
435        if !names.iter().any(|n| category_key(n) == category_key(name)) {
436            names.push(name.to_string());
437        }
438    }
439    if categories_of(original) == names {
440        return false;
441    }
442    let present: Vec<usize> = original.iter().enumerate().filter(|(_, l)| lines::name(l) == "CATEGORIES").map(|(i, _)| i).collect();
443    for &i in &present {
444        out[i] = None;
445    }
446    if !names.is_empty() {
447        let line = categories_line(&names);
448        match present.first() {
449            Some(&i) => out[i] = Some(line),
450            None => added.push(line),
451        }
452    }
453    true
454}
455
456/// One property of several values: an unchanged value keeps its line; a new
457/// label rewrites only that line (keeping its group and PREF); new values are
458/// added; values gone are removed, with the lines their group carried
459/// (Apple's `item1.X-ABLabel`). Returns whether anything changed.
460fn merge(original: &[String], out: &mut [Option<String>], added: &mut Vec<String>, property: &str, wanted: &[(String, String)]) -> bool {
461    let present: Vec<usize> = original.iter().enumerate().filter(|(_, l)| lines::name(l) == property).map(|(i, _)| i).collect();
462    let mut used: Vec<usize> = Vec::new();
463    let mut changed = false;
464    for (label, value) in wanted.iter().filter(|(_, v)| !v.is_empty()) {
465        let found = present.iter().copied().find(|i| !used.contains(i) && shown(&original[*i]) == *value);
466        match found {
467            Some(i) => {
468                used.push(i);
469                let line = split_line(&original[i]);
470                let first = label_of(&line).split(',').next().unwrap_or("").trim().to_string();
471                if first != label.trim().to_lowercase() {
472                    changed = true;
473                    let group = original[i].split([';', ':']).next().and_then(|h| h.rsplit_once('.')).map(|(g, _)| format!("{g}.")).unwrap_or_default();
474                    out[i] = Some(write_value(&group, property, label, value, is_preferred(&line)));
475                }
476            }
477            None => {
478                changed = true;
479                added.push(write_value("", property, label, value, false));
480            }
481        }
482    }
483    for &i in present.iter().filter(|i| !used.contains(i)) {
484        changed = true;
485        out[i] = None;
486        // The lines of its group go with it, unless another line still uses the group.
487        if let Some((group, _)) = original[i].split([';', ':']).next().and_then(|h| h.rsplit_once('.')) {
488            let prefix = format!("{group}.");
489            let still_used = original.iter().enumerate().any(|(j, l)| out[j].is_some() && l.starts_with(&prefix) && ["EMAIL", "TEL", "ADR", "URL"].contains(&lines::name(l).as_str()));
490            if !still_used {
491                for (j, line) in original.iter().enumerate() {
492                    if line.starts_with(&prefix) {
493                        out[j] = None;
494                    }
495                }
496            }
497        }
498    }
499    changed
500}
501
502/// A value's line, as vCard 3.0 writes it.
503fn write_value(group: &str, property: &str, label: &str, value: &str, preferred: bool) -> String {
504    let mut types: Vec<&str> = Vec::new();
505    if property == "EMAIL" {
506        types.push("INTERNET");
507    }
508    types.extend(known_type(label));
509    if preferred {
510        types.push("PREF");
511    }
512    let params = if types.is_empty() { String::new() } else { format!(";TYPE={}", types.join(",")) };
513    let value = match property {
514        "ADR" => lines::structured(&address_parts(value)),
515        "URL" => one_line(value),
516        _ => lines::escape(value),
517    };
518    format!("{group}{property}{params}:{value}")
519}
520
521/// A value written as it is (a date, a web address): without line breaks or
522/// other control characters, which would start another line of the card.
523fn one_line(value: &str) -> String {
524    value.chars().filter(|c| !c.is_control()).collect()
525}
526
527fn known_type(label: &str) -> Option<&'static str> {
528    match label.trim().to_lowercase().as_str() {
529        "work" => Some("WORK"),
530        "home" => Some("HOME"),
531        "cell" | "mobile" => Some("CELL"),
532        "fax" => Some("FAX"),
533        "pager" => Some("PAGER"),
534        _ => None,
535    }
536}
537
538/// "Jane van Exemple" → N: family "van Exemple"? Names do not split by rule;
539/// the last word is taken as the family name, the rest as given names.
540fn structured_name(name: &str) -> Vec<String> {
541    let words: Vec<&str> = name.split_whitespace().collect();
542    match words.split_last() {
543        Some((family, given)) if !given.is_empty() => vec![family.to_string(), given.join(" "), String::new(), String::new(), String::new()],
544        _ => vec![name.to_string(), String::new(), String::new(), String::new(), String::new()],
545    }
546}
547
548/// "20261003T120000Z", for REV.
549pub(crate) fn now_stamp() -> String {
550    jiff::Timestamp::now().strftime("%Y%m%dT%H%M%SZ").to_string()
551}
552
553/// A new card from the form, in vCard 3.0, with its own UID.
554pub fn new_card(edit: &ContactEdit) -> String {
555    new_card_with_uid(edit, &vdir::new_name())
556}
557
558/// A new card from the form, with the UID given (an import that must find it again).
559pub fn new_card_with_uid(edit: &ContactEdit, uid: &str) -> String {
560    let start = lines::fold(&["BEGIN:VCARD".to_string(), "VERSION:3.0".to_string(), "PRODID:-//Sioul//Sioul//EN".to_string(), format!("UID:{uid}"), "END:VCARD".to_string()]);
561    apply(&start, edit)
562}
563
564/// A card as vCard 3.0 (RFC 2426), for servers that take nothing newer
565/// (Google): what only 4.0 has goes, `PREF=1` becomes `TYPE=pref`, a photo
566/// given as a `data:` address is written inline. A 3.0 card comes back as is.
567pub fn as_vcard3(card: &str) -> String {
568    let lines = lines::unfold(card);
569    if !lines.iter().any(|l| l.eq_ignore_ascii_case("VERSION:4.0")) {
570        return card.to_string();
571    }
572    const ONLY_4: &[&str] = &["KIND", "GENDER", "ANNIVERSARY", "MEMBER", "RELATED", "LANG", "CLIENTPIDMAP", "XML", "CALADRURI", "CALURI", "FBURL"];
573    let mut out = Vec::with_capacity(lines.len());
574    for line in lines {
575        let Some((head, value)) = line.split_once(':') else {
576            out.push(line);
577            continue;
578        };
579        let mut params = head.split(';');
580        let name = params.next().unwrap_or("");
581        let bare = name.rsplit('.').next().unwrap_or(name).to_ascii_uppercase();
582        if bare == "VERSION" {
583            out.push("VERSION:3.0".to_string());
584            continue;
585        }
586        if ONLY_4.contains(&bare.as_str()) {
587            continue;
588        }
589        let mut kept: Vec<String> = Vec::new();
590        for param in params {
591            let upper = param.to_ascii_uppercase();
592            if upper.starts_with("PREF=") {
593                kept.push("TYPE=pref".into());
594            } else if !(upper.starts_with("PID=") || upper.starts_with("ALTID=") || upper.starts_with("VALUE=URI") && value.starts_with("data:")) {
595                kept.push(param.to_string());
596            }
597        }
598        // PHOTO:data:image/jpeg;base64,… → PHOTO;ENCODING=b;TYPE=JPEG:…
599        if let Some((media, data)) = value.strip_prefix("data:").and_then(|d| d.split_once(";base64,")) {
600            let format = media.rsplit('/').next().unwrap_or("jpeg").to_ascii_uppercase();
601            kept.push("ENCODING=b".into());
602            kept.push(format!("TYPE={format}"));
603            out.push(format!("{name};{}:{data}", kept.join(";")));
604            continue;
605        }
606        let head = std::iter::once(name.to_string()).chain(kept).collect::<Vec<_>>().join(";");
607        out.push(format!("{head}:{value}"));
608    }
609    lines::fold(&out)
610}
611
612/// Every contact of every address book, by name.
613pub fn all() -> Vec<Contact> {
614    let mut contacts: Vec<Contact> = vdir::collections(Kind::Contacts)
615        .iter()
616        .flat_map(|book| book.items().into_iter().filter_map(|path| read(&path, book)).collect::<Vec<_>>())
617        .collect();
618    contacts.sort_by_key(|c| sort_key(&c.name));
619    contacts
620}
621
622/// Names sort without their accents and case: "Émile" next to "Emma".
623fn sort_key(name: &str) -> String {
624    folded(name)
625}
626
627fn folded(text: &str) -> String {
628    crate::text::fold(text).into_iter().collect::<String>().to_lowercase()
629}
630
631/// Contacts whose name, organisation, addresses or numbers hold the query.
632pub fn search<'a>(contacts: &'a [Contact], query: &str) -> Vec<&'a Contact> {
633    let query = folded(query.trim());
634    if query.is_empty() {
635        return contacts.iter().collect();
636    }
637    contacts
638        .iter()
639        .filter(|c| {
640            std::iter::once(&c.name)
641                .chain(std::iter::once(&c.org))
642                .chain(c.emails.iter().map(|e| &e.value))
643                .chain(c.phones.iter().map(|p| &p.value))
644                .any(|field| folded(field).contains(&query))
645        })
646        .collect()
647}
648
649/// The categories the cards have, to filter by: the most used first, then by
650/// name; each name once (`category_key`), as first written.
651pub fn categories_in_use(contacts: &[Contact]) -> Vec<String> {
652    let mut used: Vec<(usize, String, String)> = Vec::new();
653    for name in contacts.iter().flat_map(|c| c.categories.iter()) {
654        let key = category_key(name);
655        match used.iter_mut().find(|(_, k, _)| *k == key) {
656            Some((count, _, _)) => *count += 1,
657            None => used.push((1, key, name.clone())),
658        }
659    }
660    used.sort_by(|(a, x, _), (b, y, _)| b.cmp(a).then_with(|| x.cmp(y)));
661    used.into_iter().map(|(_, _, name)| name).collect()
662}
663
664/// Whether a contact has this category, case and accents aside.
665pub fn in_category(contact: &Contact, category: &str) -> bool {
666    let wanted = category_key(category);
667    contact.categories.iter().any(|c| category_key(c) == wanted)
668}
669
670/// The contact with this e-mail address.
671pub fn by_address<'a>(contacts: &'a [Contact], address: &str) -> Option<&'a Contact> {
672    contacts.iter().find(|c| c.emails.iter().any(|e| e.value.eq_ignore_ascii_case(address.trim())))
673}
674
675/// Addresses to complete what is typed in a To field: "Jane <jane@example.org>".
676pub fn completions(contacts: &[Contact], typed: &str, limit: usize) -> Vec<String> {
677    let typed = folded(typed.trim());
678    if typed.chars().count() < 2 {
679        return Vec::new();
680    }
681    contacts
682        .iter()
683        .flat_map(|c| c.emails.iter().map(move |e| (c, e)))
684        .filter(|(c, e)| folded(&c.name).split_whitespace().any(|w| w.starts_with(&typed)) || folded(&e.value).starts_with(&typed) || folded(&c.name).starts_with(&typed))
685        .take(limit)
686        .map(|(c, e)| if c.name.is_empty() || c.name == e.value { e.value.clone() } else { format!("{} <{}>", c.name, e.value) })
687        .collect()
688}
689
690/// Where a new contact goes: the first address book that can be written to.
691pub fn default_book() -> Option<Collection> {
692    vdir::collections(Kind::Contacts).into_iter().find(|b| !b.read_only)
693}
694
695/// The file a new contact gets in an address book.
696pub fn new_path(book: &Collection) -> PathBuf {
697    book.dir.join(format!("{}.vcf", vdir::new_name()))
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    #[test]
705    fn a_photo_to_show() {
706        let card = |photo: &str| lines::unfold(&format!("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Jane\r\n{photo}\r\nEND:VCARD\r\n"));
707        let folder = std::env::temp_dir().join(format!("sioul-photos-{}", std::process::id()));
708        let photo_of = |card: &[String]| photo_in(card, &folder);
709        let three = photo_of(&card("PHOTO;ENCODING=b;TYPE=PNG:aGVsbG8="));
710        let four = photo_of(&card("PHOTO:data:image/png;base64,aGVsbG8="));
711        assert!(three.starts_with("file:///") && three.ends_with(".png"), "{three}");
712        // The same picture, one file.
713        assert_eq!(three, four);
714        let written = folder.join(format!("{}.png", vdir::content_hash(b"hello")));
715        assert_eq!((three.clone(), std::fs::read(&written).unwrap()), (crate::notes::file_url(&written), b"hello".to_vec()));
716        assert_eq!(photo_of(&card("PHOTO;VALUE=uri:https://example.org/jane.jpg")), "https://example.org/jane.jpg");
717        assert_eq!(photo_of(&card("PHOTO;VALUE=uri:http://example.org/jane.jpg")), "");
718        assert_eq!(photo_of(&card("NOTE:none")), "");
719        let _ = std::fs::remove_dir_all(&folder);
720    }
721
722    #[test]
723    fn a_card_for_google() {
724        let card = "BEGIN:VCARD\r\nVERSION:4.0\r\nKIND:individual\r\nFN:Jane\r\nEMAIL;PREF=1;TYPE=work:jane@example.org\r\nGENDER:F\r\nPHOTO:data:image/png;base64,iVBORw0K\r\nEND:VCARD\r\n";
725        let three = as_vcard3(card);
726        assert_eq!(three, "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Jane\r\nEMAIL;TYPE=pref;TYPE=work:jane@example.org\r\nPHOTO;ENCODING=b;TYPE=PNG:iVBORw0K\r\nEND:VCARD\r\n");
727        assert_eq!(as_vcard3(&three), three);
728    }
729
730    const CARD: &str = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:jane-1\r\nFN:Jane Exemple\r\nN:Exemple;Jane;;;\r\n\
731        EMAIL;TYPE=INTERNET,WORK:jane@example.org\r\nEMAIL;TYPE=HOME:jane@example.net\r\nTEL;TYPE=CELL:+33 6 00 00 00 00\r\n\
732        ORG:Exemple SARL\r\nADR;TYPE=WORK:;;1 rue de l'Exemple;Lyon;;69000;France\r\nBDAY:1984-05-12\r\n\
733        X-OTHER-APP:keep me\r\nPHOTO;ENCODING=b;TYPE=JPEG:AAAA\r\nNOTE:Met at the market\r\nEND:VCARD\r\n";
734
735    fn book() -> Collection {
736        Collection { kind: Kind::Contacts, account: "a".into(), id: "contacts".into(), dir: PathBuf::from("/tmp"), name: "Contacts".into(), color: None, read_only: false, components: vec![] }
737    }
738
739    #[test]
740    fn reads_the_fields_people_use() {
741        let c = contact(&lines::unfold(CARD), Path::new("/tmp/jane.vcf"), &book());
742        assert_eq!(c.name, "Jane Exemple");
743        assert_eq!(c.emails, vec![Labeled { label: "work".into(), value: "jane@example.org".into() }, Labeled { label: "home".into(), value: "jane@example.net".into() }]);
744        assert_eq!(c.phones[0].label, "cell");
745        assert_eq!(c.org, "Exemple SARL");
746        assert_eq!(c.addresses[0].value, "1 rue de l'Exemple\n69000 Lyon\nFrance");
747        assert_eq!(c.birthday, "1984-05-12");
748        assert_eq!(c.notes, "Met at the market");
749    }
750
751    #[test]
752    fn editing_keeps_what_sioul_does_not_show() {
753        let edit = ContactEdit {
754            name: "Jane Martin".into(),
755            emails: vec![Labeled { label: "work".into(), value: "jane@example.org".into() }],
756            phones: vec![],
757            addresses: vec![Labeled { label: "home".into(), value: "2 place du Square\n69000 Lyon\nFrance".into() }],
758            ..ContactEdit::default()
759        };
760        let text = apply(CARD, &edit);
761        assert!(text.contains("X-OTHER-APP:keep me") && text.contains("PHOTO;ENCODING=b;TYPE=JPEG:AAAA"), "{text}");
762        assert!(text.contains("FN:Jane Martin") && text.contains("N:Martin;Jane;;;"), "{text}");
763        assert!(!text.contains("jane@example.net") && !text.contains("TEL"), "{text}");
764        let again = contact(&lines::unfold(&text), Path::new("/tmp/jane.vcf"), &book());
765        assert_eq!(again.addresses[0].value, "2 place du Square\n69000 Lyon\nFrance");
766        assert_eq!(again.addresses[0].label, "home");
767        assert!(text.starts_with("BEGIN:VCARD\r\nVERSION:3.0"), "{text}");
768    }
769
770    /// The shapes found in real address books: PREF, VOICE, lower-case types, Apple's
771    /// groups and "no year" birthdays, compact dates, every part of an address.
772    const MIXED: &str = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:mixed-1\r\nFN:Lou Exemple\r\nN:Exemple;Lou;;;\r\n\
773        EMAIL;TYPE=PREF;PREF=1:lou@example.org\r\nEMAIL;TYPE=home:lou@example.net\r\nitem1.EMAIL:lou@example.com\r\n\
774        item1.X-ABLabel:club\r\nTEL;TYPE=VOICE,PREF:+33 4 00 00 00 00\r\nTEL;TYPE=cell:+33 6 00 00 00 00\r\n\
775        ADR;TYPE=HOME:BP 12;Bât. B;1 rue de l\\'Exemple;Lyon;Rhône;69000;France\r\n\
776        BDAY;X-APPLE-OMIT-YEAR=1604:1604-05-12\r\nNOTE:Lunch\\, then the market\r\nEND:VCARD\r\n";
777
778    #[test]
779    fn french_addresses_split_where_they_should() {
780        assert_eq!(address_parts("12 rue de l'Exemple\nCS 90000\n69000 Lyon Cedex 03"), vec!["", "", "12 rue de l'Exemple, CS 90000", "Lyon Cedex 03", "", "69000", ""]);
781        assert_eq!(address_parts("5 place de l'Exemple\n69001 Lyon\nFrance")[6], "France");
782        assert_eq!(tidy_address("12 rue de l'Exemple\nCS 90000\n69000 Lyon Cedex 03"), "12 rue de l'Exemple, CS 90000\n69000 Lyon Cedex 03");
783    }
784
785    #[test]
786    fn an_unchanged_card_comes_back_as_it_was() {
787        let c = contact(&lines::unfold(MIXED), Path::new("/tmp/m.vcf"), &book());
788        assert_eq!(c.birthday, "--05-12");
789        assert_eq!(c.emails.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["", "home", ""]);
790        assert_eq!(c.addresses[0].value, "BP 12\nBât. B\n1 rue de l'Exemple\n69000 Lyon\nRhône\nFrance");
791        assert_eq!(c.notes, "Lunch, then the market");
792        let same = ContactEdit {
793            name: c.name.clone(),
794            emails: c.emails.clone(),
795            phones: c.phones.clone(),
796            org: c.org.clone(),
797            title: c.title.clone(),
798            addresses: c.addresses.clone(),
799            birthday: c.birthday.clone(),
800            notes: c.notes.clone(),
801            urls: c.urls.clone(),
802            categories: Some(c.categories.clone()),
803        };
804        assert_eq!(apply(MIXED, &same), MIXED);
805        let compact = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:A\r\nBDAY;VALUE=DATE:19840512\r\nEND:VCARD\r\n";
806        assert_eq!(contact(&lines::unfold(compact), Path::new("/tmp/c.vcf"), &book()).birthday, "1984-05-12");
807    }
808
809    #[test]
810    fn broken_cards_and_values_written_as_they_are() {
811        // A line without its colon, ending in an accent: read, not a crash.
812        let broken = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:A\r\nNOTE;café\r\nTEL;CELL;é\r\nEND:VCARD\r\n";
813        assert_eq!(contact(&lines::unfold(broken), Path::new("/tmp/b.vcf"), &book()).name, "A");
814        // A line break in a web address or a birthday stays out of the card.
815        let edit = ContactEdit { name: "A".into(), urls: vec!["https://example.org/\r\nEMAIL:x@example.org".into()], birthday: "1984-05-12\nNOTE:x".into(), ..ContactEdit::default() };
816        let text = new_card(&edit);
817        assert!(!text.contains("\nEMAIL") && !text.contains("\nNOTE") && text.contains("URL:https://example.org/EMAIL:x@example.org"), "{text}");
818    }
819
820    #[test]
821    fn a_change_touches_only_its_line() {
822        let c = contact(&lines::unfold(MIXED), Path::new("/tmp/m.vcf"), &book());
823        let mut edit = ContactEdit { name: c.name.clone(), emails: c.emails.clone(), phones: c.phones.clone(), addresses: c.addresses.clone(), birthday: c.birthday.clone(), notes: c.notes.clone(), ..ContactEdit::default() };
824        // The club address goes, the mobile becomes "work", a number comes.
825        edit.emails.retain(|e| e.value != "lou@example.com");
826        edit.phones[1].label = "work".into();
827        edit.phones.push(Labeled { label: "home".into(), value: "+33 4 00 00 00 11".into() });
828        let text = apply(MIXED, &edit);
829        assert!(text.contains("EMAIL;TYPE=PREF;PREF=1:lou@example.org") && text.contains("EMAIL;TYPE=home:lou@example.net"), "{text}");
830        assert!(!text.contains("item1.") && !text.contains("lou@example.com"), "{text}");
831        assert!(text.contains("TEL;TYPE=VOICE,PREF:+33 4 00 00 00 00") && text.contains("TEL;TYPE=WORK:+33 6 00 00 00 00"), "{text}");
832        assert!(text.contains("TEL;TYPE=HOME:+33 4 00 00 00 11") && text.contains("BDAY;X-APPLE-OMIT-YEAR=1604:1604-05-12"), "{text}");
833        assert!(text.contains("ADR;TYPE=HOME:BP 12;Bât. B;") && text.contains("REV:"), "{text}");
834    }
835
836    #[test]
837    fn new_cards_and_completion() {
838        let text = new_card(&ContactEdit { name: "Paul Exemple".into(), emails: vec![Labeled { label: String::new(), value: "paul@example.org".into() }], ..ContactEdit::default() });
839        assert!(parse(&text).is_some());
840        let card = lines::unfold(&text);
841        assert!(text.contains("UID:sioul-") && text.contains("FN:Paul Exemple") && text.contains("EMAIL;TYPE=INTERNET:paul@example.org"), "{text}");
842        let contacts = vec![contact(&card, Path::new("/tmp/p.vcf"), &book()), contact(&lines::unfold(CARD), Path::new("/tmp/j.vcf"), &book())];
843        assert_eq!(completions(&contacts, "pa", 5), vec!["Paul Exemple <paul@example.org>"]);
844        assert_eq!(completions(&contacts, "exe", 5).len(), 3);
845        assert_eq!(search(&contacts, "SARL").len(), 1);
846        assert_eq!(by_address(&contacts, "JANE@example.net").map(|c| c.name.as_str()), Some("Jane Exemple"));
847    }
848
849    /// The form's values for a contact, nothing changed.
850    fn edit_of(c: &Contact) -> ContactEdit {
851        ContactEdit {
852            name: c.name.clone(),
853            emails: c.emails.clone(),
854            phones: c.phones.clone(),
855            org: c.org.clone(),
856            title: c.title.clone(),
857            addresses: c.addresses.clone(),
858            birthday: c.birthday.clone(),
859            notes: c.notes.clone(),
860            urls: c.urls.clone(),
861            categories: Some(c.categories.clone()),
862        }
863    }
864
865    #[test]
866    fn categories_as_nextcloud_writes_them() {
867        // Two lines, an escaped comma, one name twice in another case: one list.
868        let card = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c-1\r\nFN:Lou Exemple\r\nCATEGORIES:Amis,Famille\r\nX-OTHER:keep\r\nCATEGORIES:amis,Club\\, échecs\r\nEND:VCARD\r\n";
869        let c = contact(&lines::unfold(card), Path::new("/tmp/c.vcf"), &book());
870        assert_eq!(c.categories, ["Amis", "Famille", "Club, échecs"]);
871        // Saved as they are: the card untouched, both lines kept; left out: untouched too.
872        let mut edit = edit_of(&c);
873        assert_eq!(apply(card, &edit), card);
874        edit.categories = None;
875        assert_eq!(apply(card, &edit), card);
876        // One taken off, one added: one line where the first was, its comma escaped, the rest kept.
877        edit.categories = Some(vec!["Famille".into(), "Club, échecs".into(), "Voisins".into(), " famille ".into()]);
878        let text = apply(card, &edit);
879        assert!(text.contains("\r\nCATEGORIES:Famille,Club\\, échecs,Voisins\r\nX-OTHER:keep\r\n") && text.matches("CATEGORIES").count() == 1, "{text}");
880        assert_eq!(contact(&lines::unfold(&text), Path::new("/tmp/c.vcf"), &book()).categories, ["Famille", "Club, échecs", "Voisins"]);
881        // All taken off: no line. A new card: one line, each name once.
882        edit.categories = Some(Vec::new());
883        assert!(!apply(card, &edit).contains("CATEGORIES"));
884        let new = new_card(&ContactEdit { name: "Paul".into(), categories: Some(vec!["Amis".into(), " amis ".into()]), ..ContactEdit::default() });
885        assert!(new.contains("\r\nCATEGORIES:Amis\r\n"), "{new}");
886        // Compared folded, as the sender lists compare them.
887        assert_eq!(category_key(" AMIS "), category_key("amis"));
888        assert_eq!(category_key("Équipe"), "equipe");
889        // The list filters by them: the most used first, each name once, as first written.
890        let with = |file: &str, names: &str| contact(&lines::unfold(&format!("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:{file}\r\nCATEGORIES:{names}\r\nEND:VCARD\r\n")), Path::new(file), &book());
891        let everyone = [with("a", "Famille,Club"), with("b", "famille"), with("c", "Équipe"), with("d", "equipe,FAMILLE")];
892        assert_eq!(categories_in_use(&everyone), ["Famille", "Équipe", "Club"]);
893        assert_eq!(everyone.iter().filter(|c| in_category(c, "FAMILLE")).count(), 3);
894        assert!(in_category(&everyone[3], "Équipe") && !in_category(&everyone[1], "Club"));
895    }
896}