diff --git a/crates/ical/src/rrule/iter.rs b/crates/ical/src/rrule/iter.rs index af6cc39..85b1746 100644 --- a/crates/ical/src/rrule/iter.rs +++ b/crates/ical/src/rrule/iter.rs @@ -1,6 +1,108 @@ +use super::{RecurrenceFrequency, RecurrenceLimit, RecurrenceRule}; use crate::CalDateTime; +use chrono::{Datelike, Duration, IsoWeek, NaiveDate, Weekday, WeekdaySet}; +use std::collections::HashSet; -use super::{RecurrenceLimit, RecurrenceRule}; +/* +* 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( @@ -9,15 +111,9 @@ impl RecurrenceRule { end: Option, limit: Option, ) -> Vec { - let start = start; - // Terrible code, should clean this up later. - let mut end = end; + let mut end = end.as_ref(); if let Some(RecurrenceLimit::Until(until)) = &self.limit { - let mut _end = end.unwrap_or(until.clone()); - if until.utc() < _end.utc() { - _end = until.clone(); - } - end = Some(_end); + end = Some(end.unwrap_or(until).min(until)); } let mut count = if let Some(RecurrenceLimit::Count(count)) = &self.limit { *count @@ -28,15 +124,73 @@ impl RecurrenceRule { count = count.min(limit) } - let mut datetimes = vec![start.clone()]; - let mut datetime_utc = start.utc(); + 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 { - if let Some(end) = &end { - if datetime_utc > end.utc() { - break; + 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; } + _ => {} } - datetimes.push(datetime_utc.into()); + + #[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 @@ -49,6 +203,7 @@ mod tests { #[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 new file mode 100644 index 0000000..e69de29 diff --git a/crates/ical/src/rrule/iter_yearly.rs b/crates/ical/src/rrule/iter_yearly.rs new file mode 100644 index 0000000..cd06ab0 --- /dev/null +++ b/crates/ical/src/rrule/iter_yearly.rs @@ -0,0 +1,175 @@ +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 index a6520cb..95ef19b 100644 --- a/crates/ical/src/rrule/mod.rs +++ b/crates/ical/src/rrule/mod.rs @@ -1,9 +1,11 @@ use super::{CalDateTime, CalDateTimeError}; -use chrono::Weekday; +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 { @@ -70,7 +72,7 @@ pub struct RecurrenceRule { pub frequency: RecurrenceFrequency, pub limit: Option, // Repeat every n-th time - pub interval: usize, + pub interval: u32, pub bysecond: Option>, pub byminute: Option>, @@ -79,7 +81,7 @@ pub struct RecurrenceRule { pub bymonthday: Option>, pub byyearday: Option>, pub byweekno: Option>, - pub bymonth: Option>, + pub bymonth0: Option>, pub week_start: Option, // Selects the n-th occurence within an a recurrence rule pub bysetpos: Option>, @@ -97,7 +99,7 @@ impl RecurrenceRule { let mut bymonthday = None; let mut byyearday = None; let mut byweekno = None; - let mut bymonth = None; + let mut bymonth0 = None; let mut week_start = None; let mut bysetpos = None; @@ -175,10 +177,13 @@ impl RecurrenceRule { ); } ("BYMONTH", val) => { - bymonth = Some( + bymonth0 = Some( val.split(',') .map(|val| val.parse()) - .collect::, _>>()?, + .collect::, _>>()? + .into_iter() + .map(|month| month - 1) + .collect(), ); } ("WKST", val) => week_start = Some(IcalWeekday::from_str(val)?.into()), @@ -203,13 +208,44 @@ impl RecurrenceRule { bymonthday, byyearday, byweekno, - bymonth, + 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}; diff --git a/crates/ical/src/timestamp.rs b/crates/ical/src/timestamp.rs index 9b9674c..883a3a9 100644 --- a/crates/ical/src/timestamp.rs +++ b/crates/ical/src/timestamp.rs @@ -228,6 +228,13 @@ impl CalDateTime { CalDateTime::Date(date) => date.and_time(NaiveTime::default()).and_utc(), } } + + pub fn timezone(&self) -> Option { + match &self { + CalDateTime::DateTime(datetime) => Some(datetime.timezone()), + CalDateTime::Date(_) => None, + } + } } impl From for DateTime { @@ -340,6 +347,34 @@ impl Datelike for CalDateTime { } } +impl CalDateTime { + pub fn inc_year(&self, interval: u32) -> Option { + self.with_year(self.year() + interval as i32) + } + + // Increments the year until a valid date is found + pub fn inc_find_year(&self, interval: u32) -> Self { + let mut year = self.year(); + loop { + year += interval as i32; + if let Some(date) = self.with_year(year) { + return date; + } + } + } + + pub fn inc_month(&self, interval: u32) -> Self { + let mut month0 = self.month0(); + loop { + month0 += interval; + if month0 >= 12 {} + if let Some(date) = self.with_month0(month0) { + return date; + } + } + } +} + #[cfg(test)] mod tests { use crate::CalDateTime;