diff --git a/server/src/email_extract.rs b/server/src/email_extract.rs index 70dcc56..5bf4cc7 100644 --- a/server/src/email_extract.rs +++ b/server/src/email_extract.rs @@ -1,7 +1,7 @@ use std::io::{Cursor, Read}; use askama::Template; -use chrono::{TimeZone, Utc, Datelike, NaiveDate, Local, LocalResult}; +use chrono::{Datelike, Local, LocalResult, NaiveDate, TimeZone, Utc}; use chrono_tz::Tz; use mailparse::{parse_content_type, parse_mail, MailHeader, MailHeaderMap, ParsedMail}; use quick_xml::de::from_str as xml_from_str; @@ -1319,7 +1319,7 @@ pub fn render_ical_summary(ical_data: &str) -> Result { } } } - }, + } "DTEND" => dtend = prop.value.as_deref(), "ORGANIZER" => { organizer = prop.value.as_deref(); @@ -1336,40 +1336,52 @@ pub fn render_ical_summary(ical_data: &str) -> Result { } // Parse start/end as chrono DateTime - let (start_dt, end_dt, 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 = tz.from_utc_datetime(&chrono::NaiveDateTime::from_timestamp_opt(0, 0).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 fmt_start = local_start.format("%c").to_string(); - let fmt_end = local_end.format("%c").to_string(); - let mut days = vec![]; - let mut 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 (start_dt, end_dt, 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 = tz.from_utc_datetime( + &chrono::NaiveDateTime::from_timestamp_opt(0, 0).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 fmt_start = local_start.format("%c").to_string(); + let fmt_end = local_end.format("%c").to_string(); + let mut days = vec![]; + let mut 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(); + } } - } - // 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(); - } - #[cfg(test)] - println!("DEBUG event_days: {:?}", days); - (start, end, fmt_start, fmt_end, days) - } else { - let tz = chrono_tz::UTC; - let fallback = tz.from_utc_datetime(&chrono::NaiveDateTime::from_timestamp_opt(0, 0).unwrap()); - (fallback, fallback, String::new(), String::new(), vec![]) - }; + // 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(); + } + #[cfg(test)] + println!("DEBUG event_days: {:?}", days); + (start, end, fmt_start, fmt_end, days) + } else { + let tz = chrono_tz::UTC; + let fallback = tz.from_utc_datetime( + &chrono::NaiveDateTime::from_timestamp_opt(0, 0).unwrap(), + ); + (fallback, fallback, String::new(), String::new(), vec![]) + }; // Render a single merged calendar table for all spanned months let mut calendar_html = String::new(); @@ -1378,13 +1390,15 @@ pub fn render_ical_summary(ical_data: &str) -> Result { let first_event = event_days.first().unwrap(); let last_event = event_days.last().unwrap(); // First day of the first event's month - let first_of_month = NaiveDate::from_ymd_opt(first_event.year(), first_event.month(), 1).unwrap(); + let first_of_month = + NaiveDate::from_ymd_opt(first_event.year(), first_event.month(), 1).unwrap(); // Last day of the last event's month 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() + NaiveDate::from_ymd_opt(last_event.year(), last_event.month() + 1, 1) + .unwrap() }; next_month.pred_opt().unwrap() }; @@ -1433,42 +1447,96 @@ pub fn render_ical_summary(ical_data: &str) -> Result { } else { "border:1px solid #eee; text-align:center;background:#f7f7f7;color:#bbb;" }; - calendar_html.push_str(&format!("{}", style, day.day())); + calendar_html.push_str(&format!( + "{}", + style, + day.day() + )); } calendar_html.push_str(""); } calendar_html.push_str(""); } - let mut summary_html = String::from("
"); - if let Some(summary) = summary { - summary_html.push_str(&format!("
Summary: {}
", summary)); - } - if let Some(desc) = description { - summary_html.push_str(&format!("
{}
", desc)); - } - summary_html.push_str(&format!("
Start: {}
", local_fmt_start)); - summary_html.push_str(&format!("
End: {}
", local_fmt_end)); - if let Some(cn) = organizer_cn { - summary_html.push_str(&format!("
Organizer: {}
", cn)); - } else if let Some(organizer) = organizer { - summary_html.push_str(&format!("
Organizer: {}
", organizer)); - } - summary_html.push_str("
"); - // Wrap in a flexbox for layout + // Remove block-level summary wrapper, move its styles to the flex child + let summary_inner = { + let mut s = String::new(); + s.push_str("
Summary: "); + if let Some(summary) = summary { + s.push_str(summary); + } + s.push_str("
"); + s.push_str(&format!( + "
Start: {}
", + local_fmt_start + )); + s.push_str(&format!( + "
End: {}
", + local_fmt_end + )); + if let Some(cn) = organizer_cn { + s.push_str(&format!( + "
Organizer: {}
", + cn + )); + } else if let Some(organizer) = organizer { + s.push_str(&format!( + "
Organizer: {}
", + organizer + )); + } + s + }; + + // Responsive layout: row with wrap by default, summary expands, calendar right-justified let event_block = format!( - "
\ -
{}
\ -
{}
\ + "\ +
\ +
{}
\ + {}\
", - summary_html, calendar_html + summary_inner, + if !calendar_html.is_empty() { + format!("
{}
", calendar_html) + } else { + String::new() + } ); - summary_parts.push(event_block); + + // Description: only if present and non-whitespace, below summary+calendar + let mut desc_block = String::new(); + if let Some(desc) = description { + // Replace escaped newlines with real newlines, then split + let desc = desc.replace("\\n", "\n"); + if desc.trim().len() > 0 { + let paragraphs: Vec = desc + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .map(|line| { + format!("

{}

", line) + }) + .collect(); + if !paragraphs.is_empty() { + desc_block = format!( + "
{}
", + paragraphs.join("\n") + ); + } + } + } + summary_parts.push(format!("{}{}", event_block, desc_block)); } } fn render_month_calendar_widget(date: NaiveDate, event_days: &[NaiveDate]) -> String { let first = date.with_day(1).unwrap(); - let last = first.with_day(1).unwrap().succ_opt().unwrap_or(first).pred_opt().unwrap_or(first); + let last = first + .with_day(1) + .unwrap() + .succ_opt() + .unwrap_or(first) + .pred_opt() + .unwrap_or(first); let month_name = first.format("%B").to_string(); let year = first.year(); let mut html = String::from( @@ -1493,12 +1561,18 @@ pub fn render_ical_summary(ical_data: &str) -> Result { if event_days.contains(&cur) { html.push_str(&format!("{}", cur.day())); } else { - html.push_str(&format!("{}", cur.day())); + html.push_str(&format!( + "{}", + cur.day() + )); } - cur = match cur.succ_opt() { Some(d) => d, None => break }; + cur = match cur.succ_opt() { + Some(d) => d, + None => break, + }; } let last_wd = last.weekday().num_days_from_sunday(); - for _ in last_wd+1..7 { + for _ in last_wd + 1..7 { html.push_str(""); } html.push_str(""); @@ -1567,10 +1641,10 @@ mod tests { fn test_ical_render() { let ical = fs::read_to_string("testdata/ical-example-1.ics").unwrap(); let html = render_ical_summary(&ical).unwrap(); - assert!(html.contains("Summary: dentist night guard")); - assert!(html.contains("Start: ")); - assert!(html.contains("End: ")); - assert!(html.contains("Organizer: Bill Thiede")); + assert!(html.contains("Summary: dentist night guard")); + assert!(html.contains("Start: ")); + assert!(html.contains("End: ")); + assert!(html.contains("Organizer: Bill Thiede")); } #[test] @@ -1578,58 +1652,60 @@ mod tests { let ical = fs::read_to_string("testdata/ical-example-2.ics").unwrap(); let html = render_ical_summary(&ical).unwrap(); println!("HTML OUTPUT: {}", html); - assert!(html.contains("Summary: [tenative] dinner w/ amatute")); - assert!(html.contains("Start: ")); - assert!(html.contains("End: ")); - assert!(html.contains("Organizer: Family")); - + assert!(html.contains("Summary: [tenative] dinner w/ amatute")); + assert!(html.contains("Start: ")); + assert!(html.contains("End: ")); + assert!(html.contains("Organizer: Family")); } #[test] fn test_ical_multiday() { - let ical = fs::read_to_string("testdata/ical-multiday.ics").unwrap(); - let html = render_ical_summary(&ical).unwrap(); - println!("HTML MULTIDAY: {}", html); - // Print the calendar row containing 28, 29, 30 for debug - if let Some(row_start) = html.find("") { - if let Some(row_end) = html[row_start..].find("") { - println!("CALENDAR ROW: {}", &html[row_start..row_start+row_end+5]); - } - } - assert!(html.contains("Summary: Multi-day Event")); - assert!(html.contains("This event spans multiple days")); - // Should highlight 28, 29, 30 in the merged calendar table (event days), and dim 27, 31 (out-of-event) - for day in [28, 29, 30] { - assert!(html.contains(&format!("{}", day)), "Missing highlighted day {}", day); - } - for day in [27, 31] { - assert!(html.contains(&format!("{}", day)), "Missing dimmed day {}", day); + let ical = fs::read_to_string("testdata/ical-multiday.ics").unwrap(); + let html = render_ical_summary(&ical).unwrap(); + println!("HTML MULTIDAY: {}", html); + // Print the calendar row containing 28, 29, 30 for debug + if let Some(row_start) = html.find("") { + if let Some(row_end) = html[row_start..].find("") { + println!( + "CALENDAR ROW: {}", + &html[row_start..row_start + row_end + 5] + ); } + } + assert!(html.contains("Summary: Multi-day Event")); + assert!(html.contains("This event spans multiple days")); + // Should highlight 28, 29, 30 in the merged calendar table (event days), and dim 27, 31 (out-of-event) + for day in [28, 29, 30] { + assert!(html.contains(&format!("{}", day)), "Missing highlighted day {}", day); + } + for day in [27, 31] { + assert!(html.contains(&format!("{}", day)), "Missing dimmed day {}", day); + } } #[test] fn test_ical_straddle() { let ical = fs::read_to_string("testdata/ical-straddle.ics").unwrap(); - let html = render_ical_summary(&ical).unwrap(); - println!("HTML STRADDLE: {}", html); + let html = render_ical_summary(&ical).unwrap(); + println!("HTML STRADDLE: {}", html); assert!(html.contains("Summary: Straddle Month Event")); assert!(html.contains("This event straddles two months")); // Should highlight 30, 31 in August and 1, 2 in September - assert!(html.contains("30")); - assert!(html.contains("31")); - assert!(html.contains("1")); - assert!(html.contains("2")); + assert!(html.contains("30")); + assert!(html.contains("31")); + assert!(html.contains("1")); + assert!(html.contains("2")); } #[test] fn test_ical_straddle_real() { let ical = fs::read_to_string("testdata/ical-straddle-real.ics").unwrap(); - let html = render_ical_summary(&ical).unwrap(); - println!("HTML STRADDLE REAL: {}", html); + let html = render_ical_summary(&ical).unwrap(); + println!("HTML STRADDLE REAL: {}", html); // Should highlight 30, 31 in August and 1 in September (DTEND is exclusive) - assert!(html.contains("30")); - assert!(html.contains("31")); - assert!(html.contains("1")); - assert!(!html.contains("2")); + assert!(html.contains("30")); + assert!(html.contains("31")); + assert!(html.contains("1")); + assert!(!html.contains("2")); } }