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<()> {
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 {
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();
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();
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)));
}
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));
}
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));
}
}
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));
}
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 {
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) {
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") {
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 {
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 {
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 {
if tz == Some("Z") || tz.is_none() {
return format!("{date}T{time}.000Z");
}
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")))
}