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
|