use core::{error, fmt};
use alloc::{
borrow::Cow,
format,
string::{String, ToString},
vec,
vec::Vec,
};
use serde_json::{Map, Value, json};
use crate::{
component::{IcalComponent, IcalComponentName},
ical::Ical,
param::{IcalParam, IcalParamKind},
prop::{IcalProp, IcalPropName},
tree::prop::prop_spec,
value::{
IcalUnknownValue, IcalValue, IcalValueKind,
binary::IcalBinary,
boolean::IcalBoolean,
cal_address::IcalCalAddress,
datetime::{IcalDate, IcalDateTime, IcalDateTimeList, IcalTime},
duration::IcalDuration,
float::IcalFloat,
geo::IcalGeo,
integer::IcalInteger,
period::IcalPeriod,
recur::IcalRecur,
request_status::IcalRequestStatus,
text::{IcalText, IcalTextList},
uri::IcalUri,
utc_offset::IcalUtcOffset,
},
version::IcalVersion,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum IcalJcalError {
NotAComponent,
NotACalendar(String),
}
impl fmt::Display for IcalJcalError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotAComponent => {
f.write_str("jCal value is not a [name, [properties], [components]] array")
}
Self::NotACalendar(name) => {
write!(f, "jCal document is a `{name}`, not a `vcalendar`")
}
}
}
}
impl error::Error for IcalJcalError {}
impl Ical<'_> {
pub fn to_jcal(&self) -> Value {
let mut props = vec![json!([
"version",
Map::new(),
"text",
Value::String((*self.version).to_string())
])];
props.extend(self.props.iter().map(prop_to_jcal));
let components: Vec<Value> = self.components.iter().map(component_to_jcal).collect();
json!(["vcalendar", props, components])
}
}
impl<'a> Ical<'a> {
pub fn from_jcal(jcal: &'a Value) -> Result<Self, IcalJcalError> {
let (name, props, components) =
split_component(jcal).ok_or(IcalJcalError::NotAComponent)?;
if !name.eq_ignore_ascii_case("vcalendar") {
return Err(IcalJcalError::NotACalendar(name.to_string()));
}
let mut version = IcalVersion::V2_0;
let mut decoded = Vec::new();
for entry in props {
let prop = prop_from_jcal(entry, version);
if prop.name.eq_ignore_ascii_case("VERSION") {
if let IcalValue::Text(text) = &prop.value {
version = text.0.parse().unwrap_or(IcalVersion::V2_0);
}
continue;
}
decoded.push(prop);
}
Ok(Ical {
version,
props: decoded,
components: components
.iter()
.map(|component| component_from_jcal(component, version))
.collect(),
})
}
}
pub(crate) fn component_to_jcal(component: &IcalComponent<'_>) -> Value {
let props: Vec<Value> = component.props.iter().map(prop_to_jcal).collect();
let children: Vec<Value> = component.components.iter().map(component_to_jcal).collect();
json!([component.name.to_lowercase(), props, children])
}
pub(crate) fn component_from_jcal<'a>(jcal: &'a Value, version: IcalVersion) -> IcalComponent<'a> {
let Some((name, props, components)) = split_component(jcal) else {
return IcalComponent {
name: IcalComponentName::from(Cow::Borrowed("")),
props: Vec::new(),
components: Vec::new(),
};
};
IcalComponent {
name: IcalComponentName::from(Cow::Borrowed(name)),
props: props
.iter()
.map(|prop| prop_from_jcal(prop, version))
.collect(),
components: components
.iter()
.map(|component| component_from_jcal(component, version))
.collect(),
}
}
fn split_component(jcal: &Value) -> Option<(&str, &[Value], &[Value])> {
let array = jcal.as_array()?;
let name = array.first()?.as_str()?;
let props = array.get(1).and_then(Value::as_array).map(Vec::as_slice);
let components = array.get(2).and_then(Value::as_array).map(Vec::as_slice);
Some((name, props.unwrap_or(&[]), components.unwrap_or(&[])))
}
pub(crate) fn prop_to_jcal(prop: &IcalProp<'_>) -> Value {
let mut params = Map::new();
for param in &prop.params {
if matches!(param.kind(), Some(IcalParamKind::Value)) {
continue;
}
let (name, value) = param_to_jcal(param);
params.insert(name, value);
}
let mut entry = vec![
Value::String(prop.name.to_lowercase()),
Value::Object(params),
Value::String(type_slot(prop)),
];
entry.extend(value_to_jcal(&prop.value));
Value::Array(entry)
}
pub(crate) fn type_slot(prop: &IcalProp<'_>) -> String {
let declared = prop.params.iter().find_map(|param| match param {
IcalParam::Value(kind) => Some(kind.to_ascii_lowercase()),
_ => None,
});
if let Some(declared) = declared {
return declared;
}
match prop.value.kind() {
Some(IcalValueKind::DateTimeList) => (*IcalValueKind::DateTime).to_ascii_lowercase(),
Some(IcalValueKind::TextList) => (*IcalValueKind::Text).to_ascii_lowercase(),
Some(kind) => (*kind).to_ascii_lowercase(),
None => String::from("unknown"),
}
}
pub(crate) fn param_to_jcal(param: &IcalParam<'_>) -> (String, Value) {
if let IcalParam::Unknown { name, values } = param {
return (name.to_ascii_lowercase(), strings(values));
}
let name = param
.kind()
.map(|kind| (*kind).to_ascii_lowercase())
.unwrap_or_default();
let value = match param {
IcalParam::DelegatedFrom(values)
| IcalParam::DelegatedTo(values)
| IcalParam::Member(values)
| IcalParam::Feature(values) => strings(values),
other => Value::String(param_scalar(other).into_owned()),
};
(name, value)
}
fn strings(values: &[Cow<'_, str>]) -> Value {
match values {
[one] => Value::String(one.to_string()),
many => Value::Array(many.iter().map(|v| Value::String(v.to_string())).collect()),
}
}
pub(crate) fn param_scalar<'a>(param: &'a IcalParam<'_>) -> Cow<'a, str> {
match param {
IcalParam::AltRep(v)
| IcalParam::Cn(v)
| IcalParam::CuType(v)
| IcalParam::Dir(v)
| IcalParam::Encoding(v)
| IcalParam::FmtType(v)
| IcalParam::FbType(v)
| IcalParam::Language(v)
| IcalParam::PartStat(v)
| IcalParam::Range(v)
| IcalParam::Related(v)
| IcalParam::RelType(v)
| IcalParam::Role(v)
| IcalParam::Rsvp(v)
| IcalParam::SentBy(v)
| IcalParam::TzId(v)
| IcalParam::Value(v)
| IcalParam::Display(v)
| IcalParam::Email(v)
| IcalParam::Label(v)
| IcalParam::Order(v)
| IcalParam::Schema(v)
| IcalParam::Derived(v)
| IcalParam::ScheduleAgent(v)
| IcalParam::ScheduleForceSend(v)
| IcalParam::ScheduleStatus(v)
| IcalParam::LinkRel(v)
| IcalParam::Gap(v)
| IcalParam::Charset(v) => Cow::Borrowed(v.as_ref()),
_ => Cow::Borrowed(""),
}
}
pub(crate) fn prop_from_jcal<'a>(entry: &'a Value, version: IcalVersion) -> IcalProp<'a> {
let array = entry.as_array().map(Vec::as_slice).unwrap_or(&[]);
let name = array.first().and_then(Value::as_str).unwrap_or("");
let params = array.get(1).and_then(Value::as_object);
let slot = array.get(2).and_then(Value::as_str).unwrap_or("");
let values = array.get(3..).unwrap_or(&[]);
let mut decoded: Vec<IcalParam<'a>> = params
.into_iter()
.flat_map(|params| {
params
.iter()
.map(|(name, value)| param_from_jcal(name, value))
})
.collect();
let declared = slot.parse::<IcalValueKind>().ok();
let name = IcalPropName::from(Cow::Borrowed(name));
let spec = match name {
IcalPropName::Kind(kind) => Some(prop_spec(kind)),
IcalPropName::Unknown(_) => None,
};
let kind = match &spec {
Some(spec) => Some((spec.value)(version, declared)),
None => declared,
};
let default = spec
.as_ref()
.and_then(|spec| (spec.allowed_values)(version).first().copied())
.map(default_slot)
.unwrap_or(IcalValueKind::Text);
let names_something = !slot.is_empty() && !slot.eq_ignore_ascii_case("unknown");
if names_something && declared != Some(default) {
decoded.push(IcalParam::Value(Cow::Owned(slot.to_ascii_uppercase())));
}
IcalProp {
name: uppercased(name),
params: decoded,
value: value_from_jcal(kind, values),
}
}
fn uppercased(name: IcalPropName<'_>) -> IcalPropName<'_> {
match name {
IcalPropName::Unknown(name) => IcalPropName::Unknown(Cow::Owned(name.to_ascii_uppercase())),
known => known,
}
}
fn default_slot(kind: IcalValueKind) -> IcalValueKind {
match kind {
IcalValueKind::DateTimeList => IcalValueKind::DateTime,
IcalValueKind::TextList => IcalValueKind::Text,
other => other,
}
}
pub(crate) fn param_from_jcal<'a>(name: &'a str, value: &'a Value) -> IcalParam<'a> {
let values: Vec<Cow<'a, str>> = match value {
Value::Array(items) => items.iter().map(scalar_text).collect(),
other => vec![scalar_text(other)],
};
let Ok(kind) = name.parse::<IcalParamKind>() else {
return IcalParam::Unknown {
name: Cow::Owned(name.to_ascii_uppercase()),
values,
};
};
let first = values.first().cloned().unwrap_or(Cow::Borrowed(""));
match kind {
IcalParamKind::AltRep => IcalParam::AltRep(first),
IcalParamKind::Cn => IcalParam::Cn(first),
IcalParamKind::CuType => IcalParam::CuType(first),
IcalParamKind::DelegatedFrom => IcalParam::DelegatedFrom(values),
IcalParamKind::DelegatedTo => IcalParam::DelegatedTo(values),
IcalParamKind::Dir => IcalParam::Dir(first),
IcalParamKind::Encoding => IcalParam::Encoding(first),
IcalParamKind::FmtType => IcalParam::FmtType(first),
IcalParamKind::FbType => IcalParam::FbType(first),
IcalParamKind::Language => IcalParam::Language(first),
IcalParamKind::Member => IcalParam::Member(values),
IcalParamKind::PartStat => IcalParam::PartStat(first),
IcalParamKind::Range => IcalParam::Range(first),
IcalParamKind::Related => IcalParam::Related(first),
IcalParamKind::RelType => IcalParam::RelType(first),
IcalParamKind::Role => IcalParam::Role(first),
IcalParamKind::Rsvp => IcalParam::Rsvp(first),
IcalParamKind::SentBy => IcalParam::SentBy(first),
IcalParamKind::TzId => IcalParam::TzId(first),
IcalParamKind::Value => IcalParam::Value(first),
IcalParamKind::Display => IcalParam::Display(first),
IcalParamKind::Email => IcalParam::Email(first),
IcalParamKind::Feature => IcalParam::Feature(values),
IcalParamKind::Label => IcalParam::Label(first),
IcalParamKind::Order => IcalParam::Order(first),
IcalParamKind::Schema => IcalParam::Schema(first),
IcalParamKind::Derived => IcalParam::Derived(first),
IcalParamKind::ScheduleAgent => IcalParam::ScheduleAgent(first),
IcalParamKind::ScheduleForceSend => IcalParam::ScheduleForceSend(first),
IcalParamKind::ScheduleStatus => IcalParam::ScheduleStatus(first),
IcalParamKind::LinkRel => IcalParam::LinkRel(first),
IcalParamKind::Gap => IcalParam::Gap(first),
IcalParamKind::Charset => IcalParam::Charset(first),
}
}
fn scalar_text(value: &Value) -> Cow<'_, str> {
match value {
Value::String(text) => Cow::Borrowed(text),
Value::Null => Cow::Borrowed(""),
other => Cow::Owned(other.to_string()),
}
}
fn value_to_jcal(value: &IcalValue<'_>) -> Vec<Value> {
match value {
IcalValue::Binary(IcalBinary::Uri(v) | IcalBinary::Base64(v)) => {
vec![Value::String(v.to_string())]
}
IcalValue::Boolean(v) => vec![match v.0.eq_ignore_ascii_case("TRUE") {
true => Value::Bool(true),
false => match v.0.eq_ignore_ascii_case("FALSE") {
true => Value::Bool(false),
false => Value::String(v.0.to_string()),
},
}],
IcalValue::CalAddress(v) => vec![Value::String(v.0.to_string())],
IcalValue::Date(v) => vec![Value::String(date_to_json(&v.0))],
IcalValue::DateTime(v) => vec![Value::String(datetime_to_json(&v.0))],
IcalValue::DateTimeList(v) => {
v.0.iter()
.map(|item| Value::String(period_to_json(item)))
.collect()
}
IcalValue::Duration(v) => vec![Value::String(v.0.to_string())],
IcalValue::Float(v) => vec![number(&v.0)],
IcalValue::Geo(v) => vec![Value::Array(vec![
number(&v.latitude),
number(&v.longitude),
])],
IcalValue::Integer(v) => vec![number(&v.0)],
IcalValue::Period(v) => vec![Value::String(period_to_json(&v.0))],
IcalValue::Recur(v) => vec![recur_to_json(&v.0)],
IcalValue::RequestStatus(v) => {
let mut parts = vec![
Value::String(v.code.to_string()),
Value::String(v.description.to_string()),
];
if !v.extra.is_empty() {
parts.push(Value::String(v.extra.to_string()));
}
vec![Value::Array(parts)]
}
IcalValue::Text(v) => vec![Value::String(v.0.to_string())],
IcalValue::TextList(v) => {
v.0.iter()
.map(|item| Value::String(item.to_string()))
.collect()
}
IcalValue::Time(v) => vec![Value::String(time_to_json(&v.0))],
IcalValue::Uri(v) => vec![Value::String(v.0.to_string())],
IcalValue::UtcOffset(v) => vec![Value::String(offset_to_json(&v.0))],
IcalValue::Unknown(v) => unknown_to_jcal(v),
}
}
fn unknown_to_jcal(value: &IcalUnknownValue<'_>) -> Vec<Value> {
match value.components.as_slice() {
[] => vec![Value::String(String::new())],
[one] => one
.iter()
.map(|item| Value::String(item.to_string()))
.collect(),
many => many
.iter()
.map(|component| {
Value::Array(
component
.iter()
.map(|item| Value::String(item.to_string()))
.collect(),
)
})
.collect(),
}
}
fn value_from_jcal<'a>(kind: Option<IcalValueKind>, values: &'a [Value]) -> IcalValue<'a> {
let first = values.first();
let text = || first.map(scalar_text).unwrap_or(Cow::Borrowed(""));
fn rewritten(text: Cow<'_, str>, by: impl Fn(&str) -> Option<String>) -> Cow<'_, str> {
match by(&text) {
Some(rewritten) => Cow::Owned(rewritten),
None => text,
}
}
let Some(kind) = kind else {
return IcalValue::Unknown(unknown_from_jcal(values));
};
match kind {
IcalValueKind::Binary => IcalValue::Binary(IcalBinary::Base64(text())),
IcalValueKind::Boolean => IcalValue::Boolean(IcalBoolean(match first {
Some(Value::Bool(true)) => Cow::Borrowed("TRUE"),
Some(Value::Bool(false)) => Cow::Borrowed("FALSE"),
_ => text(),
})),
IcalValueKind::CalAddress => IcalValue::CalAddress(IcalCalAddress(text())),
IcalValueKind::Date => IcalValue::Date(IcalDate(rewritten(text(), date_from_json))),
IcalValueKind::DateTime => {
IcalValue::DateTime(IcalDateTime(rewritten(text(), datetime_from_json)))
}
IcalValueKind::DateTimeList => IcalValue::DateTimeList(IcalDateTimeList(
values
.iter()
.map(|value| rewritten(scalar_text(value), period_from_json))
.collect(),
)),
IcalValueKind::Duration => IcalValue::Duration(IcalDuration(text())),
IcalValueKind::Float => IcalValue::Float(IcalFloat(text())),
IcalValueKind::Geo => {
let pair = first
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
IcalValue::Geo(IcalGeo {
latitude: pair.first().map(scalar_text).unwrap_or(Cow::Borrowed("")),
longitude: pair.get(1).map(scalar_text).unwrap_or(Cow::Borrowed("")),
})
}
IcalValueKind::Integer => IcalValue::Integer(IcalInteger(text())),
IcalValueKind::Period => IcalValue::Period(IcalPeriod(rewritten(text(), period_from_json))),
IcalValueKind::Recur => IcalValue::Recur(IcalRecur(recur_from_json(first))),
IcalValueKind::RequestStatus => {
let parts = first
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
IcalValue::RequestStatus(IcalRequestStatus {
code: parts.first().map(scalar_text).unwrap_or(Cow::Borrowed("")),
description: parts.get(1).map(scalar_text).unwrap_or(Cow::Borrowed("")),
extra: parts.get(2).map(scalar_text).unwrap_or(Cow::Borrowed("")),
})
}
IcalValueKind::Text => IcalValue::Text(IcalText(text())),
IcalValueKind::TextList => {
IcalValue::TextList(IcalTextList(values.iter().map(scalar_text).collect()))
}
IcalValueKind::Time => IcalValue::Time(IcalTime(rewritten(text(), time_from_json))),
IcalValueKind::Uri => IcalValue::Uri(IcalUri(text())),
IcalValueKind::UtcOffset => {
IcalValue::UtcOffset(IcalUtcOffset(rewritten(text(), offset_from_json)))
}
}
}
fn unknown_from_jcal<'a>(values: &'a [Value]) -> IcalUnknownValue<'a> {
let nested = values.iter().any(|value| value.is_array());
let components = match nested {
true => values
.iter()
.map(|value| match value.as_array() {
Some(items) => items.iter().map(scalar_text).collect(),
None => vec![scalar_text(value)],
})
.collect(),
false => vec![values.iter().map(scalar_text).collect()],
};
IcalUnknownValue { components }
}
fn number(text: &str) -> Value {
if let Ok(whole) = text.parse::<i64>() {
return Value::Number(whole.into());
}
text.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.map(Value::Number)
.unwrap_or_else(|| Value::String(text.to_string()))
}
pub(crate) fn date_to_json(text: &str) -> String {
match text.len() == 8 && text.bytes().all(|b| b.is_ascii_digit()) {
true => format!("{}-{}-{}", &text[..4], &text[4..6], &text[6..8]),
false => text.to_string(),
}
}
pub(crate) fn date_from_json(text: &str) -> Option<String> {
let bytes = text.as_bytes();
(bytes.len() == 10 && bytes[4] == b'-' && bytes[7] == b'-')
.then(|| format!("{}{}{}", &text[..4], &text[5..7], &text[8..10]))
}
fn time_to_json(text: &str) -> String {
let (body, zulu) = split_zulu(text);
match body.len() == 6 && body.bytes().all(|b| b.is_ascii_digit()) {
true => format!("{}:{}:{}{zulu}", &body[..2], &body[2..4], &body[4..6]),
false => text.to_string(),
}
}
fn time_from_json(text: &str) -> Option<String> {
let (body, zulu) = split_zulu(text);
let bytes = body.as_bytes();
(bytes.len() == 8 && bytes[2] == b':' && bytes[5] == b':')
.then(|| format!("{}{}{}{zulu}", &body[..2], &body[3..5], &body[6..8]))
}
pub(crate) fn datetime_to_json(text: &str) -> String {
let (body, zulu) = split_zulu(text);
let Some((date, time)) = split_t(body) else {
return date_to_json(body) + zulu;
};
format!("{}T{}{zulu}", date_to_json(date), time_to_json(time))
}
pub(crate) fn datetime_from_json(text: &str) -> Option<String> {
let (body, zulu) = split_zulu(text);
let Some((date, time)) = split_t(body) else {
return date_from_json(body).map(|date| format!("{date}{zulu}"));
};
let (rewritten_date, rewritten_time) = (date_from_json(date), time_from_json(time));
if rewritten_date.is_none() && rewritten_time.is_none() {
return None;
}
Some(format!(
"{}T{}{zulu}",
rewritten_date.as_deref().unwrap_or(date),
rewritten_time.as_deref().unwrap_or(time),
))
}
fn period_to_json(text: &str) -> String {
match text.split_once('/') {
Some((start, end)) => format!("{}/{}", datetime_to_json(start), datetime_to_json(end)),
None => datetime_to_json(text),
}
}
fn period_from_json(text: &str) -> Option<String> {
let Some((start, end)) = text.split_once('/') else {
return datetime_from_json(text);
};
let (rewritten_start, rewritten_end) = (datetime_from_json(start), datetime_from_json(end));
if rewritten_start.is_none() && rewritten_end.is_none() {
return None;
}
Some(format!(
"{}/{}",
rewritten_start.as_deref().unwrap_or(start),
rewritten_end.as_deref().unwrap_or(end),
))
}
fn offset_to_json(text: &str) -> String {
let (sign, digits) = match text.as_bytes().first() {
Some(b'+') | Some(b'-') => (&text[..1], &text[1..]),
_ => ("", text),
};
if !digits.bytes().all(|b| b.is_ascii_digit()) {
return text.to_string();
}
match digits.len() {
4 => format!("{sign}{}:{}", &digits[..2], &digits[2..4]),
6 => format!("{sign}{}:{}:{}", &digits[..2], &digits[2..4], &digits[4..6]),
_ => text.to_string(),
}
}
fn offset_from_json(text: &str) -> Option<String> {
text.contains(':').then(|| text.replace(':', ""))
}
const RECUR_ORDER: [&str; 14] = [
"FREQ",
"UNTIL",
"COUNT",
"INTERVAL",
"BYSECOND",
"BYMINUTE",
"BYHOUR",
"BYDAY",
"BYMONTHDAY",
"BYYEARDAY",
"BYWEEKNO",
"BYMONTH",
"BYSETPOS",
"WKST",
];
fn recur_to_json(text: &str) -> Value {
let mut parts = Map::new();
for part in text.split(';').filter(|part| !part.is_empty()) {
let (name, value) = match part.split_once('=') {
Some(split) => split,
None => (part, ""),
};
let name = name.to_ascii_lowercase();
let values: Vec<Value> = value
.split(',')
.map(|item| match name.as_str() {
"until" => Value::String(datetime_to_json(item)),
"freq" | "wkst" | "byday" | "rscale" | "skip" => {
Value::String(item.to_ascii_lowercase())
}
_ => number(item),
})
.collect();
parts.insert(
name,
match values.as_slice() {
[one] => one.clone(),
many => Value::Array(many.to_vec()),
},
);
}
Value::Object(parts)
}
fn recur_from_json(value: Option<&Value>) -> Cow<'_, str> {
let Some(Value::Object(parts)) = value else {
return value.map(scalar_text).unwrap_or(Cow::Borrowed(""));
};
let mut names: Vec<&String> = parts.keys().collect();
names.sort_by_key(|name| {
RECUR_ORDER
.iter()
.position(|known| known.eq_ignore_ascii_case(name))
.unwrap_or(RECUR_ORDER.len())
});
let mut rule = String::new();
for name in names {
let value = &parts[name];
let text = match value {
Value::Array(items) => items
.iter()
.map(|item| recur_part_text(name, item))
.collect::<Vec<_>>()
.join(","),
other => recur_part_text(name, other),
};
if !rule.is_empty() {
rule.push(';');
}
rule.push_str(&name.to_ascii_uppercase());
rule.push('=');
rule.push_str(&text);
}
Cow::Owned(rule)
}
fn recur_part_text(name: &str, value: &Value) -> String {
let text = scalar_text(value);
match name {
"until" => datetime_from_json(&text).unwrap_or_else(|| text.into_owned()),
"freq" | "wkst" | "byday" | "rscale" | "skip" => text.to_ascii_uppercase(),
_ => text.into_owned(),
}
}
fn split_zulu(text: &str) -> (&str, &str) {
match text.as_bytes().last() {
Some(b'Z') | Some(b'z') => (&text[..text.len() - 1], &text[text.len() - 1..]),
_ => (text, ""),
}
}
fn split_t(text: &str) -> Option<(&str, &str)> {
let at = text.find(['T', 't'])?;
Some((&text[..at], &text[at + 1..]))
}