diff --git a/Cargo.lock b/Cargo.lock index acac77a..2c3e96a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -969,6 +969,28 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf 0.11.3", +] + +[[package]] +name = "chrono-tz-build" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" +dependencies = [ + "parse-zoneinfo", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + [[package]] name = "cipher" version = "0.4.4" @@ -3271,6 +3293,7 @@ dependencies = [ "build-info-build", "cacher", "chrono", + "chrono-tz", "clap", "css-inline", "flate2", @@ -4320,6 +4343,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + [[package]] name = "paste" version = "1.0.15" diff --git a/server/Cargo.toml b/server/Cargo.toml index 3156f0e..7048344 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -12,6 +12,7 @@ version.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +chrono-tz = "0.8" html2text = "0.6" ammonia = "4.1.0" anyhow = "1.0.98" diff --git a/server/src/email_extract.rs b/server/src/email_extract.rs index d4273df..7d1f0ed 100644 --- a/server/src/email_extract.rs +++ b/server/src/email_extract.rs @@ -1,7 +1,8 @@ use std::io::{Cursor, Read}; use askama::Template; -use chrono::{TimeZone, Utc}; +use chrono::{TimeZone, Utc, Datelike, NaiveDate, Local, LocalResult}; +use chrono_tz::Tz; use mailparse::{parse_content_type, parse_mail, MailHeader, MailHeaderMap, ParsedMail}; use quick_xml::de::from_str as xml_from_str; use tracing::{error, info, warn}; @@ -1303,10 +1304,20 @@ pub fn render_ical_summary(ical_data: &str) -> Result { let mut dtend = None; let mut organizer = None; let mut organizer_cn = None; + let mut tzid: Option = None; for prop in &event.properties { match prop.name.as_str() { "SUMMARY" => summary = prop.value.as_deref(), - "DTSTART" => dtstart = prop.value.as_deref(), + "DTSTART" => { + dtstart = prop.value.as_deref(); + if let Some(params) = &prop.params { + if let Some((k, values)) = params.iter().find(|(k, _)| k == "TZID") { + if let Some(val) = values.get(0) { + tzid = Some(val.clone()); + } + } + } + }, "DTEND" => dtend = prop.value.as_deref(), "ORGANIZER" => { organizer = prop.value.as_deref(); @@ -1321,17 +1332,40 @@ 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 end_d = end.date_naive(); + while d <= end_d { + days.push(d); + d = d.succ_opt().unwrap(); + } + (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 calendar widget + let calendar_html = render_month_calendar_widget(start_dt.date_naive(), &event_days); + event_summary.push_str(&calendar_html); + if let Some(summary) = summary { event_summary.push_str(&format!("Summary: {}
", summary)); } - if let Some(dtstart) = dtstart { - let formatted = parse_ical_datetime(dtstart).unwrap_or_else(|| dtstart.to_string()); - event_summary.push_str(&format!("Start: {}
", formatted)); - } - if let Some(dtend) = dtend { - let formatted = parse_ical_datetime(dtend).unwrap_or_else(|| dtend.to_string()); - event_summary.push_str(&format!("End: {}
", formatted)); - } + event_summary.push_str(&format!("Start: {}
", local_fmt_start)); + event_summary.push_str(&format!("End: {}
", local_fmt_end)); if let Some(cn) = organizer_cn { event_summary.push_str(&format!("Organizer: {}
", cn)); } else if let Some(organizer) = organizer { @@ -1341,6 +1375,53 @@ pub fn render_ical_summary(ical_data: &str) -> Result { } } + 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 mut html = String::from(""); + for wd in ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] { + html.push_str(&format!("", wd)); + } + html.push_str(""); + let mut cur = first; + let mut started = false; + for _ in 0..first.weekday().num_days_from_sunday() { + html.push_str(""); + } + while cur.month() == first.month() { + if cur.weekday() == chrono::Weekday::Sun && started { + html.push_str(""); + } + started = true; + if event_days.contains(&cur) { + html.push_str(&format!("", cur.day())); + } else { + html.push_str(&format!("", cur.day())); + } + 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 { + html.push_str(""); + } + html.push_str("
{}
{}{}
"); + html + } + + fn parse_ical_datetime_tz(dt: &str, tz: Tz) -> Option> { + let dt = dt.split(':').last().unwrap_or(dt); + if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(dt, "%Y%m%dT%H%M%SZ") { + Some(tz.from_utc_datetime(&ndt)) + } else if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(dt, "%Y%m%dT%H%M%S") { + match tz.from_local_datetime(&ndt) { + LocalResult::Single(dt) => Some(dt), + _ => None, + } + } else { + None + } + } + fn parse_ical_datetime(dt: &str) -> Option { use chrono::{DateTime, NaiveDateTime, Utc}; let dt = dt.split(':').last().unwrap_or(dt); @@ -1382,10 +1463,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: 2025-01-08T08:00:00
")); - assert!(html.contains("End: 2025-01-08T09:00:00
")); - 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] @@ -1393,9 +1474,9 @@ 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: 2025-08-13T01:00:00Z
")); - assert!(html.contains("End: 2025-08-13T03:00:00Z
")); - 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
")); } }