Some checks failed
Build and Push Docker Image / docker (push) Failing after 1m7s
Moved event fetching logic from CalendarView to Calendar component to properly use the visible date range instead of hardcoded current month. The Calendar component already tracks the current visible date through navigation, so events now load correctly for August and other months when navigating. Changes: - Calendar component now manages its own events state and fetching - Event fetching responds to current_date changes from navigation - CalendarView simplified to just render Calendar component - Fixed cargo fmt/clippy formatting across codebase 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1845 lines
65 KiB
Rust
1845 lines
65 KiB
Rust
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday};
|
|
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,
|
|
}
|
|
|
|
// 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(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.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(end) = base_event.dtend {
|
|
occurrence_event.dtend = Some(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>,
|
|
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": None as Option<String>, // No end date by default
|
|
"recurrence_count": None as Option<u32>, // No count limit by default
|
|
"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,
|
|
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": vec![false; 7], // Default - could be enhanced
|
|
"recurrence_interval": 1_u32, // Default interval
|
|
"recurrence_end_date": None as Option<String>, // No end date by default
|
|
"recurrence_count": None as Option<u32>, // No count limit by default
|
|
"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
|
|
}
|
|
}
|