Implements server-side database caching with 5-minute refresh intervals to dramatically improve external calendar performance while keeping data fresh. Backend changes: - New external_calendar_cache table with ICS data storage - Smart cache logic: serves from cache if < 5min old, fetches fresh otherwise - Cache repository methods for get/update/clear operations - Migration script for cache table creation Frontend changes: - 5-minute auto-refresh interval for background updates - Manual refresh button (🔄) for each external calendar - Last updated timestamps showing when each calendar was refreshed - Centralized refresh function with proper cleanup on logout Performance improvements: - Initial load: instant from cache vs slow external HTTP requests - Background updates: fresh data without user waiting - Reduced external API calls: only when cache is stale - Scalable: handles multiple external calendars efficiently 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
2113 lines
75 KiB
Rust
2113 lines
75 KiB
Rust
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday};
|
|
use gloo_storage::{LocalStorage, Storage};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use wasm_bindgen::JsCast;
|
|
use wasm_bindgen_futures::JsFuture;
|
|
use web_sys::{Request, RequestInit, RequestMode, Response};
|
|
|
|
// Import RFC 5545 compliant VEvent from shared library
|
|
use calendar_models::VEvent;
|
|
|
|
// Create type alias for backward compatibility
|
|
pub type CalendarEvent = VEvent;
|
|
|
|
#[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,
|
|
pub is_visible: bool,
|
|
}
|
|
|
|
// CalendarEvent, EventStatus, and EventClass are now imported from shared library
|
|
|
|
// ==================== V2 API MODELS ====================
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct AttendeeV2 {
|
|
pub email: String,
|
|
pub name: Option<String>,
|
|
pub role: Option<AttendeeRoleV2>,
|
|
pub status: Option<ParticipationStatusV2>,
|
|
pub rsvp: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum AttendeeRoleV2 {
|
|
Chair,
|
|
Required,
|
|
Optional,
|
|
NonParticipant,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum ParticipationStatusV2 {
|
|
NeedsAction,
|
|
Accepted,
|
|
Declined,
|
|
Tentative,
|
|
Delegated,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct AlarmV2 {
|
|
pub action: AlarmActionV2,
|
|
pub trigger_minutes: i32, // Minutes before event (negative = before)
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum AlarmActionV2 {
|
|
Audio,
|
|
Display,
|
|
Email,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub enum DeleteActionV2 {
|
|
DeleteThis,
|
|
DeleteFollowing,
|
|
DeleteSeries,
|
|
}
|
|
|
|
// V2 Request/Response Models
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct EventSummaryV2 {
|
|
pub uid: String,
|
|
pub summary: Option<String>,
|
|
pub dtstart: DateTime<Utc>,
|
|
pub dtend: Option<DateTime<Utc>>,
|
|
pub location: Option<String>,
|
|
pub all_day: bool,
|
|
pub href: Option<String>,
|
|
pub etag: Option<String>,
|
|
}
|
|
|
|
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
|
|
/// Fetch events for month as VEvent with RFC 5545 compliance
|
|
pub async fn fetch_events_for_month_vevent(
|
|
&self,
|
|
token: &str,
|
|
password: &str,
|
|
year: i32,
|
|
month: u32,
|
|
) -> Result<Vec<VEvent>, String> {
|
|
// Since CalendarEvent is now a type alias for VEvent, just call the main method
|
|
self.fetch_events_for_month(token, password, year, month)
|
|
.await
|
|
}
|
|
|
|
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
|
|
/// Internally uses VEvent for RFC 5545 compliance while maintaining CalendarEvent API
|
|
/// Group VEvents by date with RFC 5545 compliance
|
|
pub fn group_events_by_date(events: Vec<VEvent>) -> HashMap<NaiveDate, Vec<VEvent>> {
|
|
let mut grouped = HashMap::new();
|
|
|
|
// Expand recurring events using VEvent (better RFC 5545 compliance)
|
|
let expanded_vevents = Self::expand_recurring_events(events);
|
|
|
|
// Group by date
|
|
for vevent in expanded_vevents {
|
|
let date = vevent.get_date();
|
|
grouped.entry(date).or_insert_with(Vec::new).push(vevent);
|
|
}
|
|
|
|
grouped
|
|
}
|
|
|
|
/// Expand recurring events using VEvent (RFC 5545 compliant)
|
|
pub fn expand_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
|
let mut expanded_events = Vec::new();
|
|
let today = chrono::Utc::now().date_naive();
|
|
let start_range = today - Duration::days(36500); // Show past 100 years (to catch any historical yearly events)
|
|
let end_range = today + Duration::days(36500); // Show next 100 years
|
|
|
|
for event in events {
|
|
if let Some(ref rrule) = event.rrule {
|
|
web_sys::console::log_1(
|
|
&format!(
|
|
"📅 Processing recurring VEvent '{}' with RRULE: {}",
|
|
event.summary.as_deref().unwrap_or("Untitled"),
|
|
rrule
|
|
)
|
|
.into(),
|
|
);
|
|
|
|
// Log if event has exception dates
|
|
if !event.exdate.is_empty() {
|
|
web_sys::console::log_1(
|
|
&format!(
|
|
"📅 VEvent '{}' has {} exception dates: {:?}",
|
|
event.summary.as_deref().unwrap_or("Untitled"),
|
|
event.exdate.len(),
|
|
event.exdate
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
|
|
// Generate occurrences for recurring events using VEvent
|
|
let occurrences = Self::generate_occurrences(&event, rrule, start_range, end_range);
|
|
web_sys::console::log_1(
|
|
&format!(
|
|
"📅 Generated {} occurrences for VEvent '{}'",
|
|
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 VEvent based on RRULE (RFC 5545 compliant)
|
|
fn generate_occurrences(
|
|
base_event: &VEvent,
|
|
rrule: &str,
|
|
start_range: NaiveDate,
|
|
end_range: NaiveDate,
|
|
) -> Vec<VEvent> {
|
|
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
|
|
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.dtstart.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.dtstart
|
|
+ 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.dtstart + Duration::days(days_diff);
|
|
|
|
// Check if this occurrence is in the exception dates (EXDATE)
|
|
let is_exception = base_event.exdate.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 with updated DTSTAMP
|
|
let mut occurrence_event = base_event.clone();
|
|
occurrence_event.dtstart = occurrence_datetime;
|
|
occurrence_event.dtstamp = chrono::Utc::now(); // Update DTSTAMP for each occurrence
|
|
|
|
|
|
if let Some(base_end) = base_event.dtend {
|
|
if base_event.all_day {
|
|
// For all-day events, maintain the RFC-5545 end date pattern
|
|
// End date should always be exactly one day after start date
|
|
occurrence_event.dtend = Some(occurrence_datetime + Duration::days(1));
|
|
} else {
|
|
// For timed events, preserve the original duration
|
|
occurrence_event.dtend = Some(base_end + Duration::days(days_diff));
|
|
}
|
|
}
|
|
|
|
occurrences.push(occurrence_event);
|
|
occurrence_count += 1; // Only count when we actually add an occurrence
|
|
}
|
|
}
|
|
|
|
// Calculate next occurrence date
|
|
match freq {
|
|
"DAILY" => {
|
|
current_date = current_date + Duration::days(interval as i64);
|
|
}
|
|
"WEEKLY" => {
|
|
if let Some(byday) = components.get("BYDAY") {
|
|
// For BYDAY weekly events, we need to handle multiple days per week
|
|
// Break out of the single-occurrence loop and use specialized logic
|
|
return Self::generate_weekly_byday_occurrences(
|
|
base_event,
|
|
byday,
|
|
interval,
|
|
start_range,
|
|
end_range,
|
|
until_date,
|
|
count,
|
|
);
|
|
} else {
|
|
current_date = current_date + Duration::weeks(interval as i64);
|
|
}
|
|
}
|
|
"MONTHLY" => {
|
|
// Handle MONTHLY with BYMONTHDAY or BYDAY
|
|
if let Some(bymonthday) = components.get("BYMONTHDAY") {
|
|
// Monthly by day of month (e.g., 15th of every month)
|
|
return Self::generate_monthly_bymonthday_occurrences(
|
|
base_event,
|
|
bymonthday,
|
|
interval,
|
|
start_range,
|
|
end_range,
|
|
until_date,
|
|
count,
|
|
);
|
|
} else if let Some(byday) = components.get("BYDAY") {
|
|
// Monthly by weekday position (e.g., first Monday)
|
|
return Self::generate_monthly_byday_occurrences(
|
|
base_event,
|
|
byday,
|
|
interval,
|
|
start_range,
|
|
end_range,
|
|
until_date,
|
|
count,
|
|
);
|
|
} else {
|
|
// 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(bymonth) = components.get("BYMONTH") {
|
|
// Yearly with specific months (e.g., January, March, June)
|
|
return Self::generate_yearly_bymonth_occurrences(
|
|
base_event,
|
|
bymonth,
|
|
interval,
|
|
start_range,
|
|
end_range,
|
|
until_date,
|
|
count,
|
|
);
|
|
} else {
|
|
// Simple yearly increment (same date each year)
|
|
if let Some(next_year) = Self::add_years(current_date, interval) {
|
|
current_date = next_year;
|
|
} else {
|
|
break; // Invalid date
|
|
}
|
|
}
|
|
}
|
|
_ => break, // Unsupported frequency
|
|
}
|
|
}
|
|
|
|
occurrences
|
|
}
|
|
|
|
/// Generate occurrences for WEEKLY frequency with BYDAY (handles multiple days per week)
|
|
fn generate_weekly_byday_occurrences(
|
|
base_event: &VEvent,
|
|
byday: &str,
|
|
interval: i32,
|
|
start_range: NaiveDate,
|
|
end_range: NaiveDate,
|
|
until_date: Option<chrono::DateTime<chrono::Utc>>,
|
|
count: usize,
|
|
) -> Vec<VEvent> {
|
|
let mut occurrences = Vec::new();
|
|
let weekdays = Self::parse_byday(byday);
|
|
|
|
if weekdays.is_empty() {
|
|
return occurrences;
|
|
}
|
|
|
|
let start_date = base_event.dtstart.date_naive();
|
|
|
|
// Find the Monday of the week containing the start_date (reference week)
|
|
let reference_week_start =
|
|
start_date - Duration::days(start_date.weekday().num_days_from_monday() as i64);
|
|
|
|
let mut total_events_generated = 0; // Count of actual events generated (matches RFC 5545 COUNT)
|
|
let mut week_interval_number = 0; // Which interval week we're processing (0, 1, 2, ...)
|
|
let max_weeks = (count * 10).min(520); // Prevent infinite loops - max 10 years
|
|
|
|
// Continue generating until we reach count or date limits
|
|
while total_events_generated < count && week_interval_number < max_weeks {
|
|
// Calculate the actual week we're processing: original + (interval_number * interval) weeks
|
|
let current_week_start = reference_week_start
|
|
+ Duration::weeks((week_interval_number as i32 * interval) as i64);
|
|
|
|
// Stop if we've gone past the end range
|
|
if current_week_start > end_range {
|
|
break;
|
|
}
|
|
|
|
// Generate occurrences for all matching weekdays in this week
|
|
for &weekday in &weekdays {
|
|
let occurrence_date =
|
|
current_week_start + Duration::days(weekday.num_days_from_monday() as i64);
|
|
|
|
// Skip if occurrence is before start_range or after end_range
|
|
if occurrence_date < start_range || occurrence_date > end_range {
|
|
continue;
|
|
}
|
|
|
|
// Skip if this occurrence is before the original event date
|
|
if occurrence_date < start_date {
|
|
continue;
|
|
}
|
|
|
|
// Check UNTIL constraint
|
|
if let Some(until) = until_date {
|
|
let days_diff = occurrence_date.signed_duration_since(start_date).num_days();
|
|
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
|
|
if occurrence_datetime > until {
|
|
web_sys::console::log_1(
|
|
&format!(
|
|
"🛑 Stopping at {} due to UNTIL {}",
|
|
occurrence_datetime, until
|
|
)
|
|
.into(),
|
|
);
|
|
return occurrences;
|
|
}
|
|
}
|
|
|
|
// Calculate the occurrence datetime
|
|
let days_diff = occurrence_date.signed_duration_since(start_date).num_days();
|
|
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
|
|
|
|
// Check if this occurrence is in the exception dates (EXDATE)
|
|
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
|
let exception_naive = exception_date.naive_utc();
|
|
let occurrence_naive = occurrence_datetime.naive_utc();
|
|
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.dtstart = occurrence_datetime;
|
|
occurrence_event.dtstamp = chrono::Utc::now();
|
|
|
|
if let Some(end) = base_event.dtend {
|
|
occurrence_event.dtend = Some(end + Duration::days(days_diff));
|
|
}
|
|
|
|
total_events_generated += 1;
|
|
occurrences.push(occurrence_event);
|
|
|
|
// Stop if we've reached the count limit
|
|
if total_events_generated >= count {
|
|
return occurrences;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Move to the next interval week
|
|
week_interval_number += 1;
|
|
}
|
|
|
|
occurrences
|
|
}
|
|
|
|
/// Generate occurrences for MONTHLY frequency with BYMONTHDAY
|
|
fn generate_monthly_bymonthday_occurrences(
|
|
base_event: &VEvent,
|
|
bymonthday: &str,
|
|
interval: i32,
|
|
start_range: NaiveDate,
|
|
end_range: NaiveDate,
|
|
until_date: Option<chrono::DateTime<chrono::Utc>>,
|
|
count: usize,
|
|
) -> Vec<VEvent> {
|
|
let mut occurrences = Vec::new();
|
|
|
|
// Parse BYMONTHDAY (e.g., "15" or "1,15,31")
|
|
let monthdays: Vec<u32> = bymonthday
|
|
.split(',')
|
|
.filter_map(|day| day.trim().parse().ok())
|
|
.filter(|&day| day >= 1 && day <= 31)
|
|
.collect();
|
|
|
|
if monthdays.is_empty() {
|
|
return occurrences;
|
|
}
|
|
|
|
let start_date = base_event.dtstart.date_naive();
|
|
let mut current_month_start =
|
|
NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap();
|
|
let mut total_occurrences = 0;
|
|
let mut months_processed = 0;
|
|
let max_months = (count * 12).min(120); // Prevent infinite loops - max 10 years
|
|
|
|
// Generate occurrences month by month
|
|
while current_month_start <= end_range
|
|
&& total_occurrences < count
|
|
&& months_processed < max_months
|
|
{
|
|
// Generate occurrences for all matching days in this month
|
|
for &day in &monthdays {
|
|
// Try to create the date, skip if invalid (e.g., Feb 31)
|
|
if let Some(occurrence_date) = NaiveDate::from_ymd_opt(
|
|
current_month_start.year(),
|
|
current_month_start.month(),
|
|
day,
|
|
) {
|
|
// Skip if occurrence is before start_range or after end_range
|
|
if occurrence_date < start_range || occurrence_date > end_range {
|
|
continue;
|
|
}
|
|
|
|
// Skip if we've reached the count limit
|
|
if total_occurrences >= count {
|
|
break;
|
|
}
|
|
|
|
// Skip if this occurrence is before the original event date
|
|
if occurrence_date < start_date {
|
|
continue;
|
|
}
|
|
|
|
// Check UNTIL constraint
|
|
if let Some(until) = until_date {
|
|
let days_diff =
|
|
occurrence_date.signed_duration_since(start_date).num_days();
|
|
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
|
|
if occurrence_datetime > until {
|
|
web_sys::console::log_1(
|
|
&format!(
|
|
"🛑 Stopping at {} due to UNTIL {}",
|
|
occurrence_datetime, until
|
|
)
|
|
.into(),
|
|
);
|
|
return occurrences;
|
|
}
|
|
}
|
|
|
|
// Calculate the occurrence datetime
|
|
let days_diff = occurrence_date.signed_duration_since(start_date).num_days();
|
|
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
|
|
|
|
// Check if this occurrence is in the exception dates (EXDATE)
|
|
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
|
let exception_naive = exception_date.naive_utc();
|
|
let occurrence_naive = occurrence_datetime.naive_utc();
|
|
let diff = occurrence_naive - exception_naive;
|
|
diff.num_seconds().abs() < 60
|
|
});
|
|
|
|
if !is_exception {
|
|
// Create occurrence event
|
|
let mut occurrence_event = base_event.clone();
|
|
occurrence_event.dtstart = occurrence_datetime;
|
|
occurrence_event.dtstamp = chrono::Utc::now();
|
|
|
|
if let Some(end) = base_event.dtend {
|
|
occurrence_event.dtend = Some(end + Duration::days(days_diff));
|
|
}
|
|
|
|
occurrences.push(occurrence_event);
|
|
total_occurrences += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Move to next month interval
|
|
months_processed += 1;
|
|
if let Some(next_month_start) = Self::add_months(current_month_start, interval) {
|
|
current_month_start =
|
|
NaiveDate::from_ymd_opt(next_month_start.year(), next_month_start.month(), 1)
|
|
.unwrap();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
occurrences
|
|
}
|
|
|
|
/// Generate occurrences for MONTHLY frequency with BYDAY (e.g., "1MO" = first Monday)
|
|
fn generate_monthly_byday_occurrences(
|
|
base_event: &VEvent,
|
|
byday: &str,
|
|
interval: i32,
|
|
start_range: NaiveDate,
|
|
end_range: NaiveDate,
|
|
until_date: Option<chrono::DateTime<chrono::Utc>>,
|
|
count: usize,
|
|
) -> Vec<VEvent> {
|
|
let mut occurrences = Vec::new();
|
|
|
|
// Parse BYDAY for monthly (e.g., "1MO" = first Monday, "-1FR" = last Friday)
|
|
if let Some((position, weekday)) = Self::parse_monthly_byday(byday) {
|
|
let start_date = base_event.dtstart.date_naive();
|
|
let mut current_month_start =
|
|
NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap();
|
|
let mut total_occurrences = 0;
|
|
|
|
// Generate occurrences month by month
|
|
while current_month_start <= end_range && total_occurrences < count {
|
|
if let Some(occurrence_date) =
|
|
Self::find_nth_weekday_in_month(current_month_start, position, weekday)
|
|
{
|
|
// Skip if occurrence is before start_range or after end_range
|
|
if occurrence_date < start_range || occurrence_date > end_range {
|
|
} else if occurrence_date >= start_date && total_occurrences < count {
|
|
// Check UNTIL constraint
|
|
if let Some(until) = until_date {
|
|
let days_diff =
|
|
occurrence_date.signed_duration_since(start_date).num_days();
|
|
let occurrence_datetime =
|
|
base_event.dtstart + Duration::days(days_diff);
|
|
if occurrence_datetime > until {
|
|
return occurrences;
|
|
}
|
|
}
|
|
|
|
// Calculate the occurrence datetime
|
|
let days_diff =
|
|
occurrence_date.signed_duration_since(start_date).num_days();
|
|
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
|
|
|
|
// Check EXDATE
|
|
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
|
let exception_naive = exception_date.naive_utc();
|
|
let occurrence_naive = occurrence_datetime.naive_utc();
|
|
let diff = occurrence_naive - exception_naive;
|
|
diff.num_seconds().abs() < 60
|
|
});
|
|
|
|
if !is_exception {
|
|
let mut occurrence_event = base_event.clone();
|
|
occurrence_event.dtstart = occurrence_datetime;
|
|
occurrence_event.dtstamp = chrono::Utc::now();
|
|
|
|
if let Some(end) = base_event.dtend {
|
|
occurrence_event.dtend = Some(end + Duration::days(days_diff));
|
|
}
|
|
|
|
occurrences.push(occurrence_event);
|
|
total_occurrences += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Move to next month interval
|
|
if let Some(next_month) = Self::add_months(current_month_start, interval) {
|
|
current_month_start =
|
|
NaiveDate::from_ymd_opt(next_month.year(), next_month.month(), 1).unwrap();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
occurrences
|
|
}
|
|
|
|
/// Generate occurrences for YEARLY frequency with BYMONTH
|
|
fn generate_yearly_bymonth_occurrences(
|
|
base_event: &VEvent,
|
|
bymonth: &str,
|
|
interval: i32,
|
|
start_range: NaiveDate,
|
|
end_range: NaiveDate,
|
|
until_date: Option<chrono::DateTime<chrono::Utc>>,
|
|
count: usize,
|
|
) -> Vec<VEvent> {
|
|
let mut occurrences = Vec::new();
|
|
|
|
// Parse BYMONTH (e.g., "1,3,6" -> [January, March, June])
|
|
let months: Vec<u32> = bymonth
|
|
.split(',')
|
|
.filter_map(|month| month.trim().parse().ok())
|
|
.filter(|&month| month >= 1 && month <= 12)
|
|
.collect();
|
|
|
|
if months.is_empty() {
|
|
return occurrences;
|
|
}
|
|
|
|
let start_date = base_event.dtstart.date_naive();
|
|
let mut current_year = start_date.year();
|
|
let mut total_occurrences = 0;
|
|
|
|
// Generate occurrences year by year
|
|
while total_occurrences < count {
|
|
// Generate occurrences for all matching months in this year
|
|
for &month in &months {
|
|
// Create the date for this year/month/day
|
|
if let Some(occurrence_date) =
|
|
NaiveDate::from_ymd_opt(current_year, month, start_date.day())
|
|
{
|
|
// Skip if occurrence is before start_range or after end_range
|
|
if occurrence_date < start_range || occurrence_date > end_range {
|
|
continue;
|
|
}
|
|
|
|
// Skip if we've reached the count limit
|
|
if total_occurrences >= count {
|
|
break;
|
|
}
|
|
|
|
// Skip if this occurrence is before the original event date
|
|
if occurrence_date < start_date {
|
|
continue;
|
|
}
|
|
|
|
// Check UNTIL constraint
|
|
if let Some(until) = until_date {
|
|
let days_diff =
|
|
occurrence_date.signed_duration_since(start_date).num_days();
|
|
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
|
|
if occurrence_datetime > until {
|
|
return occurrences;
|
|
}
|
|
}
|
|
|
|
// Calculate the occurrence datetime
|
|
let days_diff = occurrence_date.signed_duration_since(start_date).num_days();
|
|
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
|
|
|
|
// Check EXDATE
|
|
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
|
let exception_naive = exception_date.naive_utc();
|
|
let occurrence_naive = occurrence_datetime.naive_utc();
|
|
let diff = occurrence_naive - exception_naive;
|
|
diff.num_seconds().abs() < 60
|
|
});
|
|
|
|
if !is_exception {
|
|
let mut occurrence_event = base_event.clone();
|
|
occurrence_event.dtstart = occurrence_datetime;
|
|
occurrence_event.dtstamp = chrono::Utc::now();
|
|
|
|
if let Some(end) = base_event.dtend {
|
|
occurrence_event.dtend = Some(end + Duration::days(days_diff));
|
|
}
|
|
|
|
occurrences.push(occurrence_event);
|
|
total_occurrences += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Move to next year interval
|
|
current_year += interval;
|
|
|
|
// Stop if we've gone beyond reasonable range
|
|
if current_year > end_range.year() + 10 {
|
|
break;
|
|
}
|
|
}
|
|
|
|
occurrences
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// 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> {
|
|
// Forward to delete_event_with_uid with extracted UID
|
|
let event_uid = event_href.trim_end_matches(".ics").to_string();
|
|
self.delete_event_with_uid(
|
|
token,
|
|
password,
|
|
calendar_path,
|
|
event_href,
|
|
delete_action,
|
|
occurrence_date,
|
|
event_uid,
|
|
None, // No recurrence info available
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Delete an event from the CalDAV server with UID and recurrence support
|
|
pub async fn delete_event_with_uid(
|
|
&self,
|
|
token: &str,
|
|
password: &str,
|
|
calendar_path: String,
|
|
event_href: String,
|
|
delete_action: String,
|
|
occurrence_date: Option<String>,
|
|
event_uid: String,
|
|
recurrence: 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);
|
|
|
|
// Determine if this is a series event based on recurrence
|
|
let is_series = recurrence
|
|
.as_ref()
|
|
.map(|r| !r.is_empty() && r.to_uppercase() != "NONE")
|
|
.unwrap_or(false);
|
|
|
|
let (body, url) = if is_series {
|
|
// Use series-specific endpoint and payload for recurring events
|
|
// Map delete_action to delete_scope for series endpoint
|
|
let delete_scope = match delete_action.as_str() {
|
|
"delete_this" => "this_only",
|
|
"delete_following" => "this_and_future",
|
|
"delete_series" => "all_in_series",
|
|
_ => "this_only", // Default to single occurrence
|
|
};
|
|
|
|
let body = serde_json::json!({
|
|
"series_uid": event_uid,
|
|
"calendar_path": calendar_path,
|
|
"event_href": event_href,
|
|
"delete_scope": delete_scope,
|
|
"occurrence_date": occurrence_date
|
|
});
|
|
let url = format!("{}/calendar/events/series/delete", self.base_url);
|
|
(body, url)
|
|
} else {
|
|
// Use regular endpoint for non-recurring events
|
|
let body = serde_json::json!({
|
|
"calendar_path": calendar_path,
|
|
"event_href": event_href,
|
|
"delete_action": delete_action,
|
|
"occurrence_date": occurrence_date
|
|
});
|
|
let url = format!("{}/calendar/events/delete", self.base_url);
|
|
(body, url)
|
|
};
|
|
|
|
let body_string = serde_json::to_string(&body)
|
|
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
|
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>,
|
|
recurrence_count: Option<u32>,
|
|
recurrence_until: Option<String>,
|
|
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);
|
|
|
|
// Determine if this is a series event based on recurrence
|
|
let is_series = !recurrence.is_empty() && recurrence.to_uppercase() != "NONE";
|
|
|
|
let (body, url) = if is_series {
|
|
// Use series-specific endpoint and payload for recurring events
|
|
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,
|
|
"recurrence_interval": 1_u32, // Default interval
|
|
"recurrence_end_date": recurrence_until,
|
|
"recurrence_count": recurrence_count,
|
|
"calendar_path": calendar_path
|
|
});
|
|
let url = format!("{}/calendar/events/series/create", self.base_url);
|
|
(body, url)
|
|
} else {
|
|
// Use regular endpoint for non-recurring events
|
|
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 url = format!("{}/calendar/events/create", self.base_url);
|
|
(body, url)
|
|
};
|
|
|
|
let body_string = serde_json::to_string(&body)
|
|
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
|
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
|
|
))
|
|
}
|
|
}
|
|
|
|
pub async fn update_event(
|
|
&self,
|
|
token: &str,
|
|
password: &str,
|
|
event_uid: String,
|
|
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>,
|
|
exception_dates: Vec<DateTime<Utc>>,
|
|
update_action: Option<String>,
|
|
until_date: Option<DateTime<Utc>>,
|
|
) -> Result<(), String> {
|
|
// Forward to update_event_with_scope with default scope
|
|
self.update_event_with_scope(
|
|
token,
|
|
password,
|
|
event_uid,
|
|
title,
|
|
description,
|
|
start_date,
|
|
start_time,
|
|
end_date,
|
|
end_time,
|
|
location,
|
|
all_day,
|
|
status,
|
|
class,
|
|
priority,
|
|
organizer,
|
|
attendees,
|
|
categories,
|
|
reminder,
|
|
recurrence,
|
|
recurrence_days,
|
|
calendar_path,
|
|
exception_dates,
|
|
update_action,
|
|
until_date,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn update_event_with_scope(
|
|
&self,
|
|
token: &str,
|
|
password: &str,
|
|
event_uid: String,
|
|
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>,
|
|
exception_dates: Vec<DateTime<Utc>>,
|
|
update_action: Option<String>,
|
|
until_date: Option<DateTime<Utc>>,
|
|
) -> 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);
|
|
|
|
// Always use regular endpoint - recurring events should use update_series() instead
|
|
let body = serde_json::json!({
|
|
"uid": event_uid,
|
|
"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,
|
|
"update_action": update_action,
|
|
"occurrence_date": null,
|
|
"exception_dates": exception_dates.iter().map(|dt| dt.to_rfc3339()).collect::<Vec<String>>(),
|
|
"until_date": until_date.as_ref().map(|dt| dt.to_rfc3339())
|
|
});
|
|
let url = format!("{}/calendar/events/update", self.base_url);
|
|
|
|
let body_string = serde_json::to_string(&body)
|
|
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
|
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
|
|
))
|
|
}
|
|
}
|
|
|
|
pub async fn update_series(
|
|
&self,
|
|
token: &str,
|
|
password: &str,
|
|
series_uid: String,
|
|
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>,
|
|
recurrence_count: Option<u32>,
|
|
recurrence_until: Option<String>,
|
|
calendar_path: Option<String>,
|
|
update_scope: String,
|
|
occurrence_date: 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!({
|
|
"series_uid": series_uid,
|
|
"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,
|
|
"recurrence_interval": 1_u32, // Default interval - could be enhanced to be a parameter
|
|
"recurrence_end_date": recurrence_until,
|
|
"recurrence_count": recurrence_count,
|
|
"calendar_path": calendar_path,
|
|
"update_scope": update_scope,
|
|
"occurrence_date": occurrence_date
|
|
});
|
|
|
|
let url = format!("{}/calendar/events/series/update", self.base_url);
|
|
|
|
web_sys::console::log_1(
|
|
&format!("🔄 update_series: Making request to URL: {}", url).into(),
|
|
);
|
|
web_sys::console::log_1(
|
|
&format!(
|
|
"🔄 update_series: Request body: {}",
|
|
serde_json::to_string_pretty(&body).unwrap_or_default()
|
|
)
|
|
.into(),
|
|
);
|
|
|
|
let body_string = serde_json::to_string(&body)
|
|
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
|
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
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Parse monthly BYDAY (e.g., "1MO" -> (1, Monday), "-1FR" -> (-1, Friday))
|
|
fn parse_monthly_byday(byday: &str) -> Option<(i8, chrono::Weekday)> {
|
|
if byday.len() < 3 {
|
|
return None;
|
|
}
|
|
|
|
// Extract position and weekday
|
|
let (position_str, weekday_str) = if byday.starts_with('-') {
|
|
(&byday[..2], &byday[2..])
|
|
} else {
|
|
(&byday[..1], &byday[1..])
|
|
};
|
|
|
|
let position = position_str.parse::<i8>().ok()?;
|
|
let weekday = match weekday_str {
|
|
"MO" => chrono::Weekday::Mon,
|
|
"TU" => chrono::Weekday::Tue,
|
|
"WE" => chrono::Weekday::Wed,
|
|
"TH" => chrono::Weekday::Thu,
|
|
"FR" => chrono::Weekday::Fri,
|
|
"SA" => chrono::Weekday::Sat,
|
|
"SU" => chrono::Weekday::Sun,
|
|
_ => return None,
|
|
};
|
|
|
|
Some((position, weekday))
|
|
}
|
|
|
|
/// Find nth weekday in a month (e.g., 1st Monday, 2nd Tuesday, -1 = last)
|
|
fn find_nth_weekday_in_month(
|
|
month_start: NaiveDate,
|
|
position: i8,
|
|
weekday: chrono::Weekday,
|
|
) -> Option<NaiveDate> {
|
|
let year = month_start.year();
|
|
let month = month_start.month();
|
|
|
|
if position > 0 {
|
|
// Find nth occurrence from beginning of month
|
|
let mut current = NaiveDate::from_ymd_opt(year, month, 1)?;
|
|
let mut count = 0;
|
|
|
|
while current.month() == month {
|
|
if current.weekday() == weekday {
|
|
count += 1;
|
|
if count == position as u8 {
|
|
return Some(current);
|
|
}
|
|
}
|
|
current = current.succ_opt()?;
|
|
}
|
|
} else if position < 0 {
|
|
// Find nth occurrence from end of month
|
|
let next_month = if month == 12 {
|
|
NaiveDate::from_ymd_opt(year + 1, 1, 1)?
|
|
} else {
|
|
NaiveDate::from_ymd_opt(year, month + 1, 1)?
|
|
};
|
|
let mut current = next_month.pred_opt()?; // Last day of current month
|
|
let mut count = 0;
|
|
|
|
while current.month() == month {
|
|
if current.weekday() == weekday {
|
|
count += 1;
|
|
if count == (-position) as u8 {
|
|
return Some(current);
|
|
}
|
|
}
|
|
current = current.pred_opt()?;
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
// ==================== EXTERNAL CALENDAR METHODS ====================
|
|
|
|
pub async fn get_external_calendars() -> Result<Vec<ExternalCalendar>, String> {
|
|
let token = LocalStorage::get::<String>("auth_token")
|
|
.map_err(|_| "No authentication token found".to_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 service = Self::new();
|
|
let url = format!("{}/external-calendars", service.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))?;
|
|
|
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
|
.await
|
|
.map_err(|e| format!("Request failed: {:?}", e))?;
|
|
|
|
let resp: Response = resp_value
|
|
.dyn_into()
|
|
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
|
|
|
if !resp.ok() {
|
|
return Err(format!("HTTP error: {}", resp.status()));
|
|
}
|
|
|
|
let json = JsFuture::from(resp.json().unwrap())
|
|
.await
|
|
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
|
|
|
|
let external_calendars: Vec<ExternalCalendar> = serde_wasm_bindgen::from_value(json)
|
|
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
|
|
|
|
Ok(external_calendars)
|
|
}
|
|
|
|
pub async fn create_external_calendar(name: &str, url: &str, color: &str) -> Result<ExternalCalendar, String> {
|
|
let token = LocalStorage::get::<String>("auth_token")
|
|
.map_err(|_| "No authentication token found".to_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,
|
|
"url": url,
|
|
"color": color
|
|
});
|
|
|
|
let service = Self::new();
|
|
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!("{}/external-calendars", service.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("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!("Request failed: {:?}", e))?;
|
|
|
|
let resp: Response = resp_value
|
|
.dyn_into()
|
|
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
|
|
|
if !resp.ok() {
|
|
return Err(format!("HTTP error: {}", resp.status()));
|
|
}
|
|
|
|
let json = JsFuture::from(resp.json().unwrap())
|
|
.await
|
|
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
|
|
|
|
let external_calendar: ExternalCalendar = serde_wasm_bindgen::from_value(json)
|
|
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
|
|
|
|
Ok(external_calendar)
|
|
}
|
|
|
|
pub async fn update_external_calendar(
|
|
id: i32,
|
|
name: &str,
|
|
url: &str,
|
|
color: &str,
|
|
is_visible: bool,
|
|
) -> Result<(), String> {
|
|
let token = LocalStorage::get::<String>("auth_token")
|
|
.map_err(|_| "No authentication token found".to_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,
|
|
"url": url,
|
|
"color": color,
|
|
"is_visible": is_visible
|
|
});
|
|
|
|
let service = Self::new();
|
|
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!("{}/external-calendars/{}", service.base_url, id);
|
|
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("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!("Request failed: {:?}", e))?;
|
|
|
|
let resp: Response = resp_value
|
|
.dyn_into()
|
|
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
|
|
|
if !resp.ok() {
|
|
return Err(format!("HTTP error: {}", resp.status()));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn delete_external_calendar(id: i32) -> Result<(), String> {
|
|
let token = LocalStorage::get::<String>("auth_token")
|
|
.map_err(|_| "No authentication token found".to_string())?;
|
|
|
|
let window = web_sys::window().ok_or("No global window exists")?;
|
|
|
|
let opts = RequestInit::new();
|
|
opts.set_method("DELETE");
|
|
opts.set_mode(RequestMode::Cors);
|
|
|
|
let service = Self::new();
|
|
let url = format!("{}/external-calendars/{}", service.base_url, id);
|
|
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))?;
|
|
|
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
|
.await
|
|
.map_err(|e| format!("Request failed: {:?}", e))?;
|
|
|
|
let resp: Response = resp_value
|
|
.dyn_into()
|
|
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
|
|
|
if !resp.ok() {
|
|
return Err(format!("HTTP error: {}", resp.status()));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn fetch_external_calendar_events(id: i32) -> Result<Vec<VEvent>, String> {
|
|
let token = LocalStorage::get::<String>("auth_token")
|
|
.map_err(|_| "No authentication token found".to_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 service = Self::new();
|
|
let url = format!("{}/external-calendars/{}/events", service.base_url, id);
|
|
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))?;
|
|
|
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
|
.await
|
|
.map_err(|e| format!("Request failed: {:?}", e))?;
|
|
|
|
let resp: Response = resp_value
|
|
.dyn_into()
|
|
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
|
|
|
if !resp.ok() {
|
|
return Err(format!("HTTP error: {}", resp.status()));
|
|
}
|
|
|
|
let json = JsFuture::from(resp.json().unwrap())
|
|
.await
|
|
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
|
|
|
|
#[derive(Deserialize)]
|
|
struct ExternalCalendarEventsResponse {
|
|
events: Vec<VEvent>,
|
|
last_fetched: chrono::DateTime<chrono::Utc>,
|
|
}
|
|
|
|
let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json)
|
|
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
|
|
|
|
Ok(response.events)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct ExternalCalendar {
|
|
pub id: i32,
|
|
pub name: String,
|
|
pub url: String,
|
|
pub color: String,
|
|
pub is_visible: bool,
|
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
|
pub last_fetched: Option<chrono::DateTime<chrono::Utc>>,
|
|
}
|