use core::{error, fmt};
use alloc::{
borrow::Cow,
format,
string::{String, ToString},
vec,
vec::Vec,
};
use serde_json::{Map, Value, json};
use crate::{
param::{VcardParam, VcardParamKind},
prop::{VcardProp, VcardPropName},
tree::prop::spec::prop_spec,
value::{
VcardValue, VcardValueKind, VcardValueUnknown,
adr::VcardAdr,
binary::VcardBinary,
client_pid_map::VcardClientPidMap,
datetime::{VcardDateAndOrTime, VcardTimestamp},
gender::VcardGender,
geo::VcardGeo,
language::VcardLanguageTag,
n::VcardN,
org::VcardOrg,
text::{VcardText, VcardTextList},
uri::VcardUri,
utc_offset::VcardUtcOffset,
},
vcard::Vcard,
version::VcardVersion,
};
#[derive(Debug)]
pub enum VcardJcardParseError {
InvalidCard,
InvalidProp(String),
}
impl fmt::Display for VcardJcardParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidCard => {
write!(f, "Cannot parse jCard: the root is not [\"vcard\", [...]]")
}
Self::InvalidProp(prop) => write!(f, "Cannot parse jCard property `{prop}`"),
}
}
}
impl error::Error for VcardJcardParseError {}
impl Vcard<'_> {
pub fn to_jcard(&self) -> Value {
let mut entries = Vec::with_capacity(self.properties.len() + 1);
entries.push(json!(["version", {}, "text", &*self.version]));
entries.extend(self.properties.iter().map(prop_to_jcard));
json!(["vcard", entries])
}
pub fn from_jcard(jcard: &Value) -> Result<Vcard<'_>, VcardJcardParseError> {
let root = jcard
.as_array()
.filter(|root| root.len() == 2)
.ok_or(VcardJcardParseError::InvalidCard)?;
let tag = root[0].as_str().ok_or(VcardJcardParseError::InvalidCard)?;
if !tag.eq_ignore_ascii_case("vcard") {
return Err(VcardJcardParseError::InvalidCard);
}
let entries = root[1]
.as_array()
.ok_or(VcardJcardParseError::InvalidCard)?;
let version = entries
.iter()
.filter_map(Value::as_array)
.find(|entry| entry_is_version(entry))
.and_then(|entry| entry.get(3))
.and_then(Value::as_str)
.and_then(|version| version.parse().ok())
.unwrap_or(VcardVersion::V4_0);
let mut properties = Vec::new();
for entry in entries {
let invalid = || VcardJcardParseError::InvalidProp(entry.to_string());
let entry = entry
.as_array()
.filter(|entry| entry.len() >= 3)
.ok_or_else(invalid)?;
if entry_is_version(entry) {
continue;
}
let name = entry[0].as_str().ok_or_else(invalid)?;
let params = entry[1].as_object().ok_or_else(invalid)?;
let slot = entry[2].as_str().ok_or_else(invalid)?;
properties.push(prop_from_jcard(name, params, slot, &entry[3..], version));
}
Ok(Vcard {
version,
properties,
})
}
}
pub(crate) fn prop_to_jcard(prop: &VcardProp<'_>) -> Value {
let full = &*prop.name;
let (group, name) = match full.split_once('.') {
Some((group, name)) => (Some(group), name),
None => (None, full),
};
let mut params = Map::new();
if let Some(group) = group {
params.insert("group".into(), Value::String(group.to_ascii_lowercase()));
}
let mut declared = None;
for param in &prop.params {
if let VcardParam::Value(kind) = param {
declared = Some(kind.to_ascii_lowercase());
continue;
}
let (key, value) = param_to_jcard(param);
merge_param(&mut params, key, value);
}
let (default_slot, values) = value_to_jcard(&prop.value);
let slot = declared.unwrap_or_else(|| default_slot.to_string());
let mut entry = vec![
Value::String(name.to_ascii_lowercase()),
Value::Object(params),
Value::String(slot),
];
entry.extend(values);
Value::Array(entry)
}
pub(crate) fn param_to_jcard(param: &VcardParam<'_>) -> (String, Value) {
match param {
VcardParam::Unknown { name, values } => (name.to_ascii_lowercase(), text_or_list(values)),
VcardParam::Pid(values) | VcardParam::SortAs(values) | VcardParam::Type(values) => {
(known_param_key(param), text_or_list(values))
}
VcardParam::AltId(value)
| VcardParam::Author(value)
| VcardParam::AuthorName(value)
| VcardParam::CalScale(value)
| VcardParam::Charset(value)
| VcardParam::Created(value)
| VcardParam::Derived(value)
| VcardParam::Encoding(value)
| VcardParam::Geo(value)
| VcardParam::Jsptr(value)
| VcardParam::Label(value)
| VcardParam::Language(value)
| VcardParam::MediaType(value)
| VcardParam::Phonetic(value)
| VcardParam::Pref(value)
| VcardParam::PropId(value)
| VcardParam::Script(value)
| VcardParam::ServiceType(value)
| VcardParam::Tz(value)
| VcardParam::Username(value)
| VcardParam::Value(value) => (known_param_key(param), Value::String(value.to_string())),
}
}
fn known_param_key(param: &VcardParam<'_>) -> String {
param
.kind()
.map(|kind| kind.to_ascii_lowercase())
.unwrap_or_default()
}
pub(crate) fn merge_param(params: &mut Map<String, Value>, key: String, value: Value) {
match params.get_mut(&key) {
None => {
params.insert(key, value);
}
Some(Value::Array(existing)) => match value {
Value::Array(values) => existing.extend(values),
value => existing.push(value),
},
Some(existing) => {
let mut values = vec![existing.take()];
match value {
Value::Array(more) => values.extend(more),
value => values.push(value),
}
*existing = Value::Array(values);
}
}
}
fn value_to_jcard(value: &VcardValue<'_>) -> (&'static str, Vec<Value>) {
match value {
VcardValue::Text(text) => ("text", vec![Value::String(text.0.to_string())]),
VcardValue::TextList(list) if list.0.is_empty() => {
("text", vec![Value::String(String::new())])
}
VcardValue::TextList(list) => ("text", strings(&list.0)),
VcardValue::Uri(uri) => ("uri", vec![Value::String(uri.0.to_string())]),
VcardValue::DateAndOrTime(date) => (
"date-and-or-time",
vec![Value::String(basic_to_extended(&date.0))],
),
VcardValue::Timestamp(timestamp) => (
"timestamp",
vec![Value::String(basic_to_extended(×tamp.0))],
),
VcardValue::LanguageTag(tag) => ("language-tag", vec![Value::String(tag.0.to_string())]),
VcardValue::UtcOffset(offset) => (
"utc-offset",
vec![Value::String(offset_to_extended(&offset.0))],
),
VcardValue::N(n) => (
"text",
vec![Value::Array(vec![
text_or_list(&n.family),
text_or_list(&n.given),
text_or_list(&n.additional),
text_or_list(&n.prefixes),
text_or_list(&n.suffixes),
])],
),
VcardValue::Adr(adr) => {
let mut components = vec![
text_or_list(&adr.po_box),
text_or_list(&adr.extended),
text_or_list(&adr.street),
text_or_list(&adr.locality),
text_or_list(&adr.region),
text_or_list(&adr.postal_code),
text_or_list(&adr.country),
];
if adr.has_extended_components() {
components.extend([
text_or_list(&adr.room),
text_or_list(&adr.apartment),
text_or_list(&adr.floor),
text_or_list(&adr.street_number),
text_or_list(&adr.street_name),
text_or_list(&adr.building),
text_or_list(&adr.block),
text_or_list(&adr.subdistrict),
text_or_list(&adr.district),
text_or_list(&adr.landmark),
text_or_list(&adr.direction),
]);
}
("text", vec![Value::Array(components)])
}
VcardValue::Gender(gender) if gender.identity.is_empty() => {
("text", vec![Value::String(gender.sex.to_string())])
}
VcardValue::Gender(gender) => (
"text",
vec![Value::Array(vec![
Value::String(gender.sex.to_string()),
Value::String(gender.identity.to_string()),
])],
),
VcardValue::Org(org) => match org.0.as_slice() {
[] => ("text", vec![Value::String(String::new())]),
[unit] => ("text", vec![Value::String(unit.to_string())]),
units => ("text", vec![Value::Array(strings(units))]),
},
VcardValue::ClientPidMap(map) => (
"text",
vec![Value::Array(vec![
Value::String(map.id.to_string()),
Value::String(map.uri.to_string()),
])],
),
VcardValue::Geo(geo) => (
"text",
vec![Value::Array(vec![
Value::String(geo.latitude.to_string()),
Value::String(geo.longitude.to_string()),
])],
),
VcardValue::Binary(VcardBinary::Uri(uri)) => ("uri", vec![Value::String(uri.to_string())]),
VcardValue::Binary(VcardBinary::Base64(data)) => {
("unknown", vec![Value::String(data.to_string())])
}
VcardValue::Unknown(unknown) => ("unknown", vec![unknown_to_jcard(unknown)]),
}
}
fn unknown_to_jcard(unknown: &VcardValueUnknown<'_>) -> Value {
match unknown.components.as_slice() {
[] => Value::String(String::new()),
[group] => text_or_list(group),
groups => Value::Array(
groups
.iter()
.map(|group| Value::Array(strings(group)))
.collect(),
),
}
}
fn text_or_list(values: &[Cow<'_, str>]) -> Value {
match values {
[] => Value::String(String::new()),
[value] => Value::String(value.to_string()),
values => Value::Array(strings(values)),
}
}
fn strings(values: &[Cow<'_, str>]) -> Vec<Value> {
values
.iter()
.map(|value| Value::String(value.to_string()))
.collect()
}
fn entry_is_version(entry: &[Value]) -> bool {
matches!(
entry.first().and_then(Value::as_str),
Some(name) if name.eq_ignore_ascii_case("version"),
)
}
pub(crate) fn prop_from_jcard<'a>(
name: &'a str,
params: &'a Map<String, Value>,
slot: &'a str,
values: &'a [Value],
version: VcardVersion,
) -> VcardProp<'a> {
let group = params.get("group").and_then(Value::as_str);
let name = match group {
Some(group) => format!("{group}.{}", name.to_ascii_uppercase()),
None => name.to_ascii_uppercase(),
};
let name = VcardPropName::from(Cow::Owned(name));
let mut prop_params: Vec<VcardParam<'_>> = params
.iter()
.filter(|(key, _)| !key.eq_ignore_ascii_case("group"))
.map(|(key, value)| param_from_jcard(key, value))
.collect();
let kind = match &name {
VcardPropName::Kind(prop) => {
let spec = prop_spec(*prop);
let default = (spec.value)(version, None);
let declared = slot
.parse::<VcardValueKind>()
.ok()
.filter(|declared| !(*declared == VcardValueKind::Text && is_text_shaped(default)));
if declared.is_some() && declared != Some(default) {
prop_params.push(VcardParam::Value(Cow::Borrowed(slot)));
}
Some((spec.value)(version, declared))
}
VcardPropName::Unknown(_) => {
if !slot.eq_ignore_ascii_case("unknown") {
prop_params.push(VcardParam::Value(Cow::Borrowed(slot)));
}
None
}
};
let value = match kind {
Some(kind) => value_from_jcard(kind, values),
None => VcardValue::Unknown(unknown_from_jcard(values)),
};
VcardProp {
name,
params: prop_params,
value,
}
}
pub(crate) fn param_from_jcard<'a>(key: &'a str, value: &'a Value) -> VcardParam<'a> {
let Ok(kind) = key.parse::<VcardParamKind>() else {
return VcardParam::Unknown {
name: Cow::Owned(key.to_ascii_uppercase()),
values: scalars(value),
};
};
match kind {
VcardParamKind::AltId => VcardParam::AltId(scalar(value)),
VcardParamKind::CalScale => VcardParam::CalScale(scalar(value)),
VcardParamKind::Charset => VcardParam::Charset(scalar(value)),
VcardParamKind::Encoding => VcardParam::Encoding(scalar(value)),
VcardParamKind::Geo => VcardParam::Geo(scalar(value)),
VcardParamKind::Label => VcardParam::Label(scalar(value)),
VcardParamKind::Language => VcardParam::Language(scalar(value)),
VcardParamKind::MediaType => VcardParam::MediaType(scalar(value)),
VcardParamKind::Pid => VcardParam::Pid(scalars(value)),
VcardParamKind::Pref => VcardParam::Pref(scalar(value)),
VcardParamKind::SortAs => VcardParam::SortAs(scalars(value)),
VcardParamKind::Type => VcardParam::Type(scalars(value)),
VcardParamKind::Tz => VcardParam::Tz(scalar(value)),
VcardParamKind::Value => VcardParam::Value(scalar(value)),
VcardParamKind::Author => VcardParam::Author(scalar(value)),
VcardParamKind::AuthorName => VcardParam::AuthorName(scalar(value)),
VcardParamKind::Created => VcardParam::Created(scalar(value)),
VcardParamKind::Derived => VcardParam::Derived(scalar(value)),
VcardParamKind::Jsptr => VcardParam::Jsptr(scalar(value)),
VcardParamKind::Phonetic => VcardParam::Phonetic(scalar(value)),
VcardParamKind::PropId => VcardParam::PropId(scalar(value)),
VcardParamKind::Script => VcardParam::Script(scalar(value)),
VcardParamKind::ServiceType => VcardParam::ServiceType(scalar(value)),
VcardParamKind::Username => VcardParam::Username(scalar(value)),
}
}
fn value_from_jcard<'a>(kind: VcardValueKind, values: &'a [Value]) -> VcardValue<'a> {
match kind {
VcardValueKind::Text => VcardValue::Text(VcardText(first_scalar(values))),
VcardValueKind::TextList => {
VcardValue::TextList(VcardTextList(values.iter().flat_map(scalars).collect()))
}
VcardValueKind::Uri => VcardValue::Uri(VcardUri(first_scalar(values))),
VcardValueKind::DateAndOrTime => {
VcardValue::DateAndOrTime(VcardDateAndOrTime(extended_to_basic(first_scalar(values))))
}
VcardValueKind::Timestamp => {
VcardValue::Timestamp(VcardTimestamp(extended_to_basic(first_scalar(values))))
}
VcardValueKind::LanguageTag => {
VcardValue::LanguageTag(VcardLanguageTag(first_scalar(values)))
}
VcardValueKind::UtcOffset => {
VcardValue::UtcOffset(VcardUtcOffset(offset_to_basic(first_scalar(values))))
}
VcardValueKind::N => {
let mut components = structured(values.first()).into_iter();
VcardValue::N(VcardN {
family: components.next().unwrap_or_default(),
given: components.next().unwrap_or_default(),
additional: components.next().unwrap_or_default(),
prefixes: components.next().unwrap_or_default(),
suffixes: components.next().unwrap_or_default(),
})
}
VcardValueKind::Adr => {
let mut components = structured(values.first()).into_iter();
let mut next = || components.next().unwrap_or_default();
VcardValue::Adr(VcardAdr {
po_box: next(),
extended: next(),
street: next(),
locality: next(),
region: next(),
postal_code: next(),
country: next(),
room: next(),
apartment: next(),
floor: next(),
street_number: next(),
street_name: next(),
building: next(),
block: next(),
subdistrict: next(),
district: next(),
landmark: next(),
direction: next(),
})
}
VcardValueKind::Gender => {
let mut components = structured(values.first()).into_iter();
VcardValue::Gender(VcardGender {
sex: first_of(components.next()),
identity: first_of(components.next()),
})
}
VcardValueKind::Org => VcardValue::Org(VcardOrg(
structured(values.first())
.into_iter()
.map(|unit| first_of(Some(unit)))
.collect(),
)),
VcardValueKind::ClientPidMap => {
let mut components = structured(values.first()).into_iter();
VcardValue::ClientPidMap(VcardClientPidMap {
id: first_of(components.next()),
uri: first_of(components.next()),
})
}
VcardValueKind::Geo => {
let mut components = structured(values.first()).into_iter();
VcardValue::Geo(VcardGeo {
latitude: first_of(components.next()),
longitude: first_of(components.next()),
})
}
VcardValueKind::Binary => VcardValue::Binary(VcardBinary::Base64(first_scalar(values))),
}
}
fn unknown_from_jcard(values: &[Value]) -> VcardValueUnknown<'_> {
let components = match values {
[] => vec![vec![Cow::Borrowed("")]],
[Value::Array(groups)] if groups.iter().any(Value::is_array) => {
groups.iter().map(scalars).collect()
}
[Value::Array(group)] => vec![group.iter().map(scalar).collect()],
[value] => vec![vec![scalar(value)]],
values => values.iter().map(scalars).collect(),
};
VcardValueUnknown { components }
}
fn is_text_shaped(kind: VcardValueKind) -> bool {
matches!(
kind,
VcardValueKind::Text
| VcardValueKind::TextList
| VcardValueKind::N
| VcardValueKind::Adr
| VcardValueKind::Gender
| VcardValueKind::Org
| VcardValueKind::ClientPidMap
| VcardValueKind::Geo,
)
}
fn structured(value: Option<&Value>) -> Vec<Vec<Cow<'_, str>>> {
match value {
None => Vec::new(),
Some(Value::Array(components)) => components.iter().map(scalars).collect(),
Some(value) => vec![vec![scalar(value)]],
}
}
fn first_of(group: Option<Vec<Cow<'_, str>>>) -> Cow<'_, str> {
group
.and_then(|group| group.into_iter().next())
.unwrap_or_default()
}
fn first_scalar(values: &[Value]) -> Cow<'_, str> {
values.first().map(scalar).unwrap_or_default()
}
fn scalar(value: &Value) -> Cow<'_, str> {
match value {
Value::String(value) => Cow::Borrowed(value.as_str()),
Value::Number(_) | Value::Bool(_) => Cow::Owned(value.to_string()),
Value::Array(values) => values.first().map(scalar).unwrap_or_default(),
Value::Null | Value::Object(_) => Cow::Borrowed(""),
}
}
fn scalars(value: &Value) -> Vec<Cow<'_, str>> {
match value {
Value::Array(values) => values.iter().map(scalar).collect(),
value => vec![scalar(value)],
}
}
pub(crate) fn basic_to_extended(raw: &str) -> String {
let (date, time) = match raw.split_once('T') {
Some((date, time)) => (date, Some(time)),
None => (raw, None),
};
let date = date_to_extended(date);
match time {
Some(time) => format!("{date}T{}", time_to_extended(time)),
None => date,
}
}
fn date_to_extended(date: &str) -> String {
let bytes = date.as_bytes();
if bytes.len() == 8 && bytes.iter().all(u8::is_ascii_digit) {
return format!("{}-{}-{}", &date[..4], &date[4..6], &date[6..]);
}
if let Some(rest) = date.strip_prefix("--") {
let bytes = rest.as_bytes();
if bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit) {
return format!("--{}-{}", &rest[..2], &rest[2..]);
}
}
date.to_string()
}
fn time_to_extended(time: &str) -> String {
if time.contains(':') {
return time.to_string();
}
let dashes = time.len() - time.trim_start_matches('-').len();
let (prefix, rest) = time.split_at(dashes);
let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
let (digits, zone) = rest.split_at(digits);
let body = match digits.len() {
0 | 2 => digits.to_string(),
4 => format!("{}:{}", &digits[..2], &digits[2..]),
6 => format!("{}:{}:{}", &digits[..2], &digits[2..4], &digits[4..]),
_ => return time.to_string(),
};
format!("{prefix}{body}{}", offset_to_extended(zone))
}
fn offset_to_extended(offset: &str) -> String {
match offset.as_bytes() {
[b'+' | b'-', rest @ ..] if rest.len() == 4 && rest.iter().all(u8::is_ascii_digit) => {
format!("{}:{}", &offset[..3], &offset[3..])
}
_ => offset.to_string(),
}
}
pub(crate) fn extended_to_basic(raw: Cow<'_, str>) -> Cow<'_, str> {
match extended_str_to_basic(&raw) {
Some(basic) => Cow::Owned(basic),
None => raw,
}
}
fn extended_str_to_basic(raw: &str) -> Option<String> {
let (date, time) = match raw.split_once('T') {
Some((date, time)) => (date, Some(time)),
None => (raw, None),
};
let basic_date = date_to_basic(date);
if basic_date == date && !time.is_some_and(|time| time.contains(':')) {
return None;
}
let mut basic = basic_date;
if let Some(time) = time {
basic.push('T');
basic.extend(time.chars().filter(|c| *c != ':'));
}
Some(basic)
}
fn date_to_basic(date: &str) -> String {
let bytes = date.as_bytes();
let full = bytes.len() == 10
&& bytes[4] == b'-'
&& bytes[7] == b'-'
&& [0, 1, 2, 3, 5, 6, 8, 9]
.iter()
.all(|&i| bytes[i].is_ascii_digit());
if full {
return format!("{}{}{}", &date[..4], &date[5..7], &date[8..]);
}
let truncated = bytes.len() == 7
&& date.starts_with("--")
&& bytes[4] == b'-'
&& [2, 3, 5, 6].iter().all(|&i| bytes[i].is_ascii_digit());
if truncated {
return format!("--{}{}", &date[2..4], &date[5..]);
}
date.to_string()
}
fn offset_to_basic(raw: Cow<'_, str>) -> Cow<'_, str> {
if raw.contains(':') {
Cow::Owned(raw.chars().filter(|c| *c != ':').collect())
} else {
raw
}
}
#[cfg(test)]
mod tests {
use alloc::{borrow::Cow, vec};
use serde_json::json;
use crate::{
jcard::{VcardJcardParseError, basic_to_extended, extended_str_to_basic},
param::VcardParam,
prop::VcardPropName,
tree::cst::VcardCst,
value::{VcardValue, VcardValueUnknown, text::VcardText},
vcard::Vcard,
version::VcardVersion,
};
#[test]
fn exports_a_simple_card_with_its_version_entry() {
let cst =
VcardCst::parse("BEGIN:VCARD\r\nVERSION:4.0\r\nFN:John Doe\r\nEND:VCARD\r\n").unwrap();
assert_eq!(
cst.decode().to_jcard(),
json!([
"vcard",
[
["version", {}, "text", "4.0"],
["fn", {}, "text", "John Doe"],
],
]),
);
}
#[test]
fn moves_the_value_param_into_the_type_slot_and_back() {
let cst = VcardCst::parse(
"BEGIN:VCARD\r\nVERSION:4.0\r\nBDAY;VALUE=text:circa 1800\r\nEND:VCARD\r\n",
)
.unwrap();
let jcard = cst.decode().to_jcard();
assert_eq!(
jcard,
json!([
"vcard",
[
["version", {}, "text", "4.0"],
["bday", {}, "text", "circa 1800"],
],
]),
);
let card = Vcard::from_jcard(&jcard).unwrap();
let prop = &card.properties[0];
assert_eq!(prop.params, vec![VcardParam::Value(Cow::Borrowed("text"))]);
assert_eq!(
prop.value,
VcardValue::Text(VcardText(Cow::Borrowed("circa 1800"))),
);
}
#[test]
fn moves_a_group_prefix_into_the_group_parameter_and_back() {
let input = concat!(
"BEGIN:VCARD\r\n",
"VERSION:4.0\r\n",
"ITEM1.X-ABLABEL:Nickname\r\n",
"END:VCARD\r\n",
);
let jcard = VcardCst::parse(input).unwrap().decode().to_jcard();
assert_eq!(
jcard,
json!([
"vcard",
[
["version", {}, "text", "4.0"],
["x-ablabel", { "group": "item1" }, "unknown", "Nickname"],
],
]),
);
let card = Vcard::from_jcard(&jcard).unwrap();
let prop = &card.properties[0];
assert_eq!(
prop.name,
VcardPropName::Unknown(Cow::Borrowed("item1.X-ABLABEL")),
);
assert_eq!(
prop.value,
VcardValue::Unknown(VcardValueUnknown {
components: vec![vec![Cow::Borrowed("Nickname")]],
}),
);
}
#[test]
fn converts_dates_between_basic_and_extended() {
assert_eq!(basic_to_extended("19850412"), "1985-04-12");
assert_eq!(basic_to_extended("1985-04"), "1985-04");
assert_eq!(basic_to_extended("--0412"), "--04-12");
assert_eq!(basic_to_extended("---12"), "---12");
assert_eq!(basic_to_extended("T102200"), "T10:22:00");
assert_eq!(basic_to_extended("T-2200"), "T-22:00");
assert_eq!(
basic_to_extended("19961022T140000-0500"),
"1996-10-22T14:00:00-05:00",
);
assert_eq!(
basic_to_extended("19961022T140000Z"),
"1996-10-22T14:00:00Z"
);
assert_eq!(basic_to_extended("circa 1800"), "circa 1800");
assert_eq!(
extended_str_to_basic("1996-10-22T14:00:00-05:00").as_deref(),
Some("19961022T140000-0500"),
);
assert_eq!(extended_str_to_basic("--04-12").as_deref(), Some("--0412"));
assert_eq!(extended_str_to_basic("1985-04"), None);
assert_eq!(extended_str_to_basic("circa 1800"), None);
}
#[test]
fn merges_a_repeated_parameter_and_keeps_a_list_one() {
let input = concat!(
"BEGIN:VCARD\r\n",
"VERSION:4.0\r\n",
"TEL;TYPE=work,home;TYPE=voice:tel:123\r\n",
"END:VCARD\r\n",
);
let jcard = VcardCst::parse(input).unwrap().decode().to_jcard();
assert_eq!(
jcard,
json!([
"vcard",
[
["version", {}, "text", "4.0"],
["tel", { "type": ["work", "home", "voice"] }, "text", "tel:123"],
],
]),
);
let card = Vcard::from_jcard(&jcard).unwrap();
assert_eq!(
card.properties[0].params,
vec![VcardParam::Type(vec![
Cow::Borrowed("work"),
Cow::Borrowed("home"),
Cow::Borrowed("voice"),
])],
);
}
#[test]
fn round_trips_a_card_through_jcard() {
let input = concat!(
"BEGIN:VCARD\r\n",
"VERSION:4.0\r\n",
"FN:John Doe\r\n",
"N:Doe;John;;Dr.;\r\n",
"EMAIL;TYPE=work:john@example.com\r\n",
"CATEGORIES:friend,colleague\r\n",
"BDAY:19850412\r\n",
"LANG;PREF=1:fr\r\n",
"REV:19961022T140000Z\r\n",
"TZ;VALUE=utc-offset:-0500\r\n",
"UID:urn:uuid:4fbe8971-0bc3-424c-9c26-36c3e1eff6b1\r\n",
"GENDER:F;grrrl\r\n",
"ORG:Example;Research;Optics\r\n",
"ORG:Solo\r\n",
"CLIENTPIDMAP:1;urn:uuid:3df67951-1932-4fc6-9d54-8b4c0e0ba0b2\r\n",
"GEO:geo:37.386013\r\n",
"PHOTO:data:image/jpeg;base64,Zm9v\r\n",
"KEY;ENCODING=b:Zm9v\r\n",
"X-SKI:snowboard\r\n",
"END:VCARD\r\n",
);
let cst = VcardCst::parse(input).unwrap();
let card = cst.decode();
let jcard = card.to_jcard();
let reimported = Vcard::from_jcard(&jcard).unwrap();
assert_eq!(reimported.version, VcardVersion::V4_0);
assert_eq!(reimported, card);
}
#[test]
fn imports_a_foreign_jcard_liberally() {
let jcard = json!([
"VCARD",
[
["fn", {}, "text", "SimonPerreault"],
["clientpidmap", {}, "text", [1, "urn:uuid:x"]],
[
"n",
{},
"text",
["Perreault", "Simon", "", "", ["ing.jr", "M.Sc."]]
],
],
]);
let card = Vcard::from_jcard(&jcard).unwrap();
assert_eq!(card.version, VcardVersion::V4_0);
assert_eq!(card.properties.len(), 3);
assert_eq!(
card.properties[1].value,
VcardValue::ClientPidMap(crate::value::client_pid_map::VcardClientPidMap {
id: Cow::Borrowed("1"),
uri: Cow::Borrowed("urn:uuid:x"),
}),
);
assert_eq!(
card.properties[2].value,
VcardValue::N(crate::value::n::VcardN {
family: vec![Cow::Borrowed("Perreault")],
given: vec![Cow::Borrowed("Simon")],
additional: vec![Cow::Borrowed("")],
prefixes: vec![Cow::Borrowed("")],
suffixes: vec![Cow::Borrowed("ing.jr"), Cow::Borrowed("M.Sc.")],
}),
);
}
#[test]
fn errors_on_a_broken_root_or_property_entry() {
for jcard in [json!({}), json!(["vcalendar", []]), json!(["vcard", [], 3])] {
assert!(matches!(
Vcard::from_jcard(&jcard),
Err(VcardJcardParseError::InvalidCard),
));
}
let jcard = json!(["vcard", [["fn", {}]]]);
assert!(matches!(
Vcard::from_jcard(&jcard),
Err(VcardJcardParseError::InvalidProp(_)),
));
}
}