From 3c7ee0911631814359b661f00e6467d8cc863886 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Sun, 18 May 2025 11:55:25 +0200 Subject: [PATCH] WIP: Preparation for recurrence expansion --- crates/store/src/calendar/event.rs | 96 ++++++++++++++++--- crates/store/src/calendar/ical.rs | 21 ++++ crates/store/src/calendar/journal.rs | 1 + crates/store/src/calendar/mod.rs | 2 +- crates/store/src/calendar/object.rs | 38 ++++++-- crates/store/src/calendar/rrule/iter.rs | 41 ++++++++ .../src/calendar/{rrule.rs => rrule/mod.rs} | 7 +- crates/store/src/calendar/timestamp.rs | 81 ++++++++++------ crates/store/src/calendar/todo.rs | 1 + crates/store/src/error.rs | 10 +- 10 files changed, 244 insertions(+), 54 deletions(-) create mode 100644 crates/store/src/calendar/rrule/iter.rs rename crates/store/src/calendar/{rrule.rs => rrule/mod.rs} (98%) diff --git a/crates/store/src/calendar/event.rs b/crates/store/src/calendar/event.rs index aa7e121..1523f64 100644 --- a/crates/store/src/calendar/event.rs +++ b/crates/store/src/calendar/event.rs @@ -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, + pub(crate) ics: String, } impl EventObject { pub fn get_first_occurence(&self) -> Result, 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, 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, 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, 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()); + } +} diff --git a/crates/store/src/calendar/ical.rs b/crates/store/src/calendar/ical.rs index bb89e5e..3f3c134 100644 --- a/crates/store/src/calendar/ical.rs +++ b/crates/store/src/calendar/ical.rs @@ -1,3 +1,5 @@ +use ical::{generator::IcalEvent, property::Property}; + pub trait IcalProperty { fn get_param(&self, name: &str) -> Option>; 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); + } +} diff --git a/crates/store/src/calendar/journal.rs b/crates/store/src/calendar/journal.rs index 354037e..fe9b684 100644 --- a/crates/store/src/calendar/journal.rs +++ b/crates/store/src/calendar/journal.rs @@ -3,4 +3,5 @@ use ical::parser::ical::component::IcalJournal; #[derive(Debug, Clone)] pub struct JournalObject { pub journal: IcalJournal, + pub(crate) ics: String, } diff --git a/crates/store/src/calendar/mod.rs b/crates/store/src/calendar/mod.rs index 037e00f..7c5c4e6 100644 --- a/crates/store/src/calendar/mod.rs +++ b/crates/store/src/calendar/mod.rs @@ -3,7 +3,7 @@ mod event; mod ical; mod journal; mod object; -mod rrule; +pub mod rrule; mod timestamp; mod todo; diff --git a/crates/store/src/calendar/object.rs b/crates/store/src/calendar/object.rs index 0b1cbd4..1617feb 100644 --- a/crates/store/src/calendar/object.rs +++ b/crates/store/src/calendar/object.rs @@ -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 { + // 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()), + } + } } diff --git a/crates/store/src/calendar/rrule/iter.rs b/crates/store/src/calendar/rrule/iter.rs new file mode 100644 index 0000000..c4495a0 --- /dev/null +++ b/crates/store/src/calendar/rrule/iter.rs @@ -0,0 +1,41 @@ +use crate::calendar::CalDateTime; + +use super::{RecurrenceLimit, RecurrenceRule}; + +impl RecurrenceRule { + pub fn between( + &self, + start: CalDateTime, + end: Option, + ) -> impl IntoIterator { + 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 + } +} diff --git a/crates/store/src/calendar/rrule.rs b/crates/store/src/calendar/rrule/mod.rs similarity index 98% rename from crates/store/src/calendar/rrule.rs rename to crates/store/src/calendar/rrule/mod.rs index 7c7ae33..33968e6 100644 --- a/crates/store/src/calendar/rrule.rs +++ b/crates/store/src/calendar/rrule/mod.rs @@ -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)] diff --git a/crates/store/src/calendar/timestamp.rs b/crates/store/src/calendar/timestamp.rs index 43c0375..57ea375 100644 --- a/crates/store/src/calendar/timestamp.rs +++ b/crates/store/src/calendar/timestamp.rs @@ -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); @@ -49,7 +66,7 @@ impl ValueSerialize for UtcDateTime { #[derive(Debug, Clone, PartialEq)] pub enum CalDateTime { // Form 1, example: 19980118T230000 - Local(NaiveDateTime), + Local(DateTime), // Form 2, example: 19980119T070000Z Utc(DateTime), // Form 3, example: TZID=America/New_York:19980119T020000 @@ -66,7 +83,13 @@ impl Add 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, - ) -> Result, Error> { + ) -> Result, 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::() { 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) -> Result { + pub fn parse(value: &str, timezone: Option) -> Result { 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 { 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 for DateTime { @@ -200,10 +225,10 @@ impl From for DateTime { } } -pub fn parse_duration(string: &str) -> Result { +pub fn parse_duration(string: &str) -> Result { 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") { diff --git a/crates/store/src/calendar/todo.rs b/crates/store/src/calendar/todo.rs index 5e9566f..12d9aa1 100644 --- a/crates/store/src/calendar/todo.rs +++ b/crates/store/src/calendar/todo.rs @@ -3,4 +3,5 @@ use ical::parser::ical::component::IcalTodo; #[derive(Debug, Clone)] pub struct TodoObject { pub todo: IcalTodo, + pub(crate) ics: String, } diff --git a/crates/store/src/error.rs b/crates/store/src/error.rs index 7ba1864..4b741e9 100644 --- a/crates/store/src/error.rs +++ b/crates/store/src/error.rs @@ -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 {