Move ical-related stuff to rustical_ical crate

This commit is contained in:
Lennart
2025-06-03 18:15:26 +02:00
parent 5a6ffd3c19
commit 7f3ce01c2b
35 changed files with 121 additions and 68 deletions

View File

@@ -0,0 +1,212 @@
use crate::Error;
use crate::{CalDateTime, ComponentMut, parse_duration};
use chrono::{DateTime, Duration};
use ical::{
generator::IcalEvent,
parser::{Component, ical::component::IcalTimeZone},
property::Property,
};
use rrule::{RRule, RRuleSet};
use std::{collections::HashMap, str::FromStr};
#[derive(Debug, Clone)]
pub struct EventObject {
pub(crate) event: IcalEvent,
pub(crate) timezones: HashMap<String, IcalTimeZone>,
pub(crate) ics: String,
}
impl EventObject {
pub fn get_first_occurence(&self) -> Result<Option<CalDateTime>, Error> {
if let Some(dtstart) = self.event.get_property("DTSTART") {
Ok(Some(CalDateTime::parse_prop(dtstart, &self.timezones)?))
} else {
Ok(None)
}
}
pub fn get_last_occurence(&self) -> Result<Option<CalDateTime>, Error> {
if let Some(_rrule) = self.event.get_property("RRULE") {
// TODO: understand recurrence rules
return Ok(None);
}
if let Some(dtend) = self.event.get_property("DTEND") {
return Ok(Some(CalDateTime::parse_prop(dtend, &self.timezones)?));
};
let duration = self.get_duration()?.unwrap_or(Duration::days(1));
let first_occurence = self.get_first_occurence()?;
Ok(first_occurence.map(|first_occurence| first_occurence + duration))
}
pub fn get_duration(&self) -> Result<Option<Duration>, Error> {
if let Some(Property {
value: Some(duration),
..
}) = self.event.get_property("DURATION")
{
Ok(Some(parse_duration(duration)?))
} else {
Ok(None)
}
}
pub fn recurrence_ruleset(&self) -> Result<Option<rrule::RRuleSet>, Error> {
let dtstart: DateTime<rrule::Tz> = if let Some(dtstart) = self.get_first_occurence()? {
dtstart
.as_datetime()
.with_timezone(&dtstart.timezone().into())
} else {
return Ok(None);
};
let mut rrule_set = RRuleSet::new(dtstart);
for prop in &self.event.properties {
rrule_set = match prop.name.as_str() {
"RRULE" => {
let rrule = RRule::from_str(prop.value.as_ref().ok_or(Error::RRuleError(
rrule::ParseError::MissingDateGenerationRules.into(),
))?)?
.validate(dtstart)
.unwrap();
rrule_set.rrule(rrule)
}
"RDATE" => {
let rdate = CalDateTime::parse_prop(prop, &self.timezones)?.into();
rrule_set.rdate(rdate)
}
"EXDATE" => {
let exdate = CalDateTime::parse_prop(prop, &self.timezones)?.into();
rrule_set.exdate(exdate)
}
_ => rrule_set,
}
}
Ok(Some(rrule_set))
}
pub fn expand_recurrence(&self) -> Result<Vec<IcalEvent>, Error> {
if let Some(rrule_set) = self.recurrence_ruleset()? {
let mut events = vec![];
let dates = rrule_set.all(2048).dates;
for date in dates {
let date = CalDateTime::from(date);
let mut ev = self.event.clone();
ev.remove_property("RRULE");
ev.set_property(Property {
name: "RECURRENCE-ID".to_string(),
value: Some(date.format()),
params: None,
});
ev.set_property(Property {
name: "DTSTART".to_string(),
value: Some(date.format()),
params: None,
});
if let Some(duration) = self.get_duration()? {
ev.set_property(Property {
name: "DTEND".to_string(),
value: Some((date + duration).format()),
params: None,
});
}
events.push(ev);
}
Ok(events)
} else {
Ok(vec![self.event.clone()])
}
}
}
#[cfg(test)]
mod tests {
use crate::CalendarObject;
use ical::generator::Emitter;
const ICS: &str = r#"BEGIN:VCALENDAR
CALSCALE:GREGORIAN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
END:VTIMEZONE
BEGIN:VEVENT
UID:318ec6503573d9576818daf93dac07317058d95c
DTSTAMP:20250502T132758Z
DTSTART;TZID=Europe/Berlin:20250506T090000
DTEND;TZID=Europe/Berlin:20250506T092500
SEQUENCE:2
SUMMARY:weekly stuff
TRANSP:OPAQUE
RRULE:FREQ=WEEKLY;COUNT=4;INTERVAL=2;BYDAY=TU,TH,SU
END:VEVENT
END:VCALENDAR"#;
const EXPANDED: [&str; 4] = [
"BEGIN:VEVENT\r
UID:318ec6503573d9576818daf93dac07317058d95c\r
DTSTAMP:20250502T132758Z\r
DTEND;TZID=Europe/Berlin:20250506T092500\r
SEQUENCE:2\r
SUMMARY:weekly stuff\r
TRANSP:OPAQUE\r
RECURRENCE-ID:20250506T090000\r
DTSTART:20250506T090000\r
END:VEVENT\r\n",
"BEGIN:VEVENT\r
UID:318ec6503573d9576818daf93dac07317058d95c\r
DTSTAMP:20250502T132758Z\r
DTEND;TZID=Europe/Berlin:20250506T092500\r
SEQUENCE:2\r
SUMMARY:weekly stuff\r
TRANSP:OPAQUE\r
RECURRENCE-ID:20250508T090000\r
DTSTART:20250508T090000\r
END:VEVENT\r\n",
"BEGIN:VEVENT\r
UID:318ec6503573d9576818daf93dac07317058d95c\r
DTSTAMP:20250502T132758Z\r
DTEND;TZID=Europe/Berlin:20250506T092500\r
SEQUENCE:2\r
SUMMARY:weekly stuff\r
TRANSP:OPAQUE\r
RECURRENCE-ID:20250511T090000\r
DTSTART:20250511T090000\r
END:VEVENT\r\n",
"BEGIN:VEVENT\r
UID:318ec6503573d9576818daf93dac07317058d95c\r
DTSTAMP:20250502T132758Z\r
DTEND;TZID=Europe/Berlin:20250506T092500\r
SEQUENCE:2\r
SUMMARY:weekly stuff\r
TRANSP:OPAQUE\r
RECURRENCE-ID:20250520T090000\r
DTSTART:20250520T090000\r
END:VEVENT\r\n",
];
#[test]
fn test_expand_recurrence() {
let event = CalendarObject::from_ics(
"318ec6503573d9576818daf93dac07317058d95c".to_string(),
ICS.to_string(),
)
.unwrap();
let event = event.event().unwrap();
let events: Vec<String> = event
.expand_recurrence()
.unwrap()
.into_iter()
.map(|event| Emitter::generate(&event))
.collect();
assert_eq!(events.as_slice(), &EXPANDED);
}
}

View File

@@ -0,0 +1,7 @@
use ical::parser::ical::component::IcalJournal;
#[derive(Debug, Clone)]
pub struct JournalObject {
pub journal: IcalJournal,
pub(crate) ics: String,
}

View File

@@ -0,0 +1,9 @@
mod event;
mod journal;
mod object;
mod todo;
pub use event::*;
pub use journal::*;
pub use object::*;
pub use todo::*;

View File

@@ -0,0 +1,202 @@
use super::{EventObject, JournalObject, TodoObject};
use crate::CalDateTime;
use crate::Error;
use ical::{
generator::{Emitter, IcalCalendar},
parser::{Component, ical::component::IcalTimeZone},
};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::{collections::HashMap, io::BufReader};
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
// specified in https://datatracker.ietf.org/doc/html/rfc5545#section-3.6
pub enum CalendarObjectType {
Event = 0,
Todo = 1,
Journal = 2,
}
impl CalendarObjectType {
pub fn as_str(&self) -> &'static str {
match self {
CalendarObjectType::Event => "VEVENT",
CalendarObjectType::Todo => "VTODO",
CalendarObjectType::Journal => "VJOURNAL",
}
}
}
impl rustical_xml::ValueSerialize for CalendarObjectType {
fn serialize(&self) -> String {
self.as_str().to_owned()
}
}
impl rustical_xml::ValueDeserialize for CalendarObjectType {
fn deserialize(val: &str) -> std::result::Result<Self, rustical_xml::XmlError> {
match <String as rustical_xml::ValueDeserialize>::deserialize(val)?.as_str() {
"VEVENT" => Ok(Self::Event),
"VTODO" => Ok(Self::Todo),
"VJOURNAL" => Ok(Self::Journal),
_ => Err(rustical_xml::XmlError::InvalidValue(
rustical_xml::ParseValueError::Other(format!(
"Invalid value '{}', must be VEVENT, VTODO, or VJOURNAL",
val
)),
)),
}
}
}
#[derive(Debug, Clone)]
pub enum CalendarObjectComponent {
Event(EventObject),
Todo(TodoObject),
Journal(JournalObject),
}
#[derive(Debug, Clone)]
pub struct CalendarObject {
id: String,
data: CalendarObjectComponent,
cal: IcalCalendar,
}
impl CalendarObject {
pub fn from_ics(object_id: String, ics: String) -> Result<Self, Error> {
let mut parser = ical::IcalParser::new(BufReader::new(ics.as_bytes()));
let cal = parser.next().ok_or(Error::MissingCalendar)??;
if parser.next().is_some() {
return Err(Error::InvalidData(
"multiple calendars, only one allowed".to_owned(),
));
}
if cal.events.len()
+ cal.alarms.len()
+ cal.todos.len()
+ cal.journals.len()
+ cal.free_busys.len()
!= 1
{
// https://datatracker.ietf.org/doc/html/rfc4791#section-4.1
return Err(Error::InvalidData(
"iCalendar object is only allowed to have exactly one component".to_owned(),
));
}
let timezones: HashMap<String, IcalTimeZone> = cal
.timezones
.clone()
.into_iter()
.filter_map(|timezone| {
let timezone_prop = timezone.get_property("TZID")?.to_owned();
let tzid = timezone_prop.value?;
Some((tzid, timezone))
})
.collect();
if let Some(event) = cal.events.first() {
return Ok(CalendarObject {
id: object_id,
cal: cal.clone(),
data: CalendarObjectComponent::Event(EventObject {
event: event.clone(),
timezones,
ics,
}),
});
}
if let Some(todo) = cal.todos.first() {
return Ok(CalendarObject {
id: object_id,
cal: cal.clone(),
data: CalendarObjectComponent::Todo(TodoObject {
todo: todo.clone(),
ics,
}),
});
}
if let Some(journal) = cal.journals.first() {
return Ok(CalendarObject {
id: object_id,
cal: cal.clone(),
data: CalendarObjectComponent::Journal(JournalObject {
journal: journal.clone(),
ics,
}),
});
}
Err(Error::InvalidData(
"iCalendar component type not supported :(".to_owned(),
))
}
pub fn get_id(&self) -> &str {
&self.id
}
pub fn get_etag(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(&self.id);
hasher.update(self.get_ics());
format!("{:x}", hasher.finalize())
}
pub fn get_ics(&self) -> &str {
match &self.data {
CalendarObjectComponent::Todo(todo) => &todo.ics,
CalendarObjectComponent::Event(event) => &event.ics,
CalendarObjectComponent::Journal(journal) => &journal.ics,
}
}
pub fn get_component_name(&self) -> &str {
match self.data {
CalendarObjectComponent::Todo(_) => "VTODO",
CalendarObjectComponent::Event(_) => "VEVENT",
CalendarObjectComponent::Journal(_) => "VJOURNAL",
}
}
pub fn get_object_type(&self) -> CalendarObjectType {
match self.data {
CalendarObjectComponent::Todo(_) => CalendarObjectType::Todo,
CalendarObjectComponent::Event(_) => CalendarObjectType::Event,
CalendarObjectComponent::Journal(_) => CalendarObjectType::Journal,
}
}
pub fn get_first_occurence(&self) -> Result<Option<CalDateTime>, Error> {
match &self.data {
CalendarObjectComponent::Event(event) => event.get_first_occurence(),
_ => Ok(None),
}
}
pub fn get_last_occurence(&self) -> Result<Option<CalDateTime>, Error> {
match &self.data {
CalendarObjectComponent::Event(event) => event.get_last_occurence(),
_ => Ok(None),
}
}
pub fn event(&self) -> Option<&EventObject> {
match &self.data {
CalendarObjectComponent::Event(event) => Some(event),
_ => None,
}
}
pub fn expand_recurrence(&self) -> Result<String, Error> {
// Only events can be expanded
match &self.data {
CalendarObjectComponent::Event(event) => {
let mut cal = self.cal.clone();
cal.events = event.expand_recurrence()?;
Ok(cal.generate())
}
_ => Ok(self.get_ics().to_string()),
}
}
}

View File

@@ -0,0 +1,7 @@
use ical::parser::ical::component::IcalTodo;
#[derive(Debug, Clone)]
pub struct TodoObject {
pub todo: IcalTodo,
pub(crate) ics: String,
}