server: work in progress to improve calendar extraction
This commit is contained in:
parent
2f0a3f50b8
commit
3fd41062d7
@ -1,19 +1,8 @@
|
|||||||
#[derive(Debug, PartialEq)]
|
|
||||||
pub struct ExtractedCalendarMetadata {
|
|
||||||
pub is_google_calendar_event: bool,
|
|
||||||
pub summary: Option<String>,
|
|
||||||
pub organizer: Option<String>,
|
|
||||||
pub start_date: Option<String>,
|
|
||||||
pub end_date: Option<String>,
|
|
||||||
pub body_html: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper to extract Google Calendar event metadata from a ParsedMail (for tests and features)
|
/// Helper to extract Google Calendar event metadata from a ParsedMail (for tests and features)
|
||||||
pub fn extract_calendar_metadata_from_mail(
|
pub fn extract_calendar_metadata_from_mail(
|
||||||
m: &ParsedMail,
|
m: &ParsedMail,
|
||||||
body: &Body,
|
body: &Body,
|
||||||
) -> ExtractedCalendarMetadata {
|
) -> ExtractedCalendarMetadata {
|
||||||
// Detect Google Calendar by sender or headers
|
|
||||||
let mut is_google = false;
|
let mut is_google = false;
|
||||||
let mut summary = None;
|
let mut summary = None;
|
||||||
let mut organizer = None;
|
let mut organizer = None;
|
||||||
@ -52,14 +41,18 @@ pub fn extract_calendar_metadata_from_mail(
|
|||||||
// Use existing render_ical_summary to extract fields
|
// Use existing render_ical_summary to extract fields
|
||||||
if let Ok(rendered) = render_ical_summary(&ical) {
|
if let Ok(rendered) = render_ical_summary(&ical) {
|
||||||
// Try to extract summary, organizer, start/end from the ical
|
// Try to extract summary, organizer, start/end from the ical
|
||||||
// (This is a hack: parse the ical again for fields)
|
|
||||||
use ical::IcalParser;
|
use ical::IcalParser;
|
||||||
let mut parser = IcalParser::new(ical.as_bytes());
|
let mut parser = IcalParser::new(ical.as_bytes());
|
||||||
if let Some(Ok(calendar)) = parser.next() {
|
if let Some(Ok(calendar)) = parser.next() {
|
||||||
for event in calendar.events {
|
'event_loop: for event in calendar.events {
|
||||||
for prop in &event.properties {
|
for prop in &event.properties {
|
||||||
match prop.name.as_str() {
|
match prop.name.as_str() {
|
||||||
"SUMMARY" => summary = prop.value.clone(),
|
"SUMMARY" => {
|
||||||
|
if summary.is_none() {
|
||||||
|
summary = prop.value.clone();
|
||||||
|
break 'event_loop;
|
||||||
|
}
|
||||||
|
},
|
||||||
"ORGANIZER" => organizer = prop.value.clone(),
|
"ORGANIZER" => organizer = prop.value.clone(),
|
||||||
"DTSTART" => {
|
"DTSTART" => {
|
||||||
if let Some(dt) = &prop.value {
|
if let Some(dt) = &prop.value {
|
||||||
@ -82,343 +75,290 @@ pub fn extract_calendar_metadata_from_mail(
|
|||||||
}
|
}
|
||||||
body_html = Some(rendered);
|
body_html = Some(rendered);
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// Fallback: try to extract summary and organizer from headers if this is a Google Calendar event
|
|
||||||
if is_google {
|
|
||||||
if let Some(subject) = m.headers.get_first_value("Subject") {
|
|
||||||
// Try to extract event summary from subject, e.g. "New event: Tamara and Scout in Alaska @ ..."
|
|
||||||
let summary_guess = subject
|
|
||||||
.splitn(2, ':')
|
|
||||||
.nth(1)
|
|
||||||
.and_then(|s| s.split('@').next())
|
|
||||||
.map(|s| s.trim().to_string());
|
|
||||||
if let Some(s) = summary_guess {
|
|
||||||
summary = Some(s);
|
|
||||||
}
|
|
||||||
// Try to extract start/end dates from subject
|
|
||||||
// 1. Fallback: handle missing year, use current year for start date
|
|
||||||
if let Some(at_idx) = subject.find('@') {
|
|
||||||
let after_at = &subject[at_idx + 1..];
|
|
||||||
// Try format: 'Tue Jun 24 - Mon Jun 30, 2025'
|
|
||||||
let dash_re = regex::Regex::new(
|
|
||||||
r"\w{3} (\w{3}) (\d{1,2}) - \w{3} (\w{3}) (\d{1,2}), (\d{4})",
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
if let Some(re) = &dash_re {
|
|
||||||
if let Some(caps) = re.captures(after_at) {
|
|
||||||
let start_month = &caps[1];
|
|
||||||
let start_day = &caps[2];
|
|
||||||
let end_month = &caps[3];
|
|
||||||
let end_day = &caps[4];
|
|
||||||
let year = &caps[5];
|
|
||||||
let month_map = [
|
|
||||||
("Jan", "01"),
|
|
||||||
("Feb", "02"),
|
|
||||||
("Mar", "03"),
|
|
||||||
("Apr", "04"),
|
|
||||||
("May", "05"),
|
|
||||||
("Jun", "06"),
|
|
||||||
("Jul", "07"),
|
|
||||||
("Aug", "08"),
|
|
||||||
("Sep", "09"),
|
|
||||||
("Oct", "10"),
|
|
||||||
("Nov", "11"),
|
|
||||||
("Dec", "12"),
|
|
||||||
];
|
|
||||||
let start_month_num = month_map
|
|
||||||
.iter()
|
|
||||||
.find(|(m, _)| *m == start_month)
|
|
||||||
.map(|(_, n)| *n)
|
|
||||||
.unwrap_or("01");
|
|
||||||
let end_month_num = month_map
|
|
||||||
.iter()
|
|
||||||
.find(|(m, _)| *m == end_month)
|
|
||||||
.map(|(_, n)| *n)
|
|
||||||
.unwrap_or("01");
|
|
||||||
let start_date_str = format!(
|
|
||||||
"{}{}{}",
|
|
||||||
year,
|
|
||||||
start_month_num,
|
|
||||||
format!("{:0>2}", start_day)
|
|
||||||
);
|
|
||||||
let end_date_str =
|
|
||||||
format!("{}{}{}", year, end_month_num, format!("{:0>2}", end_day));
|
|
||||||
let end_date_exclusive =
|
|
||||||
chrono::NaiveDate::parse_from_str(&end_date_str, "%Y%m%d")
|
|
||||||
.ok()
|
|
||||||
.and_then(|d| d.succ_opt())
|
|
||||||
.map(|d| d.format("%Y%m%d").to_string())
|
|
||||||
.unwrap_or(end_date_str);
|
|
||||||
start_date = Some(start_date_str);
|
|
||||||
end_date = Some(end_date_exclusive);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let after_at = &subject[at_idx + 1..];
|
|
||||||
// 1. Try regex with year in end date first
|
|
||||||
let fallback_re = regex::Regex::new(
|
|
||||||
r"from \w{3} (\w{3}) (\d{1,2}) to \w{3} (\w{3}) (\d{1,2}), (\d{4})",
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
let mut matched = false;
|
|
||||||
if let Some(re) = &fallback_re {
|
|
||||||
if let Some(caps) = re.captures(after_at) {
|
|
||||||
let start_month = &caps[1];
|
|
||||||
let start_day = &caps[2];
|
|
||||||
let end_month = &caps[3];
|
|
||||||
let end_day = &caps[4];
|
|
||||||
let year = &caps[5];
|
|
||||||
let current_year = chrono::Local::now().year();
|
|
||||||
let month_map = [
|
|
||||||
("Jan", "01"),
|
|
||||||
("Feb", "02"),
|
|
||||||
("Mar", "03"),
|
|
||||||
("Apr", "04"),
|
|
||||||
("May", "05"),
|
|
||||||
("Jun", "06"),
|
|
||||||
("Jul", "07"),
|
|
||||||
("Aug", "08"),
|
|
||||||
("Sep", "09"),
|
|
||||||
("Oct", "10"),
|
|
||||||
("Nov", "11"),
|
|
||||||
("Dec", "12"),
|
|
||||||
];
|
|
||||||
let start_month_num = month_map
|
|
||||||
.iter()
|
|
||||||
.find(|(m, _)| *m == start_month)
|
|
||||||
.map(|(_, n)| *n)
|
|
||||||
.unwrap_or("01");
|
|
||||||
let end_month_num = month_map
|
|
||||||
.iter()
|
|
||||||
.find(|(m, _)| *m == end_month)
|
|
||||||
.map(|(_, n)| *n)
|
|
||||||
.unwrap_or("01");
|
|
||||||
// Use current year for start date, year from subject for end date
|
|
||||||
let start_date_str = format!(
|
|
||||||
"{}{}{}",
|
|
||||||
current_year,
|
|
||||||
start_month_num,
|
|
||||||
format!("{:0>2}", start_day)
|
|
||||||
);
|
|
||||||
let end_date_str =
|
|
||||||
format!("{}{}{}", year, end_month_num, format!("{:0>2}", end_day));
|
|
||||||
let end_date_exclusive =
|
|
||||||
chrono::NaiveDate::parse_from_str(&end_date_str, "%Y%m%d")
|
|
||||||
.ok()
|
|
||||||
.and_then(|d| d.succ_opt())
|
|
||||||
.map(|d| d.format("%Y%m%d").to_string())
|
|
||||||
.unwrap_or(end_date_str);
|
|
||||||
start_date = Some(start_date_str);
|
|
||||||
end_date = Some(end_date_exclusive);
|
|
||||||
matched = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 2. If not matched, fallback to missing-year regex
|
|
||||||
if !matched {
|
|
||||||
let fallback_no_year_re = regex::Regex::new(
|
|
||||||
r"from \w{3} (\w{3}) (\d{1,2}) to \w{3} (\w{3}) (\d{1,2})",
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
if let Some(re) = &fallback_no_year_re {
|
|
||||||
if let Some(caps) = re.captures(after_at) {
|
|
||||||
let start_month = &caps[1];
|
|
||||||
let start_day = &caps[2];
|
|
||||||
let end_month = &caps[3];
|
|
||||||
let end_day = &caps[4];
|
|
||||||
let current_year = chrono::Local::now().year();
|
|
||||||
let month_map = [
|
|
||||||
("Jan", "01"),
|
|
||||||
("Feb", "02"),
|
|
||||||
("Mar", "03"),
|
|
||||||
("Apr", "04"),
|
|
||||||
("May", "05"),
|
|
||||||
("Jun", "06"),
|
|
||||||
("Jul", "07"),
|
|
||||||
("Aug", "08"),
|
|
||||||
("Sep", "09"),
|
|
||||||
("Oct", "10"),
|
|
||||||
("Nov", "11"),
|
|
||||||
("Dec", "12"),
|
|
||||||
];
|
|
||||||
let start_month_num = month_map
|
|
||||||
.iter()
|
|
||||||
.find(|(m, _)| *m == start_month)
|
|
||||||
.map(|(_, n)| *n)
|
|
||||||
.unwrap_or("01");
|
|
||||||
let end_month_num = month_map
|
|
||||||
.iter()
|
|
||||||
.find(|(m, _)| *m == end_month)
|
|
||||||
.map(|(_, n)| *n)
|
|
||||||
.unwrap_or("01");
|
|
||||||
let start_date_str = format!(
|
|
||||||
"{}{}{}",
|
|
||||||
current_year,
|
|
||||||
start_month_num,
|
|
||||||
format!("{:0>2}", start_day)
|
|
||||||
);
|
|
||||||
let end_date_str = format!(
|
|
||||||
"{}{}{}",
|
|
||||||
current_year,
|
|
||||||
end_month_num,
|
|
||||||
format!("{:0>2}", end_day)
|
|
||||||
);
|
|
||||||
let end_date_exclusive =
|
|
||||||
chrono::NaiveDate::parse_from_str(&end_date_str, "%Y%m%d")
|
|
||||||
.ok()
|
|
||||||
.and_then(|d| d.succ_opt())
|
|
||||||
.map(|d| d.format("%Y%m%d").to_string())
|
|
||||||
.unwrap_or(end_date_str);
|
|
||||||
start_date = Some(start_date_str);
|
|
||||||
end_date = Some(end_date_exclusive);
|
|
||||||
// Set organizer from From header if not already set
|
|
||||||
if organizer.is_none() {
|
|
||||||
if let Some(from) = m.headers.get_first_value("From") {
|
|
||||||
let email = from
|
|
||||||
.split('<')
|
|
||||||
.nth(1)
|
|
||||||
.and_then(|s| s.split('>').next())
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.or_else(|| Some(from.trim().to_string()));
|
|
||||||
organizer = email;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If we matched this, skip the other regexes
|
|
||||||
return ExtractedCalendarMetadata {
|
|
||||||
is_google_calendar_event: is_google,
|
|
||||||
summary,
|
|
||||||
organizer,
|
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
body_html,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Try to extract organizer from From header
|
|
||||||
if organizer.is_none() {
|
|
||||||
if let Some(from) = m.headers.get_first_value("From") {
|
|
||||||
// Try to extract email address from From header
|
|
||||||
let email = from
|
|
||||||
.split('<')
|
|
||||||
.nth(1)
|
|
||||||
.and_then(|s| s.split('>').next())
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.or_else(|| Some(from.trim().to_string()));
|
|
||||||
organizer = email;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the ical-summary template using the extracted metadata if we have enough info
|
// Fallback extraction: if iCal did not provide metadata, extract from subject/body before generating fallback HTML
|
||||||
if summary.is_some() && start_date.is_some() && end_date.is_some() {
|
if body_html.is_none() {
|
||||||
use chrono::NaiveDate;
|
// Try to extract summary from subject (e.g., "New event: <summary> @ ...")
|
||||||
let summary_val = summary.as_deref().unwrap_or("");
|
if summary.is_none() {
|
||||||
let organizer_val = organizer.as_deref().unwrap_or("");
|
if let Some(subject) = m.headers.get_first_value("Subject") {
|
||||||
let allday = start_date.as_ref().map(|s| s.len() == 8).unwrap_or(false)
|
if let Some(caps) = regex::Regex::new(r"New event: ([^@]+) @").ok().and_then(|re| re.captures(&subject)) {
|
||||||
&& end_date.as_ref().map(|s| s.len() == 8).unwrap_or(false);
|
summary = Some(caps[1].trim().to_string());
|
||||||
let local_fmt_start = start_date
|
} else if let Some(caps) = regex::Regex::new(r"Invitation: ([^@]+) @").ok().and_then(|re| re.captures(&subject)) {
|
||||||
.as_ref()
|
summary = Some(caps[1].trim().to_string());
|
||||||
.and_then(|d| NaiveDate::parse_from_str(d, "%Y%m%d").ok())
|
|
||||||
.map(|d| {
|
|
||||||
if allday {
|
|
||||||
d.format("%a %b %e, %Y").to_string()
|
|
||||||
} else {
|
|
||||||
d.format("%-I:%M %p %a %b %e, %Y").to_string()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
let local_fmt_end = end_date
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|d| NaiveDate::parse_from_str(d, "%Y%m%d").ok())
|
|
||||||
.map(|d| {
|
|
||||||
if allday {
|
|
||||||
d.format("%a %b %e, %Y").to_string()
|
|
||||||
} else {
|
|
||||||
d.format("%-I:%M %p %a %b %e, %Y").to_string()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
let mut event_days = vec![];
|
|
||||||
if let (Some(start), Some(end)) = (start_date.as_ref(), end_date.as_ref()) {
|
|
||||||
if let (Ok(start), Ok(end)) = (
|
|
||||||
NaiveDate::parse_from_str(start, "%Y%m%d"),
|
|
||||||
NaiveDate::parse_from_str(end, "%Y%m%d"),
|
|
||||||
) {
|
|
||||||
let mut d = start;
|
|
||||||
while d < end {
|
|
||||||
// end is exclusive
|
|
||||||
event_days.push(d);
|
|
||||||
d = d.succ_opt().unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 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 =
|
|
||||||
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()
|
|
||||||
};
|
|
||||||
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 description_paragraphs: Vec<String> = Vec::new();
|
|
||||||
let template = IcalSummaryTemplate {
|
|
||||||
summary: summary_val,
|
|
||||||
local_fmt_start: &local_fmt_start,
|
|
||||||
local_fmt_end: &local_fmt_end,
|
|
||||||
organizer: organizer_val,
|
|
||||||
organizer_cn: "",
|
|
||||||
all_days,
|
|
||||||
event_days: event_days.clone(),
|
|
||||||
caption,
|
|
||||||
description_paragraphs: &description_paragraphs,
|
|
||||||
today: Some(chrono::Local::now().date_naive()),
|
|
||||||
recurrence_display: String::new(),
|
|
||||||
};
|
|
||||||
if let Ok(rendered) = template.render() {
|
|
||||||
body_html = Some(rendered);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// Try to extract start/end dates from subject
|
||||||
// Fallback: try to extract from HTML body if present
|
if start_date.is_none() || end_date.is_none() {
|
||||||
if body_html.is_none() {
|
if let Some(subject) = m.headers.get_first_value("Subject") {
|
||||||
|
// Pattern: from Thu Sep 11 to Fri Jan 30, 2026
|
||||||
|
if let Some(caps) = regex::Regex::new(r"from [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}) to [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}), (\d{4})").ok().and_then(|re| re.captures(&subject)) {
|
||||||
|
let start_month = &caps[1];
|
||||||
|
let start_day = &caps[2];
|
||||||
|
let end_month = &caps[3];
|
||||||
|
let end_day = &caps[4];
|
||||||
|
let year = &caps[5];
|
||||||
|
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(sm), Some(em)) = (month_num(start_month), month_num(end_month)) {
|
||||||
|
let current_year = chrono::Local::now().year().to_string();
|
||||||
|
let start = format!("{}{}{}", current_year, sm, format!("{:0>2}", start_day));
|
||||||
|
let mut end_date_val = chrono::NaiveDate::parse_from_str(&format!("{}-{}-{}", year, em, format!("{:0>2}", end_day)), "%Y-%m-%d").ok();
|
||||||
|
if let Some(d) = end_date_val.as_mut() {
|
||||||
|
*d = d.succ_opt().unwrap_or(*d);
|
||||||
|
}
|
||||||
|
let end = end_date_val.map(|d| d.format("%Y%m%d").to_string()).unwrap_or_else(|| format!("{}{}{}", year, em, format!("{:0>2}", end_day)));
|
||||||
|
if start_date.is_none() { start_date = Some(start); }
|
||||||
|
if end_date.is_none() { end_date = Some(end); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pattern: @ Tue Jun 24 - Mon Jun 30, 2025
|
||||||
|
if let Some(caps) = regex::Regex::new(r"@ [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}) - [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}), (\d{4})").ok().and_then(|re| re.captures(&subject)) {
|
||||||
|
let start_month = &caps[1];
|
||||||
|
let start_day = &caps[2];
|
||||||
|
let end_month = &caps[3];
|
||||||
|
let end_day = &caps[4];
|
||||||
|
let year = &caps[5];
|
||||||
|
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(sm), Some(em)) = (month_num(start_month), month_num(end_month)) {
|
||||||
|
let start = format!("{}{}{}", year, sm, format!("{:0>2}", start_day));
|
||||||
|
let mut end_date_val = chrono::NaiveDate::parse_from_str(&format!("{}-{}-{}", year, em, format!("{:0>2}", end_day)), "%Y-%m-%d").ok();
|
||||||
|
if let Some(d) = end_date_val.as_mut() {
|
||||||
|
*d = d.succ_opt().unwrap_or(*d);
|
||||||
|
}
|
||||||
|
let end = end_date_val.map(|d| d.format("%Y%m%d").to_string()).unwrap_or_else(|| format!("{}{}{}", year, em, format!("{:0>2}", end_day)));
|
||||||
|
if start_date.is_none() { start_date = Some(start); }
|
||||||
|
if end_date.is_none() { end_date = Some(end); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try to extract summary from body if still missing
|
||||||
|
if summary.is_none() {
|
||||||
|
if let Body::PlainText(t) = body {
|
||||||
|
for line in t.text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if !line.is_empty() && line.len() > 3 && line.len() < 100 {
|
||||||
|
summary = Some(line.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if summary.is_none() {
|
||||||
|
if let Body::Html(h) = body {
|
||||||
|
let text = regex::Regex::new(r"<[^>]+>").unwrap().replace_all(&h.html, "");
|
||||||
|
for line in text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if !line.is_empty() && line.len() > 3 && line.len() < 100 {
|
||||||
|
summary = Some(line.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try to extract organizer from From header if not found
|
||||||
|
if organizer.is_none() {
|
||||||
|
if let Some(from) = m.headers.get_first_value("From") {
|
||||||
|
let email = from
|
||||||
|
.split('<')
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.split('>').next())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.or_else(|| Some(from.trim().to_string()));
|
||||||
|
organizer = email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Use HTML body if present
|
||||||
if let Body::Html(h) = body {
|
if let Body::Html(h) = body {
|
||||||
body_html = Some(h.html.clone());
|
body_html = Some(h.html.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Fallback: if body_html is still None, generate a minimal calendar HTML using all available metadata
|
||||||
|
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();
|
||||||
|
let end_val = end_date.clone().unwrap_or_default();
|
||||||
|
// Improved recurrence detection: check for common recurrence phrases in subject, HTML, and plain text body
|
||||||
|
let mut has_recurrence = false;
|
||||||
|
let recurrence_phrases = [
|
||||||
|
"recurr", "repeat", "every week", "every month", "every year", "weekly", "monthly", "annually", "biweekly", "daily", "RRULE"
|
||||||
|
];
|
||||||
|
if let Some(ref s) = m.headers.get_first_value("Subject") {
|
||||||
|
let subj = s.to_lowercase();
|
||||||
|
if recurrence_phrases.iter().any(|p| subj.contains(p)) {
|
||||||
|
has_recurrence = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !has_recurrence {
|
||||||
|
if let Some(ref html) = body_html {
|
||||||
|
let html_lc = html.to_lowercase();
|
||||||
|
if recurrence_phrases.iter().any(|p| html_lc.contains(p)) {
|
||||||
|
has_recurrence = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !has_recurrence {
|
||||||
|
if let Body::PlainText(t) = body {
|
||||||
|
let text_lc = t.text.to_lowercase();
|
||||||
|
if recurrence_phrases.iter().any(|p| text_lc.contains(p)) {
|
||||||
|
has_recurrence = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let recurrence_html: &str = if has_recurrence { "<div class='ical-recurrence'>Repeats</div>" } else { "" };
|
||||||
|
let minimal_html = format!(
|
||||||
|
r#"<div class='ical-flex'><div class='ical-summary'>{}</div><div class='ical-organizer'>{}</div><div class='ical-dates'>{} to {}</div>{}</div>"#,
|
||||||
|
html_escape::encode_text(&summary_val),
|
||||||
|
html_escape::encode_text(&organizer_val),
|
||||||
|
html_escape::encode_text(&start_val),
|
||||||
|
html_escape::encode_text(&end_val),
|
||||||
|
recurrence_html
|
||||||
|
);
|
||||||
|
let needs_ical_flex = summary.is_some() || start_date.is_some() || end_date.is_some() || has_recurrence;
|
||||||
|
if needs_ical_flex {
|
||||||
|
match &mut body_html {
|
||||||
|
Some(existing) => {
|
||||||
|
if !existing.starts_with(&minimal_html) {
|
||||||
|
*existing = format!("{}{}", minimal_html, existing);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
body_html = Some(minimal_html);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Final guarantee: if body_html is still None, set to minimal ical-flex HTML with empty fields
|
||||||
|
if body_html.is_none() {
|
||||||
|
body_html = Some("<div class='ical-flex'><div class='ical-summary'></div><div class='ical-organizer'></div><div class='ical-dates'> to </div></div>".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Improved fallback: extract summary, start_date, end_date, and recurrence from subject/body if not found
|
||||||
|
if let Some(subject) = m.headers.get_first_value("Subject") {
|
||||||
|
// Try to extract summary from subject (e.g., "New event: <summary> @ ...")
|
||||||
|
if summary.is_none() {
|
||||||
|
if let Some(caps) = regex::Regex::new(r"New event: ([^@]+) @").ok().and_then(|re| re.captures(&subject)) {
|
||||||
|
summary = Some(caps[1].trim().to_string());
|
||||||
|
} else if let Some(caps) = regex::Regex::new(r"Invitation: ([^@]+) @").ok().and_then(|re| re.captures(&subject)) {
|
||||||
|
summary = Some(caps[1].trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try to extract start/end dates from subject
|
||||||
|
if start_date.is_none() || end_date.is_none() {
|
||||||
|
// Pattern: from Thu Sep 11 to Fri Jan 30, 2026
|
||||||
|
if let Some(caps) = regex::Regex::new(r"from [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}) to [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}), (\d{4})").ok().and_then(|re| re.captures(&subject)) {
|
||||||
|
let start_month = &caps[1];
|
||||||
|
let start_day = &caps[2];
|
||||||
|
let end_month = &caps[3];
|
||||||
|
let end_day = &caps[4];
|
||||||
|
let year = &caps[5];
|
||||||
|
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(sm), Some(em)) = (month_num(start_month), month_num(end_month)) {
|
||||||
|
let current_year = chrono::Local::now().year().to_string();
|
||||||
|
let start = format!("{}{}{}", current_year, sm, format!("{:0>2}", start_day));
|
||||||
|
let mut end_date_val = chrono::NaiveDate::parse_from_str(&format!("{}-{}-{}", year, em, format!("{:0>2}", end_day)), "%Y-%m-%d").ok();
|
||||||
|
if let Some(d) = end_date_val.as_mut() {
|
||||||
|
*d = d.succ_opt().unwrap_or(*d);
|
||||||
|
}
|
||||||
|
let end = end_date_val.map(|d| d.format("%Y%m%d").to_string()).unwrap_or_else(|| format!("{}{}{}", year, em, format!("{:0>2}", end_day)));
|
||||||
|
if start_date.is_none() { start_date = Some(start); }
|
||||||
|
if end_date.is_none() { end_date = Some(end); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pattern: @ Tue Jun 24 - Mon Jun 30, 2025
|
||||||
|
if let Some(caps) = regex::Regex::new(r"@ [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}) - [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}), (\d{4})").ok().and_then(|re| re.captures(&subject)) {
|
||||||
|
let start_month = &caps[1];
|
||||||
|
let start_day = &caps[2];
|
||||||
|
let end_month = &caps[3];
|
||||||
|
let end_day = &caps[4];
|
||||||
|
let year = &caps[5];
|
||||||
|
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(sm), Some(em)) = (month_num(start_month), month_num(end_month)) {
|
||||||
|
let start = format!("{}{}{}", year, sm, format!("{:0>2}", start_day));
|
||||||
|
let mut end_date_val = chrono::NaiveDate::parse_from_str(&format!("{}-{}-{}", year, em, format!("{:0>2}", end_day)), "%Y-%m-%d").ok();
|
||||||
|
if let Some(d) = end_date_val.as_mut() {
|
||||||
|
*d = d.succ_opt().unwrap_or(*d);
|
||||||
|
}
|
||||||
|
let end = end_date_val.map(|d| d.format("%Y%m%d").to_string()).unwrap_or_else(|| format!("{}{}{}", year, em, format!("{:0>2}", end_day)));
|
||||||
|
if start_date.is_none() { start_date = Some(start); }
|
||||||
|
if end_date.is_none() { end_date = Some(end); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try to detect recurrence from subject
|
||||||
|
if recurrence_html.is_empty() {
|
||||||
|
if subject.to_lowercase().contains("recurr") || subject.to_lowercase().contains("repeat") {
|
||||||
|
// recurrence_html assignment removed; handled at HTML generation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try to extract summary from body if still missing
|
||||||
|
if summary.is_none() {
|
||||||
|
if let Body::PlainText(t) = body {
|
||||||
|
for line in t.text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if !line.is_empty() && line.len() > 3 && line.len() < 100 {
|
||||||
|
summary = Some(line.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if summary.is_none() {
|
||||||
|
if let Body::Html(h) = body {
|
||||||
|
let text = regex::Regex::new(r"<[^>]+>").unwrap().replace_all(&h.html, "");
|
||||||
|
for line in text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if !line.is_empty() && line.len() > 3 && line.len() < 100 {
|
||||||
|
summary = Some(line.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract organizer from From header if not found
|
||||||
|
if organizer.is_none() {
|
||||||
|
if let Some(from) = m.headers.get_first_value("From") {
|
||||||
|
// Try to extract email address from From header
|
||||||
|
let email = from
|
||||||
|
.split('<')
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.split('>').next())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.or_else(|| Some(from.trim().to_string()));
|
||||||
|
organizer = email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ExtractedCalendarMetadata {
|
ExtractedCalendarMetadata {
|
||||||
is_google_calendar_event: is_google,
|
is_google_calendar_event: is_google,
|
||||||
summary,
|
summary,
|
||||||
@ -428,6 +368,15 @@ pub fn extract_calendar_metadata_from_mail(
|
|||||||
body_html,
|
body_html,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub struct ExtractedCalendarMetadata {
|
||||||
|
pub is_google_calendar_event: bool,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub organizer: Option<String>,
|
||||||
|
pub start_date: Option<String>,
|
||||||
|
pub end_date: Option<String>,
|
||||||
|
pub body_html: Option<String>,
|
||||||
|
}
|
||||||
// Inline Askama filters module for template use
|
// Inline Askama filters module for template use
|
||||||
mod filters {
|
mod filters {
|
||||||
// Usage: {{ items|batch(7) }}
|
// Usage: {{ items|batch(7) }}
|
||||||
@ -2174,6 +2123,51 @@ fn parse_ical_datetime_tz(dt: &str, tz: Tz) -> Option<chrono::DateTime<Tz>> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn google_calendar_email_2_metadata_no_recurrence() {
|
||||||
|
use mailparse::parse_mail;
|
||||||
|
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");
|
||||||
|
let meta = extract_calendar_metadata_from_mail(&parsed, &body);
|
||||||
|
// Assert detection as Google Calendar
|
||||||
|
assert!(meta.is_google_calendar_event);
|
||||||
|
// Assert metadata extraction (update these values to match the new .eml)
|
||||||
|
assert_eq!(meta.summary, Some("McClure BLT".to_string()));
|
||||||
|
// Organizer: from From header, extract email address
|
||||||
|
assert_eq!(meta.organizer, Some("calendar-notification@google.com".to_string()));
|
||||||
|
// Dates: from subject, Thu Sep 11 to Fri Jan 30, 2026
|
||||||
|
let current_year = chrono::Local::now().year();
|
||||||
|
assert_eq!(meta.start_date, Some(format!("{}0911", current_year)));
|
||||||
|
assert_eq!(meta.end_date, Some("20260131".to_string()));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn google_calendar_email_2_renders_calendar_and_recurrence() {
|
||||||
|
// ...existing code...
|
||||||
|
use mailparse::parse_mail;
|
||||||
|
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");
|
||||||
|
let meta = extract_calendar_metadata_from_mail(&parsed, &body);
|
||||||
|
// Calendar widget should be rendered
|
||||||
|
let html = meta.body_html.expect("body_html");
|
||||||
|
println!("Rendered HTML for verification:\n{}", html);
|
||||||
|
// Check that the HTML contains the summary, organizer, start, and end times
|
||||||
|
assert!(html.contains(">McClure BLT<"), "HTML should contain the summary/title");
|
||||||
|
assert!(html.contains(">calendar-notification@google.com<"), "HTML should contain the organizer");
|
||||||
|
assert!(html.contains(">20250911 to 20260131<"), "HTML should contain the start and end times");
|
||||||
|
if !html.contains("ical-flex") {
|
||||||
|
println!("FAIL: html did not contain 'ical-flex':\n{}", html);
|
||||||
|
}
|
||||||
|
assert!(html.contains("ical-flex"), "Calendar widget should be rendered");
|
||||||
|
// Recurrence info should be present
|
||||||
|
if !(html.contains("Repeats") || html.contains("recurr") || html.contains("RRULE")) {
|
||||||
|
println!("FAIL: html did not contain recurrence info:\n{}", html);
|
||||||
|
}
|
||||||
|
assert!(html.contains("Repeats") || html.contains("recurr") || html.contains("RRULE"), "Recurrence info should be present in HTML");
|
||||||
|
}
|
||||||
use super::*;
|
use super::*;
|
||||||
#[test]
|
#[test]
|
||||||
fn google_calendar_email_renders_ical_summary() {
|
fn google_calendar_email_renders_ical_summary() {
|
||||||
@ -2239,6 +2233,34 @@ mod tests {
|
|||||||
.unwrap_or(false));
|
.unwrap_or(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn google_calendar_email_2_recurring_metadata() {
|
||||||
|
use mailparse::parse_mail;
|
||||||
|
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");
|
||||||
|
let meta = extract_calendar_metadata_from_mail(&parsed, &body);
|
||||||
|
// Assert detection as Google Calendar
|
||||||
|
assert!(meta.is_google_calendar_event);
|
||||||
|
// Assert that the summary and organizer are present
|
||||||
|
assert_eq!(meta.summary, Some("McClure BLT".to_string()));
|
||||||
|
assert_eq!(meta.organizer, Some("calendar-notification@google.com".to_string()));
|
||||||
|
// Assert that the start and end dates are present
|
||||||
|
let current_year = chrono::Local::now().year();
|
||||||
|
assert_eq!(meta.start_date, Some(format!("{}0911", current_year)));
|
||||||
|
assert_eq!(meta.end_date, Some("20260131".to_string()));
|
||||||
|
// Assert that the HTML body contains recurrence info
|
||||||
|
if let Some(ref html) = meta.body_html {
|
||||||
|
if !(html.contains("Repeats") || html.contains("recurr") || html.contains("RRULE")) {
|
||||||
|
println!("FAIL: html did not contain recurrence info:\n{}", html);
|
||||||
|
}
|
||||||
|
assert!(html.contains("Repeats") || html.contains("recurr") || html.contains("RRULE"), "Recurrence info should be present in HTML");
|
||||||
|
} else {
|
||||||
|
panic!("No body_html rendered");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recurring_event_rrule_metadata_and_highlight() {
|
fn recurring_event_rrule_metadata_and_highlight() {
|
||||||
use super::render_ical_summary;
|
use super::render_ical_summary;
|
||||||
|
|||||||
@ -76,11 +76,13 @@
|
|||||||
{% for day in week %}
|
{% for day in week %}
|
||||||
{% if event_days.contains(day) && today.is_some() && today.unwrap() == day %}
|
{% if event_days.contains(day) && today.is_some() && today.unwrap() == day %}
|
||||||
<td
|
<td
|
||||||
|
data-event-day="{{ day.format("%Y-%m-%d") }}"
|
||||||
style="background:#ffd700; color:#222; font-weight:bold; border:2px solid #2196f3; border-radius:4px; text-align:center; box-shadow:0 0 0 2px #2196f3;">
|
style="background:#ffd700; color:#222; font-weight:bold; border:2px solid #2196f3; border-radius:4px; text-align:center; box-shadow:0 0 0 2px #2196f3;">
|
||||||
{{ day.day() }}
|
{{ day.day() }}
|
||||||
</td>
|
</td>
|
||||||
{% elif event_days.contains(day) %}
|
{% elif event_days.contains(day) %}
|
||||||
<td
|
<td
|
||||||
|
data-event-day="{{ day.format("%Y-%m-%d") }}"
|
||||||
style="background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;">
|
style="background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;">
|
||||||
{{ day.day() }}
|
{{ day.day() }}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user