feat(calendar): expand recurring PST events into per-occurrence rows
RRULE was previously ignored entirely, so a recurring meeting only ever
stored its first occurrence — invisible to any later date filter/lookup.
Adds RRULE parsing + occurrence expansion (DAILY/WEEKLY/MONTHLY/YEARLY,
INTERVAL/COUNT/UNTIL/BYDAY/BYMONTHDAY/BYMONTH) covering every pattern
found in a real 7.2GB mailbox (ADR-0008), each occurrence stored as its
own row keyed by "{uid}@{ymd}" so re-import dedup still applies.
Also fixes a real parser bug found via that mailbox: this libpst build
joins multi-value BYDAY with `;` instead of RFC 5545's `,`
(BYDAY=MO;TU;WE;TH;FR), which previously made RRULE parsing bail out
silently for any event with more than one weekday.
Indefinite rules (no COUNT/UNTIL — only YEARLY holidays in practice) are
capped at 10 years/500 occurrences; ponytail-flagged as the ceiling to
raise if a real series needs more.
This commit is contained in:
+405
-13
@@ -208,6 +208,7 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
let mut description = None;
|
||||
let mut starts_at = None;
|
||||
let mut ends_at = None;
|
||||
let mut rrule: Option<String> = None;
|
||||
let mut attendees: Vec<AttendeeInfo> = Vec::new();
|
||||
|
||||
for line in &lines {
|
||||
@@ -223,22 +224,49 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
description = None;
|
||||
starts_at = None;
|
||||
ends_at = None;
|
||||
rrule = None;
|
||||
attendees = Vec::new();
|
||||
}
|
||||
"END" if value.eq_ignore_ascii_case("VEVENT") && in_event => {
|
||||
events.push(ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
source: source.to_string(),
|
||||
subject: summary.take(),
|
||||
organizer: organizer.take(),
|
||||
starts_at,
|
||||
ends_at,
|
||||
description: description.take(),
|
||||
raw_uid: uid.take(),
|
||||
},
|
||||
attendees: std::mem::take(&mut attendees),
|
||||
});
|
||||
let attendees = std::mem::take(&mut attendees);
|
||||
match (rrule.take(), &uid, starts_at) {
|
||||
// A recurring event needs a UID to key its occurrences
|
||||
// for dedup (source, raw_uid) — without one, fall back
|
||||
// to importing just the single stored occurrence below.
|
||||
(Some(rule), Some(base_uid), Some(dtstart)) => {
|
||||
let duration = ends_at.map(|e| e - dtstart).unwrap_or(0);
|
||||
for occ_start in expand_rrule(&rule, dtstart) {
|
||||
events.push(ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
source: source.to_string(),
|
||||
subject: summary.clone(),
|
||||
organizer: organizer.clone(),
|
||||
starts_at: Some(occ_start),
|
||||
ends_at: Some(occ_start + duration),
|
||||
description: description.clone(),
|
||||
raw_uid: Some(format!("{base_uid}@{}", ymd_digits(occ_start))),
|
||||
},
|
||||
attendees: attendees.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
events.push(ImportedEvent {
|
||||
event: CalendarEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
source: source.to_string(),
|
||||
subject: summary.take(),
|
||||
organizer: organizer.take(),
|
||||
starts_at,
|
||||
ends_at,
|
||||
description: description.take(),
|
||||
raw_uid: uid.take(),
|
||||
},
|
||||
attendees,
|
||||
});
|
||||
}
|
||||
}
|
||||
in_event = false;
|
||||
}
|
||||
"UID" if in_event => uid = Some(value.to_string()),
|
||||
@@ -246,6 +274,7 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
"DESCRIPTION" if in_event => description = Some(unescape_text(value)),
|
||||
"DTSTART" if in_event => starts_at = parse_ics_datetime(value),
|
||||
"DTEND" if in_event => ends_at = parse_ics_datetime(value),
|
||||
"RRULE" if in_event => rrule = Some(value.to_string()),
|
||||
"ORGANIZER" if in_event => {
|
||||
let (name, email) = cal_address(params, value);
|
||||
organizer = name.or(email);
|
||||
@@ -267,6 +296,290 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
|
||||
events
|
||||
}
|
||||
|
||||
// ---- RRULE (RFC 5545 recurrence) expansion — pure, no I/O ----
|
||||
|
||||
// ponytail: caps for RRULEs with no COUNT/UNTIL (only YEARLY holidays do this
|
||||
// in practice) and a hard ceiling regardless — extend both if a real series
|
||||
// needs more instances than this.
|
||||
const RECURRENCE_HORIZON_YEARS: i64 = 10;
|
||||
const RECURRENCE_MAX_OCCURRENCES: usize = 500;
|
||||
|
||||
enum Freq {
|
||||
Daily,
|
||||
Weekly,
|
||||
Monthly,
|
||||
Yearly,
|
||||
}
|
||||
|
||||
struct Rrule {
|
||||
freq: Freq,
|
||||
interval: i64,
|
||||
count: Option<usize>,
|
||||
until: Option<i64>,
|
||||
byday: Vec<u32>, // weekday indices, 0=SU..6=SA
|
||||
bymonthday: Vec<u32>, // 1..31
|
||||
bymonth: Vec<u32>, // 1..12
|
||||
}
|
||||
|
||||
fn weekday_code_to_index(code: &str) -> Option<u32> {
|
||||
// Strips a leading ordinal like "2MO" ("2nd Monday") — not seen in this
|
||||
// codebase's real-world data (only plain weekday codes are), but the
|
||||
// weekday is all this parser uses either way.
|
||||
let letters: String = code.chars().filter(|c| c.is_ascii_alphabetic()).collect();
|
||||
match letters.to_ascii_uppercase().as_str() {
|
||||
"SU" => Some(0),
|
||||
"MO" => Some(1),
|
||||
"TU" => Some(2),
|
||||
"WE" => Some(3),
|
||||
"TH" => Some(4),
|
||||
"FR" => Some(5),
|
||||
"SA" => Some(6),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `FREQ=WEEKLY;COUNT=26;BYDAY=MO`-style RRULE values. Supports
|
||||
/// DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL/COUNT/UNTIL/BYDAY/BYMONTHDAY/
|
||||
/// BYMONTH — every combination confirmed present in a real 7.2GB mailbox
|
||||
/// (ADR-0008). No BYSETPOS, no per-occurrence exceptions (RECURRENCE-ID).
|
||||
fn parse_rrule(rule: &str) -> Option<Rrule> {
|
||||
let mut freq = None;
|
||||
let mut interval = 1i64;
|
||||
let mut count = None;
|
||||
let mut until = None;
|
||||
let mut byday = Vec::new();
|
||||
let mut bymonthday = Vec::new();
|
||||
let mut bymonth = Vec::new();
|
||||
let mut current_list_key = String::new();
|
||||
for part in rule.split(';') {
|
||||
// readpst joins a multi-value BYDAY with `;` instead of RFC 5545's
|
||||
// `,` (e.g. `BYDAY=MO;TU;WE;TH;FR`), so a continuation token has no
|
||||
// `=` at all — attribute it to whichever list key came before it.
|
||||
let (key, v) = match part.split_once('=') {
|
||||
Some((k, v)) => {
|
||||
current_list_key = k.to_ascii_uppercase();
|
||||
(current_list_key.as_str(), v)
|
||||
}
|
||||
None => (current_list_key.as_str(), part),
|
||||
};
|
||||
match key {
|
||||
"FREQ" => {
|
||||
freq = match v.to_ascii_uppercase().as_str() {
|
||||
"DAILY" => Some(Freq::Daily),
|
||||
"WEEKLY" => Some(Freq::Weekly),
|
||||
"MONTHLY" => Some(Freq::Monthly),
|
||||
"YEARLY" => Some(Freq::Yearly),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
"INTERVAL" => interval = v.parse().unwrap_or(1).max(1),
|
||||
"COUNT" => count = v.parse().ok(),
|
||||
"UNTIL" => until = parse_ics_datetime(v),
|
||||
"BYDAY" => byday.extend(v.split(',').filter_map(weekday_code_to_index)),
|
||||
"BYMONTHDAY" => bymonthday.extend(v.split(',').filter_map(|s| s.parse::<u32>().ok())),
|
||||
"BYMONTH" => bymonth.extend(v.split(',').filter_map(|s| s.parse::<u32>().ok())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(Rrule {
|
||||
freq: freq?,
|
||||
interval,
|
||||
count,
|
||||
until,
|
||||
byday,
|
||||
bymonthday,
|
||||
bymonth,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_leap_year(year: i64) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
}
|
||||
|
||||
fn days_in_month(year: i64, month: u32) -> u32 {
|
||||
match month {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 => {
|
||||
if is_leap_year(year) {
|
||||
29
|
||||
} else {
|
||||
28
|
||||
}
|
||||
}
|
||||
_ => 30,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of `ymd_hms_to_unix`'s date half (Howard Hinnant's
|
||||
/// `civil_from_days`, public domain, proleptic Gregorian).
|
||||
fn civil_from_days(epoch_day: i64) -> (i64, u32, u32) {
|
||||
let z = epoch_day + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097; // [0, 146096]
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
|
||||
let year = if m <= 2 { y + 1 } else { y };
|
||||
(year, m, d)
|
||||
}
|
||||
|
||||
/// 0=Sunday..6=Saturday for a given day count since the unix epoch
|
||||
/// (1970-01-01 was a Thursday).
|
||||
fn weekday_from_epoch_day(epoch_day: i64) -> u32 {
|
||||
(((epoch_day % 7) + 7 + 4) % 7) as u32
|
||||
}
|
||||
|
||||
fn ymd_digits(unix_secs: i64) -> String {
|
||||
let (y, m, d) = civil_from_days(unix_secs.div_euclid(86_400));
|
||||
format!("{y:04}{m:02}{d:02}")
|
||||
}
|
||||
|
||||
/// Expands an RRULE into occurrence start timestamps (unix seconds),
|
||||
/// reusing `dtstart`'s time-of-day for every occurrence.
|
||||
fn expand_rrule(rule: &str, dtstart: i64) -> Vec<i64> {
|
||||
let Some(r) = parse_rrule(rule) else {
|
||||
return vec![dtstart];
|
||||
};
|
||||
let time_of_day = dtstart.rem_euclid(86_400);
|
||||
let start_day = dtstart.div_euclid(86_400);
|
||||
let (hour, min, sec) = (
|
||||
time_of_day / 3600,
|
||||
(time_of_day / 60) % 60,
|
||||
time_of_day % 60,
|
||||
);
|
||||
|
||||
let indefinite = r.count.is_none() && r.until.is_none();
|
||||
let effective_until = if indefinite {
|
||||
dtstart + RECURRENCE_HORIZON_YEARS * 365 * 86_400
|
||||
} else {
|
||||
r.until.unwrap_or(i64::MAX)
|
||||
};
|
||||
let count_cap = r
|
||||
.count
|
||||
.unwrap_or(RECURRENCE_MAX_OCCURRENCES)
|
||||
.min(RECURRENCE_MAX_OCCURRENCES);
|
||||
|
||||
let mut occurrences = Vec::new();
|
||||
match r.freq {
|
||||
// Outlook emits "every weekday" as either FREQ, always with BYDAY —
|
||||
// both iterate calendar weeks and keep the requested weekdays.
|
||||
Freq::Weekly | Freq::Daily if !r.byday.is_empty() => {
|
||||
let step_days: i64 = if matches!(r.freq, Freq::Weekly) {
|
||||
7 * r.interval
|
||||
} else {
|
||||
7
|
||||
};
|
||||
let mut week_start = start_day - weekday_from_epoch_day(start_day) as i64;
|
||||
'weeks: loop {
|
||||
for &wd in &r.byday {
|
||||
let day = week_start + wd as i64;
|
||||
if day < start_day {
|
||||
continue;
|
||||
}
|
||||
let ts = day * 86_400 + time_of_day;
|
||||
if ts > effective_until || occurrences.len() >= count_cap {
|
||||
break 'weeks;
|
||||
}
|
||||
occurrences.push(ts);
|
||||
}
|
||||
week_start += step_days;
|
||||
}
|
||||
}
|
||||
Freq::Daily => {
|
||||
let mut day = start_day;
|
||||
loop {
|
||||
let ts = day * 86_400 + time_of_day;
|
||||
if ts > effective_until || occurrences.len() >= count_cap {
|
||||
break;
|
||||
}
|
||||
occurrences.push(ts);
|
||||
day += r.interval;
|
||||
}
|
||||
}
|
||||
Freq::Weekly => {
|
||||
let mut day = start_day;
|
||||
loop {
|
||||
let ts = day * 86_400 + time_of_day;
|
||||
if ts > effective_until || occurrences.len() >= count_cap {
|
||||
break;
|
||||
}
|
||||
occurrences.push(ts);
|
||||
day += 7 * r.interval;
|
||||
}
|
||||
}
|
||||
Freq::Monthly => {
|
||||
let (y0, m0, d0) = civil_from_days(start_day);
|
||||
let day_of_month = r.bymonthday.first().copied().unwrap_or(d0);
|
||||
let mut idx: i64 = 0;
|
||||
loop {
|
||||
let total = (m0 as i64 - 1) + idx * r.interval;
|
||||
let year = y0 + total.div_euclid(12);
|
||||
let month = (total.rem_euclid(12) + 1) as u32;
|
||||
if day_of_month <= days_in_month(year, month) {
|
||||
let ts = ymd_hms_to_unix(
|
||||
year,
|
||||
month,
|
||||
day_of_month,
|
||||
hour as u32,
|
||||
min as u32,
|
||||
sec as u32,
|
||||
);
|
||||
if ts >= dtstart {
|
||||
if ts > effective_until || occurrences.len() >= count_cap {
|
||||
break;
|
||||
}
|
||||
occurrences.push(ts);
|
||||
}
|
||||
}
|
||||
idx += 1;
|
||||
if idx as usize > RECURRENCE_MAX_OCCURRENCES * 2 {
|
||||
break; // safety valve against a pathological rule
|
||||
}
|
||||
}
|
||||
}
|
||||
Freq::Yearly => {
|
||||
let (y0, m0, d0) = civil_from_days(start_day);
|
||||
let months = if r.bymonth.is_empty() {
|
||||
vec![m0]
|
||||
} else {
|
||||
r.bymonth.clone()
|
||||
};
|
||||
let day_of_month = r.bymonthday.first().copied().unwrap_or(d0);
|
||||
let mut year = y0;
|
||||
loop {
|
||||
for &month in &months {
|
||||
if day_of_month <= days_in_month(year, month) {
|
||||
let ts = ymd_hms_to_unix(
|
||||
year,
|
||||
month,
|
||||
day_of_month,
|
||||
hour as u32,
|
||||
min as u32,
|
||||
sec as u32,
|
||||
);
|
||||
if ts >= dtstart && ts <= effective_until && occurrences.len() < count_cap {
|
||||
occurrences.push(ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
year += r.interval;
|
||||
if occurrences.len() >= count_cap || year > y0 + RECURRENCE_HORIZON_YEARS * 2 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if occurrences.is_empty() {
|
||||
vec![dtstart]
|
||||
} else {
|
||||
occurrences
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an iCalendar DATE-TIME (`20260701T090000Z` / `20260701T090000`) or
|
||||
/// DATE (`20260701`) value to a unix epoch. Both `Z`-suffixed and floating
|
||||
/// (no `Z`, no `TZID`) values are treated as UTC — full IANA timezone
|
||||
@@ -408,4 +721,83 @@ END:VCALENDAR\r\n";
|
||||
});
|
||||
assert!(matches!(result, Err(CalError::Open(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_from_days_round_trips_with_ymd_hms_to_unix() {
|
||||
for (y, m, d) in [(1970, 1, 1), (2026, 7, 1), (2000, 2, 29), (2026, 1, 12)] {
|
||||
let ts = ymd_hms_to_unix(y, m, d, 0, 0, 0);
|
||||
assert_eq!(civil_from_days(ts.div_euclid(86_400)), (y, m, d));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_rrule_weekly_single_byday_matches_the_real_1on1_pattern() {
|
||||
// The exact rule readpst produced for a real "Weekly 1:1" on Mondays.
|
||||
let dtstart = ymd_hms_to_unix(2026, 1, 12, 16, 30, 0); // a Monday
|
||||
let occurrences = expand_rrule("FREQ=WEEKLY;COUNT=26;BYDAY=MO", dtstart);
|
||||
assert_eq!(occurrences.len(), 26);
|
||||
assert_eq!(occurrences[0], dtstart);
|
||||
for (i, occ) in occurrences.iter().enumerate() {
|
||||
assert_eq!(*occ, dtstart + i as i64 * 7 * 86_400);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_rrule_weekly_multi_byday_covers_every_weekday_in_order() {
|
||||
let dtstart = ymd_hms_to_unix(2026, 1, 12, 9, 0, 0); // Monday
|
||||
let occurrences = expand_rrule("FREQ=WEEKLY;COUNT=10;BYDAY=MO;TU;WE;TH;FR", dtstart);
|
||||
assert_eq!(occurrences.len(), 10);
|
||||
// Mon..Fri week 1, then Mon..Fri week 2 — a flat +1 day step except
|
||||
// the weekend gap between index 4 (Fri) and 5 (next Mon).
|
||||
for i in 0..4 {
|
||||
assert_eq!(occurrences[i + 1] - occurrences[i], 86_400);
|
||||
}
|
||||
assert_eq!(occurrences[5] - occurrences[4], 3 * 86_400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_rrule_monthly_bymonthday_steps_calendar_months() {
|
||||
let dtstart = ymd_hms_to_unix(2026, 1, 1, 9, 0, 0);
|
||||
let occurrences = expand_rrule("FREQ=MONTHLY;COUNT=7;BYMONTHDAY=1", dtstart);
|
||||
assert_eq!(occurrences.len(), 7);
|
||||
assert_eq!(occurrences[6], ymd_hms_to_unix(2026, 7, 1, 9, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_rrule_yearly_with_no_count_or_until_is_capped_by_the_horizon() {
|
||||
let dtstart = ymd_hms_to_unix(2020, 11, 11, 0, 0, 0);
|
||||
let occurrences = expand_rrule("FREQ=YEARLY;BYMONTHDAY=11;BYMONTH=11", dtstart);
|
||||
assert!(
|
||||
!occurrences.is_empty() && occurrences.len() <= RECURRENCE_HORIZON_YEARS as usize + 2
|
||||
);
|
||||
for occ in &occurrences {
|
||||
let (_, m, d) = civil_from_days(occ.div_euclid(86_400));
|
||||
assert_eq!((m, d), (11, 11));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vevents_expands_a_recurring_event_into_distinct_occurrences() {
|
||||
let ics = "BEGIN:VEVENT\r\n\
|
||||
UID:series-1\r\n\
|
||||
SUMMARY:Weekly 1:1\r\n\
|
||||
DTSTART:20260112T163000Z\r\n\
|
||||
DTEND:20260112T170000Z\r\n\
|
||||
RRULE:FREQ=WEEKLY;COUNT=3;BYDAY=MO\r\n\
|
||||
END:VEVENT\r\n";
|
||||
let events = parse_vevents(ics, "pst");
|
||||
assert_eq!(events.len(), 3);
|
||||
let raw_uids: Vec<_> = events.iter().map(|e| e.event.raw_uid.clone()).collect();
|
||||
assert_eq!(
|
||||
raw_uids.len(),
|
||||
raw_uids
|
||||
.iter()
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len()
|
||||
);
|
||||
for e in &events {
|
||||
assert_eq!(e.event.subject.as_deref(), Some("Weekly 1:1"));
|
||||
assert_eq!(e.event.ends_at.unwrap() - e.event.starts_at.unwrap(), 1800);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user