From caf924203e485684ea3b11df3fdf37c3e8bbbfd7 Mon Sep 17 00:00:00 2001 From: Bill Thiede Date: Tue, 26 Aug 2025 21:16:36 -0700 Subject: [PATCH] server: fix some recurring parsing/viz --- server/src/email_extract.rs | 332 +++++++++++++++++++++++++----- server/testdata/ical-straddle.ics | 12 +- 2 files changed, 287 insertions(+), 57 deletions(-) diff --git a/server/src/email_extract.rs b/server/src/email_extract.rs index 41d505f..94e4f2b 100644 --- a/server/src/email_extract.rs +++ b/server/src/email_extract.rs @@ -5,7 +5,7 @@ mod tests { #[test] fn google_calendar_email_renders_ical_summary() { use mailparse::parse_mail; - let raw_email = include_str!("../testdata/google-calendar-example.eml"); + let raw_email = include_str!("../../server/testdata/google-calendar-example.eml"); let parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail"); let mut part_addr = vec![]; let body = extract_body(&parsed, &mut part_addr).expect("extract_body"); @@ -34,7 +34,7 @@ mod tests { #[test] fn google_calendar_email_2_renders_ical_summary() { use mailparse::parse_mail; - let raw_email = include_str!("../testdata/google-calendar-example-2.eml"); + let raw_email = include_str!("../../server/testdata/google-calendar-example-2.eml"); let parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail"); let mut part_addr = vec![]; let body = extract_body(&parsed, &mut part_addr).expect("extract_body"); @@ -65,6 +65,28 @@ mod tests { .map(|h| h.contains("ical-flex")) .unwrap_or(false)); } + + #[test] + fn recurring_event_rrule_metadata_and_highlight() { + use super::render_ical_summary; + // This .ics should contain an RRULE for recurrence + let ical = include_str!("../../server/testdata/ical-straddle.ics"); + let html = render_ical_summary(&ical).expect("render ical summary"); + // Should mention recurrence in the display + assert!(html.contains("Repeats") || html.contains("recurr") || html.contains("RRULE")); + + // Should only highlight the correct days (not all days between start and end) + // For a weekly event, check that only the correct weekday cells are highlighted + // (e.g., if event is every Monday, only Mondays are highlighted) + // This is a weak test: just check that not all days in the range are highlighted + let highlighted = html.matches("background:#ffd700").count(); + let total_days = html.matches(" 0, "Should highlight at least one day"); + assert!( + highlighted < total_days / 2, + "Should not highlight all days" + ); + } } #[derive(Debug, PartialEq)] pub struct ExtractedCalendarMetadata { @@ -1846,6 +1868,10 @@ use ical::IcalParser; pub fn render_ical_summary(ical_data: &str) -> Result { let mut summary_parts = Vec::new(); let mut parser = IcalParser::new(ical_data.as_bytes()); + + use std::collections::HashSet; + + use chrono::{Datelike, NaiveDate}; while let Some(Ok(calendar)) = parser.next() { for event in calendar.events { let mut summary = None; @@ -1855,6 +1881,7 @@ pub fn render_ical_summary(ical_data: &str) -> Result { let mut organizer = None; let mut organizer_cn = None; let mut tzid: Option = None; + let mut rrule: Option = None; for prop in &event.properties { match prop.name.as_str() { "SUMMARY" => summary = prop.value.as_deref(), @@ -1880,62 +1907,216 @@ pub fn render_ical_summary(ical_data: &str) -> Result { } } } + "RRULE" => rrule = prop.value.clone(), _ => {} } } // Parse start/end as chrono DateTime - let (local_fmt_start, local_fmt_end, event_days) = if let Some(dtstart) = dtstart { - let tz: Tz = tzid - .as_deref() - .unwrap_or("UTC") - .parse() - .unwrap_or(chrono_tz::UTC); - let fallback = chrono::DateTime::::from_timestamp(0, 0) - .map(|dt| dt.with_timezone(&tz)) - .unwrap_or_else(|| { - tz.with_ymd_and_hms(1970, 1, 1, 0, 0, 0) - .single() - .unwrap_or_else(|| tz.timestamp_opt(0, 0).single().unwrap()) - }); - let start = parse_ical_datetime_tz(dtstart, tz).unwrap_or(fallback); - let end = dtend - .and_then(|d| parse_ical_datetime_tz(d, tz)) - .unwrap_or(start); - let local_start = start.with_timezone(&Local); - let local_end = end.with_timezone(&Local); - let allday = dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false)); - let fmt_start = if allday { - local_start.format("%a %b %e, %Y").to_string() - } else { - local_start.format("%-I:%M %p %a %b %e, %Y").to_string() - }; - let fmt_end = if allday { - local_end.format("%a %b %e, %Y").to_string() - } else { - local_end.format("%-I:%M %p %a %b %e, %Y").to_string() - }; - let mut days = vec![]; - let d = start.date_naive(); - let mut end_d = end.date_naive(); - // Check for all-day event (DATE, not DATE-TIME) - let allday = dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false)); - if allday { - // DTEND is exclusive for all-day events - if end_d > d { - end_d = end_d.pred_opt().unwrap(); + let (local_fmt_start, local_fmt_end, event_days, recurrence_display) = + if let Some(dtstart) = dtstart { + let tz: Tz = tzid + .as_deref() + .unwrap_or("UTC") + .parse() + .unwrap_or(chrono_tz::UTC); + let fallback = chrono::DateTime::::from_timestamp(0, 0) + .map(|dt| dt.with_timezone(&tz)) + .unwrap_or_else(|| { + tz.with_ymd_and_hms(1970, 1, 1, 0, 0, 0) + .single() + .unwrap_or_else(|| tz.timestamp_opt(0, 0).single().unwrap()) + }); + let start = parse_ical_datetime_tz(dtstart, tz).unwrap_or(fallback); + let end = dtend + .and_then(|d| parse_ical_datetime_tz(d, tz)) + .unwrap_or(start); + let local_start = start.with_timezone(&Local); + let local_end = end.with_timezone(&Local); + let allday = + dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false)); + let fmt_start = if allday { + local_start.format("%a %b %e, %Y").to_string() + } else { + local_start.format("%-I:%M %p %a %b %e, %Y").to_string() + }; + let fmt_end = if allday { + local_end.format("%a %b %e, %Y").to_string() + } else { + local_end.format("%-I:%M %p %a %b %e, %Y").to_string() + }; + + // Recurrence support: parse RRULE and generate event_days accordingly + let mut days = vec![]; + let mut recurrence_display = String::new(); + if let Some(rrule_str) = rrule { + // Very basic RRULE parser for FREQ, INTERVAL, BYDAY, UNTIL, COUNT + let mut freq = "DAILY"; + let mut interval = 1; + let mut byday: Option> = None; + let mut until: Option = None; + let mut count: Option = None; + for part in rrule_str.split(';') { + let mut kv = part.splitn(2, '='); + let k = kv.next().unwrap_or(""); + let v = kv.next().unwrap_or(""); + match k { + "FREQ" => freq = v, + "INTERVAL" => interval = v.parse().unwrap_or(1), + "BYDAY" => { + byday = Some(v.split(',').map(|s| s.to_string()).collect()) + } + "UNTIL" => { + if v.len() >= 8 { + until = + chrono::NaiveDate::parse_from_str(&v[0..8], "%Y%m%d") + .ok(); + } + } + "COUNT" => count = v.parse().ok(), + _ => {} + } + } + // Human-readable recurrence string + recurrence_display = match freq { + "DAILY" => format!( + "Every {} day{}", + interval, + if interval > 1 { "s" } else { "" } + ), + "WEEKLY" => { + let days = byday.as_ref().map(|v| v.join(", ")).unwrap_or_default(); + if !days.is_empty() { + format!( + "Every {} week{} on {}", + interval, + if interval > 1 { "s" } else { "" }, + days + ) + } else { + format!( + "Every {} week{}", + interval, + if interval > 1 { "s" } else { "" } + ) + } + } + "MONTHLY" => format!( + "Every {} month{}", + interval, + if interval > 1 { "s" } else { "" } + ), + "YEARLY" => format!( + "Every {} year{}", + interval, + if interval > 1 { "s" } else { "" } + ), + _ => format!("Repeats: {}", freq), + }; + + // Generate event days for the recurrence + let mut cur = start.date_naive(); + let mut n = 0; + let max_span = 366; // safety: don't generate more than a year + let mut weekday_set = HashSet::new(); + if let Some(ref byday_vec) = byday { + for wd in byday_vec { + let wd = wd.trim(); + let chrono_wd = match wd { + "MO" => Some(chrono::Weekday::Mon), + "TU" => Some(chrono::Weekday::Tue), + "WE" => Some(chrono::Weekday::Wed), + "TH" => Some(chrono::Weekday::Thu), + "FR" => Some(chrono::Weekday::Fri), + "SA" => Some(chrono::Weekday::Sat), + "SU" => Some(chrono::Weekday::Sun), + _ => None, + }; + if let Some(wd) = chrono_wd { + weekday_set.insert(wd); + } + } + } + if freq == "WEEKLY" { + // For weekly, only add days that match BYDAY and are in the correct interval week + let mut cur_date = cur; + let mut added = 0; + let until_date = + until.unwrap_or(cur_date + chrono::Duration::days(max_span as i64)); + // Find the first week start (the week containing the DTSTART) + let week_start = cur_date + - chrono::Duration::days( + cur_date.weekday().num_days_from_monday() as i64 + ); + while cur_date <= until_date && added < count.unwrap_or(max_span) { + let weeks_since_start = + ((cur_date - week_start).num_days() / 7) as usize; + if weeks_since_start % interval == 0 + && weekday_set.contains(&cur_date.weekday()) + { + days.push(cur_date); + added += 1; + } + cur_date = cur_date.succ_opt().unwrap(); + } + } else { + let mut cur_date = cur; + let mut added = 0; + while added < count.unwrap_or(max_span) { + if let Some(until_date) = until { + if cur_date > until_date { + break; + } + } + match freq { + "DAILY" => { + days.push(cur_date); + cur_date = cur_date.succ_opt().unwrap(); + } + "MONTHLY" => { + days.push(cur_date); + cur_date = + cur_date.with_day(1).unwrap().succ_opt().unwrap(); + while cur_date.day() != 1 { + cur_date = cur_date.succ_opt().unwrap(); + } + } + "YEARLY" => { + days.push(cur_date); + cur_date = cur_date + .with_year(cur_date.year() + interval as i32) + .unwrap(); + } + _ => { + days.push(cur_date); + cur_date = cur_date.succ_opt().unwrap(); + } + } + added += 1; + } + } + } else { + // No RRULE: just add all days between start and end + let d = start.date_naive(); + let mut end_d = end.date_naive(); + let allday = + dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false)); + if allday { + // DTEND is exclusive for all-day events + if end_d > d { + end_d = end_d.pred_opt().unwrap(); + } + } + let mut day_iter = d; + while day_iter <= end_d { + days.push(day_iter); + day_iter = day_iter.succ_opt().unwrap(); + } } - } - // Only include actual event days - let mut day_iter = d; - while day_iter <= end_d { - days.push(day_iter); - day_iter = day_iter.succ_opt().unwrap(); - } - (fmt_start, fmt_end, days) - } else { - (String::new(), String::new(), vec![]) - }; + (fmt_start, fmt_end, days, recurrence_display) + } else { + (String::new(), String::new(), vec![], String::new()) + }; // Compute calendar grid for template rendering let (all_days, caption) = if !event_days.is_empty() { @@ -1995,6 +2176,51 @@ pub fn render_ical_summary(ical_data: &str) -> Result { let local_fmt_start_val = &local_fmt_start; let local_fmt_end_val = &local_fmt_end; let description_paragraphs_val = &description_paragraphs; + // Compute calendar grid for template rendering + let (all_days, caption) = if !event_days.is_empty() { + let first_event = event_days.first().unwrap(); + let last_event = event_days.last().unwrap(); + let first_of_month = + chrono::NaiveDate::from_ymd_opt(first_event.year(), first_event.month(), 1) + .unwrap(); + let last_of_month = { + let next_month = if last_event.month() == 12 { + chrono::NaiveDate::from_ymd_opt(last_event.year() + 1, 1, 1).unwrap() + } else { + chrono::NaiveDate::from_ymd_opt( + last_event.year(), + last_event.month() + 1, + 1, + ) + .unwrap() + }; + next_month.pred_opt().unwrap() + }; + let mut cal_start = first_of_month; + while cal_start.weekday() != chrono::Weekday::Sun { + cal_start = cal_start.pred_opt().unwrap(); + } + let mut cal_end = last_of_month; + while cal_end.weekday() != chrono::Weekday::Sat { + cal_end = cal_end.succ_opt().unwrap(); + } + let mut all_days = vec![]; + let mut d = cal_start; + while d <= cal_end { + all_days.push(d); + d = d.succ_opt().unwrap(); + } + let start_month = first_event.format("%B %Y"); + let end_month = last_event.format("%B %Y"); + let caption = if start_month.to_string() == end_month.to_string() { + start_month.to_string() + } else { + format!("{} – {}", start_month, end_month) + }; + (all_days, caption) + } else { + (vec![], String::new()) + }; let template = IcalSummaryTemplate { summary: summary_val, local_fmt_start: local_fmt_start_val, @@ -2006,7 +2232,7 @@ pub fn render_ical_summary(ical_data: &str) -> Result { caption, description_paragraphs: description_paragraphs_val, today: Some(chrono::Local::now().date_naive()), - recurrence_display: String::new(), + recurrence_display, }; summary_parts.push(template.render()?); } diff --git a/server/testdata/ical-straddle.ics b/server/testdata/ical-straddle.ics index 675fa8b..7feafdd 100644 --- a/server/testdata/ical-straddle.ics +++ b/server/testdata/ical-straddle.ics @@ -1,9 +1,13 @@ + BEGIN:VCALENDAR VERSION:2.0 +PRODID:-//Test Recurring Event//EN BEGIN:VEVENT -SUMMARY:Straddle Month Event -DTSTART;VALUE=DATE:20250830 -DTEND;VALUE=DATE:20250903 -DESCRIPTION:This event straddles two months. +UID:recurring-test-1@example.com +DTSTART;VALUE=DATE:20250804 +DTEND;VALUE=DATE:20250805 +RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20250825T000000Z +SUMMARY:Test Recurring Event (Mon, Wed, Fri) +DESCRIPTION:This event recurs every Monday, Wednesday, and Friday in August 2025. END:VEVENT END:VCALENDAR