server: big improvements for parsing all day events

This commit is contained in:
2026-01-20 09:39:40 -08:00
parent 626eca5619
commit a1cf16350b
2 changed files with 874 additions and 8 deletions

View File

@@ -241,6 +241,31 @@ pub fn extract_calendar_metadata_from_mail(
if end_date.is_none() { end_date = Some(end); }
}
}
// Pattern: single all-day event: @ Sun Jan 18, 2026 (no time range)
if start_date.is_none() {
if let Some(caps) = regex::Regex::new(r"@ [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}), (\d{4})(?:\s*\(|$)").ok().and_then(|re| re.captures(&subject)) {
let month = &caps[1];
let day = &caps[2];
let year = &caps[3];
fn month_num(mon: &str) -> Option<&'static str> {
match mon {
"Jan" => Some("01"), "Feb" => Some("02"), "Mar" => Some("03"), "Apr" => Some("04"),
"May" => Some("05"), "Jun" => Some("06"), "Jul" => Some("07"), "Aug" => Some("08"),
"Sep" => Some("09"), "Oct" => Some("10"), "Nov" => Some("11"), "Dec" => Some("12"),
_ => None
}
}
if let Some(mm) = month_num(month) {
let start = format!("{}{}{:0>2}", year, mm, day);
// For all-day events, end date is the next day (exclusive)
if let Ok(d) = chrono::NaiveDate::parse_from_str(&format!("{}-{}-{:0>2}", year, mm, day), "%Y-%m-%d") {
let end = d.succ_opt().unwrap_or(d).format("%Y%m%d").to_string();
start_date = Some(start);
end_date = Some(end);
}
}
}
}
}
}
}
@@ -328,6 +353,8 @@ pub fn extract_calendar_metadata_from_mail(
let needs_ical_flex =
summary.is_some() || start_date.is_some() || end_date.is_some() || has_recurrence;
if needs_ical_flex {
use chrono::{Datelike, NaiveDate};
let summary_val = summary.clone().unwrap_or_default();
let organizer_val = organizer.clone().unwrap_or_default();
let start_val = start_date.clone().unwrap_or_default();
@@ -337,15 +364,101 @@ pub fn extract_calendar_metadata_from_mail(
} else {
String::new()
};
// Compute event_days and all_days for calendar grid rendering
let mut event_days: Vec<NaiveDate> = Vec::new();
let (local_fmt_start, local_fmt_end) = if let (Some(ref start_str), Some(ref end_str)) =
(&start_date, &end_date)
{
// Parse YYYYMMDD format dates
let start_d = NaiveDate::parse_from_str(start_str, "%Y%m%d").ok();
let end_d = NaiveDate::parse_from_str(end_str, "%Y%m%d").ok();
if let (Some(start), Some(end)) = (start_d, end_d) {
// For all-day events, end date is exclusive, so we need to subtract one day
let display_end = if end > start {
end.pred_opt().unwrap_or(end)
} else {
end
};
// Add all days from start to display_end (inclusive) to event_days
let mut day_iter = start;
while day_iter <= display_end {
event_days.push(day_iter);
day_iter = day_iter.succ_opt().unwrap_or(day_iter);
if day_iter == display_end && day_iter == start {
// Single day event
break;
}
}
// Format dates for display
let fmt_start = start.format("%a %b %e, %Y").to_string();
let fmt_end = display_end.format("%a %b %e, %Y").to_string();
(fmt_start, fmt_end)
} else {
(start_val.clone(), end_val.clone())
}
} else {
(start_val.clone(), end_val.clone())
};
// Compute calendar grid (all_days) from event_days
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 =
NaiveDate::from_ymd_opt(first_event.year(), first_event.month(), 1).unwrap();
let last_of_month = {
let next_month = if last_event.month() == 12 {
NaiveDate::from_ymd_opt(last_event.year() + 1, 1, 1).unwrap()
} else {
NaiveDate::from_ymd_opt(last_event.year(), last_event.month() + 1, 1).unwrap()
};
next_month.pred_opt().unwrap()
};
// Start from Sunday of the week containing first_of_month
let mut cal_start = first_of_month;
while cal_start.weekday() != chrono::Weekday::Sun {
cal_start = cal_start.pred_opt().unwrap();
}
// End on Saturday of the week containing last_of_month
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: &start_val,
local_fmt_end: &end_val,
local_fmt_start: &local_fmt_start,
local_fmt_end: &local_fmt_end,
organizer: &organizer_val,
organizer_cn: "",
all_days: vec![],
event_days: vec![],
caption: String::new(),
all_days,
event_days,
caption,
description_paragraphs: &[],
today: Some(chrono::Local::now().date_naive()),
recurrence_display,
@@ -2286,8 +2399,9 @@ mod tests {
html.contains("Dentist appt"),
"HTML should contain the summary"
);
// Date is now formatted as human-readable "Tue Sep 23, 2025"
assert!(
html.contains("20250923"),
html.contains("Sep 23, 2025") || html.contains("20250923"),
"HTML should contain the event date"
);
assert!(
@@ -2341,12 +2455,13 @@ mod tests {
html.contains("<b>Organizer:</b> calendar-notification@google.com"),
"HTML should contain the labeled organizer"
);
// Dates are now formatted as human-readable
assert!(
html.contains("<b>Start:</b> 20250911"),
html.contains("<b>Start:</b> Thu Sep 11, 2025") || html.contains("<b>Start:</b> 20250911"),
"HTML should contain the labeled start time"
);
assert!(
html.contains("<b>End:</b> 20260131"),
html.contains("<b>End:</b> Fri Jan 30, 2026") || html.contains("<b>End:</b> 20260131"),
"HTML should contain the labeled end time"
);
if !html.contains("ical-flex") {
@@ -2468,6 +2583,29 @@ mod tests {
}
}
#[test]
fn google_calendar_email_4_single_allday_event() {
use mailparse::parse_mail;
let raw_email = include_str!("../../server/testdata/google-calendar-example-4.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");
let meta = extract_calendar_metadata_from_mail(&parsed, &body);
// Assert detection as Google Calendar
assert!(meta.is_google_calendar_event);
// Assert metadata extraction for single all-day event
assert_eq!(meta.summary, Some("Emery Sleeps Over".to_string()));
assert_eq!(meta.organizer, Some("tconvertino@gmail.com".to_string()));
// Dates: Sunday Jan 18, 2026 (all-day event)
assert_eq!(meta.start_date, Some("20260118".to_string()));
assert_eq!(meta.end_date, Some("20260119".to_string())); // All-day events end next day
// Assert ical summary is rendered and shows Jan 18 highlighted
let html = meta.body_html.expect("body_html");
println!("Rendered HTML: {}", html);
assert!(html.contains("ical-flex"), "Calendar widget should be rendered");
assert!(html.contains(r#"data-event-day="2026-01-18""#), "Jan 18 should be highlighted");
}
#[test]
fn recurring_event_rrule_metadata_and_highlight() {
use super::render_ical_summary;