Data formats

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

#contacts#import#export#crm

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 error

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.

vcard_read_file(path: str) → listor an error

The contacts of a .vcf file, as vcard_parse gives them.

vcard_write(contacts: list) → stror an error

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.

Get it

$ quillo install vcard               # vcard.qwer into this folder$ quillo install vcard --example     # and its example, vcard.qwe

Or 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.vcf

Then, in your program, all of it or only what you call:

main.qwe
use vcard.qweruse vcard_parse, vcard_read_file from vcard      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]

Example

vcard.qwe
// 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

vcard.qwer
//! 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)}
Thread
89Useful? Like it, or add to its thread below.

Thread 0 posts

On vCard: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.