Git · dadoum.zone
Repositories
Help
Report an Issue
apple-crates
Code
Commits
Branches
Tags
Search
Tree:
2ab8bf0
Branches
Tags
main
apple-crates
icloud
src
vcard.rs
Add instructions for agents, and a few minor API adjustments and additions.
Dadoum
commited
2ab8bf0
at 2026-10-27 03:57:30
vcard.rs
Blame
History
Raw
//! A lossless vCard editor: untouched properties (including photos and Apple //! extensions) survive edits. Values are decoded only for the fields we expose. use thiserror::Error; #[derive(Debug, Error)] #[error("This contact is not a supported vCard 3.0 or 4.0.")] pub struct VCardError; #[derive(Clone, PartialEq, Eq)] pub struct VCard { lines: Vec<String>, } #[derive(Clone)] pub struct CardField { pub index: usize, pub name: String, pub label: String, pub values: Vec<String>, } fn property(line: &str) -> Option<(&str, &str, &str)> { // A colon inside a quoted parameter is not the value delimiter. let mut quoted = false; let colon = line.char_indices().find_map(|(i, c)| { if c == '"' { quoted = !quoted; } (c == ':' && !quoted).then_some(i) })?; let head = &line[..colon]; let name = head.split(';').next()?.rsplit('.').next()?; Some((head, name, &line[colon + 1..])) } fn decode(value: &str, structured: bool) -> Vec<String> { let mut values = vec![String::new()]; let mut chars = value.chars(); while let Some(c) = chars.next() { match c { '\\' => { if let Some(c) = chars.next() { values .last_mut() .unwrap() .push(if matches!(c, 'n' | 'N') { '\n' } else { c }); } } ';' if structured => values.push(String::new()), _ => values.last_mut().unwrap().push(c), } } values } fn encode(value: &str) -> String { value .replace('\\', "\\\\") .replace("\r\n", "\n") .replace('\r', "\n") .replace('\n', "\\n") .replace(';', "\\;") .replace(',', "\\,") } impl VCard { pub fn parse(text: &str) -> Result<Self, VCardError> { let mut lines: Vec<String> = Vec::new(); for line in text.lines() { if (line.starts_with(' ') || line.starts_with('\t')) && !lines.is_empty() { lines.last_mut().unwrap().push_str(&line[1..]); } else if !line.is_empty() { lines.push(line.to_owned()); } } if lines .first() .is_none_or(|s| !s.eq_ignore_ascii_case("BEGIN:VCARD")) || lines .last() .is_none_or(|s| !s.eq_ignore_ascii_case("END:VCARD")) || !lines .iter() .any(|s| s == "VERSION:3.0" || s == "VERSION:4.0") || lines .iter() .filter(|s| s.eq_ignore_ascii_case("BEGIN:VCARD")) .count() != 1 || lines.iter().any(|line| property(line).is_none()) { return Err(VCardError); } Ok(Self { lines }) } pub fn new_contact() -> Self { Self { lines: vec![ "BEGIN:VCARD".into(), "VERSION:3.0".into(), format!("UID:{}", uuid::Uuid::new_v4()), "FN:".into(), "N:;;;;".into(), "END:VCARD".into(), ], } } pub fn uid(&self) -> Option<&str> { self.lines.iter().find_map(|line| { let (_, name, value) = property(line)?; name.eq_ignore_ascii_case("UID").then_some(value) }) } pub fn display_name(&self) -> String { let fields = self.fields(); if let Some(field) = fields .iter() .find(|f| f.name == "FN" && !f.values[0].trim().is_empty()) { return field.values[0].clone(); } if let Some(field) = fields.iter().find(|f| f.name == "N") { let name = [3, 1, 2, 0, 4] .into_iter() .filter_map(|i| field.values.get(i)) .filter(|s| !s.trim().is_empty()) .cloned() .collect::<Vec<_>>() .join(" "); if !name.is_empty() { return name; } } fields .iter() .find(|f| matches!(f.name.as_str(), "ORG" | "EMAIL" | "TEL") && !f.values[0].is_empty()) .map(|f| f.values.join(" ")) .unwrap_or_else(|| "Unnamed contact".into()) } pub fn fields(&self) -> Vec<CardField> { self.lines .iter() .enumerate() .filter_map(|(index, line)| { let (head, name, value) = property(line)?; let name = name.to_ascii_uppercase(); let title = match name.as_str() { "FN" => "Display name", "N" => "Name", "ORG" => "Company", "TITLE" => "Job title", "TEL" => "Phone", "EMAIL" => "Email", "ADR" => "Address", "BDAY" => "Birthday", "NOTE" => "Notes", _ => return None, }; // Non-text encodings require another editor; preserve them verbatim. if head.to_ascii_uppercase().contains("ENCODING=") { return None; } let group = head .split(';') .next() .unwrap_or("") .rsplit_once('.') .map(|(g, _)| g); let custom_label = group.and_then(|group| { self.lines.iter().find_map(|l| { let (h, n, v) = property(l)?; (n.eq_ignore_ascii_case("X-ABLabel") && h.starts_with(&format!("{group}."))) .then(|| { decode(v, false)[0] .trim_start_matches("_$!<") .trim_end_matches(">!$_") .to_owned() }) }) }); let kind = custom_label.or_else(|| { head.split(';').skip(1).find_map(|p| { let (key, value) = p.split_once('=')?; key.eq_ignore_ascii_case("TYPE") .then(|| value.trim_matches('"').replace(',', ", ").to_lowercase()) }) }); Some(CardField { index, label: kind .map(|k| format!("{title} · {k}")) .unwrap_or_else(|| title.into()), values: decode(value, matches!(name.as_str(), "N" | "ADR" | "ORG")), name, }) }) .collect() } pub fn set_field(&mut self, index: usize, values: &[String]) { let Some(line) = self.lines.get(index) else { return; }; let Some((head, _, _)) = property(line) else { return; }; self.lines[index] = format!( "{head}:{}", values .iter() .map(|v| encode(v)) .collect::<Vec<_>>() .join(";") ); } /// Add a property from this editor's finite field set, never arbitrary input. pub fn add_field(&mut self, name: &str, label: &str) { if !matches!( name, "FN" | "N" | "ORG" | "TITLE" | "TEL" | "EMAIL" | "ADR" | "BDAY" | "NOTE" ) { return; } let count = match name { "N" => 5, "ADR" => 7, _ => 1, }; let head = if matches!(name, "TEL" | "EMAIL" | "ADR") && matches!(label, "HOME" | "WORK" | "CELL") { format!("{name};TYPE={label}") } else { name.into() }; self.lines.insert( self.lines.len() - 1, format!("{head}:{}", ";".repeat(count - 1)), ); } pub fn remove_field(&mut self, index: usize) { if index > 1 && index + 1 < self.lines.len() { self.lines.remove(index); } } pub fn search_text(&self) -> String { self.fields() .iter() .flat_map(|f| f.values.iter()) .cloned() .collect::<Vec<_>>() .join(" ") .to_lowercase() } pub fn serialize(&self) -> String { let mut out = String::new(); for line in &self.lines { let mut used = 0; for c in line.chars() { if used + c.len_utf8() > 75 { out.push_str("\r\n "); used = 1; } out.push(c); used += c.len_utf8(); } out.push_str("\r\n"); } out } } #[cfg(test)] mod tests { use super::*; #[test] fn editing_preserves_extensions_photos_parameters_and_structured_values() { let text = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Zoë Test\r\nN:Test;Zoë;;;\r\nitem1.EMAIL;TYPE=HOME:z@example.invalid\r\nitem1.X-ABLabel:_$!<Home>!$_\r\nPHOTO;ENCODING=b:YWJj\r\n ZGVm\r\nX-UNKNOWN:keep\\,this\r\nEND:VCARD\r\n"; let mut card = VCard::parse(text).unwrap(); assert_eq!(card.display_name(), "Zoë Test"); assert_eq!(card.fields()[2].label, "Email · Home"); card.set_field(4, &["new@example.invalid".into()]); let out = card.serialize(); assert!(out.contains("item1.EMAIL;TYPE=HOME:new@example.invalid")); assert!(out.contains("PHOTO;ENCODING=b:YWJjZGVm")); assert!(out.contains("X-UNKNOWN:keep\\,this")); assert!(out.contains("N:Test;Zoë;;;")); } #[test] fn text_and_unicode_folding_round_trip_without_property_injection() { let mut card = VCard::new_contact(); card.add_field("NOTE", ""); let index = card.fields().last().unwrap().index; let note = format!("{},;\\\nEMAIL:injected", "é".repeat(100)); card.set_field(index, &[note.clone()]); let text = card.serialize(); assert!(text.lines().all(|l| l.len() <= 75)); let decoded = VCard::parse(&text).unwrap(); assert_eq!(decoded.fields().last().unwrap().values, [note]); assert!(!decoded.fields().iter().any(|f| f.name == "EMAIL")); } #[test] fn quoted_parameter_colons_and_escaped_component_separators() { let card = VCard::parse( "BEGIN:VCARD\nVERSION:4.0\nFN:A\nADR;LABEL=\"a:b\":;;Road\\; Lane;City;;;\nEND:VCARD", ) .unwrap(); assert_eq!(card.fields()[1].values[2], "Road; Lane"); assert!(VCard::parse("BEGIN:VCARD\nVERSION:2.1\nEND:VCARD").is_err()); } }