server: fix some recurring parsing/viz
This commit is contained in:
parent
7b7f012b19
commit
caf924203e
@ -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,12 +1907,14 @@ 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) =
|
||||||
|
if let Some(dtstart) = dtstart {
|
||||||
let tz: Tz = tzid
|
let tz: Tz = tzid
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("UTC")
|
.unwrap_or("UTC")
|
||||||
@ -1904,7 +1933,8 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
|
|||||||
.unwrap_or(start);
|
.unwrap_or(start);
|
||||||
let local_start = start.with_timezone(&Local);
|
let local_start = start.with_timezone(&Local);
|
||||||
let local_end = end.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 allday =
|
||||||
|
dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false));
|
||||||
let fmt_start = if allday {
|
let fmt_start = if allday {
|
||||||
local_start.format("%a %b %e, %Y").to_string()
|
local_start.format("%a %b %e, %Y").to_string()
|
||||||
} else {
|
} else {
|
||||||
@ -1915,26 +1945,177 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
|
|||||||
} else {
|
} else {
|
||||||
local_end.format("%-I:%M %p %a %b %e, %Y").to_string()
|
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 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<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 d = start.date_naive();
|
||||||
let mut end_d = end.date_naive();
|
let mut end_d = end.date_naive();
|
||||||
// Check for all-day event (DATE, not DATE-TIME)
|
let allday =
|
||||||
let allday = dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false));
|
dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false));
|
||||||
if allday {
|
if allday {
|
||||||
// DTEND is exclusive for all-day events
|
// DTEND is exclusive for all-day events
|
||||||
if end_d > d {
|
if end_d > d {
|
||||||
end_d = end_d.pred_opt().unwrap();
|
end_d = end_d.pred_opt().unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Only include actual event days
|
|
||||||
let mut day_iter = d;
|
let mut day_iter = d;
|
||||||
while day_iter <= end_d {
|
while day_iter <= end_d {
|
||||||
days.push(day_iter);
|
days.push(day_iter);
|
||||||
day_iter = day_iter.succ_opt().unwrap();
|
day_iter = day_iter.succ_opt().unwrap();
|
||||||
}
|
}
|
||||||
(fmt_start, fmt_end, days)
|
}
|
||||||
|
(fmt_start, fmt_end, days, recurrence_display)
|
||||||
} else {
|
} else {
|
||||||
(String::new(), String::new(), vec![])
|
(String::new(), String::new(), vec![], String::new())
|
||||||
};
|
};
|
||||||
|
|
||||||
// Compute calendar grid for template rendering
|
// Compute calendar grid for template rendering
|
||||||
@ -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()?);
|
||||||
}
|
}
|
||||||
|
|||||||
12
server/testdata/ical-straddle.ics
vendored
12
server/testdata/ical-straddle.ics
vendored
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user