use anyhow::{Context, Result};
use reqwest::Client;
use super::types::{CalendarEvent, CalendarInfo};
pub struct CalDavClient {
client: Client,
base_url: String,
username: String,
password: String,
}
impl CalDavClient {
pub fn new(base_url: &str, username: &str, password: &str) -> Self {
Self {
client: Client::new(),
base_url: base_url.trim_end_matches('/').to_string(),
username: username.to_string(),
password: password.to_string(),
}
}
pub async fn list_calendars(&self) -> Result<Vec<CalendarInfo>> {
let url = format!("{}/", self.base_url);
let body = r#"<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:displayname/>
<D:resourcetype/>
<C:calendar-color/>
</D:prop>
</D:propfind>"#;
let resp = self
.client
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url)
.header("Depth", "1")
.header("Content-Type", "application/xml")
.basic_auth(&self.username, Some(&self.password))
.body(body)
.send()
.await
.context("CalDAV PROPFIND failed")?;
let text = resp.text().await?;
let calendars = Self::parse_propfind_calendars(&text);
Ok(calendars)
}
pub async fn list_events(
&self,
calendar_path: &str,
time_min: Option<&str>,
time_max: Option<&str>,
) -> Result<Vec<CalendarEvent>> {
let url = format!(
"{}/{}",
self.base_url,
calendar_path.trim_start_matches('/')
);
let time_range = match (time_min, time_max) {
(Some(min), Some(max)) => {
format!(r#"<C:time-range start="{}" end="{}"/>"#, min, max)
}
(Some(min), None) => format!(r#"<C:time-range start="{}"/>"#, min),
(None, Some(max)) => format!(r#"<C:time-range end="{}"/>"#, max),
(None, None) => String::new(),
};
let body = format!(
r#"<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
{}
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>"#,
time_range
);
let resp = self
.client
.request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url)
.header("Depth", "1")
.header("Content-Type", "application/xml")
.basic_auth(&self.username, Some(&self.password))
.body(body)
.send()
.await
.context("CalDAV REPORT failed")?;
let text = resp.text().await?;
let events = Self::parse_ical_events(&text);
Ok(events)
}
pub async fn create_event(&self, calendar_path: &str, event: &CalendarEvent) -> Result<()> {
let event_path = format!(
"{}/{}/{}.ics",
self.base_url,
calendar_path.trim_start_matches('/'),
&event.id
);
let ical = Self::event_to_ical(event);
let resp = self
.client
.put(&event_path)
.header("Content-Type", "text/calendar; charset=utf-8")
.basic_auth(&self.username, Some(&self.password))
.body(ical)
.send()
.await
.context("CalDAV PUT (create) failed")?;
let status = resp.status();
if status.is_success() || status.as_u16() == 201 {
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("CalDAV create failed ({}): {}", status, body)
}
}
pub async fn update_event(&self, calendar_path: &str, event: &CalendarEvent) -> Result<()> {
self.create_event(calendar_path, event).await
}
pub async fn delete_event(&self, calendar_path: &str, event_id: &str) -> Result<()> {
let event_path = format!(
"{}/{}/{}.ics",
self.base_url,
calendar_path.trim_start_matches('/'),
event_id
);
let resp = self
.client
.delete(&event_path)
.basic_auth(&self.username, Some(&self.password))
.send()
.await
.context("CalDAV DELETE failed")?;
let status = resp.status();
if status.is_success() || status.as_u16() == 204 {
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("CalDAV delete failed ({}): {}", status, body)
}
}
fn event_to_ical(event: &CalendarEvent) -> String {
let mut lines = vec![
"BEGIN:VCALENDAR".to_string(),
"VERSION:2.0".to_string(),
"PRODID:-//Brainwires//CalDAV Client//EN".to_string(),
"BEGIN:VEVENT".to_string(),
format!("UID:{}", event.id),
format!("SUMMARY:{}", event.title),
];
if event.all_day {
let start_date = event.start.split('T').next().unwrap_or(&event.start);
let end_date = event.end.split('T').next().unwrap_or(&event.end);
lines.push(format!(
"DTSTART;VALUE=DATE:{}",
start_date.replace('-', "")
));
lines.push(format!("DTEND;VALUE=DATE:{}", end_date.replace('-', "")));
} else {
lines.push(format!("DTSTART:{}", Self::rfc3339_to_ical(&event.start)));
lines.push(format!("DTEND:{}", Self::rfc3339_to_ical(&event.end)));
}
if let Some(ref desc) = event.description {
lines.push(format!("DESCRIPTION:{}", desc));
}
if let Some(ref loc) = event.location {
lines.push(format!("LOCATION:{}", loc));
}
for attendee in &event.attendees {
let name_param = attendee
.name
.as_ref()
.map(|n| format!(";CN={}", n))
.unwrap_or_default();
lines.push(format!("ATTENDEE{}:mailto:{}", name_param, attendee.email));
}
lines.push("END:VEVENT".to_string());
lines.push("END:VCALENDAR".to_string());
lines.join("\r\n")
}
fn rfc3339_to_ical(dt: &str) -> String {
dt.replace('-', "").replace(':', "")
}
fn parse_propfind_calendars(xml: &str) -> Vec<CalendarInfo> {
let mut calendars = Vec::new();
let mut idx = 0u32;
for line in xml.lines() {
let trimmed = line.trim();
if trimmed.contains("<displayname>") || trimmed.contains("<D:displayname>") {
let name = trimmed
.replace("<displayname>", "")
.replace("</displayname>", "")
.replace("<D:displayname>", "")
.replace("</D:displayname>", "")
.trim()
.to_string();
if !name.is_empty() {
calendars.push(CalendarInfo {
id: format!("calendar-{}", idx),
name,
color: None,
primary: idx == 0,
});
idx += 1;
}
}
}
calendars
}
fn parse_ical_events(xml: &str) -> Vec<CalendarEvent> {
let mut events = Vec::new();
let mut remaining = xml;
while let Some(start_pos) = remaining.find("BEGIN:VEVENT") {
if let Some(end_pos) = remaining[start_pos..].find("END:VEVENT") {
let vevent = &remaining[start_pos..start_pos + end_pos + "END:VEVENT".len()];
if let Some(event) = Self::parse_single_vevent(vevent) {
events.push(event);
}
remaining = &remaining[start_pos + end_pos + "END:VEVENT".len()..];
} else {
break;
}
}
events
}
fn parse_single_vevent(vevent: &str) -> Option<CalendarEvent> {
let get_prop = |name: &str| -> Option<String> {
for line in vevent.lines() {
let trimmed = line.trim();
if trimmed.starts_with(name) {
let value = if let Some(idx) = trimmed.find(':') {
&trimmed[idx + 1..]
} else {
continue;
};
return Some(value.to_string());
}
}
None
};
let uid = get_prop("UID")?;
let summary = get_prop("SUMMARY").unwrap_or_default();
let start = get_prop("DTSTART").unwrap_or_default();
let end = get_prop("DTEND").unwrap_or(start.clone());
let description = get_prop("DESCRIPTION");
let location = get_prop("LOCATION");
let all_day = vevent.contains("VALUE=DATE");
Some(CalendarEvent {
id: uid,
title: summary,
description,
location,
start,
end,
all_day,
attendees: vec![],
recurrence: None,
reminders: vec![],
calendar_id: None,
})
}
}