mirror of
https://github.com/lennart-k/rustical.git
synced 2025-12-13 22:52:22 +00:00
WIP: Janky recurrence rule evaluation
This commit is contained in:
@@ -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<CalDateTime>,
|
||||
limit: Option<usize>,
|
||||
) -> Vec<CalDateTime> {
|
||||
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![]);
|
||||
|
||||
0
crates/ical/src/rrule/iter_monthly.rs
Normal file
0
crates/ical/src/rrule/iter_monthly.rs
Normal file
175
crates/ical/src/rrule/iter_yearly.rs
Normal file
175
crates/ical/src/rrule/iter_yearly.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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<RecurrenceLimit>,
|
||||
// Repeat every n-th time
|
||||
pub interval: usize,
|
||||
pub interval: u32,
|
||||
|
||||
pub bysecond: Option<Vec<usize>>,
|
||||
pub byminute: Option<Vec<usize>>,
|
||||
@@ -79,7 +81,7 @@ pub struct RecurrenceRule {
|
||||
pub bymonthday: Option<Vec<i8>>,
|
||||
pub byyearday: Option<Vec<i64>>,
|
||||
pub byweekno: Option<Vec<i8>>,
|
||||
pub bymonth: Option<Vec<i8>>,
|
||||
pub bymonth0: Option<Vec<u8>>,
|
||||
pub week_start: Option<Weekday>,
|
||||
// Selects the n-th occurence within an a recurrence rule
|
||||
pub bysetpos: Option<Vec<i64>>,
|
||||
@@ -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::<Result<Vec<_>, _>>()?,
|
||||
.collect::<Result<Vec<u8>, _>>()?
|
||||
.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<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)]
|
||||
mod tests {
|
||||
use super::{ParserError, RecurrenceRule};
|
||||
|
||||
@@ -228,6 +228,13 @@ impl CalDateTime {
|
||||
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> {
|
||||
@@ -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)]
|
||||
mod tests {
|
||||
use crate::CalDateTime;
|
||||
|
||||
Reference in New Issue
Block a user