From 5a6ffd3c193d1297a0503d18ba5e49a9f12e4a53 Mon Sep 17 00:00:00 2001 From: Lennart <18233294+lennart-k@users.noreply.github.com> Date: Tue, 3 Jun 2025 17:48:07 +0200 Subject: [PATCH] some preparation for reccurence expansion --- Cargo.lock | 15 + crates/ical/Cargo.toml | 1 + crates/ical/src/lib.rs | 2 - crates/ical/src/rrule/iter.rs | 211 ------------ crates/ical/src/rrule/iter_monthly.rs | 0 crates/ical/src/rrule/iter_yearly.rs | 175 ---------- crates/ical/src/rrule/mod.rs | 318 ------------------ crates/ical/src/timestamp.rs | 150 ++++++--- crates/ical/src/timezone.rs | 22 +- crates/store/Cargo.toml | 1 + .../store/src/addressbook/address_object.rs | 4 +- crates/store/src/calendar/event.rs | 173 +++++++--- crates/store/src/calendar/object.rs | 17 +- crates/store/src/error.rs | 6 +- 14 files changed, 278 insertions(+), 817 deletions(-) delete mode 100644 crates/ical/src/rrule/iter.rs delete mode 100644 crates/ical/src/rrule/iter_monthly.rs delete mode 100644 crates/ical/src/rrule/iter_yearly.rs delete mode 100644 crates/ical/src/rrule/mod.rs diff --git a/Cargo.lock b/Cargo.lock index f738407..6f084c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2894,6 +2894,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rrule" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "720acfb4980b9d8a6a430f6d7a11933e701ebbeba5eee39cc9d8c5f932aaff74" +dependencies = [ + "chrono", + "chrono-tz", + "log", + "regex", + "thiserror 2.0.12", +] + [[package]] name = "rsa" version = "0.9.8" @@ -3191,6 +3204,7 @@ dependencies = [ "ical", "lazy_static", "regex", + "rrule", "rustical_xml", "strum", "strum_macros", @@ -3227,6 +3241,7 @@ dependencies = [ "lazy_static", "rand 0.8.5", "regex", + "rrule", "rstest", "rstest_reuse", "rustical_dav", diff --git a/crates/ical/Cargo.toml b/crates/ical/Cargo.toml index e8d047d..d4215c8 100644 --- a/crates/ical/Cargo.toml +++ b/crates/ical/Cargo.toml @@ -16,3 +16,4 @@ lazy_static.workspace = true regex.workspace = true strum.workspace = true strum_macros.workspace = true +rrule = "0.14" diff --git a/crates/ical/src/lib.rs b/crates/ical/src/lib.rs index fc6385c..f75f94d 100644 --- a/crates/ical/src/lib.rs +++ b/crates/ical/src/lib.rs @@ -1,5 +1,3 @@ -pub mod rrule; - mod property_ext; pub use property_ext::*; diff --git a/crates/ical/src/rrule/iter.rs b/crates/ical/src/rrule/iter.rs deleted file mode 100644 index 541cd2f..0000000 --- a/crates/ical/src/rrule/iter.rs +++ /dev/null @@ -1,211 +0,0 @@ -use super::{RecurrenceFrequency, RecurrenceLimit, RecurrenceRule}; -use crate::CalDateTime; -use chrono::{Datelike, Duration, IsoWeek, NaiveDate, Weekday, WeekdaySet}; -use std::collections::HashSet; - -/* -* https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10 - +----------+--------+--------+-------+-------+------+-------+------+ - | |SECONDLY|MINUTELY|HOURLY |DAILY |WEEKLY|MONTHLY|YEARLY| - +----------+--------+--------+-------+-------+------+-------+------+ - |BYMONTH |Limit |Limit |Limit |Limit |Limit |Limit |Expand| - +----------+--------+--------+-------+-------+------+-------+------+ - |BYWEEKNO |N/A |N/A |N/A |N/A |N/A |N/A |Expand| - +----------+--------+--------+-------+-------+------+-------+------+ - |BYYEARDAY |Limit |Limit |Limit |N/A |N/A |N/A |Expand| - +----------+--------+--------+-------+-------+------+-------+------+ - |BYMONTHDAY|Limit |Limit |Limit |Limit |N/A |Expand |Expand| - +----------+--------+--------+-------+-------+------+-------+------+ - |BYDAY |Limit |Limit |Limit |Limit |Expand|Note 1 |Note 2| - +----------+--------+--------+-------+-------+------+-------+------+ - |BYHOUR |Limit |Limit |Limit |Expand |Expand|Expand |Expand| - +----------+--------+--------+-------+-------+------+-------+------+ - |BYMINUTE |Limit |Limit |Expand |Expand |Expand|Expand |Expand| - +----------+--------+--------+-------+-------+------+-------+------+ - |BYSECOND |Limit |Expand |Expand |Expand |Expand|Expand |Expand| - +----------+--------+--------+-------+-------+------+-------+------+ - |BYSETPOS |Limit |Limit |Limit |Limit |Limit |Limit |Limit | - +----------+--------+--------+-------+-------+------+-------+------+ - - Note 1: Limit if BYMONTHDAY is present; otherwise, special expand - for MONTHLY. - - Note 2: Limit if BYYEARDAY or BYMONTHDAY is present; otherwise, - special expand for WEEKLY if BYWEEKNO present; otherwise, - special expand for MONTHLY if BYMONTH present; otherwise, - special expand for YEARLY. -*/ - -pub(crate) fn is_leap_year(year: i32) -> bool { - year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) -} - -pub(crate) fn last_day_of_month(year: i32, month: u8) -> u8 { - match month { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 => { - if is_leap_year(year) { - 29 - } else { - 28 - } - } - _ => panic!("invalid month: {}", month), - } -} - -pub(crate) fn list_days_in_month(year: i32, month: u8) -> &'static [i8] { - match last_day_of_month(year, month) { - 28 => &DAYS28, - 29 => &DAYS29, - 30 => &DAYS30, - 31 => &DAYS31, - _ => unreachable!(), - } -} - -pub(crate) fn first_weekday(year: i32, wkst: Weekday) -> NaiveDate { - // The first week of the year (starting from WKST) is the week having at - // least four days in the year - // isoweek_start marks week 1 with WKST - let mut isoweek_start = NaiveDate::from_isoywd_opt(year, 1, wkst).unwrap(); - if isoweek_start.year() == year && isoweek_start.day0() >= 4 { - // We can fit another week before - isoweek_start -= Duration::days(7); - } - isoweek_start -} - -pub(crate) fn weeks_in_year(year: i32, wkst: Weekday) -> (NaiveDate, u8) { - let first_day = first_weekday(year, wkst); - let next_year_first_day = first_weekday(year + 1, wkst); - ( - first_day, - (next_year_first_day - first_day).num_weeks() as u8, - ) -} - -// Writing this bodge was easier than doing it the hard way :D -const DAYS28: [i8; 28] = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, -]; -const DAYS29: [i8; 29] = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, -]; -const DAYS30: [i8; 30] = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, -]; -const DAYS31: [i8; 31] = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, -]; - -impl RecurrenceRule { - pub fn between( - &self, - start: CalDateTime, - end: Option, - limit: Option, - ) -> Vec { - let mut end = end.as_ref(); - if let Some(RecurrenceLimit::Until(until)) = &self.limit { - end = Some(end.unwrap_or(until).min(until)); - } - let mut count = if let Some(RecurrenceLimit::Count(count)) = &self.limit { - *count - } else { - 2048 - }; - if let Some(limit) = limit { - count = count.min(limit) - } - - let datetimes = vec![]; - - let mut year = start.year(); - let mut month0 = start.month0(); - // let months0 = self.bymonth0.clone().unwrap_or(vec![start.month0() as i8]); - - let offset_weekdays = self.offset_weekdays(); - let absolute_weekdays = self.absolute_weekdays(); - - while datetimes.len() < count { - let mut result_dates = vec![start.date()]; - // Iterate over frequency*interval - match self.frequency { - RecurrenceFrequency::Yearly => year += self.interval as i32, - RecurrenceFrequency::Monthly => { - month0 += self.interval; - year += (month0 as f32 / 12.).floor() as i32; - month0 %= 12; - } - _ => {} - } - - #[allow(clippy::single_match)] - match self.frequency { - RecurrenceFrequency::Yearly => {} - // RecurrenceFrequency::Monthly => { - // // Filter bymonth - // if let Some(bymonth0) = &self.bymonth0 { - // if !bymonth0.contains(&(month0 as u8)) { - // continue; - // } - // } - // - // if let Some(monthdays) = &self.bymonthday { - // for monthday in monthdays { - // let monthday = if *monthday > 0 { - // *monthday as u32 - // } else { - // // +1 because -1 is the last day - // last_day_of_month(year, month0 as u8 + 1) as u32 - // + 1 - // // monthday is negative - // + *monthday as u32 - // }; - // let date = if let Some(date) = - // NaiveDate::from_ymd_opt(year, month0 as u32 + 1, monthday) - // { - // date - // } else { - // continue; - // }; - // - // if let Some(weekdays) = absolute_weekdays { - // if weekdays.contains(date.weekday()) { - // dates.insert(date); - // } - // } else { - // dates.insert(date); - // } - // } - // } - // } - _ => {} - } - - if let Some(end) = end {} - // datetimes.push(datetime.to_owned()); - } - - datetimes - } -} - -#[cfg(test)] -mod tests { - // use crate::{CalDateTime, rrule::RecurrenceRule}; - - // #[test] - // fn test_between() { - // // Example: Last workday of the month - // let rrule = RecurrenceRule::parse("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1").unwrap(); - // let start = CalDateTime::parse("20250516T133000Z", None).unwrap(); - // assert_eq!(rrule.between(start, None, Some(4)), vec![]); - // } -} diff --git a/crates/ical/src/rrule/iter_monthly.rs b/crates/ical/src/rrule/iter_monthly.rs deleted file mode 100644 index e69de29..0000000 diff --git a/crates/ical/src/rrule/iter_yearly.rs b/crates/ical/src/rrule/iter_yearly.rs deleted file mode 100644 index cd06ab0..0000000 --- a/crates/ical/src/rrule/iter_yearly.rs +++ /dev/null @@ -1,175 +0,0 @@ -use std::collections::HashSet; - -use chrono::{Datelike, Duration, NaiveDate}; - -use super::{ - RecurrenceRule, - iter::{last_day_of_month, list_days_in_month, weeks_in_year}, -}; - -impl RecurrenceRule { - pub fn week_expansion(&self, year: i32) -> Option> { - let absolute_weekdays = self.absolute_weekdays(); - - if let Some(byweekno) = &self.byweekno { - let mut dates = HashSet::new(); - - let weekstart = self.week_start.unwrap_or(chrono::Weekday::Mon); - let (first_weekstart, num_weeks) = weeks_in_year(year, weekstart); - - let weeknums0: Vec = byweekno - .iter() - .map(|num| { - if *num < 0 { - (num_weeks as i8 + *num) as u8 - } else { - (*num - 1) as u8 - } - }) - .collect(); - - for weeknum0 in weeknums0 { - let weekstart_date = first_weekstart + Duration::weeks(weeknum0 as i64); - // Iterate over the week and check if the weekdays are allowed - for i in 0..7 { - let date = weekstart_date + Duration::days(i); - if let Some(weekdays) = absolute_weekdays { - if weekdays.contains(date.weekday()) { - dates.insert(date); - } - } else { - dates.insert(date); - } - } - } - Some(dates) - } else { - None - } - } - - pub fn month_expansion(&self, year: i32) -> Option> { - let offset_weekdays = self.offset_weekdays(); - let absolute_weekdays = self.absolute_weekdays(); - - if let Some(bymonth0) = &self.bymonth0 { - let mut dates = HashSet::new(); - for month0 in bymonth0 { - // Add BYMONTHDAY or all days - let monthdays = self - .bymonthday - .as_deref() - .unwrap_or(list_days_in_month(year, month0 + 1)); - - for monthday in monthdays { - let monthday = if *monthday > 0 { - *monthday as u32 - } else { - // +1 because -1 is the last day - last_day_of_month(year, month0 + 1) as u32 - + 1 - // monthday is negative - + *monthday as u32 - }; - let date = if let Some(date) = - NaiveDate::from_ymd_opt(year, *month0 as u32 + 1, monthday) - { - date - } else { - continue; - }; - - if let Some(weekdays) = absolute_weekdays { - if weekdays.contains(date.weekday()) { - dates.insert(date); - } - } else { - dates.insert(date); - } - } - - // Add offset weekdays - if let Some(offset_weekdays) = &offset_weekdays { - for (num, day) in offset_weekdays.iter() { - let date = if *num > 0 { - NaiveDate::from_weekday_of_month_opt( - year, - *month0 as u32 + 1, - *day, - *num as u8, - ) - } else { - // If index negative: - // Go to first day of next month and walk back the weeks - NaiveDate::from_weekday_of_month_opt( - year, - *month0 as u32 + 1 + 1, - *day, - 1, - ) - .map(|date| date + Duration::weeks(*num)) - }; - - if let Some(date) = date { - dates.insert(date); - } - } - } - } - Some(dates) - } else { - None - } - } - - pub fn dates_yearly( - &self, - start: NaiveDate, - end: Option, - limit: usize, - ) -> Vec { - let mut dates = vec![start]; - let mut year = start.year(); - - while dates.len() < limit { - // Expand BYMONTH - let month_expansion = self.month_expansion(year); - - // Expand BYWEEKNO - let week_expansion = self.week_expansion(year); - - let mut occurence_set = match (month_expansion, week_expansion) { - (Some(month_expansion), Some(week_expansion)) => month_expansion - .intersection(&week_expansion) - .cloned() - .collect(), - (Some(month_expansion), None) => month_expansion, - (None, Some(week_expansion)) => week_expansion, - (None, None) => start - .with_year(year) - .map(|date| HashSet::from([date])) - .unwrap_or_default(), - } - .into_iter() - .collect::>(); - occurence_set.sort(); - if let Some(bysetpos) = &self.bysetpos { - occurence_set = bysetpos - .iter() - .filter_map(|i| { - if *i > 0 { - occurence_set.get((*i - 1) as usize) - } else { - occurence_set.get((occurence_set.len() as i64 + *i) as usize) - } - }) - .cloned() - .collect(); - } - dates.extend_from_slice(occurence_set.as_slice()); - year += 1; - } - - dates - } -} diff --git a/crates/ical/src/rrule/mod.rs b/crates/ical/src/rrule/mod.rs deleted file mode 100644 index 62c0ea5..0000000 --- a/crates/ical/src/rrule/mod.rs +++ /dev/null @@ -1,318 +0,0 @@ -use super::{CalDateTime, CalDateTimeError}; -use chrono::{Weekday, WeekdaySet}; -use std::{num::ParseIntError, str::FromStr}; -use strum_macros::EnumString; - -mod iter; -mod iter_monthly; -mod iter_yearly; - -#[derive(Debug, thiserror::Error)] -pub enum ParserError { - #[error("Missing RRULE FREQ")] - MissingFrequency, - #[error("Invalid RRULE part: {0}")] - InvalidPart(String), - #[error(transparent)] - StrumError(#[from] strum::ParseError), - #[error(transparent)] - ParseIntError(#[from] ParseIntError), - #[error(transparent)] - CalDateTimeError(#[from] CalDateTimeError), -} - -#[derive(Debug, Clone, EnumString, Default, PartialEq)] -#[strum(serialize_all = "UPPERCASE")] -pub enum RecurrenceFrequency { - Secondly, - Minutely, - Hourly, - #[default] - Daily, - Weekly, - Monthly, - Yearly, -} - -#[derive(Debug, Clone, EnumString, PartialEq)] -#[strum(serialize_all = "UPPERCASE")] -pub enum IcalWeekday { - Mo, - Tu, - We, - Th, - Fr, - Sa, - Su, -} - -impl From for Weekday { - fn from(value: IcalWeekday) -> Self { - match value { - IcalWeekday::Mo => Self::Mon, - IcalWeekday::Tu => Self::Tue, - IcalWeekday::We => Self::Wed, - IcalWeekday::Th => Self::Thu, - IcalWeekday::Fr => Self::Fri, - IcalWeekday::Sa => Self::Sat, - IcalWeekday::Su => Self::Sun, - } - } -} - -#[derive(Debug, Clone, PartialEq)] -pub enum RecurrenceLimit { - Count(usize), - Until(CalDateTime), -} - -#[derive(Debug, Clone, Default, PartialEq)] -pub struct RecurrenceRule { - // Self-explanatory - pub frequency: RecurrenceFrequency, - pub limit: Option, - // Repeat every n-th time - pub interval: u32, - - pub bysecond: Option>, - pub byminute: Option>, - pub byhour: Option>, - pub byday: Option, Weekday)>>, - pub bymonthday: Option>, - pub byyearday: Option>, - pub byweekno: Option>, - pub bymonth0: Option>, - pub week_start: Option, - // Selects the n-th occurence within an a recurrence rule - pub bysetpos: Option>, -} - -impl RecurrenceRule { - pub fn parse(rule: &str) -> Result { - let mut frequency = None; - let mut limit = None; - let mut interval = 1; - let mut bysecond = None; - let mut byminute = None; - let mut byhour = None; - let mut byday = None; - let mut bymonthday = None; - let mut byyearday = None; - let mut byweekno = None; - let mut bymonth0 = None; - let mut week_start = None; - let mut bysetpos = None; - - for part in rule.split(';') { - match part - .split_once('=') - .ok_or(ParserError::InvalidPart(part.to_owned()))? - { - ("FREQ", val) => { - frequency = Some(RecurrenceFrequency::from_str(val)?); - } - ("COUNT", val) => limit = Some(RecurrenceLimit::Count(val.parse()?)), - ("UNTIL", val) => { - limit = Some(RecurrenceLimit::Until(CalDateTime::parse(val, None)?)) - } - ("INTERVAL", val) => interval = val.parse()?, - ("BYSECOND", val) => { - bysecond = Some( - val.split(',') - .map(|val| val.parse()) - .collect::, _>>()?, - ); - } - ("BYMINUTE", val) => { - byminute = Some( - val.split(',') - .map(|val| val.parse()) - .collect::, _>>()?, - ); - } - ("BYHOUR", val) => { - byhour = Some( - val.split(',') - .map(|val| val.parse()) - .collect::, _>>()?, - ); - } - ("BYDAY", val) => { - byday = Some( - val.split(',') - .map(|val| { - assert!(val.len() >= 2); - let weekday = - IcalWeekday::from_str(val.get((val.len() - 2)..).unwrap())? - .into(); - let prefix = if val.len() > 2 { - Some(val.get(..(val.len() - 2)).unwrap().parse()?) - } else { - None - }; - Ok((prefix, weekday)) - }) - .collect::, ParserError>>()?, - ); - } - ("BYMONTHDAY", val) => { - bymonthday = Some( - val.split(',') - .map(|val| val.parse()) - .collect::, _>>()?, - ); - } - ("BYYEARDAY", val) => { - byyearday = Some( - val.split(',') - .map(|val| val.parse()) - .collect::, _>>()?, - ); - } - ("BYWEEKNO", val) => { - byweekno = Some( - val.split(',') - .map(|val| val.parse()) - .collect::, _>>()?, - ); - } - ("BYMONTH", val) => { - bymonth0 = Some( - val.split(',') - .map(|val| val.parse()) - .collect::, _>>()? - .into_iter() - .map(|month| month - 1) - .collect(), - ); - } - ("WKST", val) => week_start = Some(IcalWeekday::from_str(val)?.into()), - ("BYSETPOS", val) => { - bysetpos = Some( - val.split(',') - .map(|val| val.parse()) - .collect::, _>>()?, - ); - } - (name, val) => panic!("Cannot handle {name}={val}"), - } - } - Ok(Self { - frequency: frequency.ok_or(ParserError::MissingFrequency)?, - limit, - interval, - bysecond, - byminute, - byhour, - byday, - bymonthday, - byyearday, - byweekno, - bymonth0, - week_start, - bysetpos, - }) - } -} - -impl RecurrenceRule { - fn offset_weekdays(&self) -> Option> { - if let Some(byday) = self.byday.as_ref() { - Some( - byday - .iter() - .filter_map(|(offset, day)| offset.map(|offset| (offset, day.clone()))) - .collect(), - ) - } else { - None - } - } - - fn absolute_weekdays(&self) -> Option { - if let Some(byday) = self.byday.as_ref() { - Some(WeekdaySet::from_iter(byday.iter().filter_map( - |(offset, day)| { - if &None == offset { - Some(day.clone()) - } else { - None - } - }, - ))) - } else { - None - } - } -} - -#[cfg(test)] -mod tests { - use super::{ParserError, RecurrenceRule}; - use crate::{ - CalDateTime, - rrule::{RecurrenceFrequency, RecurrenceLimit}, - }; - use chrono::Weekday; - - #[test] - fn parse_recurrence_rule() -> Result<(), ParserError> { - assert_eq!( - RecurrenceRule::parse("FREQ=DAILY;UNTIL=20250516T133000Z;INTERVAL=3")?, - RecurrenceRule { - frequency: RecurrenceFrequency::Daily, - limit: Some(RecurrenceLimit::Until( - CalDateTime::parse("20250516T133000Z", None).unwrap() - )), - interval: 3, - ..Default::default() - } - ); - assert_eq!( - RecurrenceRule::parse("FREQ=WEEKLY;COUNT=4;INTERVAL=2;BYDAY=TU,TH,SU")?, - RecurrenceRule { - frequency: RecurrenceFrequency::Weekly, - limit: Some(RecurrenceLimit::Count(4)), - interval: 2, - byday: Some(vec![ - (None, Weekday::Tue), - (None, Weekday::Thu), - (None, Weekday::Sun), - ]), - ..Default::default() - } - ); - // Example: Last workday of the month - assert_eq!( - RecurrenceRule::parse("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1")?, - RecurrenceRule { - frequency: RecurrenceFrequency::Monthly, - byday: Some(vec![ - (None, Weekday::Mon), - (None, Weekday::Tue), - (None, Weekday::Wed), - (None, Weekday::Thu), - (None, Weekday::Fri), - ]), - bysetpos: Some(vec![-1]), - ..Default::default() - } - ); - - // Every last Sunday of March - assert_eq!( - RecurrenceRule::parse("FREQ=YEARLY;UNTIL=20370329T010000Z;BYDAY=-1SU;BYMONTH=3")?, - RecurrenceRule { - frequency: RecurrenceFrequency::Yearly, - limit: Some(RecurrenceLimit::Until( - CalDateTime::parse("20370329T010000Z", None).unwrap() - )), - byday: Some(vec![(Some(-1), Weekday::Sun)]), - bymonth0: Some(vec![3]), - ..Default::default() - } - ); - - Ok(()) - } -} diff --git a/crates/ical/src/timestamp.rs b/crates/ical/src/timestamp.rs index 883a3a9..78896a1 100644 --- a/crates/ical/src/timestamp.rs +++ b/crates/ical/src/timestamp.rs @@ -9,7 +9,7 @@ use ical::{ }; use lazy_static::lazy_static; use rustical_xml::{ValueDeserialize, ValueSerialize}; -use std::{collections::HashMap, ops::Add}; +use std::{borrow::Cow, collections::HashMap, ops::Add}; lazy_static! { static ref RE_VCARD_DATE_MM_DD: regex::Regex = @@ -62,14 +62,46 @@ impl ValueSerialize for UtcDateTime { } } -#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum CalDateTime { // Form 1, example: 19980118T230000 -> Local // Form 2, example: 19980119T070000Z -> UTC // Form 3, example: TZID=America/New_York:19980119T020000 -> Olson // https://en.wikipedia.org/wiki/Tz_database DateTime(DateTime), - Date(NaiveDate), + Date(NaiveDate, CalTimezone), +} + +impl From for DateTime { + fn from(value: CalDateTime) -> Self { + value + .as_datetime() + .into_owned() + .with_timezone(&value.timezone().to_owned().into()) + } +} + +impl From> for CalDateTime { + fn from(value: DateTime) -> Self { + Self::DateTime(value.with_timezone(&value.timezone().into())) + } +} + +impl PartialOrd for CalDateTime { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for CalDateTime { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match (&self, &other) { + (Self::DateTime(a), Self::DateTime(b)) => a.cmp(b), + (Self::DateTime(a), Self::Date(..)) => a.cmp(&other.as_datetime()), + (Self::Date(..), Self::DateTime(b)) => self.as_datetime().as_ref().cmp(b), + (Self::Date(..), Self::Date(..)) => self.as_datetime().cmp(&other.as_datetime()), + } + } } impl From> for CalDateTime { @@ -90,9 +122,9 @@ impl Add for CalDateTime { fn add(self, duration: Duration) -> Self::Output { match self { Self::DateTime(datetime) => Self::DateTime(datetime + duration), - Self::Date(date) => Self::DateTime( + Self::Date(date, tz) => Self::DateTime( date.and_time(NaiveTime::default()) - .and_local_timezone(CalTimezone::Local) + .and_local_timezone(tz) .earliest() .expect("Local timezone has constant offset") + duration, @@ -105,12 +137,13 @@ impl CalDateTime { pub fn parse_prop( prop: &Property, timezones: &HashMap, - ) -> Result, CalDateTimeError> { - let prop_value = if let Some(value) = prop.value.as_ref() { - value - } else { - return Ok(None); - }; + ) -> Result { + let prop_value = prop + .value + .as_ref() + .ok_or(CalDateTimeError::InvalidDatetimeFormat( + "empty property".to_owned(), + ))?; // Use the TZID parameter from the property let timezone = if let Some(tzid) = prop.get_tzid() { @@ -144,7 +177,7 @@ impl CalDateTime { None }; - Self::parse(prop_value, timezone).map(Some) + Self::parse(prop_value, timezone) } pub fn format(&self) -> String { @@ -153,25 +186,26 @@ impl CalDateTime { CalTimezone::Utc => datetime.format(UTC_DATE_TIME).to_string(), _ => datetime.format(LOCAL_DATE_TIME).to_string(), }, - Self::Date(date) => date.format(LOCAL_DATE).to_string(), + Self::Date(date, _) => date.format(LOCAL_DATE).to_string(), } } pub fn date(&self) -> NaiveDate { match self { Self::DateTime(datetime) => datetime.date_naive(), - Self::Date(date) => date.to_owned(), + Self::Date(date, _) => date.to_owned(), } } - pub fn into_datetime(self) -> DateTime { + pub fn as_datetime(&self) -> Cow> { match self { - Self::DateTime(datetime) => datetime, - Self::Date(date) => date - .and_time(NaiveTime::default()) - .and_local_timezone(CalTimezone::Local) - .earliest() - .expect("Midnight always exists"), + Self::DateTime(datetime) => Cow::Borrowed(datetime), + Self::Date(date, tz) => Cow::Owned( + date.and_time(NaiveTime::default()) + .and_local_timezone(tz.to_owned()) + .earliest() + .expect("Midnight always exists"), + ), } } @@ -196,15 +230,18 @@ impl CalDateTime { if let Ok(datetime) = NaiveDateTime::parse_from_str(value, UTC_DATE_TIME) { return Ok(datetime.and_utc().into()); } + let timezone = timezone + .map(CalTimezone::Olson) + .unwrap_or(CalTimezone::Local); if let Ok(date) = NaiveDate::parse_from_str(value, LOCAL_DATE) { - return Ok(CalDateTime::Date(date)); + return Ok(CalDateTime::Date(date, timezone)); } if let Ok(date) = NaiveDate::parse_from_str(value, "%Y-%m-%d") { - return Ok(CalDateTime::Date(date)); + return Ok(CalDateTime::Date(date, timezone)); } if let Ok(date) = NaiveDate::parse_from_str(value, "%Y%m%d") { - return Ok(CalDateTime::Date(date)); + return Ok(CalDateTime::Date(date, timezone)); } if let Some(captures) = RE_VCARD_DATE_MM_DD.captures(value) { // Because 1972 is a leap year @@ -216,6 +253,7 @@ impl CalDateTime { return Ok(CalDateTime::Date( NaiveDate::from_ymd_opt(year, month, day) .ok_or(CalDateTimeError::ParseError(value.to_string()))?, + timezone, )); } @@ -223,16 +261,13 @@ impl CalDateTime { } pub fn utc(&self) -> DateTime { - match &self { - CalDateTime::DateTime(datetime) => datetime.to_utc(), - CalDateTime::Date(date) => date.and_time(NaiveTime::default()).and_utc(), - } + self.as_datetime().to_utc() } - pub fn timezone(&self) -> Option { + pub fn timezone(&self) -> CalTimezone { match &self { - CalDateTime::DateTime(datetime) => Some(datetime.timezone()), - CalDateTime::Date(_) => None, + CalDateTime::DateTime(datetime) => datetime.timezone(), + CalDateTime::Date(_, tz) => tz.to_owned(), } } } @@ -247,86 +282,88 @@ impl Datelike for CalDateTime { fn year(&self) -> i32 { match &self { CalDateTime::DateTime(datetime) => datetime.year(), - CalDateTime::Date(date) => date.year(), + CalDateTime::Date(date, _) => date.year(), } } fn month(&self) -> u32 { match &self { CalDateTime::DateTime(datetime) => datetime.month(), - CalDateTime::Date(date) => date.month(), + CalDateTime::Date(date, _) => date.month(), } } fn month0(&self) -> u32 { match &self { CalDateTime::DateTime(datetime) => datetime.month0(), - CalDateTime::Date(date) => date.month0(), + CalDateTime::Date(date, _) => date.month0(), } } fn day(&self) -> u32 { match &self { CalDateTime::DateTime(datetime) => datetime.day(), - CalDateTime::Date(date) => date.day(), + CalDateTime::Date(date, _) => date.day(), } } fn day0(&self) -> u32 { match &self { CalDateTime::DateTime(datetime) => datetime.day0(), - CalDateTime::Date(date) => date.day0(), + CalDateTime::Date(date, _) => date.day0(), } } fn ordinal(&self) -> u32 { match &self { CalDateTime::DateTime(datetime) => datetime.ordinal(), - CalDateTime::Date(date) => date.ordinal(), + CalDateTime::Date(date, _) => date.ordinal(), } } fn ordinal0(&self) -> u32 { match &self { CalDateTime::DateTime(datetime) => datetime.ordinal0(), - CalDateTime::Date(date) => date.ordinal0(), + CalDateTime::Date(date, _) => date.ordinal0(), } } fn weekday(&self) -> chrono::Weekday { match &self { CalDateTime::DateTime(datetime) => datetime.weekday(), - CalDateTime::Date(date) => date.weekday(), + CalDateTime::Date(date, _) => date.weekday(), } } fn iso_week(&self) -> chrono::IsoWeek { match &self { CalDateTime::DateTime(datetime) => datetime.iso_week(), - CalDateTime::Date(date) => date.iso_week(), + CalDateTime::Date(date, _) => date.iso_week(), } } fn with_year(&self, year: i32) -> Option { match &self { CalDateTime::DateTime(datetime) => Some(Self::DateTime(datetime.with_year(year)?)), - CalDateTime::Date(date) => Some(Self::Date(date.with_year(year)?)), + CalDateTime::Date(date, tz) => Some(Self::Date(date.with_year(year)?, tz.to_owned())), } } fn with_month(&self, month: u32) -> Option { match &self { CalDateTime::DateTime(datetime) => Some(Self::DateTime(datetime.with_month(month)?)), - CalDateTime::Date(date) => Some(Self::Date(date.with_month(month)?)), + CalDateTime::Date(date, tz) => Some(Self::Date(date.with_month(month)?, tz.to_owned())), } } fn with_month0(&self, month0: u32) -> Option { match &self { CalDateTime::DateTime(datetime) => Some(Self::DateTime(datetime.with_month0(month0)?)), - CalDateTime::Date(date) => Some(Self::Date(date.with_month0(month0)?)), + CalDateTime::Date(date, tz) => { + Some(Self::Date(date.with_month0(month0)?, tz.to_owned())) + } } } fn with_day(&self, day: u32) -> Option { match &self { CalDateTime::DateTime(datetime) => Some(Self::DateTime(datetime.with_day(day)?)), - CalDateTime::Date(date) => Some(Self::Date(date.with_day(day)?)), + CalDateTime::Date(date, tz) => Some(Self::Date(date.with_day(day)?, tz.to_owned())), } } fn with_day0(&self, day0: u32) -> Option { match &self { CalDateTime::DateTime(datetime) => Some(Self::DateTime(datetime.with_day0(day0)?)), - CalDateTime::Date(date) => Some(Self::Date(date.with_day0(day0)?)), + CalDateTime::Date(date, tz) => Some(Self::Date(date.with_day0(day0)?, tz.to_owned())), } } fn with_ordinal(&self, ordinal: u32) -> Option { @@ -334,7 +371,9 @@ impl Datelike for CalDateTime { CalDateTime::DateTime(datetime) => { Some(Self::DateTime(datetime.with_ordinal(ordinal)?)) } - CalDateTime::Date(date) => Some(Self::Date(date.with_ordinal(ordinal)?)), + CalDateTime::Date(date, tz) => { + Some(Self::Date(date.with_ordinal(ordinal)?, tz.to_owned())) + } } } fn with_ordinal0(&self, ordinal0: u32) -> Option { @@ -342,7 +381,9 @@ impl Datelike for CalDateTime { CalDateTime::DateTime(datetime) => { Some(Self::DateTime(datetime.with_ordinal0(ordinal0)?)) } - CalDateTime::Date(date) => Some(Self::Date(date.with_ordinal0(ordinal0)?)), + CalDateTime::Date(date, tz) => { + Some(Self::Date(date.with_ordinal0(ordinal0)?, tz.to_owned())) + } } } } @@ -384,15 +425,24 @@ mod tests { fn test_vcard_date() { assert_eq!( CalDateTime::parse("19850412", None).unwrap(), - CalDateTime::Date(NaiveDate::from_ymd_opt(1985, 4, 12).unwrap()) + CalDateTime::Date( + NaiveDate::from_ymd_opt(1985, 4, 12).unwrap(), + crate::CalTimezone::Local + ) ); assert_eq!( CalDateTime::parse("1985-04-12", None).unwrap(), - CalDateTime::Date(NaiveDate::from_ymd_opt(1985, 4, 12).unwrap()) + CalDateTime::Date( + NaiveDate::from_ymd_opt(1985, 4, 12).unwrap(), + crate::CalTimezone::Local + ) ); assert_eq!( CalDateTime::parse("--0412", None).unwrap(), - CalDateTime::Date(NaiveDate::from_ymd_opt(1972, 4, 12).unwrap()) + CalDateTime::Date( + NaiveDate::from_ymd_opt(1972, 4, 12).unwrap(), + crate::CalTimezone::Local + ) ); } } diff --git a/crates/ical/src/timezone.rs b/crates/ical/src/timezone.rs index ca9cec2..0658b28 100644 --- a/crates/ical/src/timezone.rs +++ b/crates/ical/src/timezone.rs @@ -2,13 +2,33 @@ use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone, Utc}; use chrono_tz::Tz; use derive_more::{Display, From}; -#[derive(Debug, Clone, From)] +#[derive(Debug, Clone, From, PartialEq, Eq)] pub enum CalTimezone { Local, Utc, Olson(Tz), } +impl From for rrule::Tz { + fn from(value: CalTimezone) -> Self { + match value { + CalTimezone::Local => Self::LOCAL, + CalTimezone::Utc => Self::UTC, + CalTimezone::Olson(tz) => Self::Tz(tz), + } + } +} + +impl From for CalTimezone { + fn from(value: rrule::Tz) -> Self { + match value { + rrule::Tz::Local(_) => Self::Local, + rrule::Tz::Tz(chrono_tz::UTC) => Self::Utc, + rrule::Tz::Tz(tz) => Self::Olson(tz), + } + } +} + #[derive(Debug, Clone, PartialEq, Display)] pub enum CalTimezoneOffset { Local(chrono::FixedOffset), diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index cb26605..7638d1e 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -31,6 +31,7 @@ rustical_dav.workspace = true strum.workspace = true strum_macros.workspace = true rustical_ical.workspace = true +rrule = "0.14" [dev-dependencies] rstest = { workspace = true } diff --git a/crates/store/src/addressbook/address_object.rs b/crates/store/src/addressbook/address_object.rs index 84881f0..851593c 100644 --- a/crates/store/src/addressbook/address_object.rs +++ b/crates/store/src/addressbook/address_object.rs @@ -48,12 +48,12 @@ impl AddressObject { pub fn get_anniversary(&self) -> Option { let prop = self.vcard.get_property("ANNIVERSARY")?; - CalDateTime::parse_prop(prop, &HashMap::default()).unwrap_or(None) + CalDateTime::parse_prop(prop, &HashMap::default()).ok() } pub fn get_birthday(&self) -> Option { let prop = self.vcard.get_property("BDAY")?; - CalDateTime::parse_prop(prop, &HashMap::default()).unwrap_or(None) + CalDateTime::parse_prop(prop, &HashMap::default()).ok() } pub fn get_full_name(&self) -> Option<&String> { diff --git a/crates/store/src/calendar/event.rs b/crates/store/src/calendar/event.rs index 89ea14a..a237ae8 100644 --- a/crates/store/src/calendar/event.rs +++ b/crates/store/src/calendar/event.rs @@ -1,15 +1,13 @@ use crate::Error; -use chrono::Duration; +use chrono::{DateTime, Duration}; use ical::{ generator::IcalEvent, parser::{Component, ical::component::IcalTimeZone}, property::Property, }; -use rustical_ical::{ - CalDateTime, ComponentMut, parse_duration, - rrule::{ParserError, RecurrenceRule}, -}; -use std::collections::HashMap; +use rrule::{RRule, RRuleSet}; +use rustical_ical::{CalDateTime, ComponentMut, parse_duration}; +use std::{collections::HashMap, str::FromStr}; #[derive(Debug, Clone)] pub struct EventObject { @@ -21,7 +19,7 @@ pub struct EventObject { impl EventObject { pub fn get_first_occurence(&self) -> Result, Error> { if let Some(dtstart) = self.event.get_property("DTSTART") { - Ok(CalDateTime::parse_prop(dtstart, &self.timezones)?) + Ok(Some(CalDateTime::parse_prop(dtstart, &self.timezones)?)) } else { Ok(None) } @@ -34,7 +32,7 @@ impl EventObject { } if let Some(dtend) = self.event.get_property("DTEND") { - return Ok(CalDateTime::parse_prop(dtend, &self.timezones)?); + return Ok(Some(CalDateTime::parse_prop(dtend, &self.timezones)?)); }; let duration = self.get_duration()?.unwrap_or(Duration::days(1)); @@ -55,57 +53,81 @@ impl EventObject { } } - pub fn recurrence_rule(&self) -> Result, ParserError> { - let rrule = if let Some(&Property { - value: Some(rrule), .. - }) = self.event.get_property("RRULE").as_ref() - { - rrule + pub fn recurrence_ruleset(&self) -> Result, Error> { + let dtstart: DateTime = if let Some(dtstart) = self.get_first_occurence()? { + dtstart + .as_datetime() + .with_timezone(&dtstart.timezone().into()) } else { return Ok(None); }; - RecurrenceRule::parse(rrule).map(Some) + + 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, 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, None); - // - // for date in dates { - // let dtstart_utc = date; - // 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()]) - // } - // } + pub fn expand_recurrence(&self) -> Result, 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 @@ -127,6 +149,49 @@ 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( @@ -134,6 +199,14 @@ END:VCALENDAR"#; ICS.to_string(), ) .unwrap(); - assert_eq!(event.expand_recurrence().unwrap(), "asd".to_string()); + let event = event.event().unwrap(); + + let events: Vec = event + .expand_recurrence() + .unwrap() + .into_iter() + .map(|event| Emitter::generate(&event)) + .collect(); + assert_eq!(events.as_slice(), &EXPANDED); } } diff --git a/crates/store/src/calendar/object.rs b/crates/store/src/calendar/object.rs index 18094b1..78f5dfd 100644 --- a/crates/store/src/calendar/object.rs +++ b/crates/store/src/calendar/object.rs @@ -181,14 +181,21 @@ impl CalendarObject { } } + pub fn event(&self) -> Option<&EventObject> { + match &self.data { + CalendarObjectComponent::Event(event) => Some(event), + _ => 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()) - // } + 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/error.rs b/crates/store/src/error.rs index 947974b..0721f5f 100644 --- a/crates/store/src/error.rs +++ b/crates/store/src/error.rs @@ -12,9 +12,6 @@ pub enum Error { #[error("Invalid ics/vcf input: {0}")] InvalidData(String), - #[error(transparent)] - RRuleParserError(#[from] rustical_ical::rrule::ParserError), - #[error("Read-only")] ReadOnly, @@ -32,6 +29,9 @@ pub enum Error { #[error(transparent)] CalDateTimeError(#[from] CalDateTimeError), + + #[error(transparent)] + RRuleError(#[from] rrule::RRuleError), } impl ResponseError for Error {