vCard
vCards read and written: contacts from phones, Outlook and Google Contacts imported, and save-contact files made.
Version 1.0.0 · 3 functions · 0 crates
Functions
What use vcard.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use vcard_parse, vcard_read_file from vcard (Quillo 0.6).
vcard_parse(text: str) → listor an errorThe contacts in a .vcf text: { name, first, last, middle, prefix, suffix, org, title, emails, phones, addresses, urls, birthday, note, uid } each. emails and phones are { value, types } (types such as "work", "home", "cell"), addresses { street, city, region, postal_code, country, types }. Folded lines, escapes and vCard 2.1's quoted-printable are read; photos are skipped.
vcard_read_file(path: str) → listor an errorThe contacts of a .vcf file, as vcard_parse gives them.
vcard_write(contacts: list) → stror an errorContacts as a .vcf text (vCard 3.0, which phones and mail apps all read), in the shape vcard_parse gives: name (or first and last), org, title, emails and phones (text, or { value, types }), addresses, urls, birthday, note, uid. Missing fields are left out; lines are escaped and folded.
Get it
$ quillo install vcard # vcard.qwer into this folder$ quillo install vcard --example # and its example, vcard.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/vcard.qwer$ curl -O https://quillolang.org/plugins/files/vcard.qwe$ curl -O https://quillolang.org/plugins/files/vcard_sample.vcfThen, in your program, all of it or only what you call:
use vcard.qweruse vcard_parse, vcard_read_file from vcard // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
Example
// Contacts imported from a phone's export, and a "save contact" button for a staff page.//// quillo run vcard.qwe then open http://localhost:8080/contactsuse "vcard.qwer"route GET /contacts = vcard_read_file("vcard_sample.vcf")? | map((c) => { name: c.name, org: c.org, email: len(c.emails) > 0 ? c.emails[0].value : null, phone: len(c.phones) > 0 ? c.phones[0].value : null, note: c.note })route GET /ada = vcard_read_file("vcard_sample.vcf")?[0]route GET /staff/ada.vcf = respond(200, vcard_write([{ first: "Ada", last: "Lovelace", org: "Quillo Shop", title: "Head of Engineering", emails: [{ value: "ada@shop.example.com", types: ["work"] }], phones: ["+31 20 555 0101"], addresses: [{ street: "Keizersgracht 1", city: "Amsterdam", postal_code: "1015 CJ", country: "Netherlands", types: ["work"] }], note: "Ask me about Quillo; or anything, really.",}])?, { "content-type": "text/vcard; charset=utf-8", "content-disposition": "attachment; filename=ada.vcf" })route GET /roundtrip { let text = vcard_write(vcard_read_file("vcard_sample.vcf")?)? vcard_parse(text)? | map((c) => c.name)}$ quillo run vcard.qwe$ curl 'localhost:8080/contacts'$ curl 'localhost:8080/ada'$ curl 'localhost:8080/staff/ada.vcf'Source
//! vCards (.vcf) read and written: contacts from phones, Outlook and Google Contacts imported, and "save contact" files made, versions 2.1, 3.0 and 4.0 read.//! version: 1.0.0//!//! [dependencies]use serde_json::{json, Map, Value};/// Files up to 20 MB and 100,000 contacts are read.const MAX: usize = 20 * 1024 * 1024;fn unescape(s: &str) -> String { let mut out = String::new(); let mut it = s.chars(); while let Some(c) = it.next() { if c == '\\' { match it.next() { Some('n') | Some('N') => out.push('\n'), Some(o) => out.push(o), None => {} } } else { out.push(c); } } out}/// Splits at `sep` where it is not escaped.fn split_unescaped(s: &str, sep: char) -> Vec<String> { let mut parts = vec![]; let mut cur = String::new(); let mut esc = false; for c in s.chars() { if esc { cur.push('\\'); cur.push(c); esc = false; } else if c == '\\' { esc = true; } else if c == sep { parts.push(std::mem::take(&mut cur)); } else { cur.push(c); } } parts.push(cur); parts.into_iter().map(|p| unescape(&p)).collect()}fn quoted_printable(s: &str) -> String { let b = s.as_bytes(); let mut out = vec![]; let mut i = 0; while i < b.len() { if b[i] == b'=' && i + 2 < b.len() { if let Some(x) = std::str::from_utf8(&b[i + 1..i + 3]).ok().and_then(|h| u8::from_str_radix(h, 16).ok()) { out.push(x); i += 3; continue; } } out.push(b[i]); i += 1; } String::from_utf8_lossy(&out).into_owned()}struct Line { name: String, params: Vec<(String, String)>, value: String,}fn lines(text: &str) -> Vec<String> { // Unfold: a line starting with a space or a tab goes on the one before; vCard 2.1's quoted-printable soft breaks too. let mut out: Vec<String> = vec![]; for raw in text.replace("\r\n", "\n").split('\n') { if (raw.starts_with(' ') || raw.starts_with('\t')) && !out.is_empty() { out.last_mut().unwrap().push_str(&raw[1..]); } else if out.last().is_some_and(|l| l.ends_with('=') && l.to_uppercase().contains("QUOTED-PRINTABLE")) { let l = out.last_mut().unwrap(); l.pop(); l.push_str(raw); } else { out.push(raw.to_string()); } } out}fn parse_line(l: &str) -> Option<Line> { let (head, value) = l.split_once(':')?; let mut parts = head.split(';'); let name = parts.next()?.rsplit('.').next()?.to_uppercase(); let mut params = vec![]; for p in parts { match p.split_once('=') { Some((k, v)) => { for v in v.trim_matches('"').split(',') { params.push((k.to_uppercase(), v.to_string())); } } // vCard 2.1 writes types bare: TEL;WORK;VOICE: None => params.push(("TYPE".into(), p.to_string())), } } let value = if params.iter().any(|(k, v)| k == "ENCODING" && v.eq_ignore_ascii_case("QUOTED-PRINTABLE")) { quoted_printable(value) } else { value.to_string() }; Some(Line { name, params, value })}fn types(l: &Line) -> Vec<Value> { l.params.iter().filter(|(k, _)| k == "TYPE").map(|(_, v)| Value::String(v.to_lowercase())).filter(|v| v != "internet" && v != "voice" && v != "pref").collect()}fn contact(card: &[Line]) -> Value { let mut c = Map::new(); let mut emails = vec![]; let mut phones = vec![]; let mut addresses = vec![]; let mut urls = vec![]; for l in card { let v = &l.value; match l.name.as_str() { "FN" => { c.insert("name".into(), json!(unescape(v))); } "N" => { let p = split_unescaped(v, ';'); let at = |i: usize| p.get(i).filter(|s| !s.is_empty()).cloned(); c.insert("last".into(), json!(at(0))); c.insert("first".into(), json!(at(1))); c.insert("middle".into(), json!(at(2))); c.insert("prefix".into(), json!(at(3))); c.insert("suffix".into(), json!(at(4))); } "EMAIL" => emails.push(json!({ "value": unescape(v).trim(), "types": types(l) })), "TEL" => phones.push(json!({ "value": unescape(v).trim().trim_start_matches("tel:"), "types": types(l) })), "ADR" => { let p = split_unescaped(v, ';'); let at = |i: usize| p.get(i).filter(|s| !s.is_empty()).cloned(); addresses.push(json!({ "street": at(2), "city": at(3), "region": at(4), "postal_code": at(5), "country": at(6), "types": types(l) })); } "ORG" => { c.insert("org".into(), json!(split_unescaped(v, ';').into_iter().next())); } "TITLE" => { c.insert("title".into(), json!(unescape(v))); } "URL" => urls.push(json!(unescape(v))), "NOTE" => { c.insert("note".into(), json!(unescape(v))); } "BDAY" => { c.insert("birthday".into(), json!(v.trim())); } "UID" => { c.insert("uid".into(), json!(v.trim())); } _ => {} } } if !c.contains_key("name") { let n = [c.get("first"), c.get("last")].iter().filter_map(|x| x.and_then(|v| v.as_str())).collect::<Vec<_>>().join(" "); c.insert("name".into(), json!(n)); } for k in ["first", "last", "middle", "prefix", "suffix", "org", "title", "note", "birthday", "uid"] { c.entry(k).or_insert(Value::Null); } c.insert("emails".into(), Value::Array(emails)); c.insert("phones".into(), Value::Array(phones)); c.insert("addresses".into(), Value::Array(addresses)); c.insert("urls".into(), Value::Array(urls)); Value::Object(c)}/// The contacts in a .vcf text: `{ name, first, last, middle, prefix, suffix, org, title, emails, phones, addresses, urls,/// birthday, note, uid }` each. `emails` and `phones` are `{ value, types }` (`types` such as "work", "home", "cell"),/// `addresses` `{ street, city, region, postal_code, country, types }`. Folded lines, escapes and vCard 2.1's/// quoted-printable are read; photos are skipped.pub fn vcard_parse(text: &str) -> Result<Vec<Value>, String> { if text.len() > MAX { return Err("vCard files up to 20 MB".into()); } let mut out = vec![]; let mut card: Option<Vec<Line>> = None; for l in lines(text) { let up = l.trim().to_uppercase(); if up == "BEGIN:VCARD" { card = Some(vec![]); } else if up == "END:VCARD" { if let Some(c) = card.take() { out.push(contact(&c)); if out.len() >= 100_000 { break; } } } else if let (Some(c), Some(line)) = (card.as_mut(), parse_line(&l)) { c.push(line); } } if out.is_empty() { return Err("no BEGIN:VCARD in the text".into()); } Ok(out)}/// The contacts of a .vcf file, as vcard_parse gives them.pub fn vcard_read_file(path: &str) -> Result<Vec<Value>, String> { vcard_parse(&String::from_utf8_lossy(&std::fs::read(path).map_err(|e| format!("{}: {}", path, e))?))}fn esc(s: &str) -> String { s.replace('\\', "\\\\").replace('\n', "\\n").replace(',', "\\,").replace(';', "\\;")}/// A line folded at 75 bytes, never inside a character.fn fold(line: &str, out: &mut String) { let mut n = 0; for c in line.chars() { if n + c.len_utf8() > 75 { out.push_str("\r\n "); n = 1; } out.push(c); n += c.len_utf8(); } out.push_str("\r\n");}fn s(v: &Value, k: &str) -> String { v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string()}fn entries(v: &Value, k: &str) -> Vec<(String, Vec<String>)> { v.get(k).and_then(|x| x.as_array()).map(|a| a.iter().map(|e| match e { Value::String(t) => (t.clone(), vec![]), o => (s(o, "value"), o.get("types").and_then(|t| t.as_array()).map(|t| t.iter().filter_map(|x| x.as_str().map(str::to_uppercase)).collect()).unwrap_or_default()), }).collect()).unwrap_or_default()}/// Contacts as a .vcf text (vCard 3.0, which phones and mail apps all read), in the shape vcard_parse gives: `name` (or/// `first` and `last`), `org`, `title`, `emails` and `phones` (text, or `{ value, types }`), `addresses`, `urls`, `birthday`,/// `note`, `uid`. Missing fields are left out; lines are escaped and folded.pub fn vcard_write(contacts: Vec<serde_json::Value>) -> Result<String, String> { let mut out = String::new(); for (i, c) in contacts.iter().enumerate().take(100_000) { if !c.is_object() { return Err(format!("contact {} is not a map", i + 1)); } let (first, last) = (s(c, "first"), s(c, "last")); let name = if s(c, "name").is_empty() { format!("{} {}", first, last).trim().to_string() } else { s(c, "name") }; if name.is_empty() { return Err(format!("contact {} has no name, first or last", i + 1)); } let (first, last) = if first.is_empty() && last.is_empty() { match name.rsplit_once(' ') { Some((f, l)) => (f.to_string(), l.to_string()), None => (name.clone(), String::new()) } } else { (first, last) }; out.push_str("BEGIN:VCARD\r\nVERSION:3.0\r\n"); fold(&format!("FN:{}", esc(&name)), &mut out); fold(&format!("N:{};{};{};{};{}", esc(&last), esc(&first), esc(&s(c, "middle")), esc(&s(c, "prefix")), esc(&s(c, "suffix"))), &mut out); for (k, tag) in [("org", "ORG"), ("title", "TITLE"), ("note", "NOTE"), ("birthday", "BDAY"), ("uid", "UID")] { let v = s(c, k); if !v.is_empty() { fold(&format!("{}:{}", tag, if tag == "BDAY" || tag == "UID" { v } else { esc(&v) }), &mut out); } } for (k, tag) in [("emails", "EMAIL"), ("phones", "TEL")] { for (v, t) in entries(c, k) { let ty = if t.is_empty() { String::new() } else { format!(";TYPE={}", t.join(",")) }; fold(&format!("{}{}:{}", tag, ty, esc(&v)), &mut out); } } for a in c.get("addresses").and_then(|x| x.as_array()).into_iter().flatten() { let t: Vec<String> = a.get("types").and_then(|t| t.as_array()).map(|t| t.iter().filter_map(|x| x.as_str().map(str::to_uppercase)).collect()).unwrap_or_default(); let ty = if t.is_empty() { String::new() } else { format!(";TYPE={}", t.join(",")) }; fold(&format!("ADR{}:;;{};{};{};{};{}", ty, esc(&s(a, "street")), esc(&s(a, "city")), esc(&s(a, "region")), esc(&s(a, "postal_code")), esc(&s(a, "country"))), &mut out); } for (u, _) in entries(c, "urls") { fold(&format!("URL:{}", u), &mut out); } out.push_str("END:VCARD\r\n"); } Ok(out)}Related
avro.qwer · 1.0.0Apache Avro data files: rows written with a schema, as Kafka, Hadoop and BigQuery use, and read back with the schema they carry.9 views · 6 downloadsBibTeX bibtex.qwer · 1.0.0BibTeX and BibLaTeX bibliographies read, with LaTeX accents and macros resolved; reference lists in APA or IEEE style, CSL-JSON for Zotero and pandoc, and entries written back.7 views · 6 downloadsCSV csv.qwer · 1.0.0CSV in and out: rows as maps keyed by the header, with quotes, commas and line breaks in cells handled.8 views · 8 downloadsFixed width fixed_width.qwer · 1.0.0Fixed-width records read and written from a column spec: bank payment files, payroll exports and mainframe formats.8 views · 8 downloadsINI ini.qwer · 1.0.0INI files read and written, sections and keys: php.ini, setup.cfg, .gitconfig and the settings of older software.8 views · 7 downloadsJSON Lines ndjson.qwer · 1.0.0JSON Lines (NDJSON) read a page at a time from files of any size, searched by field, appended to and written: logs, exports and imports.7 views · 6 downloadsThread 0 posts
On vCard: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.