server: move calendar rendering to askama template
This commit is contained in:
parent
d16c221995
commit
5b48c5dbc3
@ -1091,6 +1091,18 @@ pub struct TlsReportTemplate<'a> {
|
|||||||
pub report: &'a FormattedTlsRpt,
|
pub report: &'a FormattedTlsRpt,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "ical_summary.html")]
|
||||||
|
pub struct IcalSummaryTemplate<'a> {
|
||||||
|
pub summary: &'a str,
|
||||||
|
pub local_fmt_start: &'a str,
|
||||||
|
pub local_fmt_end: &'a str,
|
||||||
|
pub organizer: &'a str,
|
||||||
|
pub organizer_cn: &'a str,
|
||||||
|
pub calendar_html: &'a str,
|
||||||
|
pub description_paragraphs: &'a [String],
|
||||||
|
}
|
||||||
|
|
||||||
// Add this helper function to parse the DMARC XML and summarize it.
|
// Add this helper function to parse the DMARC XML and summarize it.
|
||||||
pub fn parse_dmarc_report(xml: &str) -> Result<String, ServerError> {
|
pub fn parse_dmarc_report(xml: &str) -> Result<String, ServerError> {
|
||||||
let feedback: Feedback = xml_from_str(xml)
|
let feedback: Feedback = xml_from_str(xml)
|
||||||
@ -1298,7 +1310,6 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
|
|||||||
let mut parser = IcalParser::new(ical_data.as_bytes());
|
let mut parser = IcalParser::new(ical_data.as_bytes());
|
||||||
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 event_summary = String::new();
|
|
||||||
let mut summary = None;
|
let mut summary = None;
|
||||||
let mut description = None;
|
let mut description = None;
|
||||||
let mut dtstart = None;
|
let mut dtstart = None;
|
||||||
@ -1336,376 +1347,163 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse start/end as chrono DateTime
|
// Parse start/end as chrono DateTime
|
||||||
let (start_dt, end_dt, local_fmt_start, local_fmt_end, event_days) =
|
let (local_fmt_start, local_fmt_end, event_days) = if let Some(dtstart) = dtstart {
|
||||||
if let Some(dtstart) = dtstart {
|
let tz: Tz = tzid
|
||||||
let tz: Tz = tzid
|
.as_deref()
|
||||||
.as_deref()
|
.unwrap_or("UTC")
|
||||||
.unwrap_or("UTC")
|
.parse()
|
||||||
.parse()
|
.unwrap_or(chrono_tz::UTC);
|
||||||
.unwrap_or(chrono_tz::UTC);
|
let fallback =
|
||||||
let fallback = tz.from_utc_datetime(
|
tz.from_utc_datetime(&chrono::NaiveDateTime::from_timestamp_opt(0, 0).unwrap());
|
||||||
&chrono::NaiveDateTime::from_timestamp_opt(0, 0).unwrap(),
|
let start = parse_ical_datetime_tz(dtstart, tz).unwrap_or(fallback);
|
||||||
);
|
let end = dtend
|
||||||
let start = parse_ical_datetime_tz(dtstart, tz).unwrap_or(fallback);
|
.and_then(|d| parse_ical_datetime_tz(d, tz))
|
||||||
let end = dtend
|
.unwrap_or(start);
|
||||||
.and_then(|d| parse_ical_datetime_tz(d, tz))
|
let local_start = start.with_timezone(&Local);
|
||||||
.unwrap_or(start);
|
let local_end = end.with_timezone(&Local);
|
||||||
let local_start = start.with_timezone(&Local);
|
let fmt_start = local_start.format("%c").to_string();
|
||||||
let local_end = end.with_timezone(&Local);
|
let fmt_end = local_end.format("%c").to_string();
|
||||||
let fmt_start = local_start.format("%c").to_string();
|
let mut days = vec![];
|
||||||
let fmt_end = local_end.format("%c").to_string();
|
let mut d = start.date_naive();
|
||||||
let mut days = vec![];
|
let mut end_d = end.date_naive();
|
||||||
let mut d = start.date_naive();
|
// Check for all-day event (DATE, not DATE-TIME)
|
||||||
let mut end_d = end.date_naive();
|
let allday = dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false));
|
||||||
// Check for all-day event (DATE, not DATE-TIME)
|
if allday {
|
||||||
let allday =
|
// DTEND is exclusive for all-day events
|
||||||
dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false));
|
if end_d > d {
|
||||||
if allday {
|
end_d = end_d.pred_opt().unwrap();
|
||||||
// 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![])
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render a single merged calendar table for all spanned months
|
|
||||||
let mut calendar_html = String::new();
|
|
||||||
if !event_days.is_empty() {
|
|
||||||
use chrono::Datelike;
|
|
||||||
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();
|
|
||||||
// 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()
|
|
||||||
};
|
|
||||||
next_month.pred_opt().unwrap()
|
|
||||||
};
|
|
||||||
// Find the first Sunday before or on the first day of the first event month
|
|
||||||
let mut cal_start = first_of_month;
|
|
||||||
while cal_start.weekday() != chrono::Weekday::Sun {
|
|
||||||
cal_start = cal_start.pred_opt().unwrap();
|
|
||||||
}
|
|
||||||
// Find the last Saturday after or on the last day of the last event month
|
|
||||||
let mut cal_end = last_of_month;
|
|
||||||
while cal_end.weekday() != chrono::Weekday::Sat {
|
|
||||||
cal_end = cal_end.succ_opt().unwrap();
|
|
||||||
}
|
|
||||||
// Collect all days in the calendar range
|
|
||||||
let mut all_days = vec![];
|
|
||||||
let mut d = cal_start;
|
|
||||||
while d <= cal_end {
|
|
||||||
all_days.push(d);
|
|
||||||
d = d.succ_opt().unwrap();
|
|
||||||
}
|
|
||||||
// Table header: show month/year range
|
|
||||||
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)
|
|
||||||
};
|
|
||||||
calendar_html.push_str(&format!(
|
|
||||||
"<table class='ical-month' style='border-collapse:collapse; min-width:220px; background:#fff; box-shadow:0 2px 8px #bbb; font-size:14px; margin:0; float:right;'>"
|
|
||||||
));
|
|
||||||
calendar_html.push_str(&format!("<caption style='caption-side:top; text-align:center; font-weight:bold; font-size:16px; padding:8px 0;'>{}</caption>", caption));
|
|
||||||
calendar_html.push_str("<thead><tr>");
|
|
||||||
for wd in ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] {
|
|
||||||
calendar_html.push_str(&format!("<th style='padding:4px 6px; border-bottom:1px solid #ccc; color:#666; font-weight:600; background:#f7f7f7'>{}</th>", wd));
|
|
||||||
}
|
|
||||||
calendar_html.push_str("</tr></thead><tbody>");
|
|
||||||
for week in all_days.chunks(7) {
|
|
||||||
calendar_html.push_str("<tr>");
|
|
||||||
for day in week {
|
|
||||||
#[cfg(test)]
|
|
||||||
println!("CAL DAY: {} is_event: {}", day, event_days.contains(day));
|
|
||||||
let is_event = event_days.contains(day);
|
|
||||||
let style = if is_event {
|
|
||||||
"background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;"
|
|
||||||
} else {
|
|
||||||
"border:1px solid #eee; text-align:center;background:#f7f7f7;color:#bbb;"
|
|
||||||
};
|
|
||||||
calendar_html.push_str(&format!(
|
|
||||||
"<td style='{}'>{}</td>",
|
|
||||||
style,
|
|
||||||
day.day()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
calendar_html.push_str("</tr>");
|
|
||||||
}
|
|
||||||
calendar_html.push_str("</tbody></table>");
|
|
||||||
}
|
|
||||||
// Remove block-level summary wrapper, move its styles to the flex child
|
|
||||||
let summary_inner = {
|
|
||||||
let mut s = String::new();
|
|
||||||
s.push_str("<div style='font-size:17px; font-weight:bold; margin-bottom:8px; color:#333;'><b>Summary:</b> ");
|
|
||||||
if let Some(summary) = summary {
|
|
||||||
s.push_str(summary);
|
|
||||||
}
|
|
||||||
s.push_str("</div>");
|
|
||||||
s.push_str(&format!(
|
|
||||||
"<div style='margin-bottom:4px;'><b>Start:</b> {}</div>",
|
|
||||||
local_fmt_start
|
|
||||||
));
|
|
||||||
s.push_str(&format!(
|
|
||||||
"<div style='margin-bottom:4px;'><b>End:</b> {}</div>",
|
|
||||||
local_fmt_end
|
|
||||||
));
|
|
||||||
if let Some(cn) = organizer_cn {
|
|
||||||
s.push_str(&format!(
|
|
||||||
"<div style='margin-bottom:4px;'><b>Organizer:</b> {}</div>",
|
|
||||||
cn
|
|
||||||
));
|
|
||||||
} else if let Some(organizer) = organizer {
|
|
||||||
s.push_str(&format!(
|
|
||||||
"<div style='margin-bottom:4px;'><b>Organizer:</b> {}</div>",
|
|
||||||
organizer
|
|
||||||
));
|
|
||||||
}
|
|
||||||
s
|
|
||||||
};
|
|
||||||
|
|
||||||
// Responsive layout: row with wrap by default, summary expands, calendar right-justified
|
|
||||||
let event_block = format!(
|
|
||||||
"<style>.ical-flex {{ display: flex; flex-direction: row; flex-wrap: wrap; align-items: stretch; gap: 0.5em; max-width: 700px; width: 100%; }}\n.ical-flex .summary-block {{ flex: 1 1 0%; }}\n.ical-flex .calendar-block {{ flex: none; margin-left: auto; }}\n@media (max-width: 599px) {{ .ical-flex {{ flex-direction: column; }} .ical-flex > div.summary-block {{ margin-bottom: 0.5em; margin-left: 0; }} .ical-flex > div.calendar-block {{ margin-left: 0; }} }}</style>\
|
|
||||||
<div class='ical-flex'>\
|
|
||||||
<div class='summary-block' style='background:#f7f7f7; border-radius:8px; box-shadow:0 2px 8px #bbb; padding:16px 18px; margin:0 0 8px 0; min-width:220px; max-width:400px; font-size:15px; color:#222;'>{}</div>\
|
|
||||||
{}\
|
|
||||||
</div>",
|
|
||||||
summary_inner,
|
|
||||||
if !calendar_html.is_empty() {
|
|
||||||
format!("<div class=\"calendar-block\" style=\"margin-top: 8px;\">{}</div>", calendar_html)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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<String> = desc
|
|
||||||
.lines()
|
|
||||||
.map(|line| line.trim())
|
|
||||||
.filter(|line| !line.is_empty())
|
|
||||||
.map(|line| {
|
|
||||||
format!("<p style='margin: 0 0 8px 0; color:#444;'>{}</p>", line)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
if !paragraphs.is_empty() {
|
|
||||||
desc_block = format!(
|
|
||||||
"<div style='max-width:700px; width:100%;'>{}</div>",
|
|
||||||
paragraphs.join("\n")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// Only include actual event days
|
||||||
summary_parts.push(format!("{}{}", event_block, desc_block));
|
let mut day_iter = d;
|
||||||
}
|
while day_iter <= end_d {
|
||||||
}
|
days.push(day_iter);
|
||||||
|
day_iter = day_iter.succ_opt().unwrap();
|
||||||
fn render_month_calendar_widget(date: NaiveDate, event_days: &[NaiveDate]) -> String {
|
}
|
||||||
let first = date.with_day(1).unwrap();
|
(fmt_start, fmt_end, days)
|
||||||
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(
|
|
||||||
"<table class='ical-month' style='border-collapse:collapse; min-width:220px; background:#fff; box-shadow:0 2px 8px #bbb; font-size:14px; margin:0; float:right;'>"
|
|
||||||
);
|
|
||||||
html.push_str(&format!("<caption style='caption-side:top; text-align:center; font-weight:bold; font-size:16px; padding:8px 0;'>{} {}</caption>", month_name, year));
|
|
||||||
html.push_str("<thead><tr>");
|
|
||||||
for wd in ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] {
|
|
||||||
html.push_str(&format!("<th style='padding:4px 6px; border-bottom:1px solid #ccc; color:#666; font-weight:600; background:#f7f7f7'>{}</th>", wd));
|
|
||||||
}
|
|
||||||
html.push_str("</tr></thead><tbody><tr>");
|
|
||||||
let mut cur = first;
|
|
||||||
let mut started = false;
|
|
||||||
for _ in 0..first.weekday().num_days_from_sunday() {
|
|
||||||
html.push_str("<td style='background:#f7f7f7'></td>");
|
|
||||||
}
|
|
||||||
while cur.month() == first.month() {
|
|
||||||
if cur.weekday() == chrono::Weekday::Sun && started {
|
|
||||||
html.push_str("</tr><tr>");
|
|
||||||
}
|
|
||||||
started = true;
|
|
||||||
if event_days.contains(&cur) {
|
|
||||||
html.push_str(&format!("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>{}</td>", cur.day()));
|
|
||||||
} else {
|
} else {
|
||||||
html.push_str(&format!(
|
(String::new(), String::new(), vec![])
|
||||||
"<td style='border:1px solid #eee; text-align:center;'>{}</td>",
|
|
||||||
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("<td style='background:#f7f7f7'></td>");
|
|
||||||
}
|
|
||||||
html.push_str("</tr></tbody></table>");
|
|
||||||
html
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_ical_datetime_tz(dt: &str, tz: Tz) -> Option<chrono::DateTime<Tz>> {
|
// Render calendar table HTML
|
||||||
let dt = dt.split(':').last().unwrap_or(dt);
|
let calendar_html = if !event_days.is_empty() {
|
||||||
if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(dt, "%Y%m%dT%H%M%SZ") {
|
render_merged_calendar_table(&event_days)
|
||||||
Some(tz.from_utc_datetime(&ndt))
|
} else {
|
||||||
} else if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(dt, "%Y%m%dT%H%M%S") {
|
String::new()
|
||||||
match tz.from_local_datetime(&ndt) {
|
};
|
||||||
LocalResult::Single(dt) => Some(dt),
|
|
||||||
_ => None,
|
// Description paragraphs
|
||||||
}
|
let description_paragraphs: Vec<String> = if let Some(desc) = description {
|
||||||
} else if let Ok(nd) = chrono::NaiveDate::parse_from_str(dt, "%Y%m%d") {
|
let desc = desc.replace("\\n", "\n");
|
||||||
// All-day event: treat as midnight in local time
|
desc.lines()
|
||||||
let ndt = nd.and_hms_opt(0, 0, 0).unwrap();
|
.map(|line| line.trim().to_string())
|
||||||
match tz.from_local_datetime(&ndt) {
|
.filter(|line| !line.is_empty())
|
||||||
LocalResult::Single(dt) => Some(dt),
|
.collect::<Vec<_>>()
|
||||||
_ => None,
|
} else {
|
||||||
}
|
Vec::new()
|
||||||
} else {
|
};
|
||||||
None
|
|
||||||
|
let summary_val = summary.unwrap_or("");
|
||||||
|
let organizer_val = organizer.unwrap_or("");
|
||||||
|
let organizer_cn_val = organizer_cn.unwrap_or("");
|
||||||
|
let local_fmt_start_val = &local_fmt_start;
|
||||||
|
let local_fmt_end_val = &local_fmt_end;
|
||||||
|
let calendar_html_val = &calendar_html;
|
||||||
|
let description_paragraphs_val = &description_paragraphs;
|
||||||
|
let template = IcalSummaryTemplate {
|
||||||
|
summary: summary_val,
|
||||||
|
local_fmt_start: local_fmt_start_val,
|
||||||
|
local_fmt_end: local_fmt_end_val,
|
||||||
|
organizer: organizer_val,
|
||||||
|
organizer_cn: organizer_cn_val,
|
||||||
|
calendar_html: calendar_html_val,
|
||||||
|
description_paragraphs: description_paragraphs_val,
|
||||||
|
};
|
||||||
|
summary_parts.push(template.render()?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_ical_datetime(dt: &str) -> Option<String> {
|
|
||||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
|
||||||
let dt = dt.split(':').last().unwrap_or(dt);
|
|
||||||
if let Ok(ndt) = NaiveDateTime::parse_from_str(dt, "%Y%m%dT%H%M%SZ") {
|
|
||||||
let dt_utc: DateTime<Utc> = TimeZone::from_utc_datetime(&Utc, &ndt);
|
|
||||||
return Some(dt_utc.format("%Y-%m-%dT%H:%M:%SZ").to_string());
|
|
||||||
}
|
|
||||||
if let Ok(ndt) = NaiveDateTime::parse_from_str(dt, "%Y%m%dT%H%M%S") {
|
|
||||||
return Some(ndt.format("%Y-%m-%dT%H:%M:%S").to_string());
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(summary_parts.join("<hr>"))
|
Ok(summary_parts.join("<hr>"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
fn render_merged_calendar_table(event_days: &[NaiveDate]) -> String {
|
||||||
mod tests {
|
use chrono::Datelike;
|
||||||
use std::fs;
|
let first_event = event_days.first().unwrap();
|
||||||
|
let last_event = event_days.last().unwrap();
|
||||||
use super::*;
|
let first_of_month =
|
||||||
|
NaiveDate::from_ymd_opt(first_event.year(), first_event.month(), 1).unwrap();
|
||||||
#[test]
|
let last_of_month = {
|
||||||
fn test_parse_dmarc_report() {
|
let next_month = if last_event.month() == 12 {
|
||||||
let xml = fs::read_to_string("testdata/dmarc-example.xml").unwrap();
|
NaiveDate::from_ymd_opt(last_event.year() + 1, 1, 1).unwrap()
|
||||||
let html = parse_dmarc_report(&xml).unwrap();
|
} else {
|
||||||
assert!(html.contains("hotmail.com"));
|
NaiveDate::from_ymd_opt(last_event.year(), last_event.month() + 1, 1).unwrap()
|
||||||
assert!(html.contains("msn.com"));
|
};
|
||||||
|
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;
|
||||||
#[test]
|
while cal_end.weekday() != chrono::Weekday::Sat {
|
||||||
fn test_parse_dmarc_report_no_envelope_to() {
|
cal_end = cal_end.succ_opt().unwrap();
|
||||||
let xml = fs::read_to_string("testdata/dmarc-example-no-envelope-to.xml").unwrap();
|
|
||||||
let html = parse_dmarc_report(&xml).unwrap();
|
|
||||||
assert!(!html.contains("Envelope To"));
|
|
||||||
}
|
}
|
||||||
|
let mut all_days = vec![];
|
||||||
#[test]
|
let mut d = cal_start;
|
||||||
fn test_ical_render() {
|
while d <= cal_end {
|
||||||
let ical = fs::read_to_string("testdata/ical-example-1.ics").unwrap();
|
all_days.push(d);
|
||||||
let html = render_ical_summary(&ical).unwrap();
|
d = d.succ_opt().unwrap();
|
||||||
assert!(html.contains("<b>Summary:</b> dentist night guard"));
|
|
||||||
assert!(html.contains("<b>Start:</b> "));
|
|
||||||
assert!(html.contains("<b>End:</b> "));
|
|
||||||
assert!(html.contains("<b>Organizer:</b> Bill Thiede"));
|
|
||||||
}
|
}
|
||||||
|
let start_month = first_event.format("%B %Y");
|
||||||
#[test]
|
let end_month = last_event.format("%B %Y");
|
||||||
fn test_ical_render_2() {
|
let caption = if start_month.to_string() == end_month.to_string() {
|
||||||
let ical = fs::read_to_string("testdata/ical-example-2.ics").unwrap();
|
start_month.to_string()
|
||||||
let html = render_ical_summary(&ical).unwrap();
|
} else {
|
||||||
println!("HTML OUTPUT: {}", html);
|
format!("{} – {}", start_month, end_month)
|
||||||
assert!(html.contains("<b>Summary:</b> [tenative] dinner w/ amatute"));
|
};
|
||||||
assert!(html.contains("<b>Start:</b> "));
|
let mut calendar_html = String::new();
|
||||||
assert!(html.contains("<b>End:</b> "));
|
calendar_html.push_str(&format!(
|
||||||
assert!(html.contains("<b>Organizer:</b> Family"));
|
"<table class='ical-month' style='border-collapse:collapse; min-width:220px; background:#fff; box-shadow:0 2px 8px #bbb; font-size:14px; margin:0;'>"
|
||||||
|
));
|
||||||
|
calendar_html.push_str(&format!("<caption style='caption-side:top; text-align:center; font-weight:bold; font-size:16px; padding:8px 0;'>{}</caption>", caption));
|
||||||
|
calendar_html.push_str("<thead><tr>");
|
||||||
|
for wd in ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] {
|
||||||
|
calendar_html.push_str(&format!("<th style='padding:4px 6px; border-bottom:1px solid #ccc; color:#666; font-weight:600; background:#f7f7f7'>{}</th>", wd));
|
||||||
}
|
}
|
||||||
|
calendar_html.push_str("</tr></thead><tbody>");
|
||||||
#[test]
|
for week in all_days.chunks(7) {
|
||||||
fn test_ical_multiday() {
|
calendar_html.push_str("<tr>");
|
||||||
let ical = fs::read_to_string("testdata/ical-multiday.ics").unwrap();
|
for day in week {
|
||||||
let html = render_ical_summary(&ical).unwrap();
|
let is_event = event_days.contains(day);
|
||||||
println!("HTML MULTIDAY: {}", html);
|
let style = if is_event {
|
||||||
// Print the calendar row containing 28, 29, 30 for debug
|
"background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;"
|
||||||
if let Some(row_start) = html.find("<tr>") {
|
} else {
|
||||||
if let Some(row_end) = html[row_start..].find("</tr>") {
|
"border:1px solid #eee; text-align:center;background:#f7f7f7;color:#bbb;"
|
||||||
println!(
|
};
|
||||||
"CALENDAR ROW: {}",
|
calendar_html.push_str(&format!("<td style='{}'>{}</td>", style, day.day()));
|
||||||
&html[row_start..row_start + row_end + 5]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(html.contains("<b>Summary:</b> 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!("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>{}</td>", day)), "Missing highlighted day {}", day);
|
|
||||||
}
|
|
||||||
for day in [27, 31] {
|
|
||||||
assert!(html.contains(&format!("<td style='border:1px solid #eee; text-align:center;background:#f7f7f7;color:#bbb;'>{}</td>", day)), "Missing dimmed day {}", day);
|
|
||||||
}
|
}
|
||||||
|
calendar_html.push_str("</tr>");
|
||||||
}
|
}
|
||||||
|
calendar_html.push_str("</tbody></table>");
|
||||||
|
calendar_html
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
fn parse_ical_datetime_tz(dt: &str, tz: Tz) -> Option<chrono::DateTime<Tz>> {
|
||||||
fn test_ical_straddle() {
|
let dt = dt.split(':').last().unwrap_or(dt);
|
||||||
let ical = fs::read_to_string("testdata/ical-straddle.ics").unwrap();
|
if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(dt, "%Y%m%dT%H%M%SZ") {
|
||||||
let html = render_ical_summary(&ical).unwrap();
|
Some(tz.from_utc_datetime(&ndt))
|
||||||
println!("HTML STRADDLE: {}", html);
|
} else if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(dt, "%Y%m%dT%H%M%S") {
|
||||||
assert!(html.contains("<b>Summary:</b> Straddle Month Event"));
|
match tz.from_local_datetime(&ndt) {
|
||||||
assert!(html.contains("This event straddles two months"));
|
LocalResult::Single(dt) => Some(dt),
|
||||||
// Should highlight 30, 31 in August and 1, 2 in September
|
_ => None,
|
||||||
assert!(html.contains("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>30</td>"));
|
}
|
||||||
assert!(html.contains("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>31</td>"));
|
} else if let Ok(nd) = chrono::NaiveDate::parse_from_str(dt, "%Y%m%d") {
|
||||||
assert!(html.contains("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>1</td>"));
|
// All-day event: treat as midnight in local time
|
||||||
assert!(html.contains("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>2</td>"));
|
let ndt = nd.and_hms_opt(0, 0, 0).unwrap();
|
||||||
}
|
match tz.from_local_datetime(&ndt) {
|
||||||
|
LocalResult::Single(dt) => Some(dt),
|
||||||
#[test]
|
_ => None,
|
||||||
fn test_ical_straddle_real() {
|
}
|
||||||
let ical = fs::read_to_string("testdata/ical-straddle-real.ics").unwrap();
|
} else {
|
||||||
let html = render_ical_summary(&ical).unwrap();
|
None
|
||||||
println!("HTML STRADDLE REAL: {}", html);
|
|
||||||
// Should highlight 30, 31 in August and 1 in September (DTEND is exclusive)
|
|
||||||
assert!(html.contains("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>30</td>"));
|
|
||||||
assert!(html.contains("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>31</td>"));
|
|
||||||
assert!(html.contains("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>1</td>"));
|
|
||||||
assert!(!html.contains("<td style='background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;'>2</td>"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
use askama::Template;
|
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "dmarc_report.html")]
|
|
||||||
pub struct DmarcReportTemplate<'a> {
|
|
||||||
pub feedback: &'a crate::nm::Feedback,
|
|
||||||
}
|
|
||||||
55
server/templates/ical_summary.html
Normal file
55
server/templates/ical_summary.html
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
|
||||||
|
<style>
|
||||||
|
.ical-flex {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.5em;
|
||||||
|
max-width: 700px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.ical-flex .summary-block {
|
||||||
|
flex: 1 1 0%;
|
||||||
|
}
|
||||||
|
.ical-flex .calendar-block {
|
||||||
|
flex: none;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.ical-flex {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.ical-flex > div.summary-block {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.ical-flex > div.calendar-block {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="ical-flex">
|
||||||
|
<div class="summary-block" style="background:#f7f7f7; border-radius:8px; box-shadow:0 2px 8px #bbb; padding:16px 18px; margin:0 0 8px 0; min-width:220px; max-width:400px; font-size:15px; color:#222;">
|
||||||
|
<div style="font-size:17px; font-weight:bold; margin-bottom:8px; color:#333;"><b>Summary:</b> {{ summary }}</div>
|
||||||
|
<div style="margin-bottom:4px;"><b>Start:</b> {{ local_fmt_start }}</div>
|
||||||
|
<div style="margin-bottom:4px;"><b>End:</b> {{ local_fmt_end }}</div>
|
||||||
|
{% if !organizer_cn.is_empty() %}
|
||||||
|
<div style="margin-bottom:4px;"><b>Organizer:</b> {{ organizer_cn }}</div>
|
||||||
|
{% elif !organizer.is_empty() %}
|
||||||
|
<div style="margin-bottom:4px;"><b>Organizer:</b> {{ organizer }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if !calendar_html.is_empty() %}
|
||||||
|
{% if !calendar_html.is_empty() %}
|
||||||
|
<div class="calendar-block" style="margin-top: 8px;">{{ calendar_html | safe }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if !description_paragraphs.is_empty() %}
|
||||||
|
<div style="max-width:700px; width:100%;">
|
||||||
|
{% for p in description_paragraphs %}
|
||||||
|
<p style="margin: 0 0 8px 0; color:#444;">{{ p }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
Loading…
x
Reference in New Issue
Block a user