diff --git a/src-tauri/src/calendar/mod.rs b/src-tauri/src/calendar/mod.rs index d6711e2..067b141 100644 --- a/src-tauri/src/calendar/mod.rs +++ b/src-tauri/src/calendar/mod.rs @@ -208,6 +208,7 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec { let mut description = None; let mut starts_at = None; let mut ends_at = None; + let mut rrule: Option = None; let mut attendees: Vec = Vec::new(); for line in &lines { @@ -223,22 +224,49 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec { 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 { "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 { 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, + until: Option, + byday: Vec, // weekday indices, 0=SU..6=SA + bymonthday: Vec, // 1..31 + bymonth: Vec, // 1..12 +} + +fn weekday_code_to_index(code: &str) -> Option { + // 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 { + 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::().ok())), + "BYMONTH" => bymonth.extend(v.split(',').filter_map(|s| s.parse::().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 { + 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::>() + .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); + } + } }