server: fix some recurring parsing/viz

This commit is contained in:
Bill Thiede 2025-08-26 21:16:36 -07:00
parent 7b7f012b19
commit caf924203e
2 changed files with 287 additions and 57 deletions

View File

@ -5,7 +5,7 @@ mod tests {
#[test] #[test]
fn google_calendar_email_renders_ical_summary() { fn google_calendar_email_renders_ical_summary() {
use mailparse::parse_mail; 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 parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail");
let mut part_addr = vec![]; let mut part_addr = vec![];
let body = extract_body(&parsed, &mut part_addr).expect("extract_body"); let body = extract_body(&parsed, &mut part_addr).expect("extract_body");
@ -34,7 +34,7 @@ mod tests {
#[test] #[test]
fn google_calendar_email_2_renders_ical_summary() { fn google_calendar_email_2_renders_ical_summary() {
use mailparse::parse_mail; 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 parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail");
let mut part_addr = vec![]; let mut part_addr = vec![];
let body = extract_body(&parsed, &mut part_addr).expect("extract_body"); let body = extract_body(&parsed, &mut part_addr).expect("extract_body");
@ -65,6 +65,28 @@ mod tests {
.map(|h| h.contains("ical-flex")) .map(|h| h.contains("ical-flex"))
.unwrap_or(false)); .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("<td").count();
assert!(highlighted > 0, "Should highlight at least one day");
assert!(
highlighted < total_days / 2,
"Should not highlight all days"
);
}
} }
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub struct ExtractedCalendarMetadata { pub struct ExtractedCalendarMetadata {
@ -1846,6 +1868,10 @@ use ical::IcalParser;
pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> { pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
let mut summary_parts = Vec::new(); let mut summary_parts = Vec::new();
let mut parser = IcalParser::new(ical_data.as_bytes()); let mut parser = IcalParser::new(ical_data.as_bytes());
use std::collections::HashSet;
use chrono::{Datelike, NaiveDate};
while let Some(Ok(calendar)) = parser.next() { while let Some(Ok(calendar)) = parser.next() {
for event in calendar.events { for event in calendar.events {
let mut summary = None; let mut summary = None;
@ -1855,6 +1881,7 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
let mut organizer = None; let mut organizer = None;
let mut organizer_cn = None; let mut organizer_cn = None;
let mut tzid: Option<String> = None; let mut tzid: Option<String> = None;
let mut rrule: Option<String> = None;
for prop in &event.properties { for prop in &event.properties {
match prop.name.as_str() { match prop.name.as_str() {
"SUMMARY" => summary = prop.value.as_deref(), "SUMMARY" => summary = prop.value.as_deref(),
@ -1880,62 +1907,216 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
} }
} }
} }
"RRULE" => rrule = prop.value.clone(),
_ => {} _ => {}
} }
} }
// Parse start/end as chrono DateTime // Parse start/end as chrono DateTime
let (local_fmt_start, local_fmt_end, event_days) = if let Some(dtstart) = dtstart { let (local_fmt_start, local_fmt_end, event_days, recurrence_display) =
let tz: Tz = tzid if let Some(dtstart) = dtstart {
.as_deref() let tz: Tz = tzid
.unwrap_or("UTC") .as_deref()
.parse() .unwrap_or("UTC")
.unwrap_or(chrono_tz::UTC); .parse()
let fallback = chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0) .unwrap_or(chrono_tz::UTC);
.map(|dt| dt.with_timezone(&tz)) let fallback = chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0)
.unwrap_or_else(|| { .map(|dt| dt.with_timezone(&tz))
tz.with_ymd_and_hms(1970, 1, 1, 0, 0, 0) .unwrap_or_else(|| {
.single() tz.with_ymd_and_hms(1970, 1, 1, 0, 0, 0)
.unwrap_or_else(|| tz.timestamp_opt(0, 0).single().unwrap()) .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 let start = parse_ical_datetime_tz(dtstart, tz).unwrap_or(fallback);
.and_then(|d| parse_ical_datetime_tz(d, tz)) let end = dtend
.unwrap_or(start); .and_then(|d| parse_ical_datetime_tz(d, tz))
let local_start = start.with_timezone(&Local); .unwrap_or(start);
let local_end = end.with_timezone(&Local); let local_start = start.with_timezone(&Local);
let allday = dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false)); let local_end = end.with_timezone(&Local);
let fmt_start = if allday { let allday =
local_start.format("%a %b %e, %Y").to_string() dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false));
} else { let fmt_start = if allday {
local_start.format("%-I:%M %p %a %b %e, %Y").to_string() local_start.format("%a %b %e, %Y").to_string()
}; } else {
let fmt_end = if allday { local_start.format("%-I:%M %p %a %b %e, %Y").to_string()
local_end.format("%a %b %e, %Y").to_string() };
} else { let fmt_end = if allday {
local_end.format("%-I:%M %p %a %b %e, %Y").to_string() local_end.format("%a %b %e, %Y").to_string()
}; } else {
let mut days = vec![]; local_end.format("%-I:%M %p %a %b %e, %Y").to_string()
let d = start.date_naive(); };
let mut end_d = end.date_naive();
// Check for all-day event (DATE, not DATE-TIME) // Recurrence support: parse RRULE and generate event_days accordingly
let allday = dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false)); let mut days = vec![];
if allday { let mut recurrence_display = String::new();
// DTEND is exclusive for all-day events if let Some(rrule_str) = rrule {
if end_d > d { // Very basic RRULE parser for FREQ, INTERVAL, BYDAY, UNTIL, COUNT
end_d = end_d.pred_opt().unwrap(); let mut freq = "DAILY";
let mut interval = 1;
let mut byday: Option<Vec<String>> = None;
let mut until: Option<NaiveDate> = None;
let mut count: Option<usize> = 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();
}
} }
} (fmt_start, fmt_end, days, recurrence_display)
// Only include actual event days } else {
let mut day_iter = d; (String::new(), String::new(), vec![], String::new())
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![])
};
// Compute calendar grid for template rendering // Compute calendar grid for template rendering
let (all_days, caption) = if !event_days.is_empty() { let (all_days, caption) = if !event_days.is_empty() {
@ -1995,6 +2176,51 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
let local_fmt_start_val = &local_fmt_start; let local_fmt_start_val = &local_fmt_start;
let local_fmt_end_val = &local_fmt_end; let local_fmt_end_val = &local_fmt_end;
let description_paragraphs_val = &description_paragraphs; 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 { let template = IcalSummaryTemplate {
summary: summary_val, summary: summary_val,
local_fmt_start: local_fmt_start_val, local_fmt_start: local_fmt_start_val,
@ -2006,7 +2232,7 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
caption, caption,
description_paragraphs: description_paragraphs_val, description_paragraphs: description_paragraphs_val,
today: Some(chrono::Local::now().date_naive()), today: Some(chrono::Local::now().date_naive()),
recurrence_display: String::new(), recurrence_display,
}; };
summary_parts.push(template.render()?); summary_parts.push(template.render()?);
} }

View File

@ -1,9 +1,13 @@
BEGIN:VCALENDAR BEGIN:VCALENDAR
VERSION:2.0 VERSION:2.0
PRODID:-//Test Recurring Event//EN
BEGIN:VEVENT BEGIN:VEVENT
SUMMARY:Straddle Month Event UID:recurring-test-1@example.com
DTSTART;VALUE=DATE:20250830 DTSTART;VALUE=DATE:20250804
DTEND;VALUE=DATE:20250903 DTEND;VALUE=DATE:20250805
DESCRIPTION:This event straddles two months. 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:VEVENT
END:VCALENDAR END:VCALENDAR