Added support for external calendars #14
							
								
								
									
										16
									
								
								backend/migrations/006_create_external_calendars_table.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								backend/migrations/006_create_external_calendars_table.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | -- Create external_calendars table | ||||||
|  | CREATE TABLE external_calendars ( | ||||||
|  |     id INTEGER PRIMARY KEY AUTOINCREMENT, | ||||||
|  |     user_id INTEGER NOT NULL, | ||||||
|  |     name TEXT NOT NULL, | ||||||
|  |     url TEXT NOT NULL, | ||||||
|  |     color TEXT NOT NULL DEFAULT '#4285f4', | ||||||
|  |     is_visible BOOLEAN NOT NULL DEFAULT 1, | ||||||
|  |     created_at DATETIME DEFAULT CURRENT_TIMESTAMP, | ||||||
|  |     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, | ||||||
|  |     last_fetched DATETIME, | ||||||
|  |     FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | -- Create index for performance | ||||||
|  | CREATE INDEX idx_external_calendars_user_id ON external_calendars(user_id); | ||||||
| @@ -112,6 +112,17 @@ impl AuthService { | |||||||
|         self.decode_token(token) |         self.decode_token(token) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /// Get user from token | ||||||
|  |     pub async fn get_user_from_token(&self, token: &str) -> Result<crate::db::User, ApiError> { | ||||||
|  |         let claims = self.verify_token(token)?; | ||||||
|  |          | ||||||
|  |         let user_repo = UserRepository::new(&self.db); | ||||||
|  |         user_repo | ||||||
|  |             .find_or_create(&claims.username, &claims.server_url) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| ApiError::Database(format!("Failed to get user: {}", e))) | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /// Create CalDAV config from token |     /// Create CalDAV config from token | ||||||
|     pub fn caldav_config_from_token( |     pub fn caldav_config_from_token( | ||||||
|         &self, |         &self, | ||||||
|   | |||||||
| @@ -99,6 +99,38 @@ pub struct UserPreferences { | |||||||
|     pub updated_at: DateTime<Utc>, |     pub updated_at: DateTime<Utc>, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /// External calendar model | ||||||
|  | #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] | ||||||
|  | pub struct ExternalCalendar { | ||||||
|  |     pub id: i32, | ||||||
|  |     pub user_id: String, | ||||||
|  |     pub name: String, | ||||||
|  |     pub url: String, | ||||||
|  |     pub color: String, | ||||||
|  |     pub is_visible: bool, | ||||||
|  |     pub created_at: DateTime<Utc>, | ||||||
|  |     pub updated_at: DateTime<Utc>, | ||||||
|  |     pub last_fetched: Option<DateTime<Utc>>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl ExternalCalendar { | ||||||
|  |     /// Create a new external calendar | ||||||
|  |     pub fn new(user_id: String, name: String, url: String, color: String) -> Self { | ||||||
|  |         let now = Utc::now(); | ||||||
|  |         Self { | ||||||
|  |             id: 0, // Will be set by database | ||||||
|  |             user_id, | ||||||
|  |             name, | ||||||
|  |             url, | ||||||
|  |             color, | ||||||
|  |             is_visible: true, | ||||||
|  |             created_at: now, | ||||||
|  |             updated_at: now, | ||||||
|  |             last_fetched: None, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
| impl UserPreferences { | impl UserPreferences { | ||||||
|     /// Create default preferences for a new user |     /// Create default preferences for a new user | ||||||
|     pub fn default_for_user(user_id: String) -> Self { |     pub fn default_for_user(user_id: String) -> Self { | ||||||
| @@ -311,3 +343,88 @@ impl<'a> PreferencesRepository<'a> { | |||||||
|         Ok(()) |         Ok(()) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /// Repository for ExternalCalendar operations | ||||||
|  | pub struct ExternalCalendarRepository<'a> { | ||||||
|  |     db: &'a Database, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl<'a> ExternalCalendarRepository<'a> { | ||||||
|  |     pub fn new(db: &'a Database) -> Self { | ||||||
|  |         Self { db } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Get all external calendars for a user | ||||||
|  |     pub async fn get_by_user(&self, user_id: &str) -> Result<Vec<ExternalCalendar>> { | ||||||
|  |         sqlx::query_as::<_, ExternalCalendar>( | ||||||
|  |             "SELECT * FROM external_calendars WHERE user_id = ? ORDER BY created_at ASC", | ||||||
|  |         ) | ||||||
|  |         .bind(user_id) | ||||||
|  |         .fetch_all(self.db.pool()) | ||||||
|  |         .await | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Create a new external calendar | ||||||
|  |     pub async fn create(&self, calendar: &ExternalCalendar) -> Result<i32> { | ||||||
|  |         let result = sqlx::query( | ||||||
|  |             "INSERT INTO external_calendars (user_id, name, url, color, is_visible, created_at, updated_at)  | ||||||
|  |              VALUES (?, ?, ?, ?, ?, ?, ?)", | ||||||
|  |         ) | ||||||
|  |         .bind(&calendar.user_id) | ||||||
|  |         .bind(&calendar.name) | ||||||
|  |         .bind(&calendar.url) | ||||||
|  |         .bind(&calendar.color) | ||||||
|  |         .bind(&calendar.is_visible) | ||||||
|  |         .bind(&calendar.created_at) | ||||||
|  |         .bind(&calendar.updated_at) | ||||||
|  |         .execute(self.db.pool()) | ||||||
|  |         .await?; | ||||||
|  |  | ||||||
|  |         Ok(result.last_insert_rowid() as i32) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Update an external calendar | ||||||
|  |     pub async fn update(&self, id: i32, calendar: &ExternalCalendar) -> Result<()> { | ||||||
|  |         sqlx::query( | ||||||
|  |             "UPDATE external_calendars  | ||||||
|  |              SET name = ?, url = ?, color = ?, is_visible = ?, updated_at = ? | ||||||
|  |              WHERE id = ? AND user_id = ?", | ||||||
|  |         ) | ||||||
|  |         .bind(&calendar.name) | ||||||
|  |         .bind(&calendar.url) | ||||||
|  |         .bind(&calendar.color) | ||||||
|  |         .bind(&calendar.is_visible) | ||||||
|  |         .bind(Utc::now()) | ||||||
|  |         .bind(id) | ||||||
|  |         .bind(&calendar.user_id) | ||||||
|  |         .execute(self.db.pool()) | ||||||
|  |         .await?; | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Delete an external calendar | ||||||
|  |     pub async fn delete(&self, id: i32, user_id: &str) -> Result<()> { | ||||||
|  |         sqlx::query("DELETE FROM external_calendars WHERE id = ? AND user_id = ?") | ||||||
|  |             .bind(id) | ||||||
|  |             .bind(user_id) | ||||||
|  |             .execute(self.db.pool()) | ||||||
|  |             .await?; | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Update last_fetched timestamp | ||||||
|  |     pub async fn update_last_fetched(&self, id: i32, user_id: &str) -> Result<()> { | ||||||
|  |         sqlx::query( | ||||||
|  |             "UPDATE external_calendars SET last_fetched = ? WHERE id = ? AND user_id = ?", | ||||||
|  |         ) | ||||||
|  |         .bind(Utc::now()) | ||||||
|  |         .bind(id) | ||||||
|  |         .bind(user_id) | ||||||
|  |         .execute(self.db.pool()) | ||||||
|  |         .await?; | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,12 +0,0 @@ | |||||||
| // Re-export all handlers from the modular structure |  | ||||||
| mod auth; |  | ||||||
| mod calendar; |  | ||||||
| mod events; |  | ||||||
| mod preferences; |  | ||||||
| mod series; |  | ||||||
|  |  | ||||||
| pub use auth::{get_user_info, login, verify_token}; |  | ||||||
| pub use calendar::{create_calendar, delete_calendar}; |  | ||||||
| pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event}; |  | ||||||
| pub use preferences::{get_preferences, logout, update_preferences}; |  | ||||||
| pub use series::{create_event_series, delete_event_series, update_event_series}; |  | ||||||
							
								
								
									
										142
									
								
								backend/src/handlers/external_calendars.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								backend/src/handlers/external_calendars.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,142 @@ | |||||||
|  | use axum::{ | ||||||
|  |     extract::{Path, State}, | ||||||
|  |     response::Json, | ||||||
|  | }; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  | use std::sync::Arc; | ||||||
|  |  | ||||||
|  | use crate::{ | ||||||
|  |     db::{ExternalCalendar, ExternalCalendarRepository}, | ||||||
|  |     models::ApiError, | ||||||
|  |     AppState, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | use super::auth::{extract_bearer_token}; | ||||||
|  |  | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | pub struct CreateExternalCalendarRequest { | ||||||
|  |     pub name: String, | ||||||
|  |     pub url: String, | ||||||
|  |     pub color: String, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Deserialize)] | ||||||
|  | pub struct UpdateExternalCalendarRequest { | ||||||
|  |     pub name: String, | ||||||
|  |     pub url: String, | ||||||
|  |     pub color: String, | ||||||
|  |     pub is_visible: bool, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize)] | ||||||
|  | pub struct ExternalCalendarResponse { | ||||||
|  |     pub id: i32, | ||||||
|  |     pub name: String, | ||||||
|  |     pub url: String, | ||||||
|  |     pub color: String, | ||||||
|  |     pub is_visible: bool, | ||||||
|  |     pub created_at: chrono::DateTime<chrono::Utc>, | ||||||
|  |     pub updated_at: chrono::DateTime<chrono::Utc>, | ||||||
|  |     pub last_fetched: Option<chrono::DateTime<chrono::Utc>>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl From<ExternalCalendar> for ExternalCalendarResponse { | ||||||
|  |     fn from(calendar: ExternalCalendar) -> Self { | ||||||
|  |         Self { | ||||||
|  |             id: calendar.id, | ||||||
|  |             name: calendar.name, | ||||||
|  |             url: calendar.url, | ||||||
|  |             color: calendar.color, | ||||||
|  |             is_visible: calendar.is_visible, | ||||||
|  |             created_at: calendar.created_at, | ||||||
|  |             updated_at: calendar.updated_at, | ||||||
|  |             last_fetched: calendar.last_fetched, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn get_external_calendars( | ||||||
|  |     headers: axum::http::HeaderMap, | ||||||
|  |     State(app_state): State<Arc<AppState>>, | ||||||
|  | ) -> Result<Json<Vec<ExternalCalendarResponse>>, ApiError> { | ||||||
|  |     // Extract and verify token, get user | ||||||
|  |     let token = extract_bearer_token(&headers)?; | ||||||
|  |     let user = app_state.auth_service.get_user_from_token(&token).await?; | ||||||
|  |  | ||||||
|  |     let repo = ExternalCalendarRepository::new(&app_state.db); | ||||||
|  |     let calendars = repo | ||||||
|  |         .get_by_user(&user.id) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Database(format!("Failed to get external calendars: {}", e)))?; | ||||||
|  |  | ||||||
|  |     let response: Vec<ExternalCalendarResponse> = calendars.into_iter().map(Into::into).collect(); | ||||||
|  |     Ok(Json(response)) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn create_external_calendar( | ||||||
|  |     headers: axum::http::HeaderMap, | ||||||
|  |     State(app_state): State<Arc<AppState>>, | ||||||
|  |     Json(request): Json<CreateExternalCalendarRequest>, | ||||||
|  | ) -> Result<Json<ExternalCalendarResponse>, ApiError> { | ||||||
|  |     let token = extract_bearer_token(&headers)?; | ||||||
|  |     let user = app_state.auth_service.get_user_from_token(&token).await?; | ||||||
|  |  | ||||||
|  |     let calendar = ExternalCalendar::new( | ||||||
|  |         user.id, | ||||||
|  |         request.name, | ||||||
|  |         request.url, | ||||||
|  |         request.color, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     let repo = ExternalCalendarRepository::new(&app_state.db); | ||||||
|  |     let id = repo | ||||||
|  |         .create(&calendar) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Database(format!("Failed to create external calendar: {}", e)))?; | ||||||
|  |  | ||||||
|  |     let mut created_calendar = calendar; | ||||||
|  |     created_calendar.id = id; | ||||||
|  |  | ||||||
|  |     Ok(Json(created_calendar.into())) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn update_external_calendar( | ||||||
|  |     headers: axum::http::HeaderMap, | ||||||
|  |     State(app_state): State<Arc<AppState>>, | ||||||
|  |     Path(id): Path<i32>, | ||||||
|  |     Json(request): Json<UpdateExternalCalendarRequest>, | ||||||
|  | ) -> Result<Json<()>, ApiError> { | ||||||
|  |     let token = extract_bearer_token(&headers)?; | ||||||
|  |     let user = app_state.auth_service.get_user_from_token(&token).await?; | ||||||
|  |  | ||||||
|  |     let mut calendar = ExternalCalendar::new( | ||||||
|  |         user.id, | ||||||
|  |         request.name, | ||||||
|  |         request.url, | ||||||
|  |         request.color, | ||||||
|  |     ); | ||||||
|  |     calendar.is_visible = request.is_visible; | ||||||
|  |  | ||||||
|  |     let repo = ExternalCalendarRepository::new(&app_state.db); | ||||||
|  |     repo.update(id, &calendar) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Database(format!("Failed to update external calendar: {}", e)))?; | ||||||
|  |  | ||||||
|  |     Ok(Json(())) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn delete_external_calendar( | ||||||
|  |     headers: axum::http::HeaderMap, | ||||||
|  |     State(app_state): State<Arc<AppState>>, | ||||||
|  |     Path(id): Path<i32>, | ||||||
|  | ) -> Result<Json<()>, ApiError> { | ||||||
|  |     let token = extract_bearer_token(&headers)?; | ||||||
|  |     let user = app_state.auth_service.get_user_from_token(&token).await?; | ||||||
|  |  | ||||||
|  |     let repo = ExternalCalendarRepository::new(&app_state.db); | ||||||
|  |     repo.delete(id, &user.id) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Database(format!("Failed to delete external calendar: {}", e)))?; | ||||||
|  |  | ||||||
|  |     Ok(Json(())) | ||||||
|  | } | ||||||
							
								
								
									
										238
									
								
								backend/src/handlers/ics_fetcher.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										238
									
								
								backend/src/handlers/ics_fetcher.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,238 @@ | |||||||
|  | use axum::{ | ||||||
|  |     extract::{Path, State}, | ||||||
|  |     response::Json, | ||||||
|  | }; | ||||||
|  | use chrono::{DateTime, Utc}; | ||||||
|  | use ical::parser::ical::component::IcalEvent; | ||||||
|  | use reqwest::Client; | ||||||
|  | use serde::Serialize; | ||||||
|  | use std::sync::Arc; | ||||||
|  |  | ||||||
|  | use crate::{ | ||||||
|  |     db::ExternalCalendarRepository, | ||||||
|  |     models::ApiError, | ||||||
|  |     AppState, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Import VEvent from calendar-models shared crate | ||||||
|  | use calendar_models::VEvent; | ||||||
|  |  | ||||||
|  | use super::auth::{extract_bearer_token}; | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize)] | ||||||
|  | pub struct ExternalCalendarEventsResponse { | ||||||
|  |     pub events: Vec<VEvent>, | ||||||
|  |     pub last_fetched: DateTime<Utc>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub async fn fetch_external_calendar_events( | ||||||
|  |     headers: axum::http::HeaderMap, | ||||||
|  |     State(app_state): State<Arc<AppState>>, | ||||||
|  |     Path(id): Path<i32>, | ||||||
|  | ) -> Result<Json<ExternalCalendarEventsResponse>, ApiError> { | ||||||
|  |     let token = extract_bearer_token(&headers)?; | ||||||
|  |     let user = app_state.auth_service.get_user_from_token(&token).await?; | ||||||
|  |  | ||||||
|  |     let repo = ExternalCalendarRepository::new(&app_state.db); | ||||||
|  |      | ||||||
|  |     // Get user's external calendars to verify ownership and get URL | ||||||
|  |     let calendars = repo | ||||||
|  |         .get_by_user(&user.id) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Database(format!("Failed to get external calendars: {}", e)))?; | ||||||
|  |  | ||||||
|  |     let calendar = calendars | ||||||
|  |         .into_iter() | ||||||
|  |         .find(|c| c.id == id) | ||||||
|  |         .ok_or_else(|| ApiError::NotFound("External calendar not found".to_string()))?; | ||||||
|  |  | ||||||
|  |     if !calendar.is_visible { | ||||||
|  |         return Ok(Json(ExternalCalendarEventsResponse { | ||||||
|  |             events: vec![], | ||||||
|  |             last_fetched: Utc::now(), | ||||||
|  |         })); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Fetch ICS content from URL | ||||||
|  |     let client = Client::new(); | ||||||
|  |     let response = client | ||||||
|  |         .get(&calendar.url) | ||||||
|  |         .send() | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Internal(format!("Failed to fetch calendar: {}", e)))?; | ||||||
|  |  | ||||||
|  |     if !response.status().is_success() { | ||||||
|  |         return Err(ApiError::Internal(format!("Calendar server returned: {}", response.status()))); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let ics_content = response | ||||||
|  |         .text() | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Internal(format!("Failed to read calendar content: {}", e)))?; | ||||||
|  |  | ||||||
|  |     // Parse ICS content | ||||||
|  |     let events = parse_ics_content(&ics_content) | ||||||
|  |         .map_err(|e| ApiError::BadRequest(format!("Failed to parse calendar: {}", e)))?; | ||||||
|  |  | ||||||
|  |     // Update last_fetched timestamp | ||||||
|  |     repo.update_last_fetched(id, &user.id) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| ApiError::Database(format!("Failed to update last_fetched: {}", e)))?; | ||||||
|  |  | ||||||
|  |     Ok(Json(ExternalCalendarEventsResponse { | ||||||
|  |         events, | ||||||
|  |         last_fetched: Utc::now(), | ||||||
|  |     })) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn parse_ics_content(ics_content: &str) -> Result<Vec<VEvent>, Box<dyn std::error::Error>> { | ||||||
|  |     let reader = ical::IcalParser::new(ics_content.as_bytes()); | ||||||
|  |     let mut events = Vec::new(); | ||||||
|  |  | ||||||
|  |     for calendar in reader { | ||||||
|  |         let calendar = calendar?; | ||||||
|  |         for component in calendar.events { | ||||||
|  |             if let Ok(vevent) = convert_ical_to_vevent(component) { | ||||||
|  |                 events.push(vevent); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Ok(events) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::error::Error>> { | ||||||
|  |     use uuid::Uuid; | ||||||
|  |  | ||||||
|  |     let mut summary = None; | ||||||
|  |     let mut description = None; | ||||||
|  |     let mut location = None; | ||||||
|  |     let mut dtstart = None; | ||||||
|  |     let mut dtend = None; | ||||||
|  |     let mut uid = None; | ||||||
|  |     let mut all_day = false; | ||||||
|  |     let mut rrule = None; | ||||||
|  |  | ||||||
|  |     // Extract properties | ||||||
|  |     for property in ical_event.properties { | ||||||
|  |         match property.name.as_str() { | ||||||
|  |             "SUMMARY" => { | ||||||
|  |                 summary = property.value; | ||||||
|  |             } | ||||||
|  |             "DESCRIPTION" => { | ||||||
|  |                 description = property.value; | ||||||
|  |             } | ||||||
|  |             "LOCATION" => { | ||||||
|  |                 location = property.value; | ||||||
|  |             } | ||||||
|  |             "DTSTART" => { | ||||||
|  |                 if let Some(value) = property.value { | ||||||
|  |                     // Check if it's a date-only value (all-day event) | ||||||
|  |                     if value.len() == 8 && !value.contains('T') { | ||||||
|  |                         all_day = true; | ||||||
|  |                         // Parse YYYYMMDD format | ||||||
|  |                         if let Ok(date) = chrono::NaiveDate::parse_from_str(&value, "%Y%m%d") { | ||||||
|  |                             dtstart = Some(chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap())); | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         // Parse datetime - could be various formats | ||||||
|  |                         if let Some(dt) = parse_datetime(&value) { | ||||||
|  |                             dtstart = Some(dt); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             "DTEND" => { | ||||||
|  |                 if let Some(value) = property.value { | ||||||
|  |                     if all_day && value.len() == 8 && !value.contains('T') { | ||||||
|  |                         // For all-day events, DTEND is exclusive so use the date as-is at noon | ||||||
|  |                         if let Ok(date) = chrono::NaiveDate::parse_from_str(&value, "%Y%m%d") { | ||||||
|  |                             dtend = Some(chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap())); | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         if let Some(dt) = parse_datetime(&value) { | ||||||
|  |                             dtend = Some(dt); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             "UID" => { | ||||||
|  |                 uid = property.value; | ||||||
|  |             } | ||||||
|  |             "RRULE" => { | ||||||
|  |                 rrule = property.value; | ||||||
|  |             } | ||||||
|  |             _ => {} // Ignore other properties for now | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let vevent = VEvent { | ||||||
|  |         uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()), | ||||||
|  |         dtstart: dtstart.ok_or("Missing DTSTART")?, | ||||||
|  |         dtend, | ||||||
|  |         summary, | ||||||
|  |         description, | ||||||
|  |         location, | ||||||
|  |         all_day, | ||||||
|  |         rrule, | ||||||
|  |         exdate: Vec::new(), // External calendars don't need exception handling | ||||||
|  |         recurrence_id: None, | ||||||
|  |         created: None, | ||||||
|  |         last_modified: None, | ||||||
|  |         dtstamp: Utc::now(), | ||||||
|  |         sequence: Some(0), | ||||||
|  |         status: None, | ||||||
|  |         transp: None, | ||||||
|  |         organizer: None, | ||||||
|  |         attendees: Vec::new(), | ||||||
|  |         url: None, | ||||||
|  |         attachments: Vec::new(), | ||||||
|  |         categories: Vec::new(), | ||||||
|  |         priority: None, | ||||||
|  |         resources: Vec::new(), | ||||||
|  |         related_to: None, | ||||||
|  |         geo: None, | ||||||
|  |         duration: None, | ||||||
|  |         class: None, | ||||||
|  |         contact: None, | ||||||
|  |         comment: None, | ||||||
|  |         rdate: Vec::new(), | ||||||
|  |         alarms: Vec::new(), | ||||||
|  |         etag: None, | ||||||
|  |         href: None, | ||||||
|  |         calendar_path: None, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     Ok(vevent) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fn parse_datetime(datetime_str: &str) -> Option<DateTime<Utc>> { | ||||||
|  |     // Try various datetime formats commonly found in ICS files | ||||||
|  |      | ||||||
|  |     // Format: 20231201T103000Z (UTC) | ||||||
|  |     if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%SZ") { | ||||||
|  |         return Some(dt.with_timezone(&Utc)); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // Format: 20231201T103000 (floating time - assume UTC) | ||||||
|  |     if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S") { | ||||||
|  |         return Some(chrono::TimeZone::from_utc_datetime(&Utc, &dt)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Format: 20231201T103000-0500 (with timezone offset) | ||||||
|  |     if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S%z") { | ||||||
|  |         return Some(dt.with_timezone(&Utc)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Format: 2023-12-01T10:30:00Z (ISO format) | ||||||
|  |     if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%SZ") { | ||||||
|  |         return Some(dt.with_timezone(&Utc)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Format: 2023-12-01T10:30:00 (ISO without timezone) | ||||||
|  |     if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%S") { | ||||||
|  |         return Some(chrono::TimeZone::from_utc_datetime(&Utc, &dt)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     None | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								backend/src/handlers/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								backend/src/handlers/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | pub mod auth; | ||||||
|  | pub mod calendar; | ||||||
|  | pub mod events; | ||||||
|  | pub mod external_calendars; | ||||||
|  | pub mod ics_fetcher; | ||||||
|  | pub mod preferences; | ||||||
|  | pub mod series; | ||||||
|  |  | ||||||
|  | pub use auth::*; | ||||||
|  | pub use calendar::*; | ||||||
|  | pub use events::*; | ||||||
|  | pub use external_calendars::*; | ||||||
|  | pub use ics_fetcher::*; | ||||||
|  | pub use preferences::*; | ||||||
|  | pub use series::*; | ||||||
| @@ -1,6 +1,6 @@ | |||||||
| use axum::{ | use axum::{ | ||||||
|     response::Json, |     response::Json, | ||||||
|     routing::{get, post}, |     routing::{delete, get, post}, | ||||||
|     Router, |     Router, | ||||||
| }; | }; | ||||||
| use std::sync::Arc; | use std::sync::Arc; | ||||||
| @@ -72,6 +72,12 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> { | |||||||
|         .route("/api/preferences", get(handlers::get_preferences)) |         .route("/api/preferences", get(handlers::get_preferences)) | ||||||
|         .route("/api/preferences", post(handlers::update_preferences)) |         .route("/api/preferences", post(handlers::update_preferences)) | ||||||
|         .route("/api/auth/logout", post(handlers::logout)) |         .route("/api/auth/logout", post(handlers::logout)) | ||||||
|  |         // External calendars endpoints | ||||||
|  |         .route("/api/external-calendars", get(handlers::get_external_calendars)) | ||||||
|  |         .route("/api/external-calendars", post(handlers::create_external_calendar)) | ||||||
|  |         .route("/api/external-calendars/:id", post(handlers::update_external_calendar)) | ||||||
|  |         .route("/api/external-calendars/:id", delete(handlers::delete_external_calendar)) | ||||||
|  |         .route("/api/external-calendars/:id/events", get(handlers::fetch_external_calendar_events)) | ||||||
|         .layer( |         .layer( | ||||||
|             CorsLayer::new() |             CorsLayer::new() | ||||||
|                 .allow_origin(Any) |                 .allow_origin(Any) | ||||||
|   | |||||||
| @@ -37,6 +37,7 @@ reqwest = { version = "0.11", features = ["json"] } | |||||||
| ical = "0.7" | ical = "0.7" | ||||||
| serde = { version = "1.0", features = ["derive"] } | serde = { version = "1.0", features = ["derive"] } | ||||||
| serde_json = "1.0" | serde_json = "1.0" | ||||||
|  | serde-wasm-bindgen = "0.6" | ||||||
|  |  | ||||||
| # Date and time handling | # Date and time handling | ||||||
| chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] } | chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] } | ||||||
|   | |||||||
| @@ -1,10 +1,11 @@ | |||||||
| use crate::components::{ | use crate::components::{ | ||||||
|     CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction, |     CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction, | ||||||
|     EditAction, EventContextMenu, EventCreationData, RouteHandler, Sidebar, Theme, ViewMode, |     EditAction, EventContextMenu, EventCreationData, ExternalCalendarModal, RouteHandler,  | ||||||
|  |     Sidebar, Theme, ViewMode, | ||||||
| }; | }; | ||||||
| use crate::components::sidebar::{Style}; | use crate::components::sidebar::{Style}; | ||||||
| use crate::models::ical::VEvent; | use crate::models::ical::VEvent; | ||||||
| use crate::services::{calendar_service::UserInfo, CalendarService}; | use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; | ||||||
| use chrono::NaiveDate; | use chrono::NaiveDate; | ||||||
| use gloo_storage::{LocalStorage, Storage}; | use gloo_storage::{LocalStorage, Storage}; | ||||||
| use wasm_bindgen::JsCast; | use wasm_bindgen::JsCast; | ||||||
| @@ -74,6 +75,11 @@ pub fn App() -> Html { | |||||||
|     let _recurring_edit_event = use_state(|| -> Option<VEvent> { None }); |     let _recurring_edit_event = use_state(|| -> Option<VEvent> { None }); | ||||||
|     let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { None }); |     let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { None }); | ||||||
|      |      | ||||||
|  |     // External calendar state | ||||||
|  |     let external_calendars = use_state(|| -> Vec<ExternalCalendar> { Vec::new() }); | ||||||
|  |     let external_calendar_events = use_state(|| -> Vec<VEvent> { Vec::new() }); | ||||||
|  |     let external_calendar_modal_open = use_state(|| false); | ||||||
|  |  | ||||||
|     // Calendar view state - load from localStorage if available |     // Calendar view state - load from localStorage if available | ||||||
|     let current_view = use_state(|| { |     let current_view = use_state(|| { | ||||||
|         // Try to load saved view mode from localStorage |         // Try to load saved view mode from localStorage | ||||||
| @@ -301,6 +307,50 @@ pub fn App() -> Html { | |||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // Load external calendars when auth token is available | ||||||
|  |     { | ||||||
|  |         let auth_token = auth_token.clone(); | ||||||
|  |         let external_calendars = external_calendars.clone(); | ||||||
|  |         let external_calendar_events = external_calendar_events.clone(); | ||||||
|  |  | ||||||
|  |         use_effect_with((*auth_token).clone(), move |token| { | ||||||
|  |             if token.is_some() { | ||||||
|  |                 let external_calendars = external_calendars.clone(); | ||||||
|  |                 let external_calendar_events = external_calendar_events.clone(); | ||||||
|  |  | ||||||
|  |                 wasm_bindgen_futures::spawn_local(async move { | ||||||
|  |                     // Load external calendars | ||||||
|  |                     match CalendarService::get_external_calendars().await { | ||||||
|  |                         Ok(calendars) => { | ||||||
|  |                             external_calendars.set(calendars.clone()); | ||||||
|  |                              | ||||||
|  |                             // Load events for visible external calendars | ||||||
|  |                             let mut all_events = Vec::new(); | ||||||
|  |                             for calendar in calendars { | ||||||
|  |                                 if calendar.is_visible { | ||||||
|  |                                     if let Ok(events) = CalendarService::fetch_external_calendar_events(calendar.id).await { | ||||||
|  |                                         all_events.extend(events); | ||||||
|  |                                     } | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                             external_calendar_events.set(all_events); | ||||||
|  |                         } | ||||||
|  |                         Err(err) => { | ||||||
|  |                             web_sys::console::log_1( | ||||||
|  |                                 &format!("Failed to load external calendars: {}", err).into(), | ||||||
|  |                             ); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 }); | ||||||
|  |             } else { | ||||||
|  |                 external_calendars.set(Vec::new()); | ||||||
|  |                 external_calendar_events.set(Vec::new()); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             || () | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     let on_outside_click = { |     let on_outside_click = { | ||||||
|         let color_picker_open = color_picker_open.clone(); |         let color_picker_open = color_picker_open.clone(); | ||||||
|         let context_menu_open = context_menu_open.clone(); |         let context_menu_open = context_menu_open.clone(); | ||||||
| @@ -924,6 +974,53 @@ pub fn App() -> Html { | |||||||
|                                         let create_modal_open = create_modal_open.clone(); |                                         let create_modal_open = create_modal_open.clone(); | ||||||
|                                         move |_| create_modal_open.set(true) |                                         move |_| create_modal_open.set(true) | ||||||
|                                     })} |                                     })} | ||||||
|  |                                     on_create_external_calendar={Callback::from({ | ||||||
|  |                                         let external_calendar_modal_open = external_calendar_modal_open.clone(); | ||||||
|  |                                         move |_| external_calendar_modal_open.set(true) | ||||||
|  |                                     })} | ||||||
|  |                                     external_calendars={(*external_calendars).clone()} | ||||||
|  |                                     on_external_calendar_toggle={Callback::from({ | ||||||
|  |                                         let external_calendars = external_calendars.clone(); | ||||||
|  |                                         let external_calendar_events = external_calendar_events.clone(); | ||||||
|  |                                         move |id: i32| { | ||||||
|  |                                             let external_calendars = external_calendars.clone(); | ||||||
|  |                                             let external_calendar_events = external_calendar_events.clone(); | ||||||
|  |                                             wasm_bindgen_futures::spawn_local(async move { | ||||||
|  |                                                 // Find the calendar and toggle its visibility | ||||||
|  |                                                 let mut calendars = (*external_calendars).clone(); | ||||||
|  |                                                 if let Some(calendar) = calendars.iter_mut().find(|c| c.id == id) { | ||||||
|  |                                                     calendar.is_visible = !calendar.is_visible; | ||||||
|  |                                                      | ||||||
|  |                                                     // Update on server | ||||||
|  |                                                     if let Err(err) = CalendarService::update_external_calendar( | ||||||
|  |                                                         calendar.id, | ||||||
|  |                                                         &calendar.name, | ||||||
|  |                                                         &calendar.url, | ||||||
|  |                                                         &calendar.color, | ||||||
|  |                                                         calendar.is_visible, | ||||||
|  |                                                     ).await { | ||||||
|  |                                                         web_sys::console::log_1( | ||||||
|  |                                                             &format!("Failed to update external calendar: {}", err).into(), | ||||||
|  |                                                         ); | ||||||
|  |                                                         return; | ||||||
|  |                                                     } | ||||||
|  |                                                      | ||||||
|  |                                                     external_calendars.set(calendars.clone()); | ||||||
|  |                                                      | ||||||
|  |                                                     // Reload events for all visible external calendars | ||||||
|  |                                                     let mut all_events = Vec::new(); | ||||||
|  |                                                     for cal in calendars { | ||||||
|  |                                                         if cal.is_visible { | ||||||
|  |                                                             if let Ok(events) = CalendarService::fetch_external_calendar_events(cal.id).await { | ||||||
|  |                                                                 all_events.extend(events); | ||||||
|  |                                                             } | ||||||
|  |                                                         } | ||||||
|  |                                                     } | ||||||
|  |                                                     external_calendar_events.set(all_events); | ||||||
|  |                                                 } | ||||||
|  |                                             }); | ||||||
|  |                                         } | ||||||
|  |                                     })} | ||||||
|                                     color_picker_open={(*color_picker_open).clone()} |                                     color_picker_open={(*color_picker_open).clone()} | ||||||
|                                     on_color_change={on_color_change} |                                     on_color_change={on_color_change} | ||||||
|                                     on_color_picker_toggle={on_color_picker_toggle} |                                     on_color_picker_toggle={on_color_picker_toggle} | ||||||
| @@ -941,6 +1038,7 @@ pub fn App() -> Html { | |||||||
|                                         auth_token={(*auth_token).clone()} |                                         auth_token={(*auth_token).clone()} | ||||||
|                                         user_info={(*user_info).clone()} |                                         user_info={(*user_info).clone()} | ||||||
|                                         on_login={on_login.clone()} |                                         on_login={on_login.clone()} | ||||||
|  |                                         external_calendar_events={(*external_calendar_events).clone()} | ||||||
|                                         on_event_context_menu={Some(on_event_context_menu.clone())} |                                         on_event_context_menu={Some(on_event_context_menu.clone())} | ||||||
|                                         on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())} |                                         on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())} | ||||||
|                                         view={(*current_view).clone()} |                                         view={(*current_view).clone()} | ||||||
| @@ -1193,6 +1291,46 @@ pub fn App() -> Html { | |||||||
|                     on_create={on_event_create} |                     on_create={on_event_create} | ||||||
|                     available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()} |                     available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()} | ||||||
|                 /> |                 /> | ||||||
|  |  | ||||||
|  |                 <ExternalCalendarModal | ||||||
|  |                     is_open={*external_calendar_modal_open} | ||||||
|  |                     on_close={Callback::from({ | ||||||
|  |                         let external_calendar_modal_open = external_calendar_modal_open.clone(); | ||||||
|  |                         move |_| external_calendar_modal_open.set(false) | ||||||
|  |                     })} | ||||||
|  |                     on_success={Callback::from({ | ||||||
|  |                         let external_calendars = external_calendars.clone(); | ||||||
|  |                         let external_calendar_events = external_calendar_events.clone(); | ||||||
|  |                         move |_| { | ||||||
|  |                             // Reload external calendars | ||||||
|  |                             let external_calendars = external_calendars.clone(); | ||||||
|  |                             let external_calendar_events = external_calendar_events.clone(); | ||||||
|  |                             wasm_bindgen_futures::spawn_local(async move { | ||||||
|  |                                 match CalendarService::get_external_calendars().await { | ||||||
|  |                                     Ok(calendars) => { | ||||||
|  |                                         external_calendars.set(calendars.clone()); | ||||||
|  |                                          | ||||||
|  |                                         // Load events for visible external calendars | ||||||
|  |                                         let mut all_events = Vec::new(); | ||||||
|  |                                         for calendar in calendars { | ||||||
|  |                                             if calendar.is_visible { | ||||||
|  |                                                 if let Ok(events) = CalendarService::fetch_external_calendar_events(calendar.id).await { | ||||||
|  |                                                     all_events.extend(events); | ||||||
|  |                                                 } | ||||||
|  |                                             } | ||||||
|  |                                         } | ||||||
|  |                                         external_calendar_events.set(all_events); | ||||||
|  |                                     } | ||||||
|  |                                     Err(err) => { | ||||||
|  |                                         web_sys::console::log_1( | ||||||
|  |                                             &format!("Failed to reload external calendars: {}", err).into(), | ||||||
|  |                                         ); | ||||||
|  |                                     } | ||||||
|  |                                 } | ||||||
|  |                             }); | ||||||
|  |                         } | ||||||
|  |                     })} | ||||||
|  |                 /> | ||||||
|             </div> |             </div> | ||||||
|         </BrowserRouter> |         </BrowserRouter> | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -14,6 +14,8 @@ pub struct CalendarProps { | |||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub user_info: Option<UserInfo>, |     pub user_info: Option<UserInfo>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|  |     pub external_calendar_events: Vec<VEvent>, | ||||||
|  |     #[prop_or_default] | ||||||
|     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, |     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>, |     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>, | ||||||
| @@ -101,10 +103,13 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|         let loading = loading.clone(); |         let loading = loading.clone(); | ||||||
|         let error = error.clone(); |         let error = error.clone(); | ||||||
|         let current_date = current_date.clone(); |         let current_date = current_date.clone(); | ||||||
|  |         let external_events = props.external_calendar_events.clone(); // Clone before the effect | ||||||
|  |         let view = props.view.clone(); // Clone before the effect | ||||||
|          |          | ||||||
|         use_effect_with((*current_date, props.view.clone()), move |(date, _view)| { |         use_effect_with((*current_date, view.clone(), external_events.len()), move |(date, _view, _external_len)| { | ||||||
|             let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); |             let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); | ||||||
|             let date = *date; // Clone the date to avoid lifetime issues |             let date = *date; // Clone the date to avoid lifetime issues | ||||||
|  |             let external_events = external_events.clone(); // Clone external events to avoid lifetime issues | ||||||
|              |              | ||||||
|             if let Some(token) = auth_token { |             if let Some(token) = auth_token { | ||||||
|                 let events = events.clone(); |                 let events = events.clone(); | ||||||
| @@ -141,7 +146,11 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                         .await |                         .await | ||||||
|                     { |                     { | ||||||
|                         Ok(vevents) => { |                         Ok(vevents) => { | ||||||
|                             let grouped_events = CalendarService::group_events_by_date(vevents); |                             // Combine regular events with external calendar events | ||||||
|  |                             let mut all_events = vevents; | ||||||
|  |                             all_events.extend(external_events); | ||||||
|  |                              | ||||||
|  |                             let grouped_events = CalendarService::group_events_by_date(all_events); | ||||||
|                             events.set(grouped_events); |                             events.set(grouped_events); | ||||||
|                             loading.set(false); |                             loading.set(false); | ||||||
|                         } |                         } | ||||||
|   | |||||||
							
								
								
									
										193
									
								
								frontend/src/components/external_calendar_modal.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								frontend/src/components/external_calendar_modal.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,193 @@ | |||||||
|  | use web_sys::HtmlInputElement; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
|  | use crate::services::calendar_service::CalendarService; | ||||||
|  |  | ||||||
|  | #[derive(Properties, PartialEq)] | ||||||
|  | pub struct ExternalCalendarModalProps { | ||||||
|  |     pub is_open: bool, | ||||||
|  |     pub on_close: Callback<()>, | ||||||
|  |     pub on_success: Callback<()>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[function_component(ExternalCalendarModal)] | ||||||
|  | pub fn external_calendar_modal(props: &ExternalCalendarModalProps) -> Html { | ||||||
|  |     let name_ref = use_node_ref(); | ||||||
|  |     let url_ref = use_node_ref(); | ||||||
|  |     let color_ref = use_node_ref(); | ||||||
|  |     let is_loading = use_state(|| false); | ||||||
|  |     let error_message = use_state(|| None::<String>); | ||||||
|  |  | ||||||
|  |     let on_submit = { | ||||||
|  |         let name_ref = name_ref.clone(); | ||||||
|  |         let url_ref = url_ref.clone(); | ||||||
|  |         let color_ref = color_ref.clone(); | ||||||
|  |         let is_loading = is_loading.clone(); | ||||||
|  |         let error_message = error_message.clone(); | ||||||
|  |         let on_close = props.on_close.clone(); | ||||||
|  |         let on_success = props.on_success.clone(); | ||||||
|  |  | ||||||
|  |         Callback::from(move |e: SubmitEvent| { | ||||||
|  |             e.prevent_default(); | ||||||
|  |              | ||||||
|  |             let name = name_ref | ||||||
|  |                 .cast::<HtmlInputElement>() | ||||||
|  |                 .map(|input| input.value()) | ||||||
|  |                 .unwrap_or_default() | ||||||
|  |                 .trim() | ||||||
|  |                 .to_string(); | ||||||
|  |              | ||||||
|  |             let url = url_ref | ||||||
|  |                 .cast::<HtmlInputElement>() | ||||||
|  |                 .map(|input| input.value()) | ||||||
|  |                 .unwrap_or_default() | ||||||
|  |                 .trim() | ||||||
|  |                 .to_string(); | ||||||
|  |                  | ||||||
|  |             let color = color_ref | ||||||
|  |                 .cast::<HtmlInputElement>() | ||||||
|  |                 .map(|input| input.value()) | ||||||
|  |                 .unwrap_or_else(|| "#4285f4".to_string()); | ||||||
|  |  | ||||||
|  |             if name.is_empty() { | ||||||
|  |                 error_message.set(Some("Calendar name is required".to_string())); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if url.is_empty() { | ||||||
|  |                 error_message.set(Some("Calendar URL is required".to_string())); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Basic URL validation | ||||||
|  |             if !url.starts_with("http://") && !url.starts_with("https://") { | ||||||
|  |                 error_message.set(Some("Please enter a valid HTTP or HTTPS URL".to_string())); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             error_message.set(None); | ||||||
|  |             is_loading.set(true); | ||||||
|  |  | ||||||
|  |             let is_loading = is_loading.clone(); | ||||||
|  |             let error_message = error_message.clone(); | ||||||
|  |             let on_close = on_close.clone(); | ||||||
|  |             let on_success = on_success.clone(); | ||||||
|  |  | ||||||
|  |             wasm_bindgen_futures::spawn_local(async move { | ||||||
|  |                 match CalendarService::create_external_calendar(&name, &url, &color).await { | ||||||
|  |                     Ok(_) => { | ||||||
|  |                         is_loading.set(false); | ||||||
|  |                         on_success.emit(()); | ||||||
|  |                         on_close.emit(()); | ||||||
|  |                     } | ||||||
|  |                     Err(e) => { | ||||||
|  |                         is_loading.set(false); | ||||||
|  |                         error_message.set(Some(format!("Failed to add calendar: {}", e))); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             }); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_cancel = { | ||||||
|  |         let on_close = props.on_close.clone(); | ||||||
|  |         Callback::from(move |_| { | ||||||
|  |             on_close.emit(()); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |      | ||||||
|  |     let on_cancel_clone = on_cancel.clone(); | ||||||
|  |  | ||||||
|  |     if !props.is_open { | ||||||
|  |         return html! {}; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     html! { | ||||||
|  |         <div class="modal-overlay" onclick={on_cancel_clone}> | ||||||
|  |             <div class="modal-content" onclick={Callback::from(|e: MouseEvent| e.stop_propagation())}> | ||||||
|  |                 <div class="modal-header"> | ||||||
|  |                     <h3>{"Add External Calendar"}</h3> | ||||||
|  |                     <button class="modal-close" onclick={on_cancel.clone()}>{"×"}</button> | ||||||
|  |                 </div> | ||||||
|  |                  | ||||||
|  |                 <form onsubmit={on_submit}> | ||||||
|  |                     <div class="modal-body"> | ||||||
|  |                         { | ||||||
|  |                             if let Some(error) = (*error_message).as_ref() { | ||||||
|  |                                 html! { | ||||||
|  |                                     <div class="error-message"> | ||||||
|  |                                         {error} | ||||||
|  |                                     </div> | ||||||
|  |                                 } | ||||||
|  |                             } else { | ||||||
|  |                                 html! {} | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                          | ||||||
|  |                         <div class="form-group"> | ||||||
|  |                             <label for="external-calendar-name">{"Calendar Name"}</label> | ||||||
|  |                             <input | ||||||
|  |                                 ref={name_ref} | ||||||
|  |                                 id="external-calendar-name" | ||||||
|  |                                 type="text" | ||||||
|  |                                 placeholder="My External Calendar" | ||||||
|  |                                 disabled={*is_loading} | ||||||
|  |                                 required={true} | ||||||
|  |                             /> | ||||||
|  |                         </div> | ||||||
|  |  | ||||||
|  |                         <div class="form-group"> | ||||||
|  |                             <label for="external-calendar-url">{"ICS URL"}</label> | ||||||
|  |                             <input | ||||||
|  |                                 ref={url_ref} | ||||||
|  |                                 id="external-calendar-url" | ||||||
|  |                                 type="url" | ||||||
|  |                                 placeholder="https://example.com/calendar.ics" | ||||||
|  |                                 disabled={*is_loading} | ||||||
|  |                                 required={true} | ||||||
|  |                             /> | ||||||
|  |                             <small class="form-help"> | ||||||
|  |                                 {"Enter the public ICS URL for the calendar you want to add"} | ||||||
|  |                             </small> | ||||||
|  |                         </div> | ||||||
|  |  | ||||||
|  |                         <div class="form-group"> | ||||||
|  |                             <label for="external-calendar-color">{"Color"}</label> | ||||||
|  |                             <input | ||||||
|  |                                 ref={color_ref} | ||||||
|  |                                 id="external-calendar-color" | ||||||
|  |                                 type="color" | ||||||
|  |                                 value="#4285f4" | ||||||
|  |                                 disabled={*is_loading} | ||||||
|  |                             /> | ||||||
|  |                         </div> | ||||||
|  |                     </div> | ||||||
|  |  | ||||||
|  |                     <div class="modal-actions"> | ||||||
|  |                         <button | ||||||
|  |                             type="button" | ||||||
|  |                             class="btn btn-secondary" | ||||||
|  |                             onclick={on_cancel} | ||||||
|  |                             disabled={*is_loading} | ||||||
|  |                         > | ||||||
|  |                             {"Cancel"} | ||||||
|  |                         </button> | ||||||
|  |                         <button | ||||||
|  |                             type="submit" | ||||||
|  |                             class="btn btn-primary" | ||||||
|  |                             disabled={*is_loading} | ||||||
|  |                         > | ||||||
|  |                             { | ||||||
|  |                                 if *is_loading { | ||||||
|  |                                     "Adding..." | ||||||
|  |                                 } else { | ||||||
|  |                                     "Add Calendar" | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         </button> | ||||||
|  |                     </div> | ||||||
|  |                 </form> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -8,6 +8,7 @@ pub mod create_event_modal; | |||||||
| pub mod event_context_menu; | pub mod event_context_menu; | ||||||
| pub mod event_form; | pub mod event_form; | ||||||
| pub mod event_modal; | pub mod event_modal; | ||||||
|  | pub mod external_calendar_modal; | ||||||
| pub mod login; | pub mod login; | ||||||
| pub mod month_view; | pub mod month_view; | ||||||
| pub mod recurring_edit_modal; | pub mod recurring_edit_modal; | ||||||
| @@ -26,6 +27,7 @@ pub use create_event_modal::CreateEventModal; | |||||||
| pub use event_form::EventCreationData; | pub use event_form::EventCreationData; | ||||||
| pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu}; | pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu}; | ||||||
| pub use event_modal::EventModal; | pub use event_modal::EventModal; | ||||||
|  | pub use external_calendar_modal::ExternalCalendarModal; | ||||||
| pub use login::Login; | pub use login::Login; | ||||||
| pub use month_view::MonthView; | pub use month_view::MonthView; | ||||||
| pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal}; | pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal}; | ||||||
|   | |||||||
| @@ -20,6 +20,8 @@ pub struct RouteHandlerProps { | |||||||
|     pub user_info: Option<UserInfo>, |     pub user_info: Option<UserInfo>, | ||||||
|     pub on_login: Callback<String>, |     pub on_login: Callback<String>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|  |     pub external_calendar_events: Vec<VEvent>, | ||||||
|  |     #[prop_or_default] | ||||||
|     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, |     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>, |     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>, | ||||||
| @@ -48,6 +50,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | |||||||
|     let auth_token = props.auth_token.clone(); |     let auth_token = props.auth_token.clone(); | ||||||
|     let user_info = props.user_info.clone(); |     let user_info = props.user_info.clone(); | ||||||
|     let on_login = props.on_login.clone(); |     let on_login = props.on_login.clone(); | ||||||
|  |     let external_calendar_events = props.external_calendar_events.clone(); | ||||||
|     let on_event_context_menu = props.on_event_context_menu.clone(); |     let on_event_context_menu = props.on_event_context_menu.clone(); | ||||||
|     let on_calendar_context_menu = props.on_calendar_context_menu.clone(); |     let on_calendar_context_menu = props.on_calendar_context_menu.clone(); | ||||||
|     let view = props.view.clone(); |     let view = props.view.clone(); | ||||||
| @@ -60,6 +63,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | |||||||
|             let auth_token = auth_token.clone(); |             let auth_token = auth_token.clone(); | ||||||
|             let user_info = user_info.clone(); |             let user_info = user_info.clone(); | ||||||
|             let on_login = on_login.clone(); |             let on_login = on_login.clone(); | ||||||
|  |             let external_calendar_events = external_calendar_events.clone(); | ||||||
|             let on_event_context_menu = on_event_context_menu.clone(); |             let on_event_context_menu = on_event_context_menu.clone(); | ||||||
|             let on_calendar_context_menu = on_calendar_context_menu.clone(); |             let on_calendar_context_menu = on_calendar_context_menu.clone(); | ||||||
|             let view = view.clone(); |             let view = view.clone(); | ||||||
| @@ -87,6 +91,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | |||||||
|                         html! { |                         html! { | ||||||
|                             <CalendarView |                             <CalendarView | ||||||
|                                 user_info={user_info} |                                 user_info={user_info} | ||||||
|  |                                 external_calendar_events={external_calendar_events} | ||||||
|                                 on_event_context_menu={on_event_context_menu} |                                 on_event_context_menu={on_event_context_menu} | ||||||
|                                 on_calendar_context_menu={on_calendar_context_menu} |                                 on_calendar_context_menu={on_calendar_context_menu} | ||||||
|                                 view={view} |                                 view={view} | ||||||
| @@ -108,6 +113,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | |||||||
| pub struct CalendarViewProps { | pub struct CalendarViewProps { | ||||||
|     pub user_info: Option<UserInfo>, |     pub user_info: Option<UserInfo>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|  |     pub external_calendar_events: Vec<VEvent>, | ||||||
|  |     #[prop_or_default] | ||||||
|     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, |     pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>, |     pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>, | ||||||
| @@ -139,6 +146,7 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html { | |||||||
|         <div class="calendar-view"> |         <div class="calendar-view"> | ||||||
|             <Calendar |             <Calendar | ||||||
|                 user_info={props.user_info.clone()} |                 user_info={props.user_info.clone()} | ||||||
|  |                 external_calendar_events={props.external_calendar_events.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()} | ||||||
|                 view={props.view.clone()} |                 view={props.view.clone()} | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| use crate::components::CalendarListItem; | use crate::components::CalendarListItem; | ||||||
| use crate::services::calendar_service::UserInfo; | use crate::services::calendar_service::{UserInfo, ExternalCalendar}; | ||||||
| use web_sys::HtmlSelectElement; | use web_sys::HtmlSelectElement; | ||||||
| use yew::prelude::*; | use yew::prelude::*; | ||||||
| use yew_router::prelude::*; | use yew_router::prelude::*; | ||||||
| @@ -101,6 +101,9 @@ pub struct SidebarProps { | |||||||
|     pub user_info: Option<UserInfo>, |     pub user_info: Option<UserInfo>, | ||||||
|     pub on_logout: Callback<()>, |     pub on_logout: Callback<()>, | ||||||
|     pub on_create_calendar: Callback<()>, |     pub on_create_calendar: Callback<()>, | ||||||
|  |     pub on_create_external_calendar: Callback<()>, | ||||||
|  |     pub external_calendars: Vec<ExternalCalendar>, | ||||||
|  |     pub on_external_calendar_toggle: Callback<i32>, | ||||||
|     pub color_picker_open: Option<String>, |     pub color_picker_open: Option<String>, | ||||||
|     pub on_color_change: Callback<(String, String)>, |     pub on_color_change: Callback<(String, String)>, | ||||||
|     pub on_color_picker_toggle: Callback<String>, |     pub on_color_picker_toggle: Callback<String>, | ||||||
| @@ -206,11 +209,60 @@ pub fn sidebar(props: &SidebarProps) -> Html { | |||||||
|                     html! {} |                     html! {} | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |              | ||||||
|  |             // External calendars section | ||||||
|  |             <div class="external-calendar-list"> | ||||||
|  |                 <h3>{"External Calendars"}</h3> | ||||||
|  |                 { | ||||||
|  |                     if !props.external_calendars.is_empty() { | ||||||
|  |                         html! { | ||||||
|  |                             <ul class="external-calendar-items"> | ||||||
|  |                                 { | ||||||
|  |                                     props.external_calendars.iter().map(|cal| { | ||||||
|  |                                         let on_toggle = { | ||||||
|  |                                             let on_external_calendar_toggle = props.on_external_calendar_toggle.clone(); | ||||||
|  |                                             let cal_id = cal.id; | ||||||
|  |                                             Callback::from(move |_| { | ||||||
|  |                                                 on_external_calendar_toggle.emit(cal_id); | ||||||
|  |                                             }) | ||||||
|  |                                         }; | ||||||
|  |                                          | ||||||
|  |                                         html! { | ||||||
|  |                                             <li class="external-calendar-item"> | ||||||
|  |                                                 <div class="external-calendar-info"> | ||||||
|  |                                                     <input | ||||||
|  |                                                         type="checkbox" | ||||||
|  |                                                         checked={cal.is_visible} | ||||||
|  |                                                         onchange={on_toggle} | ||||||
|  |                                                     /> | ||||||
|  |                                                     <span  | ||||||
|  |                                                         class="external-calendar-color"  | ||||||
|  |                                                         style={format!("background-color: {}", cal.color)} | ||||||
|  |                                                     /> | ||||||
|  |                                                     <span class="external-calendar-name">{&cal.name}</span> | ||||||
|  |                                                     <span class="external-calendar-indicator">{"📅"}</span> | ||||||
|  |                                                 </div> | ||||||
|  |                                             </li> | ||||||
|  |                                         } | ||||||
|  |                                     }).collect::<Html>() | ||||||
|  |                                 } | ||||||
|  |                             </ul> | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         html! {} | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             </div> | ||||||
|  |              | ||||||
|             <div class="sidebar-footer"> |             <div class="sidebar-footer"> | ||||||
|                 <button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button"> |                 <button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button"> | ||||||
|                     {"+ Create Calendar"} |                     {"+ Create Calendar"} | ||||||
|                 </button> |                 </button> | ||||||
|                  |                  | ||||||
|  |                 <button onclick={props.on_create_external_calendar.reform(|_| ())} class="create-external-calendar-button"> | ||||||
|  |                     {"+ Add External Calendar"} | ||||||
|  |                 </button> | ||||||
|  |  | ||||||
|                 <div class="view-selector"> |                 <div class="view-selector"> | ||||||
|                     <select class="view-selector-dropdown" onchange={on_view_change}> |                     <select class="view-selector-dropdown" onchange={on_view_change}> | ||||||
|                         <option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option> |                         <option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option> | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday}; | use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday}; | ||||||
|  | use gloo_storage::{LocalStorage, Storage}; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use wasm_bindgen::JsCast; | use wasm_bindgen::JsCast; | ||||||
| @@ -1854,4 +1855,256 @@ impl CalendarService { | |||||||
|  |  | ||||||
|         None |         None | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // ==================== EXTERNAL CALENDAR METHODS ==================== | ||||||
|  |  | ||||||
|  |     pub async fn get_external_calendars() -> Result<Vec<ExternalCalendar>, String> { | ||||||
|  |         let token = LocalStorage::get::<String>("auth_token") | ||||||
|  |             .map_err(|_| "No authentication token found".to_string())?; | ||||||
|  |  | ||||||
|  |         let window = web_sys::window().ok_or("No global window exists")?; | ||||||
|  |  | ||||||
|  |         let opts = RequestInit::new(); | ||||||
|  |         opts.set_method("GET"); | ||||||
|  |         opts.set_mode(RequestMode::Cors); | ||||||
|  |  | ||||||
|  |         let service = Self::new(); | ||||||
|  |         let url = format!("{}/external-calendars", service.base_url); | ||||||
|  |         let request = Request::new_with_str_and_init(&url, &opts) | ||||||
|  |             .map_err(|e| format!("Request creation failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         request | ||||||
|  |             .headers() | ||||||
|  |             .set("Authorization", &format!("Bearer {}", token)) | ||||||
|  |             .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let resp_value = JsFuture::from(window.fetch_with_request(&request)) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| format!("Request failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let resp: Response = resp_value | ||||||
|  |             .dyn_into() | ||||||
|  |             .map_err(|e| format!("Response casting failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         if !resp.ok() { | ||||||
|  |             return Err(format!("HTTP error: {}", resp.status())); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let json = JsFuture::from(resp.json().unwrap()) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| format!("JSON parsing failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let external_calendars: Vec<ExternalCalendar> = serde_wasm_bindgen::from_value(json) | ||||||
|  |             .map_err(|e| format!("Deserialization failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         Ok(external_calendars) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn create_external_calendar(name: &str, url: &str, color: &str) -> Result<ExternalCalendar, String> { | ||||||
|  |         let token = LocalStorage::get::<String>("auth_token") | ||||||
|  |             .map_err(|_| "No authentication token found".to_string())?; | ||||||
|  |  | ||||||
|  |         let window = web_sys::window().ok_or("No global window exists")?; | ||||||
|  |  | ||||||
|  |         let opts = RequestInit::new(); | ||||||
|  |         opts.set_method("POST"); | ||||||
|  |         opts.set_mode(RequestMode::Cors); | ||||||
|  |  | ||||||
|  |         let body = serde_json::json!({ | ||||||
|  |             "name": name, | ||||||
|  |             "url": url, | ||||||
|  |             "color": color | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         let service = Self::new(); | ||||||
|  |         let body_string = serde_json::to_string(&body) | ||||||
|  |             .map_err(|e| format!("JSON serialization failed: {}", e))?; | ||||||
|  |         opts.set_body(&body_string.into()); | ||||||
|  |  | ||||||
|  |         let url = format!("{}/external-calendars", service.base_url); | ||||||
|  |         let request = Request::new_with_str_and_init(&url, &opts) | ||||||
|  |             .map_err(|e| format!("Request creation failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         request | ||||||
|  |             .headers() | ||||||
|  |             .set("Authorization", &format!("Bearer {}", token)) | ||||||
|  |             .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         request | ||||||
|  |             .headers() | ||||||
|  |             .set("Content-Type", "application/json") | ||||||
|  |             .map_err(|e| format!("Content-Type header setting failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let resp_value = JsFuture::from(window.fetch_with_request(&request)) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| format!("Request failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let resp: Response = resp_value | ||||||
|  |             .dyn_into() | ||||||
|  |             .map_err(|e| format!("Response casting failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         if !resp.ok() { | ||||||
|  |             return Err(format!("HTTP error: {}", resp.status())); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let json = JsFuture::from(resp.json().unwrap()) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| format!("JSON parsing failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let external_calendar: ExternalCalendar = serde_wasm_bindgen::from_value(json) | ||||||
|  |             .map_err(|e| format!("Deserialization failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         Ok(external_calendar) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn update_external_calendar( | ||||||
|  |         id: i32, | ||||||
|  |         name: &str, | ||||||
|  |         url: &str, | ||||||
|  |         color: &str, | ||||||
|  |         is_visible: bool, | ||||||
|  |     ) -> Result<(), String> { | ||||||
|  |         let token = LocalStorage::get::<String>("auth_token") | ||||||
|  |             .map_err(|_| "No authentication token found".to_string())?; | ||||||
|  |  | ||||||
|  |         let window = web_sys::window().ok_or("No global window exists")?; | ||||||
|  |  | ||||||
|  |         let opts = RequestInit::new(); | ||||||
|  |         opts.set_method("POST"); | ||||||
|  |         opts.set_mode(RequestMode::Cors); | ||||||
|  |  | ||||||
|  |         let body = serde_json::json!({ | ||||||
|  |             "name": name, | ||||||
|  |             "url": url, | ||||||
|  |             "color": color, | ||||||
|  |             "is_visible": is_visible | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         let service = Self::new(); | ||||||
|  |         let body_string = serde_json::to_string(&body) | ||||||
|  |             .map_err(|e| format!("JSON serialization failed: {}", e))?; | ||||||
|  |         opts.set_body(&body_string.into()); | ||||||
|  |  | ||||||
|  |         let url = format!("{}/external-calendars/{}", service.base_url, id); | ||||||
|  |         let request = Request::new_with_str_and_init(&url, &opts) | ||||||
|  |             .map_err(|e| format!("Request creation failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         request | ||||||
|  |             .headers() | ||||||
|  |             .set("Authorization", &format!("Bearer {}", token)) | ||||||
|  |             .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         request | ||||||
|  |             .headers() | ||||||
|  |             .set("Content-Type", "application/json") | ||||||
|  |             .map_err(|e| format!("Content-Type header setting failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let resp_value = JsFuture::from(window.fetch_with_request(&request)) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| format!("Request failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let resp: Response = resp_value | ||||||
|  |             .dyn_into() | ||||||
|  |             .map_err(|e| format!("Response casting failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         if !resp.ok() { | ||||||
|  |             return Err(format!("HTTP error: {}", resp.status())); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn delete_external_calendar(id: i32) -> Result<(), String> { | ||||||
|  |         let token = LocalStorage::get::<String>("auth_token") | ||||||
|  |             .map_err(|_| "No authentication token found".to_string())?; | ||||||
|  |  | ||||||
|  |         let window = web_sys::window().ok_or("No global window exists")?; | ||||||
|  |  | ||||||
|  |         let opts = RequestInit::new(); | ||||||
|  |         opts.set_method("DELETE"); | ||||||
|  |         opts.set_mode(RequestMode::Cors); | ||||||
|  |  | ||||||
|  |         let service = Self::new(); | ||||||
|  |         let url = format!("{}/external-calendars/{}", service.base_url, id); | ||||||
|  |         let request = Request::new_with_str_and_init(&url, &opts) | ||||||
|  |             .map_err(|e| format!("Request creation failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         request | ||||||
|  |             .headers() | ||||||
|  |             .set("Authorization", &format!("Bearer {}", token)) | ||||||
|  |             .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let resp_value = JsFuture::from(window.fetch_with_request(&request)) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| format!("Request failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let resp: Response = resp_value | ||||||
|  |             .dyn_into() | ||||||
|  |             .map_err(|e| format!("Response casting failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         if !resp.ok() { | ||||||
|  |             return Err(format!("HTTP error: {}", resp.status())); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn fetch_external_calendar_events(id: i32) -> Result<Vec<VEvent>, String> { | ||||||
|  |         let token = LocalStorage::get::<String>("auth_token") | ||||||
|  |             .map_err(|_| "No authentication token found".to_string())?; | ||||||
|  |  | ||||||
|  |         let window = web_sys::window().ok_or("No global window exists")?; | ||||||
|  |  | ||||||
|  |         let opts = RequestInit::new(); | ||||||
|  |         opts.set_method("GET"); | ||||||
|  |         opts.set_mode(RequestMode::Cors); | ||||||
|  |  | ||||||
|  |         let service = Self::new(); | ||||||
|  |         let url = format!("{}/external-calendars/{}/events", service.base_url, id); | ||||||
|  |         let request = Request::new_with_str_and_init(&url, &opts) | ||||||
|  |             .map_err(|e| format!("Request creation failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         request | ||||||
|  |             .headers() | ||||||
|  |             .set("Authorization", &format!("Bearer {}", token)) | ||||||
|  |             .map_err(|e| format!("Authorization header setting failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let resp_value = JsFuture::from(window.fetch_with_request(&request)) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| format!("Request failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         let resp: Response = resp_value | ||||||
|  |             .dyn_into() | ||||||
|  |             .map_err(|e| format!("Response casting failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         if !resp.ok() { | ||||||
|  |             return Err(format!("HTTP error: {}", resp.status())); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let json = JsFuture::from(resp.json().unwrap()) | ||||||
|  |             .await | ||||||
|  |             .map_err(|e| format!("JSON parsing failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         #[derive(Deserialize)] | ||||||
|  |         struct ExternalCalendarEventsResponse { | ||||||
|  |             events: Vec<VEvent>, | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json) | ||||||
|  |             .map_err(|e| format!("Deserialization failed: {:?}", e))?; | ||||||
|  |  | ||||||
|  |         Ok(response.events) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||||
|  | pub struct ExternalCalendar { | ||||||
|  |     pub id: i32, | ||||||
|  |     pub name: String, | ||||||
|  |     pub url: String, | ||||||
|  |     pub color: String, | ||||||
|  |     pub is_visible: bool, | ||||||
|  |     pub created_at: chrono::DateTime<chrono::Utc>, | ||||||
|  |     pub updated_at: chrono::DateTime<chrono::Utc>, | ||||||
|  |     pub last_fetched: Option<chrono::DateTime<chrono::Utc>>, | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user