WIP: Janky recurrence rule evaluation

This commit is contained in:
Lennart
2025-06-02 20:19:55 +02:00
parent 3c9c1c7abf
commit 088b920b68
5 changed files with 423 additions and 22 deletions

View File

@@ -1,6 +1,108 @@
use super::{RecurrenceFrequency, RecurrenceLimit, RecurrenceRule};
use crate::CalDateTime; 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 { impl RecurrenceRule {
pub fn between( pub fn between(
@@ -9,15 +111,9 @@ impl RecurrenceRule {
end: Option<CalDateTime>, end: Option<CalDateTime>,
limit: Option<usize>, limit: Option<usize>,
) -> Vec<CalDateTime> { ) -> Vec<CalDateTime> {
let start = start; let mut end = end.as_ref();
// Terrible code, should clean this up later.
let mut end = end;
if let Some(RecurrenceLimit::Until(until)) = &self.limit { if let Some(RecurrenceLimit::Until(until)) = &self.limit {
let mut _end = end.unwrap_or(until.clone()); end = Some(end.unwrap_or(until).min(until));
if until.utc() < _end.utc() {
_end = until.clone();
}
end = Some(_end);
} }
let mut count = if let Some(RecurrenceLimit::Count(count)) = &self.limit { let mut count = if let Some(RecurrenceLimit::Count(count)) = &self.limit {
*count *count
@@ -28,15 +124,73 @@ impl RecurrenceRule {
count = count.min(limit) count = count.min(limit)
} }
let mut datetimes = vec![start.clone()]; let datetimes = vec![];
let mut datetime_utc = start.utc();
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 { while datetimes.len() < count {
if let Some(end) = &end { let mut result_dates = vec![start.date()];
if datetime_utc > end.utc() { // Iterate over frequency*interval
break; 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 datetimes
@@ -49,6 +203,7 @@ mod tests {
#[test] #[test]
fn test_between() { 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 rrule = RecurrenceRule::parse("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1").unwrap();
let start = CalDateTime::parse("20250516T133000Z", None).unwrap(); let start = CalDateTime::parse("20250516T133000Z", None).unwrap();
assert_eq!(rrule.between(start, None, Some(4)), vec![]); assert_eq!(rrule.between(start, None, Some(4)), vec![]);

View File

View File

@@ -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<HashSet<NaiveDate>> {
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<u8> = 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<HashSet<NaiveDate>> {
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<NaiveDate>,
limit: usize,
) -> Vec<NaiveDate> {
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::<Vec<_>>();
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
}
}

View File

@@ -1,9 +1,11 @@
use super::{CalDateTime, CalDateTimeError}; use super::{CalDateTime, CalDateTimeError};
use chrono::Weekday; use chrono::{Weekday, WeekdaySet};
use std::{num::ParseIntError, str::FromStr}; use std::{num::ParseIntError, str::FromStr};
use strum_macros::EnumString; use strum_macros::EnumString;
mod iter; mod iter;
mod iter_monthly;
mod iter_yearly;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ParserError { pub enum ParserError {
@@ -70,7 +72,7 @@ pub struct RecurrenceRule {
pub frequency: RecurrenceFrequency, pub frequency: RecurrenceFrequency,
pub limit: Option<RecurrenceLimit>, pub limit: Option<RecurrenceLimit>,
// Repeat every n-th time // Repeat every n-th time
pub interval: usize, pub interval: u32,
pub bysecond: Option<Vec<usize>>, pub bysecond: Option<Vec<usize>>,
pub byminute: Option<Vec<usize>>, pub byminute: Option<Vec<usize>>,
@@ -79,7 +81,7 @@ pub struct RecurrenceRule {
pub bymonthday: Option<Vec<i8>>, pub bymonthday: Option<Vec<i8>>,
pub byyearday: Option<Vec<i64>>, pub byyearday: Option<Vec<i64>>,
pub byweekno: Option<Vec<i8>>, pub byweekno: Option<Vec<i8>>,
pub bymonth: Option<Vec<i8>>, pub bymonth0: Option<Vec<u8>>,
pub week_start: Option<Weekday>, pub week_start: Option<Weekday>,
// Selects the n-th occurence within an a recurrence rule // Selects the n-th occurence within an a recurrence rule
pub bysetpos: Option<Vec<i64>>, pub bysetpos: Option<Vec<i64>>,
@@ -97,7 +99,7 @@ impl RecurrenceRule {
let mut bymonthday = None; let mut bymonthday = None;
let mut byyearday = None; let mut byyearday = None;
let mut byweekno = None; let mut byweekno = None;
let mut bymonth = None; let mut bymonth0 = None;
let mut week_start = None; let mut week_start = None;
let mut bysetpos = None; let mut bysetpos = None;
@@ -175,10 +177,13 @@ impl RecurrenceRule {
); );
} }
("BYMONTH", val) => { ("BYMONTH", val) => {
bymonth = Some( bymonth0 = Some(
val.split(',') val.split(',')
.map(|val| val.parse()) .map(|val| val.parse())
.collect::<Result<Vec<_>, _>>()?, .collect::<Result<Vec<u8>, _>>()?
.into_iter()
.map(|month| month - 1)
.collect(),
); );
} }
("WKST", val) => week_start = Some(IcalWeekday::from_str(val)?.into()), ("WKST", val) => week_start = Some(IcalWeekday::from_str(val)?.into()),
@@ -203,13 +208,44 @@ impl RecurrenceRule {
bymonthday, bymonthday,
byyearday, byyearday,
byweekno, byweekno,
bymonth, bymonth0,
week_start, week_start,
bysetpos, bysetpos,
}) })
} }
} }
impl RecurrenceRule {
fn offset_weekdays(&self) -> Option<Vec<(i64, Weekday)>> {
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<WeekdaySet> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::{ParserError, RecurrenceRule}; use super::{ParserError, RecurrenceRule};

View File

@@ -228,6 +228,13 @@ impl CalDateTime {
CalDateTime::Date(date) => date.and_time(NaiveTime::default()).and_utc(), CalDateTime::Date(date) => date.and_time(NaiveTime::default()).and_utc(),
} }
} }
pub fn timezone(&self) -> Option<CalTimezone> {
match &self {
CalDateTime::DateTime(datetime) => Some(datetime.timezone()),
CalDateTime::Date(_) => None,
}
}
} }
impl From<CalDateTime> for DateTime<Utc> { impl From<CalDateTime> for DateTime<Utc> {
@@ -340,6 +347,34 @@ impl Datelike for CalDateTime {
} }
} }
impl CalDateTime {
pub fn inc_year(&self, interval: u32) -> Option<Self> {
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)] #[cfg(test)]
mod tests { mod tests {
use crate::CalDateTime; use crate::CalDateTime;