Fix calendar event fetching to use visible date range
Some checks failed
Build and Push Docker Image / docker (push) Failing after 1m7s
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>
This commit is contained in:
@@ -2,16 +2,16 @@ use chrono::{Duration, Utc};
|
|||||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::models::{CalDAVLoginRequest, AuthResponse, ApiError};
|
|
||||||
use crate::config::CalDAVConfig;
|
|
||||||
use crate::calendar::CalDAVClient;
|
use crate::calendar::CalDAVClient;
|
||||||
|
use crate::config::CalDAVConfig;
|
||||||
|
use crate::models::{ApiError, AuthResponse, CalDAVLoginRequest};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
pub exp: i64, // Expiration time
|
pub exp: i64, // Expiration time
|
||||||
pub iat: i64, // Issued at
|
pub iat: i64, // Issued at
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -34,7 +34,7 @@ impl AuthService {
|
|||||||
let caldav_config = CalDAVConfig::new(
|
let caldav_config = CalDAVConfig::new(
|
||||||
request.server_url.clone(),
|
request.server_url.clone(),
|
||||||
request.username.clone(),
|
request.username.clone(),
|
||||||
request.password.clone()
|
request.password.clone(),
|
||||||
);
|
);
|
||||||
println!("📝 Created CalDAV config");
|
println!("📝 Created CalDAV config");
|
||||||
|
|
||||||
@@ -45,7 +45,10 @@ impl AuthService {
|
|||||||
// Try to discover calendars as an authentication test
|
// Try to discover calendars as an authentication test
|
||||||
match caldav_client.discover_calendars().await {
|
match caldav_client.discover_calendars().await {
|
||||||
Ok(calendars) => {
|
Ok(calendars) => {
|
||||||
println!("✅ Authentication successful! Found {} calendars", calendars.len());
|
println!(
|
||||||
|
"✅ Authentication successful! Found {} calendars",
|
||||||
|
calendars.len()
|
||||||
|
);
|
||||||
// Authentication successful, generate JWT token
|
// Authentication successful, generate JWT token
|
||||||
let token = self.generate_token(&request.username, &request.server_url)?;
|
let token = self.generate_token(&request.username, &request.server_url)?;
|
||||||
|
|
||||||
@@ -58,7 +61,9 @@ impl AuthService {
|
|||||||
Err(err) => {
|
Err(err) => {
|
||||||
println!("❌ Authentication failed: {:?}", err);
|
println!("❌ Authentication failed: {:?}", err);
|
||||||
// Authentication failed
|
// Authentication failed
|
||||||
Err(ApiError::Unauthorized("Invalid CalDAV credentials or server unavailable".to_string()))
|
Err(ApiError::Unauthorized(
|
||||||
|
"Invalid CalDAV credentials or server unavailable".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,13 +74,17 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create CalDAV config from token
|
/// Create CalDAV config from token
|
||||||
pub fn caldav_config_from_token(&self, token: &str, password: &str) -> Result<CalDAVConfig, ApiError> {
|
pub fn caldav_config_from_token(
|
||||||
|
&self,
|
||||||
|
token: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<CalDAVConfig, ApiError> {
|
||||||
let claims = self.verify_token(token)?;
|
let claims = self.verify_token(token)?;
|
||||||
|
|
||||||
Ok(CalDAVConfig::new(
|
Ok(CalDAVConfig::new(
|
||||||
claims.server_url,
|
claims.server_url,
|
||||||
claims.username,
|
claims.username,
|
||||||
password.to_string()
|
password.to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +102,11 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Basic URL validation
|
// Basic URL validation
|
||||||
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://") {
|
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://")
|
||||||
return Err(ApiError::BadRequest("Server URL must start with http:// or https://".to_string()));
|
{
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"Server URL must start with http:// or https://".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
use calendar_models::{CalendarUser, EventClass, EventStatus, VAlarm, VEvent};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use calendar_models::{VEvent, EventStatus, EventClass, CalendarUser, VAlarm};
|
|
||||||
|
|
||||||
// Global mutex to serialize CalDAV HTTP requests to prevent race conditions
|
// Global mutex to serialize CalDAV HTTP requests to prevent race conditions
|
||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
@@ -128,7 +128,10 @@ impl CalDAVClient {
|
|||||||
///
|
///
|
||||||
/// This method performs a REPORT request to get calendar data and parses
|
/// This method performs a REPORT request to get calendar data and parses
|
||||||
/// the returned iCalendar format into CalendarEvent structs.
|
/// the returned iCalendar format into CalendarEvent structs.
|
||||||
pub async fn fetch_events(&self, calendar_path: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
pub async fn fetch_events(
|
||||||
|
&self,
|
||||||
|
calendar_path: &str,
|
||||||
|
) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
||||||
// CalDAV REPORT request to get calendar events
|
// CalDAV REPORT request to get calendar events
|
||||||
let report_body = r#"<?xml version="1.0" encoding="utf-8" ?>
|
let report_body = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
@@ -149,7 +152,11 @@ impl CalDAVClient {
|
|||||||
// Extract the base URL (scheme + host + port) from server_url
|
// Extract the base URL (scheme + host + port) from server_url
|
||||||
let server_url = &self.config.server_url;
|
let server_url = &self.config.server_url;
|
||||||
// Find the first '/' after "https://" or "http://"
|
// Find the first '/' after "https://" or "http://"
|
||||||
let scheme_end = if server_url.starts_with("https://") { 8 } else { 7 };
|
let scheme_end = if server_url.starts_with("https://") {
|
||||||
|
8
|
||||||
|
} else {
|
||||||
|
7
|
||||||
|
};
|
||||||
if let Some(path_start) = server_url[scheme_end..].find('/') {
|
if let Some(path_start) = server_url[scheme_end..].find('/') {
|
||||||
let base_url = &server_url[..scheme_end + path_start];
|
let base_url = &server_url[..scheme_end + path_start];
|
||||||
format!("{}{}", base_url, calendar_path)
|
format!("{}{}", base_url, calendar_path)
|
||||||
@@ -163,7 +170,8 @@ impl CalDAVClient {
|
|||||||
println!("🔑 REPORT Basic Auth: Basic {}", basic_auth);
|
println!("🔑 REPORT Basic Auth: Basic {}", basic_auth);
|
||||||
println!("🌐 REPORT URL: {}", url);
|
println!("🌐 REPORT URL: {}", url);
|
||||||
|
|
||||||
let response = self.http_client
|
let response = self
|
||||||
|
.http_client
|
||||||
.request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url)
|
.request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url)
|
||||||
.header("Authorization", format!("Basic {}", basic_auth))
|
.header("Authorization", format!("Basic {}", basic_auth))
|
||||||
.header("Content-Type", "application/xml")
|
.header("Content-Type", "application/xml")
|
||||||
@@ -183,7 +191,11 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse CalDAV XML response containing calendar data
|
/// Parse CalDAV XML response containing calendar data
|
||||||
fn parse_calendar_response(&self, xml_response: &str, calendar_path: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
fn parse_calendar_response(
|
||||||
|
&self,
|
||||||
|
xml_response: &str,
|
||||||
|
calendar_path: &str,
|
||||||
|
) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
|
||||||
// Extract calendar data from XML response
|
// Extract calendar data from XML response
|
||||||
@@ -205,7 +217,11 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch a single calendar event by UID from the CalDAV server
|
/// Fetch a single calendar event by UID from the CalDAV server
|
||||||
pub async fn fetch_event_by_uid(&self, calendar_path: &str, uid: &str) -> Result<Option<CalendarEvent>, CalDAVError> {
|
pub async fn fetch_event_by_uid(
|
||||||
|
&self,
|
||||||
|
calendar_path: &str,
|
||||||
|
uid: &str,
|
||||||
|
) -> Result<Option<CalendarEvent>, CalDAVError> {
|
||||||
// First fetch all events and find the one with matching UID
|
// First fetch all events and find the one with matching UID
|
||||||
let events = self.fetch_events(calendar_path).await?;
|
let events = self.fetch_events(calendar_path).await?;
|
||||||
|
|
||||||
@@ -225,10 +241,16 @@ impl CalDAVClient {
|
|||||||
if let Some(end_pos) = response_block.find("</d:response>") {
|
if let Some(end_pos) = response_block.find("</d:response>") {
|
||||||
let response_content = &response_block[..end_pos];
|
let response_content = &response_block[..end_pos];
|
||||||
|
|
||||||
let href = self.extract_xml_content(response_content, "href").unwrap_or_default();
|
let href = self
|
||||||
let etag = self.extract_xml_content(response_content, "getetag").unwrap_or_default();
|
.extract_xml_content(response_content, "href")
|
||||||
|
.unwrap_or_default();
|
||||||
|
let etag = self
|
||||||
|
.extract_xml_content(response_content, "getetag")
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
if let Some(calendar_data) = self.extract_xml_content(response_content, "cal:calendar-data") {
|
if let Some(calendar_data) =
|
||||||
|
self.extract_xml_content(response_content, "cal:calendar-data")
|
||||||
|
{
|
||||||
sections.push(CalendarDataSection {
|
sections.push(CalendarDataSection {
|
||||||
href: if href.is_empty() { None } else { Some(href) },
|
href: if href.is_empty() { None } else { Some(href) },
|
||||||
etag: if etag.is_empty() { None } else { Some(etag) },
|
etag: if etag.is_empty() { None } else { Some(etag) },
|
||||||
@@ -245,12 +267,28 @@ impl CalDAVClient {
|
|||||||
fn extract_xml_content(&self, xml: &str, tag: &str) -> Option<String> {
|
fn extract_xml_content(&self, xml: &str, tag: &str) -> Option<String> {
|
||||||
// Handle both with and without namespace prefixes
|
// Handle both with and without namespace prefixes
|
||||||
let patterns = [
|
let patterns = [
|
||||||
format!("(?s)<{}>(.*?)</{}>", tag, tag), // <tag>content</tag>
|
format!("(?s)<{}>(.*?)</{}>", tag, tag), // <tag>content</tag>
|
||||||
format!("(?s)<{}>(.*?)</.*:{}>", tag, tag.split(':').last().unwrap_or(tag)), // <tag>content</ns:tag>
|
format!(
|
||||||
format!("(?s)<.*:{}>(.*?)</{}>", tag.split(':').last().unwrap_or(tag), tag), // <ns:tag>content</tag>
|
"(?s)<{}>(.*?)</.*:{}>",
|
||||||
format!("(?s)<.*:{}>(.*?)</.*:{}>", tag.split(':').last().unwrap_or(tag), tag.split(':').last().unwrap_or(tag)), // <ns:tag>content</ns:tag>
|
tag,
|
||||||
format!("(?s)<{}[^>]*>(.*?)</{}>", tag, tag), // <tag attr>content</tag>
|
tag.split(':').last().unwrap_or(tag)
|
||||||
format!("(?s)<{}[^>]*>(.*?)</.*:{}>", tag, tag.split(':').last().unwrap_or(tag)),
|
), // <tag>content</ns:tag>
|
||||||
|
format!(
|
||||||
|
"(?s)<.*:{}>(.*?)</{}>",
|
||||||
|
tag.split(':').last().unwrap_or(tag),
|
||||||
|
tag
|
||||||
|
), // <ns:tag>content</tag>
|
||||||
|
format!(
|
||||||
|
"(?s)<.*:{}>(.*?)</.*:{}>",
|
||||||
|
tag.split(':').last().unwrap_or(tag),
|
||||||
|
tag.split(':').last().unwrap_or(tag)
|
||||||
|
), // <ns:tag>content</ns:tag>
|
||||||
|
format!("(?s)<{}[^>]*>(.*?)</{}>", tag, tag), // <tag attr>content</tag>
|
||||||
|
format!(
|
||||||
|
"(?s)<{}[^>]*>(.*?)</.*:{}>",
|
||||||
|
tag,
|
||||||
|
tag.split(':').last().unwrap_or(tag)
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
for pattern in &patterns {
|
for pattern in &patterns {
|
||||||
@@ -287,21 +325,29 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a single iCal event into a CalendarEvent struct
|
/// Parse a single iCal event into a CalendarEvent struct
|
||||||
fn parse_ical_event(&self, event: ical::parser::ical::component::IcalEvent) -> Result<CalendarEvent, CalDAVError> {
|
fn parse_ical_event(
|
||||||
|
&self,
|
||||||
|
event: ical::parser::ical::component::IcalEvent,
|
||||||
|
) -> Result<CalendarEvent, CalDAVError> {
|
||||||
let mut properties: HashMap<String, String> = HashMap::new();
|
let mut properties: HashMap<String, String> = HashMap::new();
|
||||||
|
|
||||||
// Extract all properties from the event
|
// Extract all properties from the event
|
||||||
for property in &event.properties {
|
for property in &event.properties {
|
||||||
properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default());
|
properties.insert(
|
||||||
|
property.name.to_uppercase(),
|
||||||
|
property.value.clone().unwrap_or_default(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Required UID field
|
// Required UID field
|
||||||
let uid = properties.get("UID")
|
let uid = properties
|
||||||
|
.get("UID")
|
||||||
.ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))?
|
.ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))?
|
||||||
.clone();
|
.clone();
|
||||||
|
|
||||||
// Parse start time (required)
|
// Parse start time (required)
|
||||||
let start = properties.get("DTSTART")
|
let start = properties
|
||||||
|
.get("DTSTART")
|
||||||
.ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?;
|
.ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?;
|
||||||
let start = self.parse_datetime(start, properties.get("DTSTART"))?;
|
let start = self.parse_datetime(start, properties.get("DTSTART"))?;
|
||||||
|
|
||||||
@@ -316,12 +362,14 @@ impl CalDAVClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Determine if it's an all-day event
|
// Determine if it's an all-day event
|
||||||
let all_day = properties.get("DTSTART")
|
let all_day = properties
|
||||||
|
.get("DTSTART")
|
||||||
.map(|s| !s.contains("T"))
|
.map(|s| !s.contains("T"))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Parse status
|
// Parse status
|
||||||
let status = properties.get("STATUS")
|
let status = properties
|
||||||
|
.get("STATUS")
|
||||||
.map(|s| match s.to_uppercase().as_str() {
|
.map(|s| match s.to_uppercase().as_str() {
|
||||||
"TENTATIVE" => EventStatus::Tentative,
|
"TENTATIVE" => EventStatus::Tentative,
|
||||||
"CANCELLED" => EventStatus::Cancelled,
|
"CANCELLED" => EventStatus::Cancelled,
|
||||||
@@ -330,7 +378,8 @@ impl CalDAVClient {
|
|||||||
.unwrap_or(EventStatus::Confirmed);
|
.unwrap_or(EventStatus::Confirmed);
|
||||||
|
|
||||||
// Parse classification
|
// Parse classification
|
||||||
let class = properties.get("CLASS")
|
let class = properties
|
||||||
|
.get("CLASS")
|
||||||
.map(|s| match s.to_uppercase().as_str() {
|
.map(|s| match s.to_uppercase().as_str() {
|
||||||
"PRIVATE" => EventClass::Private,
|
"PRIVATE" => EventClass::Private,
|
||||||
"CONFIDENTIAL" => EventClass::Confidential,
|
"CONFIDENTIAL" => EventClass::Confidential,
|
||||||
@@ -339,20 +388,24 @@ impl CalDAVClient {
|
|||||||
.unwrap_or(EventClass::Public);
|
.unwrap_or(EventClass::Public);
|
||||||
|
|
||||||
// Parse priority
|
// Parse priority
|
||||||
let priority = properties.get("PRIORITY")
|
let priority = properties
|
||||||
|
.get("PRIORITY")
|
||||||
.and_then(|s| s.parse::<u8>().ok())
|
.and_then(|s| s.parse::<u8>().ok())
|
||||||
.filter(|&p| p <= 9);
|
.filter(|&p| p <= 9);
|
||||||
|
|
||||||
// Parse categories
|
// Parse categories
|
||||||
let categories = properties.get("CATEGORIES")
|
let categories = properties
|
||||||
|
.get("CATEGORIES")
|
||||||
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect())
|
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Parse dates
|
// Parse dates
|
||||||
let created = properties.get("CREATED")
|
let created = properties
|
||||||
|
.get("CREATED")
|
||||||
.and_then(|s| self.parse_datetime(s, None).ok());
|
.and_then(|s| self.parse_datetime(s, None).ok());
|
||||||
|
|
||||||
let last_modified = properties.get("LAST-MODIFIED")
|
let last_modified = properties
|
||||||
|
.get("LAST-MODIFIED")
|
||||||
.and_then(|s| self.parse_datetime(s, None).ok());
|
.and_then(|s| self.parse_datetime(s, None).ok());
|
||||||
|
|
||||||
// Parse exception dates (EXDATE)
|
// Parse exception dates (EXDATE)
|
||||||
@@ -403,7 +456,10 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse VALARM components from an iCal event
|
/// Parse VALARM components from an iCal event
|
||||||
fn parse_valarms(&self, event: &ical::parser::ical::component::IcalEvent) -> Result<Vec<VAlarm>, CalDAVError> {
|
fn parse_valarms(
|
||||||
|
&self,
|
||||||
|
event: &ical::parser::ical::component::IcalEvent,
|
||||||
|
) -> Result<Vec<VAlarm>, CalDAVError> {
|
||||||
let mut alarms = Vec::new();
|
let mut alarms = Vec::new();
|
||||||
|
|
||||||
for alarm in &event.alarms {
|
for alarm in &event.alarms {
|
||||||
@@ -416,20 +472,30 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a single VALARM component into a VAlarm
|
/// Parse a single VALARM component into a VAlarm
|
||||||
fn parse_single_valarm(&self, alarm: &ical::parser::ical::component::IcalAlarm) -> Result<VAlarm, CalDAVError> {
|
fn parse_single_valarm(
|
||||||
|
&self,
|
||||||
|
alarm: &ical::parser::ical::component::IcalAlarm,
|
||||||
|
) -> Result<VAlarm, CalDAVError> {
|
||||||
let mut properties: HashMap<String, String> = HashMap::new();
|
let mut properties: HashMap<String, String> = HashMap::new();
|
||||||
|
|
||||||
// Extract all properties from the alarm
|
// Extract all properties from the alarm
|
||||||
for property in &alarm.properties {
|
for property in &alarm.properties {
|
||||||
properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default());
|
properties.insert(
|
||||||
|
property.name.to_uppercase(),
|
||||||
|
property.value.clone().unwrap_or_default(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse ACTION (required)
|
// Parse ACTION (required)
|
||||||
let action = match properties.get("ACTION").map(|s| s.to_uppercase()) {
|
let action = match properties.get("ACTION").map(|s| s.to_uppercase()) {
|
||||||
Some(ref action_str) if action_str == "DISPLAY" => calendar_models::AlarmAction::Display,
|
Some(ref action_str) if action_str == "DISPLAY" => {
|
||||||
|
calendar_models::AlarmAction::Display
|
||||||
|
}
|
||||||
Some(ref action_str) if action_str == "EMAIL" => calendar_models::AlarmAction::Email,
|
Some(ref action_str) if action_str == "EMAIL" => calendar_models::AlarmAction::Email,
|
||||||
Some(ref action_str) if action_str == "AUDIO" => calendar_models::AlarmAction::Audio,
|
Some(ref action_str) if action_str == "AUDIO" => calendar_models::AlarmAction::Audio,
|
||||||
Some(ref action_str) if action_str == "PROCEDURE" => calendar_models::AlarmAction::Procedure,
|
Some(ref action_str) if action_str == "PROCEDURE" => {
|
||||||
|
calendar_models::AlarmAction::Procedure
|
||||||
|
}
|
||||||
_ => calendar_models::AlarmAction::Display, // Default
|
_ => calendar_models::AlarmAction::Display, // Default
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -468,15 +534,15 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
if trigger.starts_with("-PT") && trigger.ends_with("M") {
|
if trigger.starts_with("-PT") && trigger.ends_with("M") {
|
||||||
// Parse "-PT15M" format (minutes)
|
// Parse "-PT15M" format (minutes)
|
||||||
let minutes_str = &trigger[3..trigger.len()-1];
|
let minutes_str = &trigger[3..trigger.len() - 1];
|
||||||
minutes_str.parse::<i32>().ok()
|
minutes_str.parse::<i32>().ok()
|
||||||
} else if trigger.starts_with("-PT") && trigger.ends_with("H") {
|
} else if trigger.starts_with("-PT") && trigger.ends_with("H") {
|
||||||
// Parse "-PT1H" format (hours)
|
// Parse "-PT1H" format (hours)
|
||||||
let hours_str = &trigger[3..trigger.len()-1];
|
let hours_str = &trigger[3..trigger.len() - 1];
|
||||||
hours_str.parse::<i32>().ok().map(|h| h * 60)
|
hours_str.parse::<i32>().ok().map(|h| h * 60)
|
||||||
} else if trigger.starts_with("-P") && trigger.ends_with("D") {
|
} else if trigger.starts_with("-P") && trigger.ends_with("D") {
|
||||||
// Parse "-P1D" format (days)
|
// Parse "-P1D" format (days)
|
||||||
let days_str = &trigger[2..trigger.len()-1];
|
let days_str = &trigger[2..trigger.len() - 1];
|
||||||
days_str.parse::<i32>().ok().map(|d| d * 24 * 60)
|
days_str.parse::<i32>().ok().map(|d| d * 24 * 60)
|
||||||
} else {
|
} else {
|
||||||
// Try to parse as raw minutes
|
// Try to parse as raw minutes
|
||||||
@@ -498,10 +564,7 @@ impl CalDAVClient {
|
|||||||
// Note: paths should be relative to the server URL base
|
// Note: paths should be relative to the server URL base
|
||||||
let user_calendar_path = format!("/calendars/{}/", self.config.username);
|
let user_calendar_path = format!("/calendars/{}/", self.config.username);
|
||||||
|
|
||||||
let discovery_paths = vec![
|
let discovery_paths = vec!["/calendars/", user_calendar_path.as_str()];
|
||||||
"/calendars/",
|
|
||||||
user_calendar_path.as_str(),
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut all_calendars = Vec::new();
|
let mut all_calendars = Vec::new();
|
||||||
|
|
||||||
@@ -533,9 +596,13 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
let url = format!("{}{}", self.config.server_url.trim_end_matches('/'), path);
|
let url = format!("{}{}", self.config.server_url.trim_end_matches('/'), path);
|
||||||
|
|
||||||
let response = self.http_client
|
let response = self
|
||||||
|
.http_client
|
||||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url)
|
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url)
|
||||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", self.config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("Content-Type", "application/xml")
|
.header("Content-Type", "application/xml")
|
||||||
.header("Depth", "2") // Deeper search to find actual calendars
|
.header("Depth", "2") // Deeper search to find actual calendars
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
@@ -545,7 +612,11 @@ impl CalDAVClient {
|
|||||||
.map_err(CalDAVError::RequestError)?;
|
.map_err(CalDAVError::RequestError)?;
|
||||||
|
|
||||||
if response.status().as_u16() != 207 {
|
if response.status().as_u16() != 207 {
|
||||||
println!("❌ Discovery PROPFIND failed for {}: HTTP {}", path, response.status().as_u16());
|
println!(
|
||||||
|
"❌ Discovery PROPFIND failed for {}: HTTP {}",
|
||||||
|
path,
|
||||||
|
response.status().as_u16()
|
||||||
|
);
|
||||||
return Err(CalDAVError::ServerError(response.status().as_u16()));
|
return Err(CalDAVError::ServerError(response.status().as_u16()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,19 +636,26 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
// Check if this is a calendar collection by looking for supported-calendar-component-set
|
// Check if this is a calendar collection by looking for supported-calendar-component-set
|
||||||
// This indicates it's an actual calendar that can contain events
|
// This indicates it's an actual calendar that can contain events
|
||||||
let has_supported_components = response_content.contains("supported-calendar-component-set") &&
|
let has_supported_components = response_content
|
||||||
(response_content.contains("VEVENT") || response_content.contains("VTODO"));
|
.contains("supported-calendar-component-set")
|
||||||
let has_calendar_resourcetype = response_content.contains("<cal:calendar") || response_content.contains("<c:calendar");
|
&& (response_content.contains("VEVENT")
|
||||||
|
|| response_content.contains("VTODO"));
|
||||||
|
let has_calendar_resourcetype = response_content.contains("<cal:calendar")
|
||||||
|
|| response_content.contains("<c:calendar");
|
||||||
|
|
||||||
let is_calendar = has_supported_components || has_calendar_resourcetype;
|
let is_calendar = has_supported_components || has_calendar_resourcetype;
|
||||||
|
|
||||||
// Also check resourcetype for collection
|
// Also check resourcetype for collection
|
||||||
let has_collection = response_content.contains("<d:collection") || response_content.contains("<collection");
|
let has_collection = response_content.contains("<d:collection")
|
||||||
|
|| response_content.contains("<collection");
|
||||||
|
|
||||||
if is_calendar && has_collection {
|
if is_calendar && has_collection {
|
||||||
// Exclude system directories like inbox, outbox, and root calendar directories
|
// Exclude system directories like inbox, outbox, and root calendar directories
|
||||||
if !href.contains("/inbox/") && !href.contains("/outbox/") &&
|
if !href.contains("/inbox/")
|
||||||
!href.ends_with("/calendars/") && href.ends_with('/') {
|
&& !href.contains("/outbox/")
|
||||||
|
&& !href.ends_with("/calendars/")
|
||||||
|
&& href.ends_with('/')
|
||||||
|
{
|
||||||
println!("📅 Found calendar collection: {}", href);
|
println!("📅 Found calendar collection: {}", href);
|
||||||
calendar_paths.push(href);
|
calendar_paths.push(href);
|
||||||
} else {
|
} else {
|
||||||
@@ -595,7 +673,11 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse iCal datetime format
|
/// Parse iCal datetime format
|
||||||
fn parse_datetime(&self, datetime_str: &str, _original_property: Option<&String>) -> Result<DateTime<Utc>, CalDAVError> {
|
fn parse_datetime(
|
||||||
|
&self,
|
||||||
|
datetime_str: &str,
|
||||||
|
_original_property: Option<&String>,
|
||||||
|
) -> Result<DateTime<Utc>, CalDAVError> {
|
||||||
use chrono::TimeZone;
|
use chrono::TimeZone;
|
||||||
|
|
||||||
// Handle different iCal datetime formats
|
// Handle different iCal datetime formats
|
||||||
@@ -603,9 +685,9 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
// Try different parsing formats
|
// Try different parsing formats
|
||||||
let formats = [
|
let formats = [
|
||||||
"%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z
|
"%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z
|
||||||
"%Y%m%dT%H%M%S", // Local format: 20231225T120000
|
"%Y%m%dT%H%M%S", // Local format: 20231225T120000
|
||||||
"%Y%m%d", // Date only: 20231225
|
"%Y%m%d", // Date only: 20231225
|
||||||
];
|
];
|
||||||
|
|
||||||
for format in &formats {
|
for format in &formats {
|
||||||
@@ -617,7 +699,10 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(CalDAVError::ParseError(format!("Unable to parse datetime: {}", datetime_str)))
|
Err(CalDAVError::ParseError(format!(
|
||||||
|
"Unable to parse datetime: {}",
|
||||||
|
datetime_str
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse EXDATE properties from an iCal event
|
/// Parse EXDATE properties from an iCal event
|
||||||
@@ -643,7 +728,12 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new calendar on the CalDAV server using MKCALENDAR
|
/// Create a new calendar on the CalDAV server using MKCALENDAR
|
||||||
pub async fn create_calendar(&self, name: &str, description: Option<&str>, color: Option<&str>) -> Result<(), CalDAVError> {
|
pub async fn create_calendar(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
color: Option<&str>,
|
||||||
|
) -> Result<(), CalDAVError> {
|
||||||
// Sanitize calendar name for URL path
|
// Sanitize calendar name for URL path
|
||||||
let calendar_id = name
|
let calendar_id = name
|
||||||
.chars()
|
.chars()
|
||||||
@@ -652,17 +742,27 @@ impl CalDAVClient {
|
|||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
|
|
||||||
let calendar_path = format!("/calendars/{}/{}/", self.config.username, calendar_id);
|
let calendar_path = format!("/calendars/{}/{}/", self.config.username, calendar_id);
|
||||||
let full_url = format!("{}{}", self.config.server_url.trim_end_matches('/'), calendar_path);
|
let full_url = format!(
|
||||||
|
"{}{}",
|
||||||
|
self.config.server_url.trim_end_matches('/'),
|
||||||
|
calendar_path
|
||||||
|
);
|
||||||
|
|
||||||
// Build color property if provided
|
// Build color property if provided
|
||||||
let color_property = if let Some(color) = color {
|
let color_property = if let Some(color) = color {
|
||||||
format!(r#"<ic:calendar-color xmlns:ic="http://apple.com/ns/ical/">{}</ic:calendar-color>"#, color)
|
format!(
|
||||||
|
r#"<ic:calendar-color xmlns:ic="http://apple.com/ns/ical/">{}</ic:calendar-color>"#,
|
||||||
|
color
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
let description_property = if let Some(desc) = description {
|
let description_property = if let Some(desc) = description {
|
||||||
format!(r#"<c:calendar-description xmlns:c="urn:ietf:params:xml:ns:caldav">{}</c:calendar-description>"#, desc)
|
format!(
|
||||||
|
r#"<c:calendar-description xmlns:c="urn:ietf:params:xml:ns:caldav">{}</c:calendar-description>"#,
|
||||||
|
desc
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
@@ -688,10 +788,17 @@ impl CalDAVClient {
|
|||||||
println!("Creating calendar at: {}", full_url);
|
println!("Creating calendar at: {}", full_url);
|
||||||
println!("MKCALENDAR body: {}", mkcalendar_body);
|
println!("MKCALENDAR body: {}", mkcalendar_body);
|
||||||
|
|
||||||
let response = self.http_client
|
let response = self
|
||||||
.request(reqwest::Method::from_bytes(b"MKCALENDAR").unwrap(), &full_url)
|
.http_client
|
||||||
|
.request(
|
||||||
|
reqwest::Method::from_bytes(b"MKCALENDAR").unwrap(),
|
||||||
|
&full_url,
|
||||||
|
)
|
||||||
.header("Content-Type", "application/xml; charset=utf-8")
|
.header("Content-Type", "application/xml; charset=utf-8")
|
||||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", self.config.get_basic_auth()),
|
||||||
|
)
|
||||||
.body(mkcalendar_body)
|
.body(mkcalendar_body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -721,14 +828,22 @@ impl CalDAVClient {
|
|||||||
} else {
|
} else {
|
||||||
calendar_path
|
calendar_path
|
||||||
};
|
};
|
||||||
format!("{}{}", self.config.server_url.trim_end_matches('/'), clean_path)
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
self.config.server_url.trim_end_matches('/'),
|
||||||
|
clean_path
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("Deleting calendar at: {}", full_url);
|
println!("Deleting calendar at: {}", full_url);
|
||||||
|
|
||||||
let response = self.http_client
|
let response = self
|
||||||
|
.http_client
|
||||||
.delete(&full_url)
|
.delete(&full_url)
|
||||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", self.config.get_basic_auth()),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
|
.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
|
||||||
@@ -747,7 +862,11 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new event in a CalDAV calendar
|
/// Create a new event in a CalDAV calendar
|
||||||
pub async fn create_event(&self, calendar_path: &str, event: &CalendarEvent) -> Result<String, CalDAVError> {
|
pub async fn create_event(
|
||||||
|
&self,
|
||||||
|
calendar_path: &str,
|
||||||
|
event: &CalendarEvent,
|
||||||
|
) -> Result<String, CalDAVError> {
|
||||||
// Generate a unique filename for the event (using UID + .ics extension)
|
// Generate a unique filename for the event (using UID + .ics extension)
|
||||||
let event_filename = format!("{}.ics", event.uid);
|
let event_filename = format!("{}.ics", event.uid);
|
||||||
|
|
||||||
@@ -790,9 +909,13 @@ impl CalDAVClient {
|
|||||||
let _lock = CALDAV_HTTP_MUTEX.lock().await;
|
let _lock = CALDAV_HTTP_MUTEX.lock().await;
|
||||||
println!("📡 Lock acquired, sending CREATE request to CalDAV server...");
|
println!("📡 Lock acquired, sending CREATE request to CalDAV server...");
|
||||||
|
|
||||||
let response = self.http_client
|
let response = self
|
||||||
|
.http_client
|
||||||
.put(&full_url)
|
.put(&full_url)
|
||||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", self.config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("Content-Type", "text/calendar; charset=utf-8")
|
.header("Content-Type", "text/calendar; charset=utf-8")
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
.body(ical_data)
|
.body(ical_data)
|
||||||
@@ -814,13 +937,22 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update an existing event on the CalDAV server
|
/// Update an existing event on the CalDAV server
|
||||||
pub async fn update_event(&self, calendar_path: &str, event: &CalendarEvent, event_href: &str) -> Result<(), CalDAVError> {
|
pub async fn update_event(
|
||||||
|
&self,
|
||||||
|
calendar_path: &str,
|
||||||
|
event: &CalendarEvent,
|
||||||
|
event_href: &str,
|
||||||
|
) -> Result<(), CalDAVError> {
|
||||||
// Construct the full URL for the event
|
// Construct the full URL for the event
|
||||||
let full_url = if event_href.starts_with("http") {
|
let full_url = if event_href.starts_with("http") {
|
||||||
event_href.to_string()
|
event_href.to_string()
|
||||||
} else if event_href.starts_with("/dav.php") {
|
} else if event_href.starts_with("/dav.php") {
|
||||||
// Event href is already a full path, combine with base server URL (without /dav.php)
|
// Event href is already a full path, combine with base server URL (without /dav.php)
|
||||||
let base_url = self.config.server_url.trim_end_matches('/').trim_end_matches("/dav.php");
|
let base_url = self
|
||||||
|
.config
|
||||||
|
.server_url
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.trim_end_matches("/dav.php");
|
||||||
format!("{}{}", base_url, event_href)
|
format!("{}{}", base_url, event_href)
|
||||||
} else {
|
} else {
|
||||||
// Event href is just a filename, combine with calendar path
|
// Event href is just a filename, combine with calendar path
|
||||||
@@ -829,7 +961,12 @@ impl CalDAVClient {
|
|||||||
} else {
|
} else {
|
||||||
calendar_path
|
calendar_path
|
||||||
};
|
};
|
||||||
format!("{}/dav.php{}/{}", self.config.server_url.trim_end_matches('/'), clean_path, event_href)
|
format!(
|
||||||
|
"{}/dav.php{}/{}",
|
||||||
|
self.config.server_url.trim_end_matches('/'),
|
||||||
|
clean_path,
|
||||||
|
event_href
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("📝 Updating event at: {}", full_url);
|
println!("📝 Updating event at: {}", full_url);
|
||||||
@@ -846,9 +983,13 @@ impl CalDAVClient {
|
|||||||
println!("🔗 PUT URL: {}", full_url);
|
println!("🔗 PUT URL: {}", full_url);
|
||||||
println!("🔍 Request headers: Authorization: Basic [HIDDEN], Content-Type: text/calendar; charset=utf-8");
|
println!("🔍 Request headers: Authorization: Basic [HIDDEN], Content-Type: text/calendar; charset=utf-8");
|
||||||
|
|
||||||
let response = self.http_client
|
let response = self
|
||||||
|
.http_client
|
||||||
.put(&full_url)
|
.put(&full_url)
|
||||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", self.config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("Content-Type", "text/calendar; charset=utf-8")
|
.header("Content-Type", "text/calendar; charset=utf-8")
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
@@ -862,7 +1003,10 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
println!("Event update response status: {}", response.status());
|
println!("Event update response status: {}", response.status());
|
||||||
|
|
||||||
if response.status().is_success() || response.status().as_u16() == 201 || response.status().as_u16() == 204 {
|
if response.status().is_success()
|
||||||
|
|| response.status().as_u16() == 201
|
||||||
|
|| response.status().as_u16() == 204
|
||||||
|
{
|
||||||
println!("✅ Event updated successfully");
|
println!("✅ Event updated successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -878,13 +1022,10 @@ impl CalDAVClient {
|
|||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
|
|
||||||
// Format datetime for iCal (YYYYMMDDTHHMMSSZ format)
|
// Format datetime for iCal (YYYYMMDDTHHMMSSZ format)
|
||||||
let format_datetime = |dt: &DateTime<Utc>| -> String {
|
let format_datetime =
|
||||||
dt.format("%Y%m%dT%H%M%SZ").to_string()
|
|dt: &DateTime<Utc>| -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() };
|
||||||
};
|
|
||||||
|
|
||||||
let format_date = |dt: &DateTime<Utc>| -> String {
|
let format_date = |dt: &DateTime<Utc>| -> String { dt.format("%Y%m%d").to_string() };
|
||||||
dt.format("%Y%m%d").to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start building the iCal event
|
// Start building the iCal event
|
||||||
let mut ical = String::new();
|
let mut ical = String::new();
|
||||||
@@ -899,7 +1040,10 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
// Start and end times
|
// Start and end times
|
||||||
if event.all_day {
|
if event.all_day {
|
||||||
ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", format_date(&event.dtstart)));
|
ical.push_str(&format!(
|
||||||
|
"DTSTART;VALUE=DATE:{}\r\n",
|
||||||
|
format_date(&event.dtstart)
|
||||||
|
));
|
||||||
if let Some(end) = &event.dtend {
|
if let Some(end) = &event.dtend {
|
||||||
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end)));
|
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end)));
|
||||||
}
|
}
|
||||||
@@ -916,7 +1060,10 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(description) = &event.description {
|
if let Some(description) = &event.description {
|
||||||
ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(description)));
|
ical.push_str(&format!(
|
||||||
|
"DESCRIPTION:{}\r\n",
|
||||||
|
self.escape_ical_text(description)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(location) = &event.location {
|
if let Some(location) = &event.location {
|
||||||
@@ -951,7 +1098,10 @@ impl CalDAVClient {
|
|||||||
// Categories
|
// Categories
|
||||||
if !event.categories.is_empty() {
|
if !event.categories.is_empty() {
|
||||||
let categories = event.categories.join(",");
|
let categories = event.categories.join(",");
|
||||||
ical.push_str(&format!("CATEGORIES:{}\r\n", self.escape_ical_text(&categories)));
|
ical.push_str(&format!(
|
||||||
|
"CATEGORIES:{}\r\n",
|
||||||
|
self.escape_ical_text(&categories)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creation and modification times
|
// Creation and modification times
|
||||||
@@ -989,9 +1139,15 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(description) = &alarm.description {
|
if let Some(description) = &alarm.description {
|
||||||
ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(description)));
|
ical.push_str(&format!(
|
||||||
|
"DESCRIPTION:{}\r\n",
|
||||||
|
self.escape_ical_text(description)
|
||||||
|
));
|
||||||
} else if let Some(summary) = &event.summary {
|
} else if let Some(summary) = &event.summary {
|
||||||
ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(summary)));
|
ical.push_str(&format!(
|
||||||
|
"DESCRIPTION:{}\r\n",
|
||||||
|
self.escape_ical_text(summary)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
ical.push_str("END:VALARM\r\n");
|
ical.push_str("END:VALARM\r\n");
|
||||||
@@ -1005,7 +1161,10 @@ impl CalDAVClient {
|
|||||||
// Exception dates (EXDATE)
|
// Exception dates (EXDATE)
|
||||||
for exception_date in &event.exdate {
|
for exception_date in &event.exdate {
|
||||||
if event.all_day {
|
if event.all_day {
|
||||||
ical.push_str(&format!("EXDATE;VALUE=DATE:{}\r\n", format_date(exception_date)));
|
ical.push_str(&format!(
|
||||||
|
"EXDATE;VALUE=DATE:{}\r\n",
|
||||||
|
format_date(exception_date)
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
ical.push_str(&format!("EXDATE:{}\r\n", format_datetime(exception_date)));
|
ical.push_str(&format!("EXDATE:{}\r\n", format_datetime(exception_date)));
|
||||||
}
|
}
|
||||||
@@ -1027,13 +1186,21 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete an event from a CalDAV calendar
|
/// Delete an event from a CalDAV calendar
|
||||||
pub async fn delete_event(&self, calendar_path: &str, event_href: &str) -> Result<(), CalDAVError> {
|
pub async fn delete_event(
|
||||||
|
&self,
|
||||||
|
calendar_path: &str,
|
||||||
|
event_href: &str,
|
||||||
|
) -> Result<(), CalDAVError> {
|
||||||
// Construct the full URL for the event
|
// Construct the full URL for the event
|
||||||
let full_url = if event_href.starts_with("http") {
|
let full_url = if event_href.starts_with("http") {
|
||||||
event_href.to_string()
|
event_href.to_string()
|
||||||
} else if event_href.starts_with("/dav.php") {
|
} else if event_href.starts_with("/dav.php") {
|
||||||
// Event href is already a full path, combine with base server URL (without /dav.php)
|
// Event href is already a full path, combine with base server URL (without /dav.php)
|
||||||
let base_url = self.config.server_url.trim_end_matches('/').trim_end_matches("/dav.php");
|
let base_url = self
|
||||||
|
.config
|
||||||
|
.server_url
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.trim_end_matches("/dav.php");
|
||||||
format!("{}{}", base_url, event_href)
|
format!("{}{}", base_url, event_href)
|
||||||
} else {
|
} else {
|
||||||
// Event href is just a filename, combine with calendar path
|
// Event href is just a filename, combine with calendar path
|
||||||
@@ -1042,7 +1209,12 @@ impl CalDAVClient {
|
|||||||
} else {
|
} else {
|
||||||
calendar_path
|
calendar_path
|
||||||
};
|
};
|
||||||
format!("{}/dav.php{}/{}", self.config.server_url.trim_end_matches('/'), clean_path, event_href)
|
format!(
|
||||||
|
"{}/dav.php{}/{}",
|
||||||
|
self.config.server_url.trim_end_matches('/'),
|
||||||
|
clean_path,
|
||||||
|
event_href
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("Deleting event at: {}", full_url);
|
println!("Deleting event at: {}", full_url);
|
||||||
@@ -1051,9 +1223,13 @@ impl CalDAVClient {
|
|||||||
let _lock = CALDAV_HTTP_MUTEX.lock().await;
|
let _lock = CALDAV_HTTP_MUTEX.lock().await;
|
||||||
println!("📡 Lock acquired, sending DELETE request to CalDAV server...");
|
println!("📡 Lock acquired, sending DELETE request to CalDAV server...");
|
||||||
|
|
||||||
let response = self.http_client
|
let response = self
|
||||||
|
.http_client
|
||||||
.delete(&full_url)
|
.delete(&full_url)
|
||||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", self.config.get_basic_auth()),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
|
.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
|
||||||
@@ -1103,8 +1279,11 @@ mod tests {
|
|||||||
/// This test requires a valid .env file and a calendar with some events
|
/// This test requires a valid .env file and a calendar with some events
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_fetch_calendar_events() {
|
async fn test_fetch_calendar_events() {
|
||||||
let config = CalDAVConfig::from_env()
|
let config = CalDAVConfig::new(
|
||||||
.expect("Failed to load CalDAV config from environment");
|
"https://example.com".to_string(),
|
||||||
|
"test_user".to_string(),
|
||||||
|
"test_password".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
@@ -1147,7 +1326,10 @@ mod tests {
|
|||||||
for event in &events {
|
for event in &events {
|
||||||
assert!(!event.uid.is_empty(), "Event UID should not be empty");
|
assert!(!event.uid.is_empty(), "Event UID should not be empty");
|
||||||
// All events should have a start time
|
// All events should have a start time
|
||||||
assert!(event.dtstart > DateTime::from_timestamp(0, 0).unwrap(), "Event should have valid start time");
|
assert!(
|
||||||
|
event.dtstart > DateTime::from_timestamp(0, 0).unwrap(),
|
||||||
|
"Event should have valid start time"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("\n✓ Calendar event fetching test passed!");
|
println!("\n✓ Calendar event fetching test passed!");
|
||||||
@@ -1192,11 +1374,11 @@ END:VCALENDAR"#;
|
|||||||
username: "test".to_string(),
|
username: "test".to_string(),
|
||||||
password: "test".to_string(),
|
password: "test".to_string(),
|
||||||
calendar_path: None,
|
calendar_path: None,
|
||||||
tasks_path: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
let events = client.parse_ical_data(sample_ical)
|
let events = client
|
||||||
|
.parse_ical_data(sample_ical)
|
||||||
.expect("Should be able to parse sample iCal data");
|
.expect("Should be able to parse sample iCal data");
|
||||||
|
|
||||||
assert_eq!(events.len(), 1);
|
assert_eq!(events.len(), 1);
|
||||||
@@ -1223,23 +1405,25 @@ END:VCALENDAR"#;
|
|||||||
username: "test".to_string(),
|
username: "test".to_string(),
|
||||||
password: "test".to_string(),
|
password: "test".to_string(),
|
||||||
calendar_path: None,
|
calendar_path: None,
|
||||||
tasks_path: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Test UTC format
|
// Test UTC format
|
||||||
let dt1 = client.parse_datetime("20231225T120000Z", None)
|
let dt1 = client
|
||||||
|
.parse_datetime("20231225T120000Z", None)
|
||||||
.expect("Should parse UTC datetime");
|
.expect("Should parse UTC datetime");
|
||||||
println!("Parsed UTC datetime: {}", dt1);
|
println!("Parsed UTC datetime: {}", dt1);
|
||||||
|
|
||||||
// Test date-only format (should be treated as all-day)
|
// Test date-only format (should be treated as all-day)
|
||||||
let dt2 = client.parse_datetime("20231225", None)
|
let dt2 = client
|
||||||
|
.parse_datetime("20231225", None)
|
||||||
.expect("Should parse date-only");
|
.expect("Should parse date-only");
|
||||||
println!("Parsed date-only: {}", dt2);
|
println!("Parsed date-only: {}", dt2);
|
||||||
|
|
||||||
// Test local format
|
// Test local format
|
||||||
let dt3 = client.parse_datetime("20231225T120000", None)
|
let dt3 = client
|
||||||
|
.parse_datetime("20231225T120000", None)
|
||||||
.expect("Should parse local datetime");
|
.expect("Should parse local datetime");
|
||||||
println!("Parsed local datetime: {}", dt3);
|
println!("Parsed local datetime: {}", dt3);
|
||||||
|
|
||||||
@@ -1259,5 +1443,4 @@ END:VCALENDAR"#;
|
|||||||
|
|
||||||
println!("✓ Event enum tests passed!");
|
println!("✓ Event enum tests passed!");
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
use base64::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::env;
|
use std::env;
|
||||||
use base64::prelude::*;
|
|
||||||
|
|
||||||
/// Configuration for CalDAV server connection and authentication.
|
/// Configuration for CalDAV server connection and authentication.
|
||||||
///
|
///
|
||||||
@@ -139,7 +139,6 @@ mod tests {
|
|||||||
username: "testuser".to_string(),
|
username: "testuser".to_string(),
|
||||||
password: "testpass".to_string(),
|
password: "testpass".to_string(),
|
||||||
calendar_path: None,
|
calendar_path: None,
|
||||||
tasks_path: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth = config.get_basic_auth();
|
let auth = config.get_basic_auth();
|
||||||
@@ -161,7 +160,7 @@ mod tests {
|
|||||||
let config = CalDAVConfig::new(
|
let config = CalDAVConfig::new(
|
||||||
"https://example.com".to_string(),
|
"https://example.com".to_string(),
|
||||||
"test_user".to_string(),
|
"test_user".to_string(),
|
||||||
"test_password".to_string()
|
"test_password".to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
println!("Testing authentication to: {}", config.server_url);
|
println!("Testing authentication to: {}", config.server_url);
|
||||||
@@ -172,7 +171,10 @@ mod tests {
|
|||||||
// Make a simple OPTIONS request to test authentication
|
// Make a simple OPTIONS request to test authentication
|
||||||
let response = client
|
let response = client
|
||||||
.request(reqwest::Method::OPTIONS, &config.server_url)
|
.request(reqwest::Method::OPTIONS, &config.server_url)
|
||||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -190,9 +192,9 @@ mod tests {
|
|||||||
|
|
||||||
// For Baikal/CalDAV servers, we should see DAV headers
|
// For Baikal/CalDAV servers, we should see DAV headers
|
||||||
assert!(
|
assert!(
|
||||||
response.headers().contains_key("dav") ||
|
response.headers().contains_key("dav")
|
||||||
response.headers().contains_key("DAV") ||
|
|| response.headers().contains_key("DAV")
|
||||||
response.status().is_success(),
|
|| response.status().is_success(),
|
||||||
"Server doesn't appear to be a CalDAV server - missing DAV headers"
|
"Server doesn't appear to be a CalDAV server - missing DAV headers"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -210,7 +212,7 @@ mod tests {
|
|||||||
let config = CalDAVConfig::new(
|
let config = CalDAVConfig::new(
|
||||||
"https://example.com".to_string(),
|
"https://example.com".to_string(),
|
||||||
"test_user".to_string(),
|
"test_user".to_string(),
|
||||||
"test_password".to_string()
|
"test_password".to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
@@ -227,8 +229,14 @@ mod tests {
|
|||||||
</d:propfind>"#;
|
</d:propfind>"#;
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url)
|
.request(
|
||||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
reqwest::Method::from_bytes(b"PROPFIND").unwrap(),
|
||||||
|
&config.server_url,
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("Content-Type", "application/xml")
|
.header("Content-Type", "application/xml")
|
||||||
.header("Depth", "1")
|
.header("Depth", "1")
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
@@ -251,7 +259,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// The response should contain XML with calendar information
|
// The response should contain XML with calendar information
|
||||||
assert!(body.contains("calendar"), "Response should contain calendar information");
|
assert!(
|
||||||
|
body.contains("calendar"),
|
||||||
|
"Response should contain calendar information"
|
||||||
|
);
|
||||||
|
|
||||||
println!("✓ PROPFIND calendars test passed!");
|
println!("✓ PROPFIND calendars test passed!");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ mod calendar;
|
|||||||
mod events;
|
mod events;
|
||||||
mod series;
|
mod series;
|
||||||
|
|
||||||
pub use auth::{login, verify_token, get_user_info};
|
pub use auth::{get_user_info, login, verify_token};
|
||||||
pub use calendar::{create_calendar, delete_calendar};
|
pub use calendar::{create_calendar, delete_calendar};
|
||||||
pub use events::{get_calendar_events, refresh_event, create_event, update_event, delete_event};
|
pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event};
|
||||||
pub use series::{create_event_series, update_event_series, delete_event_series};
|
pub use series::{create_event_series, delete_event_series, update_event_series};
|
||||||
|
|||||||
@@ -1,33 +1,38 @@
|
|||||||
use axum::{
|
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||||
extract::State,
|
|
||||||
http::HeaderMap,
|
|
||||||
response::Json,
|
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo}};
|
|
||||||
use crate::calendar::CalDAVClient;
|
use crate::calendar::CalDAVClient;
|
||||||
use crate::config::CalDAVConfig;
|
use crate::config::CalDAVConfig;
|
||||||
|
use crate::{
|
||||||
|
models::{ApiError, AuthResponse, CalDAVLoginRequest, CalendarInfo, UserInfo},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
|
pub fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
|
||||||
let auth_header = headers.get("authorization")
|
let auth_header = headers
|
||||||
|
.get("authorization")
|
||||||
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
|
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
|
||||||
|
|
||||||
let auth_str = auth_header.to_str()
|
let auth_str = auth_header
|
||||||
|
.to_str()
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid Authorization header".to_string()))?;
|
.map_err(|_| ApiError::BadRequest("Invalid Authorization header".to_string()))?;
|
||||||
|
|
||||||
if let Some(token) = auth_str.strip_prefix("Bearer ") {
|
if let Some(token) = auth_str.strip_prefix("Bearer ") {
|
||||||
Ok(token.to_string())
|
Ok(token.to_string())
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::BadRequest("Authorization header must be Bearer token".to_string()))
|
Err(ApiError::BadRequest(
|
||||||
|
"Authorization header must be Bearer token".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
|
pub fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
|
||||||
let password_header = headers.get("x-caldav-password")
|
let password_header = headers
|
||||||
|
.get("x-caldav-password")
|
||||||
.ok_or_else(|| ApiError::BadRequest("Missing X-CalDAV-Password header".to_string()))?;
|
.ok_or_else(|| ApiError::BadRequest("Missing X-CalDAV-Password header".to_string()))?;
|
||||||
|
|
||||||
password_header.to_str()
|
password_header
|
||||||
|
.to_str()
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string()))
|
.map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string()))
|
||||||
}
|
}
|
||||||
@@ -43,7 +48,9 @@ pub async fn login(
|
|||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
if request.username.is_empty() || request.password.is_empty() || request.server_url.is_empty() {
|
if request.username.is_empty() || request.password.is_empty() || request.server_url.is_empty() {
|
||||||
return Err(ApiError::BadRequest("Username, password, and server URL are required".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Username, password, and server URL are required".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("✅ Input validation passed");
|
println!("✅ Input validation passed");
|
||||||
@@ -55,14 +62,17 @@ pub async fn login(
|
|||||||
let config = CalDAVConfig::new(
|
let config = CalDAVConfig::new(
|
||||||
request.server_url.clone(),
|
request.server_url.clone(),
|
||||||
request.username.clone(),
|
request.username.clone(),
|
||||||
request.password.clone()
|
request.password.clone(),
|
||||||
);
|
);
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
client.discover_calendars()
|
client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Unauthorized(format!("Authentication failed: {}", e)))?;
|
.map_err(|e| ApiError::Unauthorized(format!("Authentication failed: {}", e)))?;
|
||||||
|
|
||||||
let token = state.auth_service.generate_token(&request.username, &request.server_url)?;
|
let token = state
|
||||||
|
.auth_service
|
||||||
|
.generate_token(&request.username, &request.server_url)?;
|
||||||
|
|
||||||
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
||||||
|
|
||||||
@@ -91,23 +101,30 @@ pub async fn get_user_info(
|
|||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config.clone());
|
let client = CalDAVClient::new(config.clone());
|
||||||
|
|
||||||
// Discover calendars
|
// Discover calendars
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
println!("✅ Authentication successful! Found {} calendars", calendar_paths.len());
|
println!(
|
||||||
|
"✅ Authentication successful! Found {} calendars",
|
||||||
|
calendar_paths.len()
|
||||||
|
);
|
||||||
|
|
||||||
let calendars: Vec<CalendarInfo> = calendar_paths.iter().map(|path| {
|
let calendars: Vec<CalendarInfo> = calendar_paths
|
||||||
CalendarInfo {
|
.iter()
|
||||||
|
.map(|path| CalendarInfo {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
display_name: extract_calendar_name(path),
|
display_name: extract_calendar_name(path),
|
||||||
color: generate_calendar_color(path),
|
color: generate_calendar_color(path),
|
||||||
}
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(UserInfo {
|
Ok(Json(UserInfo {
|
||||||
username: config.username,
|
username: config.username,
|
||||||
@@ -126,10 +143,9 @@ fn generate_calendar_color(path: &str) -> String {
|
|||||||
|
|
||||||
// Define a set of pleasant colors
|
// Define a set of pleasant colors
|
||||||
let colors = [
|
let colors = [
|
||||||
"#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6",
|
"#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#06B6D4", "#84CC16", "#F97316",
|
||||||
"#06B6D4", "#84CC16", "#F97316", "#EC4899", "#6366F1",
|
"#EC4899", "#6366F1", "#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626", "#7C3AED",
|
||||||
"#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626",
|
"#059669", "#D97706", "#BE185D", "#4F46E5",
|
||||||
"#7C3AED", "#059669", "#D97706", "#BE185D", "#4F46E5"
|
|
||||||
];
|
];
|
||||||
|
|
||||||
colors[(hash as usize) % colors.len()].to_string()
|
colors[(hash as usize) % colors.len()].to_string()
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
use axum::{
|
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||||
extract::State,
|
|
||||||
http::HeaderMap,
|
|
||||||
response::Json,
|
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{AppState, models::{ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse}};
|
|
||||||
use crate::calendar::CalDAVClient;
|
use crate::calendar::CalDAVClient;
|
||||||
|
use crate::{
|
||||||
|
models::{
|
||||||
|
ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest,
|
||||||
|
DeleteCalendarResponse,
|
||||||
|
},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
use super::auth::{extract_bearer_token, extract_password_header};
|
use super::auth::{extract_bearer_token, extract_password_header};
|
||||||
|
|
||||||
@@ -20,22 +22,36 @@ pub async fn create_calendar(
|
|||||||
|
|
||||||
// Validate request
|
// Validate request
|
||||||
if request.name.trim().is_empty() {
|
if request.name.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest("Calendar name is required".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Calendar name is required".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Create calendar on CalDAV server
|
// Create calendar on CalDAV server
|
||||||
match client.create_calendar(&request.name, request.description.as_deref(), request.color.as_deref()).await {
|
match client
|
||||||
|
.create_calendar(
|
||||||
|
&request.name,
|
||||||
|
request.description.as_deref(),
|
||||||
|
request.color.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(_) => Ok(Json(CreateCalendarResponse {
|
Ok(_) => Ok(Json(CreateCalendarResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Calendar created successfully".to_string(),
|
message: "Calendar created successfully".to_string(),
|
||||||
})),
|
})),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to create calendar: {}", e);
|
eprintln!("Failed to create calendar: {}", e);
|
||||||
Err(ApiError::Internal(format!("Failed to create calendar: {}", e)))
|
Err(ApiError::Internal(format!(
|
||||||
|
"Failed to create calendar: {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,11 +66,15 @@ pub async fn delete_calendar(
|
|||||||
|
|
||||||
// Validate request
|
// Validate request
|
||||||
if request.path.trim().is_empty() {
|
if request.path.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest("Calendar path is required".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Calendar path is required".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Delete calendar on CalDAV server
|
// Delete calendar on CalDAV server
|
||||||
@@ -65,7 +85,10 @@ pub async fn delete_calendar(
|
|||||||
})),
|
})),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to delete calendar: {}", e);
|
eprintln!("Failed to delete calendar: {}", e);
|
||||||
Err(ApiError::Internal(format!("Failed to delete calendar: {}", e)))
|
Err(ApiError::Internal(format!(
|
||||||
|
"Failed to delete calendar: {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,23 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{State, Query, Path},
|
extract::{Path, Query, State},
|
||||||
http::HeaderMap,
|
http::HeaderMap,
|
||||||
response::Json,
|
response::Json,
|
||||||
};
|
};
|
||||||
|
use chrono::Datelike;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use chrono::Datelike;
|
|
||||||
|
|
||||||
use calendar_models::{VEvent, EventStatus, EventClass, CalendarUser, Attendee, VAlarm, AlarmAction, AlarmTrigger};
|
|
||||||
use crate::{AppState, models::{ApiError, DeleteEventRequest, DeleteEventResponse, CreateEventRequest, CreateEventResponse, UpdateEventRequest, UpdateEventResponse}};
|
|
||||||
use crate::calendar::{CalDAVClient, CalendarEvent};
|
use crate::calendar::{CalDAVClient, CalendarEvent};
|
||||||
|
use crate::{
|
||||||
|
models::{
|
||||||
|
ApiError, CreateEventRequest, CreateEventResponse, DeleteEventRequest, DeleteEventResponse,
|
||||||
|
UpdateEventRequest, UpdateEventResponse,
|
||||||
|
},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
use calendar_models::{
|
||||||
|
AlarmAction, AlarmTrigger, Attendee, CalendarUser, EventClass, EventStatus, VAlarm, VEvent,
|
||||||
|
};
|
||||||
|
|
||||||
use super::auth::{extract_bearer_token, extract_password_header};
|
use super::auth::{extract_bearer_token, extract_password_header};
|
||||||
|
|
||||||
@@ -30,11 +38,14 @@ pub async fn get_calendar_events(
|
|||||||
println!("🔑 API call with password length: {}", password.len());
|
println!("🔑 API call with password length: {}", password.len());
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Discover calendars if needed
|
// Discover calendars if needed
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
@@ -54,7 +65,10 @@ pub async fn get_calendar_events(
|
|||||||
all_events.extend(events);
|
all_events.extend(events);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e);
|
eprintln!(
|
||||||
|
"Failed to fetch events from calendar {}: {}",
|
||||||
|
calendar_path, e
|
||||||
|
);
|
||||||
// Continue with other calendars instead of failing completely
|
// Continue with other calendars instead of failing completely
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,11 +96,14 @@ pub async fn refresh_event(
|
|||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Discover calendars
|
// Discover calendars
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
@@ -101,13 +118,20 @@ pub async fn refresh_event(
|
|||||||
Ok(Json(None))
|
Ok(Json(None))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_href: &str) -> Result<Option<CalendarEvent>, crate::calendar::CalDAVError> {
|
async fn fetch_event_by_href(
|
||||||
|
client: &CalDAVClient,
|
||||||
|
calendar_path: &str,
|
||||||
|
event_href: &str,
|
||||||
|
) -> Result<Option<CalendarEvent>, crate::calendar::CalDAVError> {
|
||||||
// This is a simplified implementation - in a real scenario, you'd want to fetch the specific event by href
|
// This is a simplified implementation - in a real scenario, you'd want to fetch the specific event by href
|
||||||
// For now, we'll fetch all events and find the matching one by href (inefficient but functional)
|
// For now, we'll fetch all events and find the matching one by href (inefficient but functional)
|
||||||
let events = client.fetch_events(calendar_path).await?;
|
let events = client.fetch_events(calendar_path).await?;
|
||||||
|
|
||||||
println!("🔍 fetch_event_by_href: looking for href='{}'", event_href);
|
println!("🔍 fetch_event_by_href: looking for href='{}'", event_href);
|
||||||
println!("🔍 Available events with hrefs: {:?}", events.iter().map(|e| (&e.uid, &e.href)).collect::<Vec<_>>());
|
println!(
|
||||||
|
"🔍 Available events with hrefs: {:?}",
|
||||||
|
events.iter().map(|e| (&e.uid, &e.href)).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
// First try to match by exact href
|
// First try to match by exact href
|
||||||
for event in &events {
|
for event in &events {
|
||||||
@@ -123,7 +147,10 @@ async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_h
|
|||||||
let filename = event_href.split('/').last().unwrap_or(event_href);
|
let filename = event_href.split('/').last().unwrap_or(event_href);
|
||||||
let uid_from_href = filename.trim_end_matches(".ics");
|
let uid_from_href = filename.trim_end_matches(".ics");
|
||||||
|
|
||||||
println!("🔍 Fallback: trying UID match. filename='{}', uid='{}'", filename, uid_from_href);
|
println!(
|
||||||
|
"🔍 Fallback: trying UID match. filename='{}', uid='{}'",
|
||||||
|
filename, uid_from_href
|
||||||
|
);
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
if event.uid == uid_from_href {
|
if event.uid == uid_from_href {
|
||||||
@@ -146,23 +173,31 @@ pub async fn delete_event(
|
|||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Handle different delete actions for recurring events
|
// Handle different delete actions for recurring events
|
||||||
match request.delete_action.as_str() {
|
match request.delete_action.as_str() {
|
||||||
"delete_this" => {
|
"delete_this" => {
|
||||||
if let Some(event) = fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await
|
if let Some(event) =
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? {
|
fetch_event_by_href(&client, &request.calendar_path, &request.event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?
|
||||||
|
{
|
||||||
// Check if this is a recurring event
|
// Check if this is a recurring event
|
||||||
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
|
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
|
||||||
// Recurring event - add EXDATE for this occurrence
|
// Recurring event - add EXDATE for this occurrence
|
||||||
if let Some(occurrence_date) = &request.occurrence_date {
|
if let Some(occurrence_date) = &request.occurrence_date {
|
||||||
let exception_utc = if let Ok(date) = chrono::DateTime::parse_from_rfc3339(occurrence_date) {
|
let exception_utc = if let Ok(date) =
|
||||||
|
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
||||||
|
{
|
||||||
// RFC3339 format (with time and timezone)
|
// RFC3339 format (with time and timezone)
|
||||||
date.with_timezone(&chrono::Utc)
|
date.with_timezone(&chrono::Utc)
|
||||||
} else if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") {
|
} else if let Ok(naive_date) =
|
||||||
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||||
|
{
|
||||||
// Simple date format (YYYY-MM-DD)
|
// Simple date format (YYYY-MM-DD)
|
||||||
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
||||||
} else {
|
} else {
|
||||||
@@ -172,12 +207,26 @@ pub async fn delete_event(
|
|||||||
let mut updated_event = event;
|
let mut updated_event = event;
|
||||||
updated_event.exdate.push(exception_utc);
|
updated_event.exdate.push(exception_utc);
|
||||||
|
|
||||||
println!("🔄 Adding EXDATE {} to recurring event {}", exception_utc.format("%Y%m%dT%H%M%SZ"), updated_event.uid);
|
println!(
|
||||||
|
"🔄 Adding EXDATE {} to recurring event {}",
|
||||||
|
exception_utc.format("%Y%m%dT%H%M%SZ"),
|
||||||
|
updated_event.uid
|
||||||
|
);
|
||||||
|
|
||||||
// Update the event with the new EXDATE
|
// Update the event with the new EXDATE
|
||||||
client.update_event(&request.calendar_path, &updated_event, &request.event_href)
|
client
|
||||||
|
.update_event(
|
||||||
|
&request.calendar_path,
|
||||||
|
&updated_event,
|
||||||
|
&request.event_href,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to update event with EXDATE: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!(
|
||||||
|
"Failed to update event with EXDATE: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
println!("✅ Successfully updated recurring event with EXDATE");
|
println!("✅ Successfully updated recurring event with EXDATE");
|
||||||
|
|
||||||
@@ -192,9 +241,12 @@ pub async fn delete_event(
|
|||||||
// Non-recurring event - delete the entire event
|
// Non-recurring event - delete the entire event
|
||||||
println!("🗑️ Deleting non-recurring event: {}", event.uid);
|
println!("🗑️ Deleting non-recurring event: {}", event.uid);
|
||||||
|
|
||||||
client.delete_event(&request.calendar_path, &request.event_href)
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to delete event: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
println!("✅ Successfully deleted non-recurring event");
|
println!("✅ Successfully deleted non-recurring event");
|
||||||
|
|
||||||
@@ -206,51 +258,77 @@ pub async fn delete_event(
|
|||||||
} else {
|
} else {
|
||||||
Err(ApiError::NotFound("Event not found".to_string()))
|
Err(ApiError::NotFound("Event not found".to_string()))
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"delete_following" => {
|
"delete_following" => {
|
||||||
// For "this and following" deletion, we need to:
|
// For "this and following" deletion, we need to:
|
||||||
// 1. Fetch the recurring event
|
// 1. Fetch the recurring event
|
||||||
// 2. Modify the RRULE to end before this occurrence
|
// 2. Modify the RRULE to end before this occurrence
|
||||||
// 3. Update the event
|
// 3. Update the event
|
||||||
|
|
||||||
if let Some(mut event) = fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await
|
if let Some(mut event) =
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? {
|
fetch_event_by_href(&client, &request.calendar_path, &request.event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?
|
||||||
|
{
|
||||||
if let Some(occurrence_date) = &request.occurrence_date {
|
if let Some(occurrence_date) = &request.occurrence_date {
|
||||||
let until_date = if let Ok(date) = chrono::DateTime::parse_from_rfc3339(occurrence_date) {
|
let until_date = if let Ok(date) =
|
||||||
|
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
||||||
|
{
|
||||||
// RFC3339 format (with time and timezone)
|
// RFC3339 format (with time and timezone)
|
||||||
date.with_timezone(&chrono::Utc)
|
date.with_timezone(&chrono::Utc)
|
||||||
} else if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") {
|
} else if let Ok(naive_date) =
|
||||||
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||||
|
{
|
||||||
// Simple date format (YYYY-MM-DD)
|
// Simple date format (YYYY-MM-DD)
|
||||||
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
||||||
} else {
|
} else {
|
||||||
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
|
return Err(ApiError::BadRequest(format!(
|
||||||
|
"Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD",
|
||||||
|
occurrence_date
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Modify the RRULE to add an UNTIL clause
|
// Modify the RRULE to add an UNTIL clause
|
||||||
if let Some(rrule) = &event.rrule {
|
if let Some(rrule) = &event.rrule {
|
||||||
// Remove existing UNTIL if present and add new one
|
// Remove existing UNTIL if present and add new one
|
||||||
let parts: Vec<&str> = rrule.split(';').filter(|part| {
|
let parts: Vec<&str> = rrule
|
||||||
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
.split(';')
|
||||||
}).collect();
|
.filter(|part| {
|
||||||
|
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let new_rrule = format!("{};UNTIL={}", parts.join(";"), until_date.format("%Y%m%dT%H%M%SZ"));
|
let new_rrule = format!(
|
||||||
|
"{};UNTIL={}",
|
||||||
|
parts.join(";"),
|
||||||
|
until_date.format("%Y%m%dT%H%M%SZ")
|
||||||
|
);
|
||||||
event.rrule = Some(new_rrule);
|
event.rrule = Some(new_rrule);
|
||||||
|
|
||||||
// Update the event with the modified RRULE
|
// Update the event with the modified RRULE
|
||||||
client.update_event(&request.calendar_path, &event, &request.event_href)
|
client
|
||||||
|
.update_event(&request.calendar_path, &event, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to update event with modified RRULE: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!(
|
||||||
|
"Failed to update event with modified RRULE: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(DeleteEventResponse {
|
Ok(Json(DeleteEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "This and following occurrences deleted successfully".to_string(),
|
message: "This and following occurrences deleted successfully"
|
||||||
|
.to_string(),
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
// No RRULE, just delete the single event
|
// No RRULE, just delete the single event
|
||||||
client.delete_event(&request.calendar_path, &request.event_href)
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to delete event: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(DeleteEventResponse {
|
Ok(Json(DeleteEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -258,15 +336,18 @@ pub async fn delete_event(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::BadRequest("Occurrence date is required for following deletion".to_string()))
|
Err(ApiError::BadRequest(
|
||||||
|
"Occurrence date is required for following deletion".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::NotFound("Event not found".to_string()))
|
Err(ApiError::NotFound("Event not found".to_string()))
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"delete_series" | _ => {
|
"delete_series" | _ => {
|
||||||
// Delete the entire event/series
|
// Delete the entire event/series
|
||||||
client.delete_event(&request.calendar_path, &request.event_href)
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
||||||
|
|
||||||
@@ -283,8 +364,10 @@ pub async fn create_event(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(request): Json<CreateEventRequest>,
|
Json(request): Json<CreateEventRequest>,
|
||||||
) -> Result<Json<CreateEventResponse>, ApiError> {
|
) -> Result<Json<CreateEventResponse>, ApiError> {
|
||||||
println!("📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",
|
println!(
|
||||||
request.title, request.all_day, request.calendar_path);
|
"📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",
|
||||||
|
request.title, request.all_day, request.calendar_path
|
||||||
|
);
|
||||||
|
|
||||||
// Extract and verify token
|
// Extract and verify token
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
@@ -296,11 +379,15 @@ pub async fn create_event(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if request.title.len() > 200 {
|
if request.title.len() > 200 {
|
||||||
return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Event title too long (max 200 characters)".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Determine which calendar to use
|
// Determine which calendar to use
|
||||||
@@ -308,31 +395,41 @@ pub async fn create_event(
|
|||||||
path
|
path
|
||||||
} else {
|
} else {
|
||||||
// Use the first available calendar
|
// Use the first available calendar
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
if calendar_paths.is_empty() {
|
if calendar_paths.is_empty() {
|
||||||
return Err(ApiError::BadRequest("No calendars available for event creation".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"No calendars available for event creation".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
calendar_paths[0].clone()
|
calendar_paths[0].clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse dates and times
|
// Parse dates and times
|
||||||
let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
let start_datetime =
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
||||||
|
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
||||||
|
|
||||||
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
|
|
||||||
// Validate that end is after start
|
// Validate that end is after start
|
||||||
if end_datetime <= start_datetime {
|
if end_datetime <= start_datetime {
|
||||||
return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"End date/time must be after start date/time".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a unique UID for the event
|
// Generate a unique UID for the event
|
||||||
let uid = format!("{}-{}", uuid::Uuid::new_v4(), chrono::Utc::now().timestamp());
|
let uid = format!(
|
||||||
|
"{}-{}",
|
||||||
|
uuid::Uuid::new_v4(),
|
||||||
|
chrono::Utc::now().timestamp()
|
||||||
|
);
|
||||||
|
|
||||||
// Parse status
|
// Parse status
|
||||||
let status = match request.status.to_lowercase().as_str() {
|
let status = match request.status.to_lowercase().as_str() {
|
||||||
@@ -352,7 +449,8 @@ pub async fn create_event(
|
|||||||
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
|
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
request.attendees
|
request
|
||||||
|
.attendees
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
@@ -363,7 +461,8 @@ pub async fn create_event(
|
|||||||
let categories: Vec<String> = if request.categories.trim().is_empty() {
|
let categories: Vec<String> = if request.categories.trim().is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
request.categories
|
request
|
||||||
|
.categories
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
@@ -402,7 +501,8 @@ pub async fn create_event(
|
|||||||
|
|
||||||
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
|
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
|
||||||
if request.recurrence_days.len() == 7 {
|
if request.recurrence_days.len() == 7 {
|
||||||
let selected_days: Vec<&str> = request.recurrence_days
|
let selected_days: Vec<&str> = request
|
||||||
|
.recurrence_days
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(i, &selected)| {
|
.filter_map(|(i, &selected)| {
|
||||||
@@ -416,20 +516,20 @@ pub async fn create_event(
|
|||||||
5 => "FR", // Friday
|
5 => "FR", // Friday
|
||||||
6 => "SA", // Saturday
|
6 => "SA", // Saturday
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !selected_days.is_empty() {
|
if !selected_days.is_empty() {
|
||||||
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
|
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Some(rrule)
|
Some(rrule)
|
||||||
},
|
}
|
||||||
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
|
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
|
||||||
"YEARLY" => Some("FREQ=YEARLY".to_string()),
|
"YEARLY" => Some("FREQ=YEARLY".to_string()),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -439,9 +539,21 @@ pub async fn create_event(
|
|||||||
// Create the VEvent struct (RFC 5545 compliant)
|
// Create the VEvent struct (RFC 5545 compliant)
|
||||||
let mut event = VEvent::new(uid, start_datetime);
|
let mut event = VEvent::new(uid, start_datetime);
|
||||||
event.dtend = Some(end_datetime);
|
event.dtend = Some(end_datetime);
|
||||||
event.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) };
|
event.summary = if request.title.trim().is_empty() {
|
||||||
event.description = if request.description.trim().is_empty() { None } else { Some(request.description) };
|
None
|
||||||
event.location = if request.location.trim().is_empty() { None } else { Some(request.location) };
|
} else {
|
||||||
|
Some(request.title.clone())
|
||||||
|
};
|
||||||
|
event.description = if request.description.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.description)
|
||||||
|
};
|
||||||
|
event.location = if request.location.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.location)
|
||||||
|
};
|
||||||
event.status = Some(status);
|
event.status = Some(status);
|
||||||
event.class = Some(class);
|
event.class = Some(class);
|
||||||
event.priority = request.priority;
|
event.priority = request.priority;
|
||||||
@@ -456,41 +568,53 @@ pub async fn create_event(
|
|||||||
language: None,
|
language: None,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
event.attendees = attendees.into_iter().map(|email| Attendee {
|
event.attendees = attendees
|
||||||
cal_address: email,
|
.into_iter()
|
||||||
common_name: None,
|
.map(|email| Attendee {
|
||||||
role: None,
|
cal_address: email,
|
||||||
part_stat: None,
|
common_name: None,
|
||||||
rsvp: None,
|
role: None,
|
||||||
cu_type: None,
|
part_stat: None,
|
||||||
member: Vec::new(),
|
rsvp: None,
|
||||||
delegated_to: Vec::new(),
|
cu_type: None,
|
||||||
delegated_from: Vec::new(),
|
member: Vec::new(),
|
||||||
sent_by: None,
|
delegated_to: Vec::new(),
|
||||||
dir_entry_ref: None,
|
delegated_from: Vec::new(),
|
||||||
language: None,
|
sent_by: None,
|
||||||
}).collect();
|
dir_entry_ref: None,
|
||||||
|
language: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
event.categories = categories;
|
event.categories = categories;
|
||||||
event.rrule = rrule;
|
event.rrule = rrule;
|
||||||
event.all_day = request.all_day;
|
event.all_day = request.all_day;
|
||||||
event.alarms = alarms.into_iter().map(|reminder| VAlarm {
|
event.alarms = alarms
|
||||||
action: AlarmAction::Display,
|
.into_iter()
|
||||||
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(-reminder.minutes_before as i64)),
|
.map(|reminder| VAlarm {
|
||||||
duration: None,
|
action: AlarmAction::Display,
|
||||||
repeat: None,
|
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(
|
||||||
description: reminder.description,
|
-reminder.minutes_before as i64,
|
||||||
summary: None,
|
)),
|
||||||
attendees: Vec::new(),
|
duration: None,
|
||||||
attach: Vec::new(),
|
repeat: None,
|
||||||
}).collect();
|
description: reminder.description,
|
||||||
|
summary: None,
|
||||||
|
attendees: Vec::new(),
|
||||||
|
attach: Vec::new(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
event.calendar_path = Some(calendar_path.clone());
|
event.calendar_path = Some(calendar_path.clone());
|
||||||
|
|
||||||
// Create the event on the CalDAV server
|
// Create the event on the CalDAV server
|
||||||
let event_href = client.create_event(&calendar_path, &event)
|
let event_href = client
|
||||||
|
.create_event(&calendar_path, &event)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
|
||||||
|
|
||||||
println!("✅ Event created successfully with UID: {} at href: {}", event.uid, event_href);
|
println!(
|
||||||
|
"✅ Event created successfully with UID: {} at href: {}",
|
||||||
|
event.uid, event_href
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Json(CreateEventResponse {
|
Ok(Json(CreateEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -520,18 +644,23 @@ pub async fn update_event(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if request.title.len() > 200 {
|
if request.title.len() > 200 {
|
||||||
return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Event title too long (max 200 characters)".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Find the event across all calendars (or in the specified calendar)
|
// Find the event across all calendars (or in the specified calendar)
|
||||||
let calendar_paths = if let Some(path) = &request.calendar_path {
|
let calendar_paths = if let Some(path) = &request.calendar_path {
|
||||||
vec![path.clone()]
|
vec![path.clone()]
|
||||||
} else {
|
} else {
|
||||||
client.discover_calendars()
|
client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
||||||
};
|
};
|
||||||
@@ -544,7 +673,10 @@ pub async fn update_event(
|
|||||||
for event in events {
|
for event in events {
|
||||||
if event.uid == request.uid {
|
if event.uid == request.uid {
|
||||||
// Use the actual href from the event, or generate one if missing
|
// Use the actual href from the event, or generate one if missing
|
||||||
let event_href = event.href.clone().unwrap_or_else(|| format!("{}.ics", event.uid));
|
let event_href = event
|
||||||
|
.href
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{}.ics", event.uid));
|
||||||
println!("🔍 Found event {} with href: {}", event.uid, event_href);
|
println!("🔍 Found event {} with href: {}", event.uid, event_href);
|
||||||
found_event = Some((event, calendar_path.clone(), event_href));
|
found_event = Some((event, calendar_path.clone(), event_href));
|
||||||
break;
|
break;
|
||||||
@@ -553,9 +685,12 @@ pub async fn update_event(
|
|||||||
if found_event.is_some() {
|
if found_event.is_some() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e);
|
eprintln!(
|
||||||
|
"Failed to fetch events from calendar {}: {}",
|
||||||
|
calendar_path, e
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,23 +700,38 @@ pub async fn update_event(
|
|||||||
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
|
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
|
||||||
|
|
||||||
// Parse dates and times
|
// Parse dates and times
|
||||||
let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
let start_datetime =
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
||||||
|
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
||||||
|
|
||||||
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
|
|
||||||
// Validate that end is after start
|
// Validate that end is after start
|
||||||
if end_datetime <= start_datetime {
|
if end_datetime <= start_datetime {
|
||||||
return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"End date/time must be after start date/time".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update event properties
|
// Update event properties
|
||||||
event.dtstart = start_datetime;
|
event.dtstart = start_datetime;
|
||||||
event.dtend = Some(end_datetime);
|
event.dtend = Some(end_datetime);
|
||||||
event.summary = if request.title.trim().is_empty() { None } else { Some(request.title) };
|
event.summary = if request.title.trim().is_empty() {
|
||||||
event.description = if request.description.trim().is_empty() { None } else { Some(request.description) };
|
None
|
||||||
event.location = if request.location.trim().is_empty() { None } else { Some(request.location) };
|
} else {
|
||||||
|
Some(request.title)
|
||||||
|
};
|
||||||
|
event.description = if request.description.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.description)
|
||||||
|
};
|
||||||
|
event.location = if request.location.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.location)
|
||||||
|
};
|
||||||
event.all_day = request.all_day;
|
event.all_day = request.all_day;
|
||||||
|
|
||||||
// Parse and update status
|
// Parse and update status
|
||||||
@@ -601,8 +751,12 @@ pub async fn update_event(
|
|||||||
event.priority = request.priority;
|
event.priority = request.priority;
|
||||||
|
|
||||||
// Update the event on the CalDAV server
|
// Update the event on the CalDAV server
|
||||||
println!("📝 Updating event {} at calendar_path: {}, event_href: {}", event.uid, calendar_path, event_href);
|
println!(
|
||||||
client.update_event(&calendar_path, &event, &event_href)
|
"📝 Updating event {} at calendar_path: {}, event_href: {}",
|
||||||
|
event.uid, calendar_path, event_href
|
||||||
|
);
|
||||||
|
client
|
||||||
|
.update_event(&calendar_path, &event, &event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?;
|
||||||
|
|
||||||
@@ -614,8 +768,12 @@ pub async fn update_event(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result<chrono::DateTime<chrono::Utc>, String> {
|
fn parse_event_datetime(
|
||||||
use chrono::{NaiveDate, NaiveTime, NaiveDateTime, Utc, TimeZone};
|
date_str: &str,
|
||||||
|
time_str: &str,
|
||||||
|
all_day: bool,
|
||||||
|
) -> Result<chrono::DateTime<chrono::Utc>, String> {
|
||||||
|
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
|
||||||
|
|
||||||
// Parse the date
|
// Parse the date
|
||||||
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||||
@@ -623,7 +781,8 @@ fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result
|
|||||||
|
|
||||||
if all_day {
|
if all_day {
|
||||||
// For all-day events, use midnight UTC
|
// For all-day events, use midnight UTC
|
||||||
let datetime = date.and_hms_opt(0, 0, 0)
|
let datetime = date
|
||||||
|
.and_hms_opt(0, 0, 0)
|
||||||
.ok_or_else(|| "Failed to create midnight datetime".to_string())?;
|
.ok_or_else(|| "Failed to create midnight datetime".to_string())?;
|
||||||
Ok(Utc.from_utc_datetime(&datetime))
|
Ok(Utc.from_utc_datetime(&datetime))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
use axum::{
|
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||||
extract::State,
|
|
||||||
http::HeaderMap,
|
|
||||||
response::Json,
|
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use chrono::TimeZone;
|
use chrono::TimeZone;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{AppState, models::{ApiError, CreateEventSeriesRequest, CreateEventSeriesResponse, UpdateEventSeriesRequest, UpdateEventSeriesResponse, DeleteEventSeriesRequest, DeleteEventSeriesResponse}};
|
|
||||||
use crate::calendar::CalDAVClient;
|
use crate::calendar::CalDAVClient;
|
||||||
use calendar_models::{VEvent, EventStatus, EventClass};
|
use crate::{
|
||||||
|
models::{
|
||||||
|
ApiError, CreateEventSeriesRequest, CreateEventSeriesResponse, DeleteEventSeriesRequest,
|
||||||
|
DeleteEventSeriesResponse, UpdateEventSeriesRequest, UpdateEventSeriesResponse,
|
||||||
|
},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
use calendar_models::{EventClass, EventStatus, VEvent};
|
||||||
|
|
||||||
use super::auth::{extract_bearer_token, extract_password_header};
|
use super::auth::{extract_bearer_token, extract_password_header};
|
||||||
|
|
||||||
@@ -18,8 +20,10 @@ pub async fn create_event_series(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(request): Json<CreateEventSeriesRequest>,
|
Json(request): Json<CreateEventSeriesRequest>,
|
||||||
) -> Result<Json<CreateEventSeriesResponse>, ApiError> {
|
) -> Result<Json<CreateEventSeriesResponse>, ApiError> {
|
||||||
println!("📝 Create event series request received: title='{}', recurrence='{}', all_day={}",
|
println!(
|
||||||
request.title, request.recurrence, request.all_day);
|
"📝 Create event series request received: title='{}', recurrence='{}', all_day={}",
|
||||||
|
request.title, request.recurrence, request.all_day
|
||||||
|
);
|
||||||
|
|
||||||
// Extract and verify token
|
// Extract and verify token
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
@@ -31,11 +35,15 @@ pub async fn create_event_series(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if request.title.len() > 200 {
|
if request.title.len() > 200 {
|
||||||
return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Event title too long (max 200 characters)".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.recurrence == "none" {
|
if request.recurrence == "none" {
|
||||||
return Err(ApiError::BadRequest("Use regular create endpoint for non-recurring events".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Use regular create endpoint for non-recurring events".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate recurrence type - handle both simple strings and RRULE strings
|
// Validate recurrence type - handle both simple strings and RRULE strings
|
||||||
@@ -50,7 +58,9 @@ pub async fn create_event_series(
|
|||||||
} else if request.recurrence.contains("FREQ=YEARLY") {
|
} else if request.recurrence.contains("FREQ=YEARLY") {
|
||||||
"yearly"
|
"yearly"
|
||||||
} else {
|
} else {
|
||||||
return Err(ApiError::BadRequest("Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Handle simple strings
|
// Handle simple strings
|
||||||
@@ -60,12 +70,19 @@ pub async fn create_event_series(
|
|||||||
"weekly" => "weekly",
|
"weekly" => "weekly",
|
||||||
"monthly" => "monthly",
|
"monthly" => "monthly",
|
||||||
"yearly" => "yearly",
|
"yearly" => "yearly",
|
||||||
_ => return Err(ApiError::BadRequest("Invalid recurrence type. Must be daily, weekly, monthly, or yearly".to_string())),
|
_ => {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"Invalid recurrence type. Must be daily, weekly, monthly, or yearly"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Determine which calendar to use
|
// Determine which calendar to use
|
||||||
@@ -73,12 +90,15 @@ pub async fn create_event_series(
|
|||||||
path.clone()
|
path.clone()
|
||||||
} else {
|
} else {
|
||||||
// Use the first available calendar
|
// Use the first available calendar
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
if calendar_paths.is_empty() {
|
if calendar_paths.is_empty() {
|
||||||
return Err(ApiError::BadRequest("No calendars available for event creation".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"No calendars available for event creation".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
calendar_paths[0].clone()
|
calendar_paths[0].clone()
|
||||||
@@ -87,37 +107,47 @@ pub async fn create_event_series(
|
|||||||
println!("📅 Using calendar path: {}", calendar_path);
|
println!("📅 Using calendar path: {}", calendar_path);
|
||||||
|
|
||||||
// Parse datetime components
|
// Parse datetime components
|
||||||
let start_date = chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d")
|
let start_date =
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string()))?;
|
chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d").map_err(|_| {
|
||||||
|
ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
let (start_datetime, end_datetime) = if request.all_day {
|
let (start_datetime, end_datetime) = if request.all_day {
|
||||||
// For all-day events, use the dates as-is
|
// For all-day events, use the dates as-is
|
||||||
let start_dt = start_date.and_hms_opt(0, 0, 0)
|
let start_dt = start_date
|
||||||
|
.and_hms_opt(0, 0, 0)
|
||||||
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
|
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
|
||||||
|
|
||||||
let end_date = if !request.end_date.is_empty() {
|
let end_date = if !request.end_date.is_empty() {
|
||||||
chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d")
|
chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d").map_err(|_| {
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string()))?
|
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string())
|
||||||
|
})?
|
||||||
} else {
|
} else {
|
||||||
start_date
|
start_date
|
||||||
};
|
};
|
||||||
|
|
||||||
let end_dt = end_date.and_hms_opt(23, 59, 59)
|
let end_dt = end_date
|
||||||
|
.and_hms_opt(23, 59, 59)
|
||||||
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
||||||
|
|
||||||
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
|
(
|
||||||
|
chrono::Utc.from_utc_datetime(&start_dt),
|
||||||
|
chrono::Utc.from_utc_datetime(&end_dt),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// Parse times for timed events
|
// Parse times for timed events
|
||||||
let start_time = if !request.start_time.is_empty() {
|
let start_time = if !request.start_time.is_empty() {
|
||||||
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M")
|
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string()))?
|
ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string())
|
||||||
|
})?
|
||||||
} else {
|
} else {
|
||||||
chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap() // Default to 9 AM
|
chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap() // Default to 9 AM
|
||||||
};
|
};
|
||||||
|
|
||||||
let end_time = if !request.end_time.is_empty() {
|
let end_time = if !request.end_time.is_empty() {
|
||||||
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M")
|
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M").map_err(|_| {
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string()))?
|
ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string())
|
||||||
|
})?
|
||||||
} else {
|
} else {
|
||||||
chrono::NaiveTime::from_hms_opt(10, 0, 0).unwrap() // Default to 1 hour duration
|
chrono::NaiveTime::from_hms_opt(10, 0, 0).unwrap() // Default to 1 hour duration
|
||||||
};
|
};
|
||||||
@@ -125,13 +155,18 @@ pub async fn create_event_series(
|
|||||||
let start_dt = start_date.and_time(start_time);
|
let start_dt = start_date.and_time(start_time);
|
||||||
let end_dt = if !request.end_date.is_empty() {
|
let end_dt = if !request.end_date.is_empty() {
|
||||||
let end_date = chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d")
|
let end_date = chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d")
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string()))?;
|
.map_err(|_| {
|
||||||
|
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string())
|
||||||
|
})?;
|
||||||
end_date.and_time(end_time)
|
end_date.and_time(end_time)
|
||||||
} else {
|
} else {
|
||||||
start_date.and_time(end_time)
|
start_date.and_time(end_time)
|
||||||
};
|
};
|
||||||
|
|
||||||
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
|
(
|
||||||
|
chrono::Utc.from_utc_datetime(&start_dt),
|
||||||
|
chrono::Utc.from_utc_datetime(&end_dt),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate a unique UID for the series
|
// Generate a unique UID for the series
|
||||||
@@ -140,9 +175,21 @@ pub async fn create_event_series(
|
|||||||
// Create the VEvent for the series
|
// Create the VEvent for the series
|
||||||
let mut event = VEvent::new(uid.clone(), start_datetime);
|
let mut event = VEvent::new(uid.clone(), start_datetime);
|
||||||
event.dtend = Some(end_datetime);
|
event.dtend = Some(end_datetime);
|
||||||
event.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) };
|
event.summary = if request.title.trim().is_empty() {
|
||||||
event.description = if request.description.trim().is_empty() { None } else { Some(request.description.clone()) };
|
None
|
||||||
event.location = if request.location.trim().is_empty() { None } else { Some(request.location.clone()) };
|
} else {
|
||||||
|
Some(request.title.clone())
|
||||||
|
};
|
||||||
|
event.description = if request.description.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.description.clone())
|
||||||
|
};
|
||||||
|
event.location = if request.location.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.location.clone())
|
||||||
|
};
|
||||||
|
|
||||||
// Set event status
|
// Set event status
|
||||||
event.status = Some(match request.status.to_lowercase().as_str() {
|
event.status = Some(match request.status.to_lowercase().as_str() {
|
||||||
@@ -171,13 +218,16 @@ pub async fn create_event_series(
|
|||||||
};
|
};
|
||||||
event.rrule = Some(rrule);
|
event.rrule = Some(rrule);
|
||||||
|
|
||||||
|
|
||||||
// Create the event on the CalDAV server
|
// Create the event on the CalDAV server
|
||||||
let event_href = client.create_event(&calendar_path, &event)
|
let event_href = client
|
||||||
|
.create_event(&calendar_path, &event)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to create event series: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to create event series: {}", e)))?;
|
||||||
|
|
||||||
println!("✅ Event series created successfully with UID: {}, href: {}", uid, event_href);
|
println!(
|
||||||
|
"✅ Event series created successfully with UID: {}, href: {}",
|
||||||
|
uid, event_href
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Json(CreateEventSeriesResponse {
|
Ok(Json(CreateEventSeriesResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -194,8 +244,10 @@ pub async fn update_event_series(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(request): Json<UpdateEventSeriesRequest>,
|
Json(request): Json<UpdateEventSeriesRequest>,
|
||||||
) -> Result<Json<UpdateEventSeriesResponse>, ApiError> {
|
) -> Result<Json<UpdateEventSeriesResponse>, ApiError> {
|
||||||
println!("🔄 Update event series request received: series_uid='{}', update_scope='{}'",
|
println!(
|
||||||
request.series_uid, request.update_scope);
|
"🔄 Update event series request received: series_uid='{}', update_scope='{}'",
|
||||||
|
request.series_uid, request.update_scope
|
||||||
|
);
|
||||||
|
|
||||||
// Extract and verify token
|
// Extract and verify token
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
@@ -211,13 +263,20 @@ pub async fn update_event_series(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if request.title.len() > 200 {
|
if request.title.len() > 200 {
|
||||||
return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Event title too long (max 200 characters)".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate update scope
|
// Validate update scope
|
||||||
match request.update_scope.as_str() {
|
match request.update_scope.as_str() {
|
||||||
"this_only" | "this_and_future" | "all_in_series" => {},
|
"this_only" | "this_and_future" | "all_in_series" => {}
|
||||||
_ => return Err(ApiError::BadRequest("Invalid update_scope. Must be: this_only, this_and_future, or all_in_series".to_string())),
|
_ => {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"Invalid update_scope. Must be: this_only, this_and_future, or all_in_series"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate recurrence type - handle both simple strings and RRULE strings
|
// Validate recurrence type - handle both simple strings and RRULE strings
|
||||||
@@ -232,7 +291,9 @@ pub async fn update_event_series(
|
|||||||
} else if request.recurrence.contains("FREQ=YEARLY") {
|
} else if request.recurrence.contains("FREQ=YEARLY") {
|
||||||
"yearly"
|
"yearly"
|
||||||
} else {
|
} else {
|
||||||
return Err(ApiError::BadRequest("Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Handle simple strings
|
// Handle simple strings
|
||||||
@@ -242,12 +303,19 @@ pub async fn update_event_series(
|
|||||||
"weekly" => "weekly",
|
"weekly" => "weekly",
|
||||||
"monthly" => "monthly",
|
"monthly" => "monthly",
|
||||||
"yearly" => "yearly",
|
"yearly" => "yearly",
|
||||||
_ => return Err(ApiError::BadRequest("Invalid recurrence type. Must be daily, weekly, monthly, or yearly".to_string())),
|
_ => {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"Invalid recurrence type. Must be daily, weekly, monthly, or yearly"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Use the parsed frequency for further processing (avoiding unused variable warning)
|
// Use the parsed frequency for further processing (avoiding unused variable warning)
|
||||||
@@ -257,13 +325,16 @@ pub async fn update_event_series(
|
|||||||
let calendar_paths = if let Some(ref path) = request.calendar_path {
|
let calendar_paths = if let Some(ref path) = request.calendar_path {
|
||||||
vec![path.clone()]
|
vec![path.clone()]
|
||||||
} else {
|
} else {
|
||||||
client.discover_calendars()
|
client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
||||||
};
|
};
|
||||||
|
|
||||||
if calendar_paths.is_empty() {
|
if calendar_paths.is_empty() {
|
||||||
return Err(ApiError::BadRequest("No calendars available for event update".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"No calendars available for event update".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the series event across all specified calendars
|
// Find the series event across all specified calendars
|
||||||
@@ -278,34 +349,46 @@ pub async fn update_event_series(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut existing_event = existing_event
|
let mut existing_event = existing_event.ok_or_else(|| {
|
||||||
.ok_or_else(|| ApiError::NotFound(format!("Event series with UID '{}' not found", request.series_uid)))?;
|
ApiError::NotFound(format!(
|
||||||
|
"Event series with UID '{}' not found",
|
||||||
|
request.series_uid
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
println!("📅 Found series event in calendar: {}", calendar_path);
|
println!("📅 Found series event in calendar: {}", calendar_path);
|
||||||
println!("📅 Event details: UID={}, summary={:?}, dtstart={}",
|
println!(
|
||||||
existing_event.uid, existing_event.summary, existing_event.dtstart);
|
"📅 Event details: UID={}, summary={:?}, dtstart={}",
|
||||||
|
existing_event.uid, existing_event.summary, existing_event.dtstart
|
||||||
|
);
|
||||||
|
|
||||||
// Parse datetime components for the update
|
// Parse datetime components for the update
|
||||||
let original_start_date = existing_event.dtstart.date_naive();
|
let original_start_date = existing_event.dtstart.date_naive();
|
||||||
|
|
||||||
// For "this_and_future" and "this_only" updates, use the occurrence date for the modified event
|
// For "this_and_future" and "this_only" updates, use the occurrence date for the modified event
|
||||||
// For "all_in_series" updates, preserve the original series start date
|
// For "all_in_series" updates, preserve the original series start date
|
||||||
let start_date = if (request.update_scope == "this_and_future" || request.update_scope == "this_only") && request.occurrence_date.is_some() {
|
let start_date = if (request.update_scope == "this_and_future"
|
||||||
|
|| request.update_scope == "this_only")
|
||||||
|
&& request.occurrence_date.is_some()
|
||||||
|
{
|
||||||
let occurrence_date_str = request.occurrence_date.as_ref().unwrap();
|
let occurrence_date_str = request.occurrence_date.as_ref().unwrap();
|
||||||
chrono::NaiveDate::parse_from_str(occurrence_date_str, "%Y-%m-%d")
|
chrono::NaiveDate::parse_from_str(occurrence_date_str, "%Y-%m-%d").map_err(|_| {
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid occurrence_date format. Expected YYYY-MM-DD".to_string()))?
|
ApiError::BadRequest("Invalid occurrence_date format. Expected YYYY-MM-DD".to_string())
|
||||||
|
})?
|
||||||
} else {
|
} else {
|
||||||
original_start_date
|
original_start_date
|
||||||
};
|
};
|
||||||
|
|
||||||
let (start_datetime, end_datetime) = if request.all_day {
|
let (start_datetime, end_datetime) = if request.all_day {
|
||||||
let start_dt = start_date.and_hms_opt(0, 0, 0)
|
let start_dt = start_date
|
||||||
|
.and_hms_opt(0, 0, 0)
|
||||||
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
|
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
|
||||||
|
|
||||||
// For all-day events, also preserve the original date pattern
|
// For all-day events, also preserve the original date pattern
|
||||||
let end_date = if !request.end_date.is_empty() {
|
let end_date = if !request.end_date.is_empty() {
|
||||||
// Calculate the duration from the original event
|
// Calculate the duration from the original event
|
||||||
let original_duration_days = existing_event.dtend
|
let original_duration_days = existing_event
|
||||||
|
.dtend
|
||||||
.map(|end| (end.date_naive() - existing_event.dtstart.date_naive()).num_days())
|
.map(|end| (end.date_naive() - existing_event.dtstart.date_naive()).num_days())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
start_date + chrono::Duration::days(original_duration_days)
|
start_date + chrono::Duration::days(original_duration_days)
|
||||||
@@ -313,25 +396,32 @@ pub async fn update_event_series(
|
|||||||
start_date
|
start_date
|
||||||
};
|
};
|
||||||
|
|
||||||
let end_dt = end_date.and_hms_opt(23, 59, 59)
|
let end_dt = end_date
|
||||||
|
.and_hms_opt(23, 59, 59)
|
||||||
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
||||||
|
|
||||||
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
|
(
|
||||||
|
chrono::Utc.from_utc_datetime(&start_dt),
|
||||||
|
chrono::Utc.from_utc_datetime(&end_dt),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
let start_time = if !request.start_time.is_empty() {
|
let start_time = if !request.start_time.is_empty() {
|
||||||
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M")
|
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string()))?
|
ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string())
|
||||||
|
})?
|
||||||
} else {
|
} else {
|
||||||
existing_event.dtstart.time()
|
existing_event.dtstart.time()
|
||||||
};
|
};
|
||||||
|
|
||||||
let end_time = if !request.end_time.is_empty() {
|
let end_time = if !request.end_time.is_empty() {
|
||||||
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M")
|
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M").map_err(|_| {
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string()))?
|
ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string())
|
||||||
|
})?
|
||||||
} else {
|
} else {
|
||||||
existing_event.dtend.map(|dt| dt.time()).unwrap_or_else(|| {
|
existing_event
|
||||||
existing_event.dtstart.time() + chrono::Duration::hours(1)
|
.dtend
|
||||||
})
|
.map(|dt| dt.time())
|
||||||
|
.unwrap_or_else(|| existing_event.dtstart.time() + chrono::Duration::hours(1))
|
||||||
};
|
};
|
||||||
|
|
||||||
let start_dt = start_date.and_time(start_time);
|
let start_dt = start_date.and_time(start_time);
|
||||||
@@ -340,13 +430,17 @@ pub async fn update_event_series(
|
|||||||
start_date.and_time(end_time)
|
start_date.and_time(end_time)
|
||||||
} else {
|
} else {
|
||||||
// Calculate end time based on original duration
|
// Calculate end time based on original duration
|
||||||
let original_duration = existing_event.dtend
|
let original_duration = existing_event
|
||||||
|
.dtend
|
||||||
.map(|end| end - existing_event.dtstart)
|
.map(|end| end - existing_event.dtstart)
|
||||||
.unwrap_or_else(|| chrono::Duration::hours(1));
|
.unwrap_or_else(|| chrono::Duration::hours(1));
|
||||||
(chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc()
|
(chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc()
|
||||||
};
|
};
|
||||||
|
|
||||||
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
|
(
|
||||||
|
chrono::Utc.from_utc_datetime(&start_dt),
|
||||||
|
chrono::Utc.from_utc_datetime(&end_dt),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle different update scopes
|
// Handle different update scopes
|
||||||
@@ -354,39 +448,73 @@ pub async fn update_event_series(
|
|||||||
"all_in_series" => {
|
"all_in_series" => {
|
||||||
// Update the entire series - modify the master event
|
// Update the entire series - modify the master event
|
||||||
update_entire_series(&mut existing_event, &request, start_datetime, end_datetime)?
|
update_entire_series(&mut existing_event, &request, start_datetime, end_datetime)?
|
||||||
},
|
}
|
||||||
"this_and_future" => {
|
"this_and_future" => {
|
||||||
// Split the series: keep past occurrences, create new series from occurrence date
|
// Split the series: keep past occurrences, create new series from occurrence date
|
||||||
update_this_and_future(&mut existing_event, &request, start_datetime, end_datetime, &client, &calendar_path).await?
|
update_this_and_future(
|
||||||
},
|
&mut existing_event,
|
||||||
|
&request,
|
||||||
|
start_datetime,
|
||||||
|
end_datetime,
|
||||||
|
&client,
|
||||||
|
&calendar_path,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
"this_only" => {
|
"this_only" => {
|
||||||
// Create exception for single occurrence, keep original series
|
// Create exception for single occurrence, keep original series
|
||||||
let event_href = existing_event.href.as_ref()
|
let event_href = existing_event
|
||||||
.ok_or_else(|| ApiError::Internal("Event missing href for single occurrence update".to_string()))?
|
.href
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ApiError::Internal(
|
||||||
|
"Event missing href for single occurrence update".to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
.clone();
|
.clone();
|
||||||
update_single_occurrence(&mut existing_event, &request, start_datetime, end_datetime, &client, &calendar_path, &event_href).await?
|
update_single_occurrence(
|
||||||
},
|
&mut existing_event,
|
||||||
|
&request,
|
||||||
|
start_datetime,
|
||||||
|
end_datetime,
|
||||||
|
&client,
|
||||||
|
&calendar_path,
|
||||||
|
&event_href,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
_ => unreachable!(), // Already validated above
|
_ => unreachable!(), // Already validated above
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update the event on the CalDAV server using the original event's href
|
// Update the event on the CalDAV server using the original event's href
|
||||||
println!("📤 Updating event on CalDAV server...");
|
println!("📤 Updating event on CalDAV server...");
|
||||||
let event_href = existing_event.href.as_ref()
|
let event_href = existing_event
|
||||||
|
.href
|
||||||
|
.as_ref()
|
||||||
.ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?;
|
.ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?;
|
||||||
println!("📤 Using event href: {}", event_href);
|
println!("📤 Using event href: {}", event_href);
|
||||||
println!("📤 Calendar path: {}", calendar_path);
|
println!("📤 Calendar path: {}", calendar_path);
|
||||||
|
|
||||||
match client.update_event(&calendar_path, &updated_event, event_href).await {
|
match client
|
||||||
|
.update_event(&calendar_path, &updated_event, event_href)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
println!("✅ CalDAV update completed successfully");
|
println!("✅ CalDAV update completed successfully");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!("❌ CalDAV update failed: {}", e);
|
println!("❌ CalDAV update failed: {}", e);
|
||||||
return Err(ApiError::Internal(format!("Failed to update event series: {}", e)));
|
return Err(ApiError::Internal(format!(
|
||||||
|
"Failed to update event series: {}",
|
||||||
|
e
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("✅ Event series updated successfully with UID: {}", request.series_uid);
|
println!(
|
||||||
|
"✅ Event series updated successfully with UID: {}",
|
||||||
|
request.series_uid
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Json(UpdateEventSeriesResponse {
|
Ok(Json(UpdateEventSeriesResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -402,8 +530,10 @@ pub async fn delete_event_series(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(request): Json<DeleteEventSeriesRequest>,
|
Json(request): Json<DeleteEventSeriesRequest>,
|
||||||
) -> Result<Json<DeleteEventSeriesResponse>, ApiError> {
|
) -> Result<Json<DeleteEventSeriesResponse>, ApiError> {
|
||||||
println!("🗑️ Delete event series request received: series_uid='{}', delete_scope='{}'",
|
println!(
|
||||||
request.series_uid, request.delete_scope);
|
"🗑️ Delete event series request received: series_uid='{}', delete_scope='{}'",
|
||||||
|
request.series_uid, request.delete_scope
|
||||||
|
);
|
||||||
|
|
||||||
// Extract and verify token
|
// Extract and verify token
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
@@ -415,7 +545,9 @@ pub async fn delete_event_series(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if request.calendar_path.trim().is_empty() {
|
if request.calendar_path.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest("Calendar path is required".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Calendar path is required".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.event_href.trim().is_empty() {
|
if request.event_href.trim().is_empty() {
|
||||||
@@ -424,12 +556,19 @@ pub async fn delete_event_series(
|
|||||||
|
|
||||||
// Validate delete scope
|
// Validate delete scope
|
||||||
match request.delete_scope.as_str() {
|
match request.delete_scope.as_str() {
|
||||||
"this_only" | "this_and_future" | "all_in_series" => {},
|
"this_only" | "this_and_future" | "all_in_series" => {}
|
||||||
_ => return Err(ApiError::BadRequest("Invalid delete_scope. Must be: this_only, this_and_future, or all_in_series".to_string())),
|
_ => {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"Invalid delete_scope. Must be: this_only, this_and_future, or all_in_series"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Handle different deletion scopes
|
// Handle different deletion scopes
|
||||||
@@ -437,19 +576,22 @@ pub async fn delete_event_series(
|
|||||||
"all_in_series" => {
|
"all_in_series" => {
|
||||||
// Delete the entire series - simply delete the event
|
// Delete the entire series - simply delete the event
|
||||||
delete_entire_series(&client, &request).await?
|
delete_entire_series(&client, &request).await?
|
||||||
},
|
}
|
||||||
"this_and_future" => {
|
"this_and_future" => {
|
||||||
// Modify RRULE to end before this occurrence
|
// Modify RRULE to end before this occurrence
|
||||||
delete_this_and_future(&client, &request).await?
|
delete_this_and_future(&client, &request).await?
|
||||||
},
|
}
|
||||||
"this_only" => {
|
"this_only" => {
|
||||||
// Add EXDATE for single occurrence
|
// Add EXDATE for single occurrence
|
||||||
delete_single_occurrence(&client, &request).await?
|
delete_single_occurrence(&client, &request).await?
|
||||||
},
|
}
|
||||||
_ => unreachable!(), // Already validated above
|
_ => unreachable!(), // Already validated above
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("✅ Event series deletion completed with {} occurrences affected", occurrences_affected);
|
println!(
|
||||||
|
"✅ Event series deletion completed with {} occurrences affected",
|
||||||
|
occurrences_affected
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Json(DeleteEventSeriesResponse {
|
Ok(Json(DeleteEventSeriesResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -460,8 +602,10 @@ pub async fn delete_event_series(
|
|||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|
||||||
|
fn build_series_rrule_with_freq(
|
||||||
fn build_series_rrule_with_freq(request: &CreateEventSeriesRequest, freq: &str) -> Result<String, ApiError> {
|
request: &CreateEventSeriesRequest,
|
||||||
|
freq: &str,
|
||||||
|
) -> Result<String, ApiError> {
|
||||||
let mut rrule_parts = Vec::new();
|
let mut rrule_parts = Vec::new();
|
||||||
|
|
||||||
// Add frequency
|
// Add frequency
|
||||||
@@ -470,7 +614,11 @@ fn build_series_rrule_with_freq(request: &CreateEventSeriesRequest, freq: &str)
|
|||||||
"weekly" => rrule_parts.push("FREQ=WEEKLY".to_string()),
|
"weekly" => rrule_parts.push("FREQ=WEEKLY".to_string()),
|
||||||
"monthly" => rrule_parts.push("FREQ=MONTHLY".to_string()),
|
"monthly" => rrule_parts.push("FREQ=MONTHLY".to_string()),
|
||||||
"yearly" => rrule_parts.push("FREQ=YEARLY".to_string()),
|
"yearly" => rrule_parts.push("FREQ=YEARLY".to_string()),
|
||||||
_ => return Err(ApiError::BadRequest("Invalid recurrence frequency".to_string())),
|
_ => {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"Invalid recurrence frequency".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add interval if specified and greater than 1
|
// Add interval if specified and greater than 1
|
||||||
@@ -482,7 +630,8 @@ fn build_series_rrule_with_freq(request: &CreateEventSeriesRequest, freq: &str)
|
|||||||
|
|
||||||
// Handle weekly recurrence with specific days (BYDAY)
|
// Handle weekly recurrence with specific days (BYDAY)
|
||||||
if freq == "weekly" && request.recurrence_days.len() == 7 {
|
if freq == "weekly" && request.recurrence_days.len() == 7 {
|
||||||
let selected_days: Vec<&str> = request.recurrence_days
|
let selected_days: Vec<&str> = request
|
||||||
|
.recurrence_days
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(i, &selected)| {
|
.filter_map(|(i, &selected)| {
|
||||||
@@ -513,12 +662,17 @@ fn build_series_rrule_with_freq(request: &CreateEventSeriesRequest, freq: &str)
|
|||||||
// Parse the end date and convert to RRULE format (YYYYMMDDTHHMMSSZ)
|
// Parse the end date and convert to RRULE format (YYYYMMDDTHHMMSSZ)
|
||||||
match chrono::NaiveDate::parse_from_str(end_date, "%Y-%m-%d") {
|
match chrono::NaiveDate::parse_from_str(end_date, "%Y-%m-%d") {
|
||||||
Ok(date) => {
|
Ok(date) => {
|
||||||
let end_datetime = date.and_hms_opt(23, 59, 59)
|
let end_datetime = date
|
||||||
|
.and_hms_opt(23, 59, 59)
|
||||||
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
||||||
let utc_end = chrono::Utc.from_utc_datetime(&end_datetime);
|
let utc_end = chrono::Utc.from_utc_datetime(&end_datetime);
|
||||||
rrule_parts.push(format!("UNTIL={}", utc_end.format("%Y%m%dT%H%M%SZ")));
|
rrule_parts.push(format!("UNTIL={}", utc_end.format("%Y%m%dT%H%M%SZ")));
|
||||||
},
|
}
|
||||||
Err(_) => return Err(ApiError::BadRequest("Invalid recurrence_end_date format. Expected YYYY-MM-DD".to_string())),
|
Err(_) => {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"Invalid recurrence_end_date format. Expected YYYY-MM-DD".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if let Some(count) = request.recurrence_count {
|
} else if let Some(count) = request.recurrence_count {
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
@@ -543,17 +697,17 @@ fn update_entire_series(
|
|||||||
updated_event.dtstart = start_datetime;
|
updated_event.dtstart = start_datetime;
|
||||||
updated_event.dtend = Some(end_datetime);
|
updated_event.dtend = Some(end_datetime);
|
||||||
updated_event.summary = if request.title.trim().is_empty() {
|
updated_event.summary = if request.title.trim().is_empty() {
|
||||||
existing_event.summary.clone() // Keep original if empty
|
existing_event.summary.clone() // Keep original if empty
|
||||||
} else {
|
} else {
|
||||||
Some(request.title.clone())
|
Some(request.title.clone())
|
||||||
};
|
};
|
||||||
updated_event.description = if request.description.trim().is_empty() {
|
updated_event.description = if request.description.trim().is_empty() {
|
||||||
existing_event.description.clone() // Keep original if empty
|
existing_event.description.clone() // Keep original if empty
|
||||||
} else {
|
} else {
|
||||||
Some(request.description.clone())
|
Some(request.description.clone())
|
||||||
};
|
};
|
||||||
updated_event.location = if request.location.trim().is_empty() {
|
updated_event.location = if request.location.trim().is_empty() {
|
||||||
existing_event.location.clone() // Keep original if empty
|
existing_event.location.clone() // Keep original if empty
|
||||||
} else {
|
} else {
|
||||||
Some(request.location.clone())
|
Some(request.location.clone())
|
||||||
};
|
};
|
||||||
@@ -641,30 +795,42 @@ async fn update_this_and_future(
|
|||||||
client: &CalDAVClient,
|
client: &CalDAVClient,
|
||||||
calendar_path: &str,
|
calendar_path: &str,
|
||||||
) -> Result<(VEvent, u32), ApiError> {
|
) -> Result<(VEvent, u32), ApiError> {
|
||||||
|
|
||||||
// Clone the existing event to create the new series before modifying the RRULE of the
|
// Clone the existing event to create the new series before modifying the RRULE of the
|
||||||
// original, because we'd like to preserve the original UNTIL logic
|
// original, because we'd like to preserve the original UNTIL logic
|
||||||
let mut new_series = existing_event.clone();
|
let mut new_series = existing_event.clone();
|
||||||
let occurrence_date = request.occurrence_date.as_ref()
|
let occurrence_date = request.occurrence_date.as_ref().ok_or_else(|| {
|
||||||
.ok_or_else(|| ApiError::BadRequest("occurrence_date is required for this_and_future updates".to_string()))?;
|
ApiError::BadRequest("occurrence_date is required for this_and_future updates".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
// Parse occurrence date
|
// Parse occurrence date
|
||||||
let occurrence_date_parsed = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
let occurrence_date_parsed = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid occurrence date format".to_string()))?;
|
.map_err(|_| ApiError::BadRequest("Invalid occurrence date format".to_string()))?;
|
||||||
|
|
||||||
// Step 1: Add UNTIL to the original series to stop before the occurrence date
|
// Step 1: Add UNTIL to the original series to stop before the occurrence date
|
||||||
let until_datetime = occurrence_date_parsed.and_hms_opt(0, 0, 0)
|
let until_datetime = occurrence_date_parsed
|
||||||
|
.and_hms_opt(0, 0, 0)
|
||||||
.ok_or_else(|| ApiError::BadRequest("Invalid occurrence date".to_string()))?;
|
.ok_or_else(|| ApiError::BadRequest("Invalid occurrence date".to_string()))?;
|
||||||
let utc_until = chrono::Utc.from_utc_datetime(&until_datetime);
|
let utc_until = chrono::Utc.from_utc_datetime(&until_datetime);
|
||||||
|
|
||||||
// Create modified RRULE with UNTIL clause for the original series
|
// Create modified RRULE with UNTIL clause for the original series
|
||||||
let original_rrule = existing_event.rrule.clone().unwrap_or_else(|| "FREQ=WEEKLY".to_string());
|
let original_rrule = existing_event
|
||||||
let parts: Vec<&str> = original_rrule.split(';').filter(|part| {
|
.rrule
|
||||||
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
.clone()
|
||||||
}).collect();
|
.unwrap_or_else(|| "FREQ=WEEKLY".to_string());
|
||||||
|
let parts: Vec<&str> = original_rrule
|
||||||
|
.split(';')
|
||||||
|
.filter(|part| !part.starts_with("UNTIL=") && !part.starts_with("COUNT="))
|
||||||
|
.collect();
|
||||||
|
|
||||||
existing_event.rrule = Some(format!("{};UNTIL={}", parts.join(";"), utc_until.format("%Y%m%dT%H%M%SZ")));
|
existing_event.rrule = Some(format!(
|
||||||
println!("🔄 this_and_future: Updated original series RRULE: {:?}", existing_event.rrule);
|
"{};UNTIL={}",
|
||||||
|
parts.join(";"),
|
||||||
|
utc_until.format("%Y%m%dT%H%M%SZ")
|
||||||
|
));
|
||||||
|
println!(
|
||||||
|
"🔄 this_and_future: Updated original series RRULE: {:?}",
|
||||||
|
existing_event.rrule
|
||||||
|
);
|
||||||
|
|
||||||
// Step 2: Create a new series starting from the occurrence date with updated properties
|
// Step 2: Create a new series starting from the occurrence date with updated properties
|
||||||
let new_series_uid = format!("series-{}", uuid::Uuid::new_v4());
|
let new_series_uid = format!("series-{}", uuid::Uuid::new_v4());
|
||||||
@@ -673,9 +839,21 @@ async fn update_this_and_future(
|
|||||||
new_series.uid = new_series_uid.clone();
|
new_series.uid = new_series_uid.clone();
|
||||||
new_series.dtstart = start_datetime;
|
new_series.dtstart = start_datetime;
|
||||||
new_series.dtend = Some(end_datetime);
|
new_series.dtend = Some(end_datetime);
|
||||||
new_series.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) };
|
new_series.summary = if request.title.trim().is_empty() {
|
||||||
new_series.description = if request.description.trim().is_empty() { None } else { Some(request.description.clone()) };
|
None
|
||||||
new_series.location = if request.location.trim().is_empty() { None } else { Some(request.location.clone()) };
|
} else {
|
||||||
|
Some(request.title.clone())
|
||||||
|
};
|
||||||
|
new_series.description = if request.description.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.description.clone())
|
||||||
|
};
|
||||||
|
new_series.location = if request.location.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.location.clone())
|
||||||
|
};
|
||||||
|
|
||||||
new_series.status = Some(match request.status.to_lowercase().as_str() {
|
new_series.status = Some(match request.status.to_lowercase().as_str() {
|
||||||
"tentative" => EventStatus::Tentative,
|
"tentative" => EventStatus::Tentative,
|
||||||
@@ -698,11 +876,18 @@ async fn update_this_and_future(
|
|||||||
new_series.last_modified = Some(now);
|
new_series.last_modified = Some(now);
|
||||||
new_series.href = None; // Will be set when created
|
new_series.href = None; // Will be set when created
|
||||||
|
|
||||||
println!("🔄 this_and_future: Creating new series with UID: {}", new_series_uid);
|
println!(
|
||||||
println!("🔄 this_and_future: New series RRULE: {:?}", new_series.rrule);
|
"🔄 this_and_future: Creating new series with UID: {}",
|
||||||
|
new_series_uid
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"🔄 this_and_future: New series RRULE: {:?}",
|
||||||
|
new_series.rrule
|
||||||
|
);
|
||||||
|
|
||||||
// Create the new series on CalDAV server
|
// Create the new series on CalDAV server
|
||||||
client.create_event(calendar_path, &new_series)
|
client
|
||||||
|
.create_event(calendar_path, &new_series)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to create new series: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to create new series: {}", e)))?;
|
||||||
|
|
||||||
@@ -727,12 +912,17 @@ async fn update_single_occurrence(
|
|||||||
// 2. Create a new exception event with RECURRENCE-ID pointing to the original occurrence
|
// 2. Create a new exception event with RECURRENCE-ID pointing to the original occurrence
|
||||||
|
|
||||||
// First, add EXDATE to the original series
|
// First, add EXDATE to the original series
|
||||||
let occurrence_date = request.occurrence_date.as_ref()
|
let occurrence_date = request.occurrence_date.as_ref().ok_or_else(|| {
|
||||||
.ok_or_else(|| ApiError::BadRequest("occurrence_date is required for single occurrence updates".to_string()))?;
|
ApiError::BadRequest(
|
||||||
|
"occurrence_date is required for single occurrence updates".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Parse the occurrence date
|
// Parse the occurrence date
|
||||||
let exception_date = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
let exception_date =
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()))?;
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d").map_err(|_| {
|
||||||
|
ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
// Create the EXDATE datetime using the original event's time
|
// Create the EXDATE datetime using the original event's time
|
||||||
let original_time = existing_event.dtstart.time();
|
let original_time = existing_event.dtstart.time();
|
||||||
@@ -740,10 +930,19 @@ async fn update_single_occurrence(
|
|||||||
let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime);
|
let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime);
|
||||||
|
|
||||||
// Add the exception date to the original series
|
// Add the exception date to the original series
|
||||||
println!("📝 BEFORE adding EXDATE: existing_event.exdate = {:?}", existing_event.exdate);
|
println!(
|
||||||
|
"📝 BEFORE adding EXDATE: existing_event.exdate = {:?}",
|
||||||
|
existing_event.exdate
|
||||||
|
);
|
||||||
existing_event.exdate.push(exception_utc);
|
existing_event.exdate.push(exception_utc);
|
||||||
println!("📝 AFTER adding EXDATE: existing_event.exdate = {:?}", existing_event.exdate);
|
println!(
|
||||||
println!("🚫 Added EXDATE for single occurrence modification: {}", exception_utc.format("%Y-%m-%d %H:%M:%S"));
|
"📝 AFTER adding EXDATE: existing_event.exdate = {:?}",
|
||||||
|
existing_event.exdate
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"🚫 Added EXDATE for single occurrence modification: {}",
|
||||||
|
exception_utc.format("%Y-%m-%d %H:%M:%S")
|
||||||
|
);
|
||||||
|
|
||||||
// Create exception event by cloning the existing event to preserve all metadata
|
// Create exception event by cloning the existing event to preserve all metadata
|
||||||
let mut exception_event = existing_event.clone();
|
let mut exception_event = existing_event.clone();
|
||||||
@@ -755,17 +954,17 @@ async fn update_single_occurrence(
|
|||||||
exception_event.dtstart = start_datetime;
|
exception_event.dtstart = start_datetime;
|
||||||
exception_event.dtend = Some(end_datetime);
|
exception_event.dtend = Some(end_datetime);
|
||||||
exception_event.summary = if request.title.trim().is_empty() {
|
exception_event.summary = if request.title.trim().is_empty() {
|
||||||
existing_event.summary.clone() // Keep original if empty
|
existing_event.summary.clone() // Keep original if empty
|
||||||
} else {
|
} else {
|
||||||
Some(request.title.clone())
|
Some(request.title.clone())
|
||||||
};
|
};
|
||||||
exception_event.description = if request.description.trim().is_empty() {
|
exception_event.description = if request.description.trim().is_empty() {
|
||||||
existing_event.description.clone() // Keep original if empty
|
existing_event.description.clone() // Keep original if empty
|
||||||
} else {
|
} else {
|
||||||
Some(request.description.clone())
|
Some(request.description.clone())
|
||||||
};
|
};
|
||||||
exception_event.location = if request.location.trim().is_empty() {
|
exception_event.location = if request.location.trim().is_empty() {
|
||||||
existing_event.location.clone() // Keep original if empty
|
existing_event.location.clone() // Keep original if empty
|
||||||
} else {
|
} else {
|
||||||
Some(request.location.clone())
|
Some(request.location.clone())
|
||||||
};
|
};
|
||||||
@@ -801,10 +1000,14 @@ async fn update_single_occurrence(
|
|||||||
// Set calendar path for the exception event
|
// Set calendar path for the exception event
|
||||||
exception_event.calendar_path = Some(calendar_path.to_string());
|
exception_event.calendar_path = Some(calendar_path.to_string());
|
||||||
|
|
||||||
println!("✨ Created exception event with RECURRENCE-ID: {}", exception_utc.format("%Y-%m-%d %H:%M:%S"));
|
println!(
|
||||||
|
"✨ Created exception event with RECURRENCE-ID: {}",
|
||||||
|
exception_utc.format("%Y-%m-%d %H:%M:%S")
|
||||||
|
);
|
||||||
|
|
||||||
// Create the exception event as a new event (original series will be updated by main handler)
|
// Create the exception event as a new event (original series will be updated by main handler)
|
||||||
client.create_event(calendar_path, &exception_event)
|
client
|
||||||
|
.create_event(calendar_path, &exception_event)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to create exception event: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to create exception event: {}", e)))?;
|
||||||
|
|
||||||
@@ -820,7 +1023,8 @@ async fn delete_entire_series(
|
|||||||
request: &DeleteEventSeriesRequest,
|
request: &DeleteEventSeriesRequest,
|
||||||
) -> Result<u32, ApiError> {
|
) -> Result<u32, ApiError> {
|
||||||
// Simply delete the entire event from the CalDAV server
|
// Simply delete the entire event from the CalDAV server
|
||||||
client.delete_event(&request.calendar_path, &request.event_href)
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event series: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to delete event series: {}", e)))?;
|
||||||
|
|
||||||
@@ -835,10 +1039,13 @@ async fn delete_this_and_future(
|
|||||||
) -> Result<u32, ApiError> {
|
) -> Result<u32, ApiError> {
|
||||||
// Fetch the existing event to modify its RRULE
|
// Fetch the existing event to modify its RRULE
|
||||||
let event_uid = request.series_uid.clone();
|
let event_uid = request.series_uid.clone();
|
||||||
let existing_event = client.fetch_event_by_uid(&request.calendar_path, &event_uid)
|
let existing_event = client
|
||||||
|
.fetch_event_by_uid(&request.calendar_path, &event_uid)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch event for modification: {}", e)))?
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event for modification: {}", e)))?
|
||||||
.ok_or_else(|| ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid)))?;
|
.ok_or_else(|| {
|
||||||
|
ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid))
|
||||||
|
})?;
|
||||||
|
|
||||||
// If no occurrence_date is provided, delete the entire series
|
// If no occurrence_date is provided, delete the entire series
|
||||||
let Some(occurrence_date) = &request.occurrence_date else {
|
let Some(occurrence_date) = &request.occurrence_date else {
|
||||||
@@ -846,12 +1053,17 @@ async fn delete_this_and_future(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Parse occurrence date to set as UNTIL for the RRULE
|
// Parse occurrence date to set as UNTIL for the RRULE
|
||||||
let until_date = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
let until_date =
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()))?;
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d").map_err(|_| {
|
||||||
|
ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
// Set UNTIL to the day before the occurrence to exclude it and all future occurrences
|
// Set UNTIL to the day before the occurrence to exclude it and all future occurrences
|
||||||
let until_datetime = until_date.pred_opt()
|
let until_datetime = until_date
|
||||||
.ok_or_else(|| ApiError::BadRequest("Cannot delete from the first possible date".to_string()))?
|
.pred_opt()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ApiError::BadRequest("Cannot delete from the first possible date".to_string())
|
||||||
|
})?
|
||||||
.and_hms_opt(23, 59, 59)
|
.and_hms_opt(23, 59, 59)
|
||||||
.ok_or_else(|| ApiError::BadRequest("Invalid date calculation".to_string()))?;
|
.ok_or_else(|| ApiError::BadRequest("Invalid date calculation".to_string()))?;
|
||||||
let utc_until = chrono::Utc.from_utc_datetime(&until_datetime);
|
let utc_until = chrono::Utc.from_utc_datetime(&until_datetime);
|
||||||
@@ -860,19 +1072,30 @@ async fn delete_this_and_future(
|
|||||||
let mut updated_event = existing_event;
|
let mut updated_event = existing_event;
|
||||||
if let Some(rrule) = &updated_event.rrule {
|
if let Some(rrule) = &updated_event.rrule {
|
||||||
// Remove existing UNTIL or COUNT if present and add new UNTIL
|
// Remove existing UNTIL or COUNT if present and add new UNTIL
|
||||||
let parts: Vec<&str> = rrule.split(';').filter(|part| {
|
let parts: Vec<&str> = rrule
|
||||||
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
.split(';')
|
||||||
}).collect();
|
.filter(|part| !part.starts_with("UNTIL=") && !part.starts_with("COUNT="))
|
||||||
|
.collect();
|
||||||
|
|
||||||
updated_event.rrule = Some(format!("{};UNTIL={}", parts.join(";"), utc_until.format("%Y%m%dT%H%M%SZ")));
|
updated_event.rrule = Some(format!(
|
||||||
|
"{};UNTIL={}",
|
||||||
|
parts.join(";"),
|
||||||
|
utc_until.format("%Y%m%dT%H%M%SZ")
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the event on the CalDAV server
|
// Update the event on the CalDAV server
|
||||||
client.update_event(&request.calendar_path, &updated_event, &request.event_href)
|
client
|
||||||
|
.update_event(&request.calendar_path, &updated_event, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to update event series for deletion: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to update event series for deletion: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
println!("🗑️ Series modified with UNTIL for this_and_future deletion: {}", utc_until.format("%Y-%m-%d"));
|
println!(
|
||||||
|
"🗑️ Series modified with UNTIL for this_and_future deletion: {}",
|
||||||
|
utc_until.format("%Y-%m-%d")
|
||||||
|
);
|
||||||
Ok(1) // 1 series modified
|
Ok(1) // 1 series modified
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -883,19 +1106,26 @@ async fn delete_single_occurrence(
|
|||||||
) -> Result<u32, ApiError> {
|
) -> Result<u32, ApiError> {
|
||||||
// Fetch the existing event to add EXDATE
|
// Fetch the existing event to add EXDATE
|
||||||
let event_uid = request.series_uid.clone();
|
let event_uid = request.series_uid.clone();
|
||||||
let existing_event = client.fetch_event_by_uid(&request.calendar_path, &event_uid)
|
let existing_event = client
|
||||||
|
.fetch_event_by_uid(&request.calendar_path, &event_uid)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch event for modification: {}", e)))?
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event for modification: {}", e)))?
|
||||||
.ok_or_else(|| ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid)))?;
|
.ok_or_else(|| {
|
||||||
|
ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid))
|
||||||
|
})?;
|
||||||
|
|
||||||
// If no occurrence_date is provided, cannot delete single occurrence
|
// If no occurrence_date is provided, cannot delete single occurrence
|
||||||
let Some(occurrence_date) = &request.occurrence_date else {
|
let Some(occurrence_date) = &request.occurrence_date else {
|
||||||
return Err(ApiError::BadRequest("occurrence_date is required for single occurrence deletion".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"occurrence_date is required for single occurrence deletion".to_string(),
|
||||||
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse occurrence date
|
// Parse occurrence date
|
||||||
let exception_date = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
let exception_date =
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()))?;
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d").map_err(|_| {
|
||||||
|
ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
// Create the EXDATE datetime (use the same time as the original event)
|
// Create the EXDATE datetime (use the same time as the original event)
|
||||||
let original_time = existing_event.dtstart.time();
|
let original_time = existing_event.dtstart.time();
|
||||||
@@ -906,12 +1136,21 @@ async fn delete_single_occurrence(
|
|||||||
let mut updated_event = existing_event;
|
let mut updated_event = existing_event;
|
||||||
updated_event.exdate.push(exception_utc);
|
updated_event.exdate.push(exception_utc);
|
||||||
|
|
||||||
println!("🗑️ Added EXDATE for single occurrence deletion: {}", exception_utc.format("%Y%m%dT%H%M%SZ"));
|
println!(
|
||||||
|
"🗑️ Added EXDATE for single occurrence deletion: {}",
|
||||||
|
exception_utc.format("%Y%m%dT%H%M%SZ")
|
||||||
|
);
|
||||||
|
|
||||||
// Update the event on the CalDAV server
|
// Update the event on the CalDAV server
|
||||||
client.update_event(&request.calendar_path, &updated_event, &request.event_href)
|
client
|
||||||
|
.update_event(&request.calendar_path, &updated_event, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to update event series for single deletion: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!(
|
||||||
|
"Failed to update event series for single deletion: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(1) // 1 occurrence excluded
|
Ok(1) // 1 occurrence excluded
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ use axum::{
|
|||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tower_http::cors::{CorsLayer, Any};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod models;
|
|
||||||
pub mod handlers;
|
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod handlers;
|
||||||
|
pub mod models;
|
||||||
|
|
||||||
use auth::AuthService;
|
use auth::AuthService;
|
||||||
|
|
||||||
@@ -46,9 +46,18 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.route("/api/calendar/events/delete", post(handlers::delete_event))
|
.route("/api/calendar/events/delete", post(handlers::delete_event))
|
||||||
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
|
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
|
||||||
// Event series-specific endpoints
|
// Event series-specific endpoints
|
||||||
.route("/api/calendar/events/series/create", post(handlers::create_event_series))
|
.route(
|
||||||
.route("/api/calendar/events/series/update", post(handlers::update_event_series))
|
"/api/calendar/events/series/create",
|
||||||
.route("/api/calendar/events/series/delete", post(handlers::delete_event_series))
|
post(handlers::create_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/update",
|
||||||
|
post(handlers::update_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/delete",
|
||||||
|
post(handlers::delete_event_series),
|
||||||
|
)
|
||||||
.layer(
|
.layer(
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
|
|||||||
@@ -76,21 +76,21 @@ pub struct DeleteEventResponse {
|
|||||||
pub struct CreateEventRequest {
|
pub struct CreateEventRequest {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
pub recurrence: String, // recurrence type
|
pub recurrence: String, // recurrence type
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,24 +103,24 @@ pub struct CreateEventResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateEventRequest {
|
pub struct UpdateEventRequest {
|
||||||
pub uid: String, // Event UID to identify which event to update
|
pub uid: String, // Event UID to identify which event to update
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
pub recurrence: String, // recurrence type
|
pub recurrence: String, // recurrence type
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
pub update_action: Option<String>, // "update_series" for recurring events
|
pub update_action: Option<String>, // "update_series" for recurring events
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -139,22 +139,22 @@ pub struct UpdateEventResponse {
|
|||||||
pub struct CreateEventSeriesRequest {
|
pub struct CreateEventSeriesRequest {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
|
|
||||||
// Series-specific fields
|
// Series-specific fields
|
||||||
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
||||||
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||||
@@ -173,25 +173,25 @@ pub struct CreateEventSeriesResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateEventSeriesRequest {
|
pub struct UpdateEventSeriesRequest {
|
||||||
pub series_uid: String, // Series UID to identify which series to update
|
pub series_uid: String, // Series UID to identify which series to update
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
|
|
||||||
// Series-specific fields
|
// Series-specific fields
|
||||||
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
||||||
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||||
@@ -199,7 +199,7 @@ pub struct UpdateEventSeriesRequest {
|
|||||||
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
|
|
||||||
// Update scope control
|
// Update scope control
|
||||||
pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
|
pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
|
||||||
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
|
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
|
||||||
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
|
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
|
||||||
}
|
}
|
||||||
@@ -214,12 +214,12 @@ pub struct UpdateEventSeriesResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct DeleteEventSeriesRequest {
|
pub struct DeleteEventSeriesRequest {
|
||||||
pub series_uid: String, // Series UID to identify which series to delete
|
pub series_uid: String, // Series UID to identify which series to delete
|
||||||
pub calendar_path: String,
|
pub calendar_path: String,
|
||||||
pub event_href: String,
|
pub event_href: String,
|
||||||
|
|
||||||
// Delete scope control
|
// Delete scope control
|
||||||
pub delete_scope: String, // "this_only", "this_and_future", "all_in_series"
|
pub delete_scope: String, // "this_only", "this_and_future", "all_in_series"
|
||||||
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being deleted
|
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being deleted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
use calendar_backend::AppState;
|
|
||||||
use calendar_backend::auth::AuthService;
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::time::sleep;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
response::Json,
|
response::Json,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tower_http::cors::{CorsLayer, Any};
|
use calendar_backend::auth::AuthService;
|
||||||
|
use calendar_backend::AppState;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde_json::json;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
/// Test utilities for integration testing
|
/// Test utilities for integration testing
|
||||||
mod test_utils {
|
mod test_utils {
|
||||||
@@ -33,19 +33,55 @@ mod test_utils {
|
|||||||
.route("/", get(root))
|
.route("/", get(root))
|
||||||
.route("/api/health", get(health_check))
|
.route("/api/health", get(health_check))
|
||||||
.route("/api/auth/login", post(calendar_backend::handlers::login))
|
.route("/api/auth/login", post(calendar_backend::handlers::login))
|
||||||
.route("/api/auth/verify", get(calendar_backend::handlers::verify_token))
|
.route(
|
||||||
.route("/api/user/info", get(calendar_backend::handlers::get_user_info))
|
"/api/auth/verify",
|
||||||
.route("/api/calendar/create", post(calendar_backend::handlers::create_calendar))
|
get(calendar_backend::handlers::verify_token),
|
||||||
.route("/api/calendar/delete", post(calendar_backend::handlers::delete_calendar))
|
)
|
||||||
.route("/api/calendar/events", get(calendar_backend::handlers::get_calendar_events))
|
.route(
|
||||||
.route("/api/calendar/events/create", post(calendar_backend::handlers::create_event))
|
"/api/user/info",
|
||||||
.route("/api/calendar/events/update", post(calendar_backend::handlers::update_event))
|
get(calendar_backend::handlers::get_user_info),
|
||||||
.route("/api/calendar/events/delete", post(calendar_backend::handlers::delete_event))
|
)
|
||||||
.route("/api/calendar/events/:uid", get(calendar_backend::handlers::refresh_event))
|
.route(
|
||||||
|
"/api/calendar/create",
|
||||||
|
post(calendar_backend::handlers::create_calendar),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/delete",
|
||||||
|
post(calendar_backend::handlers::delete_calendar),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events",
|
||||||
|
get(calendar_backend::handlers::get_calendar_events),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/create",
|
||||||
|
post(calendar_backend::handlers::create_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/update",
|
||||||
|
post(calendar_backend::handlers::update_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/delete",
|
||||||
|
post(calendar_backend::handlers::delete_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/:uid",
|
||||||
|
get(calendar_backend::handlers::refresh_event),
|
||||||
|
)
|
||||||
// Event series-specific endpoints
|
// Event series-specific endpoints
|
||||||
.route("/api/calendar/events/series/create", post(calendar_backend::handlers::create_event_series))
|
.route(
|
||||||
.route("/api/calendar/events/series/update", post(calendar_backend::handlers::update_event_series))
|
"/api/calendar/events/series/create",
|
||||||
.route("/api/calendar/events/series/delete", post(calendar_backend::handlers::delete_event_series))
|
post(calendar_backend::handlers::create_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/update",
|
||||||
|
post(calendar_backend::handlers::update_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/delete",
|
||||||
|
post(calendar_backend::handlers::delete_event_series),
|
||||||
|
)
|
||||||
.layer(
|
.layer(
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
@@ -77,17 +113,25 @@ mod test_utils {
|
|||||||
"server_url": "https://example.com".to_string()
|
"server_url": "https://example.com".to_string()
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = self.client
|
let response = self
|
||||||
|
.client
|
||||||
.post(&format!("{}/api/auth/login", self.base_url))
|
.post(&format!("{}/api/auth/login", self.base_url))
|
||||||
.json(&login_payload)
|
.json(&login_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("Failed to send login request");
|
.expect("Failed to send login request");
|
||||||
|
|
||||||
assert!(response.status().is_success(), "Login failed with status: {}", response.status());
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"Login failed with status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
let login_response: serde_json::Value = response.json().await.unwrap();
|
let login_response: serde_json::Value = response.json().await.unwrap();
|
||||||
login_response["token"].as_str().expect("Login response should contain token").to_string()
|
login_response["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("Login response should contain token")
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,15 +150,16 @@ mod test_utils {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
|
||||||
use super::test_utils::*;
|
use super::test_utils::*;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
/// Test the health endpoint
|
/// Test the health endpoint
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_health_endpoint() {
|
async fn test_health_endpoint() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/health", server.base_url))
|
.get(&format!("{}/api/health", server.base_url))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -145,18 +190,29 @@ mod tests {
|
|||||||
"server_url": server_url
|
"server_url": server_url
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.post(&format!("{}/api/auth/login", server.base_url))
|
.post(&format!("{}/api/auth/login", server.base_url))
|
||||||
.json(&login_payload)
|
.json(&login_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(response.status().is_success(), "Login failed with status: {}", response.status());
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"Login failed with status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
let login_response: serde_json::Value = response.json().await.unwrap();
|
let login_response: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(login_response["token"].is_string(), "Login response should contain a token");
|
assert!(
|
||||||
assert!(login_response["username"].is_string(), "Login response should contain username");
|
login_response["token"].is_string(),
|
||||||
|
"Login response should contain a token"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
login_response["username"].is_string(),
|
||||||
|
"Login response should contain username"
|
||||||
|
);
|
||||||
|
|
||||||
println!("✓ Authentication login test passed");
|
println!("✓ Authentication login test passed");
|
||||||
}
|
}
|
||||||
@@ -169,7 +225,8 @@ mod tests {
|
|||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/auth/verify", server.base_url))
|
.get(&format!("{}/api/auth/verify", server.base_url))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.send()
|
.send()
|
||||||
@@ -196,7 +253,8 @@ mod tests {
|
|||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = "test".to_string();
|
let password = "test".to_string();
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/user/info", server.base_url))
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
@@ -210,7 +268,10 @@ mod tests {
|
|||||||
assert!(user_info["username"].is_string());
|
assert!(user_info["username"].is_string());
|
||||||
println!("✓ User info test passed");
|
println!("✓ User info test passed");
|
||||||
} else {
|
} else {
|
||||||
println!("⚠ User info test skipped (CalDAV server issues): {}", response.status());
|
println!(
|
||||||
|
"⚠ User info test skipped (CalDAV server issues): {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,20 +287,31 @@ mod tests {
|
|||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = "test".to_string();
|
let password = "test".to_string();
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.get(&format!("{}/api/calendar/events?year=2024&month=12", server.base_url))
|
.client
|
||||||
|
.get(&format!(
|
||||||
|
"{}/api/calendar/events?year=2024&month=12",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(response.status().is_success(), "Get events failed with status: {}", response.status());
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"Get events failed with status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
let events: serde_json::Value = response.json().await.unwrap();
|
let events: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(events.is_array());
|
assert!(events.is_array());
|
||||||
|
|
||||||
println!("✓ Get calendar events test passed (found {} events)", events.as_array().unwrap().len());
|
println!(
|
||||||
|
"✓ Get calendar events test passed (found {} events)",
|
||||||
|
events.as_array().unwrap().len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test event creation endpoint
|
/// Test event creation endpoint
|
||||||
@@ -274,7 +346,8 @@ mod tests {
|
|||||||
"recurrence_days": [false, false, false, false, false, false, false]
|
"recurrence_days": [false, false, false, false, false, false, false]
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.post(&format!("{}/api/calendar/events/create", server.base_url))
|
.post(&format!("{}/api/calendar/events/create", server.base_url))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
@@ -311,8 +384,12 @@ mod tests {
|
|||||||
// Use a dummy UID for testing - this will likely return 404 but we're testing the endpoint structure
|
// Use a dummy UID for testing - this will likely return 404 but we're testing the endpoint structure
|
||||||
let test_uid = "test-event-uid";
|
let test_uid = "test-event-uid";
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.get(&format!("{}/api/calendar/events/{}", server.base_url, test_uid))
|
.client
|
||||||
|
.get(&format!(
|
||||||
|
"{}/api/calendar/events/{}",
|
||||||
|
server.base_url, test_uid
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.send()
|
.send()
|
||||||
@@ -320,8 +397,11 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// We expect either 200 (if event exists) or 404 (if not found) - both are valid responses
|
// We expect either 200 (if event exists) or 404 (if not found) - both are valid responses
|
||||||
assert!(response.status() == 200 || response.status() == 404,
|
assert!(
|
||||||
"Refresh event failed with unexpected status: {}", response.status());
|
response.status() == 200 || response.status() == 404,
|
||||||
|
"Refresh event failed with unexpected status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
println!("✓ Refresh event endpoint test passed");
|
println!("✓ Refresh event endpoint test passed");
|
||||||
}
|
}
|
||||||
@@ -331,7 +411,8 @@ mod tests {
|
|||||||
async fn test_invalid_auth() {
|
async fn test_invalid_auth() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/user/info", server.base_url))
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
.header("Authorization", "Bearer invalid-token")
|
.header("Authorization", "Bearer invalid-token")
|
||||||
.send()
|
.send()
|
||||||
@@ -339,8 +420,11 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Accept both 400 and 401 as valid responses for invalid tokens
|
// Accept both 400 and 401 as valid responses for invalid tokens
|
||||||
assert!(response.status() == 401 || response.status() == 400,
|
assert!(
|
||||||
"Expected 401 or 400 for invalid token, got {}", response.status());
|
response.status() == 401 || response.status() == 400,
|
||||||
|
"Expected 401 or 400 for invalid token, got {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
println!("✓ Invalid authentication test passed");
|
println!("✓ Invalid authentication test passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +433,8 @@ mod tests {
|
|||||||
async fn test_missing_auth() {
|
async fn test_missing_auth() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/user/info", server.base_url))
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -396,8 +481,12 @@ mod tests {
|
|||||||
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/create", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/create",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.json(&create_payload)
|
.json(&create_payload)
|
||||||
@@ -456,8 +545,12 @@ mod tests {
|
|||||||
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/update", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/update",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.json(&update_payload)
|
.json(&update_payload)
|
||||||
@@ -472,10 +565,15 @@ mod tests {
|
|||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
let update_response: serde_json::Value = response.json().await.unwrap();
|
let update_response: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(update_response["success"].as_bool().unwrap_or(false));
|
assert!(update_response["success"].as_bool().unwrap_or(false));
|
||||||
assert_eq!(update_response["series_uid"].as_str().unwrap(), "test-series-uid");
|
assert_eq!(
|
||||||
|
update_response["series_uid"].as_str().unwrap(),
|
||||||
|
"test-series-uid"
|
||||||
|
);
|
||||||
println!("✓ Update event series test passed");
|
println!("✓ Update event series test passed");
|
||||||
} else if status == 404 {
|
} else if status == 404 {
|
||||||
println!("⚠ Update event series test skipped (event not found - expected for test data)");
|
println!(
|
||||||
|
"⚠ Update event series test skipped (event not found - expected for test data)"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!("⚠ Update event series test skipped (CalDAV server not accessible)");
|
println!("⚠ Update event series test skipped (CalDAV server not accessible)");
|
||||||
}
|
}
|
||||||
@@ -500,8 +598,12 @@ mod tests {
|
|||||||
"delete_scope": "all_in_series"
|
"delete_scope": "all_in_series"
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/delete", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/delete",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.json(&delete_payload)
|
.json(&delete_payload)
|
||||||
@@ -518,7 +620,9 @@ mod tests {
|
|||||||
assert!(delete_response["success"].as_bool().unwrap_or(false));
|
assert!(delete_response["success"].as_bool().unwrap_or(false));
|
||||||
println!("✓ Delete event series test passed");
|
println!("✓ Delete event series test passed");
|
||||||
} else if status == 404 {
|
} else if status == 404 {
|
||||||
println!("⚠ Delete event series test skipped (event not found - expected for test data)");
|
println!(
|
||||||
|
"⚠ Delete event series test skipped (event not found - expected for test data)"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!("⚠ Delete event series test skipped (CalDAV server not accessible)");
|
println!("⚠ Delete event series test skipped (CalDAV server not accessible)");
|
||||||
}
|
}
|
||||||
@@ -553,15 +657,23 @@ mod tests {
|
|||||||
"update_scope": "invalid_scope" // This should cause a 400 error
|
"update_scope": "invalid_scope" // This should cause a 400 error
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/update", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/update",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.json(&invalid_payload)
|
.json(&invalid_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), 400, "Expected 400 for invalid update scope");
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
400,
|
||||||
|
"Expected 400 for invalid update scope"
|
||||||
|
);
|
||||||
println!("✓ Invalid update scope test passed");
|
println!("✓ Invalid update scope test passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,15 +704,23 @@ mod tests {
|
|||||||
"recurrence_days": [false, false, false, false, false, false, false]
|
"recurrence_days": [false, false, false, false, false, false, false]
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/create", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/create",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.json(&non_recurring_payload)
|
.json(&non_recurring_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), 400, "Expected 400 for non-recurring event in series endpoint");
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
400,
|
||||||
|
"Expected 400 for non-recurring event in series endpoint"
|
||||||
|
);
|
||||||
println!("✓ Non-recurring series rejection test passed");
|
println!("✓ Non-recurring series rejection test passed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Common types and enums used across calendar components
|
//! Common types and enums used across calendar components
|
||||||
|
|
||||||
use chrono::{DateTime, Utc, Duration};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ==================== ENUMS AND COMMON TYPES ====================
|
// ==================== ENUMS AND COMMON TYPES ====================
|
||||||
@@ -64,11 +64,11 @@ pub enum AlarmAction {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct CalendarUser {
|
pub struct CalendarUser {
|
||||||
pub cal_address: String, // Calendar user address (usually email)
|
pub cal_address: String, // Calendar user address (usually email)
|
||||||
pub common_name: Option<String>, // CN parameter - display name
|
pub common_name: Option<String>, // CN parameter - display name
|
||||||
pub dir_entry_ref: Option<String>, // DIR parameter - directory entry
|
pub dir_entry_ref: Option<String>, // DIR parameter - directory entry
|
||||||
pub sent_by: Option<String>, // SENT-BY parameter
|
pub sent_by: Option<String>, // SENT-BY parameter
|
||||||
pub language: Option<String>, // LANGUAGE parameter
|
pub language: Option<String>, // LANGUAGE parameter
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -78,130 +78,130 @@ pub struct Attendee {
|
|||||||
pub role: Option<AttendeeRole>, // ROLE parameter
|
pub role: Option<AttendeeRole>, // ROLE parameter
|
||||||
pub part_stat: Option<ParticipationStatus>, // PARTSTAT parameter
|
pub part_stat: Option<ParticipationStatus>, // PARTSTAT parameter
|
||||||
pub rsvp: Option<bool>, // RSVP parameter
|
pub rsvp: Option<bool>, // RSVP parameter
|
||||||
pub cu_type: Option<String>, // CUTYPE parameter (INDIVIDUAL, GROUP, RESOURCE, ROOM, UNKNOWN)
|
pub cu_type: Option<String>, // CUTYPE parameter (INDIVIDUAL, GROUP, RESOURCE, ROOM, UNKNOWN)
|
||||||
pub member: Vec<String>, // MEMBER parameter
|
pub member: Vec<String>, // MEMBER parameter
|
||||||
pub delegated_to: Vec<String>, // DELEGATED-TO parameter
|
pub delegated_to: Vec<String>, // DELEGATED-TO parameter
|
||||||
pub delegated_from: Vec<String>, // DELEGATED-FROM parameter
|
pub delegated_from: Vec<String>, // DELEGATED-FROM parameter
|
||||||
pub sent_by: Option<String>, // SENT-BY parameter
|
pub sent_by: Option<String>, // SENT-BY parameter
|
||||||
pub dir_entry_ref: Option<String>, // DIR parameter
|
pub dir_entry_ref: Option<String>, // DIR parameter
|
||||||
pub language: Option<String>, // LANGUAGE parameter
|
pub language: Option<String>, // LANGUAGE parameter
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VAlarm {
|
pub struct VAlarm {
|
||||||
pub action: AlarmAction, // Action (ACTION) - REQUIRED
|
pub action: AlarmAction, // Action (ACTION) - REQUIRED
|
||||||
pub trigger: AlarmTrigger, // Trigger (TRIGGER) - REQUIRED
|
pub trigger: AlarmTrigger, // Trigger (TRIGGER) - REQUIRED
|
||||||
pub duration: Option<Duration>, // Duration (DURATION)
|
pub duration: Option<Duration>, // Duration (DURATION)
|
||||||
pub repeat: Option<u32>, // Repeat count (REPEAT)
|
pub repeat: Option<u32>, // Repeat count (REPEAT)
|
||||||
pub description: Option<String>, // Description for DISPLAY/EMAIL
|
pub description: Option<String>, // Description for DISPLAY/EMAIL
|
||||||
pub summary: Option<String>, // Summary for EMAIL
|
pub summary: Option<String>, // Summary for EMAIL
|
||||||
pub attendees: Vec<Attendee>, // Attendees for EMAIL
|
pub attendees: Vec<Attendee>, // Attendees for EMAIL
|
||||||
pub attach: Vec<Attachment>, // Attachments for AUDIO/EMAIL
|
pub attach: Vec<Attachment>, // Attachments for AUDIO/EMAIL
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub enum AlarmTrigger {
|
pub enum AlarmTrigger {
|
||||||
DateTime(DateTime<Utc>), // Absolute trigger time
|
DateTime(DateTime<Utc>), // Absolute trigger time
|
||||||
Duration(Duration), // Duration relative to start/end
|
Duration(Duration), // Duration relative to start/end
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct Attachment {
|
pub struct Attachment {
|
||||||
pub format_type: Option<String>, // FMTTYPE parameter (MIME type)
|
pub format_type: Option<String>, // FMTTYPE parameter (MIME type)
|
||||||
pub encoding: Option<String>, // ENCODING parameter
|
pub encoding: Option<String>, // ENCODING parameter
|
||||||
pub value: Option<String>, // VALUE parameter (BINARY or URI)
|
pub value: Option<String>, // VALUE parameter (BINARY or URI)
|
||||||
pub uri: Option<String>, // URI reference
|
pub uri: Option<String>, // URI reference
|
||||||
pub binary_data: Option<Vec<u8>>, // Binary data (when ENCODING=BASE64)
|
pub binary_data: Option<Vec<u8>>, // Binary data (when ENCODING=BASE64)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct GeographicPosition {
|
pub struct GeographicPosition {
|
||||||
pub latitude: f64, // Latitude in decimal degrees
|
pub latitude: f64, // Latitude in decimal degrees
|
||||||
pub longitude: f64, // Longitude in decimal degrees
|
pub longitude: f64, // Longitude in decimal degrees
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VTimeZone {
|
pub struct VTimeZone {
|
||||||
pub tzid: String, // Time zone ID (TZID) - REQUIRED
|
pub tzid: String, // Time zone ID (TZID) - REQUIRED
|
||||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||||
pub tzurl: Option<String>, // Time zone URL (TZURL)
|
pub tzurl: Option<String>, // Time zone URL (TZURL)
|
||||||
pub standard_components: Vec<TimeZoneComponent>, // STANDARD components
|
pub standard_components: Vec<TimeZoneComponent>, // STANDARD components
|
||||||
pub daylight_components: Vec<TimeZoneComponent>, // DAYLIGHT components
|
pub daylight_components: Vec<TimeZoneComponent>, // DAYLIGHT components
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct TimeZoneComponent {
|
pub struct TimeZoneComponent {
|
||||||
pub dtstart: DateTime<Utc>, // Start of this time zone definition
|
pub dtstart: DateTime<Utc>, // Start of this time zone definition
|
||||||
pub tzoffset_to: String, // UTC offset for this component
|
pub tzoffset_to: String, // UTC offset for this component
|
||||||
pub tzoffset_from: String, // UTC offset before this component
|
pub tzoffset_from: String, // UTC offset before this component
|
||||||
pub rrule: Option<String>, // Recurrence rule
|
pub rrule: Option<String>, // Recurrence rule
|
||||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates
|
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates
|
||||||
pub tzname: Vec<String>, // Time zone names
|
pub tzname: Vec<String>, // Time zone names
|
||||||
pub comment: Vec<String>, // Comments
|
pub comment: Vec<String>, // Comments
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VJournal {
|
pub struct VJournal {
|
||||||
// Required properties
|
// Required properties
|
||||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
|
|
||||||
// Optional properties
|
// Optional properties
|
||||||
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||||
pub description: Option<String>, // Description (DESCRIPTION)
|
pub description: Option<String>, // Description (DESCRIPTION)
|
||||||
|
|
||||||
// Classification and status
|
// Classification and status
|
||||||
pub class: Option<EventClass>, // Classification (CLASS)
|
pub class: Option<EventClass>, // Classification (CLASS)
|
||||||
pub status: Option<String>, // Status (STATUS)
|
pub status: Option<String>, // Status (STATUS)
|
||||||
|
|
||||||
// People and organization
|
// People and organization
|
||||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
|
|
||||||
// Categorization
|
// Categorization
|
||||||
pub categories: Vec<String>, // Categories (CATEGORIES)
|
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||||
|
|
||||||
// Versioning and modification
|
// Versioning and modification
|
||||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||||
|
|
||||||
// Recurrence
|
// Recurrence
|
||||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||||
|
|
||||||
// Attachments
|
// Attachments
|
||||||
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VFreeBusy {
|
pub struct VFreeBusy {
|
||||||
// Required properties
|
// Required properties
|
||||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
|
|
||||||
// Optional date-time properties
|
// Optional date-time properties
|
||||||
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||||
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
||||||
|
|
||||||
// People
|
// People
|
||||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
|
|
||||||
// Free/busy time
|
// Free/busy time
|
||||||
pub freebusy: Vec<FreeBusyTime>, // Free/busy time periods
|
pub freebusy: Vec<FreeBusyTime>, // Free/busy time periods
|
||||||
pub url: Option<String>, // URL (URL)
|
pub url: Option<String>, // URL (URL)
|
||||||
pub comment: Vec<String>, // Comments (COMMENT)
|
pub comment: Vec<String>, // Comments (COMMENT)
|
||||||
pub contact: Option<String>, // Contact information (CONTACT)
|
pub contact: Option<String>, // Contact information (CONTACT)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct FreeBusyTime {
|
pub struct FreeBusyTime {
|
||||||
pub fb_type: FreeBusyType, // Free/busy type
|
pub fb_type: FreeBusyType, // Free/busy type
|
||||||
pub periods: Vec<Period>, // Time periods
|
pub periods: Vec<Period>, // Time periods
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -214,7 +214,7 @@ pub enum FreeBusyType {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct Period {
|
pub struct Period {
|
||||||
pub start: DateTime<Utc>, // Period start
|
pub start: DateTime<Utc>, // Period start
|
||||||
pub end: Option<DateTime<Utc>>, // Period end
|
pub end: Option<DateTime<Utc>>, // Period end
|
||||||
pub duration: Option<Duration>, // Period duration (alternative to end)
|
pub duration: Option<Duration>, // Period duration (alternative to end)
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
//! This crate provides shared data structures for calendar applications
|
//! This crate provides shared data structures for calendar applications
|
||||||
//! that comply with RFC 5545 (iCalendar) specification.
|
//! that comply with RFC 5545 (iCalendar) specification.
|
||||||
|
|
||||||
pub mod vevent;
|
|
||||||
pub mod common;
|
pub mod common;
|
||||||
|
pub mod vevent;
|
||||||
|
|
||||||
pub use vevent::*;
|
|
||||||
pub use common::*;
|
pub use common::*;
|
||||||
|
pub use vevent::*;
|
||||||
|
|||||||
@@ -1,66 +1,66 @@
|
|||||||
//! VEvent - RFC 5545 compliant calendar event structure
|
//! VEvent - RFC 5545 compliant calendar event structure
|
||||||
|
|
||||||
use chrono::{DateTime, Utc, Duration};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use crate::common::*;
|
use crate::common::*;
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ==================== VEVENT COMPONENT ====================
|
// ==================== VEVENT COMPONENT ====================
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VEvent {
|
pub struct VEvent {
|
||||||
// Required properties
|
// Required properties
|
||||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED
|
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED
|
||||||
|
|
||||||
// Optional properties (commonly used)
|
// Optional properties (commonly used)
|
||||||
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
||||||
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
||||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||||
pub description: Option<String>, // Description (DESCRIPTION)
|
pub description: Option<String>, // Description (DESCRIPTION)
|
||||||
pub location: Option<String>, // Location (LOCATION)
|
pub location: Option<String>, // Location (LOCATION)
|
||||||
|
|
||||||
// Classification and status
|
// Classification and status
|
||||||
pub class: Option<EventClass>, // Classification (CLASS)
|
pub class: Option<EventClass>, // Classification (CLASS)
|
||||||
pub status: Option<EventStatus>, // Status (STATUS)
|
pub status: Option<EventStatus>, // Status (STATUS)
|
||||||
pub transp: Option<TimeTransparency>, // Time transparency (TRANSP)
|
pub transp: Option<TimeTransparency>, // Time transparency (TRANSP)
|
||||||
pub priority: Option<u8>, // Priority 0-9 (PRIORITY)
|
pub priority: Option<u8>, // Priority 0-9 (PRIORITY)
|
||||||
|
|
||||||
// People and organization
|
// People and organization
|
||||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
pub contact: Option<String>, // Contact information (CONTACT)
|
pub contact: Option<String>, // Contact information (CONTACT)
|
||||||
|
|
||||||
// Categorization and relationships
|
// Categorization and relationships
|
||||||
pub categories: Vec<String>, // Categories (CATEGORIES)
|
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||||
pub comment: Option<String>, // Comment (COMMENT)
|
pub comment: Option<String>, // Comment (COMMENT)
|
||||||
pub resources: Vec<String>, // Resources (RESOURCES)
|
pub resources: Vec<String>, // Resources (RESOURCES)
|
||||||
pub related_to: Option<String>, // Related component (RELATED-TO)
|
pub related_to: Option<String>, // Related component (RELATED-TO)
|
||||||
pub url: Option<String>, // URL (URL)
|
pub url: Option<String>, // URL (URL)
|
||||||
|
|
||||||
// Geographical
|
// Geographical
|
||||||
pub geo: Option<GeographicPosition>, // Geographic position (GEO)
|
pub geo: Option<GeographicPosition>, // Geographic position (GEO)
|
||||||
|
|
||||||
// Versioning and modification
|
// Versioning and modification
|
||||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||||
|
|
||||||
// Recurrence
|
// Recurrence
|
||||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||||
|
|
||||||
// Alarms and attachments
|
// Alarms and attachments
|
||||||
pub alarms: Vec<VAlarm>, // VALARM components
|
pub alarms: Vec<VAlarm>, // VALARM components
|
||||||
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||||
|
|
||||||
// CalDAV specific (for implementation)
|
// CalDAV specific (for implementation)
|
||||||
pub etag: Option<String>, // ETag for CalDAV
|
pub etag: Option<String>, // ETag for CalDAV
|
||||||
pub href: Option<String>, // Href for CalDAV
|
pub href: Option<String>, // Href for CalDAV
|
||||||
pub calendar_path: Option<String>, // Calendar path
|
pub calendar_path: Option<String>, // Calendar path
|
||||||
pub all_day: bool, // All-day event flag
|
pub all_day: bool, // All-day event flag
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VEvent {
|
impl VEvent {
|
||||||
@@ -129,7 +129,9 @@ impl VEvent {
|
|||||||
|
|
||||||
/// Helper method to get display title (summary or "Untitled Event")
|
/// Helper method to get display title (summary or "Untitled Event")
|
||||||
pub fn get_title(&self) -> String {
|
pub fn get_title(&self) -> String {
|
||||||
self.summary.clone().unwrap_or_else(|| "Untitled Event".to_string())
|
self.summary
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Untitled Event".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper method to get start date for UI compatibility
|
/// Helper method to get start date for UI compatibility
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
use yew::prelude::*;
|
use crate::components::{
|
||||||
use yew_router::prelude::*;
|
CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction,
|
||||||
|
EditAction, EventClass, EventContextMenu, EventCreationData, EventStatus, RecurrenceType,
|
||||||
|
ReminderType, RouteHandler, Sidebar, Theme, ViewMode,
|
||||||
|
};
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::{calendar_service::UserInfo, CalendarService};
|
||||||
|
use chrono::NaiveDate;
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction, EditAction};
|
use yew::prelude::*;
|
||||||
use crate::services::{CalendarService, calendar_service::UserInfo};
|
use yew_router::prelude::*;
|
||||||
use crate::models::ical::VEvent;
|
|
||||||
use chrono::NaiveDate;
|
|
||||||
|
|
||||||
fn get_theme_event_colors() -> Vec<String> {
|
fn get_theme_event_colors() -> Vec<String> {
|
||||||
if let Some(window) = web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
@@ -27,18 +31,28 @@ fn get_theme_event_colors() -> Vec<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vec![
|
vec![
|
||||||
"#3B82F6".to_string(), "#10B981".to_string(), "#F59E0B".to_string(), "#EF4444".to_string(),
|
"#3B82F6".to_string(),
|
||||||
"#8B5CF6".to_string(), "#06B6D4".to_string(), "#84CC16".to_string(), "#F97316".to_string(),
|
"#10B981".to_string(),
|
||||||
"#EC4899".to_string(), "#6366F1".to_string(), "#14B8A6".to_string(), "#F3B806".to_string(),
|
"#F59E0B".to_string(),
|
||||||
"#8B5A2B".to_string(), "#6B7280".to_string(), "#DC2626".to_string(), "#7C3AED".to_string()
|
"#EF4444".to_string(),
|
||||||
|
"#8B5CF6".to_string(),
|
||||||
|
"#06B6D4".to_string(),
|
||||||
|
"#84CC16".to_string(),
|
||||||
|
"#F97316".to_string(),
|
||||||
|
"#EC4899".to_string(),
|
||||||
|
"#6366F1".to_string(),
|
||||||
|
"#14B8A6".to_string(),
|
||||||
|
"#F3B806".to_string(),
|
||||||
|
"#8B5A2B".to_string(),
|
||||||
|
"#6B7280".to_string(),
|
||||||
|
"#DC2626".to_string(),
|
||||||
|
"#7C3AED".to_string(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component]
|
#[function_component]
|
||||||
pub fn App() -> Html {
|
pub fn App() -> Html {
|
||||||
let auth_token = use_state(|| -> Option<String> {
|
let auth_token = use_state(|| -> Option<String> { LocalStorage::get("auth_token").ok() });
|
||||||
LocalStorage::get("auth_token").ok()
|
|
||||||
});
|
|
||||||
|
|
||||||
let user_info = use_state(|| -> Option<UserInfo> { None });
|
let user_info = use_state(|| -> Option<UserInfo> { None });
|
||||||
let color_picker_open = use_state(|| -> Option<String> { None });
|
let color_picker_open = use_state(|| -> Option<String> { None });
|
||||||
@@ -164,8 +178,12 @@ pub fn App() -> Html {
|
|||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
let calendar_service = CalendarService::new();
|
let calendar_service = CalendarService::new();
|
||||||
|
|
||||||
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
let password = if let Ok(credentials_str) =
|
||||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
LocalStorage::get::<String>("caldav_credentials")
|
||||||
|
{
|
||||||
|
if let Ok(credentials) =
|
||||||
|
serde_json::from_str::<serde_json::Value>(&credentials_str)
|
||||||
|
{
|
||||||
credentials["password"].as_str().unwrap_or("").to_string()
|
credentials["password"].as_str().unwrap_or("").to_string()
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -177,8 +195,12 @@ pub fn App() -> Html {
|
|||||||
if !password.is_empty() {
|
if !password.is_empty() {
|
||||||
match calendar_service.fetch_user_info(&token, &password).await {
|
match calendar_service.fetch_user_info(&token, &password).await {
|
||||||
Ok(mut info) => {
|
Ok(mut info) => {
|
||||||
if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") {
|
if let Ok(saved_colors_json) =
|
||||||
if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) {
|
LocalStorage::get::<String>("calendar_colors")
|
||||||
|
{
|
||||||
|
if let Ok(saved_info) =
|
||||||
|
serde_json::from_str::<UserInfo>(&saved_colors_json)
|
||||||
|
{
|
||||||
for saved_cal in &saved_info.calendars {
|
for saved_cal in &saved_info.calendars {
|
||||||
for cal in &mut info.calendars {
|
for cal in &mut info.calendars {
|
||||||
if cal.path == saved_cal.path {
|
if cal.path == saved_cal.path {
|
||||||
@@ -191,7 +213,9 @@ pub fn App() -> Html {
|
|||||||
user_info.set(Some(info));
|
user_info.set(Some(info));
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
web_sys::console::log_1(&format!("Failed to fetch user info: {}", err).into());
|
web_sys::console::log_1(
|
||||||
|
&format!("Failed to fetch user info: {}", err).into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,10 +235,10 @@ pub fn App() -> Html {
|
|||||||
let calendar_context_menu_open = calendar_context_menu_open.clone();
|
let calendar_context_menu_open = calendar_context_menu_open.clone();
|
||||||
Callback::from(move |e: MouseEvent| {
|
Callback::from(move |e: MouseEvent| {
|
||||||
// Check if any context menu or color picker is open
|
// Check if any context menu or color picker is open
|
||||||
let any_menu_open = color_picker_open.is_some() ||
|
let any_menu_open = color_picker_open.is_some()
|
||||||
*context_menu_open ||
|
|| *context_menu_open
|
||||||
*event_context_menu_open ||
|
|| *event_context_menu_open
|
||||||
*calendar_context_menu_open;
|
|| *calendar_context_menu_open;
|
||||||
|
|
||||||
if any_menu_open {
|
if any_menu_open {
|
||||||
// Prevent the default action and stop event propagation
|
// Prevent the default action and stop event propagation
|
||||||
@@ -231,10 +255,10 @@ pub fn App() -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Compute if any context menu is open
|
// Compute if any context menu is open
|
||||||
let any_context_menu_open = color_picker_open.is_some() ||
|
let any_context_menu_open = color_picker_open.is_some()
|
||||||
*context_menu_open ||
|
|| *context_menu_open
|
||||||
*event_context_menu_open ||
|
|| *event_context_menu_open
|
||||||
*calendar_context_menu_open;
|
|| *calendar_context_menu_open;
|
||||||
|
|
||||||
let on_color_change = {
|
let on_color_change = {
|
||||||
let user_info = user_info.clone();
|
let user_info = user_info.clone();
|
||||||
@@ -323,8 +347,12 @@ pub fn App() -> Html {
|
|||||||
let _calendar_service = CalendarService::new();
|
let _calendar_service = CalendarService::new();
|
||||||
|
|
||||||
// Get CalDAV password from storage
|
// Get CalDAV password from storage
|
||||||
let _password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
let _password = if let Ok(credentials_str) =
|
||||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
LocalStorage::get::<String>("caldav_credentials")
|
||||||
|
{
|
||||||
|
if let Ok(credentials) =
|
||||||
|
serde_json::from_str::<serde_json::Value>(&credentials_str)
|
||||||
|
{
|
||||||
credentials["password"].as_str().unwrap_or("").to_string()
|
credentials["password"].as_str().unwrap_or("").to_string()
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -334,28 +362,28 @@ pub fn App() -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let params = event_data.to_create_event_params();
|
let params = event_data.to_create_event_params();
|
||||||
let create_result = _calendar_service.create_event(
|
let create_result = _calendar_service
|
||||||
&_token,
|
.create_event(
|
||||||
&_password,
|
&_token, &_password, params.0, // title
|
||||||
params.0, // title
|
params.1, // description
|
||||||
params.1, // description
|
params.2, // start_date
|
||||||
params.2, // start_date
|
params.3, // start_time
|
||||||
params.3, // start_time
|
params.4, // end_date
|
||||||
params.4, // end_date
|
params.5, // end_time
|
||||||
params.5, // end_time
|
params.6, // location
|
||||||
params.6, // location
|
params.7, // all_day
|
||||||
params.7, // all_day
|
params.8, // status
|
||||||
params.8, // status
|
params.9, // class
|
||||||
params.9, // class
|
params.10, // priority
|
||||||
params.10, // priority
|
params.11, // organizer
|
||||||
params.11, // organizer
|
params.12, // attendees
|
||||||
params.12, // attendees
|
params.13, // categories
|
||||||
params.13, // categories
|
params.14, // reminder
|
||||||
params.14, // reminder
|
params.15, // recurrence
|
||||||
params.15, // recurrence
|
params.16, // recurrence_days
|
||||||
params.16, // recurrence_days
|
params.17, // calendar_path
|
||||||
params.17 // calendar_path
|
)
|
||||||
).await;
|
.await;
|
||||||
match create_result {
|
match create_result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
web_sys::console::log_1(&"Event created successfully".into());
|
web_sys::console::log_1(&"Event created successfully".into());
|
||||||
@@ -364,8 +392,13 @@ pub fn App() -> Html {
|
|||||||
web_sys::window().unwrap().location().reload().unwrap();
|
web_sys::window().unwrap().location().reload().unwrap();
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
web_sys::console::error_1(&format!("Failed to create event: {}", err).into());
|
web_sys::console::error_1(
|
||||||
web_sys::window().unwrap().alert_with_message(&format!("Failed to create event: {}", err)).unwrap();
|
&format!("Failed to create event: {}", err).into(),
|
||||||
|
);
|
||||||
|
web_sys::window()
|
||||||
|
.unwrap()
|
||||||
|
.alert_with_message(&format!("Failed to create event: {}", err))
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -375,161 +408,232 @@ pub fn App() -> Html {
|
|||||||
|
|
||||||
let on_event_update = {
|
let on_event_update = {
|
||||||
let auth_token = auth_token.clone();
|
let auth_token = auth_token.clone();
|
||||||
Callback::from(move |(original_event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)| {
|
Callback::from(
|
||||||
web_sys::console::log_1(&format!("Updating event: {} to new times: {} - {}",
|
move |(
|
||||||
original_event.uid,
|
original_event,
|
||||||
new_start.format("%Y-%m-%d %H:%M"),
|
new_start,
|
||||||
new_end.format("%Y-%m-%d %H:%M")).into());
|
new_end,
|
||||||
|
preserve_rrule,
|
||||||
|
until_date,
|
||||||
|
update_scope,
|
||||||
|
occurrence_date,
|
||||||
|
): (
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)| {
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"Updating event: {} to new times: {} - {}",
|
||||||
|
original_event.uid,
|
||||||
|
new_start.format("%Y-%m-%d %H:%M"),
|
||||||
|
new_end.format("%Y-%m-%d %H:%M")
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
// Use the original UID for all updates
|
// Use the original UID for all updates
|
||||||
let backend_uid = original_event.uid.clone();
|
let backend_uid = original_event.uid.clone();
|
||||||
|
|
||||||
if let Some(token) = (*auth_token).clone() {
|
if let Some(token) = (*auth_token).clone() {
|
||||||
let original_event = original_event.clone();
|
let original_event = original_event.clone();
|
||||||
let backend_uid = backend_uid.clone();
|
let backend_uid = backend_uid.clone();
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
let calendar_service = CalendarService::new();
|
let calendar_service = CalendarService::new();
|
||||||
|
|
||||||
// Get CalDAV password from storage
|
// Get CalDAV password from storage
|
||||||
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
let password = if let Ok(credentials_str) =
|
||||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
LocalStorage::get::<String>("caldav_credentials")
|
||||||
credentials["password"].as_str().unwrap_or("").to_string()
|
{
|
||||||
|
if let Ok(credentials) =
|
||||||
|
serde_json::from_str::<serde_json::Value>(&credentials_str)
|
||||||
|
{
|
||||||
|
credentials["password"].as_str().unwrap_or("").to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
}
|
};
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert local times to UTC for backend storage
|
// Convert local times to UTC for backend storage
|
||||||
let start_utc = new_start.and_local_timezone(chrono::Local).unwrap().to_utc();
|
let start_utc = new_start
|
||||||
let end_utc = new_end.and_local_timezone(chrono::Local).unwrap().to_utc();
|
.and_local_timezone(chrono::Local)
|
||||||
|
.unwrap()
|
||||||
|
.to_utc();
|
||||||
|
let end_utc = new_end.and_local_timezone(chrono::Local).unwrap().to_utc();
|
||||||
|
|
||||||
// Format UTC date and time strings for backend
|
// Format UTC date and time strings for backend
|
||||||
let start_date = start_utc.format("%Y-%m-%d").to_string();
|
let start_date = start_utc.format("%Y-%m-%d").to_string();
|
||||||
let start_time = start_utc.format("%H:%M").to_string();
|
let start_time = start_utc.format("%H:%M").to_string();
|
||||||
let end_date = end_utc.format("%Y-%m-%d").to_string();
|
let end_date = end_utc.format("%Y-%m-%d").to_string();
|
||||||
let end_time = end_utc.format("%H:%M").to_string();
|
let end_time = end_utc.format("%H:%M").to_string();
|
||||||
|
|
||||||
// Convert existing event data to string formats for the API
|
// Convert existing event data to string formats for the API
|
||||||
let status_str = match original_event.status {
|
let status_str = match original_event.status {
|
||||||
Some(crate::models::ical::EventStatus::Tentative) => "TENTATIVE".to_string(),
|
Some(crate::models::ical::EventStatus::Tentative) => {
|
||||||
Some(crate::models::ical::EventStatus::Confirmed) => "CONFIRMED".to_string(),
|
"TENTATIVE".to_string()
|
||||||
Some(crate::models::ical::EventStatus::Cancelled) => "CANCELLED".to_string(),
|
}
|
||||||
None => "CONFIRMED".to_string(), // Default status
|
Some(crate::models::ical::EventStatus::Confirmed) => {
|
||||||
};
|
"CONFIRMED".to_string()
|
||||||
|
}
|
||||||
|
Some(crate::models::ical::EventStatus::Cancelled) => {
|
||||||
|
"CANCELLED".to_string()
|
||||||
|
}
|
||||||
|
None => "CONFIRMED".to_string(), // Default status
|
||||||
|
};
|
||||||
|
|
||||||
let class_str = match original_event.class {
|
let class_str = match original_event.class {
|
||||||
Some(crate::models::ical::EventClass::Public) => "PUBLIC".to_string(),
|
Some(crate::models::ical::EventClass::Public) => "PUBLIC".to_string(),
|
||||||
Some(crate::models::ical::EventClass::Private) => "PRIVATE".to_string(),
|
Some(crate::models::ical::EventClass::Private) => "PRIVATE".to_string(),
|
||||||
Some(crate::models::ical::EventClass::Confidential) => "CONFIDENTIAL".to_string(),
|
Some(crate::models::ical::EventClass::Confidential) => {
|
||||||
None => "PUBLIC".to_string(), // Default class
|
"CONFIDENTIAL".to_string()
|
||||||
};
|
}
|
||||||
|
None => "PUBLIC".to_string(), // Default class
|
||||||
|
};
|
||||||
|
|
||||||
// Convert reminders to string format
|
// Convert reminders to string format
|
||||||
let reminder_str = if !original_event.alarms.is_empty() {
|
let reminder_str = if !original_event.alarms.is_empty() {
|
||||||
// Convert from VAlarm to minutes before
|
// Convert from VAlarm to minutes before
|
||||||
"15".to_string() // TODO: Convert VAlarm trigger to minutes
|
"15".to_string() // TODO: Convert VAlarm trigger to minutes
|
||||||
} else {
|
|
||||||
"".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle recurrence (keep existing)
|
|
||||||
let recurrence_str = original_event.rrule.unwrap_or_default();
|
|
||||||
let recurrence_days = vec![false; 7]; // Default - could be enhanced to parse existing recurrence
|
|
||||||
|
|
||||||
// Determine if this is a recurring event that needs series endpoint
|
|
||||||
let has_recurrence = !recurrence_str.is_empty() && recurrence_str.to_uppercase() != "NONE";
|
|
||||||
|
|
||||||
let result = if let Some(scope) = update_scope.as_ref() {
|
|
||||||
// Use series endpoint for recurring event operations
|
|
||||||
if !has_recurrence {
|
|
||||||
web_sys::console::log_1(&"⚠️ Warning: update_scope provided for non-recurring event, using regular endpoint instead".into());
|
|
||||||
// Fall through to regular endpoint
|
|
||||||
None
|
|
||||||
} else {
|
} else {
|
||||||
Some(calendar_service.update_series(
|
"".to_string()
|
||||||
&token,
|
};
|
||||||
&password,
|
|
||||||
backend_uid.clone(),
|
|
||||||
original_event.summary.clone().unwrap_or_default(),
|
|
||||||
original_event.description.clone().unwrap_or_default(),
|
|
||||||
start_date.clone(),
|
|
||||||
start_time.clone(),
|
|
||||||
end_date.clone(),
|
|
||||||
end_time.clone(),
|
|
||||||
original_event.location.clone().unwrap_or_default(),
|
|
||||||
original_event.all_day,
|
|
||||||
status_str.clone(),
|
|
||||||
class_str.clone(),
|
|
||||||
original_event.priority,
|
|
||||||
original_event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
|
|
||||||
original_event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(","),
|
|
||||||
original_event.categories.join(","),
|
|
||||||
reminder_str.clone(),
|
|
||||||
recurrence_str.clone(),
|
|
||||||
original_event.calendar_path.clone(),
|
|
||||||
scope.clone(),
|
|
||||||
occurrence_date,
|
|
||||||
).await)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = if let Some(series_result) = result {
|
// Handle recurrence (keep existing)
|
||||||
series_result
|
let recurrence_str = original_event.rrule.unwrap_or_default();
|
||||||
} else {
|
let recurrence_days = vec![false; 7]; // Default - could be enhanced to parse existing recurrence
|
||||||
// Use regular endpoint
|
|
||||||
calendar_service.update_event(
|
// Determine if this is a recurring event that needs series endpoint
|
||||||
&token,
|
let has_recurrence =
|
||||||
&password,
|
!recurrence_str.is_empty() && recurrence_str.to_uppercase() != "NONE";
|
||||||
backend_uid,
|
|
||||||
original_event.summary.unwrap_or_default(),
|
let result = if let Some(scope) = update_scope.as_ref() {
|
||||||
original_event.description.unwrap_or_default(),
|
// Use series endpoint for recurring event operations
|
||||||
start_date,
|
if !has_recurrence {
|
||||||
start_time,
|
web_sys::console::log_1(&"⚠️ Warning: update_scope provided for non-recurring event, using regular endpoint instead".into());
|
||||||
end_date,
|
// Fall through to regular endpoint
|
||||||
end_time,
|
None
|
||||||
original_event.location.unwrap_or_default(),
|
|
||||||
original_event.all_day,
|
|
||||||
status_str,
|
|
||||||
class_str,
|
|
||||||
original_event.priority,
|
|
||||||
original_event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
|
|
||||||
original_event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(","),
|
|
||||||
original_event.categories.join(","),
|
|
||||||
reminder_str,
|
|
||||||
recurrence_str,
|
|
||||||
recurrence_days,
|
|
||||||
original_event.calendar_path,
|
|
||||||
original_event.exdate.clone(),
|
|
||||||
if preserve_rrule {
|
|
||||||
Some("update_series".to_string())
|
|
||||||
} else {
|
} else {
|
||||||
Some("this_and_future".to_string())
|
Some(
|
||||||
},
|
calendar_service
|
||||||
until_date
|
.update_series(
|
||||||
).await
|
&token,
|
||||||
};
|
&password,
|
||||||
|
backend_uid.clone(),
|
||||||
|
original_event.summary.clone().unwrap_or_default(),
|
||||||
|
original_event.description.clone().unwrap_or_default(),
|
||||||
|
start_date.clone(),
|
||||||
|
start_time.clone(),
|
||||||
|
end_date.clone(),
|
||||||
|
end_time.clone(),
|
||||||
|
original_event.location.clone().unwrap_or_default(),
|
||||||
|
original_event.all_day,
|
||||||
|
status_str.clone(),
|
||||||
|
class_str.clone(),
|
||||||
|
original_event.priority,
|
||||||
|
original_event
|
||||||
|
.organizer
|
||||||
|
.as_ref()
|
||||||
|
.map(|o| o.cal_address.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
original_event
|
||||||
|
.attendees
|
||||||
|
.iter()
|
||||||
|
.map(|a| a.cal_address.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(","),
|
||||||
|
original_event.categories.join(","),
|
||||||
|
reminder_str.clone(),
|
||||||
|
recurrence_str.clone(),
|
||||||
|
original_event.calendar_path.clone(),
|
||||||
|
scope.clone(),
|
||||||
|
occurrence_date,
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
match result {
|
let result = if let Some(series_result) = result {
|
||||||
Ok(_) => {
|
series_result
|
||||||
web_sys::console::log_1(&"Event updated successfully".into());
|
} else {
|
||||||
// Add small delay before reload to let any pending requests complete
|
// Use regular endpoint
|
||||||
wasm_bindgen_futures::spawn_local(async {
|
calendar_service
|
||||||
gloo_timers::future::sleep(std::time::Duration::from_millis(100)).await;
|
.update_event(
|
||||||
web_sys::window().unwrap().location().reload().unwrap();
|
&token,
|
||||||
});
|
&password,
|
||||||
|
backend_uid,
|
||||||
|
original_event.summary.unwrap_or_default(),
|
||||||
|
original_event.description.unwrap_or_default(),
|
||||||
|
start_date,
|
||||||
|
start_time,
|
||||||
|
end_date,
|
||||||
|
end_time,
|
||||||
|
original_event.location.unwrap_or_default(),
|
||||||
|
original_event.all_day,
|
||||||
|
status_str,
|
||||||
|
class_str,
|
||||||
|
original_event.priority,
|
||||||
|
original_event
|
||||||
|
.organizer
|
||||||
|
.as_ref()
|
||||||
|
.map(|o| o.cal_address.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
original_event
|
||||||
|
.attendees
|
||||||
|
.iter()
|
||||||
|
.map(|a| a.cal_address.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(","),
|
||||||
|
original_event.categories.join(","),
|
||||||
|
reminder_str,
|
||||||
|
recurrence_str,
|
||||||
|
recurrence_days,
|
||||||
|
original_event.calendar_path,
|
||||||
|
original_event.exdate.clone(),
|
||||||
|
if preserve_rrule {
|
||||||
|
Some("update_series".to_string())
|
||||||
|
} else {
|
||||||
|
Some("this_and_future".to_string())
|
||||||
|
},
|
||||||
|
until_date,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
web_sys::console::log_1(&"Event updated successfully".into());
|
||||||
|
// Add small delay before reload to let any pending requests complete
|
||||||
|
wasm_bindgen_futures::spawn_local(async {
|
||||||
|
gloo_timers::future::sleep(std::time::Duration::from_millis(
|
||||||
|
100,
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
web_sys::window().unwrap().location().reload().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
web_sys::console::error_1(
|
||||||
|
&format!("Failed to update event: {}", err).into(),
|
||||||
|
);
|
||||||
|
web_sys::window()
|
||||||
|
.unwrap()
|
||||||
|
.alert_with_message(&format!("Failed to update event: {}", err))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
});
|
||||||
web_sys::console::error_1(&format!("Failed to update event: {}", err).into());
|
}
|
||||||
web_sys::window().unwrap().alert_with_message(&format!("Failed to update event: {}", err)).unwrap();
|
},
|
||||||
}
|
)
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let refresh_calendars = {
|
let refresh_calendars = {
|
||||||
@@ -542,8 +646,12 @@ pub fn App() -> Html {
|
|||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
let calendar_service = CalendarService::new();
|
let calendar_service = CalendarService::new();
|
||||||
|
|
||||||
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
let password = if let Ok(credentials_str) =
|
||||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
LocalStorage::get::<String>("caldav_credentials")
|
||||||
|
{
|
||||||
|
if let Ok(credentials) =
|
||||||
|
serde_json::from_str::<serde_json::Value>(&credentials_str)
|
||||||
|
{
|
||||||
credentials["password"].as_str().unwrap_or("").to_string()
|
credentials["password"].as_str().unwrap_or("").to_string()
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -554,8 +662,12 @@ pub fn App() -> Html {
|
|||||||
|
|
||||||
match calendar_service.fetch_user_info(&token, &password).await {
|
match calendar_service.fetch_user_info(&token, &password).await {
|
||||||
Ok(mut info) => {
|
Ok(mut info) => {
|
||||||
if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") {
|
if let Ok(saved_colors_json) =
|
||||||
if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) {
|
LocalStorage::get::<String>("calendar_colors")
|
||||||
|
{
|
||||||
|
if let Ok(saved_info) =
|
||||||
|
serde_json::from_str::<UserInfo>(&saved_colors_json)
|
||||||
|
{
|
||||||
for saved_cal in &saved_info.calendars {
|
for saved_cal in &saved_info.calendars {
|
||||||
for cal in &mut info.calendars {
|
for cal in &mut info.calendars {
|
||||||
if cal.path == saved_cal.path {
|
if cal.path == saved_cal.path {
|
||||||
@@ -568,7 +680,9 @@ pub fn App() -> Html {
|
|||||||
user_info.set(Some(info));
|
user_info.set(Some(info));
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
web_sys::console::log_1(&format!("Failed to refresh calendars: {}", err).into());
|
web_sys::console::log_1(
|
||||||
|
&format!("Failed to refresh calendars: {}", err).into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -577,7 +691,9 @@ pub fn App() -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
web_sys::console::log_1(&format!("App rendering: auth_token = {:?}", auth_token.is_some()).into());
|
web_sys::console::log_1(
|
||||||
|
&format!("App rendering: auth_token = {:?}", auth_token.is_some()).into(),
|
||||||
|
);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ impl AuthService {
|
|||||||
) -> Result<R, String> {
|
) -> Result<R, String> {
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
let json_body = serde_json::to_string(body)
|
let json_body =
|
||||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
serde_json::to_string(body).map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||||
|
|
||||||
let opts = RequestInit::new();
|
let opts = RequestInit::new();
|
||||||
opts.set_method("POST");
|
opts.set_method("POST");
|
||||||
@@ -62,23 +62,27 @@ impl AuthService {
|
|||||||
let request = Request::new_with_str_and_init(&url, &opts)
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
request.headers().set("Content-Type", "application/json")
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||||
|
|
||||||
let resp: Response = resp_value.dyn_into()
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||||
|
|
||||||
let text = JsFuture::from(resp.text()
|
let text = JsFuture::from(
|
||||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
resp.text()
|
||||||
.await
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?,
|
||||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||||
|
|
||||||
let text_string = text.as_string()
|
let text_string = text.as_string().ok_or("Response text is not a string")?;
|
||||||
.ok_or("Response text is not a string")?;
|
|
||||||
|
|
||||||
if resp.ok() {
|
if resp.ok() {
|
||||||
serde_json::from_str::<R>(&text_string)
|
serde_json::from_str::<R>(&text_string)
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
use yew::prelude::*;
|
use crate::components::{
|
||||||
use chrono::{Datelike, Local, NaiveDate, Duration};
|
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
|
||||||
|
};
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::{calendar_service::UserInfo, CalendarService};
|
||||||
|
use chrono::{Datelike, Duration, Local, NaiveDate};
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
use crate::services::calendar_service::UserInfo;
|
use yew::prelude::*;
|
||||||
use crate::models::ical::VEvent;
|
|
||||||
use crate::components::{EventModal, ViewMode, CalendarHeader, MonthView, WeekView, CreateEventModal, EventCreationData};
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarProps {
|
pub struct CalendarProps {
|
||||||
#[prop_or_default]
|
|
||||||
pub events: HashMap<NaiveDate, Vec<VEvent>>,
|
|
||||||
pub on_event_click: Callback<VEvent>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub refreshing_event_uid: Option<String>,
|
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
@@ -25,7 +22,17 @@ pub struct CalendarProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
|
pub on_event_update_request: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
}
|
}
|
||||||
@@ -33,6 +40,12 @@ pub struct CalendarProps {
|
|||||||
#[function_component]
|
#[function_component]
|
||||||
pub fn Calendar(props: &CalendarProps) -> Html {
|
pub fn Calendar(props: &CalendarProps) -> Html {
|
||||||
let today = Local::now().date_naive();
|
let today = Local::now().date_naive();
|
||||||
|
|
||||||
|
// Event management state
|
||||||
|
let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new());
|
||||||
|
let loading = use_state(|| true);
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
let refreshing_event_uid = use_state(|| None::<String>);
|
||||||
// Track the currently selected date (the actual day the user has selected)
|
// Track the currently selected date (the actual day the user has selected)
|
||||||
let selected_date = use_state(|| {
|
let selected_date = use_state(|| {
|
||||||
// Try to load saved selected date from localStorage
|
// Try to load saved selected date from localStorage
|
||||||
@@ -57,17 +70,16 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Track the display date (what to show in the view)
|
// Track the display date (what to show in the view)
|
||||||
let current_date = use_state(|| {
|
let current_date = use_state(|| match props.view {
|
||||||
match props.view {
|
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
|
||||||
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
|
ViewMode::Week => *selected_date,
|
||||||
ViewMode::Week => *selected_date,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
let selected_event = use_state(|| None::<VEvent>);
|
let selected_event = use_state(|| None::<VEvent>);
|
||||||
|
|
||||||
// State for create event modal
|
// State for create event modal
|
||||||
let show_create_modal = use_state(|| false);
|
let show_create_modal = use_state(|| false);
|
||||||
let create_event_data = use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>);
|
let create_event_data =
|
||||||
|
use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>);
|
||||||
|
|
||||||
// State for time increment snapping (15 or 30 minutes)
|
// State for time increment snapping (15 or 30 minutes)
|
||||||
let time_increment = use_state(|| {
|
let time_increment = use_state(|| {
|
||||||
@@ -83,6 +95,154 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch events when current_date changes
|
||||||
|
{
|
||||||
|
let events = events.clone();
|
||||||
|
let loading = loading.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let current_date = current_date.clone();
|
||||||
|
|
||||||
|
use_effect_with((*current_date, props.view.clone()), move |(date, _view)| {
|
||||||
|
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||||
|
let date = *date; // Clone the date to avoid lifetime issues
|
||||||
|
|
||||||
|
if let Some(token) = auth_token {
|
||||||
|
let events = events.clone();
|
||||||
|
let loading = loading.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let calendar_service = CalendarService::new();
|
||||||
|
|
||||||
|
let password = if let Ok(credentials_str) =
|
||||||
|
LocalStorage::get::<String>("caldav_credentials")
|
||||||
|
{
|
||||||
|
if let Ok(credentials) =
|
||||||
|
serde_json::from_str::<serde_json::Value>(&credentials_str)
|
||||||
|
{
|
||||||
|
credentials["password"].as_str().unwrap_or("").to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_year = date.year();
|
||||||
|
let current_month = date.month();
|
||||||
|
|
||||||
|
match calendar_service
|
||||||
|
.fetch_events_for_month_vevent(
|
||||||
|
&token,
|
||||||
|
&password,
|
||||||
|
current_year,
|
||||||
|
current_month,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(vevents) => {
|
||||||
|
let grouped_events = CalendarService::group_events_by_date(vevents);
|
||||||
|
events.set(grouped_events);
|
||||||
|
loading.set(false);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error.set(Some(format!("Failed to load events: {}", err)));
|
||||||
|
loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
loading.set(false);
|
||||||
|
error.set(Some("No authentication token found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
|| ()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle event click to refresh individual events
|
||||||
|
let on_event_click = {
|
||||||
|
let events = events.clone();
|
||||||
|
let refreshing_event_uid = refreshing_event_uid.clone();
|
||||||
|
|
||||||
|
Callback::from(move |event: VEvent| {
|
||||||
|
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||||
|
|
||||||
|
if let Some(token) = auth_token {
|
||||||
|
let events = events.clone();
|
||||||
|
let refreshing_event_uid = refreshing_event_uid.clone();
|
||||||
|
let uid = event.uid.clone();
|
||||||
|
|
||||||
|
refreshing_event_uid.set(Some(uid.clone()));
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let calendar_service = CalendarService::new();
|
||||||
|
|
||||||
|
let password = if let Ok(credentials_str) =
|
||||||
|
LocalStorage::get::<String>("caldav_credentials")
|
||||||
|
{
|
||||||
|
if let Ok(credentials) =
|
||||||
|
serde_json::from_str::<serde_json::Value>(&credentials_str)
|
||||||
|
{
|
||||||
|
credentials["password"].as_str().unwrap_or("").to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
match calendar_service
|
||||||
|
.refresh_event(&token, &password, &uid)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(refreshed_event)) => {
|
||||||
|
let refreshed_vevent = refreshed_event;
|
||||||
|
let mut updated_events = (*events).clone();
|
||||||
|
|
||||||
|
for (_, day_events) in updated_events.iter_mut() {
|
||||||
|
day_events.retain(|e| e.uid != uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if refreshed_vevent.rrule.is_some() {
|
||||||
|
let new_occurrences =
|
||||||
|
CalendarService::expand_recurring_events(vec![
|
||||||
|
refreshed_vevent.clone(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for occurrence in new_occurrences {
|
||||||
|
let date = occurrence.get_date();
|
||||||
|
updated_events
|
||||||
|
.entry(date)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(occurrence);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let date = refreshed_vevent.get_date();
|
||||||
|
updated_events
|
||||||
|
.entry(date)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(refreshed_vevent);
|
||||||
|
}
|
||||||
|
|
||||||
|
events.set(updated_events);
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
let mut updated_events = (*events).clone();
|
||||||
|
for (_, day_events) in updated_events.iter_mut() {
|
||||||
|
day_events.retain(|e| e.uid != uid);
|
||||||
|
}
|
||||||
|
events.set(updated_events);
|
||||||
|
}
|
||||||
|
Err(_err) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshing_event_uid.set(None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
// Handle view mode changes - adjust current_date format when switching between month/week
|
// Handle view mode changes - adjust current_date format when switching between month/week
|
||||||
{
|
{
|
||||||
let current_date = current_date.clone();
|
let current_date = current_date.clone();
|
||||||
@@ -110,16 +270,19 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let prev_month = *current_date - Duration::days(1);
|
let prev_month = *current_date - Duration::days(1);
|
||||||
let first_of_prev = prev_month.with_day(1).unwrap();
|
let first_of_prev = prev_month.with_day(1).unwrap();
|
||||||
(first_of_prev, first_of_prev)
|
(first_of_prev, first_of_prev)
|
||||||
},
|
}
|
||||||
ViewMode::Week => {
|
ViewMode::Week => {
|
||||||
// Go to previous week
|
// Go to previous week
|
||||||
let prev_week = *selected_date - Duration::weeks(1);
|
let prev_week = *selected_date - Duration::weeks(1);
|
||||||
(prev_week, prev_week)
|
(prev_week, prev_week)
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
selected_date.set(new_selected);
|
selected_date.set(new_selected);
|
||||||
current_date.set(new_display);
|
current_date.set(new_display);
|
||||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,19 +297,23 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let next_month = if current_date.month() == 12 {
|
let next_month = if current_date.month() == 12 {
|
||||||
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
|
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
|
||||||
} else {
|
} else {
|
||||||
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1).unwrap()
|
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1)
|
||||||
|
.unwrap()
|
||||||
};
|
};
|
||||||
(next_month, next_month)
|
(next_month, next_month)
|
||||||
},
|
}
|
||||||
ViewMode::Week => {
|
ViewMode::Week => {
|
||||||
// Go to next week
|
// Go to next week
|
||||||
let next_week = *selected_date + Duration::weeks(1);
|
let next_week = *selected_date + Duration::weeks(1);
|
||||||
(next_week, next_week)
|
(next_week, next_week)
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
selected_date.set(new_selected);
|
selected_date.set(new_selected);
|
||||||
current_date.set(new_display);
|
current_date.set(new_display);
|
||||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,12 +327,15 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
ViewMode::Month => {
|
ViewMode::Month => {
|
||||||
let first_of_today = today.with_day(1).unwrap();
|
let first_of_today = today.with_day(1).unwrap();
|
||||||
(today, first_of_today) // Select today, but display the month
|
(today, first_of_today) // Select today, but display the month
|
||||||
},
|
}
|
||||||
ViewMode::Week => (today, today), // Select and display today
|
ViewMode::Week => (today, today), // Select and display today
|
||||||
};
|
};
|
||||||
selected_date.set(new_selected);
|
selected_date.set(new_selected);
|
||||||
current_date.set(new_display);
|
current_date.set(new_display);
|
||||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,22 +354,58 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let on_create_event = {
|
let on_create_event = {
|
||||||
let show_create_modal = show_create_modal.clone();
|
let show_create_modal = show_create_modal.clone();
|
||||||
let create_event_data = create_event_data.clone();
|
let create_event_data = create_event_data.clone();
|
||||||
Callback::from(move |(_date, start_datetime, end_datetime): (NaiveDate, chrono::NaiveDateTime, chrono::NaiveDateTime)| {
|
Callback::from(
|
||||||
// For drag-to-create, we don't need the temporary event approach
|
move |(_date, start_datetime, end_datetime): (
|
||||||
// Instead, just pass the local times directly via initial_time props
|
NaiveDate,
|
||||||
create_event_data.set(Some((start_datetime.date(), start_datetime.time(), end_datetime.time())));
|
chrono::NaiveDateTime,
|
||||||
show_create_modal.set(true);
|
chrono::NaiveDateTime,
|
||||||
})
|
)| {
|
||||||
|
// For drag-to-create, we don't need the temporary event approach
|
||||||
|
// Instead, just pass the local times directly via initial_time props
|
||||||
|
create_event_data.set(Some((
|
||||||
|
start_datetime.date(),
|
||||||
|
start_datetime.time(),
|
||||||
|
end_datetime.time(),
|
||||||
|
)));
|
||||||
|
show_create_modal.set(true);
|
||||||
|
},
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle drag-to-move event
|
// Handle drag-to-move event
|
||||||
let on_event_update = {
|
let on_event_update = {
|
||||||
let on_event_update_request = props.on_event_update_request.clone();
|
let on_event_update_request = props.on_event_update_request.clone();
|
||||||
Callback::from(move |(event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)| {
|
Callback::from(
|
||||||
if let Some(callback) = &on_event_update_request {
|
move |(
|
||||||
callback.emit((event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date));
|
event,
|
||||||
}
|
new_start,
|
||||||
})
|
new_end,
|
||||||
|
preserve_rrule,
|
||||||
|
until_date,
|
||||||
|
update_scope,
|
||||||
|
occurrence_date,
|
||||||
|
): (
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)| {
|
||||||
|
if let Some(callback) = &on_event_update_request {
|
||||||
|
callback.emit((
|
||||||
|
event,
|
||||||
|
new_start,
|
||||||
|
new_end,
|
||||||
|
preserve_rrule,
|
||||||
|
until_date,
|
||||||
|
update_scope,
|
||||||
|
occurrence_date,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
@@ -215,7 +421,20 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{
|
{
|
||||||
match props.view {
|
if *loading {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-loading">
|
||||||
|
<p>{"Loading calendar events..."}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else if let Some(err) = (*error).clone() {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-error">
|
||||||
|
<p>{format!("Error: {}", err)}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match props.view {
|
||||||
ViewMode::Month => {
|
ViewMode::Month => {
|
||||||
let on_day_select = {
|
let on_day_select = {
|
||||||
let selected_date = selected_date.clone();
|
let selected_date = selected_date.clone();
|
||||||
@@ -229,9 +448,9 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
<MonthView
|
<MonthView
|
||||||
current_month={*current_date}
|
current_month={*current_date}
|
||||||
today={today}
|
today={today}
|
||||||
events={props.events.clone()}
|
events={(*events).clone()}
|
||||||
on_event_click={props.on_event_click.clone()}
|
on_event_click={on_event_click.clone()}
|
||||||
refreshing_event_uid={props.refreshing_event_uid.clone()}
|
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
||||||
user_info={props.user_info.clone()}
|
user_info={props.user_info.clone()}
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
@@ -244,9 +463,9 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
<WeekView
|
<WeekView
|
||||||
current_date={*current_date}
|
current_date={*current_date}
|
||||||
today={today}
|
today={today}
|
||||||
events={props.events.clone()}
|
events={(*events).clone()}
|
||||||
on_event_click={props.on_event_click.clone()}
|
on_event_click={on_event_click.clone()}
|
||||||
refreshing_event_uid={props.refreshing_event_uid.clone()}
|
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
||||||
user_info={props.user_info.clone()}
|
user_info={props.user_info.clone()}
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
@@ -257,6 +476,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
time_increment={*time_increment}
|
time_increment={*time_increment}
|
||||||
/>
|
/>
|
||||||
},
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarContextMenuProps {
|
pub struct CalendarContextMenuProps {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use chrono::{NaiveDate, Datelike};
|
|
||||||
use crate::components::ViewMode;
|
use crate::components::ViewMode;
|
||||||
|
use chrono::{Datelike, NaiveDate};
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarHeaderProps {
|
pub struct CalendarHeaderProps {
|
||||||
@@ -18,7 +18,11 @@ pub struct CalendarHeaderProps {
|
|||||||
|
|
||||||
#[function_component(CalendarHeader)]
|
#[function_component(CalendarHeader)]
|
||||||
pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
||||||
let title = format!("{} {}", get_month_name(props.current_date.month()), props.current_date.year());
|
let title = format!(
|
||||||
|
"{} {}",
|
||||||
|
get_month_name(props.current_date.month()),
|
||||||
|
props.current_date.year()
|
||||||
|
);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="calendar-header">
|
<div class="calendar-header">
|
||||||
@@ -59,6 +63,6 @@ fn get_month_name(month: u32) -> &'static str {
|
|||||||
10 => "October",
|
10 => "October",
|
||||||
11 => "November",
|
11 => "November",
|
||||||
12 => "December",
|
12 => "December",
|
||||||
_ => "Invalid"
|
_ => "Invalid",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
|
||||||
use crate::services::calendar_service::CalendarInfo;
|
use crate::services::calendar_service::CalendarInfo;
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarListItemProps {
|
pub struct CalendarListItemProps {
|
||||||
pub calendar: CalendarInfo,
|
pub calendar: CalendarInfo,
|
||||||
pub color_picker_open: bool,
|
pub color_picker_open: bool,
|
||||||
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
|
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
|
||||||
pub on_color_picker_toggle: Callback<String>, // calendar_path
|
pub on_color_picker_toggle: Callback<String>, // calendar_path
|
||||||
pub available_colors: Vec<String>,
|
pub available_colors: Vec<String>,
|
||||||
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct ContextMenuProps {
|
pub struct ContextMenuProps {
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if name.len() > 100 {
|
if name.len() > 100 {
|
||||||
error_message.set(Some("Calendar name too long (max 100 characters)".to_string()));
|
error_message.set(Some(
|
||||||
|
"Calendar name too long (max 100 characters)".to_string(),
|
||||||
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::{HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement};
|
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
use chrono::{NaiveDate, NaiveTime, Local, TimeZone, Utc, Datelike};
|
|
||||||
use crate::services::calendar_service::CalendarInfo;
|
|
||||||
use crate::models::ical::VEvent;
|
|
||||||
use crate::components::EditAction;
|
use crate::components::EditAction;
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::CalendarInfo;
|
||||||
|
use chrono::{Datelike, Local, NaiveDate, NaiveTime, TimeZone, Utc};
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::{HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement};
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CreateEventModalProps {
|
pub struct CreateEventModalProps {
|
||||||
@@ -36,7 +36,6 @@ impl Default for EventStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
pub enum EventClass {
|
pub enum EventClass {
|
||||||
Public,
|
Public,
|
||||||
@@ -50,7 +49,6 @@ impl Default for EventClass {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
pub enum ReminderType {
|
pub enum ReminderType {
|
||||||
None,
|
None,
|
||||||
@@ -84,7 +82,6 @@ impl Default for RecurrenceType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Parse RRULE string into recurrence components
|
/// Parse RRULE string into recurrence components
|
||||||
/// Example RRULE: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;UNTIL=20231215T000000Z"
|
/// Example RRULE: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;UNTIL=20231215T000000Z"
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
@@ -145,9 +142,7 @@ fn parse_rrule(rrule: Option<&str>) -> ParsedRrule {
|
|||||||
}
|
}
|
||||||
"BYDAY" => {
|
"BYDAY" => {
|
||||||
// Parse BYDAY: "MO,WE,FR" or "1MO,-1SU" (with position)
|
// Parse BYDAY: "MO,WE,FR" or "1MO,-1SU" (with position)
|
||||||
parsed.byday = value.split(',')
|
parsed.byday = value.split(',').map(|s| s.trim().to_uppercase()).collect();
|
||||||
.map(|s| s.trim().to_uppercase())
|
|
||||||
.collect();
|
|
||||||
}
|
}
|
||||||
"BYMONTHDAY" => {
|
"BYMONTHDAY" => {
|
||||||
// Parse BYMONTHDAY: "15" or "1,15,31"
|
// Parse BYMONTHDAY: "15" or "1,15,31"
|
||||||
@@ -161,7 +156,8 @@ fn parse_rrule(rrule: Option<&str>) -> ParsedRrule {
|
|||||||
}
|
}
|
||||||
"BYMONTH" => {
|
"BYMONTH" => {
|
||||||
// Parse BYMONTH: "1,3,5" (January, March, May)
|
// Parse BYMONTH: "1,3,5" (January, March, May)
|
||||||
parsed.bymonth = value.split(',')
|
parsed.bymonth = value
|
||||||
|
.split(',')
|
||||||
.filter_map(|m| m.trim().parse::<u8>().ok())
|
.filter_map(|m| m.trim().parse::<u8>().ok())
|
||||||
.filter(|&m| m >= 1 && m <= 12)
|
.filter(|&m| m >= 1 && m <= 12)
|
||||||
.collect();
|
.collect();
|
||||||
@@ -182,7 +178,7 @@ fn byday_to_weekday_array(byday: &[String]) -> Vec<bool> {
|
|||||||
// Handle both simple days (MO, TU) and positioned days (1MO, -1SU)
|
// Handle both simple days (MO, TU) and positioned days (1MO, -1SU)
|
||||||
let day_code = if day_spec.len() > 2 {
|
let day_code = if day_spec.len() > 2 {
|
||||||
// Extract last 2 characters for positioned days like "1MO" -> "MO"
|
// Extract last 2 characters for positioned days like "1MO" -> "MO"
|
||||||
&day_spec[day_spec.len()-2..]
|
&day_spec[day_spec.len() - 2..]
|
||||||
} else {
|
} else {
|
||||||
day_spec
|
day_spec
|
||||||
};
|
};
|
||||||
@@ -221,7 +217,8 @@ fn bymonth_to_monthly_array(bymonth: &[u8]) -> Vec<bool> {
|
|||||||
/// Extract positioned weekday from BYDAY for monthly recurrence
|
/// Extract positioned weekday from BYDAY for monthly recurrence
|
||||||
/// Examples: "1MO" -> Some("1MO"), "2TU" -> Some("2TU"), "-1SU" -> Some("-1SU")
|
/// Examples: "1MO" -> Some("1MO"), "2TU" -> Some("2TU"), "-1SU" -> Some("-1SU")
|
||||||
fn extract_monthly_byday(byday: &[String]) -> Option<String> {
|
fn extract_monthly_byday(byday: &[String]) -> Option<String> {
|
||||||
byday.iter()
|
byday
|
||||||
|
.iter()
|
||||||
.find(|day| day.len() > 2) // Positioned days have length > 2
|
.find(|day| day.len() > 2) // Positioned days have length > 2
|
||||||
.cloned()
|
.cloned()
|
||||||
}
|
}
|
||||||
@@ -240,7 +237,9 @@ mod rrule_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_complex_monthly() {
|
fn test_parse_complex_monthly() {
|
||||||
let parsed = parse_rrule(Some("FREQ=MONTHLY;INTERVAL=2;BYDAY=1MO;UNTIL=20241231T000000Z"));
|
let parsed = parse_rrule(Some(
|
||||||
|
"FREQ=MONTHLY;INTERVAL=2;BYDAY=1MO;UNTIL=20241231T000000Z",
|
||||||
|
));
|
||||||
assert_eq!(parsed.freq, RecurrenceType::Monthly);
|
assert_eq!(parsed.freq, RecurrenceType::Monthly);
|
||||||
assert_eq!(parsed.interval, 2);
|
assert_eq!(parsed.interval, 2);
|
||||||
assert_eq!(parsed.byday, vec!["1MO"]);
|
assert_eq!(parsed.byday, vec!["1MO"]);
|
||||||
@@ -249,7 +248,8 @@ mod rrule_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_byday_to_weekday_array() {
|
fn test_byday_to_weekday_array() {
|
||||||
let weekdays = byday_to_weekday_array(&["MO".to_string(), "WE".to_string(), "FR".to_string()]);
|
let weekdays =
|
||||||
|
byday_to_weekday_array(&["MO".to_string(), "WE".to_string(), "FR".to_string()]);
|
||||||
// [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
|
// [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
|
||||||
assert_eq!(weekdays, vec![false, true, false, true, false, true, false]);
|
assert_eq!(weekdays, vec![false, true, false, true, false, true, false]);
|
||||||
}
|
}
|
||||||
@@ -295,14 +295,15 @@ mod rrule_tests {
|
|||||||
fn test_build_rrule_yearly() {
|
fn test_build_rrule_yearly() {
|
||||||
let mut data = EventCreationData::default();
|
let mut data = EventCreationData::default();
|
||||||
data.recurrence = RecurrenceType::Yearly;
|
data.recurrence = RecurrenceType::Yearly;
|
||||||
data.yearly_by_month = vec![false, false, true, false, true, false, false, false, false, false, false, false]; // March, May
|
data.yearly_by_month = vec![
|
||||||
|
false, false, true, false, true, false, false, false, false, false, false, false,
|
||||||
|
]; // March, May
|
||||||
|
|
||||||
let rrule = data.build_rrule();
|
let rrule = data.build_rrule();
|
||||||
println!("YEARLY RRULE: {}", rrule);
|
println!("YEARLY RRULE: {}", rrule);
|
||||||
assert!(rrule.contains("FREQ=YEARLY"));
|
assert!(rrule.contains("FREQ=YEARLY"));
|
||||||
assert!(rrule.contains("BYMONTH=3,5"));
|
assert!(rrule.contains("BYMONTH=3,5"));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
@@ -319,7 +320,7 @@ pub struct EventCreationData {
|
|||||||
pub class: EventClass,
|
pub class: EventClass,
|
||||||
pub priority: Option<u8>,
|
pub priority: Option<u8>,
|
||||||
pub organizer: String,
|
pub organizer: String,
|
||||||
pub attendees: String, // Comma-separated list
|
pub attendees: String, // Comma-separated list
|
||||||
pub categories: String, // Comma-separated list
|
pub categories: String, // Comma-separated list
|
||||||
pub reminder: ReminderType,
|
pub reminder: ReminderType,
|
||||||
pub recurrence: RecurrenceType,
|
pub recurrence: RecurrenceType,
|
||||||
@@ -332,7 +333,7 @@ pub struct EventCreationData {
|
|||||||
pub recurrence_count: Option<u32>, // COUNT - number of occurrences
|
pub recurrence_count: Option<u32>, // COUNT - number of occurrences
|
||||||
pub monthly_by_day: Option<String>, // For monthly: "1MO" = first Monday, "2TU" = second Tuesday, etc.
|
pub monthly_by_day: Option<String>, // For monthly: "1MO" = first Monday, "2TU" = second Tuesday, etc.
|
||||||
pub monthly_by_monthday: Option<u8>, // For monthly: day of month (1-31)
|
pub monthly_by_monthday: Option<u8>, // For monthly: day of month (1-31)
|
||||||
pub yearly_by_month: Vec<bool>, // For yearly: [Jan, Feb, Mar, ..., Dec]
|
pub yearly_by_month: Vec<bool>, // For yearly: [Jan, Feb, Mar, ..., Dec]
|
||||||
|
|
||||||
// Edit scope and tracking fields
|
// Edit scope and tracking fields
|
||||||
pub edit_scope: Option<EditAction>,
|
pub edit_scope: Option<EditAction>,
|
||||||
@@ -407,21 +408,25 @@ impl EventCreationData {
|
|||||||
match self.recurrence {
|
match self.recurrence {
|
||||||
RecurrenceType::Weekly => {
|
RecurrenceType::Weekly => {
|
||||||
// Add BYDAY for weekly recurrence
|
// Add BYDAY for weekly recurrence
|
||||||
let selected_days: Vec<&str> = self.recurrence_days.iter()
|
let selected_days: Vec<&str> = self
|
||||||
|
.recurrence_days
|
||||||
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(i, &selected)| if selected {
|
.filter_map(|(i, &selected)| {
|
||||||
Some(match i {
|
if selected {
|
||||||
0 => "SU", // Sunday
|
Some(match i {
|
||||||
1 => "MO", // Monday
|
0 => "SU", // Sunday
|
||||||
2 => "TU", // Tuesday
|
1 => "MO", // Monday
|
||||||
3 => "WE", // Wednesday
|
2 => "TU", // Tuesday
|
||||||
4 => "TH", // Thursday
|
3 => "WE", // Wednesday
|
||||||
5 => "FR", // Friday
|
4 => "TH", // Thursday
|
||||||
6 => "SA", // Saturday
|
5 => "FR", // Friday
|
||||||
_ => "",
|
6 => "SA", // Saturday
|
||||||
})
|
_ => "",
|
||||||
} else {
|
})
|
||||||
None
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
@@ -429,7 +434,7 @@ impl EventCreationData {
|
|||||||
if !selected_days.is_empty() {
|
if !selected_days.is_empty() {
|
||||||
parts.push(format!("BYDAY={}", selected_days.join(",")));
|
parts.push(format!("BYDAY={}", selected_days.join(",")));
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
RecurrenceType::Monthly => {
|
RecurrenceType::Monthly => {
|
||||||
// Add BYDAY or BYMONTHDAY for monthly recurrence
|
// Add BYDAY or BYMONTHDAY for monthly recurrence
|
||||||
if let Some(ref by_day) = self.monthly_by_day {
|
if let Some(ref by_day) = self.monthly_by_day {
|
||||||
@@ -437,22 +442,26 @@ impl EventCreationData {
|
|||||||
} else if let Some(by_monthday) = self.monthly_by_monthday {
|
} else if let Some(by_monthday) = self.monthly_by_monthday {
|
||||||
parts.push(format!("BYMONTHDAY={}", by_monthday));
|
parts.push(format!("BYMONTHDAY={}", by_monthday));
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
RecurrenceType::Yearly => {
|
RecurrenceType::Yearly => {
|
||||||
// Add BYMONTH for yearly recurrence
|
// Add BYMONTH for yearly recurrence
|
||||||
let selected_months: Vec<String> = self.yearly_by_month.iter()
|
let selected_months: Vec<String> = self
|
||||||
|
.yearly_by_month
|
||||||
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(i, &selected)| if selected {
|
.filter_map(|(i, &selected)| {
|
||||||
Some((i + 1).to_string()) // Convert 0-based index to 1-based month
|
if selected {
|
||||||
} else {
|
Some((i + 1).to_string()) // Convert 0-based index to 1-based month
|
||||||
None
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !selected_months.is_empty() {
|
if !selected_months.is_empty() {
|
||||||
parts.push(format!("BYMONTH={}", selected_months.join(",")));
|
parts.push(format!("BYMONTH={}", selected_months.join(",")));
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,11 +476,36 @@ impl EventCreationData {
|
|||||||
parts.join(";")
|
parts.join(";")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_create_event_params(&self) -> (String, String, String, String, String, String, String, bool, String, String, Option<u8>, String, String, String, String, String, Vec<bool>, Option<String>) {
|
pub fn to_create_event_params(
|
||||||
|
&self,
|
||||||
|
) -> (
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
bool,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
Option<u8>,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
Vec<bool>,
|
||||||
|
Option<String>,
|
||||||
|
) {
|
||||||
// Convert local date/time to UTC
|
// Convert local date/time to UTC
|
||||||
let start_local = Local.from_local_datetime(&self.start_date.and_time(self.start_time)).single()
|
let start_local = Local
|
||||||
|
.from_local_datetime(&self.start_date.and_time(self.start_time))
|
||||||
|
.single()
|
||||||
.unwrap_or_else(|| Local::now());
|
.unwrap_or_else(|| Local::now());
|
||||||
let end_local = Local.from_local_datetime(&self.end_date.and_time(self.end_time)).single()
|
let end_local = Local
|
||||||
|
.from_local_datetime(&self.end_date.and_time(self.end_time))
|
||||||
|
.single()
|
||||||
.unwrap_or_else(|| Local::now());
|
.unwrap_or_else(|| Local::now());
|
||||||
|
|
||||||
let start_utc = start_local.with_timezone(&Utc);
|
let start_utc = start_local.with_timezone(&Utc);
|
||||||
@@ -512,7 +546,7 @@ impl EventCreationData {
|
|||||||
},
|
},
|
||||||
self.build_rrule(), // Use the comprehensive RRULE builder
|
self.build_rrule(), // Use the comprehensive RRULE builder
|
||||||
self.recurrence_days.clone(),
|
self.recurrence_days.clone(),
|
||||||
self.selected_calendar.clone()
|
self.selected_calendar.clone(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -531,23 +565,48 @@ impl EventCreationData {
|
|||||||
description: event.description.clone().unwrap_or_default(),
|
description: event.description.clone().unwrap_or_default(),
|
||||||
start_date: event.dtstart.with_timezone(&chrono::Local).date_naive(),
|
start_date: event.dtstart.with_timezone(&chrono::Local).date_naive(),
|
||||||
start_time: event.dtstart.with_timezone(&chrono::Local).time(),
|
start_time: event.dtstart.with_timezone(&chrono::Local).time(),
|
||||||
end_date: event.dtend.as_ref().map(|e| e.with_timezone(&chrono::Local).date_naive()).unwrap_or(event.dtstart.with_timezone(&chrono::Local).date_naive()),
|
end_date: event
|
||||||
end_time: event.dtend.as_ref().map(|e| e.with_timezone(&chrono::Local).time()).unwrap_or(event.dtstart.with_timezone(&chrono::Local).time()),
|
.dtend
|
||||||
|
.as_ref()
|
||||||
|
.map(|e| e.with_timezone(&chrono::Local).date_naive())
|
||||||
|
.unwrap_or(event.dtstart.with_timezone(&chrono::Local).date_naive()),
|
||||||
|
end_time: event
|
||||||
|
.dtend
|
||||||
|
.as_ref()
|
||||||
|
.map(|e| e.with_timezone(&chrono::Local).time())
|
||||||
|
.unwrap_or(event.dtstart.with_timezone(&chrono::Local).time()),
|
||||||
location: event.location.clone().unwrap_or_default(),
|
location: event.location.clone().unwrap_or_default(),
|
||||||
all_day: event.all_day,
|
all_day: event.all_day,
|
||||||
status: event.status.as_ref().map(|s| match s {
|
status: event
|
||||||
crate::models::ical::EventStatus::Tentative => EventStatus::Tentative,
|
.status
|
||||||
crate::models::ical::EventStatus::Confirmed => EventStatus::Confirmed,
|
.as_ref()
|
||||||
crate::models::ical::EventStatus::Cancelled => EventStatus::Cancelled,
|
.map(|s| match s {
|
||||||
}).unwrap_or(EventStatus::Confirmed),
|
crate::models::ical::EventStatus::Tentative => EventStatus::Tentative,
|
||||||
class: event.class.as_ref().map(|c| match c {
|
crate::models::ical::EventStatus::Confirmed => EventStatus::Confirmed,
|
||||||
crate::models::ical::EventClass::Public => EventClass::Public,
|
crate::models::ical::EventStatus::Cancelled => EventStatus::Cancelled,
|
||||||
crate::models::ical::EventClass::Private => EventClass::Private,
|
})
|
||||||
crate::models::ical::EventClass::Confidential => EventClass::Confidential,
|
.unwrap_or(EventStatus::Confirmed),
|
||||||
}).unwrap_or(EventClass::Public),
|
class: event
|
||||||
|
.class
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| match c {
|
||||||
|
crate::models::ical::EventClass::Public => EventClass::Public,
|
||||||
|
crate::models::ical::EventClass::Private => EventClass::Private,
|
||||||
|
crate::models::ical::EventClass::Confidential => EventClass::Confidential,
|
||||||
|
})
|
||||||
|
.unwrap_or(EventClass::Public),
|
||||||
priority: event.priority,
|
priority: event.priority,
|
||||||
organizer: event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
|
organizer: event
|
||||||
attendees: event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(", "),
|
.organizer
|
||||||
|
.as_ref()
|
||||||
|
.map(|o| o.cal_address.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
attendees: event
|
||||||
|
.attendees
|
||||||
|
.iter()
|
||||||
|
.map(|a| a.cal_address.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
categories: event.categories.join(", "),
|
categories: event.categories.join(", "),
|
||||||
reminder: ReminderType::default(), // TODO: Convert from event reminders
|
reminder: ReminderType::default(), // TODO: Convert from event reminders
|
||||||
recurrence: parsed_rrule.freq.clone(),
|
recurrence: parsed_rrule.freq.clone(),
|
||||||
@@ -583,7 +642,6 @@ impl EventCreationData {
|
|||||||
changed_fields: vec![],
|
changed_fields: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
@@ -608,48 +666,67 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
|
|||||||
let active_tab = use_state(|| ModalTab::default());
|
let active_tab = use_state(|| ModalTab::default());
|
||||||
|
|
||||||
// Initialize with selected date or event data if provided
|
// Initialize with selected date or event data if provided
|
||||||
use_effect_with((props.selected_date, props.event_to_edit.clone(), props.is_open, props.available_calendars.clone(), props.initial_start_time, props.initial_end_time, props.edit_scope.clone()), {
|
use_effect_with(
|
||||||
let event_data = event_data.clone();
|
(
|
||||||
move |(selected_date, event_to_edit, is_open, available_calendars, initial_start_time, initial_end_time, edit_scope)| {
|
props.selected_date,
|
||||||
if *is_open {
|
props.event_to_edit.clone(),
|
||||||
let mut data = if let Some(event) = event_to_edit {
|
props.is_open,
|
||||||
// Pre-populate with event data for editing
|
props.available_calendars.clone(),
|
||||||
EventCreationData::from_calendar_event(event)
|
props.initial_start_time,
|
||||||
} else if let Some(date) = selected_date {
|
props.initial_end_time,
|
||||||
// Initialize with selected date for new event
|
props.edit_scope.clone(),
|
||||||
let mut data = EventCreationData::default();
|
),
|
||||||
data.start_date = *date;
|
{
|
||||||
data.end_date = *date;
|
let event_data = event_data.clone();
|
||||||
|
move |(
|
||||||
|
selected_date,
|
||||||
|
event_to_edit,
|
||||||
|
is_open,
|
||||||
|
available_calendars,
|
||||||
|
initial_start_time,
|
||||||
|
initial_end_time,
|
||||||
|
edit_scope,
|
||||||
|
)| {
|
||||||
|
if *is_open {
|
||||||
|
let mut data = if let Some(event) = event_to_edit {
|
||||||
|
// Pre-populate with event data for editing
|
||||||
|
EventCreationData::from_calendar_event(event)
|
||||||
|
} else if let Some(date) = selected_date {
|
||||||
|
// Initialize with selected date for new event
|
||||||
|
let mut data = EventCreationData::default();
|
||||||
|
data.start_date = *date;
|
||||||
|
data.end_date = *date;
|
||||||
|
|
||||||
// Use initial times if provided (from drag-to-create)
|
// Use initial times if provided (from drag-to-create)
|
||||||
if let Some(start_time) = initial_start_time {
|
if let Some(start_time) = initial_start_time {
|
||||||
data.start_time = *start_time;
|
data.start_time = *start_time;
|
||||||
}
|
}
|
||||||
if let Some(end_time) = initial_end_time {
|
if let Some(end_time) = initial_end_time {
|
||||||
data.end_time = *end_time;
|
data.end_time = *end_time;
|
||||||
|
}
|
||||||
|
|
||||||
|
data
|
||||||
|
} else {
|
||||||
|
// Default initialization
|
||||||
|
EventCreationData::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set default calendar to the first available one if none selected
|
||||||
|
if data.selected_calendar.is_none() && !available_calendars.is_empty() {
|
||||||
|
data.selected_calendar = Some(available_calendars[0].path.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
data
|
// Set edit scope if provided
|
||||||
} else {
|
if let Some(scope) = edit_scope {
|
||||||
// Default initialization
|
data.edit_scope = Some(scope.clone());
|
||||||
EventCreationData::default()
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Set default calendar to the first available one if none selected
|
event_data.set(data);
|
||||||
if data.selected_calendar.is_none() && !available_calendars.is_empty() {
|
|
||||||
data.selected_calendar = Some(available_calendars[0].path.clone());
|
|
||||||
}
|
}
|
||||||
|
|| ()
|
||||||
// Set edit scope if provided
|
|
||||||
if let Some(scope) = edit_scope {
|
|
||||||
data.edit_scope = Some(scope.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
}
|
||||||
|| ()
|
},
|
||||||
}
|
);
|
||||||
});
|
|
||||||
|
|
||||||
if !props.is_open {
|
if !props.is_open {
|
||||||
return html! {};
|
return html! {};
|
||||||
@@ -697,7 +774,10 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
|
|||||||
let new_calendar = if value.is_empty() { None } else { Some(value) };
|
let new_calendar = if value.is_empty() { None } else { Some(value) };
|
||||||
if data.selected_calendar != new_calendar {
|
if data.selected_calendar != new_calendar {
|
||||||
data.selected_calendar = new_calendar;
|
data.selected_calendar = new_calendar;
|
||||||
if !data.changed_fields.contains(&"selected_calendar".to_string()) {
|
if !data
|
||||||
|
.changed_fields
|
||||||
|
.contains(&"selected_calendar".to_string())
|
||||||
|
{
|
||||||
data.changed_fields.push("selected_calendar".to_string());
|
data.changed_fields.push("selected_calendar".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
pub enum DeleteAction {
|
pub enum DeleteAction {
|
||||||
@@ -41,7 +41,9 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Check if the event is recurring
|
// Check if the event is recurring
|
||||||
let is_recurring = props.event.as_ref()
|
let is_recurring = props
|
||||||
|
.event
|
||||||
|
.as_ref()
|
||||||
.map(|event| event.rrule.is_some())
|
.map(|event| event.rrule.is_some())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct EventModalProps {
|
pub struct EventModalProps {
|
||||||
@@ -236,4 +236,3 @@ fn format_recurrence_rule(rrule: &str) -> String {
|
|||||||
format!("Custom ({})", rrule)
|
format!("Custom ({})", rrule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::HtmlInputElement;
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct LoginProps {
|
pub struct LoginProps {
|
||||||
@@ -77,7 +77,8 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
web_sys::console::log_1(&"✅ Login successful!".into());
|
web_sys::console::log_1(&"✅ Login successful!".into());
|
||||||
// Store token and credentials in local storage
|
// Store token and credentials in local storage
|
||||||
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
||||||
error_message.set(Some("Failed to store authentication token".to_string()));
|
error_message
|
||||||
|
.set(Some("Failed to store authentication token".to_string()));
|
||||||
is_loading.set(false);
|
is_loading.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -172,7 +173,11 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Perform login using the CalDAV auth service
|
/// Perform login using the CalDAV auth service
|
||||||
async fn perform_login(server_url: String, username: String, password: String) -> Result<(String, String), String> {
|
async fn perform_login(
|
||||||
|
server_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<(String, String), String> {
|
||||||
use crate::auth::{AuthService, CalDAVLoginRequest};
|
use crate::auth::{AuthService, CalDAVLoginRequest};
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
|
||||||
@@ -182,7 +187,7 @@ async fn perform_login(server_url: String, username: String, password: String) -
|
|||||||
let request = CalDAVLoginRequest {
|
let request = CalDAVLoginRequest {
|
||||||
server_url: server_url.clone(),
|
server_url: server_url.clone(),
|
||||||
username: username.clone(),
|
username: username.clone(),
|
||||||
password: password.clone()
|
password: password.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
|
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
|
||||||
@@ -197,10 +202,10 @@ async fn perform_login(server_url: String, username: String, password: String) -
|
|||||||
"password": password
|
"password": password
|
||||||
});
|
});
|
||||||
Ok((response.token, credentials.to_string()))
|
Ok((response.token, credentials.to_string()))
|
||||||
},
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
|
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
|
||||||
Err(err)
|
Err(err)
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,31 +1,33 @@
|
|||||||
pub mod login;
|
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod calendar_header;
|
|
||||||
pub mod month_view;
|
|
||||||
pub mod week_view;
|
|
||||||
pub mod event_modal;
|
|
||||||
pub mod create_calendar_modal;
|
|
||||||
pub mod context_menu;
|
|
||||||
pub mod event_context_menu;
|
|
||||||
pub mod calendar_context_menu;
|
pub mod calendar_context_menu;
|
||||||
pub mod create_event_modal;
|
pub mod calendar_header;
|
||||||
pub mod sidebar;
|
|
||||||
pub mod calendar_list_item;
|
pub mod calendar_list_item;
|
||||||
pub mod route_handler;
|
pub mod context_menu;
|
||||||
|
pub mod create_calendar_modal;
|
||||||
|
pub mod create_event_modal;
|
||||||
|
pub mod event_context_menu;
|
||||||
|
pub mod event_modal;
|
||||||
|
pub mod login;
|
||||||
|
pub mod month_view;
|
||||||
pub mod recurring_edit_modal;
|
pub mod recurring_edit_modal;
|
||||||
|
pub mod route_handler;
|
||||||
|
pub mod sidebar;
|
||||||
|
pub mod week_view;
|
||||||
|
|
||||||
pub use login::Login;
|
|
||||||
pub use calendar::Calendar;
|
pub use calendar::Calendar;
|
||||||
pub use calendar_header::CalendarHeader;
|
|
||||||
pub use month_view::MonthView;
|
|
||||||
pub use week_view::WeekView;
|
|
||||||
pub use event_modal::EventModal;
|
|
||||||
pub use create_calendar_modal::CreateCalendarModal;
|
|
||||||
pub use context_menu::ContextMenu;
|
|
||||||
pub use event_context_menu::{EventContextMenu, DeleteAction, EditAction};
|
|
||||||
pub use calendar_context_menu::CalendarContextMenu;
|
pub use calendar_context_menu::CalendarContextMenu;
|
||||||
pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
|
pub use calendar_header::CalendarHeader;
|
||||||
pub use sidebar::{Sidebar, ViewMode, Theme};
|
|
||||||
pub use calendar_list_item::CalendarListItem;
|
pub use calendar_list_item::CalendarListItem;
|
||||||
|
pub use context_menu::ContextMenu;
|
||||||
|
pub use create_calendar_modal::CreateCalendarModal;
|
||||||
|
pub use create_event_modal::{
|
||||||
|
CreateEventModal, EventClass, EventCreationData, EventStatus, RecurrenceType, ReminderType,
|
||||||
|
};
|
||||||
|
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
|
||||||
|
pub use event_modal::EventModal;
|
||||||
|
pub use login::Login;
|
||||||
|
pub use month_view::MonthView;
|
||||||
|
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
|
||||||
pub use route_handler::RouteHandler;
|
pub use route_handler::RouteHandler;
|
||||||
pub use recurring_edit_modal::{RecurringEditModal, RecurringEditAction};
|
pub use sidebar::{Sidebar, Theme, ViewMode};
|
||||||
|
pub use week_view::WeekView;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use yew::prelude::*;
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
use chrono::{Datelike, NaiveDate, Weekday};
|
use chrono::{Datelike, NaiveDate, Weekday};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use web_sys::window;
|
|
||||||
use wasm_bindgen::{prelude::*, JsCast};
|
use wasm_bindgen::{prelude::*, JsCast};
|
||||||
use crate::services::calendar_service::UserInfo;
|
use web_sys::window;
|
||||||
use crate::models::ical::VEvent;
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct MonthViewProps {
|
pub struct MonthViewProps {
|
||||||
@@ -72,7 +72,10 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
}) as Box<dyn Fn()>);
|
}) as Box<dyn Fn()>);
|
||||||
|
|
||||||
if let Some(window) = window() {
|
if let Some(window) = window() {
|
||||||
let _ = window.add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref());
|
let _ = window.add_event_listener_with_callback(
|
||||||
|
"resize",
|
||||||
|
resize_closure.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
resize_closure.forget(); // Keep the closure alive
|
resize_closure.forget(); // Keep the closure alive
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +87,11 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
let get_event_color = |event: &VEvent| -> String {
|
let get_event_color = |event: &VEvent| -> String {
|
||||||
if let Some(user_info) = &props.user_info {
|
if let Some(user_info) = &props.user_info {
|
||||||
if let Some(calendar_path) = &event.calendar_path {
|
if let Some(calendar_path) = &event.calendar_path {
|
||||||
if let Some(calendar) = user_info.calendars.iter()
|
if let Some(calendar) = user_info
|
||||||
.find(|cal| &cal.path == calendar_path) {
|
.calendars
|
||||||
|
.iter()
|
||||||
|
.find(|cal| &cal.path == calendar_path)
|
||||||
|
{
|
||||||
return calendar.color.clone();
|
return calendar.color.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,20 +227,34 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
|
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
|
||||||
let total_slots = 42; // 6 rows x 7 days
|
let total_slots = 42; // 6 rows x 7 days
|
||||||
let used_slots = prev_days_count + current_days_count as usize;
|
let used_slots = prev_days_count + current_days_count as usize;
|
||||||
let remaining_slots = if used_slots < total_slots { total_slots - used_slots } else { 0 };
|
let remaining_slots = if used_slots < total_slots {
|
||||||
|
total_slots - used_slots
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
(1..=remaining_slots).map(|day| {
|
(1..=remaining_slots)
|
||||||
html! {
|
.map(|day| {
|
||||||
<div class="calendar-day next-month">{day}</div>
|
html! {
|
||||||
}
|
<div class="calendar-day next-month">{day}</div>
|
||||||
}).collect::<Html>()
|
}
|
||||||
|
})
|
||||||
|
.collect::<Html>()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_days_in_month(date: NaiveDate) -> u32 {
|
fn get_days_in_month(date: NaiveDate) -> u32 {
|
||||||
NaiveDate::from_ymd_opt(
|
NaiveDate::from_ymd_opt(
|
||||||
if date.month() == 12 { date.year() + 1 } else { date.year() },
|
if date.month() == 12 {
|
||||||
if date.month() == 12 { 1 } else { date.month() + 1 },
|
date.year() + 1
|
||||||
1
|
} else {
|
||||||
|
date.year()
|
||||||
|
},
|
||||||
|
if date.month() == 12 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
date.month() + 1
|
||||||
|
},
|
||||||
|
1,
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.pred_opt()
|
.pred_opt()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use chrono::NaiveDateTime;
|
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
pub enum RecurringEditAction {
|
pub enum RecurringEditAction {
|
||||||
@@ -25,7 +25,12 @@ pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
|
|||||||
return html! {};
|
return html! {};
|
||||||
}
|
}
|
||||||
|
|
||||||
let event_title = props.event.summary.as_ref().map(|s| s.as_str()).unwrap_or("Untitled Event");
|
let event_title = props
|
||||||
|
.event
|
||||||
|
.summary
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("Untitled Event");
|
||||||
|
|
||||||
let on_this_event = {
|
let on_this_event = {
|
||||||
let on_choice = props.on_choice.clone();
|
let on_choice = props.on_choice.clone();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
use crate::components::{Login, ViewMode};
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
use crate::components::{Login, ViewMode};
|
|
||||||
use crate::services::calendar_service::UserInfo;
|
|
||||||
use crate::models::ical::VEvent;
|
|
||||||
|
|
||||||
#[derive(Clone, Routable, PartialEq)]
|
#[derive(Clone, Routable, PartialEq)]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
@@ -28,7 +28,17 @@ pub struct RouteHandlerProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
|
pub on_event_update_request: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
}
|
}
|
||||||
@@ -106,192 +116,36 @@ pub struct CalendarViewProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
|
pub on_event_update_request: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
|
||||||
use crate::services::CalendarService;
|
|
||||||
use crate::components::Calendar;
|
use crate::components::Calendar;
|
||||||
use std::collections::HashMap;
|
|
||||||
use chrono::{Local, NaiveDate, Datelike};
|
|
||||||
|
|
||||||
#[function_component(CalendarView)]
|
#[function_component(CalendarView)]
|
||||||
pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
||||||
let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new());
|
|
||||||
let loading = use_state(|| true);
|
|
||||||
let error = use_state(|| None::<String>);
|
|
||||||
let refreshing_event = use_state(|| None::<String>);
|
|
||||||
|
|
||||||
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
|
||||||
|
|
||||||
|
|
||||||
let today = Local::now().date_naive();
|
|
||||||
let current_year = today.year();
|
|
||||||
let current_month = today.month();
|
|
||||||
|
|
||||||
let on_event_click = {
|
|
||||||
let events = events.clone();
|
|
||||||
let refreshing_event = refreshing_event.clone();
|
|
||||||
let auth_token = auth_token.clone();
|
|
||||||
|
|
||||||
Callback::from(move |event: VEvent| {
|
|
||||||
if let Some(token) = auth_token.clone() {
|
|
||||||
let events = events.clone();
|
|
||||||
let refreshing_event = refreshing_event.clone();
|
|
||||||
let uid = event.uid.clone();
|
|
||||||
|
|
||||||
refreshing_event.set(Some(uid.clone()));
|
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
let calendar_service = CalendarService::new();
|
|
||||||
|
|
||||||
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
|
||||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
|
||||||
credentials["password"].as_str().unwrap_or("").to_string()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
match calendar_service.refresh_event(&token, &password, &uid).await {
|
|
||||||
Ok(Some(refreshed_event)) => {
|
|
||||||
let refreshed_vevent = refreshed_event; // CalendarEvent is now VEvent
|
|
||||||
let mut updated_events = (*events).clone();
|
|
||||||
|
|
||||||
for (_, day_events) in updated_events.iter_mut() {
|
|
||||||
day_events.retain(|e| e.uid != uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if refreshed_vevent.rrule.is_some() {
|
|
||||||
let new_occurrences = CalendarService::expand_recurring_events(vec![refreshed_vevent.clone()]);
|
|
||||||
|
|
||||||
for occurrence in new_occurrences {
|
|
||||||
let date = occurrence.get_date();
|
|
||||||
updated_events.entry(date)
|
|
||||||
.or_insert_with(Vec::new)
|
|
||||||
.push(occurrence);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let date = refreshed_vevent.get_date();
|
|
||||||
updated_events.entry(date)
|
|
||||||
.or_insert_with(Vec::new)
|
|
||||||
.push(refreshed_vevent);
|
|
||||||
}
|
|
||||||
|
|
||||||
events.set(updated_events);
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
let mut updated_events = (*events).clone();
|
|
||||||
for (_, day_events) in updated_events.iter_mut() {
|
|
||||||
day_events.retain(|e| e.uid != uid);
|
|
||||||
}
|
|
||||||
events.set(updated_events);
|
|
||||||
}
|
|
||||||
Err(_err) => {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshing_event.set(None);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
{
|
|
||||||
let events = events.clone();
|
|
||||||
let loading = loading.clone();
|
|
||||||
let error = error.clone();
|
|
||||||
let auth_token = auth_token.clone();
|
|
||||||
|
|
||||||
use_effect_with((), move |_| {
|
|
||||||
if let Some(token) = auth_token {
|
|
||||||
let events = events.clone();
|
|
||||||
let loading = loading.clone();
|
|
||||||
let error = error.clone();
|
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
let calendar_service = CalendarService::new();
|
|
||||||
|
|
||||||
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
|
||||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
|
||||||
credentials["password"].as_str().unwrap_or("").to_string()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
match calendar_service.fetch_events_for_month_vevent(&token, &password, current_year, current_month).await {
|
|
||||||
Ok(vevents) => {
|
|
||||||
let grouped_events = CalendarService::group_events_by_date(vevents);
|
|
||||||
events.set(grouped_events);
|
|
||||||
loading.set(false);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
error.set(Some(format!("Failed to load events: {}", err)));
|
|
||||||
loading.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
loading.set(false);
|
|
||||||
error.set(Some("No authentication token found".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
|| ()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="calendar-view">
|
<div class="calendar-view">
|
||||||
{
|
<Calendar
|
||||||
if *loading {
|
user_info={props.user_info.clone()}
|
||||||
html! {
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
<div class="calendar-loading">
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
<p>{"Loading calendar events..."}</p>
|
view={props.view.clone()}
|
||||||
</div>
|
on_create_event_request={props.on_create_event_request.clone()}
|
||||||
}
|
on_event_update_request={props.on_event_update_request.clone()}
|
||||||
} else if let Some(err) = (*error).clone() {
|
context_menus_open={props.context_menus_open}
|
||||||
let dummy_callback = Callback::from(|_: VEvent| {});
|
/>
|
||||||
html! {
|
|
||||||
<div class="calendar-error">
|
|
||||||
<p>{format!("Error: {}", err)}</p>
|
|
||||||
<Calendar
|
|
||||||
events={HashMap::new()}
|
|
||||||
on_event_click={dummy_callback}
|
|
||||||
refreshing_event_uid={(*refreshing_event).clone()}
|
|
||||||
user_info={props.user_info.clone()}
|
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
|
||||||
view={props.view.clone()}
|
|
||||||
on_create_event_request={props.on_create_event_request.clone()}
|
|
||||||
on_event_update_request={props.on_event_update_request.clone()}
|
|
||||||
context_menus_open={props.context_menus_open}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
<Calendar
|
|
||||||
events={(*events).clone()}
|
|
||||||
on_event_click={on_event_click}
|
|
||||||
refreshing_event_uid={(*refreshing_event).clone()}
|
|
||||||
user_info={props.user_info.clone()}
|
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
|
||||||
view={props.view.clone()}
|
|
||||||
on_create_event_request={props.on_create_event_request.clone()}
|
|
||||||
on_event_update_request={props.on_event_update_request.clone()}
|
|
||||||
context_menus_open={props.context_menus_open}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
|
use crate::components::CalendarListItem;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
|
use web_sys::HtmlSelectElement;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
use web_sys::HtmlSelectElement;
|
|
||||||
use crate::services::calendar_service::UserInfo;
|
|
||||||
use crate::components::CalendarListItem;
|
|
||||||
|
|
||||||
#[derive(Clone, Routable, PartialEq)]
|
#[derive(Clone, Routable, PartialEq)]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
@@ -33,7 +33,6 @@ pub enum Theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Theme {
|
impl Theme {
|
||||||
|
|
||||||
pub fn value(&self) -> &'static str {
|
pub fn value(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Theme::Default => "default",
|
Theme::Default => "default",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use yew::prelude::*;
|
use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal};
|
||||||
use chrono::{Datelike, NaiveDate, Duration, Weekday, Local, Timelike, NaiveDateTime, NaiveTime};
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
|
use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Weekday};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
use crate::services::calendar_service::UserInfo;
|
use yew::prelude::*;
|
||||||
use crate::models::ical::VEvent;
|
|
||||||
use crate::components::{RecurringEditModal, RecurringEditAction, EventCreationData};
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct WeekViewProps {
|
pub struct WeekViewProps {
|
||||||
@@ -25,7 +25,17 @@ pub struct WeekViewProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update: Option<Callback<(VEvent, NaiveDateTime, NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
|
pub on_event_update: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
NaiveDateTime,
|
||||||
|
NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
@@ -47,16 +57,14 @@ struct DragState {
|
|||||||
start_date: NaiveDate,
|
start_date: NaiveDate,
|
||||||
start_y: f64,
|
start_y: f64,
|
||||||
current_y: f64,
|
current_y: f64,
|
||||||
offset_y: f64, // For event moves, this is the offset from the event's top
|
offset_y: f64, // For event moves, this is the offset from the event's top
|
||||||
has_moved: bool, // Track if we've moved enough to constitute a real drag
|
has_moved: bool, // Track if we've moved enough to constitute a real drag
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component(WeekView)]
|
#[function_component(WeekView)]
|
||||||
pub fn week_view(props: &WeekViewProps) -> Html {
|
pub fn week_view(props: &WeekViewProps) -> Html {
|
||||||
let start_of_week = get_start_of_week(props.current_date);
|
let start_of_week = get_start_of_week(props.current_date);
|
||||||
let week_days: Vec<NaiveDate> = (0..7)
|
let week_days: Vec<NaiveDate> = (0..7).map(|i| start_of_week + Duration::days(i)).collect();
|
||||||
.map(|i| start_of_week + Duration::days(i))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Drag state for event creation
|
// Drag state for event creation
|
||||||
let drag_state = use_state(|| None::<DragState>);
|
let drag_state = use_state(|| None::<DragState>);
|
||||||
@@ -75,8 +83,11 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let get_event_color = |event: &VEvent| -> String {
|
let get_event_color = |event: &VEvent| -> String {
|
||||||
if let Some(user_info) = &props.user_info {
|
if let Some(user_info) = &props.user_info {
|
||||||
if let Some(calendar_path) = &event.calendar_path {
|
if let Some(calendar_path) = &event.calendar_path {
|
||||||
if let Some(calendar) = user_info.calendars.iter()
|
if let Some(calendar) = user_info
|
||||||
.find(|cal| &cal.path == calendar_path) {
|
.calendars
|
||||||
|
.iter()
|
||||||
|
.find(|cal| &cal.path == calendar_path)
|
||||||
|
{
|
||||||
return calendar.color.clone();
|
return calendar.color.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,22 +96,23 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Generate time labels - 24 hours plus the final midnight boundary
|
// Generate time labels - 24 hours plus the final midnight boundary
|
||||||
let mut time_labels: Vec<String> = (0..24).map(|hour| {
|
let mut time_labels: Vec<String> = (0..24)
|
||||||
if hour == 0 {
|
.map(|hour| {
|
||||||
"12 AM".to_string()
|
if hour == 0 {
|
||||||
} else if hour < 12 {
|
"12 AM".to_string()
|
||||||
format!("{} AM", hour)
|
} else if hour < 12 {
|
||||||
} else if hour == 12 {
|
format!("{} AM", hour)
|
||||||
"12 PM".to_string()
|
} else if hour == 12 {
|
||||||
} else {
|
"12 PM".to_string()
|
||||||
format!("{} PM", hour - 12)
|
} else {
|
||||||
}
|
format!("{} PM", hour - 12)
|
||||||
}).collect();
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Add the final midnight boundary to show where the day ends
|
// Add the final midnight boundary to show where the day ends
|
||||||
time_labels.push("12 AM".to_string());
|
time_labels.push("12 AM".to_string());
|
||||||
|
|
||||||
|
|
||||||
// Handlers for recurring event modification modal
|
// Handlers for recurring event modification modal
|
||||||
let on_recurring_choice = {
|
let on_recurring_choice = {
|
||||||
let pending_recurring_edit = pending_recurring_edit.clone();
|
let pending_recurring_edit = pending_recurring_edit.clone();
|
||||||
@@ -141,16 +153,16 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// 1. Add EXDATE to original series (excludes this occurrence)
|
// 1. Add EXDATE to original series (excludes this occurrence)
|
||||||
// 2. Create exception event with RECURRENCE-ID and user's modifications
|
// 2. Create exception event with RECURRENCE-ID and user's modifications
|
||||||
update_callback.emit((
|
update_callback.emit((
|
||||||
edit.event.clone(), // Original event (series to modify)
|
edit.event.clone(), // Original event (series to modify)
|
||||||
edit.new_start, // Dragged start time for exception
|
edit.new_start, // Dragged start time for exception
|
||||||
edit.new_end, // Dragged end time for exception
|
edit.new_end, // Dragged end time for exception
|
||||||
true, // preserve_rrule = true
|
true, // preserve_rrule = true
|
||||||
None, // No until_date for this_only
|
None, // No until_date for this_only
|
||||||
Some("this_only".to_string()), // Update scope
|
Some("this_only".to_string()), // Update scope
|
||||||
Some(occurrence_date) // Date of occurrence being modified
|
Some(occurrence_date), // Date of occurrence being modified
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
RecurringEditAction::FutureEvents => {
|
RecurringEditAction::FutureEvents => {
|
||||||
// RFC 5545 Compliant Series Splitting: "This and Future Events"
|
// RFC 5545 Compliant Series Splitting: "This and Future Events"
|
||||||
//
|
//
|
||||||
@@ -177,7 +189,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
if let Some(update_callback) = &on_event_update {
|
if let Some(update_callback) = &on_event_update {
|
||||||
// Find the original series event (not the occurrence)
|
// Find the original series event (not the occurrence)
|
||||||
// UIDs like "uuid-timestamp" need to split on the last hyphen, not the first
|
// UIDs like "uuid-timestamp" need to split on the last hyphen, not the first
|
||||||
let base_uid = if let Some(last_hyphen_pos) = edit.event.uid.rfind('-') {
|
let base_uid = if let Some(last_hyphen_pos) = edit.event.uid.rfind('-')
|
||||||
|
{
|
||||||
let suffix = &edit.event.uid[last_hyphen_pos + 1..];
|
let suffix = &edit.event.uid[last_hyphen_pos + 1..];
|
||||||
// Check if suffix is numeric (timestamp), if so remove it
|
// Check if suffix is numeric (timestamp), if so remove it
|
||||||
if suffix.chars().all(|c| c.is_numeric()) {
|
if suffix.chars().all(|c| c.is_numeric()) {
|
||||||
@@ -189,7 +202,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
edit.event.uid.clone()
|
edit.event.uid.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🔍 Looking for original series: '{}' from occurrence: '{}'", base_uid, edit.event.uid).into());
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🔍 Looking for original series: '{}' from occurrence: '{}'",
|
||||||
|
base_uid, edit.event.uid
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
// Find the original series event by searching for the base UID
|
// Find the original series event by searching for the base UID
|
||||||
let mut original_series = None;
|
let mut original_series = None;
|
||||||
@@ -207,9 +226,12 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
|
|
||||||
let original_series = match original_series {
|
let original_series = match original_series {
|
||||||
Some(series) => {
|
Some(series) => {
|
||||||
web_sys::console::log_1(&format!("✅ Found original series: '{}'", series.uid).into());
|
web_sys::console::log_1(
|
||||||
|
&format!("✅ Found original series: '{}'", series.uid)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
series
|
series
|
||||||
},
|
}
|
||||||
None => {
|
None => {
|
||||||
web_sys::console::log_1(&format!("⚠️ Could not find original series '{}', using occurrence but fixing UID", base_uid).into());
|
web_sys::console::log_1(&format!("⚠️ Could not find original series '{}', using occurrence but fixing UID", base_uid).into());
|
||||||
let mut fallback_event = edit.event.clone();
|
let mut fallback_event = edit.event.clone();
|
||||||
@@ -220,9 +242,15 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Calculate the day before this occurrence for UNTIL clause
|
// Calculate the day before this occurrence for UNTIL clause
|
||||||
let until_date = edit.event.dtstart.date_naive() - chrono::Duration::days(1);
|
let until_date =
|
||||||
let until_datetime = until_date.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
|
edit.event.dtstart.date_naive() - chrono::Duration::days(1);
|
||||||
let until_utc = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(until_datetime, chrono::Utc);
|
let until_datetime = until_date
|
||||||
|
.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
|
||||||
|
let until_utc =
|
||||||
|
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
|
||||||
|
until_datetime,
|
||||||
|
chrono::Utc,
|
||||||
|
);
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",
|
web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",
|
||||||
until_utc.format("%Y-%m-%d %H:%M:%S UTC"),
|
until_utc.format("%Y-%m-%d %H:%M:%S UTC"),
|
||||||
@@ -243,24 +271,32 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// 1. Add UNTIL clause to original series (stops before occurrence_date)
|
// 1. Add UNTIL clause to original series (stops before occurrence_date)
|
||||||
// 2. Create new series starting from occurrence_date with dragged times
|
// 2. Create new series starting from occurrence_date with dragged times
|
||||||
update_callback.emit((
|
update_callback.emit((
|
||||||
original_series, // Original event to terminate
|
original_series, // Original event to terminate
|
||||||
new_start, // Dragged start time for new series
|
new_start, // Dragged start time for new series
|
||||||
new_end, // Dragged end time for new series
|
new_end, // Dragged end time for new series
|
||||||
true, // preserve_rrule = true
|
true, // preserve_rrule = true
|
||||||
Some(until_utc), // UNTIL date for original series
|
Some(until_utc), // UNTIL date for original series
|
||||||
Some("this_and_future".to_string()), // Update scope
|
Some("this_and_future".to_string()), // Update scope
|
||||||
Some(occurrence_date) // Date of occurrence being modified
|
Some(occurrence_date), // Date of occurrence being modified
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
RecurringEditAction::AllEvents => {
|
RecurringEditAction::AllEvents => {
|
||||||
// Modify the entire series
|
// Modify the entire series
|
||||||
let series_event = edit.event.clone();
|
let series_event = edit.event.clone();
|
||||||
|
|
||||||
if let Some(callback) = &on_event_update {
|
if let Some(callback) = &on_event_update {
|
||||||
callback.emit((series_event, edit.new_start, edit.new_end, true, None, Some("all_in_series".to_string()), None)); // Regular drag operation - preserve RRULE, update_scope = all_in_series
|
callback.emit((
|
||||||
|
series_event,
|
||||||
|
edit.new_start,
|
||||||
|
edit.new_end,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
Some("all_in_series".to_string()),
|
||||||
|
None,
|
||||||
|
)); // Regular drag operation - preserve RRULE, update_scope = all_in_series
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pending_recurring_edit.set(None);
|
pending_recurring_edit.set(None);
|
||||||
@@ -988,7 +1024,6 @@ fn pixels_to_time(pixels: f64) -> NaiveTime {
|
|||||||
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) {
|
fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) {
|
||||||
// Convert UTC times to local time for display
|
// Convert UTC times to local time for display
|
||||||
let local_start = event.dtstart.with_timezone(&Local);
|
let local_start = event.dtstart.with_timezone(&Local);
|
||||||
@@ -1009,7 +1044,6 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool)
|
|||||||
let start_minute = local_start.minute() as f32;
|
let start_minute = local_start.minute() as f32;
|
||||||
let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour
|
let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour
|
||||||
|
|
||||||
|
|
||||||
// Calculate duration and height
|
// Calculate duration and height
|
||||||
let duration_pixels = if let Some(end) = event.dtend {
|
let duration_pixels = if let Some(end) = event.dtend {
|
||||||
let local_end = end.with_timezone(&Local);
|
let local_end = end.with_timezone(&Local);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod components;
|
mod components;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user