univert 0.2.0

Universal file converter (library and CLI)
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::io::BufReader;

use ical::parser::vcard::VcardParser;
use ical::parser::vcard::component::VcardContact;
use ical::property::Property;
use serde_json::{Map, Value, json};

use crate::{Error, Job, Result, extension_of};

pub fn vcf_to_json(job: &Job) -> Result<()> {
  let file = fs::File::open(job.input)?;
  let reader = BufReader::new(file);
  let parser = VcardParser::new(reader);

  let mut cards: Vec<Value> = Vec::new();
  for item in parser {
    let contact = item.map_err(|e| Error::other(format!("vcf parse: {e}")))?;
    cards.push(build_card(&contact));
  }

  let value = if cards.len() == 1 {
    cards.into_iter().next().unwrap()
  } else {
    Value::Array(cards)
  };

  let json = serde_json::to_string_pretty(&value)
    .map_err(|e| Error::other(format!("json serialize: {e}")))?;
  fs::write(job.output, json + "\n")?;
  Ok(())
}

pub fn yaml_to_vcf(job: &Job) -> Result<()> {
  // Mirrors JS yaml2vcf: relies on vcards-js. Not yet ported; surface a
  // clear "not implemented" error instead of silently failing.
  let from = extension_of(job.input).unwrap_or_default();
  let to = extension_of(job.output).unwrap_or_default();
  Err(Error::NotImplemented { from, to })
}

fn build_card(contact: &VcardContact) -> Value {
  // Group properties by lowercase name.
  let mut grouped: BTreeMap<String, Vec<&Property>> = BTreeMap::new();
  for p in &contact.properties {
    grouped.entry(p.name.to_lowercase()).or_default().push(p);
  }

  let getf = |name: &str| grouped.get(name).and_then(|v| v.first().copied());
  let getall = |name: &str| grouped.get(name).cloned().unwrap_or_default();

  // N:family;given;additional;honorific-prefix;honorific-suffix
  let n_parts: Vec<String> = getf("n")
    .and_then(|p| p.value.as_deref())
    .map(|v| v.split(';').map(|s| s.to_string()).collect())
    .unwrap_or_default();
  let part = |i: usize| n_parts.get(i).filter(|s| !s.is_empty()).cloned();

  // Ordered result: start from the fixture-expected shape.
  let mut out: Map<String, Value> = Map::new();

  if let Some(fn_p) = getf("fn")
    && let Some(v) = fn_p.value.as_deref()
  {
    out.insert("name".into(), json!(unescape(v)));
  }
  if let Some(v) = part(1) {
    out.insert("firstname".into(), json!(v));
  }
  if let Some(v) = part(0) {
    out.insert("lastname".into(), json!(v));
  }
  if let Some(v) = part(2) {
    out.insert("additional-names".into(), json!(v));
  }
  if let Some(v) = part(3) {
    out.insert("honorific-prefixes".into(), json!(v));
  }
  if let Some(v) = part(4) {
    out.insert("honorific-suffixes1".into(), json!(v));
  }

  if let Some(p) = getf("nickname")
    && let Some(v) = p.value.as_deref()
  {
    out.insert("nickname".into(), json!(v));
  }
  if let Some(p) = getf("gender")
    && let Some(v) = p.value.as_deref()
  {
    let mapped = match v.chars().next() {
      Some('M') | Some('m') => Some("male"),
      Some('F') | Some('f') => Some("female"),
      _ => None,
    };
    if let Some(g) = mapped {
      out.insert("gender".into(), json!(g));
    }
  }
  if let Some(p) = getf("bday")
    && let Some(v) = p.value.as_deref()
  {
    out.insert("birthday".into(), json!(format_bday(v)));
  }

  // LANG (sorted by PREF ascending)
  let langs = getall("lang");
  if !langs.is_empty() {
    let mut indexed: Vec<(i64, String)> = langs
      .iter()
      .filter_map(|p| {
        let v = p.value.as_deref()?.to_string();
        let pref = param(p, "PREF")
          .and_then(|s| s.parse::<i64>().ok())
          .unwrap_or(i64::MAX);
        Some((pref, v))
      })
      .collect();
    indexed.sort_by_key(|(p, _)| *p);
    let values: Vec<Value> =
      indexed.into_iter().map(|(_, v)| json!(v)).collect();
    out.insert("languages".into(), Value::Array(values));
  }

  // EMAIL (single string; multiple become "emails" array — matches JS)
  let emails = getall("email");
  match emails.len() {
    0 => {}
    1 => {
      if let Some(v) = emails[0].value.as_deref() {
        out.insert("email".into(), json!(v));
      }
    }
    _ => {
      let arr: Vec<Value> = emails
        .iter()
        .filter_map(|p| p.value.as_deref().map(|s| json!(s)))
        .collect();
      out.insert("emails".into(), Value::Array(arr));
    }
  }

  // TEL -> phones
  let tels = getall("tel");
  if !tels.is_empty() {
    let phones: Vec<Value> = tels.iter().map(phone_to_json).collect();
    if phones.len() == 1 {
      out.insert("phone".into(), phones.into_iter().next().unwrap());
    } else {
      out.insert("phones".into(), Value::Array(phones));
    }
  }

  if let Some(p) = getf("title")
    && let Some(v) = p.value.as_deref()
  {
    out.insert("title".into(), json!(v));
  }
  if let Some(p) = getf("org")
    && let Some(v) = p.value.as_deref()
  {
    out.insert("organisation".into(), json!(v));
  }

  // ADR -> addresses
  let adrs = getall("adr");
  if !adrs.is_empty() {
    let addresses: Vec<Value> = adrs.iter().map(addr_to_json).collect();
    if addresses.len() == 1 {
      out.insert("address".into(), addresses.into_iter().next().unwrap());
    } else {
      out.insert("addresses".into(), Value::Array(addresses));
    }
  }

  if let Some(p) = getf("rev")
    && let Some(v) = p.value.as_deref()
  {
    out.insert("revision".into(), json!(iso_date(v)));
  }
  if let Some(p) = getf("anniversary")
    && let Some(v) = p.value.as_deref()
  {
    out.insert("anniversary".into(), json!(iso_date(v)));
  }
  if let Some(p) = getf("photo")
    && let Some(v) = p.value.as_deref()
  {
    out.insert("photo".into(), json!(v));
  }

  Value::Object(out)
}

fn param(prop: &Property, key: &str) -> Option<String> {
  let params = prop.params.as_ref()?;
  for (k, vals) in params {
    if k.eq_ignore_ascii_case(key) {
      return vals.first().cloned();
    }
  }
  None
}

fn phone_to_json(prop: &&Property) -> Value {
  let number = prop
    .value
    .clone()
    .unwrap_or_default()
    .replace("(0)", "")
    .chars()
    .filter(|c| c.is_ascii_digit() || *c == '+')
    .collect::<String>();

  let type_vec = collect_type(prop);

  let mut m = Map::new();
  if let Some(types) = type_vec {
    m.insert("type".into(), types);
  }
  m.insert("number".into(), json!(number));
  Value::Object(m)
}

fn addr_to_json(prop: &&Property) -> Value {
  // VALUE: post-office-box;apartment;street;city;region;zip;country
  let parts: Vec<String> = prop
    .value
    .as_deref()
    .map(|v| v.split(';').map(|s| s.to_string()).collect())
    .unwrap_or_default();
  let pick = |i: usize| parts.get(i).filter(|s| !s.is_empty()).cloned();

  let mut m = Map::new();
  if let Some(types) = collect_type(prop) {
    // JS lowercased scalar type; we preserve that by flattening single-element
    // arrays into a string.
    match types {
      Value::Array(a) if a.len() == 1 => {
        m.insert("type".into(), a.into_iter().next().unwrap());
      }
      other => {
        m.insert("type".into(), other);
      }
    }
  }
  if let Some(label) = param(prop, "LABEL") {
    m.insert("label".into(), json!(label));
  }
  if let Some(v) = pick(0) {
    m.insert("postOfficeBox".into(), json!(v));
  }
  if let Some(v) = pick(1) {
    m.insert("apartment".into(), json!(v));
  }
  if let Some(v) = pick(2) {
    m.insert("street".into(), json!(v));
  }
  if let Some(v) = pick(3) {
    m.insert("city".into(), json!(v));
  }
  if let Some(v) = pick(4) {
    m.insert("region".into(), json!(v));
  }
  if let Some(v) = pick(5) {
    m.insert("zip".into(), json!(v));
  }
  if let Some(v) = pick(6) {
    m.insert("country".into(), json!(v));
  }
  Value::Object(m)
}

fn collect_type(prop: &Property) -> Option<Value> {
  let params = prop.params.as_ref()?;
  for (k, vals) in params {
    if k.eq_ignore_ascii_case("TYPE") {
      // VCARD's TYPE value can be comma-separated within a single param value.
      let expanded: Vec<String> = vals
        .iter()
        .flat_map(|v| v.split(',').map(|s| s.trim().to_ascii_lowercase()))
        .filter(|s| !s.is_empty())
        .collect();
      if expanded.is_empty() {
        return None;
      }
      return Some(Value::Array(
        expanded.into_iter().map(|s| json!(s)).collect(),
      ));
    }
  }
  None
}

fn unescape(s: &str) -> String {
  s.replace('\\', "")
}

fn format_bday(v: &str) -> String {
  // "--0203" -> "--02-03"; "19900506" -> "1990-05-06"
  let v = v.trim();
  if let Some(rest) = v.strip_prefix("--")
    && rest.len() == 4
  {
    return format!("--{}-{}", &rest[0..2], &rest[2..4]);
  }
  if v.len() == 8 && v.chars().all(|c| c.is_ascii_digit()) {
    return format!("{}-{}-{}", &v[0..4], &v[4..6], &v[6..8]);
  }
  v.to_string()
}

fn iso_date(v: &str) -> String {
  // vCard REV / anniversary come as YYYYMMDDTHHMMSS[Z|±HHMM].
  // JS converts to full ISO 8601 with milliseconds: "2008-04-24T19:52:43.000Z"
  let v = v.trim();
  if let Some((date, time)) = v.split_once('T')
    && date.len() == 8
    && date.chars().all(|c| c.is_ascii_digit())
  {
    let d = format!("{}-{}-{}", &date[0..4], &date[4..6], &date[6..8]);
    let (hhmmss, tz) = parse_time_tz(time);
    let t = match hhmmss.len() {
      6 => format!("{}:{}:{}", &hhmmss[0..2], &hhmmss[2..4], &hhmmss[4..6]),
      4 => format!("{}:{}:00", &hhmmss[0..2], &hhmmss[2..4]),
      _ => hhmmss.to_string(),
    };
    return to_utc_iso(&d, &t, tz);
  }
  v.to_string()
}

fn parse_time_tz(time: &str) -> (&str, Option<&str>) {
  if let Some(idx) = time.find(['Z', '+', '-']) {
    (&time[..idx], Some(&time[idx..]))
  } else {
    (time, None)
  }
}

fn to_utc_iso(date: &str, time: &str, tz: Option<&str>) -> String {
  // Trivial cases first.
  if tz == Some("Z") || tz.is_none() {
    return format!("{date}T{time}.000Z");
  }

  // Offset like "-0500" or "+0130".
  let tz_str = tz.unwrap();
  if tz_str.len() == 5
    && (tz_str.starts_with('+') || tz_str.starts_with('-'))
    && tz_str[1..].chars().all(|c| c.is_ascii_digit())
  {
    let sign = if tz_str.starts_with('+') { 1 } else { -1 };
    let hh: i64 = tz_str[1..3].parse().unwrap_or(0);
    let mm: i64 = tz_str[3..5].parse().unwrap_or(0);
    let offset_sec = sign * (hh * 3600 + mm * 60);
    if let Some(utc) = shift_to_utc(date, time, offset_sec) {
      return utc;
    }
  }

  format!("{date}T{time}.000Z")
}

fn shift_to_utc(date: &str, time: &str, offset_sec: i64) -> Option<String> {
  use chrono::{NaiveDate, NaiveDateTime, NaiveTime};

  let d = NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()?;
  let t = NaiveTime::parse_from_str(time, "%H:%M:%S").ok()?;
  let dt =
    NaiveDateTime::new(d, t) - chrono::Duration::try_seconds(offset_sec)?;
  Some(format!("{}.000Z", dt.format("%Y-%m-%dT%H:%M:%S")))
}