1use crate::lines;
14use crate::vdir::{self, Collection, Kind};
15use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17
18#[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
28pub struct Contact {
29 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 pub addresses: Vec<Labeled>,
39 pub birthday: String,
41 pub notes: String,
42 pub urls: Vec<String>,
43 pub book: String,
45 pub read_only: bool,
46 pub categories: Vec<String>,
53 pub photo: String,
56}
57
58#[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 #[serde(default)]
81 pub categories: Option<Vec<String>>,
82}
83
84pub fn parse(text: &str) -> Option<calcard::vcard::VCard> {
86 calcard::vcard::VCard::parse(text).ok()
87}
88
89pub 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
96pub(crate) struct Line<'a> {
98 pub(crate) name: String,
99 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 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
132pub(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
137pub(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
148pub(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
153fn components(value: &str) -> Vec<String> {
155 split_unescaped(value, ';')
156}
157
158fn 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(¤t));
172 current.clear();
173 } else {
174 current.push(c);
175 }
176 }
177 parts.push(lines::unescape(¤t));
178 parts
179}
180
181pub fn category_key(name: &str) -> String {
185 crate::text::fold(name.trim()).into_iter().collect()
186}
187
188pub(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
203pub(crate) fn categories_line(names: &[String]) -> String {
205 format!("CATEGORIES:{}", names.iter().map(|n| lines::escape(n.trim())).collect::<Vec<_>>().join(","))
206}
207
208pub(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
219pub(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 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
239pub(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
267fn 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 crate::notes::file_url(&path)
296}
297
298fn 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
306fn 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
313fn 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
337pub fn tidy_address(text: &str) -> String {
339 address_lines(&address_parts(text))
340}
341
342fn 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
363pub 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 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 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 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 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 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
429fn 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
456fn 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 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
502fn 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
521fn 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
538fn 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
548pub(crate) fn now_stamp() -> String {
550 jiff::Timestamp::now().strftime("%Y%m%dT%H%M%SZ").to_string()
551}
552
553pub fn new_card(edit: &ContactEdit) -> String {
555 new_card_with_uid(edit, &vdir::new_name())
556}
557
558pub 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
564pub 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 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
612pub 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
622fn 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
631pub 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
649pub 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
664pub 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
670pub 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
675pub 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
690pub fn default_book() -> Option<Collection> {
692 vdir::collections(Kind::Contacts).into_iter().find(|b| !b.read_only)
693}
694
695pub 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(category_key(" AMIS "), category_key("amis"));
888 assert_eq!(category_key("Équipe"), "equipe");
889 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}