server: include a calendar widget showing the calendar event
This commit is contained in:
parent
a8a5089ed3
commit
49e93829dd
32
Cargo.lock
generated
32
Cargo.lock
generated
@ -969,6 +969,28 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.4.4"
|
version = "0.4.4"
|
||||||
@ -3271,6 +3293,7 @@ dependencies = [
|
|||||||
"build-info-build",
|
"build-info-build",
|
||||||
"cacher",
|
"cacher",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"chrono-tz",
|
||||||
"clap",
|
"clap",
|
||||||
"css-inline",
|
"css-inline",
|
||||||
"flate2",
|
"flate2",
|
||||||
@ -4320,6 +4343,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"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]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
|
|||||||
@ -12,6 +12,7 @@ version.workspace = true
|
|||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono-tz = "0.8"
|
||||||
html2text = "0.6"
|
html2text = "0.6"
|
||||||
ammonia = "4.1.0"
|
ammonia = "4.1.0"
|
||||||
anyhow = "1.0.98"
|
anyhow = "1.0.98"
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
use std::io::{Cursor, Read};
|
use std::io::{Cursor, Read};
|
||||||
|
|
||||||
use askama::Template;
|
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 mailparse::{parse_content_type, parse_mail, MailHeader, MailHeaderMap, ParsedMail};
|
||||||
use quick_xml::de::from_str as xml_from_str;
|
use quick_xml::de::from_str as xml_from_str;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
@ -1303,10 +1304,20 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
|
|||||||
let mut dtend = None;
|
let mut dtend = None;
|
||||||
let mut organizer = None;
|
let mut organizer = None;
|
||||||
let mut organizer_cn = None;
|
let mut organizer_cn = None;
|
||||||
|
let mut tzid: 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(),
|
||||||
"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(),
|
"DTEND" => dtend = prop.value.as_deref(),
|
||||||
"ORGANIZER" => {
|
"ORGANIZER" => {
|
||||||
organizer = prop.value.as_deref();
|
organizer = prop.value.as_deref();
|
||||||
@ -1321,17 +1332,40 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if let Some(summary) = summary {
|
||||||
event_summary.push_str(&format!("<b>Summary:</b> {}<br>", summary));
|
event_summary.push_str(&format!("<b>Summary:</b> {}<br>", summary));
|
||||||
}
|
}
|
||||||
if let Some(dtstart) = dtstart {
|
event_summary.push_str(&format!("<b>Start:</b> {}<br>", local_fmt_start));
|
||||||
let formatted = parse_ical_datetime(dtstart).unwrap_or_else(|| dtstart.to_string());
|
event_summary.push_str(&format!("<b>End:</b> {}<br>", local_fmt_end));
|
||||||
event_summary.push_str(&format!("<b>Start:</b> {}<br>", formatted));
|
|
||||||
}
|
|
||||||
if let Some(dtend) = dtend {
|
|
||||||
let formatted = parse_ical_datetime(dtend).unwrap_or_else(|| dtend.to_string());
|
|
||||||
event_summary.push_str(&format!("<b>End:</b> {}<br>", formatted));
|
|
||||||
}
|
|
||||||
if let Some(cn) = organizer_cn {
|
if let Some(cn) = organizer_cn {
|
||||||
event_summary.push_str(&format!("<b>Organizer:</b> {}<br>", cn));
|
event_summary.push_str(&format!("<b>Organizer:</b> {}<br>", cn));
|
||||||
} else if let Some(organizer) = organizer {
|
} else if let Some(organizer) = organizer {
|
||||||
@ -1341,6 +1375,53 @@ pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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("<table class='ical-month'><thead><tr>");
|
||||||
|
for wd in ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] {
|
||||||
|
html.push_str(&format!("<th>{}</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></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 class='ical-event-day' style='background: #ffd700; font-weight: bold'>{}</td>", cur.day()));
|
||||||
|
} else {
|
||||||
|
html.push_str(&format!("<td>{}</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></td>");
|
||||||
|
}
|
||||||
|
html.push_str("</tr></tbody></table>");
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ical_datetime_tz(dt: &str, tz: Tz) -> Option<chrono::DateTime<Tz>> {
|
||||||
|
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<String> {
|
fn parse_ical_datetime(dt: &str) -> Option<String> {
|
||||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
let dt = dt.split(':').last().unwrap_or(dt);
|
let dt = dt.split(':').last().unwrap_or(dt);
|
||||||
@ -1382,10 +1463,10 @@ mod tests {
|
|||||||
fn test_ical_render() {
|
fn test_ical_render() {
|
||||||
let ical = fs::read_to_string("testdata/ical-example-1.ics").unwrap();
|
let ical = fs::read_to_string("testdata/ical-example-1.ics").unwrap();
|
||||||
let html = render_ical_summary(&ical).unwrap();
|
let html = render_ical_summary(&ical).unwrap();
|
||||||
assert!(html.contains("<b>Summary:</b> dentist night guard<br>"));
|
assert!(html.contains("<b>Summary:</b> dentist night guard<br>"));
|
||||||
assert!(html.contains("<b>Start:</b> 2025-01-08T08:00:00<br>"));
|
assert!(html.contains("<b>Start:</b> "));
|
||||||
assert!(html.contains("<b>End:</b> 2025-01-08T09:00:00<br>"));
|
assert!(html.contains("<b>End:</b> "));
|
||||||
assert!(html.contains("<b>Organizer:</b> Bill Thiede<br>"));
|
assert!(html.contains("<b>Organizer:</b> Bill Thiede<br>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1393,9 +1474,9 @@ mod tests {
|
|||||||
let ical = fs::read_to_string("testdata/ical-example-2.ics").unwrap();
|
let ical = fs::read_to_string("testdata/ical-example-2.ics").unwrap();
|
||||||
let html = render_ical_summary(&ical).unwrap();
|
let html = render_ical_summary(&ical).unwrap();
|
||||||
println!("HTML OUTPUT: {}", html);
|
println!("HTML OUTPUT: {}", html);
|
||||||
assert!(html.contains("<b>Summary:</b> [tenative] dinner w/ amatute<br>"));
|
assert!(html.contains("<b>Summary:</b> [tenative] dinner w/ amatute<br>"));
|
||||||
assert!(html.contains("<b>Start:</b> 2025-08-13T01:00:00Z<br>"));
|
assert!(html.contains("<b>Start:</b> "));
|
||||||
assert!(html.contains("<b>End:</b> 2025-08-13T03:00:00Z<br>"));
|
assert!(html.contains("<b>End:</b> "));
|
||||||
assert!(html.contains("<b>Organizer:</b> Family<br>"));
|
assert!(html.contains("<b>Organizer:</b> Family<br>"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user