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,
rrule::{ParserError, RecurrenceRule},
};
use crate::Error;
use crate::{Error, calendar::ComponentMut};
use chrono::Duration;
use ical::{
generator::IcalEvent,
@@ -15,12 +15,13 @@ use std::collections::HashMap;
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") {
CalDateTime::parse_prop(dtstart, &self.timezones)
Ok(CalDateTime::parse_prop(dtstart, &self.timezones)?)
} else {
Ok(None)
}
@@ -33,21 +34,25 @@ impl EventObject {
}
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),
..
}) = self.event.get_property("DURATION")
{
parse_duration(duration)?
Ok(Some(parse_duration(duration)?))
} else {
Duration::days(1)
};
let first_occurence = self.get_first_occurence()?;
Ok(first_occurence.map(|first_occurence| first_occurence + duration))
Ok(None)
}
}
pub fn recurrence_rule(&self) -> Result<Option<RecurrenceRule>, ParserError> {
@@ -62,10 +67,73 @@ impl EventObject {
RecurrenceRule::parse(rrule).map(Some)
}
pub fn expand_recurrence(&self) -> Result<(), Error> {
let rrule = self.event.get_property("RRULE").unwrap();
dbg!(rrule);
Ok(())
pub fn expand_recurrence(&self) -> Result<Vec<IcalEvent>, Error> {
if let Some(rrule) = self.recurrence_rule()? {
let mut events = vec![];
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 {
fn get_param(&self, name: &str) -> Option<Vec<&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())
}
}
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)]
pub struct JournalObject {
pub journal: IcalJournal,
pub(crate) ics: String,
}

View File

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

View File

@@ -1,6 +1,9 @@
use super::{CalDateTime, EventObject, JournalObject, TodoObject};
use crate::Error;
use ical::parser::{Component, ical::component::IcalTimeZone};
use ical::{
generator::{Emitter, IcalCalendar},
parser::{Component, ical::component::IcalTimeZone},
};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::{collections::HashMap, io::BufReader};
@@ -55,8 +58,8 @@ pub enum CalendarObjectComponent {
#[derive(Debug, Clone)]
pub struct CalendarObject {
id: String,
ics: String,
data: CalendarObjectComponent,
cal: IcalCalendar,
}
impl CalendarObject {
@@ -95,26 +98,31 @@ impl CalendarObject {
if let Some(event) = cal.events.first() {
return Ok(CalendarObject {
id: object_id,
ics,
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,
ics,
data: CalendarObjectComponent::Todo(TodoObject { todo: todo.clone() }),
cal: cal.clone(),
data: CalendarObjectComponent::Todo(TodoObject {
todo: todo.clone(),
ics,
}),
});
}
if let Some(journal) = cal.journals.first() {
return Ok(CalendarObject {
id: object_id,
ics,
cal: cal.clone(),
data: CalendarObjectComponent::Journal(JournalObject {
journal: journal.clone(),
ics,
}),
});
}
@@ -135,7 +143,11 @@ impl CalendarObject {
}
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 {
@@ -167,4 +179,16 @@ impl CalendarObject {
_ => 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 strum_macros::EnumString;
mod iter;
#[derive(Debug, thiserror::Error)]
pub enum ParserError {
#[error("Missing RRULE FREQ")]
@@ -12,9 +14,8 @@ pub enum ParserError {
StrumError(#[from] strum::ParseError),
#[error(transparent)]
ParseIntError(#[from] ParseIntError),
// A little dumb :(
#[error(transparent)]
StoreError(#[from] crate::Error),
CalDateTimeError(#[from] CalDateTimeError),
}
#[derive(Debug, Clone, EnumString, Default, PartialEq)]

View File

@@ -1,6 +1,5 @@
use super::IcalProperty;
use crate::Error;
use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Utc};
use chrono_tz::Tz;
use derive_more::derive::Deref;
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";
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)]
pub struct UtcDateTime(DateTime<Utc>);
@@ -49,7 +66,7 @@ impl ValueSerialize for UtcDateTime {
#[derive(Debug, Clone, PartialEq)]
pub enum CalDateTime {
// Form 1, example: 19980118T230000
Local(NaiveDateTime),
Local(DateTime<Local>),
// Form 2, example: 19980119T070000Z
Utc(DateTime<Utc>),
// 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::Utc(datetime) => Self::Utc(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(
prop: &Property,
timezones: &HashMap<String, IcalTimeZone>,
) -> Result<Option<Self>, Error> {
) -> Result<Option<Self>, CalDateTimeError> {
let prop_value = if let Some(value) = prop.value.as_ref() {
value
} else {
@@ -95,9 +118,7 @@ impl CalDateTime {
if let Ok(tz) = olson_name.parse::<Tz>() {
Some(tz)
} else {
return Err(Error::InvalidData(format!(
"Timezone has X-LIC-LOCATION property to specify a timezone from the Olson database, however its value {olson_name} is invalid"
)));
return Err(CalDateTimeError::InvalidOlson(olson_name));
}
} else {
// If the TZID matches a name from the Olson database (e.g. Europe/Berlin) we
@@ -108,9 +129,7 @@ impl CalDateTime {
}
} else {
// TZID refers to timezone that does not exist
return Err(Error::InvalidData(format!(
"Timezone {tzid} does not exist"
)));
return Err(CalDateTimeError::InvalidTZID(tzid.to_string()));
}
} else {
// No explicit timezone specified.
@@ -134,25 +153,27 @@ impl CalDateTime {
match self {
Self::Utc(utc) => utc.date_naive(),
Self::Date(date) => date.to_owned(),
Self::Local(datetime) => datetime.date(),
Self::Local(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 Some(timezone) = timezone {
let result = datetime.and_local_timezone(timezone);
if let Some(datetime) = result.earliest() {
return Ok(CalDateTime::OlsonTZ(datetime));
} else {
// This time does not exist because there's a gap in local time
return Err(Error::InvalidData(
"Timestamp doesn't exist because of gap in local time".to_owned(),
));
}
return Ok(CalDateTime::OlsonTZ(
datetime
.and_local_timezone(timezone)
.earliest()
.ok_or(CalDateTimeError::LocalTimeGap)?,
));
}
return Ok(CalDateTime::Local(datetime));
return Ok(CalDateTime::Local(
datetime
.and_local_timezone(chrono::Local)
.earliest()
.ok_or(CalDateTimeError::LocalTimeGap)?,
));
}
if let Ok(datetime) = NaiveDateTime::parse_from_str(value, UTC_DATE_TIME) {
@@ -177,21 +198,25 @@ impl CalDateTime {
return Ok(CalDateTime::Date(
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> {
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::OlsonTZ(datetime) => datetime.to_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> {
@@ -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
.captures(string)
.ok_or(Error::InvalidData("Invalid duration format".to_owned()))?;
.ok_or(CalDateTimeError::InvalidDurationFormat(string.to_string()))?;
let mut duration = Duration::zero();
if let Some(weeks) = captures.name("W") {

View File

@@ -3,4 +3,5 @@ use ical::parser::ical::component::IcalTodo;
#[derive(Debug, Clone)]
pub struct TodoObject {
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)]
pub enum Error {
@@ -11,6 +13,9 @@ pub enum Error {
#[error("Invalid ics/vcf input: {0}")]
InvalidData(String),
#[error(transparent)]
RRuleParserError(#[from] crate::calendar::rrule::ParserError),
#[error("Read-only")]
ReadOnly,
@@ -25,6 +30,9 @@ pub enum Error {
#[error(transparent)]
Other(#[from] anyhow::Error),
#[error(transparent)]
CalDateTimeError(#[from] CalDateTimeError),
}
impl ResponseError for Error {