Skip to main content

ical/
jcal.rs

1//! # jCal
2//!
3//! The RFC 7265 jCal codec: the decoded calendar as JSON, and back.
4//!
5//! jCal is the JSON spelling of the iCalendar model. A component is
6//! `[name, [properties], [components]]` and a property is
7//! `[name, {params}, type, value...]` (RFC 7265 3.3, 3.4).
8//!
9//! [`Ical::to_jcal`] writes the decoded model as a [`serde_json::Value`];
10//! [`Ical::from_jcal`] reads one back, borrowing the JSON tree's strings.
11//!
12//! Import resolves each property's value kind through the same spec vtable as
13//! the wire decoder, so a jCal and the calendar it was written from decode to
14//! the same model.
15//!
16//! The boundary is a raw `Value`, not a serde implementation on any calendar
17//! type. One model has two JSON spellings here, jCal and JSCalendar (behind
18//! the `jscalendar` feature), and serde keys one representation per type, so
19//! it is the wrong tool.
20//!
21//! A raw-value boundary also keeps the public API free of a serialization
22//! commitment.
23//!
24//! ## Postel, again
25//!
26//! On the way out the RFC is followed: names are lowercased, the `VALUE`
27//! parameter moves into the type slot (RFC 7265 3.5.4), and dates, times,
28//! periods, offsets and recurrence rules are re-spelled in the JSON forms
29//! (3.5.1 to 3.5.7).
30//!
31//! On the way in anything is accepted: an unknown name, an unknown parameter
32//! and an unrecognised type slot all survive, a non-string scalar is coerced
33//! to text, and a missing part is an empty one.
34//!
35//! ## What round-trips, and what normalises
36//!
37//! A calendar written to jCal and read back decodes to the same model, with
38//! three normalisations that are the JSON format's, not the codec's.
39//!
40//! Parameter order is lost to the JSON object, a recurrence rule comes back
41//! with its parts in the RFC's canonical order (a JSON object has no order to
42//! preserve), and names come back in their canonical spelling.
43//!
44//! Byte fidelity is the syntax tree's job; jCal is a projection of the
45//! decoded model.
46
47pub(crate) mod datetime;
48mod export;
49mod import;
50mod json;
51mod recur;
52
53use core::{error, fmt};
54
55use alloc::{
56    string::{String, ToString},
57    vec,
58    vec::Vec,
59};
60
61use serde_json::{Map, Value, json};
62
63use crate::{
64    component::IcalComponent, ical::Ical, jcal::import::split_component, prop::IcalProp,
65    value::IcalValue, version::IcalVersion,
66};
67
68/// What a jCal value cannot be read as.
69///
70/// Only the shape of the document is refused; everything inside it is read
71/// liberally, so this is a short list on purpose.
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub enum IcalJcalError {
74    /// The document is not a `[name, [properties], [components]]` array.
75    NotAComponent,
76    /// The outermost component is not a `vcalendar`.
77    NotACalendar(String),
78}
79
80impl fmt::Display for IcalJcalError {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        match self {
83            Self::NotAComponent => {
84                f.write_str("jCal value is not a [name, [properties], [components]] array")
85            }
86            Self::NotACalendar(name) => {
87                write!(f, "jCal document is a `{name}`, not a `vcalendar`")
88            }
89        }
90    }
91}
92
93impl error::Error for IcalJcalError {}
94
95impl Ical<'_> {
96    /// The calendar as an RFC 7265 jCal value.
97    ///
98    /// `VERSION` leads the property list, as it does on the wire, carrying the
99    /// calendar's own version rather than a fixed one.
100    pub fn to_jcal(&self) -> Value {
101        let mut props = vec![json!([
102            "version",
103            Map::new(),
104            "text",
105            Value::String((*self.version).to_string())
106        ])];
107        props.extend(self.props.iter().map(IcalProp::to_jcal));
108
109        let components: Vec<Value> = self.components.iter().map(IcalComponent::to_jcal).collect();
110
111        json!(["vcalendar", props, components])
112    }
113}
114
115impl<'a> Ical<'a> {
116    /// Read a calendar back from an RFC 7265 jCal value, borrowing its strings.
117    pub fn from_jcal(jcal: &'a Value) -> Result<Self, IcalJcalError> {
118        let (name, props, components) =
119            split_component(jcal).ok_or(IcalJcalError::NotAComponent)?;
120
121        if !name.eq_ignore_ascii_case("vcalendar") {
122            return Err(IcalJcalError::NotACalendar(name.to_string()));
123        }
124
125        let mut version = IcalVersion::V2_0;
126        let mut decoded = Vec::new();
127
128        for entry in props {
129            let prop = IcalProp::from_jcal(entry, version);
130
131            // NOTE: VERSION is the hoisted-out indicator, never a property of
132            // the model (see `Ical::props`).
133            if prop.name.eq_ignore_ascii_case("VERSION") {
134                if let IcalValue::Text(text) = &prop.value {
135                    version = text.0.parse().unwrap_or(IcalVersion::V2_0);
136                }
137                continue;
138            }
139
140            decoded.push(prop);
141        }
142
143        Ok(Ical {
144            version,
145            props: decoded,
146            components: components
147                .iter()
148                .map(|component| IcalComponent::from_jcal(component, version))
149                .collect(),
150        })
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use alloc::{borrow::Cow, vec};
157
158    use crate::{
159        component::{IcalComponent, IcalComponentKind},
160        ical::Ical,
161        jcal::IcalJcalError,
162        param::IcalParam,
163        prop::{IcalProp, IcalPropKind},
164        value::{IcalValue, datetime::IcalDateTime, text::IcalText},
165        version::IcalVersion,
166    };
167
168    /// A hand-built calendar, so the codec is exercised with no parser.
169    fn calendar() -> Ical<'static> {
170        Ical {
171            version: IcalVersion::V2_0,
172            props: vec![IcalProp {
173                name: IcalPropKind::ProdId.into(),
174                params: vec![],
175                value: IcalValue::Text(IcalText(Cow::Borrowed("-//Example//EN"))),
176            }],
177            components: vec![IcalComponent {
178                name: IcalComponentKind::VEvent.into(),
179                props: vec![
180                    IcalProp {
181                        name: IcalPropKind::Uid.into(),
182                        params: vec![],
183                        value: IcalValue::Text(IcalText(Cow::Borrowed("42@example.com"))),
184                    },
185                    IcalProp {
186                        name: IcalPropKind::DtStart.into(),
187                        params: vec![],
188                        value: IcalValue::DateTime(IcalDateTime(Cow::Borrowed("20260102T120000Z"))),
189                    },
190                    IcalProp {
191                        name: IcalPropKind::Summary.into(),
192                        params: vec![IcalParam::Language(Cow::Borrowed("en"))],
193                        value: IcalValue::Text(IcalText(Cow::Borrowed("Lunch"))),
194                    },
195                ],
196                components: vec![],
197            }],
198        }
199    }
200
201    #[test]
202    fn round_trips_a_calendar_built_with_no_parser() {
203        let cal = calendar();
204        let jcal = cal.to_jcal();
205
206        assert_eq!(Ical::from_jcal(&jcal).expect("a vcalendar"), cal);
207    }
208
209    #[test]
210    fn refuses_a_document_that_is_not_a_vcalendar() {
211        let jcal = serde_json::json!(["vevent", [], []]);
212
213        assert_eq!(
214            Ical::from_jcal(&jcal),
215            Err(IcalJcalError::NotACalendar("vevent".into()))
216        );
217    }
218}