Frontend Changes: - Add DeleteAction enum with DeleteThis, DeleteFollowing, DeleteSeries options - Update EventContextMenu to show different delete options for recurring events - Add exception_dates field to CalendarEvent struct - Fix occurrence generation to respect EXDATE exclusions - Add comprehensive RRULE parsing with UNTIL date support - Fix UNTIL date parsing to handle backend format (YYYYMMDDTHHMMSSZ) - Enhanced debugging for RRULE processing and occurrence generation Backend Changes: - Add exception_dates field to CalendarEvent struct with EXDATE parsing/generation - Implement update_event method for CalDAV client - Add fetch_event_by_href helper function - Update DeleteEventRequest model with delete_action and occurrence_date fields - Implement proper delete_this logic with EXDATE addition - Implement delete_following logic with RRULE UNTIL modification - Add comprehensive logging for delete operations CalDAV Integration: - Proper EXDATE generation in iCal format for excluded occurrences - RRULE modification with UNTIL clause for partial series deletion - Event updating via CalDAV PUT operations - Full iCal RFC 5545 compliance for recurring event modifications 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
856 lines
31 KiB
Rust
856 lines
31 KiB
Rust
use chrono::{DateTime, Utc, NaiveDate, Datelike, Weekday, Duration, TimeZone};
|
|
use serde::{Deserialize, Serialize};
|
|
use wasm_bindgen::JsCast;
|
|
use wasm_bindgen_futures::JsFuture;
|
|
use web_sys::{Request, RequestInit, RequestMode, Response};
|
|
use std::collections::HashMap;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct EventReminder {
|
|
pub minutes_before: i32,
|
|
pub action: ReminderAction,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum ReminderAction {
|
|
Display,
|
|
Email,
|
|
Audio,
|
|
}
|
|
|
|
impl Default for ReminderAction {
|
|
fn default() -> Self {
|
|
ReminderAction::Display
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct UserInfo {
|
|
pub username: String,
|
|
pub server_url: String,
|
|
pub calendars: Vec<CalendarInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct CalendarInfo {
|
|
pub path: String,
|
|
pub display_name: String,
|
|
pub color: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct CalendarEvent {
|
|
pub uid: String,
|
|
pub summary: Option<String>,
|
|
pub description: Option<String>,
|
|
pub start: DateTime<Utc>,
|
|
pub end: Option<DateTime<Utc>>,
|
|
pub location: Option<String>,
|
|
pub status: EventStatus,
|
|
pub class: EventClass,
|
|
pub priority: Option<u8>,
|
|
pub organizer: Option<String>,
|
|
pub attendees: Vec<String>,
|
|
pub categories: Vec<String>,
|
|
pub created: Option<DateTime<Utc>>,
|
|
pub last_modified: Option<DateTime<Utc>>,
|
|
pub recurrence_rule: Option<String>,
|
|
pub exception_dates: Vec<DateTime<Utc>>,
|
|
pub all_day: bool,
|
|
pub reminders: Vec<EventReminder>,
|
|
pub etag: Option<String>,
|
|
pub href: Option<String>,
|
|
pub calendar_path: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum EventStatus {
|
|
Tentative,
|
|
Confirmed,
|
|
Cancelled,
|
|
}
|
|
|
|
impl Default for EventStatus {
|
|
fn default() -> Self {
|
|
EventStatus::Confirmed
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum EventClass {
|
|
Public,
|
|
Private,
|
|
Confidential,
|
|
}
|
|
|
|
impl Default for EventClass {
|
|
fn default() -> Self {
|
|
EventClass::Public
|
|
}
|
|
}
|
|
|
|
impl CalendarEvent {
|
|
/// Get the date for this event (for calendar display)
|
|
pub fn get_date(&self) -> NaiveDate {
|
|
if self.all_day {
|
|
self.start.date_naive()
|
|
} else {
|
|
self.start.date_naive()
|
|
}
|
|
}
|
|
|
|
/// Get display title for the event
|
|
pub fn get_title(&self) -> String {
|
|
self.summary.clone().unwrap_or_else(|| "Untitled Event".to_string())
|
|
}
|
|
|
|
/// Get display string for status
|
|
pub fn get_status_display(&self) -> &'static str {
|
|
match self.status {
|
|
EventStatus::Tentative => "Tentative",
|
|
EventStatus::Confirmed => "Confirmed",
|
|
EventStatus::Cancelled => "Cancelled",
|
|
}
|
|
}
|
|
|
|
/// Get display string for class
|
|
pub fn get_class_display(&self) -> &'static str {
|
|
match self.class {
|
|
EventClass::Public => "Public",
|
|
EventClass::Private => "Private",
|
|
EventClass::Confidential => "Confidential",
|
|
}
|
|
}
|
|
|
|
/// Get display string for priority
|
|
pub fn get_priority_display(&self) -> String {
|
|
match self.priority {
|
|
None => "Not set".to_string(),
|
|
Some(0) => "Undefined".to_string(),
|
|
Some(1) => "High".to_string(),
|
|
Some(p) if p <= 4 => "High".to_string(),
|
|
Some(5) => "Medium".to_string(),
|
|
Some(p) if p <= 8 => "Low".to_string(),
|
|
Some(9) => "Low".to_string(),
|
|
Some(p) => format!("Priority {}", p),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct CalendarService {
|
|
base_url: String,
|
|
}
|
|
|
|
impl CalendarService {
|
|
pub fn new() -> Self {
|
|
let base_url = option_env!("BACKEND_API_URL")
|
|
.unwrap_or("http://localhost:3000/api")
|
|
.to_string();
|
|
|
|
Self { base_url }
|
|
}
|
|
|
|
/// Fetch user info including available calendars
|
|
pub async fn fetch_user_info(&self, token: &str, password: &str) -> Result<UserInfo, String> {
|
|
let window = web_sys::window().ok_or("No global window exists")?;
|
|
|
|
let opts = RequestInit::new();
|
|
opts.set_method("GET");
|
|
opts.set_mode(RequestMode::Cors);
|
|
|
|
let url = format!("{}/user/info", self.base_url);
|
|
let request = Request::new_with_str_and_init(&url, &opts)
|
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
|
|
|
request.headers().set("Authorization", &format!("Bearer {}", token))
|
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
|
|
|
request.headers().set("X-CalDAV-Password", password)
|
|
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
|
|
|
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
|
.await
|
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
|
|
|
let resp: Response = resp_value.dyn_into()
|
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
|
|
|
let text = JsFuture::from(resp.text()
|
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
|
.await
|
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
|
|
|
let text_string = text.as_string()
|
|
.ok_or("Response text is not a string")?;
|
|
|
|
if resp.ok() {
|
|
let user_info: UserInfo = serde_json::from_str(&text_string)
|
|
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
|
Ok(user_info)
|
|
} else {
|
|
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
|
}
|
|
}
|
|
|
|
/// Fetch calendar events for a specific month
|
|
pub async fn fetch_events_for_month(
|
|
&self,
|
|
token: &str,
|
|
password: &str,
|
|
year: i32,
|
|
month: u32
|
|
) -> Result<Vec<CalendarEvent>, String> {
|
|
let window = web_sys::window().ok_or("No global window exists")?;
|
|
|
|
let opts = RequestInit::new();
|
|
opts.set_method("GET");
|
|
opts.set_mode(RequestMode::Cors);
|
|
|
|
let url = format!("{}/calendar/events?year={}&month={}", self.base_url, year, month);
|
|
let request = Request::new_with_str_and_init(&url, &opts)
|
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
|
|
|
request.headers().set("Authorization", &format!("Bearer {}", token))
|
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
|
|
|
request.headers().set("X-CalDAV-Password", password)
|
|
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
|
|
|
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
|
.await
|
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
|
|
|
let resp: Response = resp_value.dyn_into()
|
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
|
|
|
let text = JsFuture::from(resp.text()
|
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
|
.await
|
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
|
|
|
let text_string = text.as_string()
|
|
.ok_or("Response text is not a string")?;
|
|
|
|
if resp.ok() {
|
|
let events: Vec<CalendarEvent> = serde_json::from_str(&text_string)
|
|
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
|
Ok(events)
|
|
} else {
|
|
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
|
}
|
|
}
|
|
|
|
/// Convert events to a HashMap grouped by date for calendar display
|
|
pub fn group_events_by_date(events: Vec<CalendarEvent>) -> HashMap<NaiveDate, Vec<CalendarEvent>> {
|
|
let mut grouped = HashMap::new();
|
|
|
|
// Expand recurring events first
|
|
let expanded_events = Self::expand_recurring_events(events);
|
|
|
|
for event in expanded_events {
|
|
let date = event.get_date();
|
|
|
|
grouped.entry(date)
|
|
.or_insert_with(Vec::new)
|
|
.push(event);
|
|
}
|
|
|
|
grouped
|
|
}
|
|
|
|
/// Expand recurring events to show on all occurrence dates within a reasonable range
|
|
pub fn expand_recurring_events(events: Vec<CalendarEvent>) -> Vec<CalendarEvent> {
|
|
let mut expanded_events = Vec::new();
|
|
let today = chrono::Utc::now().date_naive();
|
|
let start_range = today - Duration::days(30); // Show past 30 days
|
|
let end_range = today + Duration::days(365); // Show next 365 days
|
|
|
|
for event in events {
|
|
if let Some(ref rrule) = event.recurrence_rule {
|
|
web_sys::console::log_1(&format!("📅 Processing recurring event '{}' with RRULE: {}",
|
|
event.summary.as_deref().unwrap_or("Untitled"),
|
|
rrule
|
|
).into());
|
|
|
|
// Log if event has exception dates
|
|
if !event.exception_dates.is_empty() {
|
|
web_sys::console::log_1(&format!("📅 Event '{}' has {} exception dates: {:?}",
|
|
event.summary.as_deref().unwrap_or("Untitled"),
|
|
event.exception_dates.len(),
|
|
event.exception_dates
|
|
).into());
|
|
}
|
|
|
|
// Generate occurrences for recurring events
|
|
let occurrences = Self::generate_occurrences(&event, rrule, start_range, end_range);
|
|
web_sys::console::log_1(&format!("📅 Generated {} occurrences for event '{}'",
|
|
occurrences.len(),
|
|
event.summary.as_deref().unwrap_or("Untitled")
|
|
).into());
|
|
expanded_events.extend(occurrences);
|
|
} else {
|
|
// Non-recurring event - add as-is
|
|
expanded_events.push(event);
|
|
}
|
|
}
|
|
|
|
expanded_events
|
|
}
|
|
|
|
/// Generate occurrence dates for a recurring event based on RRULE
|
|
fn generate_occurrences(
|
|
base_event: &CalendarEvent,
|
|
rrule: &str,
|
|
start_range: NaiveDate,
|
|
end_range: NaiveDate,
|
|
) -> Vec<CalendarEvent> {
|
|
let mut occurrences = Vec::new();
|
|
|
|
// Parse RRULE components
|
|
let rrule_upper = rrule.to_uppercase();
|
|
web_sys::console::log_1(&format!("🔄 Parsing RRULE: {}", rrule_upper).into());
|
|
|
|
let components: HashMap<String, String> = rrule_upper
|
|
.split(';')
|
|
.filter_map(|part| {
|
|
let mut split = part.split('=');
|
|
if let (Some(key), Some(value)) = (split.next(), split.next()) {
|
|
Some((key.to_string(), value.to_string()))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Get frequency
|
|
let freq = components.get("FREQ").map(|s| s.as_str()).unwrap_or("DAILY");
|
|
|
|
// Get interval (default 1)
|
|
let interval: i32 = components.get("INTERVAL")
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(1);
|
|
|
|
// Get count limit (default 100 to prevent infinite loops)
|
|
let count: usize = components.get("COUNT")
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(100)
|
|
.min(365); // Cap at 365 occurrences for performance
|
|
|
|
// Get UNTIL date if specified
|
|
let until_date = components.get("UNTIL")
|
|
.and_then(|until_str| {
|
|
// Parse UNTIL date in YYYYMMDDTHHMMSSZ format
|
|
// Try different parsing approaches for UTC dates
|
|
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(until_str.trim_end_matches('Z'), "%Y%m%dT%H%M%S") {
|
|
Some(chrono::Utc.from_utc_datetime(&dt))
|
|
} else if let Ok(dt) = chrono::DateTime::parse_from_str(until_str, "%Y%m%dT%H%M%SZ") {
|
|
Some(dt.with_timezone(&chrono::Utc))
|
|
} else if let Ok(date) = chrono::NaiveDate::parse_from_str(until_str, "%Y%m%d") {
|
|
// Handle date-only UNTIL
|
|
Some(chrono::Utc.from_utc_datetime(&date.and_hms_opt(23, 59, 59).unwrap()))
|
|
} else {
|
|
web_sys::console::log_1(&format!("⚠️ Failed to parse UNTIL date: {}", until_str).into());
|
|
None
|
|
}
|
|
});
|
|
|
|
if let Some(until) = until_date {
|
|
web_sys::console::log_1(&format!("📅 RRULE has UNTIL: {}", until).into());
|
|
}
|
|
|
|
let start_date = base_event.start.date_naive();
|
|
let mut current_date = start_date;
|
|
let mut occurrence_count = 0;
|
|
|
|
// Generate occurrences based on frequency
|
|
while current_date <= end_range && occurrence_count < count {
|
|
// Check UNTIL constraint - stop if current occurrence is after UNTIL date
|
|
if let Some(until) = until_date {
|
|
let current_datetime = base_event.start + Duration::days(current_date.signed_duration_since(start_date).num_days());
|
|
if current_datetime > until {
|
|
web_sys::console::log_1(&format!("🛑 Stopping at {} due to UNTIL {}", current_datetime, until).into());
|
|
break;
|
|
}
|
|
}
|
|
if current_date >= start_range {
|
|
// Calculate the occurrence datetime
|
|
let days_diff = current_date.signed_duration_since(start_date).num_days();
|
|
let occurrence_datetime = base_event.start + Duration::days(days_diff);
|
|
|
|
// Check if this occurrence is in the exception dates (EXDATE)
|
|
let is_exception = base_event.exception_dates.iter().any(|exception_date| {
|
|
// Compare dates ignoring sub-second precision
|
|
let exception_naive = exception_date.naive_utc();
|
|
let occurrence_naive = occurrence_datetime.naive_utc();
|
|
|
|
// Check if dates match (within a minute to handle minor time differences)
|
|
let diff = occurrence_naive - exception_naive;
|
|
let matches = diff.num_seconds().abs() < 60;
|
|
|
|
if matches {
|
|
web_sys::console::log_1(&format!("🚫 Excluding occurrence {} due to EXDATE {}", occurrence_naive, exception_naive).into());
|
|
}
|
|
|
|
matches
|
|
});
|
|
|
|
if !is_exception {
|
|
// Create occurrence event
|
|
let mut occurrence_event = base_event.clone();
|
|
occurrence_event.start = occurrence_datetime;
|
|
|
|
if let Some(end) = base_event.end {
|
|
occurrence_event.end = Some(end + Duration::days(days_diff));
|
|
}
|
|
|
|
occurrences.push(occurrence_event);
|
|
}
|
|
}
|
|
|
|
// Calculate next occurrence date
|
|
match freq {
|
|
"DAILY" => {
|
|
current_date = current_date + Duration::days(interval as i64);
|
|
}
|
|
"WEEKLY" => {
|
|
if let Some(byday) = components.get("BYDAY") {
|
|
// Handle specific days of week
|
|
current_date = Self::next_weekday_occurrence(current_date, byday, interval);
|
|
} else {
|
|
current_date = current_date + Duration::weeks(interval as i64);
|
|
}
|
|
}
|
|
"MONTHLY" => {
|
|
// Simple monthly increment (same day of month)
|
|
if let Some(next_month) = Self::add_months(current_date, interval) {
|
|
current_date = next_month;
|
|
} else {
|
|
break; // Invalid date
|
|
}
|
|
}
|
|
"YEARLY" => {
|
|
if let Some(next_year) = Self::add_years(current_date, interval) {
|
|
current_date = next_year;
|
|
} else {
|
|
break; // Invalid date
|
|
}
|
|
}
|
|
_ => break, // Unsupported frequency
|
|
}
|
|
|
|
occurrence_count += 1;
|
|
}
|
|
|
|
occurrences
|
|
}
|
|
|
|
/// Calculate next weekday occurrence for WEEKLY frequency with BYDAY
|
|
fn next_weekday_occurrence(current_date: NaiveDate, byday: &str, interval: i32) -> NaiveDate {
|
|
let weekdays = Self::parse_byday(byday);
|
|
if weekdays.is_empty() {
|
|
return current_date + Duration::weeks(interval as i64);
|
|
}
|
|
|
|
let current_weekday = current_date.weekday();
|
|
|
|
// Find next occurrence within current week
|
|
for &target_weekday in &weekdays {
|
|
let days_until = Self::days_until_weekday(current_weekday, target_weekday);
|
|
if days_until > 0 {
|
|
return current_date + Duration::days(days_until as i64);
|
|
}
|
|
}
|
|
|
|
// No more occurrences this week, move to next interval
|
|
let next_week_start = current_date + Duration::weeks(interval as i64) - Duration::days(current_weekday.num_days_from_monday() as i64);
|
|
next_week_start + Duration::days(weekdays[0].num_days_from_monday() as i64)
|
|
}
|
|
|
|
/// Parse BYDAY parameter (e.g., "MO,WE,FR" -> [Monday, Wednesday, Friday])
|
|
fn parse_byday(byday: &str) -> Vec<Weekday> {
|
|
byday
|
|
.split(',')
|
|
.filter_map(|day| match day {
|
|
"MO" => Some(Weekday::Mon),
|
|
"TU" => Some(Weekday::Tue),
|
|
"WE" => Some(Weekday::Wed),
|
|
"TH" => Some(Weekday::Thu),
|
|
"FR" => Some(Weekday::Fri),
|
|
"SA" => Some(Weekday::Sat),
|
|
"SU" => Some(Weekday::Sun),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Calculate days until target weekday
|
|
fn days_until_weekday(from: Weekday, to: Weekday) -> i32 {
|
|
let from_num = from.num_days_from_monday();
|
|
let to_num = to.num_days_from_monday();
|
|
|
|
if to_num > from_num {
|
|
(to_num - from_num) as i32
|
|
} else if to_num < from_num {
|
|
(7 + to_num - from_num) as i32
|
|
} else {
|
|
0 // Same day
|
|
}
|
|
}
|
|
|
|
/// Add months to a date (handling month boundary issues)
|
|
fn add_months(date: NaiveDate, months: i32) -> Option<NaiveDate> {
|
|
let mut year = date.year();
|
|
let mut month = date.month() as i32 + months;
|
|
|
|
while month > 12 {
|
|
year += 1;
|
|
month -= 12;
|
|
}
|
|
while month < 1 {
|
|
year -= 1;
|
|
month += 12;
|
|
}
|
|
|
|
// Handle day overflow (e.g., Jan 31 -> Feb 28)
|
|
let day = date.day().min(Self::days_in_month(year, month as u32));
|
|
|
|
NaiveDate::from_ymd_opt(year, month as u32, day)
|
|
}
|
|
|
|
/// Add years to a date
|
|
fn add_years(date: NaiveDate, years: i32) -> Option<NaiveDate> {
|
|
NaiveDate::from_ymd_opt(date.year() + years, date.month(), date.day())
|
|
}
|
|
|
|
/// Get number of days in a month
|
|
fn days_in_month(year: i32, month: u32) -> u32 {
|
|
match month {
|
|
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
|
4 | 6 | 9 | 11 => 30,
|
|
2 => if Self::is_leap_year(year) { 29 } else { 28 },
|
|
_ => 30,
|
|
}
|
|
}
|
|
|
|
/// Check if year is leap year
|
|
fn is_leap_year(year: i32) -> bool {
|
|
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
|
}
|
|
|
|
/// Create a new calendar on the CalDAV server
|
|
pub async fn create_calendar(
|
|
&self,
|
|
token: &str,
|
|
password: &str,
|
|
name: String,
|
|
description: Option<String>,
|
|
color: Option<String>
|
|
) -> Result<(), String> {
|
|
let window = web_sys::window().ok_or("No global window exists")?;
|
|
|
|
let opts = RequestInit::new();
|
|
opts.set_method("POST");
|
|
opts.set_mode(RequestMode::Cors);
|
|
|
|
let body = serde_json::json!({
|
|
"name": name,
|
|
"description": description,
|
|
"color": color
|
|
});
|
|
|
|
let body_string = serde_json::to_string(&body)
|
|
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
|
|
|
opts.set_body(&body_string.into());
|
|
|
|
let url = format!("{}/calendar/create", self.base_url);
|
|
let request = Request::new_with_str_and_init(&url, &opts)
|
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
|
|
|
request.headers().set("Authorization", &format!("Bearer {}", token))
|
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
|
|
|
request.headers().set("X-CalDAV-Password", password)
|
|
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
|
|
|
|
request.headers().set("Content-Type", "application/json")
|
|
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
|
|
|
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
|
.await
|
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
|
|
|
let resp: Response = resp_value.dyn_into()
|
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
|
|
|
let text = JsFuture::from(resp.text()
|
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
|
.await
|
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
|
|
|
let text_string = text.as_string()
|
|
.ok_or("Response text is not a string")?;
|
|
|
|
if resp.ok() {
|
|
Ok(())
|
|
} else {
|
|
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
|
}
|
|
}
|
|
|
|
/// Delete an event from the CalDAV server
|
|
pub async fn delete_event(
|
|
&self,
|
|
token: &str,
|
|
password: &str,
|
|
calendar_path: String,
|
|
event_href: String,
|
|
delete_action: String,
|
|
occurrence_date: Option<String>
|
|
) -> Result<String, String> {
|
|
let window = web_sys::window().ok_or("No global window exists")?;
|
|
|
|
let opts = RequestInit::new();
|
|
opts.set_method("POST");
|
|
opts.set_mode(RequestMode::Cors);
|
|
|
|
let body = serde_json::json!({
|
|
"calendar_path": calendar_path,
|
|
"event_href": event_href,
|
|
"delete_action": delete_action,
|
|
"occurrence_date": occurrence_date
|
|
});
|
|
|
|
let body_string = serde_json::to_string(&body)
|
|
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
|
|
|
let url = format!("{}/calendar/events/delete", self.base_url);
|
|
opts.set_body(&body_string.into());
|
|
let request = Request::new_with_str_and_init(&url, &opts)
|
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
|
|
|
request.headers().set("Authorization", &format!("Bearer {}", token))
|
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
|
|
|
request.headers().set("X-CalDAV-Password", password)
|
|
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
|
|
|
|
request.headers().set("Content-Type", "application/json")
|
|
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
|
|
|
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
|
.await
|
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
|
|
|
let resp: Response = resp_value.dyn_into()
|
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
|
|
|
let text = JsFuture::from(resp.text()
|
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
|
.await
|
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
|
|
|
let text_string = text.as_string()
|
|
.ok_or("Response text is not a string")?;
|
|
|
|
if resp.ok() {
|
|
// Parse the response to get the message
|
|
let response: serde_json::Value = serde_json::from_str(&text_string)
|
|
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
|
|
let message = response["message"].as_str().unwrap_or("Event deleted successfully").to_string();
|
|
Ok(message)
|
|
} else {
|
|
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
|
}
|
|
}
|
|
|
|
/// Create a new event on the CalDAV server
|
|
pub async fn create_event(
|
|
&self,
|
|
token: &str,
|
|
password: &str,
|
|
title: String,
|
|
description: String,
|
|
start_date: String,
|
|
start_time: String,
|
|
end_date: String,
|
|
end_time: String,
|
|
location: String,
|
|
all_day: bool,
|
|
status: String,
|
|
class: String,
|
|
priority: Option<u8>,
|
|
organizer: String,
|
|
attendees: String,
|
|
categories: String,
|
|
reminder: String,
|
|
recurrence: String,
|
|
recurrence_days: Vec<bool>,
|
|
calendar_path: Option<String>
|
|
) -> Result<(), String> {
|
|
let window = web_sys::window().ok_or("No global window exists")?;
|
|
|
|
let opts = RequestInit::new();
|
|
opts.set_method("POST");
|
|
opts.set_mode(RequestMode::Cors);
|
|
|
|
let body = serde_json::json!({
|
|
"title": title,
|
|
"description": description,
|
|
"start_date": start_date,
|
|
"start_time": start_time,
|
|
"end_date": end_date,
|
|
"end_time": end_time,
|
|
"location": location,
|
|
"all_day": all_day,
|
|
"status": status,
|
|
"class": class,
|
|
"priority": priority,
|
|
"organizer": organizer,
|
|
"attendees": attendees,
|
|
"categories": categories,
|
|
"reminder": reminder,
|
|
"recurrence": recurrence,
|
|
"recurrence_days": recurrence_days,
|
|
"calendar_path": calendar_path
|
|
});
|
|
|
|
let body_string = serde_json::to_string(&body)
|
|
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
|
|
|
let url = format!("{}/calendar/events/create", self.base_url);
|
|
opts.set_body(&body_string.into());
|
|
let request = Request::new_with_str_and_init(&url, &opts)
|
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
|
|
|
request.headers().set("Authorization", &format!("Bearer {}", token))
|
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
|
|
|
request.headers().set("X-CalDAV-Password", password)
|
|
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
|
|
|
|
request.headers().set("Content-Type", "application/json")
|
|
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
|
|
|
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
|
.await
|
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
|
|
|
let resp: Response = resp_value.dyn_into()
|
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
|
|
|
let text = JsFuture::from(resp.text()
|
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
|
.await
|
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
|
|
|
let text_string = text.as_string()
|
|
.ok_or("Response text is not a string")?;
|
|
|
|
if resp.ok() {
|
|
Ok(())
|
|
} else {
|
|
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
|
}
|
|
}
|
|
|
|
/// Delete a calendar from the CalDAV server
|
|
pub async fn delete_calendar(
|
|
&self,
|
|
token: &str,
|
|
password: &str,
|
|
path: String
|
|
) -> Result<(), String> {
|
|
let window = web_sys::window().ok_or("No global window exists")?;
|
|
|
|
let opts = RequestInit::new();
|
|
opts.set_method("POST");
|
|
opts.set_mode(RequestMode::Cors);
|
|
|
|
let body = serde_json::json!({
|
|
"path": path
|
|
});
|
|
|
|
let body_string = serde_json::to_string(&body)
|
|
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
|
|
|
opts.set_body(&body_string.into());
|
|
|
|
let url = format!("{}/calendar/delete", self.base_url);
|
|
let request = Request::new_with_str_and_init(&url, &opts)
|
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
|
|
|
request.headers().set("Authorization", &format!("Bearer {}", token))
|
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
|
|
|
request.headers().set("X-CalDAV-Password", password)
|
|
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
|
|
|
|
request.headers().set("Content-Type", "application/json")
|
|
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
|
|
|
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
|
.await
|
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
|
|
|
let resp: Response = resp_value.dyn_into()
|
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
|
|
|
let text = JsFuture::from(resp.text()
|
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
|
.await
|
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
|
|
|
let text_string = text.as_string()
|
|
.ok_or("Response text is not a string")?;
|
|
|
|
if resp.ok() {
|
|
Ok(())
|
|
} else {
|
|
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
|
}
|
|
}
|
|
|
|
/// Refresh a single event by UID from the CalDAV server
|
|
pub async fn refresh_event(&self, token: &str, password: &str, uid: &str) -> Result<Option<CalendarEvent>, String> {
|
|
let window = web_sys::window().ok_or("No global window exists")?;
|
|
|
|
let opts = RequestInit::new();
|
|
opts.set_method("GET");
|
|
opts.set_mode(RequestMode::Cors);
|
|
|
|
let url = format!("{}/calendar/events/{}", self.base_url, uid);
|
|
let request = Request::new_with_str_and_init(&url, &opts)
|
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
|
|
|
request.headers().set("Authorization", &format!("Bearer {}", token))
|
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
|
|
|
request.headers().set("X-CalDAV-Password", password)
|
|
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
|
|
|
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
|
.await
|
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
|
|
|
let resp: Response = resp_value.dyn_into()
|
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
|
|
|
let text = JsFuture::from(resp.text()
|
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
|
.await
|
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
|
|
|
let text_string = text.as_string()
|
|
.ok_or("Response text is not a string")?;
|
|
|
|
if resp.ok() {
|
|
let event: Option<CalendarEvent> = serde_json::from_str(&text_string)
|
|
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
|
Ok(event)
|
|
} else {
|
|
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
|
}
|
|
}
|
|
} |