WIP: Preparation for recurrence expansion

This commit is contained in:
Lennart
2025-05-18 11:55:25 +02:00
parent f55224b21a
commit 3c7ee09116
10 changed files with 244 additions and 54 deletions

View File

@@ -2,7 +2,7 @@ use super::{
CalDateTime, parse_duration, CalDateTime, parse_duration,
rrule::{ParserError, RecurrenceRule}, rrule::{ParserError, RecurrenceRule},
}; };
use crate::Error; use crate::{Error, calendar::ComponentMut};
use chrono::Duration; use chrono::Duration;
use ical::{ use ical::{
generator::IcalEvent, generator::IcalEvent,
@@ -15,12 +15,13 @@ use std::collections::HashMap;
pub struct EventObject { pub struct EventObject {
pub(crate) event: IcalEvent, pub(crate) event: IcalEvent,
pub(crate) timezones: HashMap<String, IcalTimeZone>, pub(crate) timezones: HashMap<String, IcalTimeZone>,
pub(crate) ics: String,
} }
impl EventObject { impl EventObject {
pub fn get_first_occurence(&self) -> Result<Option<CalDateTime>, Error> { pub fn get_first_occurence(&self) -> Result<Option<CalDateTime>, Error> {
if let Some(dtstart) = self.event.get_property("DTSTART") { if let Some(dtstart) = self.event.get_property("DTSTART") {
CalDateTime::parse_prop(dtstart, &self.timezones) Ok(CalDateTime::parse_prop(dtstart, &self.timezones)?)
} else { } else {
Ok(None) Ok(None)
} }
@@ -33,21 +34,25 @@ impl EventObject {
} }
if let Some(dtend) = self.event.get_property("DTEND") { if let Some(dtend) = self.event.get_property("DTEND") {
return CalDateTime::parse_prop(dtend, &self.timezones); return Ok(CalDateTime::parse_prop(dtend, &self.timezones)?);
}; };
let duration = if let Some(Property { 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), value: Some(duration),
.. ..
}) = self.event.get_property("DURATION") }) = self.event.get_property("DURATION")
{ {
parse_duration(duration)? Ok(Some(parse_duration(duration)?))
} else { } else {
Duration::days(1) Ok(None)
}; }
let first_occurence = self.get_first_occurence()?;
Ok(first_occurence.map(|first_occurence| first_occurence + duration))
} }
pub fn recurrence_rule(&self) -> Result<Option<RecurrenceRule>, ParserError> { pub fn recurrence_rule(&self) -> Result<Option<RecurrenceRule>, ParserError> {
@@ -62,10 +67,73 @@ impl EventObject {
RecurrenceRule::parse(rrule).map(Some) RecurrenceRule::parse(rrule).map(Some)
} }
pub fn expand_recurrence(&self) -> Result<(), Error> { pub fn expand_recurrence(&self) -> Result<Vec<IcalEvent>, Error> {
let rrule = self.event.get_property("RRULE").unwrap(); if let Some(rrule) = self.recurrence_rule()? {
dbg!(rrule); let mut events = vec![];
Ok(()) let first_occurence = self.get_first_occurence()?.unwrap();
let dates = rrule.between(first_occurence, None);
for date in dates {
let dtstart_utc = date.cal_utc();
let mut ev = self.event.clone();
ev.remove_property("RRULE");
ev.set_property(Property {
name: "RECURRENCE-ID".to_string(),
value: Some(dtstart_utc.format()),
params: None,
});
ev.set_property(Property {
name: "DTSTART".to_string(),
value: Some(dtstart_utc.format()),
params: None,
});
if let Some(duration) = self.get_duration()? {
ev.set_property(Property {
name: "DTEND".to_string(),
value: Some((dtstart_utc + duration).format()),
params: None,
});
}
events.push(ev);
}
Ok(events)
} else {
Ok(vec![self.event.clone()])
}
} }
} }
#[cfg(test)]
mod tests {
use crate::CalendarObject;
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"#;
#[test]
fn test_expand_recurrence() {
let event = CalendarObject::from_ics(
"318ec6503573d9576818daf93dac07317058d95c".to_string(),
ICS.to_string(),
)
.unwrap();
assert_eq!(event.expand_recurrence().unwrap(), "asd".to_string());
}
}

View File

@@ -1,3 +1,5 @@
use ical::{generator::IcalEvent, property::Property};
pub trait IcalProperty { pub trait IcalProperty {
fn get_param(&self, name: &str) -> Option<Vec<&str>>; fn get_param(&self, name: &str) -> Option<Vec<&str>>;
fn get_value_type(&self) -> Option<&str>; fn get_value_type(&self) -> Option<&str>;
@@ -23,3 +25,22 @@ impl IcalProperty for ical::property::Property {
.and_then(|params| params.into_iter().next()) .and_then(|params| params.into_iter().next())
} }
} }
pub trait ComponentMut {
fn remove_property(&mut self, name: &str);
fn set_property(&mut self, prop: Property);
fn push_property(&mut self, prop: Property);
}
impl ComponentMut for IcalEvent {
fn remove_property(&mut self, name: &str) {
self.properties.retain(|prop| prop.name != name);
}
fn set_property(&mut self, prop: Property) {
self.remove_property(&prop.name);
self.push_property(prop);
}
fn push_property(&mut self, prop: Property) {
self.properties.push(prop);
}
}

View File

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

View File

@@ -3,7 +3,7 @@ mod event;
mod ical; mod ical;
mod journal; mod journal;
mod object; mod object;
mod rrule; pub mod rrule;
mod timestamp; mod timestamp;
mod todo; mod todo;

View File

@@ -1,6 +1,9 @@
use super::{CalDateTime, EventObject, JournalObject, TodoObject}; use super::{CalDateTime, EventObject, JournalObject, TodoObject};
use crate::Error; use crate::Error;
use ical::parser::{Component, ical::component::IcalTimeZone}; use ical::{
generator::{Emitter, IcalCalendar},
parser::{Component, ical::component::IcalTimeZone},
};
use serde::Serialize; use serde::Serialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::{collections::HashMap, io::BufReader}; use std::{collections::HashMap, io::BufReader};
@@ -55,8 +58,8 @@ pub enum CalendarObjectComponent {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CalendarObject { pub struct CalendarObject {
id: String, id: String,
ics: String,
data: CalendarObjectComponent, data: CalendarObjectComponent,
cal: IcalCalendar,
} }
impl CalendarObject { impl CalendarObject {
@@ -95,26 +98,31 @@ impl CalendarObject {
if let Some(event) = cal.events.first() { if let Some(event) = cal.events.first() {
return Ok(CalendarObject { return Ok(CalendarObject {
id: object_id, id: object_id,
ics, cal: cal.clone(),
data: CalendarObjectComponent::Event(EventObject { data: CalendarObjectComponent::Event(EventObject {
event: event.clone(), event: event.clone(),
timezones, timezones,
ics,
}), }),
}); });
} }
if let Some(todo) = cal.todos.first() { if let Some(todo) = cal.todos.first() {
return Ok(CalendarObject { return Ok(CalendarObject {
id: object_id, id: object_id,
cal: cal.clone(),
data: CalendarObjectComponent::Todo(TodoObject {
todo: todo.clone(),
ics, ics,
data: CalendarObjectComponent::Todo(TodoObject { todo: todo.clone() }), }),
}); });
} }
if let Some(journal) = cal.journals.first() { if let Some(journal) = cal.journals.first() {
return Ok(CalendarObject { return Ok(CalendarObject {
id: object_id, id: object_id,
ics, cal: cal.clone(),
data: CalendarObjectComponent::Journal(JournalObject { data: CalendarObjectComponent::Journal(JournalObject {
journal: journal.clone(), journal: journal.clone(),
ics,
}), }),
}); });
} }
@@ -135,7 +143,11 @@ impl CalendarObject {
} }
pub fn get_ics(&self) -> &str { pub fn get_ics(&self) -> &str {
&self.ics 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 { pub fn get_component_name(&self) -> &str {
@@ -167,4 +179,16 @@ impl CalendarObject {
_ => Ok(None), _ => Ok(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,41 @@
use crate::calendar::CalDateTime;
use super::{RecurrenceLimit, RecurrenceRule};
impl RecurrenceRule {
pub fn between(
&self,
start: CalDateTime,
end: Option<CalDateTime>,
) -> impl IntoIterator<Item = CalDateTime> {
let start = start.cal_utc();
// Terrible code, should clean this up later.
let mut end = end.map(|end| CalDateTime::cal_utc(&end));
if let Some(RecurrenceLimit::Until(until)) = &self.limit {
let until = until.cal_utc();
let mut _end = end.unwrap_or(until.clone());
if until.utc() < _end.utc() {
_end = until;
}
end = Some(_end);
}
let count = if let Some(RecurrenceLimit::Count(count)) = &self.limit {
*count
} else {
2048
};
let mut datetimes = vec![start.clone()];
let mut datetime_utc = start.utc();
while datetimes.len() < count {
if let Some(end) = &end {
if datetime_utc > end.utc() {
break;
}
datetimes.push(CalDateTime::Utc(datetime_utc));
}
}
datetimes
}
}

View File

@@ -1,7 +1,9 @@
use super::CalDateTime; use super::{CalDateTime, CalDateTimeError};
use std::{num::ParseIntError, str::FromStr}; use std::{num::ParseIntError, str::FromStr};
use strum_macros::EnumString; use strum_macros::EnumString;
mod iter;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ParserError { pub enum ParserError {
#[error("Missing RRULE FREQ")] #[error("Missing RRULE FREQ")]
@@ -12,9 +14,8 @@ pub enum ParserError {
StrumError(#[from] strum::ParseError), StrumError(#[from] strum::ParseError),
#[error(transparent)] #[error(transparent)]
ParseIntError(#[from] ParseIntError), ParseIntError(#[from] ParseIntError),
// A little dumb :(
#[error(transparent)] #[error(transparent)]
StoreError(#[from] crate::Error), CalDateTimeError(#[from] CalDateTimeError),
} }
#[derive(Debug, Clone, EnumString, Default, PartialEq)] #[derive(Debug, Clone, EnumString, Default, PartialEq)]

View File

@@ -1,6 +1,5 @@
use super::IcalProperty; use super::IcalProperty;
use crate::Error; use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use chrono_tz::Tz; use chrono_tz::Tz;
use derive_more::derive::Deref; use derive_more::derive::Deref;
use ical::{ use ical::{
@@ -22,6 +21,24 @@ const LOCAL_DATE_TIME: &str = "%Y%m%dT%H%M%S";
const UTC_DATE_TIME: &str = "%Y%m%dT%H%M%SZ"; const UTC_DATE_TIME: &str = "%Y%m%dT%H%M%SZ";
pub const LOCAL_DATE: &str = "%Y%m%d"; pub const LOCAL_DATE: &str = "%Y%m%d";
#[derive(Debug, thiserror::Error)]
pub enum CalDateTimeError {
#[error(
"Timezone has X-LIC-LOCATION property to specify a timezone from the Olson database, however its value {0} is invalid"
)]
InvalidOlson(String),
#[error("TZID {0} does not refer to a valid timezone")]
InvalidTZID(String),
#[error("Timestamp doesn't exist because of gap in local time")]
LocalTimeGap,
#[error("Datetime string {0} has an invalid format")]
InvalidDatetimeFormat(String),
#[error("Could not parse datetime {0}")]
ParseError(String),
#[error("Duration string {0} has an invalid format")]
InvalidDurationFormat(String),
}
#[derive(Debug, Clone, Deref, PartialEq)] #[derive(Debug, Clone, Deref, PartialEq)]
pub struct UtcDateTime(DateTime<Utc>); pub struct UtcDateTime(DateTime<Utc>);
@@ -49,7 +66,7 @@ impl ValueSerialize for UtcDateTime {
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum CalDateTime { pub enum CalDateTime {
// Form 1, example: 19980118T230000 // Form 1, example: 19980118T230000
Local(NaiveDateTime), Local(DateTime<Local>),
// Form 2, example: 19980119T070000Z // Form 2, example: 19980119T070000Z
Utc(DateTime<Utc>), Utc(DateTime<Utc>),
// Form 3, example: TZID=America/New_York:19980119T020000 // Form 3, example: TZID=America/New_York:19980119T020000
@@ -66,7 +83,13 @@ impl Add<Duration> for CalDateTime {
Self::Local(datetime) => Self::Local(datetime + duration), Self::Local(datetime) => Self::Local(datetime + duration),
Self::Utc(datetime) => Self::Utc(datetime + duration), Self::Utc(datetime) => Self::Utc(datetime + duration),
Self::OlsonTZ(datetime) => Self::OlsonTZ(datetime + duration), Self::OlsonTZ(datetime) => Self::OlsonTZ(datetime + duration),
Self::Date(date) => Self::Local(date.and_time(NaiveTime::default()) + duration), Self::Date(date) => Self::Local(
date.and_time(NaiveTime::default())
.and_local_timezone(Local)
.earliest()
.expect("Local timezone has constant offset")
+ duration,
),
} }
} }
} }
@@ -75,7 +98,7 @@ impl CalDateTime {
pub fn parse_prop( pub fn parse_prop(
prop: &Property, prop: &Property,
timezones: &HashMap<String, IcalTimeZone>, timezones: &HashMap<String, IcalTimeZone>,
) -> Result<Option<Self>, Error> { ) -> Result<Option<Self>, CalDateTimeError> {
let prop_value = if let Some(value) = prop.value.as_ref() { let prop_value = if let Some(value) = prop.value.as_ref() {
value value
} else { } else {
@@ -95,9 +118,7 @@ impl CalDateTime {
if let Ok(tz) = olson_name.parse::<Tz>() { if let Ok(tz) = olson_name.parse::<Tz>() {
Some(tz) Some(tz)
} else { } else {
return Err(Error::InvalidData(format!( return Err(CalDateTimeError::InvalidOlson(olson_name));
"Timezone has X-LIC-LOCATION property to specify a timezone from the Olson database, however its value {olson_name} is invalid"
)));
} }
} else { } else {
// If the TZID matches a name from the Olson database (e.g. Europe/Berlin) we // If the TZID matches a name from the Olson database (e.g. Europe/Berlin) we
@@ -108,9 +129,7 @@ impl CalDateTime {
} }
} else { } else {
// TZID refers to timezone that does not exist // TZID refers to timezone that does not exist
return Err(Error::InvalidData(format!( return Err(CalDateTimeError::InvalidTZID(tzid.to_string()));
"Timezone {tzid} does not exist"
)));
} }
} else { } else {
// No explicit timezone specified. // No explicit timezone specified.
@@ -134,25 +153,27 @@ impl CalDateTime {
match self { match self {
Self::Utc(utc) => utc.date_naive(), Self::Utc(utc) => utc.date_naive(),
Self::Date(date) => date.to_owned(), Self::Date(date) => date.to_owned(),
Self::Local(datetime) => datetime.date(), Self::Local(datetime) => datetime.date_naive(),
Self::OlsonTZ(datetime) => datetime.date_naive(), Self::OlsonTZ(datetime) => datetime.date_naive(),
} }
} }
pub fn parse(value: &str, timezone: Option<Tz>) -> Result<Self, Error> { pub fn parse(value: &str, timezone: Option<Tz>) -> Result<Self, CalDateTimeError> {
if let Ok(datetime) = NaiveDateTime::parse_from_str(value, LOCAL_DATE_TIME) { if let Ok(datetime) = NaiveDateTime::parse_from_str(value, LOCAL_DATE_TIME) {
if let Some(timezone) = timezone { if let Some(timezone) = timezone {
let result = datetime.and_local_timezone(timezone); return Ok(CalDateTime::OlsonTZ(
if let Some(datetime) = result.earliest() { datetime
return Ok(CalDateTime::OlsonTZ(datetime)); .and_local_timezone(timezone)
} else { .earliest()
// This time does not exist because there's a gap in local time .ok_or(CalDateTimeError::LocalTimeGap)?,
return Err(Error::InvalidData(
"Timestamp doesn't exist because of gap in local time".to_owned(),
)); ));
} }
} return Ok(CalDateTime::Local(
return Ok(CalDateTime::Local(datetime)); datetime
.and_local_timezone(chrono::Local)
.earliest()
.ok_or(CalDateTimeError::LocalTimeGap)?,
));
} }
if let Ok(datetime) = NaiveDateTime::parse_from_str(value, UTC_DATE_TIME) { if let Ok(datetime) = NaiveDateTime::parse_from_str(value, UTC_DATE_TIME) {
@@ -177,21 +198,25 @@ impl CalDateTime {
return Ok(CalDateTime::Date( return Ok(CalDateTime::Date(
NaiveDate::from_ymd_opt(year, month, day) NaiveDate::from_ymd_opt(year, month, day)
.ok_or(Error::InvalidData(format!("Could not parse date {value}")))?, .ok_or(CalDateTimeError::ParseError(value.to_string()))?,
)); ));
} }
Err(Error::InvalidData("Invalid datetime format".to_owned())) Err(CalDateTimeError::InvalidDatetimeFormat(value.to_string()))
} }
pub fn utc(&self) -> DateTime<Utc> { pub fn utc(&self) -> DateTime<Utc> {
match &self { match &self {
CalDateTime::Local(local_datetime) => local_datetime.and_utc(), CalDateTime::Local(local_datetime) => local_datetime.to_utc(),
CalDateTime::Utc(utc_datetime) => utc_datetime.to_owned(), CalDateTime::Utc(utc_datetime) => utc_datetime.to_owned(),
CalDateTime::OlsonTZ(datetime) => datetime.to_utc(), CalDateTime::OlsonTZ(datetime) => datetime.to_utc(),
CalDateTime::Date(date) => date.and_time(NaiveTime::default()).and_utc(), CalDateTime::Date(date) => date.and_time(NaiveTime::default()).and_utc(),
} }
} }
pub fn cal_utc(&self) -> Self {
Self::Utc(self.utc())
}
} }
impl From<CalDateTime> for DateTime<Utc> { impl From<CalDateTime> for DateTime<Utc> {
@@ -200,10 +225,10 @@ impl From<CalDateTime> for DateTime<Utc> {
} }
} }
pub fn parse_duration(string: &str) -> Result<Duration, Error> { pub fn parse_duration(string: &str) -> Result<Duration, CalDateTimeError> {
let captures = RE_DURATION let captures = RE_DURATION
.captures(string) .captures(string)
.ok_or(Error::InvalidData("Invalid duration format".to_owned()))?; .ok_or(CalDateTimeError::InvalidDurationFormat(string.to_string()))?;
let mut duration = Duration::zero(); let mut duration = Duration::zero();
if let Some(weeks) = captures.name("W") { if let Some(weeks) = captures.name("W") {

View File

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

View File

@@ -1,4 +1,6 @@
use actix_web::{http::StatusCode, ResponseError}; use actix_web::{ResponseError, http::StatusCode};
use crate::calendar::CalDateTimeError;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
@@ -11,6 +13,9 @@ pub enum Error {
#[error("Invalid ics/vcf input: {0}")] #[error("Invalid ics/vcf input: {0}")]
InvalidData(String), InvalidData(String),
#[error(transparent)]
RRuleParserError(#[from] crate::calendar::rrule::ParserError),
#[error("Read-only")] #[error("Read-only")]
ReadOnly, ReadOnly,
@@ -25,6 +30,9 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
Other(#[from] anyhow::Error), Other(#[from] anyhow::Error),
#[error(transparent)]
CalDateTimeError(#[from] CalDateTimeError),
} }
impl ResponseError for Error { impl ResponseError for Error {