Compare commits
	
		
			5 Commits
		
	
	
		
			51d5552156
			...
			feature/sq
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 0453763c98 | ||
|   | 03c0011445 | ||
|   | 79f287ed61 | ||
|   | e55e6bf4dd | ||
| 1fa3bf44b6 | 
							
								
								
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -22,3 +22,9 @@ dist/ | ||||
| CLAUDE.md | ||||
|  | ||||
| data/ | ||||
|  | ||||
| # SQLite database | ||||
| *.db | ||||
| *.db-shm | ||||
| *.db-wal | ||||
| calendar.db | ||||
|   | ||||
| @@ -34,6 +34,10 @@ base64 = "0.21" | ||||
| thiserror = "1.0" | ||||
| lazy_static = "1.4" | ||||
|  | ||||
| # Database dependencies | ||||
| sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "uuid", "chrono", "json"] } | ||||
| tokio-rusqlite = "0.5" | ||||
|  | ||||
| [dev-dependencies] | ||||
| tokio = { version = "1.0", features = ["macros", "rt"] } | ||||
| reqwest = { version = "0.11", features = ["json"] } | ||||
|   | ||||
							
								
								
									
										8
									
								
								backend/migrations/001_create_users_table.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								backend/migrations/001_create_users_table.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| -- Create users table | ||||
| CREATE TABLE IF NOT EXISTS users ( | ||||
|     id TEXT PRIMARY KEY, | ||||
|     username TEXT NOT NULL, | ||||
|     server_url TEXT NOT NULL, | ||||
|     created_at TEXT NOT NULL, | ||||
|     UNIQUE(username, server_url) | ||||
| ); | ||||
							
								
								
									
										16
									
								
								backend/migrations/002_create_sessions_table.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								backend/migrations/002_create_sessions_table.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| -- Create sessions table | ||||
| CREATE TABLE IF NOT EXISTS sessions ( | ||||
|     id TEXT PRIMARY KEY, | ||||
|     user_id TEXT NOT NULL, | ||||
|     token TEXT NOT NULL UNIQUE, | ||||
|     created_at TEXT NOT NULL, | ||||
|     expires_at TEXT NOT NULL, | ||||
|     last_accessed TEXT NOT NULL, | ||||
|     FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE | ||||
| ); | ||||
|  | ||||
| -- Index for faster token lookups | ||||
| CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); | ||||
|  | ||||
| -- Index for cleanup of expired sessions | ||||
| CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); | ||||
							
								
								
									
										11
									
								
								backend/migrations/003_create_user_preferences_table.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								backend/migrations/003_create_user_preferences_table.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| -- Create user preferences table | ||||
| CREATE TABLE IF NOT EXISTS user_preferences ( | ||||
|     user_id TEXT PRIMARY KEY, | ||||
|     calendar_selected_date TEXT, | ||||
|     calendar_time_increment INTEGER, | ||||
|     calendar_view_mode TEXT, | ||||
|     calendar_theme TEXT, | ||||
|     calendar_colors TEXT, -- JSON string for calendar color mappings | ||||
|     updated_at TEXT NOT NULL, | ||||
|     FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE | ||||
| ); | ||||
| @@ -1,27 +1,30 @@ | ||||
| use chrono::{Duration, Utc}; | ||||
| use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use uuid::Uuid; | ||||
|  | ||||
| use crate::models::{CalDAVLoginRequest, AuthResponse, ApiError}; | ||||
| use crate::config::CalDAVConfig; | ||||
| use crate::calendar::CalDAVClient; | ||||
| use crate::config::CalDAVConfig; | ||||
| use crate::db::{Database, PreferencesRepository, Session, SessionRepository, UserRepository}; | ||||
| use crate::models::{ApiError, AuthResponse, CalDAVLoginRequest, UserPreferencesResponse}; | ||||
|  | ||||
| #[derive(Debug, Serialize, Deserialize)] | ||||
| pub struct Claims { | ||||
|     pub username: String, | ||||
|     pub server_url: String, | ||||
|     pub exp: i64,     // Expiration time | ||||
|     pub iat: i64,     // Issued at | ||||
|     pub exp: i64, // Expiration time | ||||
|     pub iat: i64, // Issued at | ||||
| } | ||||
|  | ||||
| #[derive(Clone)] | ||||
| pub struct AuthService { | ||||
|     jwt_secret: String, | ||||
|     db: Database, | ||||
| } | ||||
|  | ||||
| impl AuthService { | ||||
|     pub fn new(jwt_secret: String) -> Self { | ||||
|         Self { jwt_secret } | ||||
|     pub fn new(jwt_secret: String, db: Database) -> Self { | ||||
|         Self { jwt_secret, db } | ||||
|     } | ||||
|  | ||||
|     /// Authenticate user directly against CalDAV server | ||||
| @@ -31,36 +34,73 @@ impl AuthService { | ||||
|         println!("✅ Input validation passed"); | ||||
|  | ||||
|         // Create CalDAV config with provided credentials | ||||
|         let caldav_config = CalDAVConfig { | ||||
|             server_url: request.server_url.clone(), | ||||
|             username: request.username.clone(), | ||||
|             password: request.password.clone(), | ||||
|             calendar_path: None, | ||||
|             tasks_path: None, | ||||
|         }; | ||||
|         let caldav_config = CalDAVConfig::new( | ||||
|             request.server_url.clone(), | ||||
|             request.username.clone(), | ||||
|             request.password.clone(), | ||||
|         ); | ||||
|         println!("📝 Created CalDAV config"); | ||||
|  | ||||
|         // Test authentication against CalDAV server | ||||
|         let caldav_client = CalDAVClient::new(caldav_config.clone()); | ||||
|         println!("🔗 Created CalDAV client, attempting to discover calendars..."); | ||||
|          | ||||
|  | ||||
|         // Try to discover calendars as an authentication test | ||||
|         match caldav_client.discover_calendars().await { | ||||
|             Ok(calendars) => { | ||||
|                 println!("✅ Authentication successful! Found {} calendars", calendars.len()); | ||||
|                 // Authentication successful, generate JWT token | ||||
|                 let token = self.generate_token(&request.username, &request.server_url)?; | ||||
|                 println!( | ||||
|                     "✅ Authentication successful! Found {} calendars", | ||||
|                     calendars.len() | ||||
|                 ); | ||||
|                  | ||||
|                 // Find or create user in database | ||||
|                 let user_repo = UserRepository::new(&self.db); | ||||
|                 let user = user_repo | ||||
|                     .find_or_create(&request.username, &request.server_url) | ||||
|                     .await | ||||
|                     .map_err(|e| ApiError::Database(format!("Failed to create user: {}", e)))?; | ||||
|                  | ||||
|                 // Generate JWT token | ||||
|                 let jwt_token = self.generate_token(&request.username, &request.server_url)?; | ||||
|                  | ||||
|                 // Generate session token | ||||
|                 let session_token = format!("sess_{}", Uuid::new_v4()); | ||||
|                  | ||||
|                 // Create session in database | ||||
|                 let session = Session::new(user.id.clone(), session_token.clone(), 24); | ||||
|                 let session_repo = SessionRepository::new(&self.db); | ||||
|                 session_repo | ||||
|                     .create(&session) | ||||
|                     .await | ||||
|                     .map_err(|e| ApiError::Database(format!("Failed to create session: {}", e)))?; | ||||
|                  | ||||
|                 // Get or create user preferences | ||||
|                 let prefs_repo = PreferencesRepository::new(&self.db); | ||||
|                 let preferences = prefs_repo | ||||
|                     .get_or_create(&user.id) | ||||
|                     .await | ||||
|                     .map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?; | ||||
|                  | ||||
|                 Ok(AuthResponse { | ||||
|                     token, | ||||
|                     token: jwt_token, | ||||
|                     session_token, | ||||
|                     username: request.username, | ||||
|                     server_url: request.server_url, | ||||
|                     preferences: UserPreferencesResponse { | ||||
|                         calendar_selected_date: preferences.calendar_selected_date, | ||||
|                         calendar_time_increment: preferences.calendar_time_increment, | ||||
|                         calendar_view_mode: preferences.calendar_view_mode, | ||||
|                         calendar_theme: preferences.calendar_theme, | ||||
|                         calendar_colors: preferences.calendar_colors, | ||||
|                     }, | ||||
|                 }) | ||||
|             } | ||||
|             Err(err) => { | ||||
|                 println!("❌ Authentication failed: {:?}", err); | ||||
|                 // Authentication failed | ||||
|                 Err(ApiError::Unauthorized("Invalid CalDAV credentials or server unavailable".to_string())) | ||||
|                 Err(ApiError::Unauthorized( | ||||
|                     "Invalid CalDAV credentials or server unavailable".to_string(), | ||||
|                 )) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| @@ -71,16 +111,18 @@ impl AuthService { | ||||
|     } | ||||
|  | ||||
|     /// 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)?; | ||||
|          | ||||
|         Ok(CalDAVConfig { | ||||
|             server_url: claims.server_url, | ||||
|             username: claims.username, | ||||
|             password: password.to_string(), | ||||
|             calendar_path: None, | ||||
|             tasks_path: None, | ||||
|         }) | ||||
|  | ||||
|         Ok(CalDAVConfig::new( | ||||
|             claims.server_url, | ||||
|             claims.username, | ||||
|             password.to_string(), | ||||
|         )) | ||||
|     } | ||||
|  | ||||
|     fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> { | ||||
| @@ -97,8 +139,11 @@ impl AuthService { | ||||
|         } | ||||
|  | ||||
|         // Basic URL validation | ||||
|         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())); | ||||
|         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(), | ||||
|             )); | ||||
|         } | ||||
|  | ||||
|         Ok(()) | ||||
| @@ -135,4 +180,33 @@ impl AuthService { | ||||
|  | ||||
|         Ok(token_data.claims) | ||||
|     } | ||||
| } | ||||
|      | ||||
|     /// Validate session token | ||||
|     pub async fn validate_session(&self, session_token: &str) -> Result<String, ApiError> { | ||||
|         let session_repo = SessionRepository::new(&self.db); | ||||
|          | ||||
|         let session = session_repo | ||||
|             .find_by_token(session_token) | ||||
|             .await | ||||
|             .map_err(|e| ApiError::Database(format!("Failed to find session: {}", e)))? | ||||
|             .ok_or_else(|| ApiError::Unauthorized("Invalid session token".to_string()))?; | ||||
|          | ||||
|         if session.is_expired() { | ||||
|             return Err(ApiError::Unauthorized("Session expired".to_string())); | ||||
|         } | ||||
|          | ||||
|         Ok(session.user_id) | ||||
|     } | ||||
|      | ||||
|     /// Logout user by deleting session | ||||
|     pub async fn logout(&self, session_token: &str) -> Result<(), ApiError> { | ||||
|         let session_repo = SessionRepository::new(&self.db); | ||||
|          | ||||
|         session_repo | ||||
|             .delete(session_token) | ||||
|             .await | ||||
|             .map_err(|e| ApiError::Database(format!("Failed to delete session: {}", e)))?; | ||||
|          | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,132 +1,97 @@ | ||||
| use base64::prelude::*; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use std::env; | ||||
| use base64::prelude::*; | ||||
|  | ||||
| /// Configuration for CalDAV server connection and authentication. | ||||
| ///  | ||||
| /// | ||||
| /// This struct holds all the necessary information to connect to a CalDAV server, | ||||
| /// including server URL, credentials, and optional collection paths. | ||||
| ///  | ||||
| /// | ||||
| /// # Security Note | ||||
| ///  | ||||
| /// | ||||
| /// The password field contains sensitive information and should be handled carefully. | ||||
| /// This struct implements `Debug` but in production, consider implementing a custom | ||||
| /// `Debug` that masks the password field. | ||||
| ///  | ||||
| /// | ||||
| /// # Example | ||||
| ///  | ||||
| /// | ||||
| /// ```rust | ||||
| /// # use calendar_backend::config::CalDAVConfig; | ||||
| /// # fn example() -> Result<(), Box<dyn std::error::Error>> { | ||||
| /// // Load configuration from environment variables | ||||
| /// let config = CalDAVConfig::from_env()?; | ||||
| ///  | ||||
| /// let config = CalDAVConfig { | ||||
| ///     server_url: "https://caldav.example.com".to_string(), | ||||
| ///     username: "user@example.com".to_string(), | ||||
| ///     password: "password".to_string(), | ||||
| ///     calendar_path: None, | ||||
| ///     tasks_path: None, | ||||
| /// }; | ||||
| /// | ||||
| /// // Use the configuration for HTTP requests | ||||
| /// let auth_header = format!("Basic {}", config.get_basic_auth()); | ||||
| /// # Ok(()) | ||||
| /// # } | ||||
| /// ``` | ||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| pub struct CalDAVConfig { | ||||
|     /// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/") | ||||
|     pub server_url: String, | ||||
|      | ||||
|  | ||||
|     /// Username for authentication with the CalDAV server | ||||
|     pub username: String, | ||||
|      | ||||
|  | ||||
|     /// Password for authentication with the CalDAV server | ||||
|     ///  | ||||
|     /// | ||||
|     /// **Security Note**: This contains sensitive information | ||||
|     pub password: String, | ||||
|      | ||||
|  | ||||
|     /// Optional path to the calendar collection on the server | ||||
|     ///  | ||||
|     /// If not provided, the client will need to discover available calendars | ||||
|     /// | ||||
|     /// If not provided, the client will discover available calendars | ||||
|     /// through CalDAV PROPFIND requests | ||||
|     pub calendar_path: Option<String>, | ||||
|      | ||||
|     /// Optional path to the tasks/todo collection on the server | ||||
|     ///  | ||||
|     /// Some CalDAV servers store tasks separately from calendar events | ||||
|     pub tasks_path: Option<String>, | ||||
| } | ||||
|  | ||||
| impl CalDAVConfig { | ||||
|     /// Creates a new CalDAVConfig by loading values from environment variables. | ||||
|     ///  | ||||
|     /// This method will attempt to load a `.env` file from the current directory | ||||
|     /// and then read the following required environment variables: | ||||
|     ///  | ||||
|     /// - `CALDAV_SERVER_URL`: The CalDAV server base URL | ||||
|     /// - `CALDAV_USERNAME`: Username for authentication | ||||
|     /// - `CALDAV_PASSWORD`: Password for authentication | ||||
|     ///  | ||||
|     /// Optional environment variables: | ||||
|     ///  | ||||
|     /// - `CALDAV_CALENDAR_PATH`: Path to calendar collection | ||||
|     /// - `CALDAV_TASKS_PATH`: Path to tasks collection | ||||
|     ///  | ||||
|     /// # Errors | ||||
|     ///  | ||||
|     /// Returns `ConfigError::MissingVar` if any required environment variable | ||||
|     /// is not set or cannot be read. | ||||
|     ///  | ||||
|     /// Creates a new CalDAVConfig with the given credentials. | ||||
|     /// | ||||
|     /// # Arguments | ||||
|     /// | ||||
|     /// * `server_url` - The base URL of the CalDAV server | ||||
|     /// * `username` - Username for authentication | ||||
|     /// * `password` - Password for authentication | ||||
|     /// | ||||
|     /// # Example | ||||
|     ///  | ||||
|     /// | ||||
|     /// ```rust | ||||
|     /// # use calendar_backend::config::CalDAVConfig; | ||||
|     ///  | ||||
|     /// match CalDAVConfig::from_env() { | ||||
|     ///     Ok(config) => { | ||||
|     ///         println!("Loaded config for server: {}", config.server_url); | ||||
|     ///     } | ||||
|     ///     Err(e) => { | ||||
|     ///         eprintln!("Failed to load config: {}", e); | ||||
|     ///     } | ||||
|     /// } | ||||
|     /// let config = CalDAVConfig::new( | ||||
|     ///     "https://caldav.example.com".to_string(), | ||||
|     ///     "user@example.com".to_string(), | ||||
|     ///     "password".to_string() | ||||
|     /// ); | ||||
|     /// ``` | ||||
|     pub fn from_env() -> Result<Self, ConfigError> { | ||||
|         // Attempt to load .env file, but don't fail if it doesn't exist | ||||
|         dotenvy::dotenv().ok(); | ||||
|  | ||||
|         let server_url = env::var("CALDAV_SERVER_URL") | ||||
|             .map_err(|_| ConfigError::MissingVar("CALDAV_SERVER_URL".to_string()))?; | ||||
|  | ||||
|         let username = env::var("CALDAV_USERNAME") | ||||
|             .map_err(|_| ConfigError::MissingVar("CALDAV_USERNAME".to_string()))?; | ||||
|  | ||||
|         let password = env::var("CALDAV_PASSWORD") | ||||
|             .map_err(|_| ConfigError::MissingVar("CALDAV_PASSWORD".to_string()))?; | ||||
|  | ||||
|         // Optional paths - it's fine if these are not set | ||||
|         let calendar_path = env::var("CALDAV_CALENDAR_PATH").ok(); | ||||
|         let tasks_path = env::var("CALDAV_TASKS_PATH").ok(); | ||||
|  | ||||
|         Ok(CalDAVConfig { | ||||
|     pub fn new(server_url: String, username: String, password: String) -> Self { | ||||
|         Self { | ||||
|             server_url, | ||||
|             username, | ||||
|             password, | ||||
|             calendar_path, | ||||
|             tasks_path, | ||||
|         }) | ||||
|             calendar_path: env::var("CALDAV_CALENDAR_PATH").ok(), // Optional override from env | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Generates a Base64-encoded string for HTTP Basic Authentication. | ||||
|     ///  | ||||
|     /// | ||||
|     /// This method combines the username and password in the format | ||||
|     /// `username:password` and encodes it using Base64, which is the | ||||
|     /// standard format for the `Authorization: Basic` HTTP header. | ||||
|     ///  | ||||
|     /// | ||||
|     /// # Returns | ||||
|     ///  | ||||
|     /// | ||||
|     /// A Base64-encoded string that can be used directly in the | ||||
|     /// `Authorization` header: `Authorization: Basic <returned_value>` | ||||
|     ///  | ||||
|     /// | ||||
|     /// # Example | ||||
|     ///  | ||||
|     /// | ||||
|     /// ```rust | ||||
|     /// # use calendar_backend::config::CalDAVConfig; | ||||
|     ///  | ||||
|     /// | ||||
|     /// let config = CalDAVConfig { | ||||
|     ///     server_url: "https://example.com".to_string(), | ||||
|     ///     username: "user".to_string(), | ||||
| @@ -134,7 +99,7 @@ impl CalDAVConfig { | ||||
|     ///     calendar_path: None, | ||||
|     ///     tasks_path: None, | ||||
|     /// }; | ||||
|     ///  | ||||
|     /// | ||||
|     /// let auth_value = config.get_basic_auth(); | ||||
|     /// let auth_header = format!("Basic {}", auth_value); | ||||
|     /// ``` | ||||
| @@ -148,15 +113,15 @@ impl CalDAVConfig { | ||||
| #[derive(Debug, thiserror::Error)] | ||||
| pub enum ConfigError { | ||||
|     /// A required environment variable is missing or cannot be read. | ||||
|     ///  | ||||
|     /// | ||||
|     /// This error occurs when calling `CalDAVConfig::from_env()` and one of the | ||||
|     /// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`, | ||||
|     /// or `CALDAV_PASSWORD`) is not set. | ||||
|     #[error("Missing environment variable: {0}")] | ||||
|     MissingVar(String), | ||||
|      | ||||
|  | ||||
|     /// The configuration contains invalid or malformed values. | ||||
|     ///  | ||||
|     /// | ||||
|     /// This could include malformed URLs, invalid authentication credentials, | ||||
|     /// or other configuration issues that prevent proper CalDAV operation. | ||||
|     #[error("Invalid configuration: {0}")] | ||||
| @@ -174,7 +139,6 @@ mod tests { | ||||
|             username: "testuser".to_string(), | ||||
|             password: "testpass".to_string(), | ||||
|             calendar_path: None, | ||||
|             tasks_path: None, | ||||
|         }; | ||||
|  | ||||
|         let auth = config.get_basic_auth(); | ||||
| @@ -183,18 +147,21 @@ mod tests { | ||||
|     } | ||||
|  | ||||
|     /// Integration test that authenticates with the actual Baikal CalDAV server | ||||
|     ///  | ||||
|     /// | ||||
|     /// This test requires a valid .env file with: | ||||
|     /// - CALDAV_SERVER_URL | ||||
|     /// - CALDAV_USERNAME   | ||||
|     /// - CALDAV_PASSWORD | ||||
|     ///  | ||||
|     /// | ||||
|     /// Run with: `cargo test test_baikal_auth` | ||||
|     #[tokio::test] | ||||
|     async fn test_baikal_auth() { | ||||
|         // Load config from .env | ||||
|         let config = CalDAVConfig::from_env() | ||||
|             .expect("Failed to load CalDAV config from environment"); | ||||
|         // Use test config - update these values to test with real server | ||||
|         let config = CalDAVConfig::new( | ||||
|             "https://example.com".to_string(), | ||||
|             "test_user".to_string(), | ||||
|             "test_password".to_string(), | ||||
|         ); | ||||
|  | ||||
|         println!("Testing authentication to: {}", config.server_url); | ||||
|  | ||||
| @@ -204,7 +171,10 @@ mod tests { | ||||
|         // Make a simple OPTIONS request to test authentication | ||||
|         let response = client | ||||
|             .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") | ||||
|             .send() | ||||
|             .await | ||||
| @@ -222,9 +192,9 @@ mod tests { | ||||
|  | ||||
|         // For Baikal/CalDAV servers, we should see DAV headers | ||||
|         assert!( | ||||
|             response.headers().contains_key("dav") ||  | ||||
|             response.headers().contains_key("DAV") || | ||||
|             response.status().is_success(), | ||||
|             response.headers().contains_key("dav") | ||||
|                 || response.headers().contains_key("DAV") | ||||
|                 || response.status().is_success(), | ||||
|             "Server doesn't appear to be a CalDAV server - missing DAV headers" | ||||
|         ); | ||||
|  | ||||
| @@ -232,14 +202,18 @@ mod tests { | ||||
|     } | ||||
|  | ||||
|     /// Test making a PROPFIND request to discover calendars | ||||
|     ///  | ||||
|     /// | ||||
|     /// This test requires a valid .env file and makes an actual CalDAV PROPFIND request | ||||
|     ///  | ||||
|     /// | ||||
|     /// Run with: `cargo test test_propfind_calendars` | ||||
|     #[tokio::test] | ||||
|     async fn test_propfind_calendars() { | ||||
|         let config = CalDAVConfig::from_env() | ||||
|             .expect("Failed to load CalDAV config from environment"); | ||||
|         // Use test config - update these values to test with real server | ||||
|         let config = CalDAVConfig::new( | ||||
|             "https://example.com".to_string(), | ||||
|             "test_user".to_string(), | ||||
|             "test_password".to_string(), | ||||
|         ); | ||||
|  | ||||
|         let client = reqwest::Client::new(); | ||||
|  | ||||
| @@ -255,8 +229,14 @@ mod tests { | ||||
| </d:propfind>"#; | ||||
|  | ||||
|         let response = client | ||||
|             .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url) | ||||
|             .header("Authorization", format!("Basic {}", config.get_basic_auth())) | ||||
|             .request( | ||||
|                 reqwest::Method::from_bytes(b"PROPFIND").unwrap(), | ||||
|                 &config.server_url, | ||||
|             ) | ||||
|             .header( | ||||
|                 "Authorization", | ||||
|                 format!("Basic {}", config.get_basic_auth()), | ||||
|             ) | ||||
|             .header("Content-Type", "application/xml") | ||||
|             .header("Depth", "1") | ||||
|             .header("User-Agent", "calendar-app/0.1.0") | ||||
| @@ -267,7 +247,7 @@ mod tests { | ||||
|  | ||||
|         let status = response.status(); | ||||
|         println!("PROPFIND Response status: {}", status); | ||||
|          | ||||
|  | ||||
|         let body = response.text().await.expect("Failed to read response body"); | ||||
|         println!("PROPFIND Response body: {}", body); | ||||
|  | ||||
| @@ -279,8 +259,11 @@ mod tests { | ||||
|         ); | ||||
|  | ||||
|         // 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!"); | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
							
								
								
									
										305
									
								
								backend/src/db.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										305
									
								
								backend/src/db.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,305 @@ | ||||
| use chrono::{DateTime, Utc}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; | ||||
| use sqlx::{FromRow, Result}; | ||||
| use std::sync::Arc; | ||||
| use uuid::Uuid; | ||||
|  | ||||
| /// Database connection pool wrapper | ||||
| #[derive(Clone)] | ||||
| pub struct Database { | ||||
|     pool: Arc<SqlitePool>, | ||||
| } | ||||
|  | ||||
| impl Database { | ||||
|     /// Create a new database connection pool | ||||
|     pub async fn new(database_url: &str) -> Result<Self> { | ||||
|         let pool = SqlitePoolOptions::new() | ||||
|             .max_connections(5) | ||||
|             .connect(database_url) | ||||
|             .await?; | ||||
|  | ||||
|         // Run migrations | ||||
|         sqlx::migrate!("./migrations").run(&pool).await?; | ||||
|  | ||||
|         Ok(Self { | ||||
|             pool: Arc::new(pool), | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     /// Get a reference to the connection pool | ||||
|     pub fn pool(&self) -> &SqlitePool { | ||||
|         &self.pool | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// User model representing a CalDAV user | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] | ||||
| pub struct User { | ||||
|     pub id: String, // UUID as string for SQLite | ||||
|     pub username: String, | ||||
|     pub server_url: String, | ||||
|     pub created_at: DateTime<Utc>, | ||||
| } | ||||
|  | ||||
| impl User { | ||||
|     /// Create a new user with generated UUID | ||||
|     pub fn new(username: String, server_url: String) -> Self { | ||||
|         Self { | ||||
|             id: Uuid::new_v4().to_string(), | ||||
|             username, | ||||
|             server_url, | ||||
|             created_at: Utc::now(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Session model for user sessions | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] | ||||
| pub struct Session { | ||||
|     pub id: String,      // UUID as string | ||||
|     pub user_id: String, // Foreign key to User | ||||
|     pub token: String,   // Session token | ||||
|     pub created_at: DateTime<Utc>, | ||||
|     pub expires_at: DateTime<Utc>, | ||||
|     pub last_accessed: DateTime<Utc>, | ||||
| } | ||||
|  | ||||
| impl Session { | ||||
|     /// Create a new session for a user | ||||
|     pub fn new(user_id: String, token: String, expires_in_hours: i64) -> Self { | ||||
|         let now = Utc::now(); | ||||
|         Self { | ||||
|             id: Uuid::new_v4().to_string(), | ||||
|             user_id, | ||||
|             token, | ||||
|             created_at: now, | ||||
|             expires_at: now + chrono::Duration::hours(expires_in_hours), | ||||
|             last_accessed: now, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Check if the session has expired | ||||
|     pub fn is_expired(&self) -> bool { | ||||
|         Utc::now() > self.expires_at | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// User preferences model | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] | ||||
| pub struct UserPreferences { | ||||
|     pub user_id: String, | ||||
|     pub calendar_selected_date: Option<String>, | ||||
|     pub calendar_time_increment: Option<i32>, | ||||
|     pub calendar_view_mode: Option<String>, | ||||
|     pub calendar_theme: Option<String>, | ||||
|     pub calendar_colors: Option<String>, // JSON string | ||||
|     pub updated_at: DateTime<Utc>, | ||||
| } | ||||
|  | ||||
| impl UserPreferences { | ||||
|     /// Create default preferences for a new user | ||||
|     pub fn default_for_user(user_id: String) -> Self { | ||||
|         Self { | ||||
|             user_id, | ||||
|             calendar_selected_date: None, | ||||
|             calendar_time_increment: Some(15), | ||||
|             calendar_view_mode: Some("month".to_string()), | ||||
|             calendar_theme: Some("light".to_string()), | ||||
|             calendar_colors: None, | ||||
|             updated_at: Utc::now(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Repository for User operations | ||||
| pub struct UserRepository<'a> { | ||||
|     db: &'a Database, | ||||
| } | ||||
|  | ||||
| impl<'a> UserRepository<'a> { | ||||
|     pub fn new(db: &'a Database) -> Self { | ||||
|         Self { db } | ||||
|     } | ||||
|  | ||||
|     /// Find or create a user by username and server URL | ||||
|     pub async fn find_or_create( | ||||
|         &self, | ||||
|         username: &str, | ||||
|         server_url: &str, | ||||
|     ) -> Result<User> { | ||||
|         // Try to find existing user | ||||
|         let existing = sqlx::query_as::<_, User>( | ||||
|             "SELECT * FROM users WHERE username = ? AND server_url = ?", | ||||
|         ) | ||||
|         .bind(username) | ||||
|         .bind(server_url) | ||||
|         .fetch_optional(self.db.pool()) | ||||
|         .await?; | ||||
|  | ||||
|         if let Some(user) = existing { | ||||
|             Ok(user) | ||||
|         } else { | ||||
|             // Create new user | ||||
|             let user = User::new(username.to_string(), server_url.to_string()); | ||||
|              | ||||
|             sqlx::query( | ||||
|                 "INSERT INTO users (id, username, server_url, created_at) VALUES (?, ?, ?, ?)", | ||||
|             ) | ||||
|             .bind(&user.id) | ||||
|             .bind(&user.username) | ||||
|             .bind(&user.server_url) | ||||
|             .bind(&user.created_at) | ||||
|             .execute(self.db.pool()) | ||||
|             .await?; | ||||
|  | ||||
|             Ok(user) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Find a user by ID | ||||
|     pub async fn find_by_id(&self, user_id: &str) -> Result<Option<User>> { | ||||
|         sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?") | ||||
|             .bind(user_id) | ||||
|             .fetch_optional(self.db.pool()) | ||||
|             .await | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Repository for Session operations | ||||
| pub struct SessionRepository<'a> { | ||||
|     db: &'a Database, | ||||
| } | ||||
|  | ||||
| impl<'a> SessionRepository<'a> { | ||||
|     pub fn new(db: &'a Database) -> Self { | ||||
|         Self { db } | ||||
|     } | ||||
|  | ||||
|     /// Create a new session | ||||
|     pub async fn create(&self, session: &Session) -> Result<()> { | ||||
|         sqlx::query( | ||||
|             "INSERT INTO sessions (id, user_id, token, created_at, expires_at, last_accessed)  | ||||
|              VALUES (?, ?, ?, ?, ?, ?)", | ||||
|         ) | ||||
|         .bind(&session.id) | ||||
|         .bind(&session.user_id) | ||||
|         .bind(&session.token) | ||||
|         .bind(&session.created_at) | ||||
|         .bind(&session.expires_at) | ||||
|         .bind(&session.last_accessed) | ||||
|         .execute(self.db.pool()) | ||||
|         .await?; | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Find a session by token and update last_accessed | ||||
|     pub async fn find_by_token(&self, token: &str) -> Result<Option<Session>> { | ||||
|         let session = sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token = ?") | ||||
|             .bind(token) | ||||
|             .fetch_optional(self.db.pool()) | ||||
|             .await?; | ||||
|  | ||||
|         if let Some(ref s) = session { | ||||
|             if !s.is_expired() { | ||||
|                 // Update last_accessed time | ||||
|                 sqlx::query("UPDATE sessions SET last_accessed = ? WHERE id = ?") | ||||
|                     .bind(Utc::now()) | ||||
|                     .bind(&s.id) | ||||
|                     .execute(self.db.pool()) | ||||
|                     .await?; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         Ok(session) | ||||
|     } | ||||
|  | ||||
|     /// Delete a session (logout) | ||||
|     pub async fn delete(&self, token: &str) -> Result<()> { | ||||
|         sqlx::query("DELETE FROM sessions WHERE token = ?") | ||||
|             .bind(token) | ||||
|             .execute(self.db.pool()) | ||||
|             .await?; | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Clean up expired sessions | ||||
|     pub async fn cleanup_expired(&self) -> Result<u64> { | ||||
|         let result = sqlx::query("DELETE FROM sessions WHERE expires_at < ?") | ||||
|             .bind(Utc::now()) | ||||
|             .execute(self.db.pool()) | ||||
|             .await?; | ||||
|  | ||||
|         Ok(result.rows_affected()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Repository for UserPreferences operations | ||||
| pub struct PreferencesRepository<'a> { | ||||
|     db: &'a Database, | ||||
| } | ||||
|  | ||||
| impl<'a> PreferencesRepository<'a> { | ||||
|     pub fn new(db: &'a Database) -> Self { | ||||
|         Self { db } | ||||
|     } | ||||
|  | ||||
|     /// Get user preferences, creating defaults if not exist | ||||
|     pub async fn get_or_create(&self, user_id: &str) -> Result<UserPreferences> { | ||||
|         let existing = sqlx::query_as::<_, UserPreferences>( | ||||
|             "SELECT * FROM user_preferences WHERE user_id = ?", | ||||
|         ) | ||||
|         .bind(user_id) | ||||
|         .fetch_optional(self.db.pool()) | ||||
|         .await?; | ||||
|  | ||||
|         if let Some(prefs) = existing { | ||||
|             Ok(prefs) | ||||
|         } else { | ||||
|             // Create default preferences | ||||
|             let prefs = UserPreferences::default_for_user(user_id.to_string()); | ||||
|              | ||||
|             sqlx::query( | ||||
|                 "INSERT INTO user_preferences  | ||||
|                  (user_id, calendar_selected_date, calendar_time_increment,  | ||||
|                   calendar_view_mode, calendar_theme, calendar_colors, updated_at)  | ||||
|                  VALUES (?, ?, ?, ?, ?, ?, ?)", | ||||
|             ) | ||||
|             .bind(&prefs.user_id) | ||||
|             .bind(&prefs.calendar_selected_date) | ||||
|             .bind(&prefs.calendar_time_increment) | ||||
|             .bind(&prefs.calendar_view_mode) | ||||
|             .bind(&prefs.calendar_theme) | ||||
|             .bind(&prefs.calendar_colors) | ||||
|             .bind(&prefs.updated_at) | ||||
|             .execute(self.db.pool()) | ||||
|             .await?; | ||||
|  | ||||
|             Ok(prefs) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Update user preferences | ||||
|     pub async fn update(&self, prefs: &UserPreferences) -> Result<()> { | ||||
|         sqlx::query( | ||||
|             "UPDATE user_preferences  | ||||
|              SET calendar_selected_date = ?, calendar_time_increment = ?,  | ||||
|                  calendar_view_mode = ?, calendar_theme = ?,  | ||||
|                  calendar_colors = ?, updated_at = ? | ||||
|              WHERE user_id = ?", | ||||
|         ) | ||||
|         .bind(&prefs.calendar_selected_date) | ||||
|         .bind(&prefs.calendar_time_increment) | ||||
|         .bind(&prefs.calendar_view_mode) | ||||
|         .bind(&prefs.calendar_theme) | ||||
|         .bind(&prefs.calendar_colors) | ||||
|         .bind(Utc::now()) | ||||
|         .bind(&prefs.user_id) | ||||
|         .execute(self.db.pool()) | ||||
|         .await?; | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
| @@ -2,7 +2,12 @@ use crate::calendar::CalDAVClient; | ||||
| use crate::config::CalDAVConfig; | ||||
|  | ||||
| pub async fn debug_caldav_fetch() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     let config = CalDAVConfig::from_env()?; | ||||
|     // Use debug/test configuration | ||||
|     let config = CalDAVConfig::new( | ||||
|         "https://example.com".to_string(), | ||||
|         "debug_user".to_string(), | ||||
|         "debug_password".to_string() | ||||
|     ); | ||||
|     let client = CalDAVClient::new(config); | ||||
|      | ||||
|     println!("=== DEBUG: CalDAV Fetch ==="); | ||||
|   | ||||
| @@ -2,9 +2,11 @@ | ||||
| mod auth; | ||||
| mod calendar; | ||||
| mod events; | ||||
| mod preferences; | ||||
| 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 events::{get_calendar_events, refresh_event, create_event, update_event, delete_event}; | ||||
| pub use series::{create_event_series, update_event_series, delete_event_series}; | ||||
| 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}; | ||||
|   | ||||
| @@ -1,33 +1,38 @@ | ||||
| use axum::{ | ||||
|     extract::State, | ||||
|     http::HeaderMap, | ||||
|     response::Json, | ||||
| }; | ||||
| use axum::{extract::State, http::HeaderMap, response::Json}; | ||||
| use std::sync::Arc; | ||||
|  | ||||
| use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo}}; | ||||
| use crate::calendar::CalDAVClient; | ||||
| use crate::config::CalDAVConfig; | ||||
| use crate::{ | ||||
|     models::{ApiError, AuthResponse, CalDAVLoginRequest, CalendarInfo, UserInfo}, | ||||
|     AppState, | ||||
| }; | ||||
|  | ||||
| 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()))?; | ||||
|      | ||||
|     let auth_str = auth_header.to_str() | ||||
|  | ||||
|     let auth_str = auth_header | ||||
|         .to_str() | ||||
|         .map_err(|_| ApiError::BadRequest("Invalid Authorization header".to_string()))?; | ||||
|      | ||||
|  | ||||
|     if let Some(token) = auth_str.strip_prefix("Bearer ") { | ||||
|         Ok(token.to_string()) | ||||
|     } 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> { | ||||
|     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()))?; | ||||
|      | ||||
|     password_header.to_str() | ||||
|  | ||||
|     password_header | ||||
|         .to_str() | ||||
|         .map(|s| s.to_string()) | ||||
|         .map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string())) | ||||
| } | ||||
| @@ -40,39 +45,13 @@ pub async fn login( | ||||
|     println!("  Server URL: {}", request.server_url); | ||||
|     println!("  Username: {}", request.username); | ||||
|     println!("  Password length: {}", request.password.len()); | ||||
|      | ||||
|     // Basic validation | ||||
|     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())); | ||||
|     } | ||||
|      | ||||
|     println!("✅ Input validation passed"); | ||||
|      | ||||
|     // Create a token using the auth service | ||||
|     println!("📝 Created CalDAV config"); | ||||
|      | ||||
|     // First verify the credentials are valid by attempting to discover calendars | ||||
|     let config = CalDAVConfig { | ||||
|         server_url: request.server_url.clone(), | ||||
|         username: request.username.clone(), | ||||
|         password: request.password.clone(), | ||||
|         calendar_path: None, | ||||
|         tasks_path: None, | ||||
|     }; | ||||
|     let client = CalDAVClient::new(config); | ||||
|     client.discover_calendars() | ||||
|         .await | ||||
|         .map_err(|e| ApiError::Unauthorized(format!("Authentication failed: {}", e)))?; | ||||
|      | ||||
|     let token = state.auth_service.generate_token(&request.username, &request.server_url)?; | ||||
|      | ||||
|     println!("🔗 Created CalDAV client, attempting to discover calendars..."); | ||||
|      | ||||
|     Ok(Json(AuthResponse { | ||||
|         token, | ||||
|         username: request.username, | ||||
|         server_url: request.server_url, | ||||
|     })) | ||||
|  | ||||
|     // Use the auth service login method which now handles database, sessions, and preferences | ||||
|     let response = state.auth_service.login(request).await?; | ||||
|  | ||||
|     println!("✅ Login successful with session management"); | ||||
|  | ||||
|     Ok(Json(response)) | ||||
| } | ||||
|  | ||||
| pub async fn verify_token( | ||||
| @@ -81,7 +60,7 @@ pub async fn verify_token( | ||||
| ) -> Result<Json<serde_json::Value>, ApiError> { | ||||
|     let token = extract_bearer_token(&headers)?; | ||||
|     let is_valid = state.auth_service.verify_token(&token).is_ok(); | ||||
|      | ||||
|  | ||||
|     Ok(Json(serde_json::json!({ "valid": is_valid }))) | ||||
| } | ||||
|  | ||||
| @@ -91,26 +70,33 @@ pub async fn get_user_info( | ||||
| ) -> Result<Json<UserInfo>, ApiError> { | ||||
|     let token = extract_bearer_token(&headers)?; | ||||
|     let password = extract_password_header(&headers)?; | ||||
|      | ||||
|  | ||||
|     // 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()); | ||||
|      | ||||
|  | ||||
|     // Discover calendars | ||||
|     let calendar_paths = client.discover_calendars() | ||||
|     let calendar_paths = client | ||||
|         .discover_calendars() | ||||
|         .await | ||||
|         .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; | ||||
|          | ||||
|     println!("✅ Authentication successful! Found {} calendars", calendar_paths.len()); | ||||
|      | ||||
|     let calendars: Vec<CalendarInfo> = calendar_paths.iter().map(|path| { | ||||
|         CalendarInfo { | ||||
|  | ||||
|     println!( | ||||
|         "✅ Authentication successful! Found {} calendars", | ||||
|         calendar_paths.len() | ||||
|     ); | ||||
|  | ||||
|     let calendars: Vec<CalendarInfo> = calendar_paths | ||||
|         .iter() | ||||
|         .map(|path| CalendarInfo { | ||||
|             path: path.clone(), | ||||
|             display_name: extract_calendar_name(path), | ||||
|             color: generate_calendar_color(path), | ||||
|         } | ||||
|     }).collect(); | ||||
|      | ||||
|         }) | ||||
|         .collect(); | ||||
|  | ||||
|     Ok(Json(UserInfo { | ||||
|         username: config.username, | ||||
|         server_url: config.server_url, | ||||
| @@ -125,15 +111,14 @@ fn generate_calendar_color(path: &str) -> String { | ||||
|     for byte in path.bytes() { | ||||
|         hash = hash.wrapping_mul(31).wrapping_add(byte as u32); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // Define a set of pleasant colors | ||||
|     let colors = [ | ||||
|         "#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", | ||||
|         "#06B6D4", "#84CC16", "#F97316", "#EC4899", "#6366F1", | ||||
|         "#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626", | ||||
|         "#7C3AED", "#059669", "#D97706", "#BE185D", "#4F46E5" | ||||
|         "#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#06B6D4", "#84CC16", "#F97316", | ||||
|         "#EC4899", "#6366F1", "#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626", "#7C3AED", | ||||
|         "#059669", "#D97706", "#BE185D", "#4F46E5", | ||||
|     ]; | ||||
|      | ||||
|  | ||||
|     colors[(hash as usize) % colors.len()].to_string() | ||||
| } | ||||
|  | ||||
| @@ -156,4 +141,4 @@ fn extract_calendar_name(path: &str) -> String { | ||||
|         }) | ||||
|         .collect::<Vec<String>>() | ||||
|         .join(" ") | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,12 +1,14 @@ | ||||
| use axum::{ | ||||
|     extract::State, | ||||
|     http::HeaderMap, | ||||
|     response::Json, | ||||
| }; | ||||
| use axum::{extract::State, http::HeaderMap, response::Json}; | ||||
| use std::sync::Arc; | ||||
|  | ||||
| use crate::{AppState, models::{ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse}}; | ||||
| use crate::calendar::CalDAVClient; | ||||
| use crate::{ | ||||
|     models::{ | ||||
|         ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, | ||||
|         DeleteCalendarResponse, | ||||
|     }, | ||||
|     AppState, | ||||
| }; | ||||
|  | ||||
| use super::auth::{extract_bearer_token, extract_password_header}; | ||||
|  | ||||
| @@ -20,22 +22,36 @@ pub async fn create_calendar( | ||||
|  | ||||
|     // Validate request | ||||
|     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 | ||||
|     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); | ||||
|  | ||||
|     // 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 { | ||||
|             success: true, | ||||
|             message: "Calendar created successfully".to_string(), | ||||
|         })), | ||||
|         Err(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 | ||||
|     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 | ||||
|     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); | ||||
|  | ||||
|     // Delete calendar on CalDAV server | ||||
| @@ -65,7 +85,10 @@ pub async fn delete_calendar( | ||||
|         })), | ||||
|         Err(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::{ | ||||
|     extract::{State, Query, Path}, | ||||
|     extract::{Path, Query, State}, | ||||
|     http::HeaderMap, | ||||
|     response::Json, | ||||
| }; | ||||
| use chrono::Datelike; | ||||
| use serde::Deserialize; | ||||
| 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::{ | ||||
|     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}; | ||||
|  | ||||
| @@ -28,20 +36,23 @@ pub async fn get_calendar_events( | ||||
|     let token = extract_bearer_token(&headers)?; | ||||
|     let password = extract_password_header(&headers)?; | ||||
|     println!("🔑 API call with password length: {}", password.len()); | ||||
|      | ||||
|  | ||||
|     // 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); | ||||
|      | ||||
|  | ||||
|     // Discover calendars if needed | ||||
|     let calendar_paths = client.discover_calendars() | ||||
|     let calendar_paths = client | ||||
|         .discover_calendars() | ||||
|         .await | ||||
|         .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; | ||||
|      | ||||
|  | ||||
|     if calendar_paths.is_empty() { | ||||
|         return Ok(Json(vec![])); // No calendars found | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // Fetch events from all calendars | ||||
|     let mut all_events = Vec::new(); | ||||
|     for calendar_path in &calendar_paths { | ||||
| @@ -54,12 +65,15 @@ pub async fn get_calendar_events( | ||||
|                 all_events.extend(events); | ||||
|             } | ||||
|             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 | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // If year and month are specified, filter events | ||||
|     if let (Some(year), Some(month)) = (params.year, params.month) { | ||||
|         all_events.retain(|event| { | ||||
| @@ -68,7 +82,7 @@ pub async fn get_calendar_events( | ||||
|             event_year == year && event_month == month | ||||
|         }); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     println!("📅 Returning {} events", all_events.len()); | ||||
|     Ok(Json(all_events)) | ||||
| } | ||||
| @@ -80,16 +94,19 @@ pub async fn refresh_event( | ||||
| ) -> Result<Json<Option<CalendarEvent>>, ApiError> { | ||||
|     let token = extract_bearer_token(&headers)?; | ||||
|     let password = extract_password_header(&headers)?; | ||||
|      | ||||
|  | ||||
|     // 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); | ||||
|      | ||||
|  | ||||
|     // Discover calendars | ||||
|     let calendar_paths = client.discover_calendars() | ||||
|     let calendar_paths = client | ||||
|         .discover_calendars() | ||||
|         .await | ||||
|         .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; | ||||
|      | ||||
|  | ||||
|     // Search for the event by UID across all calendars | ||||
|     for calendar_path in &calendar_paths { | ||||
|         if let Ok(Some(mut event)) = client.fetch_event_by_uid(calendar_path, &uid).await { | ||||
| @@ -97,18 +114,25 @@ pub async fn refresh_event( | ||||
|             return Ok(Json(Some(event))); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     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 | ||||
|     // 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?; | ||||
|      | ||||
|  | ||||
|     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 | ||||
|     for event in &events { | ||||
|         if let Some(stored_href) = &event.href { | ||||
| @@ -118,22 +142,25 @@ async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_h | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // Fallback: try to match by UID extracted from href filename | ||||
|     let filename = event_href.split('/').last().unwrap_or(event_href); | ||||
|     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 { | ||||
|         if event.uid == uid_from_href { | ||||
|             println!("✅ Found matching event by UID: {}", event.uid); | ||||
|             return Ok(Some(event)); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     println!("❌ No matching event found for href: {}", event_href); | ||||
|      | ||||
|  | ||||
|     Ok(None) | ||||
| } | ||||
|  | ||||
| @@ -146,41 +173,63 @@ pub async fn delete_event( | ||||
|     let password = extract_password_header(&headers)?; | ||||
|  | ||||
|     // 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); | ||||
|  | ||||
|     // Handle different delete actions for recurring events | ||||
|     match request.delete_action.as_str() { | ||||
|         "delete_this" => { | ||||
|             if let Some(event) = 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(event) = | ||||
|                 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 | ||||
|                 if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() { | ||||
|                     // Recurring event - add EXDATE for this occurrence | ||||
|                     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) | ||||
|                             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) | ||||
|                             naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc() | ||||
|                         } else { | ||||
|                             return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date))); | ||||
|                         }; | ||||
|                          | ||||
|  | ||||
|                         let mut updated_event = event; | ||||
|                         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 | ||||
|                         client.update_event(&request.calendar_path, &updated_event, &request.event_href) | ||||
|                         client | ||||
|                             .update_event( | ||||
|                                 &request.calendar_path, | ||||
|                                 &updated_event, | ||||
|                                 &request.event_href, | ||||
|                             ) | ||||
|                             .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"); | ||||
|                          | ||||
|  | ||||
|                         Ok(Json(DeleteEventResponse { | ||||
|                             success: true, | ||||
|                             message: "Single occurrence deleted successfully".to_string(), | ||||
| @@ -191,13 +240,16 @@ pub async fn delete_event( | ||||
|                 } else { | ||||
|                     // Non-recurring event - delete the entire event | ||||
|                     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 | ||||
|                         .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"); | ||||
|                      | ||||
|  | ||||
|                     Ok(Json(DeleteEventResponse { | ||||
|                         success: true, | ||||
|                         message: "Event deleted successfully".to_string(), | ||||
| @@ -206,70 +258,99 @@ pub async fn delete_event( | ||||
|             } else { | ||||
|                 Err(ApiError::NotFound("Event not found".to_string())) | ||||
|             } | ||||
|         }, | ||||
|         } | ||||
|         "delete_following" => { | ||||
|             // For "this and following" deletion, we need to: | ||||
|             // 1. Fetch the recurring event | ||||
|             // 2. Modify the RRULE to end before this occurrence | ||||
|             // 3. Update the event | ||||
|  | ||||
|             if let Some(mut event) = 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(mut event) = | ||||
|                 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 { | ||||
|                     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) | ||||
|                         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) | ||||
|                         naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc() | ||||
|                     } 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 | ||||
|                     if let Some(rrule) = &event.rrule { | ||||
|                         // Remove existing UNTIL if present and add new one | ||||
|                         let parts: Vec<&str> = rrule.split(';').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 parts: Vec<&str> = rrule | ||||
|                             .split(';') | ||||
|                             .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") | ||||
|                         ); | ||||
|                         event.rrule = Some(new_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 | ||||
|                             .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 { | ||||
|                             success: true, | ||||
|                             message: "This and following occurrences deleted successfully".to_string(), | ||||
|                             message: "This and following occurrences deleted successfully" | ||||
|                                 .to_string(), | ||||
|                         })) | ||||
|                     } else { | ||||
|                         // 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 | ||||
|                             .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 { | ||||
|                             success: true, | ||||
|                             message: "Event deleted successfully".to_string(), | ||||
|                         })) | ||||
|                     } | ||||
|                 } 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 { | ||||
|                 Err(ApiError::NotFound("Event not found".to_string())) | ||||
|             } | ||||
|         }, | ||||
|         } | ||||
|         "delete_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 | ||||
|                 .map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?; | ||||
|              | ||||
|  | ||||
|             Ok(Json(DeleteEventResponse { | ||||
|                 success: true, | ||||
|                 message: "Event deleted successfully".to_string(), | ||||
| @@ -283,9 +364,11 @@ pub async fn create_event( | ||||
|     headers: HeaderMap, | ||||
|     Json(request): Json<CreateEventRequest>, | ||||
| ) -> Result<Json<CreateEventResponse>, ApiError> { | ||||
|     println!("📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",  | ||||
|              request.title, request.all_day, request.calendar_path); | ||||
|      | ||||
|     println!( | ||||
|         "📝 Create event request received: title='{}', all_day={}, calendar_path={:?}", | ||||
|         request.title, request.all_day, request.calendar_path | ||||
|     ); | ||||
|  | ||||
|     // Extract and verify token | ||||
|     let token = extract_bearer_token(&headers)?; | ||||
|     let password = extract_password_header(&headers)?; | ||||
| @@ -294,13 +377,17 @@ pub async fn create_event( | ||||
|     if request.title.trim().is_empty() { | ||||
|         return Err(ApiError::BadRequest("Event title is required".to_string())); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     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 | ||||
|     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); | ||||
|  | ||||
|     // Determine which calendar to use | ||||
| @@ -308,31 +395,41 @@ pub async fn create_event( | ||||
|         path | ||||
|     } else { | ||||
|         // Use the first available calendar | ||||
|         let calendar_paths = client.discover_calendars() | ||||
|         let calendar_paths = client | ||||
|             .discover_calendars() | ||||
|             .await | ||||
|             .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; | ||||
|          | ||||
|  | ||||
|         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() | ||||
|     }; | ||||
|  | ||||
|     // Parse dates and times | ||||
|     let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day) | ||||
|         .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; | ||||
|      | ||||
|     let start_datetime = | ||||
|         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) | ||||
|         .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?; | ||||
|  | ||||
|     // Validate that end is after start | ||||
|     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 | ||||
|     let uid = format!("{}-{}", uuid::Uuid::new_v4(), chrono::Utc::now().timestamp()); | ||||
|     let uid = format!( | ||||
|         "{}-{}", | ||||
|         uuid::Uuid::new_v4(), | ||||
|         chrono::Utc::now().timestamp() | ||||
|     ); | ||||
|  | ||||
|     // Parse status | ||||
|     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() { | ||||
|         Vec::new() | ||||
|     } else { | ||||
|         request.attendees | ||||
|         request | ||||
|             .attendees | ||||
|             .split(',') | ||||
|             .map(|s| s.trim().to_string()) | ||||
|             .filter(|s| !s.is_empty()) | ||||
| @@ -363,7 +461,8 @@ pub async fn create_event( | ||||
|     let categories: Vec<String> = if request.categories.trim().is_empty() { | ||||
|         Vec::new() | ||||
|     } else { | ||||
|         request.categories | ||||
|         request | ||||
|             .categories | ||||
|             .split(',') | ||||
|             .map(|s| s.trim().to_string()) | ||||
|             .filter(|s| !s.is_empty()) | ||||
| @@ -399,10 +498,11 @@ pub async fn create_event( | ||||
|             "WEEKLY" => { | ||||
|                 // Handle weekly recurrence with optional BYDAY parameter | ||||
|                 let mut rrule = "FREQ=WEEKLY".to_string(); | ||||
|                  | ||||
|  | ||||
|                 // Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]) | ||||
|                 if request.recurrence_days.len() == 7 { | ||||
|                     let selected_days: Vec<&str> = request.recurrence_days | ||||
|                     let selected_days: Vec<&str> = request | ||||
|                         .recurrence_days | ||||
|                         .iter() | ||||
|                         .enumerate() | ||||
|                         .filter_map(|(i, &selected)| { | ||||
| @@ -416,20 +516,20 @@ pub async fn create_event( | ||||
|                                     5 => "FR", // Friday | ||||
|                                     6 => "SA", // Saturday | ||||
|                                     _ => return None, | ||||
|                             }) | ||||
|                         } else { | ||||
|                             None | ||||
|                         } | ||||
|                     }) | ||||
|                     .collect(); | ||||
|                                 }) | ||||
|                             } else { | ||||
|                                 None | ||||
|                             } | ||||
|                         }) | ||||
|                         .collect(); | ||||
|  | ||||
|                 if !selected_days.is_empty() { | ||||
|                     rrule = format!("{};BYDAY={}", rrule, selected_days.join(",")); | ||||
|                     if !selected_days.is_empty() { | ||||
|                         rrule = format!("{};BYDAY={}", rrule, selected_days.join(",")); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|  | ||||
|                 Some(rrule) | ||||
|             }, | ||||
|             } | ||||
|             "MONTHLY" => Some("FREQ=MONTHLY".to_string()), | ||||
|             "YEARLY" => Some("FREQ=YEARLY".to_string()), | ||||
|             _ => None, | ||||
| @@ -439,15 +539,27 @@ pub async fn create_event( | ||||
|     // Create the VEvent struct (RFC 5545 compliant) | ||||
|     let mut event = VEvent::new(uid, start_datetime); | ||||
|     event.dtend = Some(end_datetime); | ||||
|     event.summary = if request.title.trim().is_empty() { None } 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.summary = if request.title.trim().is_empty() { | ||||
|         None | ||||
|     } 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.class = Some(class); | ||||
|     event.priority = request.priority; | ||||
|     event.organizer = if request.organizer.trim().is_empty() {  | ||||
|         None  | ||||
|     } else {  | ||||
|     event.organizer = if request.organizer.trim().is_empty() { | ||||
|         None | ||||
|     } else { | ||||
|         Some(CalendarUser { | ||||
|             cal_address: request.organizer, | ||||
|             common_name: None, | ||||
| @@ -456,41 +568,53 @@ pub async fn create_event( | ||||
|             language: None, | ||||
|         }) | ||||
|     }; | ||||
|     event.attendees = attendees.into_iter().map(|email| Attendee { | ||||
|         cal_address: email, | ||||
|         common_name: None, | ||||
|         role: None, | ||||
|         part_stat: None, | ||||
|         rsvp: None, | ||||
|         cu_type: None, | ||||
|         member: Vec::new(), | ||||
|         delegated_to: Vec::new(), | ||||
|         delegated_from: Vec::new(), | ||||
|         sent_by: None, | ||||
|         dir_entry_ref: None, | ||||
|         language: None, | ||||
|     }).collect(); | ||||
|     event.attendees = attendees | ||||
|         .into_iter() | ||||
|         .map(|email| Attendee { | ||||
|             cal_address: email, | ||||
|             common_name: None, | ||||
|             role: None, | ||||
|             part_stat: None, | ||||
|             rsvp: None, | ||||
|             cu_type: None, | ||||
|             member: Vec::new(), | ||||
|             delegated_to: Vec::new(), | ||||
|             delegated_from: Vec::new(), | ||||
|             sent_by: None, | ||||
|             dir_entry_ref: None, | ||||
|             language: None, | ||||
|         }) | ||||
|         .collect(); | ||||
|     event.categories = categories; | ||||
|     event.rrule = rrule; | ||||
|     event.all_day = request.all_day; | ||||
|     event.alarms = alarms.into_iter().map(|reminder| VAlarm { | ||||
|         action: AlarmAction::Display, | ||||
|         trigger: AlarmTrigger::Duration(chrono::Duration::minutes(-reminder.minutes_before as i64)), | ||||
|         duration: None, | ||||
|         repeat: None, | ||||
|         description: reminder.description, | ||||
|         summary: None, | ||||
|         attendees: Vec::new(), | ||||
|         attach: Vec::new(), | ||||
|     }).collect(); | ||||
|     event.alarms = alarms | ||||
|         .into_iter() | ||||
|         .map(|reminder| VAlarm { | ||||
|             action: AlarmAction::Display, | ||||
|             trigger: AlarmTrigger::Duration(chrono::Duration::minutes( | ||||
|                 -reminder.minutes_before as i64, | ||||
|             )), | ||||
|             duration: None, | ||||
|             repeat: None, | ||||
|             description: reminder.description, | ||||
|             summary: None, | ||||
|             attendees: Vec::new(), | ||||
|             attach: Vec::new(), | ||||
|         }) | ||||
|         .collect(); | ||||
|     event.calendar_path = Some(calendar_path.clone()); | ||||
|  | ||||
|     // 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 | ||||
|         .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 { | ||||
|         success: true, | ||||
| @@ -505,7 +629,7 @@ pub async fn update_event( | ||||
|     Json(request): Json<UpdateEventRequest>, | ||||
| ) -> Result<Json<UpdateEventResponse>, ApiError> { | ||||
|     // Handle update request | ||||
|      | ||||
|  | ||||
|     // Extract and verify token | ||||
|     let token = extract_bearer_token(&headers)?; | ||||
|     let password = extract_password_header(&headers)?; | ||||
| @@ -514,37 +638,45 @@ pub async fn update_event( | ||||
|     if request.uid.trim().is_empty() { | ||||
|         return Err(ApiError::BadRequest("Event UID is required".to_string())); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     if request.title.trim().is_empty() { | ||||
|         return Err(ApiError::BadRequest("Event title is required".to_string())); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     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 | ||||
|     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); | ||||
|  | ||||
|     // Find the event across all calendars (or in the specified calendar) | ||||
|     let calendar_paths = if let Some(path) = &request.calendar_path { | ||||
|         vec![path.clone()] | ||||
|     } else { | ||||
|         client.discover_calendars() | ||||
|         client | ||||
|             .discover_calendars() | ||||
|             .await | ||||
|             .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))? | ||||
|     }; | ||||
|  | ||||
|     let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, event_href) | ||||
|      | ||||
|  | ||||
|     for calendar_path in &calendar_paths { | ||||
|         match client.fetch_events(calendar_path).await { | ||||
|             Ok(events) => { | ||||
|                 for event in events { | ||||
|                     if event.uid == request.uid { | ||||
|                         // 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); | ||||
|                         found_event = Some((event, calendar_path.clone(), event_href)); | ||||
|                         break; | ||||
| @@ -553,9 +685,12 @@ pub async fn update_event( | ||||
|                 if found_event.is_some() { | ||||
|                     break; | ||||
|                 } | ||||
|             }, | ||||
|             } | ||||
|             Err(e) => { | ||||
|                 eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e); | ||||
|                 eprintln!( | ||||
|                     "Failed to fetch events from calendar {}: {}", | ||||
|                     calendar_path, e | ||||
|                 ); | ||||
|                 continue; | ||||
|             } | ||||
|         } | ||||
| @@ -565,23 +700,38 @@ pub async fn update_event( | ||||
|         .ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?; | ||||
|  | ||||
|     // Parse dates and times | ||||
|     let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day) | ||||
|         .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; | ||||
|      | ||||
|     let start_datetime = | ||||
|         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) | ||||
|         .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?; | ||||
|  | ||||
|     // Validate that end is after start | ||||
|     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 | ||||
|     event.dtstart = start_datetime; | ||||
|     event.dtend = Some(end_datetime); | ||||
|     event.summary = if request.title.trim().is_empty() { None } 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.summary = if request.title.trim().is_empty() { | ||||
|         None | ||||
|     } 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; | ||||
|  | ||||
|     // Parse and update status | ||||
| @@ -601,11 +751,15 @@ pub async fn update_event( | ||||
|     event.priority = request.priority; | ||||
|  | ||||
|     // Update the event on the CalDAV server | ||||
|     println!("📝 Updating event {} at calendar_path: {}, event_href: {}", event.uid, calendar_path, event_href); | ||||
|     client.update_event(&calendar_path, &event, &event_href) | ||||
|     println!( | ||||
|         "📝 Updating event {} at calendar_path: {}, event_href: {}", | ||||
|         event.uid, calendar_path, event_href | ||||
|     ); | ||||
|     client | ||||
|         .update_event(&calendar_path, &event, &event_href) | ||||
|         .await | ||||
|         .map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?; | ||||
|      | ||||
|  | ||||
|     println!("✅ Successfully updated event {}", event.uid); | ||||
|  | ||||
|     Ok(Json(UpdateEventResponse { | ||||
| @@ -614,27 +768,32 @@ pub async fn update_event( | ||||
|     })) | ||||
| } | ||||
|  | ||||
| fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result<chrono::DateTime<chrono::Utc>, String> { | ||||
|     use chrono::{NaiveDate, NaiveTime, NaiveDateTime, Utc, TimeZone}; | ||||
|      | ||||
| fn parse_event_datetime( | ||||
|     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 | ||||
|     let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") | ||||
|         .map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?; | ||||
|      | ||||
|  | ||||
|     if all_day { | ||||
|         // 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(Utc.from_utc_datetime(&datetime)) | ||||
|     } else { | ||||
|         // Parse the time | ||||
|         let time = NaiveTime::parse_from_str(time_str, "%H:%M") | ||||
|             .map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?; | ||||
|          | ||||
|  | ||||
|         // Combine date and time | ||||
|         let datetime = NaiveDateTime::new(date, time); | ||||
|          | ||||
|  | ||||
|         // Assume local time and convert to UTC (in a real app, you'd want timezone support) | ||||
|         Ok(Utc.from_utc_datetime(&datetime)) | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
							
								
								
									
										123
									
								
								backend/src/handlers/preferences.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								backend/src/handlers/preferences.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,123 @@ | ||||
| use axum::{ | ||||
|     extract::State, | ||||
|     http::{HeaderMap, StatusCode}, | ||||
|     response::IntoResponse, | ||||
|     Json, | ||||
| }; | ||||
| use std::sync::Arc; | ||||
|  | ||||
| use crate::{ | ||||
|     db::PreferencesRepository, | ||||
|     models::{ApiError, UpdatePreferencesRequest, UserPreferencesResponse}, | ||||
|     AppState, | ||||
| }; | ||||
|  | ||||
| /// Get user preferences | ||||
| pub async fn get_preferences( | ||||
|     State(state): State<Arc<AppState>>, | ||||
|     headers: HeaderMap, | ||||
| ) -> Result<impl IntoResponse, ApiError> { | ||||
|     // Extract session token from headers | ||||
|     let session_token = headers | ||||
|         .get("X-Session-Token") | ||||
|         .and_then(|h| h.to_str().ok()) | ||||
|         .ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?; | ||||
|  | ||||
|     // Validate session and get user ID | ||||
|     let user_id = state.auth_service.validate_session(session_token).await?; | ||||
|  | ||||
|     // Get preferences from database | ||||
|     let prefs_repo = PreferencesRepository::new(&state.db); | ||||
|     let preferences = prefs_repo | ||||
|         .get_or_create(&user_id) | ||||
|         .await | ||||
|         .map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?; | ||||
|  | ||||
|     Ok(Json(UserPreferencesResponse { | ||||
|         calendar_selected_date: preferences.calendar_selected_date, | ||||
|         calendar_time_increment: preferences.calendar_time_increment, | ||||
|         calendar_view_mode: preferences.calendar_view_mode, | ||||
|         calendar_theme: preferences.calendar_theme, | ||||
|         calendar_colors: preferences.calendar_colors, | ||||
|     })) | ||||
| } | ||||
|  | ||||
| /// Update user preferences | ||||
| pub async fn update_preferences( | ||||
|     State(state): State<Arc<AppState>>, | ||||
|     headers: HeaderMap, | ||||
|     Json(request): Json<UpdatePreferencesRequest>, | ||||
| ) -> Result<impl IntoResponse, ApiError> { | ||||
|     // Extract session token from headers | ||||
|     let session_token = headers | ||||
|         .get("X-Session-Token") | ||||
|         .and_then(|h| h.to_str().ok()) | ||||
|         .ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?; | ||||
|  | ||||
|     // Validate session and get user ID | ||||
|     let user_id = state.auth_service.validate_session(session_token).await?; | ||||
|  | ||||
|     // Update preferences in database | ||||
|     let prefs_repo = PreferencesRepository::new(&state.db); | ||||
|      | ||||
|     let mut preferences = prefs_repo | ||||
|         .get_or_create(&user_id) | ||||
|         .await | ||||
|         .map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?; | ||||
|  | ||||
|     // Update only provided fields | ||||
|     if request.calendar_selected_date.is_some() { | ||||
|         preferences.calendar_selected_date = request.calendar_selected_date; | ||||
|     } | ||||
|     if request.calendar_time_increment.is_some() { | ||||
|         preferences.calendar_time_increment = request.calendar_time_increment; | ||||
|     } | ||||
|     if request.calendar_view_mode.is_some() { | ||||
|         preferences.calendar_view_mode = request.calendar_view_mode; | ||||
|     } | ||||
|     if request.calendar_theme.is_some() { | ||||
|         preferences.calendar_theme = request.calendar_theme; | ||||
|     } | ||||
|     if request.calendar_colors.is_some() { | ||||
|         preferences.calendar_colors = request.calendar_colors; | ||||
|     } | ||||
|  | ||||
|     prefs_repo | ||||
|         .update(&preferences) | ||||
|         .await | ||||
|         .map_err(|e| ApiError::Database(format!("Failed to update preferences: {}", e)))?; | ||||
|  | ||||
|     Ok(( | ||||
|         StatusCode::OK, | ||||
|         Json(UserPreferencesResponse { | ||||
|             calendar_selected_date: preferences.calendar_selected_date, | ||||
|             calendar_time_increment: preferences.calendar_time_increment, | ||||
|             calendar_view_mode: preferences.calendar_view_mode, | ||||
|             calendar_theme: preferences.calendar_theme, | ||||
|             calendar_colors: preferences.calendar_colors, | ||||
|         }), | ||||
|     )) | ||||
| } | ||||
|  | ||||
| /// Logout user | ||||
| pub async fn logout( | ||||
|     State(state): State<Arc<AppState>>, | ||||
|     headers: HeaderMap, | ||||
| ) -> Result<impl IntoResponse, ApiError> { | ||||
|     // Extract session token from headers | ||||
|     let session_token = headers | ||||
|         .get("X-Session-Token") | ||||
|         .and_then(|h| h.to_str().ok()) | ||||
|         .ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?; | ||||
|  | ||||
|     // Delete session | ||||
|     state.auth_service.logout(session_token).await?; | ||||
|  | ||||
|     Ok(( | ||||
|         StatusCode::OK, | ||||
|         Json(serde_json::json!({ | ||||
|             "success": true, | ||||
|             "message": "Logged out successfully" | ||||
|         })), | ||||
|     )) | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -3,33 +3,43 @@ use axum::{ | ||||
|     routing::{get, post}, | ||||
|     Router, | ||||
| }; | ||||
| use tower_http::cors::{CorsLayer, Any}; | ||||
| use std::sync::Arc; | ||||
| use tower_http::cors::{Any, CorsLayer}; | ||||
|  | ||||
| pub mod auth; | ||||
| pub mod models; | ||||
| pub mod handlers; | ||||
| pub mod calendar; | ||||
| pub mod config; | ||||
| pub mod db; | ||||
| pub mod handlers; | ||||
| pub mod models; | ||||
|  | ||||
| use auth::AuthService; | ||||
| use db::Database; | ||||
|  | ||||
| #[derive(Clone)] | ||||
| pub struct AppState { | ||||
|     pub auth_service: AuthService, | ||||
|     pub db: Database, | ||||
| } | ||||
|  | ||||
| pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     // Initialize logging | ||||
|     println!("🚀 Starting Calendar Backend Server"); | ||||
|  | ||||
|     // Initialize database | ||||
|     let database_url = std::env::var("DATABASE_URL") | ||||
|         .unwrap_or_else(|_| "sqlite:calendar.db".to_string()); | ||||
|      | ||||
|     let db = Database::new(&database_url).await?; | ||||
|     println!("✅ Database initialized"); | ||||
|  | ||||
|     // Create auth service | ||||
|     let jwt_secret = std::env::var("JWT_SECRET") | ||||
|         .unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string()); | ||||
|      | ||||
|     let auth_service = AuthService::new(jwt_secret); | ||||
|      | ||||
|     let app_state = AppState { auth_service }; | ||||
|  | ||||
|     let auth_service = AuthService::new(jwt_secret, db.clone()); | ||||
|  | ||||
|     let app_state = AppState { auth_service, db }; | ||||
|  | ||||
|     // Build our application with routes | ||||
|     let app = Router::new() | ||||
| @@ -46,9 +56,22 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> { | ||||
|         .route("/api/calendar/events/delete", post(handlers::delete_event)) | ||||
|         .route("/api/calendar/events/:uid", get(handlers::refresh_event)) | ||||
|         // Event series-specific endpoints | ||||
|         .route("/api/calendar/events/series/create", 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)) | ||||
|         .route( | ||||
|             "/api/calendar/events/series/create", | ||||
|             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), | ||||
|         ) | ||||
|         // User preferences endpoints | ||||
|         .route("/api/preferences", get(handlers::get_preferences)) | ||||
|         .route("/api/preferences", post(handlers::update_preferences)) | ||||
|         .route("/api/auth/logout", post(handlers::logout)) | ||||
|         .layer( | ||||
|             CorsLayer::new() | ||||
|                 .allow_origin(Any) | ||||
| @@ -60,7 +83,7 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     // Start server | ||||
|     let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?; | ||||
|     println!("📡 Server listening on http://0.0.0.0:3000"); | ||||
|      | ||||
|  | ||||
|     axum::serve(listener, app).await?; | ||||
|  | ||||
|     Ok(()) | ||||
| @@ -76,4 +99,4 @@ async fn health_check() -> Json<serde_json::Value> { | ||||
|         "service": "calendar-backend", | ||||
|         "version": "0.1.0" | ||||
|     })) | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -4,4 +4,4 @@ use calendar_backend::*; | ||||
| #[tokio::main] | ||||
| async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     run_server().await | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -16,8 +16,28 @@ pub struct CalDAVLoginRequest { | ||||
| #[derive(Debug, Serialize)] | ||||
| pub struct AuthResponse { | ||||
|     pub token: String, | ||||
|     pub session_token: String, | ||||
|     pub username: String, | ||||
|     pub server_url: String, | ||||
|     pub preferences: UserPreferencesResponse, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Serialize, Deserialize, Clone)] | ||||
| pub struct UserPreferencesResponse { | ||||
|     pub calendar_selected_date: Option<String>, | ||||
|     pub calendar_time_increment: Option<i32>, | ||||
|     pub calendar_view_mode: Option<String>, | ||||
|     pub calendar_theme: Option<String>, | ||||
|     pub calendar_colors: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Deserialize)] | ||||
| pub struct UpdatePreferencesRequest { | ||||
|     pub calendar_selected_date: Option<String>, | ||||
|     pub calendar_time_increment: Option<i32>, | ||||
|     pub calendar_view_mode: Option<String>, | ||||
|     pub calendar_theme: Option<String>, | ||||
|     pub calendar_colors: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Serialize)] | ||||
| @@ -76,21 +96,21 @@ pub struct DeleteEventResponse { | ||||
| pub struct CreateEventRequest { | ||||
|     pub title: String, | ||||
|     pub description: String, | ||||
|     pub start_date: String,        // YYYY-MM-DD format | ||||
|     pub start_time: String,        // HH:MM format   | ||||
|     pub end_date: String,          // YYYY-MM-DD format | ||||
|     pub end_time: String,          // HH:MM format | ||||
|     pub start_date: String, // YYYY-MM-DD format | ||||
|     pub start_time: String, // HH:MM format | ||||
|     pub end_date: String,   // YYYY-MM-DD format | ||||
|     pub end_time: String,   // HH:MM format | ||||
|     pub location: String, | ||||
|     pub all_day: bool, | ||||
|     pub status: String,            // confirmed, tentative, cancelled | ||||
|     pub class: String,             // public, private, confidential | ||||
|     pub priority: Option<u8>,      // 0-9 priority level | ||||
|     pub organizer: String,         // organizer email | ||||
|     pub attendees: String,         // comma-separated attendee emails | ||||
|     pub categories: String,        // comma-separated categories | ||||
|     pub reminder: String,          // reminder type | ||||
|     pub recurrence: String,        // recurrence type | ||||
|     pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence | ||||
|     pub status: String,                // confirmed, tentative, cancelled | ||||
|     pub class: String,                 // public, private, confidential | ||||
|     pub priority: Option<u8>,          // 0-9 priority level | ||||
|     pub organizer: String,             // organizer email | ||||
|     pub attendees: String,             // comma-separated attendee emails | ||||
|     pub categories: String,            // comma-separated categories | ||||
|     pub reminder: String,              // reminder type | ||||
|     pub recurrence: String,            // recurrence type | ||||
|     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 | ||||
| } | ||||
|  | ||||
| @@ -103,24 +123,24 @@ pub struct CreateEventResponse { | ||||
|  | ||||
| #[derive(Debug, Deserialize)] | ||||
| 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 description: String, | ||||
|     pub start_date: String,        // YYYY-MM-DD format | ||||
|     pub start_time: String,        // HH:MM format   | ||||
|     pub end_date: String,          // YYYY-MM-DD format | ||||
|     pub end_time: String,          // HH:MM format | ||||
|     pub start_date: String, // YYYY-MM-DD format | ||||
|     pub start_time: String, // HH:MM format | ||||
|     pub end_date: String,   // YYYY-MM-DD format | ||||
|     pub end_time: String,   // HH:MM format | ||||
|     pub location: String, | ||||
|     pub all_day: bool, | ||||
|     pub status: String,            // confirmed, tentative, cancelled | ||||
|     pub class: String,             // public, private, confidential | ||||
|     pub priority: Option<u8>,      // 0-9 priority level | ||||
|     pub organizer: String,         // organizer email | ||||
|     pub attendees: String,         // comma-separated attendee emails | ||||
|     pub categories: String,        // comma-separated categories | ||||
|     pub reminder: String,          // reminder type | ||||
|     pub recurrence: String,        // recurrence type | ||||
|     pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence | ||||
|     pub status: String,                // confirmed, tentative, cancelled | ||||
|     pub class: String,                 // public, private, confidential | ||||
|     pub priority: Option<u8>,          // 0-9 priority level | ||||
|     pub organizer: String,             // organizer email | ||||
|     pub attendees: String,             // comma-separated attendee emails | ||||
|     pub categories: String,            // comma-separated categories | ||||
|     pub reminder: String,              // reminder type | ||||
|     pub recurrence: String,            // recurrence type | ||||
|     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 update_action: Option<String>, // "update_series" for recurring events | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
| @@ -139,22 +159,22 @@ pub struct UpdateEventResponse { | ||||
| pub struct CreateEventSeriesRequest { | ||||
|     pub title: String, | ||||
|     pub description: String, | ||||
|     pub start_date: String,        // YYYY-MM-DD format | ||||
|     pub start_time: String,        // HH:MM format   | ||||
|     pub end_date: String,          // YYYY-MM-DD format | ||||
|     pub end_time: String,          // HH:MM format | ||||
|     pub start_date: String, // YYYY-MM-DD format | ||||
|     pub start_time: String, // HH:MM format | ||||
|     pub end_date: String,   // YYYY-MM-DD format | ||||
|     pub end_time: String,   // HH:MM format | ||||
|     pub location: String, | ||||
|     pub all_day: bool, | ||||
|     pub status: String,            // confirmed, tentative, cancelled | ||||
|     pub class: String,             // public, private, confidential | ||||
|     pub priority: Option<u8>,      // 0-9 priority level | ||||
|     pub organizer: String,         // organizer email | ||||
|     pub attendees: String,         // comma-separated attendee emails | ||||
|     pub categories: String,        // comma-separated categories | ||||
|     pub reminder: String,          // reminder type | ||||
|      | ||||
|     pub status: String,       // confirmed, tentative, cancelled | ||||
|     pub class: String,        // public, private, confidential | ||||
|     pub priority: Option<u8>, // 0-9 priority level | ||||
|     pub organizer: String,    // organizer email | ||||
|     pub attendees: String,    // comma-separated attendee emails | ||||
|     pub categories: String,   // comma-separated categories | ||||
|     pub reminder: String,     // reminder type | ||||
|  | ||||
|     // 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_interval: Option<u32>, // Every N days/weeks/months/years | ||||
|     pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD) | ||||
| @@ -173,33 +193,33 @@ pub struct CreateEventSeriesResponse { | ||||
|  | ||||
| #[derive(Debug, Deserialize)] | ||||
| 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 description: String, | ||||
|     pub start_date: String,        // YYYY-MM-DD format | ||||
|     pub start_time: String,        // HH:MM format   | ||||
|     pub end_date: String,          // YYYY-MM-DD format | ||||
|     pub end_time: String,          // HH:MM format | ||||
|     pub start_date: String, // YYYY-MM-DD format | ||||
|     pub start_time: String, // HH:MM format | ||||
|     pub end_date: String,   // YYYY-MM-DD format | ||||
|     pub end_time: String,   // HH:MM format | ||||
|     pub location: String, | ||||
|     pub all_day: bool, | ||||
|     pub status: String,            // confirmed, tentative, cancelled | ||||
|     pub class: String,             // public, private, confidential | ||||
|     pub priority: Option<u8>,      // 0-9 priority level | ||||
|     pub organizer: String,         // organizer email | ||||
|     pub attendees: String,         // comma-separated attendee emails | ||||
|     pub categories: String,        // comma-separated categories | ||||
|     pub reminder: String,          // reminder type | ||||
|      | ||||
|     pub status: String,       // confirmed, tentative, cancelled | ||||
|     pub class: String,        // public, private, confidential | ||||
|     pub priority: Option<u8>, // 0-9 priority level | ||||
|     pub organizer: String,    // organizer email | ||||
|     pub attendees: String,    // comma-separated attendee emails | ||||
|     pub categories: String,   // comma-separated categories | ||||
|     pub reminder: String,     // reminder type | ||||
|  | ||||
|     // 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_interval: Option<u32>, // Every N days/weeks/months/years | ||||
|     pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD) | ||||
|     pub recurrence_count: Option<u32>, // Number of occurrences | ||||
|     pub calendar_path: Option<String>, // Optional - search all calendars if not specified | ||||
|      | ||||
|  | ||||
|     // 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 changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization) | ||||
| } | ||||
| @@ -214,12 +234,12 @@ pub struct UpdateEventSeriesResponse { | ||||
|  | ||||
| #[derive(Debug, Deserialize)] | ||||
| 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 event_href: String, | ||||
|      | ||||
|  | ||||
|     // 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 | ||||
| } | ||||
|  | ||||
| @@ -274,4 +294,4 @@ impl std::fmt::Display for ApiError { | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl std::error::Error for ApiError {} | ||||
| impl std::error::Error for ApiError {} | ||||
|   | ||||
| @@ -1,26 +1,26 @@ | ||||
| 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::{ | ||||
|     response::Json, | ||||
|     routing::{get, post}, | ||||
|     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::time::Duration; | ||||
| use tokio::time::sleep; | ||||
| use tower_http::cors::{Any, CorsLayer}; | ||||
|  | ||||
| /// Test utilities for integration testing | ||||
| mod test_utils { | ||||
|     use super::*; | ||||
|      | ||||
|  | ||||
|     pub struct TestServer { | ||||
|         pub base_url: String, | ||||
|         pub client: Client, | ||||
|     } | ||||
|      | ||||
|  | ||||
|     impl TestServer { | ||||
|         pub async fn start() -> Self { | ||||
|             // Create auth service | ||||
| @@ -33,19 +33,55 @@ mod test_utils { | ||||
|                 .route("/", get(root)) | ||||
|                 .route("/api/health", get(health_check)) | ||||
|                 .route("/api/auth/login", post(calendar_backend::handlers::login)) | ||||
|                 .route("/api/auth/verify", get(calendar_backend::handlers::verify_token)) | ||||
|                 .route("/api/user/info", get(calendar_backend::handlers::get_user_info)) | ||||
|                 .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)) | ||||
|                 .route( | ||||
|                     "/api/auth/verify", | ||||
|                     get(calendar_backend::handlers::verify_token), | ||||
|                 ) | ||||
|                 .route( | ||||
|                     "/api/user/info", | ||||
|                     get(calendar_backend::handlers::get_user_info), | ||||
|                 ) | ||||
|                 .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 | ||||
|                 .route("/api/calendar/events/series/create", 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)) | ||||
|                 .route( | ||||
|                     "/api/calendar/events/series/create", | ||||
|                     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( | ||||
|                     CorsLayer::new() | ||||
|                         .allow_origin(Any) | ||||
| @@ -58,39 +94,47 @@ mod test_utils { | ||||
|             let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); | ||||
|             let addr = listener.local_addr().unwrap(); | ||||
|             let base_url = format!("http://127.0.0.1:{}", addr.port()); | ||||
|              | ||||
|  | ||||
|             tokio::spawn(async move { | ||||
|                 axum::serve(listener, app).await.unwrap(); | ||||
|             }); | ||||
|              | ||||
|  | ||||
|             // Wait for server to start | ||||
|             sleep(Duration::from_millis(100)).await; | ||||
|              | ||||
|  | ||||
|             let client = Client::new(); | ||||
|             TestServer { base_url, client } | ||||
|         } | ||||
|          | ||||
|  | ||||
|         pub async fn login(&self) -> String { | ||||
|             let login_payload = json!({ | ||||
|                 "username": std::env::var("CALDAV_USERNAME").unwrap_or("test".to_string()), | ||||
|                 "password": std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()), | ||||
|                 "server_url": std::env::var("CALDAV_SERVER_URL").unwrap_or("https://example.com".to_string()) | ||||
|                 "username": "test".to_string(), | ||||
|                 "password": "test".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)) | ||||
|                 .json(&login_payload) | ||||
|                 .send() | ||||
|                 .await | ||||
|                 .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(); | ||||
|             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() | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     async fn root() -> &'static str { | ||||
|         "Calendar Backend API v0.1.0" | ||||
|     } | ||||
| @@ -106,26 +150,27 @@ mod test_utils { | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use super::*; | ||||
|     use super::test_utils::*; | ||||
|     use super::*; | ||||
|  | ||||
|     /// Test the health endpoint | ||||
|     #[tokio::test] | ||||
|     async fn test_health_endpoint() { | ||||
|         let server = TestServer::start().await; | ||||
|          | ||||
|         let response = server.client | ||||
|  | ||||
|         let response = server | ||||
|             .client | ||||
|             .get(&format!("{}/api/health", server.base_url)) | ||||
|             .send() | ||||
|             .await | ||||
|             .unwrap(); | ||||
|              | ||||
|  | ||||
|         assert_eq!(response.status(), 200); | ||||
|          | ||||
|  | ||||
|         let health_response: serde_json::Value = response.json().await.unwrap(); | ||||
|         assert_eq!(health_response["status"], "healthy"); | ||||
|         assert_eq!(health_response["service"], "calendar-backend"); | ||||
|          | ||||
|  | ||||
|         println!("✓ Health endpoint test passed"); | ||||
|     } | ||||
|  | ||||
| @@ -133,33 +178,42 @@ mod tests { | ||||
|     #[tokio::test] | ||||
|     async fn test_auth_login() { | ||||
|         let server = TestServer::start().await; | ||||
|          | ||||
|         // Load credentials from .env  | ||||
|         dotenvy::dotenv().ok(); | ||||
|         let username = std::env::var("CALDAV_USERNAME").unwrap_or("test".to_string()); | ||||
|         let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()); | ||||
|          | ||||
|         let server_url = std::env::var("CALDAV_SERVER_URL").unwrap_or("https://example.com".to_string()); | ||||
|          | ||||
|  | ||||
|         // Use test credentials | ||||
|         let username = "test".to_string(); | ||||
|         let password = "test".to_string(); | ||||
|         let server_url = "https://example.com".to_string(); | ||||
|  | ||||
|         let login_payload = json!({ | ||||
|             "username": username, | ||||
|             "password": password, | ||||
|             "server_url": server_url | ||||
|         }); | ||||
|          | ||||
|         let response = server.client | ||||
|  | ||||
|         let response = server | ||||
|             .client | ||||
|             .post(&format!("{}/api/auth/login", server.base_url)) | ||||
|             .json(&login_payload) | ||||
|             .send() | ||||
|             .await | ||||
|             .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(); | ||||
|         assert!(login_response["token"].is_string(), "Login response should contain a token"); | ||||
|         assert!(login_response["username"].is_string(), "Login response should contain username"); | ||||
|          | ||||
|         assert!( | ||||
|             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"); | ||||
|     } | ||||
|  | ||||
| @@ -167,52 +221,57 @@ mod tests { | ||||
|     #[tokio::test] | ||||
|     async fn test_auth_verify() { | ||||
|         let server = TestServer::start().await; | ||||
|          | ||||
|  | ||||
|         // First login to get a token | ||||
|         let token = server.login().await; | ||||
|          | ||||
|         let response = server.client | ||||
|  | ||||
|         let response = server | ||||
|             .client | ||||
|             .get(&format!("{}/api/auth/verify", server.base_url)) | ||||
|             .header("Authorization", format!("Bearer {}", token)) | ||||
|             .send() | ||||
|             .await | ||||
|             .unwrap(); | ||||
|              | ||||
|  | ||||
|         assert_eq!(response.status(), 200); | ||||
|          | ||||
|  | ||||
|         let verify_response: serde_json::Value = response.json().await.unwrap(); | ||||
|         assert!(verify_response["valid"].as_bool().unwrap_or(false)); | ||||
|          | ||||
|  | ||||
|         println!("✓ Authentication verify test passed"); | ||||
|     } | ||||
|  | ||||
|     /// Test user info endpoint | ||||
|     #[tokio::test]  | ||||
|     #[tokio::test] | ||||
|     async fn test_user_info() { | ||||
|         let server = TestServer::start().await; | ||||
|          | ||||
|  | ||||
|         // First login to get a token | ||||
|         let token = server.login().await; | ||||
|          | ||||
|  | ||||
|         // Load password from env for CalDAV requests | ||||
|         dotenvy::dotenv().ok(); | ||||
|         let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()); | ||||
|          | ||||
|         let response = server.client | ||||
|         let password = "test".to_string(); | ||||
|  | ||||
|         let response = server | ||||
|             .client | ||||
|             .get(&format!("{}/api/user/info", server.base_url)) | ||||
|             .header("Authorization", format!("Bearer {}", token)) | ||||
|             .header("X-CalDAV-Password", password) | ||||
|             .send() | ||||
|             .await | ||||
|             .unwrap(); | ||||
|              | ||||
|  | ||||
|         // Note: This might fail if CalDAV server discovery fails, which can happen | ||||
|         if response.status().is_success() { | ||||
|             let user_info: serde_json::Value = response.json().await.unwrap(); | ||||
|             assert!(user_info["username"].is_string()); | ||||
|             println!("✓ User info test passed"); | ||||
|         } else { | ||||
|             println!("⚠ User info test skipped (CalDAV server issues): {}", response.status()); | ||||
|             println!( | ||||
|                 "⚠ User info test skipped (CalDAV server issues): {}", | ||||
|                 response.status() | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -220,48 +279,59 @@ mod tests { | ||||
|     #[tokio::test] | ||||
|     async fn test_get_calendar_events() { | ||||
|         let server = TestServer::start().await; | ||||
|          | ||||
|  | ||||
|         // First login to get a token | ||||
|         let token = server.login().await; | ||||
|          | ||||
|         // Load password from env for CalDAV requests   | ||||
|  | ||||
|         // Load password from env for CalDAV requests | ||||
|         dotenvy::dotenv().ok(); | ||||
|         let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()); | ||||
|          | ||||
|         let response = server.client | ||||
|             .get(&format!("{}/api/calendar/events?year=2024&month=12", server.base_url)) | ||||
|         let password = "test".to_string(); | ||||
|  | ||||
|         let response = server | ||||
|             .client | ||||
|             .get(&format!( | ||||
|                 "{}/api/calendar/events?year=2024&month=12", | ||||
|                 server.base_url | ||||
|             )) | ||||
|             .header("Authorization", format!("Bearer {}", token)) | ||||
|             .header("X-CalDAV-Password", password) | ||||
|             .send() | ||||
|             .await | ||||
|             .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(); | ||||
|         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 | ||||
|     #[tokio::test] | ||||
|     async fn test_create_event() { | ||||
|         let server = TestServer::start().await; | ||||
|          | ||||
|  | ||||
|         // First login to get a token | ||||
|         let token = server.login().await; | ||||
|          | ||||
|  | ||||
|         // Load password from env for CalDAV requests | ||||
|         dotenvy::dotenv().ok();  | ||||
|         let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()); | ||||
|          | ||||
|         dotenvy::dotenv().ok(); | ||||
|         let password = "test".to_string(); | ||||
|  | ||||
|         let create_payload = json!({ | ||||
|             "title": "Integration Test Event", | ||||
|             "description": "Created by integration test", | ||||
|             "start_date": "2024-12-25", | ||||
|             "start_time": "10:00", | ||||
|             "end_date": "2024-12-25",  | ||||
|             "end_date": "2024-12-25", | ||||
|             "end_time": "11:00", | ||||
|             "location": "Test Location", | ||||
|             "all_day": false, | ||||
| @@ -275,8 +345,9 @@ mod tests { | ||||
|             "recurrence": "none", | ||||
|             "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)) | ||||
|             .header("Authorization", format!("Bearer {}", token)) | ||||
|             .header("X-CalDAV-Password", password) | ||||
| @@ -284,10 +355,10 @@ mod tests { | ||||
|             .send() | ||||
|             .await | ||||
|             .unwrap(); | ||||
|              | ||||
|  | ||||
|         let status = response.status(); | ||||
|         println!("Create event response status: {}", status); | ||||
|          | ||||
|  | ||||
|         // Note: This might fail if CalDAV server is not accessible, which is expected in CI | ||||
|         if status.is_success() { | ||||
|             let create_response: serde_json::Value = response.json().await.unwrap(); | ||||
| @@ -302,47 +373,58 @@ mod tests { | ||||
|     #[tokio::test] | ||||
|     async fn test_refresh_event() { | ||||
|         let server = TestServer::start().await; | ||||
|          | ||||
|  | ||||
|         // First login to get a token | ||||
|         let token = server.login().await; | ||||
|          | ||||
|  | ||||
|         // Load password from env for CalDAV requests | ||||
|         dotenvy::dotenv().ok(); | ||||
|         let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()); | ||||
|          | ||||
|         let password = "test".to_string(); | ||||
|  | ||||
|         // 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 response = server.client | ||||
|             .get(&format!("{}/api/calendar/events/{}", server.base_url, test_uid)) | ||||
|  | ||||
|         let response = server | ||||
|             .client | ||||
|             .get(&format!( | ||||
|                 "{}/api/calendar/events/{}", | ||||
|                 server.base_url, test_uid | ||||
|             )) | ||||
|             .header("Authorization", format!("Bearer {}", token)) | ||||
|             .header("X-CalDAV-Password", password) | ||||
|             .send() | ||||
|             .await | ||||
|             .unwrap(); | ||||
|              | ||||
|  | ||||
|         // We expect either 200 (if event exists) or 404 (if not found) - both are valid responses | ||||
|         assert!(response.status() == 200 || response.status() == 404,  | ||||
|                "Refresh event failed with unexpected status: {}", response.status()); | ||||
|                 | ||||
|         assert!( | ||||
|             response.status() == 200 || response.status() == 404, | ||||
|             "Refresh event failed with unexpected status: {}", | ||||
|             response.status() | ||||
|         ); | ||||
|  | ||||
|         println!("✓ Refresh event endpoint test passed"); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     /// Test invalid authentication | ||||
|     #[tokio::test] | ||||
|     async fn test_invalid_auth() { | ||||
|         let server = TestServer::start().await; | ||||
|          | ||||
|         let response = server.client | ||||
|  | ||||
|         let response = server | ||||
|             .client | ||||
|             .get(&format!("{}/api/user/info", server.base_url)) | ||||
|             .header("Authorization", "Bearer invalid-token") | ||||
|             .send() | ||||
|             .await | ||||
|             .unwrap(); | ||||
|              | ||||
|  | ||||
|         // Accept both 400 and 401 as valid responses for invalid tokens | ||||
|         assert!(response.status() == 401 || response.status() == 400,  | ||||
|                "Expected 401 or 400 for invalid token, got {}", response.status()); | ||||
|         assert!( | ||||
|             response.status() == 401 || response.status() == 400, | ||||
|             "Expected 401 or 400 for invalid token, got {}", | ||||
|             response.status() | ||||
|         ); | ||||
|         println!("✓ Invalid authentication test passed"); | ||||
|     } | ||||
|  | ||||
| @@ -350,13 +432,14 @@ mod tests { | ||||
|     #[tokio::test] | ||||
|     async fn test_missing_auth() { | ||||
|         let server = TestServer::start().await; | ||||
|          | ||||
|         let response = server.client | ||||
|  | ||||
|         let response = server | ||||
|             .client | ||||
|             .get(&format!("{}/api/user/info", server.base_url)) | ||||
|             .send() | ||||
|             .await | ||||
|             .unwrap(); | ||||
|              | ||||
|  | ||||
|         assert_eq!(response.status(), 401); | ||||
|         println!("✓ Missing authentication test passed"); | ||||
|     } | ||||
| @@ -367,20 +450,20 @@ mod tests { | ||||
|     #[tokio::test] | ||||
|     async fn test_create_event_series() { | ||||
|         let server = TestServer::start().await; | ||||
|          | ||||
|  | ||||
|         // First login to get a token | ||||
|         let token = server.login().await; | ||||
|          | ||||
|  | ||||
|         // Load password from env for CalDAV requests | ||||
|         dotenvy::dotenv().ok(); | ||||
|         let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()); | ||||
|          | ||||
|         let password = "test".to_string(); | ||||
|  | ||||
|         let create_payload = json!({ | ||||
|             "title": "Integration Test Series", | ||||
|             "description": "Created by integration test for series", | ||||
|             "start_date": "2024-12-25", | ||||
|             "start_time": "10:00", | ||||
|             "end_date": "2024-12-25",  | ||||
|             "end_date": "2024-12-25", | ||||
|             "end_time": "11:00", | ||||
|             "location": "Test Series Location", | ||||
|             "all_day": false, | ||||
| @@ -397,19 +480,23 @@ mod tests { | ||||
|             "recurrence_count": 4, | ||||
|             "calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery | ||||
|         }); | ||||
|          | ||||
|         let response = server.client | ||||
|             .post(&format!("{}/api/calendar/events/series/create", server.base_url)) | ||||
|  | ||||
|         let response = server | ||||
|             .client | ||||
|             .post(&format!( | ||||
|                 "{}/api/calendar/events/series/create", | ||||
|                 server.base_url | ||||
|             )) | ||||
|             .header("Authorization", format!("Bearer {}", token)) | ||||
|             .header("X-CalDAV-Password", password) | ||||
|             .json(&create_payload) | ||||
|             .send() | ||||
|             .await | ||||
|             .unwrap(); | ||||
|              | ||||
|  | ||||
|         let status = response.status(); | ||||
|         println!("Create series response status: {}", status); | ||||
|          | ||||
|  | ||||
|         // Note: This might fail if CalDAV server is not accessible, which is expected in CI | ||||
|         if status.is_success() { | ||||
|             let create_response: serde_json::Value = response.json().await.unwrap(); | ||||
| @@ -422,24 +509,24 @@ mod tests { | ||||
|     } | ||||
|  | ||||
|     /// Test event series update endpoint | ||||
|     #[tokio::test]  | ||||
|     #[tokio::test] | ||||
|     async fn test_update_event_series() { | ||||
|         let server = TestServer::start().await; | ||||
|          | ||||
|  | ||||
|         // First login to get a token | ||||
|         let token = server.login().await; | ||||
|          | ||||
|  | ||||
|         // Load password from env for CalDAV requests | ||||
|         dotenvy::dotenv().ok(); | ||||
|         let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()); | ||||
|          | ||||
|         let password = "test".to_string(); | ||||
|  | ||||
|         let update_payload = json!({ | ||||
|             "series_uid": "test-series-uid", | ||||
|             "title": "Updated Series Title", | ||||
|             "description": "Updated by integration test", | ||||
|             "start_date": "2024-12-26", | ||||
|             "start_time": "14:00", | ||||
|             "end_date": "2024-12-26",  | ||||
|             "end_date": "2024-12-26", | ||||
|             "end_time": "15:00", | ||||
|             "location": "Updated Location", | ||||
|             "all_day": false, | ||||
| @@ -457,27 +544,36 @@ mod tests { | ||||
|             "update_scope": "all_in_series", | ||||
|             "calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery | ||||
|         }); | ||||
|          | ||||
|         let response = server.client | ||||
|             .post(&format!("{}/api/calendar/events/series/update", server.base_url)) | ||||
|  | ||||
|         let response = server | ||||
|             .client | ||||
|             .post(&format!( | ||||
|                 "{}/api/calendar/events/series/update", | ||||
|                 server.base_url | ||||
|             )) | ||||
|             .header("Authorization", format!("Bearer {}", token)) | ||||
|             .header("X-CalDAV-Password", password) | ||||
|             .json(&update_payload) | ||||
|             .send() | ||||
|             .await | ||||
|             .unwrap(); | ||||
|              | ||||
|  | ||||
|         let status = response.status(); | ||||
|         println!("Update series response status: {}", status); | ||||
|          | ||||
|  | ||||
|         // Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI | ||||
|         if status.is_success() { | ||||
|             let update_response: serde_json::Value = response.json().await.unwrap(); | ||||
|             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"); | ||||
|         } 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 { | ||||
|             println!("⚠ Update event series test skipped (CalDAV server not accessible)"); | ||||
|         } | ||||
| @@ -487,40 +583,46 @@ mod tests { | ||||
|     #[tokio::test] | ||||
|     async fn test_delete_event_series() { | ||||
|         let server = TestServer::start().await; | ||||
|          | ||||
|         // First login to get a token   | ||||
|  | ||||
|         // First login to get a token | ||||
|         let token = server.login().await; | ||||
|          | ||||
|  | ||||
|         // Load password from env for CalDAV requests | ||||
|         dotenvy::dotenv().ok(); | ||||
|         let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()); | ||||
|          | ||||
|         let password = "test".to_string(); | ||||
|  | ||||
|         let delete_payload = json!({ | ||||
|             "series_uid": "test-series-to-delete", | ||||
|             "calendar_path": "/calendars/test/default/", | ||||
|             "event_href": "test-series.ics", | ||||
|             "delete_scope": "all_in_series" | ||||
|         }); | ||||
|          | ||||
|         let response = server.client | ||||
|             .post(&format!("{}/api/calendar/events/series/delete", server.base_url)) | ||||
|  | ||||
|         let response = server | ||||
|             .client | ||||
|             .post(&format!( | ||||
|                 "{}/api/calendar/events/series/delete", | ||||
|                 server.base_url | ||||
|             )) | ||||
|             .header("Authorization", format!("Bearer {}", token)) | ||||
|             .header("X-CalDAV-Password", password) | ||||
|             .json(&delete_payload) | ||||
|             .send() | ||||
|             .await | ||||
|             .unwrap(); | ||||
|              | ||||
|  | ||||
|         let status = response.status(); | ||||
|         println!("Delete series response status: {}", status); | ||||
|          | ||||
|  | ||||
|         // Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI | ||||
|         if status.is_success() { | ||||
|             let delete_response: serde_json::Value = response.json().await.unwrap(); | ||||
|             assert!(delete_response["success"].as_bool().unwrap_or(false)); | ||||
|             println!("✓ Delete event series test passed"); | ||||
|         } 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 { | ||||
|             println!("⚠ Delete event series test skipped (CalDAV server not accessible)"); | ||||
|         } | ||||
| @@ -530,17 +632,17 @@ mod tests { | ||||
|     #[tokio::test] | ||||
|     async fn test_invalid_update_scope() { | ||||
|         let server = TestServer::start().await; | ||||
|          | ||||
|  | ||||
|         // First login to get a token | ||||
|         let token = server.login().await; | ||||
|          | ||||
|  | ||||
|         let invalid_payload = json!({ | ||||
|             "series_uid": "test-series-uid", | ||||
|             "title": "Test Title", | ||||
|             "description": "Test", | ||||
|             "start_date": "2024-12-25", | ||||
|             "start_time": "10:00", | ||||
|             "end_date": "2024-12-25",  | ||||
|             "end_date": "2024-12-25", | ||||
|             "end_time": "11:00", | ||||
|             "location": "Test", | ||||
|             "all_day": false, | ||||
| @@ -554,16 +656,24 @@ mod tests { | ||||
|             "recurrence_days": [false, false, false, false, false, false, false], | ||||
|             "update_scope": "invalid_scope" // This should cause a 400 error | ||||
|         }); | ||||
|          | ||||
|         let response = server.client | ||||
|             .post(&format!("{}/api/calendar/events/series/update", server.base_url)) | ||||
|  | ||||
|         let response = server | ||||
|             .client | ||||
|             .post(&format!( | ||||
|                 "{}/api/calendar/events/series/update", | ||||
|                 server.base_url | ||||
|             )) | ||||
|             .header("Authorization", format!("Bearer {}", token)) | ||||
|             .json(&invalid_payload) | ||||
|             .send() | ||||
|             .await | ||||
|             .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"); | ||||
|     } | ||||
|  | ||||
| @@ -571,16 +681,16 @@ mod tests { | ||||
|     #[tokio::test] | ||||
|     async fn test_non_recurring_series_rejection() { | ||||
|         let server = TestServer::start().await; | ||||
|          | ||||
|  | ||||
|         // First login to get a token | ||||
|         let token = server.login().await; | ||||
|          | ||||
|  | ||||
|         let non_recurring_payload = json!({ | ||||
|             "title": "Non-recurring Event", | ||||
|             "description": "This should be rejected", | ||||
|             "start_date": "2024-12-25", | ||||
|             "start_time": "10:00", | ||||
|             "end_date": "2024-12-25",  | ||||
|             "end_date": "2024-12-25", | ||||
|             "end_time": "11:00", | ||||
|             "location": "Test", | ||||
|             "all_day": false, | ||||
| @@ -593,16 +703,24 @@ mod tests { | ||||
|             "recurrence": "none", // This should cause rejection | ||||
|             "recurrence_days": [false, false, false, false, false, false, false] | ||||
|         }); | ||||
|          | ||||
|         let response = server.client | ||||
|             .post(&format!("{}/api/calendar/events/series/create", server.base_url)) | ||||
|  | ||||
|         let response = server | ||||
|             .client | ||||
|             .post(&format!( | ||||
|                 "{}/api/calendar/events/series/create", | ||||
|                 server.base_url | ||||
|             )) | ||||
|             .header("Authorization", format!("Bearer {}", token)) | ||||
|             .json(&non_recurring_payload) | ||||
|             .send() | ||||
|             .await | ||||
|             .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"); | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| //! Common types and enums used across calendar components | ||||
|  | ||||
| use chrono::{DateTime, Utc, Duration}; | ||||
| use chrono::{DateTime, Duration, Utc}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| // ==================== ENUMS AND COMMON TYPES ==================== | ||||
| @@ -22,7 +22,7 @@ pub enum EventClass { | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] | ||||
| pub enum TimeTransparency { | ||||
|     Opaque,      // OPAQUE - time is not available | ||||
|     Transparent, // TRANSPARENT - time is available   | ||||
|     Transparent, // TRANSPARENT - time is available | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] | ||||
| @@ -64,11 +64,11 @@ pub enum AlarmAction { | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub struct CalendarUser { | ||||
|     pub cal_address: String,                    // Calendar user address (usually email) | ||||
|     pub common_name: Option<String>,            // CN parameter - display name | ||||
|     pub dir_entry_ref: Option<String>,          // DIR parameter - directory entry | ||||
|     pub sent_by: Option<String>,                // SENT-BY parameter | ||||
|     pub language: Option<String>,               // LANGUAGE parameter | ||||
|     pub cal_address: String,           // Calendar user address (usually email) | ||||
|     pub common_name: Option<String>,   // CN parameter - display name | ||||
|     pub dir_entry_ref: Option<String>, // DIR parameter - directory entry | ||||
|     pub sent_by: Option<String>,       // SENT-BY parameter | ||||
|     pub language: Option<String>,      // LANGUAGE parameter | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| @@ -78,130 +78,130 @@ pub struct Attendee { | ||||
|     pub role: Option<AttendeeRole>,             // ROLE parameter | ||||
|     pub part_stat: Option<ParticipationStatus>, // PARTSTAT parameter | ||||
|     pub rsvp: Option<bool>,                     // RSVP parameter | ||||
|     pub cu_type: Option<String>,                // CUTYPE parameter (INDIVIDUAL, GROUP, RESOURCE, ROOM, UNKNOWN) | ||||
|     pub member: Vec<String>,                    // MEMBER parameter | ||||
|     pub delegated_to: Vec<String>,              // DELEGATED-TO parameter   | ||||
|     pub delegated_from: Vec<String>,            // DELEGATED-FROM parameter | ||||
|     pub sent_by: Option<String>,                // SENT-BY parameter | ||||
|     pub dir_entry_ref: Option<String>,          // DIR parameter | ||||
|     pub language: Option<String>,               // LANGUAGE parameter | ||||
|     pub cu_type: Option<String>, // CUTYPE parameter (INDIVIDUAL, GROUP, RESOURCE, ROOM, UNKNOWN) | ||||
|     pub member: Vec<String>,     // MEMBER parameter | ||||
|     pub delegated_to: Vec<String>, // DELEGATED-TO parameter | ||||
|     pub delegated_from: Vec<String>, // DELEGATED-FROM parameter | ||||
|     pub sent_by: Option<String>, // SENT-BY parameter | ||||
|     pub dir_entry_ref: Option<String>, // DIR parameter | ||||
|     pub language: Option<String>, // LANGUAGE parameter | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub struct VAlarm { | ||||
|     pub action: AlarmAction,                    // Action (ACTION) - REQUIRED | ||||
|     pub trigger: AlarmTrigger,                  // Trigger (TRIGGER) - REQUIRED | ||||
|     pub duration: Option<Duration>,             // Duration (DURATION) | ||||
|     pub repeat: Option<u32>,                    // Repeat count (REPEAT) | ||||
|     pub description: Option<String>,            // Description for DISPLAY/EMAIL | ||||
|     pub summary: Option<String>,                // Summary for EMAIL | ||||
|     pub attendees: Vec<Attendee>,               // Attendees for EMAIL | ||||
|     pub attach: Vec<Attachment>,                // Attachments for AUDIO/EMAIL | ||||
|     pub action: AlarmAction,         // Action (ACTION) - REQUIRED | ||||
|     pub trigger: AlarmTrigger,       // Trigger (TRIGGER) - REQUIRED | ||||
|     pub duration: Option<Duration>,  // Duration (DURATION) | ||||
|     pub repeat: Option<u32>,         // Repeat count (REPEAT) | ||||
|     pub description: Option<String>, // Description for DISPLAY/EMAIL | ||||
|     pub summary: Option<String>,     // Summary for EMAIL | ||||
|     pub attendees: Vec<Attendee>,    // Attendees for EMAIL | ||||
|     pub attach: Vec<Attachment>,     // Attachments for AUDIO/EMAIL | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub enum AlarmTrigger { | ||||
|     DateTime(DateTime<Utc>),                    // Absolute trigger time | ||||
|     Duration(Duration),                         // Duration relative to start/end | ||||
|     DateTime(DateTime<Utc>), // Absolute trigger time | ||||
|     Duration(Duration),      // Duration relative to start/end | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub struct Attachment { | ||||
|     pub format_type: Option<String>,            // FMTTYPE parameter (MIME type) | ||||
|     pub encoding: Option<String>,               // ENCODING parameter | ||||
|     pub value: Option<String>,                  // VALUE parameter (BINARY or URI) | ||||
|     pub uri: Option<String>,                    // URI reference | ||||
|     pub binary_data: Option<Vec<u8>>,           // Binary data (when ENCODING=BASE64) | ||||
|     pub format_type: Option<String>,  // FMTTYPE parameter (MIME type) | ||||
|     pub encoding: Option<String>,     // ENCODING parameter | ||||
|     pub value: Option<String>,        // VALUE parameter (BINARY or URI) | ||||
|     pub uri: Option<String>,          // URI reference | ||||
|     pub binary_data: Option<Vec<u8>>, // Binary data (when ENCODING=BASE64) | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub struct GeographicPosition { | ||||
|     pub latitude: f64,                          // Latitude in decimal degrees | ||||
|     pub longitude: f64,                         // Longitude in decimal degrees | ||||
|     pub latitude: f64,  // Latitude in decimal degrees | ||||
|     pub longitude: f64, // Longitude in decimal degrees | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub struct VTimeZone { | ||||
|     pub tzid: String,                           // Time zone ID (TZID) - REQUIRED | ||||
|     pub last_modified: Option<DateTime<Utc>>,   // Last modified (LAST-MODIFIED) | ||||
|     pub tzurl: Option<String>,                  // Time zone URL (TZURL) | ||||
|     pub tzid: String,                                // Time zone ID (TZID) - REQUIRED | ||||
|     pub last_modified: Option<DateTime<Utc>>,        // Last modified (LAST-MODIFIED) | ||||
|     pub tzurl: Option<String>,                       // Time zone URL (TZURL) | ||||
|     pub standard_components: Vec<TimeZoneComponent>, // STANDARD components | ||||
|     pub daylight_components: Vec<TimeZoneComponent>, // DAYLIGHT components | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub struct TimeZoneComponent { | ||||
|     pub dtstart: DateTime<Utc>,                 // Start of this time zone definition | ||||
|     pub tzoffset_to: String,                    // UTC offset for this component | ||||
|     pub tzoffset_from: String,                  // UTC offset before this component   | ||||
|     pub rrule: Option<String>,                  // Recurrence rule | ||||
|     pub rdate: Vec<DateTime<Utc>>,              // Recurrence dates | ||||
|     pub tzname: Vec<String>,                    // Time zone names | ||||
|     pub comment: Vec<String>,                   // Comments | ||||
|     pub dtstart: DateTime<Utc>,    // Start of this time zone definition | ||||
|     pub tzoffset_to: String,       // UTC offset for this component | ||||
|     pub tzoffset_from: String,     // UTC offset before this component | ||||
|     pub rrule: Option<String>,     // Recurrence rule | ||||
|     pub rdate: Vec<DateTime<Utc>>, // Recurrence dates | ||||
|     pub tzname: Vec<String>,       // Time zone names | ||||
|     pub comment: Vec<String>,      // Comments | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub struct VJournal { | ||||
|     // Required properties | ||||
|     pub dtstamp: DateTime<Utc>,                 // Date-time stamp (DTSTAMP) - REQUIRED | ||||
|     pub uid: String,                            // Unique identifier (UID) - REQUIRED | ||||
|      | ||||
|     pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED | ||||
|     pub uid: String,            // Unique identifier (UID) - REQUIRED | ||||
|  | ||||
|     // Optional properties | ||||
|     pub dtstart: Option<DateTime<Utc>>,         // Start date-time (DTSTART) | ||||
|     pub summary: Option<String>,                // Summary/title (SUMMARY)  | ||||
|     pub description: Option<String>,            // Description (DESCRIPTION) | ||||
|      | ||||
|     pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART) | ||||
|     pub summary: Option<String>,        // Summary/title (SUMMARY) | ||||
|     pub description: Option<String>,    // Description (DESCRIPTION) | ||||
|  | ||||
|     // Classification and status | ||||
|     pub class: Option<EventClass>,              // Classification (CLASS) | ||||
|     pub status: Option<String>,                 // Status (STATUS) | ||||
|      | ||||
|     pub class: Option<EventClass>, // Classification (CLASS) | ||||
|     pub status: Option<String>,    // Status (STATUS) | ||||
|  | ||||
|     // People and organization | ||||
|     pub organizer: Option<CalendarUser>,        // Organizer (ORGANIZER) | ||||
|     pub attendees: Vec<Attendee>,               // Attendees (ATTENDEE) | ||||
|      | ||||
|     pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER) | ||||
|     pub attendees: Vec<Attendee>,        // Attendees (ATTENDEE) | ||||
|  | ||||
|     // Categorization | ||||
|     pub categories: Vec<String>,                // Categories (CATEGORIES) | ||||
|      | ||||
|     pub categories: Vec<String>, // Categories (CATEGORIES) | ||||
|  | ||||
|     // Versioning and modification | ||||
|     pub sequence: Option<u32>,                  // Sequence number (SEQUENCE) | ||||
|     pub created: Option<DateTime<Utc>>,         // Creation time (CREATED) | ||||
|     pub last_modified: Option<DateTime<Utc>>,   // Last modified (LAST-MODIFIED) | ||||
|      | ||||
|     pub sequence: Option<u32>,                // Sequence number (SEQUENCE) | ||||
|     pub created: Option<DateTime<Utc>>,       // Creation time (CREATED) | ||||
|     pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED) | ||||
|  | ||||
|     // Recurrence | ||||
|     pub rrule: Option<String>,                  // Recurrence rule (RRULE) | ||||
|     pub rdate: Vec<DateTime<Utc>>,              // Recurrence dates (RDATE) | ||||
|     pub exdate: Vec<DateTime<Utc>>,             // Exception dates (EXDATE) | ||||
|     pub recurrence_id: Option<DateTime<Utc>>,   // Recurrence ID (RECURRENCE-ID) | ||||
|      | ||||
|     pub rrule: Option<String>,                // Recurrence rule (RRULE) | ||||
|     pub rdate: Vec<DateTime<Utc>>,            // Recurrence dates (RDATE) | ||||
|     pub exdate: Vec<DateTime<Utc>>,           // Exception dates (EXDATE) | ||||
|     pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID) | ||||
|  | ||||
|     // Attachments | ||||
|     pub attachments: Vec<Attachment>,           // Attachments (ATTACH) | ||||
|     pub attachments: Vec<Attachment>, // Attachments (ATTACH) | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub struct VFreeBusy { | ||||
|     // Required properties | ||||
|     pub dtstamp: DateTime<Utc>,                 // Date-time stamp (DTSTAMP) - REQUIRED | ||||
|     pub uid: String,                            // Unique identifier (UID) - REQUIRED | ||||
|      | ||||
|     pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED | ||||
|     pub uid: String,            // Unique identifier (UID) - REQUIRED | ||||
|  | ||||
|     // Optional date-time properties | ||||
|     pub dtstart: Option<DateTime<Utc>>,         // Start date-time (DTSTART) | ||||
|     pub dtend: Option<DateTime<Utc>>,           // End date-time (DTEND) | ||||
|      | ||||
|     pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART) | ||||
|     pub dtend: Option<DateTime<Utc>>,   // End date-time (DTEND) | ||||
|  | ||||
|     // People | ||||
|     pub organizer: Option<CalendarUser>,        // Organizer (ORGANIZER) | ||||
|     pub attendees: Vec<Attendee>,               // Attendees (ATTENDEE) | ||||
|      | ||||
|     pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER) | ||||
|     pub attendees: Vec<Attendee>,        // Attendees (ATTENDEE) | ||||
|  | ||||
|     // Free/busy time | ||||
|     pub freebusy: Vec<FreeBusyTime>,            // Free/busy time periods | ||||
|     pub url: Option<String>,                    // URL (URL) | ||||
|     pub comment: Vec<String>,                   // Comments (COMMENT) | ||||
|     pub contact: Option<String>,                // Contact information (CONTACT) | ||||
|     pub freebusy: Vec<FreeBusyTime>, // Free/busy time periods | ||||
|     pub url: Option<String>,         // URL (URL) | ||||
|     pub comment: Vec<String>,        // Comments (COMMENT) | ||||
|     pub contact: Option<String>,     // Contact information (CONTACT) | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub struct FreeBusyTime { | ||||
|     pub fb_type: FreeBusyType,                  // Free/busy type | ||||
|     pub periods: Vec<Period>,                   // Time periods | ||||
|     pub fb_type: FreeBusyType, // Free/busy type | ||||
|     pub periods: Vec<Period>,  // Time periods | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] | ||||
| @@ -214,7 +214,7 @@ pub enum FreeBusyType { | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub struct Period { | ||||
|     pub start: DateTime<Utc>,                   // Period start | ||||
|     pub end: Option<DateTime<Utc>>,             // Period end | ||||
|     pub duration: Option<Duration>,             // Period duration (alternative to end) | ||||
| } | ||||
|     pub start: DateTime<Utc>,       // Period start | ||||
|     pub end: Option<DateTime<Utc>>, // Period end | ||||
|     pub duration: Option<Duration>, // Period duration (alternative to end) | ||||
| } | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| //! RFC 5545 Compliant Calendar Models | ||||
| //!  | ||||
| //! | ||||
| //! This crate provides shared data structures for calendar applications | ||||
| //! that comply with RFC 5545 (iCalendar) specification. | ||||
|  | ||||
| pub mod vevent; | ||||
| pub mod common; | ||||
| pub mod vevent; | ||||
|  | ||||
| pub use common::*; | ||||
| pub use vevent::*; | ||||
| pub use common::*; | ||||
| @@ -1,66 +1,66 @@ | ||||
| //! VEvent - RFC 5545 compliant calendar event structure | ||||
|  | ||||
| use chrono::{DateTime, Utc, Duration}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use crate::common::*; | ||||
| use chrono::{DateTime, Duration, Utc}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| // ==================== VEVENT COMPONENT ==================== | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub struct VEvent { | ||||
|     // Required properties | ||||
|     pub dtstamp: DateTime<Utc>,                 // Date-time stamp (DTSTAMP) - REQUIRED | ||||
|     pub uid: String,                            // Unique identifier (UID) - REQUIRED | ||||
|     pub dtstart: DateTime<Utc>,                 // Start date-time (DTSTART) - REQUIRED | ||||
|      | ||||
|     pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED | ||||
|     pub uid: String,            // Unique identifier (UID) - REQUIRED | ||||
|     pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED | ||||
|  | ||||
|     // Optional properties (commonly used) | ||||
|     pub dtend: Option<DateTime<Utc>>,           // End date-time (DTEND) | ||||
|     pub duration: Option<Duration>,             // Duration (DURATION) - alternative to DTEND | ||||
|     pub summary: Option<String>,                // Summary/title (SUMMARY) | ||||
|     pub description: Option<String>,            // Description (DESCRIPTION) | ||||
|     pub location: Option<String>,               // Location (LOCATION) | ||||
|      | ||||
|     pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND) | ||||
|     pub duration: Option<Duration>,   // Duration (DURATION) - alternative to DTEND | ||||
|     pub summary: Option<String>,      // Summary/title (SUMMARY) | ||||
|     pub description: Option<String>,  // Description (DESCRIPTION) | ||||
|     pub location: Option<String>,     // Location (LOCATION) | ||||
|  | ||||
|     // Classification and status | ||||
|     pub class: Option<EventClass>,              // Classification (CLASS) | ||||
|     pub status: Option<EventStatus>,            // Status (STATUS) | ||||
|     pub transp: Option<TimeTransparency>,       // Time transparency (TRANSP) | ||||
|     pub priority: Option<u8>,                   // Priority 0-9 (PRIORITY) | ||||
|      | ||||
|     pub class: Option<EventClass>,        // Classification (CLASS) | ||||
|     pub status: Option<EventStatus>,      // Status (STATUS) | ||||
|     pub transp: Option<TimeTransparency>, // Time transparency (TRANSP) | ||||
|     pub priority: Option<u8>,             // Priority 0-9 (PRIORITY) | ||||
|  | ||||
|     // People and organization | ||||
|     pub organizer: Option<CalendarUser>,        // Organizer (ORGANIZER) | ||||
|     pub attendees: Vec<Attendee>,               // Attendees (ATTENDEE) | ||||
|     pub contact: Option<String>,                // Contact information (CONTACT) | ||||
|      | ||||
|     pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER) | ||||
|     pub attendees: Vec<Attendee>,        // Attendees (ATTENDEE) | ||||
|     pub contact: Option<String>,         // Contact information (CONTACT) | ||||
|  | ||||
|     // Categorization and relationships | ||||
|     pub categories: Vec<String>,                // Categories (CATEGORIES) | ||||
|     pub comment: Option<String>,                // Comment (COMMENT) | ||||
|     pub resources: Vec<String>,                 // Resources (RESOURCES) | ||||
|     pub related_to: Option<String>,             // Related component (RELATED-TO) | ||||
|     pub url: Option<String>,                    // URL (URL) | ||||
|      | ||||
|     pub categories: Vec<String>,    // Categories (CATEGORIES) | ||||
|     pub comment: Option<String>,    // Comment (COMMENT) | ||||
|     pub resources: Vec<String>,     // Resources (RESOURCES) | ||||
|     pub related_to: Option<String>, // Related component (RELATED-TO) | ||||
|     pub url: Option<String>,        // URL (URL) | ||||
|  | ||||
|     // Geographical | ||||
|     pub geo: Option<GeographicPosition>,        // Geographic position (GEO) | ||||
|      | ||||
|     pub geo: Option<GeographicPosition>, // Geographic position (GEO) | ||||
|  | ||||
|     // Versioning and modification | ||||
|     pub sequence: Option<u32>,                  // Sequence number (SEQUENCE) | ||||
|     pub created: Option<DateTime<Utc>>,         // Creation time (CREATED) | ||||
|     pub last_modified: Option<DateTime<Utc>>,   // Last modified (LAST-MODIFIED) | ||||
|      | ||||
|     pub sequence: Option<u32>,                // Sequence number (SEQUENCE) | ||||
|     pub created: Option<DateTime<Utc>>,       // Creation time (CREATED) | ||||
|     pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED) | ||||
|  | ||||
|     // Recurrence | ||||
|     pub rrule: Option<String>,                  // Recurrence rule (RRULE) | ||||
|     pub rdate: Vec<DateTime<Utc>>,              // Recurrence dates (RDATE) | ||||
|     pub exdate: Vec<DateTime<Utc>>,             // Exception dates (EXDATE) | ||||
|     pub recurrence_id: Option<DateTime<Utc>>,   // Recurrence ID (RECURRENCE-ID) | ||||
|      | ||||
|     pub rrule: Option<String>,                // Recurrence rule (RRULE) | ||||
|     pub rdate: Vec<DateTime<Utc>>,            // Recurrence dates (RDATE) | ||||
|     pub exdate: Vec<DateTime<Utc>>,           // Exception dates (EXDATE) | ||||
|     pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID) | ||||
|  | ||||
|     // Alarms and attachments | ||||
|     pub alarms: Vec<VAlarm>,                    // VALARM components | ||||
|     pub attachments: Vec<Attachment>,           // Attachments (ATTACH) | ||||
|      | ||||
|     pub alarms: Vec<VAlarm>,          // VALARM components | ||||
|     pub attachments: Vec<Attachment>, // Attachments (ATTACH) | ||||
|  | ||||
|     // CalDAV specific (for implementation) | ||||
|     pub etag: Option<String>,                   // ETag for CalDAV | ||||
|     pub href: Option<String>,                   // Href for CalDAV | ||||
|     pub calendar_path: Option<String>,          // Calendar path | ||||
|     pub all_day: bool,                          // All-day event flag | ||||
|     pub etag: Option<String>,          // ETag for CalDAV | ||||
|     pub href: Option<String>,          // Href for CalDAV | ||||
|     pub calendar_path: Option<String>, // Calendar path | ||||
|     pub all_day: bool,                 // All-day event flag | ||||
| } | ||||
|  | ||||
| impl VEvent { | ||||
| @@ -129,7 +129,9 @@ impl VEvent { | ||||
|  | ||||
|     /// Helper method to get display title (summary or "Untitled Event") | ||||
|     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 | ||||
| @@ -151,7 +153,7 @@ impl VEvent { | ||||
|     pub fn get_status_display(&self) -> &'static str { | ||||
|         match &self.status { | ||||
|             Some(EventStatus::Tentative) => "Tentative", | ||||
|             Some(EventStatus::Confirmed) => "Confirmed",  | ||||
|             Some(EventStatus::Confirmed) => "Confirmed", | ||||
|             Some(EventStatus::Cancelled) => "Cancelled", | ||||
|             None => "Confirmed", // Default | ||||
|         } | ||||
| @@ -180,4 +182,4 @@ impl VEvent { | ||||
|             Some(p) => format!("Priority {}", p), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -11,11 +11,22 @@ pub struct CalDAVLoginRequest { | ||||
|     pub password: String, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Serialize, Deserialize, Clone)] | ||||
| pub struct UserPreferencesResponse { | ||||
|     pub calendar_selected_date: Option<String>, | ||||
|     pub calendar_time_increment: Option<i32>, | ||||
|     pub calendar_view_mode: Option<String>, | ||||
|     pub calendar_theme: Option<String>, | ||||
|     pub calendar_colors: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Serialize, Deserialize)] | ||||
| pub struct AuthResponse { | ||||
|     pub token: String, | ||||
|     pub session_token: String, | ||||
|     pub username: String, | ||||
|     pub server_url: String, | ||||
|     pub preferences: UserPreferencesResponse, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Deserialize)] | ||||
| @@ -34,14 +45,14 @@ impl AuthService { | ||||
|         let base_url = option_env!("BACKEND_API_URL") | ||||
|             .unwrap_or("http://localhost:3000/api") | ||||
|             .to_string(); | ||||
|          | ||||
|  | ||||
|         Self { base_url } | ||||
|     } | ||||
|  | ||||
|     pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, String> { | ||||
|         self.post_json("/auth/login", &request).await | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // Helper method for POST requests with JSON body | ||||
|     async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>( | ||||
|         &self, | ||||
| @@ -49,9 +60,9 @@ impl AuthService { | ||||
|         body: &T, | ||||
|     ) -> Result<R, String> { | ||||
|         let window = web_sys::window().ok_or("No global window exists")?; | ||||
|          | ||||
|         let json_body = serde_json::to_string(body) | ||||
|             .map_err(|e| format!("JSON serialization failed: {}", e))?; | ||||
|  | ||||
|         let json_body = | ||||
|             serde_json::to_string(body).map_err(|e| format!("JSON serialization failed: {}", e))?; | ||||
|  | ||||
|         let opts = RequestInit::new(); | ||||
|         opts.set_method("POST"); | ||||
| @@ -62,23 +73,27 @@ impl AuthService { | ||||
|         let request = Request::new_with_str_and_init(&url, &opts) | ||||
|             .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))?; | ||||
|  | ||||
|         let resp_value = JsFuture::from(window.fetch_with_request(&request)) | ||||
|             .await | ||||
|             .map_err(|e| format!("Network request failed: {:?}", e))?; | ||||
|  | ||||
|         let resp: Response = resp_value.dyn_into() | ||||
|         let resp: Response = resp_value | ||||
|             .dyn_into() | ||||
|             .map_err(|e| format!("Response cast failed: {:?}", e))?; | ||||
|  | ||||
|         let text = JsFuture::from(resp.text() | ||||
|             .map_err(|e| format!("Text extraction failed: {:?}", e))?) | ||||
|             .await | ||||
|             .map_err(|e| format!("Text promise failed: {:?}", e))?; | ||||
|         let text = JsFuture::from( | ||||
|             resp.text() | ||||
|                 .map_err(|e| format!("Text extraction failed: {:?}", e))?, | ||||
|         ) | ||||
|         .await | ||||
|         .map_err(|e| format!("Text promise failed: {:?}", e))?; | ||||
|  | ||||
|         let text_string = text.as_string() | ||||
|             .ok_or("Response text is not a string")?; | ||||
|         let text_string = text.as_string().ok_or("Response text is not a string")?; | ||||
|  | ||||
|         if resp.ok() { | ||||
|             serde_json::from_str::<R>(&text_string) | ||||
| @@ -92,4 +107,4 @@ impl AuthService { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,19 +1,16 @@ | ||||
| use yew::prelude::*; | ||||
| use chrono::{Datelike, Local, NaiveDate, Duration}; | ||||
| use crate::components::{ | ||||
|     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 web_sys::MouseEvent; | ||||
| use crate::services::calendar_service::UserInfo; | ||||
| use crate::models::ical::VEvent; | ||||
| use crate::components::{EventModal, ViewMode, CalendarHeader, MonthView, WeekView, CreateEventModal, EventCreationData}; | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| 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] | ||||
|     pub user_info: Option<UserInfo>, | ||||
|     #[prop_or_default] | ||||
| @@ -25,7 +22,17 @@ pub struct CalendarProps { | ||||
|     #[prop_or_default] | ||||
|     pub on_create_event_request: Option<Callback<EventCreationData>>, | ||||
|     #[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] | ||||
|     pub context_menus_open: bool, | ||||
| } | ||||
| @@ -33,6 +40,12 @@ pub struct CalendarProps { | ||||
| #[function_component] | ||||
| pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|     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) | ||||
|     let selected_date = use_state(|| { | ||||
|         // Try to load saved selected date from localStorage | ||||
| @@ -55,20 +68,19 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|             } | ||||
|         } | ||||
|     }); | ||||
|      | ||||
|  | ||||
|     // Track the display date (what to show in the view) | ||||
|     let current_date = use_state(|| { | ||||
|         match props.view { | ||||
|             ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date), | ||||
|             ViewMode::Week => *selected_date, | ||||
|         } | ||||
|     let current_date = use_state(|| match props.view { | ||||
|         ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date), | ||||
|         ViewMode::Week => *selected_date, | ||||
|     }); | ||||
|     let selected_event = use_state(|| None::<VEvent>); | ||||
|      | ||||
|  | ||||
|     // State for create event modal | ||||
|     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) | ||||
|     let time_increment = use_state(|| { | ||||
|         // Try to load saved time increment from localStorage | ||||
| @@ -82,7 +94,155 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|             15 | ||||
|         } | ||||
|     }); | ||||
|      | ||||
|  | ||||
|     // 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 | ||||
|     { | ||||
|         let current_date = current_date.clone(); | ||||
| @@ -98,7 +258,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|             || {} | ||||
|         }); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     let on_prev = { | ||||
|         let current_date = current_date.clone(); | ||||
|         let selected_date = selected_date.clone(); | ||||
| @@ -110,19 +270,22 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                     let prev_month = *current_date - Duration::days(1); | ||||
|                     let first_of_prev = prev_month.with_day(1).unwrap(); | ||||
|                     (first_of_prev, first_of_prev) | ||||
|                 }, | ||||
|                 } | ||||
|                 ViewMode::Week => { | ||||
|                     // Go to previous week | ||||
|                     let prev_week = *selected_date - Duration::weeks(1); | ||||
|                     (prev_week, prev_week) | ||||
|                 }, | ||||
|                 } | ||||
|             }; | ||||
|             selected_date.set(new_selected); | ||||
|             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(), | ||||
|             ); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     let on_next = { | ||||
|         let current_date = current_date.clone(); | ||||
|         let selected_date = selected_date.clone(); | ||||
| @@ -134,19 +297,23 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                     let next_month = if current_date.month() == 12 { | ||||
|                         NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap() | ||||
|                     } 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) | ||||
|                 }, | ||||
|                 } | ||||
|                 ViewMode::Week => { | ||||
|                     // Go to next week | ||||
|                     let next_week = *selected_date + Duration::weeks(1); | ||||
|                     (next_week, next_week) | ||||
|                 }, | ||||
|                 } | ||||
|             }; | ||||
|             selected_date.set(new_selected); | ||||
|             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,15 +327,18 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                 ViewMode::Month => { | ||||
|                     let first_of_today = today.with_day(1).unwrap(); | ||||
|                     (today, first_of_today) // Select today, but display the month | ||||
|                 }, | ||||
|                 } | ||||
|                 ViewMode::Week => (today, today), // Select and display today | ||||
|             }; | ||||
|             selected_date.set(new_selected); | ||||
|             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(), | ||||
|             ); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     // Handle time increment toggle | ||||
|     let on_time_increment_toggle = { | ||||
|         let time_increment = time_increment.clone(); | ||||
| @@ -179,32 +349,68 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|             let _ = LocalStorage::set("calendar_time_increment", next); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     // Handle drag-to-create event | ||||
|     let on_create_event = { | ||||
|         let show_create_modal = show_create_modal.clone(); | ||||
|         let create_event_data = create_event_data.clone(); | ||||
|         Callback::from(move |(_date, start_datetime, end_datetime): (NaiveDate, chrono::NaiveDateTime, 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); | ||||
|         }) | ||||
|         Callback::from( | ||||
|             move |(_date, start_datetime, end_datetime): ( | ||||
|                 NaiveDate, | ||||
|                 chrono::NaiveDateTime, | ||||
|                 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 | ||||
|     let on_event_update = { | ||||
|         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>)| { | ||||
|             if let Some(callback) = &on_event_update_request { | ||||
|                 callback.emit((event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date)); | ||||
|             } | ||||
|         }) | ||||
|         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>, | ||||
|             )| { | ||||
|                 if let Some(callback) = &on_event_update_request { | ||||
|                     callback.emit(( | ||||
|                         event, | ||||
|                         new_start, | ||||
|                         new_end, | ||||
|                         preserve_rrule, | ||||
|                         until_date, | ||||
|                         update_scope, | ||||
|                         occurrence_date, | ||||
|                     )); | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div class={classes!("calendar", match props.view { ViewMode::Week => Some("week-view"), _ => None })}> | ||||
|             <CalendarHeader  | ||||
|             <CalendarHeader | ||||
|                 current_date={*current_date} | ||||
|                 view_mode={props.view.clone()} | ||||
|                 on_prev={on_prev} | ||||
| @@ -213,9 +419,22 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                 time_increment={Some(*time_increment)} | ||||
|                 on_time_increment_toggle={Some(on_time_increment_toggle)} | ||||
|             /> | ||||
|              | ||||
|  | ||||
|             { | ||||
|                 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 => { | ||||
|                         let on_day_select = { | ||||
|                             let selected_date = selected_date.clone(); | ||||
| @@ -224,14 +443,14 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                                 let _ = LocalStorage::set("calendar_selected_date", date.format("%Y-%m-%d").to_string()); | ||||
|                             }) | ||||
|                         }; | ||||
|                          | ||||
|  | ||||
|                         html! { | ||||
|                             <MonthView | ||||
|                                 current_month={*current_date} | ||||
|                                 today={today} | ||||
|                                 events={props.events.clone()} | ||||
|                                 on_event_click={props.on_event_click.clone()} | ||||
|                                 refreshing_event_uid={props.refreshing_event_uid.clone()} | ||||
|                                 events={(*events).clone()} | ||||
|                                 on_event_click={on_event_click.clone()} | ||||
|                                 refreshing_event_uid={(*refreshing_event_uid).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()} | ||||
| @@ -244,9 +463,9 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                         <WeekView | ||||
|                             current_date={*current_date} | ||||
|                             today={today} | ||||
|                             events={props.events.clone()} | ||||
|                             on_event_click={props.on_event_click.clone()} | ||||
|                             refreshing_event_uid={props.refreshing_event_uid.clone()} | ||||
|                             events={(*events).clone()} | ||||
|                             on_event_click={on_event_click.clone()} | ||||
|                             refreshing_event_uid={(*refreshing_event_uid).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()} | ||||
| @@ -257,11 +476,12 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                             time_increment={*time_increment} | ||||
|                         /> | ||||
|                     }, | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|  | ||||
|             // Event details modal | ||||
|             <EventModal  | ||||
|             <EventModal | ||||
|                 event={(*selected_event).clone()} | ||||
|                 on_close={{ | ||||
|                     let selected_event_clone = selected_event.clone(); | ||||
| @@ -270,7 +490,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                     }) | ||||
|                 }} | ||||
|             /> | ||||
|              | ||||
|  | ||||
|             // Create event modal | ||||
|             <CreateEventModal | ||||
|                 is_open={*show_create_modal} | ||||
| @@ -294,7 +514,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                     Callback::from(move |event_data: EventCreationData| { | ||||
|                         show_create_modal.set(false); | ||||
|                         create_event_data.set(None); | ||||
|                          | ||||
|  | ||||
|                         // Emit the create event request to parent | ||||
|                         if let Some(callback) = &on_create_event_request { | ||||
|                             callback.emit(event_data); | ||||
| @@ -313,4 +533,4 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|             /> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::MouseEvent; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct CalendarContextMenuProps { | ||||
| @@ -13,7 +13,7 @@ pub struct CalendarContextMenuProps { | ||||
| #[function_component(CalendarContextMenu)] | ||||
| pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html { | ||||
|     let menu_ref = use_node_ref(); | ||||
|      | ||||
|  | ||||
|     if !props.is_open { | ||||
|         return html! {}; | ||||
|     } | ||||
| @@ -33,9 +33,9 @@ pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html { | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div  | ||||
|         <div | ||||
|             ref={menu_ref} | ||||
|             class="context-menu"  | ||||
|             class="context-menu" | ||||
|             style={style} | ||||
|         > | ||||
|             <div class="context-menu-item context-menu-create" onclick={on_create_event_click}> | ||||
| @@ -44,4 +44,4 @@ pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html { | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| use yew::prelude::*; | ||||
| use chrono::{NaiveDate, Datelike}; | ||||
| use crate::components::ViewMode; | ||||
| use chrono::{Datelike, NaiveDate}; | ||||
| use web_sys::MouseEvent; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct CalendarHeaderProps { | ||||
| @@ -18,7 +18,11 @@ pub struct CalendarHeaderProps { | ||||
|  | ||||
| #[function_component(CalendarHeader)] | ||||
| 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! { | ||||
|         <div class="calendar-header"> | ||||
| @@ -48,7 +52,7 @@ pub fn calendar_header(props: &CalendarHeaderProps) -> Html { | ||||
| fn get_month_name(month: u32) -> &'static str { | ||||
|     match month { | ||||
|         1 => "January", | ||||
|         2 => "February",  | ||||
|         2 => "February", | ||||
|         3 => "March", | ||||
|         4 => "April", | ||||
|         5 => "May", | ||||
| @@ -59,6 +63,6 @@ fn get_month_name(month: u32) -> &'static str { | ||||
|         10 => "October", | ||||
|         11 => "November", | ||||
|         12 => "December", | ||||
|         _ => "Invalid" | ||||
|         _ => "Invalid", | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,13 +1,13 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::MouseEvent; | ||||
| use crate::services::calendar_service::CalendarInfo; | ||||
| use web_sys::MouseEvent; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct CalendarListItemProps { | ||||
|     pub calendar: CalendarInfo, | ||||
|     pub color_picker_open: bool, | ||||
|     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 on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path) | ||||
| } | ||||
| @@ -34,7 +34,7 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html { | ||||
|  | ||||
|     html! { | ||||
|         <li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}> | ||||
|             <span class="calendar-color"  | ||||
|             <span class="calendar-color" | ||||
|                   style={format!("background-color: {}", props.calendar.color)} | ||||
|                   onclick={on_color_click}> | ||||
|                 { | ||||
| @@ -46,14 +46,14 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html { | ||||
|                                         let color_str = color.clone(); | ||||
|                                         let cal_path = props.calendar.path.clone(); | ||||
|                                         let on_color_change = props.on_color_change.clone(); | ||||
|                                          | ||||
|  | ||||
|                                         let on_color_select = Callback::from(move |_: MouseEvent| { | ||||
|                                             on_color_change.emit((cal_path.clone(), color_str.clone())); | ||||
|                                         }); | ||||
|                                          | ||||
|  | ||||
|                                         let is_selected = props.calendar.color == *color; | ||||
|                                         let class_name = if is_selected { "color-option selected" } else { "color-option" }; | ||||
|                                          | ||||
|  | ||||
|                                         html! { | ||||
|                                             <div class={class_name} | ||||
|                                                  style={format!("background-color: {}", color)} | ||||
| @@ -72,4 +72,4 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html { | ||||
|             <span class="calendar-name">{&props.calendar.display_name}</span> | ||||
|         </li> | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::MouseEvent; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct ContextMenuProps { | ||||
| @@ -13,7 +13,7 @@ pub struct ContextMenuProps { | ||||
| #[function_component(ContextMenu)] | ||||
| pub fn context_menu(props: &ContextMenuProps) -> Html { | ||||
|     let menu_ref = use_node_ref(); | ||||
|      | ||||
|  | ||||
|     // Close menu when clicking outside (handled by parent component) | ||||
|  | ||||
|     if !props.is_open { | ||||
| @@ -35,9 +35,9 @@ pub fn context_menu(props: &ContextMenuProps) -> Html { | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div  | ||||
|         <div | ||||
|             ref={menu_ref} | ||||
|             class="context-menu"  | ||||
|             class="context-menu" | ||||
|             style={style} | ||||
|         > | ||||
|             <div class="context-menu-item context-menu-delete" onclick={on_delete_click}> | ||||
| @@ -45,4 +45,4 @@ pub fn context_menu(props: &ContextMenuProps) -> Html { | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -39,30 +39,32 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html { | ||||
|         let error_message = error_message.clone(); | ||||
|         let is_creating = is_creating.clone(); | ||||
|         let on_create = props.on_create.clone(); | ||||
|          | ||||
|  | ||||
|         Callback::from(move |e: SubmitEvent| { | ||||
|             e.prevent_default(); | ||||
|              | ||||
|  | ||||
|             let name = (*calendar_name).trim(); | ||||
|             if name.is_empty() { | ||||
|                 error_message.set(Some("Calendar name is required".to_string())); | ||||
|                 return; | ||||
|             } | ||||
|              | ||||
|  | ||||
|             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; | ||||
|             } | ||||
|              | ||||
|  | ||||
|             error_message.set(None); | ||||
|             is_creating.set(true); | ||||
|              | ||||
|  | ||||
|             let desc = if (*description).trim().is_empty() { | ||||
|                 None | ||||
|             } else { | ||||
|                 Some((*description).clone()) | ||||
|             }; | ||||
|              | ||||
|  | ||||
|             on_create.emit((name.to_string(), desc, (*selected_color).clone())); | ||||
|         }) | ||||
|     }; | ||||
| @@ -90,7 +92,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html { | ||||
|                         {"×"} | ||||
|                     </button> | ||||
|                 </div> | ||||
|                  | ||||
|  | ||||
|                 <form class="modal-body" onsubmit={on_submit}> | ||||
|                     { | ||||
|                         if let Some(ref error) = *error_message { | ||||
| @@ -103,10 +105,10 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html { | ||||
|                             html! {} | ||||
|                         } | ||||
|                     } | ||||
|                      | ||||
|  | ||||
|                     <div class="form-group"> | ||||
|                         <label for="calendar-name">{"Calendar Name *"}</label> | ||||
|                         <input  | ||||
|                         <input | ||||
|                             id="calendar-name" | ||||
|                             type="text" | ||||
|                             value={(*calendar_name).clone()} | ||||
| @@ -116,7 +118,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html { | ||||
|                             disabled={*is_creating} | ||||
|                         /> | ||||
|                     </div> | ||||
|                      | ||||
|  | ||||
|                     <div class="form-group"> | ||||
|                         <label for="calendar-description">{"Description"}</label> | ||||
|                         <textarea | ||||
| @@ -128,7 +130,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html { | ||||
|                             disabled={*is_creating} | ||||
|                         /> | ||||
|                     </div> | ||||
|                      | ||||
|  | ||||
|                     <div class="form-group"> | ||||
|                         <label>{"Calendar Color"}</label> | ||||
|                         <div class="color-grid"> | ||||
| @@ -143,13 +145,13 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html { | ||||
|                                             selected_color.set(Some(color.clone())); | ||||
|                                         }) | ||||
|                                     }; | ||||
|                                      | ||||
|                                     let class_name = if is_selected {  | ||||
|                                         "color-option selected"  | ||||
|                                     } else {  | ||||
|                                         "color-option"  | ||||
|  | ||||
|                                     let class_name = if is_selected { | ||||
|                                         "color-option selected" | ||||
|                                     } else { | ||||
|                                         "color-option" | ||||
|                                     }; | ||||
|                                      | ||||
|  | ||||
|                                     html! { | ||||
|                                         <button | ||||
|                                             key={index} | ||||
| @@ -165,18 +167,18 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html { | ||||
|                         </div> | ||||
|                         <p class="color-help-text">{"Optional: Choose a color for your calendar"}</p> | ||||
|                     </div> | ||||
|                      | ||||
|  | ||||
|                     <div class="modal-actions"> | ||||
|                         <button  | ||||
|                             type="button"  | ||||
|                         <button | ||||
|                             type="button" | ||||
|                             class="cancel-button" | ||||
|                             onclick={props.on_close.reform(|_| ())} | ||||
|                             disabled={*is_creating} | ||||
|                         > | ||||
|                             {"Cancel"} | ||||
|                         </button> | ||||
|                         <button  | ||||
|                             type="submit"  | ||||
|                         <button | ||||
|                             type="submit" | ||||
|                             class="create-button" | ||||
|                             disabled={*is_creating} | ||||
|                         > | ||||
| @@ -193,4 +195,4 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html { | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,6 +1,6 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::MouseEvent; | ||||
| use crate::models::ical::VEvent; | ||||
| use web_sys::MouseEvent; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum DeleteAction { | ||||
| @@ -30,7 +30,7 @@ pub struct EventContextMenuProps { | ||||
| #[function_component(EventContextMenu)] | ||||
| pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | ||||
|     let menu_ref = use_node_ref(); | ||||
|      | ||||
|  | ||||
|     if !props.is_open { | ||||
|         return html! {}; | ||||
|     } | ||||
| @@ -41,7 +41,9 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | ||||
|     ); | ||||
|  | ||||
|     // 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()) | ||||
|         .unwrap_or(false); | ||||
|  | ||||
| @@ -64,9 +66,9 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div  | ||||
|         <div | ||||
|             ref={menu_ref} | ||||
|             class="context-menu"  | ||||
|             class="context-menu" | ||||
|             style={style} | ||||
|         > | ||||
|             { | ||||
| @@ -117,4 +119,4 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | ||||
|             } | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| use yew::prelude::*; | ||||
| use chrono::{DateTime, Utc}; | ||||
| use crate::models::ical::VEvent; | ||||
| use chrono::{DateTime, Utc}; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct EventModalProps { | ||||
| @@ -16,7 +16,7 @@ pub fn EventModal(props: &EventModalProps) -> Html { | ||||
|             on_close.emit(()); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     let backdrop_click = { | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |e: MouseEvent| { | ||||
| @@ -39,7 +39,7 @@ pub fn EventModal(props: &EventModalProps) -> Html { | ||||
|                             <strong>{"Title:"}</strong> | ||||
|                             <span>{event.get_title()}</span> | ||||
|                         </div> | ||||
|                          | ||||
|  | ||||
|                         { | ||||
|                             if let Some(ref description) = event.description { | ||||
|                                 html! { | ||||
| @@ -52,12 +52,12 @@ pub fn EventModal(props: &EventModalProps) -> Html { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|  | ||||
|                         <div class="event-detail"> | ||||
|                             <strong>{"Start:"}</strong> | ||||
|                             <span>{format_datetime(&event.dtstart, event.all_day)}</span> | ||||
|                         </div> | ||||
|                          | ||||
|  | ||||
|                         { | ||||
|                             if let Some(ref end) = event.dtend { | ||||
|                                 html! { | ||||
| @@ -70,12 +70,12 @@ pub fn EventModal(props: &EventModalProps) -> Html { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|  | ||||
|                         <div class="event-detail"> | ||||
|                             <strong>{"All Day:"}</strong> | ||||
|                             <span>{if event.all_day { "Yes" } else { "No" }}</span> | ||||
|                         </div> | ||||
|                          | ||||
|  | ||||
|                         { | ||||
|                             if let Some(ref location) = event.location { | ||||
|                                 html! { | ||||
| @@ -88,22 +88,22 @@ pub fn EventModal(props: &EventModalProps) -> Html { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|  | ||||
|                         <div class="event-detail"> | ||||
|                             <strong>{"Status:"}</strong> | ||||
|                             <span>{event.get_status_display()}</span> | ||||
|                         </div> | ||||
|                          | ||||
|  | ||||
|                         <div class="event-detail"> | ||||
|                             <strong>{"Privacy:"}</strong> | ||||
|                             <span>{event.get_class_display()}</span> | ||||
|                         </div> | ||||
|                          | ||||
|  | ||||
|                         <div class="event-detail"> | ||||
|                             <strong>{"Priority:"}</strong> | ||||
|                             <span>{event.get_priority_display()}</span> | ||||
|                         </div> | ||||
|                          | ||||
|  | ||||
|                         { | ||||
|                             if let Some(ref organizer) = event.organizer { | ||||
|                                 html! { | ||||
| @@ -116,7 +116,7 @@ pub fn EventModal(props: &EventModalProps) -> Html { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|  | ||||
|                         { | ||||
|                             if !event.attendees.is_empty() { | ||||
|                                 html! { | ||||
| @@ -129,7 +129,7 @@ pub fn EventModal(props: &EventModalProps) -> Html { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|  | ||||
|                         { | ||||
|                             if !event.categories.is_empty() { | ||||
|                                 html! { | ||||
| @@ -142,7 +142,7 @@ pub fn EventModal(props: &EventModalProps) -> Html { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|  | ||||
|                         { | ||||
|                             if let Some(ref recurrence) = event.rrule { | ||||
|                                 html! { | ||||
| @@ -160,7 +160,7 @@ pub fn EventModal(props: &EventModalProps) -> Html { | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|  | ||||
|                         { | ||||
|                             if !event.alarms.is_empty() { | ||||
|                                 html! { | ||||
| @@ -178,7 +178,7 @@ pub fn EventModal(props: &EventModalProps) -> Html { | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|  | ||||
|                         { | ||||
|                             if let Some(ref created) = event.created { | ||||
|                                 html! { | ||||
| @@ -191,7 +191,7 @@ pub fn EventModal(props: &EventModalProps) -> Html { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|  | ||||
|                         { | ||||
|                             if let Some(ref modified) = event.last_modified { | ||||
|                                 html! { | ||||
| @@ -236,4 +236,3 @@ fn format_recurrence_rule(rrule: &str) -> String { | ||||
|         format!("Custom ({})", rrule) | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::HtmlInputElement; | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
| use web_sys::HtmlInputElement; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct LoginProps { | ||||
| @@ -9,11 +9,20 @@ pub struct LoginProps { | ||||
|  | ||||
| #[function_component] | ||||
| pub fn Login(props: &LoginProps) -> Html { | ||||
|     let server_url = use_state(String::new); | ||||
|     let username = use_state(String::new); | ||||
|     // Load remembered values from LocalStorage on mount | ||||
|     let server_url = use_state(|| { | ||||
|         LocalStorage::get::<String>("remembered_server_url").unwrap_or_default() | ||||
|     }); | ||||
|     let username = use_state(|| { | ||||
|         LocalStorage::get::<String>("remembered_username").unwrap_or_default() | ||||
|     }); | ||||
|     let password = use_state(String::new); | ||||
|     let error_message = use_state(|| Option::<String>::None); | ||||
|     let is_loading = use_state(|| false); | ||||
|      | ||||
|     // Remember checkboxes state - default to checked | ||||
|     let remember_server = use_state(|| true); | ||||
|     let remember_username = use_state(|| true); | ||||
|  | ||||
|     let server_url_ref = use_node_ref(); | ||||
|     let username_ref = use_node_ref(); | ||||
| @@ -42,6 +51,38 @@ pub fn Login(props: &LoginProps) -> Html { | ||||
|             password.set(target.value()); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     let on_remember_server_change = { | ||||
|         let remember_server = remember_server.clone(); | ||||
|         let server_url = server_url.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             let target = e.target_unchecked_into::<HtmlInputElement>(); | ||||
|             let checked = target.checked(); | ||||
|             remember_server.set(checked); | ||||
|              | ||||
|             if checked { | ||||
|                 let _ = LocalStorage::set("remembered_server_url", (*server_url).clone()); | ||||
|             } else { | ||||
|                 let _ = LocalStorage::delete("remembered_server_url"); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     let on_remember_username_change = { | ||||
|         let remember_username = remember_username.clone(); | ||||
|         let username = username.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             let target = e.target_unchecked_into::<HtmlInputElement>(); | ||||
|             let checked = target.checked(); | ||||
|             remember_username.set(checked); | ||||
|              | ||||
|             if checked { | ||||
|                 let _ = LocalStorage::set("remembered_username", (*username).clone()); | ||||
|             } else { | ||||
|                 let _ = LocalStorage::delete("remembered_username"); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_submit = { | ||||
|         let server_url = server_url.clone(); | ||||
| @@ -53,7 +94,7 @@ pub fn Login(props: &LoginProps) -> Html { | ||||
|  | ||||
|         Callback::from(move |e: SubmitEvent| { | ||||
|             e.prevent_default(); | ||||
|              | ||||
|  | ||||
|             let server_url = (*server_url).clone(); | ||||
|             let username = (*username).clone(); | ||||
|             let password = (*password).clone(); | ||||
| @@ -73,11 +114,18 @@ pub fn Login(props: &LoginProps) -> Html { | ||||
|             wasm_bindgen_futures::spawn_local(async move { | ||||
|                 web_sys::console::log_1(&"🚀 Starting login process...".into()); | ||||
|                 match perform_login(server_url.clone(), username.clone(), password.clone()).await { | ||||
|                     Ok((token, credentials)) => { | ||||
|                     Ok((token, session_token, credentials, preferences)) => { | ||||
|                         web_sys::console::log_1(&"✅ Login successful!".into()); | ||||
|                         // Store token and credentials in local storage | ||||
|                         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); | ||||
|                             return; | ||||
|                         } | ||||
|                         if let Err(_) = LocalStorage::set("session_token", &session_token) { | ||||
|                             error_message | ||||
|                                 .set(Some("Failed to store session token".to_string())); | ||||
|                             is_loading.set(false); | ||||
|                             return; | ||||
|                         } | ||||
| @@ -87,6 +135,11 @@ pub fn Login(props: &LoginProps) -> Html { | ||||
|                             return; | ||||
|                         } | ||||
|                          | ||||
|                         // Store preferences from database | ||||
|                         if let Ok(prefs_json) = serde_json::to_string(&preferences) { | ||||
|                             let _ = LocalStorage::set("user_preferences", &prefs_json); | ||||
|                         } | ||||
|  | ||||
|                         is_loading.set(false); | ||||
|                         on_login.emit(token); | ||||
|                     } | ||||
| @@ -116,6 +169,15 @@ pub fn Login(props: &LoginProps) -> Html { | ||||
|                             onchange={on_server_url_change} | ||||
|                             disabled={*is_loading} | ||||
|                         /> | ||||
|                         <div class="remember-checkbox"> | ||||
|                             <input | ||||
|                                 type="checkbox" | ||||
|                                 id="remember_server" | ||||
|                                 checked={*remember_server} | ||||
|                                 onchange={on_remember_server_change} | ||||
|                             /> | ||||
|                             <label for="remember_server">{"Remember server"}</label> | ||||
|                         </div> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="form-group"> | ||||
| @@ -129,6 +191,15 @@ pub fn Login(props: &LoginProps) -> Html { | ||||
|                             onchange={on_username_change} | ||||
|                             disabled={*is_loading} | ||||
|                         /> | ||||
|                         <div class="remember-checkbox"> | ||||
|                             <input | ||||
|                                 type="checkbox" | ||||
|                                 id="remember_username" | ||||
|                                 checked={*remember_username} | ||||
|                                 onchange={on_remember_username_change} | ||||
|                             /> | ||||
|                             <label for="remember_username">{"Remember username"}</label> | ||||
|                         </div> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="form-group"> | ||||
| @@ -172,21 +243,25 @@ pub fn Login(props: &LoginProps) -> Html { | ||||
| } | ||||
|  | ||||
| /// 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, serde_json::Value), String> { | ||||
|     use crate::auth::{AuthService, CalDAVLoginRequest}; | ||||
|     use serde_json; | ||||
|      | ||||
|  | ||||
|     web_sys::console::log_1(&format!("📡 Creating auth service and request...").into()); | ||||
|      | ||||
|  | ||||
|     let auth_service = AuthService::new(); | ||||
|     let request = CalDAVLoginRequest {  | ||||
|         server_url: server_url.clone(),  | ||||
|         username: username.clone(),  | ||||
|         password: password.clone()  | ||||
|     let request = CalDAVLoginRequest { | ||||
|         server_url: server_url.clone(), | ||||
|         username: username.clone(), | ||||
|         password: password.clone(), | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into()); | ||||
|      | ||||
|  | ||||
|     match auth_service.login(request).await { | ||||
|         Ok(response) => { | ||||
|             web_sys::console::log_1(&format!("✅ Backend responded successfully").into()); | ||||
| @@ -196,11 +271,21 @@ async fn perform_login(server_url: String, username: String, password: String) - | ||||
|                 "username": username, | ||||
|                 "password": password | ||||
|             }); | ||||
|             Ok((response.token, credentials.to_string())) | ||||
|         }, | ||||
|              | ||||
|             // Extract preferences as JSON | ||||
|             let preferences = serde_json::json!({ | ||||
|                 "calendar_selected_date": response.preferences.calendar_selected_date, | ||||
|                 "calendar_time_increment": response.preferences.calendar_time_increment, | ||||
|                 "calendar_view_mode": response.preferences.calendar_view_mode, | ||||
|                 "calendar_theme": response.preferences.calendar_theme, | ||||
|                 "calendar_colors": response.preferences.calendar_colors, | ||||
|             }); | ||||
|              | ||||
|             Ok((response.token, response.session_token, credentials.to_string(), preferences)) | ||||
|         } | ||||
|         Err(err) => { | ||||
|             web_sys::console::log_1(&format!("❌ Backend error: {}", err).into()); | ||||
|             Err(err) | ||||
|         }, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,31 +1,33 @@ | ||||
| pub mod login; | ||||
| 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 create_event_modal; | ||||
| pub mod sidebar; | ||||
| pub mod calendar_header; | ||||
| 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 route_handler; | ||||
| pub mod sidebar; | ||||
| pub mod week_view; | ||||
|  | ||||
| pub use login::Login; | ||||
| 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 create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType}; | ||||
| pub use sidebar::{Sidebar, ViewMode, Theme}; | ||||
| pub use calendar_header::CalendarHeader; | ||||
| 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 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 std::collections::HashMap; | ||||
| use web_sys::window; | ||||
| use wasm_bindgen::{prelude::*, JsCast}; | ||||
| use crate::services::calendar_service::UserInfo; | ||||
| use crate::models::ical::VEvent; | ||||
| use web_sys::window; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct MonthViewProps { | ||||
| @@ -52,30 +52,33 @@ pub fn month_view(props: &MonthViewProps) -> Html { | ||||
|         let calculate_max_events = calculate_max_events.clone(); | ||||
|         use_effect_with((), move |_| { | ||||
|             let calculate_max_events_clone = calculate_max_events.clone(); | ||||
|              | ||||
|  | ||||
|             // Initial calculation with a slight delay to ensure DOM is ready | ||||
|             if let Some(window) = window() { | ||||
|                 let timeout_closure = Closure::wrap(Box::new(move || { | ||||
|                     calculate_max_events_clone(); | ||||
|                 }) as Box<dyn FnMut()>); | ||||
|                  | ||||
|  | ||||
|                 let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( | ||||
|                     timeout_closure.as_ref().unchecked_ref(), | ||||
|                     100, | ||||
|                 ); | ||||
|                 timeout_closure.forget(); | ||||
|             } | ||||
|              | ||||
|  | ||||
|             // Setup resize listener | ||||
|             let resize_closure = Closure::wrap(Box::new(move || { | ||||
|                 calculate_max_events(); | ||||
|             }) as Box<dyn Fn()>); | ||||
|              | ||||
|  | ||||
|             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 | ||||
|             } | ||||
|              | ||||
|  | ||||
|             || {} | ||||
|         }); | ||||
|     } | ||||
| @@ -84,8 +87,11 @@ pub fn month_view(props: &MonthViewProps) -> Html { | ||||
|     let get_event_color = |event: &VEvent| -> String { | ||||
|         if let Some(user_info) = &props.user_info { | ||||
|             if let Some(calendar_path) = &event.calendar_path { | ||||
|                 if let Some(calendar) = user_info.calendars.iter() | ||||
|                     .find(|cal| &cal.path == calendar_path) { | ||||
|                 if let Some(calendar) = user_info | ||||
|                     .calendars | ||||
|                     .iter() | ||||
|                     .find(|cal| &cal.path == calendar_path) | ||||
|                 { | ||||
|                     return calendar.color.clone(); | ||||
|                 } | ||||
|             } | ||||
| @@ -103,7 +109,7 @@ pub fn month_view(props: &MonthViewProps) -> Html { | ||||
|             <div class="weekday-header">{"Thu"}</div> | ||||
|             <div class="weekday-header">{"Fri"}</div> | ||||
|             <div class="weekday-header">{"Sat"}</div> | ||||
|              | ||||
|  | ||||
|             // Days from previous month (grayed out) | ||||
|             { | ||||
|                 days_from_prev_month.iter().map(|day| { | ||||
| @@ -112,7 +118,7 @@ pub fn month_view(props: &MonthViewProps) -> Html { | ||||
|                     } | ||||
|                 }).collect::<Html>() | ||||
|             } | ||||
|              | ||||
|  | ||||
|             // Days of the current month | ||||
|             { | ||||
|                 (1..=days_in_month).map(|day| { | ||||
| @@ -120,16 +126,16 @@ pub fn month_view(props: &MonthViewProps) -> Html { | ||||
|                     let is_today = date == props.today; | ||||
|                     let is_selected = props.selected_date == Some(date); | ||||
|                     let day_events = props.events.get(&date).cloned().unwrap_or_default(); | ||||
|                      | ||||
|  | ||||
|                     // Calculate visible events and overflow | ||||
|                     let max_events = *max_events_per_day as usize; | ||||
|                     let visible_events: Vec<_> = day_events.iter().take(max_events).collect(); | ||||
|                     let hidden_count = day_events.len().saturating_sub(max_events); | ||||
|                      | ||||
|  | ||||
|                     html! { | ||||
|                         <div  | ||||
|                         <div | ||||
|                             class={classes!( | ||||
|                                 "calendar-day",  | ||||
|                                 "calendar-day", | ||||
|                                 if is_today { Some("today") } else { None }, | ||||
|                                 if is_selected { Some("selected") } else { None } | ||||
|                             )} | ||||
| @@ -162,7 +168,7 @@ pub fn month_view(props: &MonthViewProps) -> Html { | ||||
|                                     visible_events.iter().map(|event| { | ||||
|                                         let event_color = get_event_color(event); | ||||
|                                         let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid); | ||||
|                                          | ||||
|  | ||||
|                                         let onclick = { | ||||
|                                             let on_event_click = props.on_event_click.clone(); | ||||
|                                             let event = (*event).clone(); | ||||
| @@ -170,7 +176,7 @@ pub fn month_view(props: &MonthViewProps) -> Html { | ||||
|                                                 on_event_click.emit(event.clone()); | ||||
|                                             }) | ||||
|                                         }; | ||||
|                                          | ||||
|  | ||||
|                                         let oncontextmenu = { | ||||
|                                             if let Some(callback) = &props.on_event_context_menu { | ||||
|                                                 let callback = callback.clone(); | ||||
| @@ -183,9 +189,9 @@ pub fn month_view(props: &MonthViewProps) -> Html { | ||||
|                                                 None | ||||
|                                             } | ||||
|                                         }; | ||||
|                                          | ||||
|  | ||||
|                                         html! { | ||||
|                                             <div  | ||||
|                                             <div | ||||
|                                                 class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })} | ||||
|                                                 style={format!("background-color: {}", event_color)} | ||||
|                                                 {onclick} | ||||
| @@ -212,7 +218,7 @@ pub fn month_view(props: &MonthViewProps) -> Html { | ||||
|                     } | ||||
|                 }).collect::<Html>() | ||||
|             } | ||||
|              | ||||
|  | ||||
|             { render_next_month_days(days_from_prev_month.len(), days_in_month) } | ||||
|         </div> | ||||
|     } | ||||
| @@ -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 { | ||||
|     let total_slots = 42; // 6 rows x 7 days | ||||
|     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 }; | ||||
|      | ||||
|     (1..=remaining_slots).map(|day| { | ||||
|         html! { | ||||
|             <div class="calendar-day next-month">{day}</div> | ||||
|         } | ||||
|     }).collect::<Html>() | ||||
|     let remaining_slots = if used_slots < total_slots { | ||||
|         total_slots - used_slots | ||||
|     } else { | ||||
|         0 | ||||
|     }; | ||||
|  | ||||
|     (1..=remaining_slots) | ||||
|         .map(|day| { | ||||
|             html! { | ||||
|                 <div class="calendar-day next-month">{day}</div> | ||||
|             } | ||||
|         }) | ||||
|         .collect::<Html>() | ||||
| } | ||||
|  | ||||
| fn get_days_in_month(date: NaiveDate) -> u32 { | ||||
|     NaiveDate::from_ymd_opt( | ||||
|         if date.month() == 12 { date.year() + 1 } else { date.year() }, | ||||
|         if date.month() == 12 { 1 } else { date.month() + 1 }, | ||||
|         1 | ||||
|         if date.month() == 12 { | ||||
|             date.year() + 1 | ||||
|         } else { | ||||
|             date.year() | ||||
|         }, | ||||
|         if date.month() == 12 { | ||||
|             1 | ||||
|         } else { | ||||
|             date.month() + 1 | ||||
|         }, | ||||
|         1, | ||||
|     ) | ||||
|     .unwrap() | ||||
|     .pred_opt() | ||||
| @@ -252,7 +272,7 @@ fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday | ||||
|         Weekday::Fri => 5, | ||||
|         Weekday::Sat => 6, | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     if days_before == 0 { | ||||
|         vec![] | ||||
|     } else { | ||||
| @@ -261,8 +281,8 @@ fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday | ||||
|         } else { | ||||
|             NaiveDate::from_ymd_opt(current_month.year(), current_month.month() - 1, 1).unwrap() | ||||
|         }; | ||||
|          | ||||
|  | ||||
|         let prev_month_days = get_days_in_month(prev_month); | ||||
|         ((prev_month_days - days_before as u32 + 1)..=prev_month_days).collect() | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| use yew::prelude::*; | ||||
| use chrono::NaiveDateTime; | ||||
| use crate::models::ical::VEvent; | ||||
| use chrono::NaiveDateTime; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum RecurringEditAction { | ||||
| @@ -25,29 +25,34 @@ pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> 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_choice = props.on_choice.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             on_choice.emit(RecurringEditAction::ThisEvent); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     let on_future_events = { | ||||
|         let on_choice = props.on_choice.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             on_choice.emit(RecurringEditAction::FutureEvents); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     let on_all_events = { | ||||
|         let on_choice = props.on_choice.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             on_choice.emit(RecurringEditAction::AllEvents); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     let on_cancel = { | ||||
|         let on_cancel = props.on_cancel.clone(); | ||||
|         Callback::from(move |_| { | ||||
| @@ -64,18 +69,18 @@ pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html { | ||||
|                 <div class="modal-body"> | ||||
|                     <p>{format!("You're modifying \"{}\" which is part of a recurring series.", event_title)}</p> | ||||
|                     <p>{"How would you like to apply this change?"}</p> | ||||
|                      | ||||
|  | ||||
|                     <div class="recurring-edit-options"> | ||||
|                         <button class="btn btn-primary recurring-option" onclick={on_this_event}> | ||||
|                             <div class="option-title">{"This event only"}</div> | ||||
|                             <div class="option-description">{"Change only this occurrence"}</div> | ||||
|                         </button> | ||||
|                          | ||||
|  | ||||
|                         <button class="btn btn-primary recurring-option" onclick={on_future_events}> | ||||
|                             <div class="option-title">{"This and future events"}</div> | ||||
|                             <div class="option-description">{"Change this occurrence and all future occurrences"}</div> | ||||
|                         </button> | ||||
|                          | ||||
|  | ||||
|                         <button class="btn btn-primary recurring-option" onclick={on_all_events}> | ||||
|                             <div class="option-title">{"All events in series"}</div> | ||||
|                             <div class="option-description">{"Change all occurrences in the series"}</div> | ||||
| @@ -90,4 +95,4 @@ pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html { | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -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_router::prelude::*; | ||||
| use crate::components::{Login, ViewMode}; | ||||
| use crate::services::calendar_service::UserInfo; | ||||
| use crate::models::ical::VEvent; | ||||
|  | ||||
| #[derive(Clone, Routable, PartialEq)] | ||||
| pub enum Route { | ||||
| @@ -28,7 +28,17 @@ pub struct RouteHandlerProps { | ||||
|     #[prop_or_default] | ||||
|     pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>, | ||||
|     #[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] | ||||
|     pub context_menus_open: bool, | ||||
| } | ||||
| @@ -44,7 +54,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | ||||
|     let on_create_event_request = props.on_create_event_request.clone(); | ||||
|     let on_event_update_request = props.on_event_update_request.clone(); | ||||
|     let context_menus_open = props.context_menus_open; | ||||
|      | ||||
|  | ||||
|     html! { | ||||
|         <Switch<Route> render={move |route| { | ||||
|             let auth_token = auth_token.clone(); | ||||
| @@ -56,7 +66,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | ||||
|             let on_create_event_request = on_create_event_request.clone(); | ||||
|             let on_event_update_request = on_event_update_request.clone(); | ||||
|             let context_menus_open = context_menus_open; | ||||
|              | ||||
|  | ||||
|             match route { | ||||
|                 Route::Home => { | ||||
|                     if auth_token.is_some() { | ||||
| @@ -74,16 +84,16 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { | ||||
|                 } | ||||
|                 Route::Calendar => { | ||||
|                     if auth_token.is_some() { | ||||
|                         html! {  | ||||
|                             <CalendarView  | ||||
|                                 user_info={user_info}  | ||||
|                         html! { | ||||
|                             <CalendarView | ||||
|                                 user_info={user_info} | ||||
|                                 on_event_context_menu={on_event_context_menu} | ||||
|                                 on_calendar_context_menu={on_calendar_context_menu} | ||||
|                                 view={view} | ||||
|                                 on_create_event_request={on_create_event_request} | ||||
|                                 on_event_update_request={on_event_update_request} | ||||
|                                 context_menus_open={context_menus_open} | ||||
|                             />  | ||||
|                             /> | ||||
|                         } | ||||
|                     } else { | ||||
|                         html! { <Redirect<Route> to={Route::Login}/> } | ||||
| @@ -106,192 +116,36 @@ pub struct CalendarViewProps { | ||||
|     #[prop_or_default] | ||||
|     pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>, | ||||
|     #[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] | ||||
|     pub context_menus_open: bool, | ||||
| } | ||||
|  | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
| use crate::services::CalendarService; | ||||
| use crate::components::Calendar; | ||||
| use std::collections::HashMap; | ||||
| use chrono::{Local, NaiveDate, Datelike}; | ||||
|  | ||||
| #[function_component(CalendarView)] | ||||
| 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! { | ||||
|         <div class="calendar-view"> | ||||
|             { | ||||
|                 if *loading { | ||||
|                     html! { | ||||
|                         <div class="calendar-loading"> | ||||
|                             <p>{"Loading calendar events..."}</p> | ||||
|                         </div> | ||||
|                     } | ||||
|                 } else if let Some(err) = (*error).clone() { | ||||
|                     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} | ||||
|                         /> | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             <Calendar | ||||
|                 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> | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| use crate::components::CalendarListItem; | ||||
| use crate::services::calendar_service::UserInfo; | ||||
| use web_sys::HtmlSelectElement; | ||||
| use yew::prelude::*; | ||||
| use yew_router::prelude::*; | ||||
| use web_sys::HtmlSelectElement; | ||||
| use crate::services::calendar_service::UserInfo; | ||||
| use crate::components::CalendarListItem; | ||||
|  | ||||
| #[derive(Clone, Routable, PartialEq)] | ||||
| pub enum Route { | ||||
| @@ -33,12 +33,11 @@ pub enum Theme { | ||||
| } | ||||
|  | ||||
| impl Theme { | ||||
|      | ||||
|     pub fn value(&self) -> &'static str { | ||||
|         match self { | ||||
|             Theme::Default => "default", | ||||
|             Theme::Ocean => "ocean", | ||||
|             Theme::Forest => "forest",  | ||||
|             Theme::Forest => "forest", | ||||
|             Theme::Sunset => "sunset", | ||||
|             Theme::Purple => "purple", | ||||
|             Theme::Dark => "dark", | ||||
| @@ -46,7 +45,7 @@ impl Theme { | ||||
|             Theme::Mint => "mint", | ||||
|         } | ||||
|     } | ||||
|      | ||||
|  | ||||
|     pub fn from_value(value: &str) -> Self { | ||||
|         match value { | ||||
|             "ocean" => Theme::Ocean, | ||||
| @@ -167,14 +166,14 @@ pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|                 <button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button"> | ||||
|                     {"+ Create Calendar"} | ||||
|                 </button> | ||||
|                  | ||||
|  | ||||
|                 <div class="view-selector"> | ||||
|                     <select class="view-selector-dropdown" onchange={on_view_change}> | ||||
|                         <option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option> | ||||
|                         <option value="week" selected={matches!(props.current_view, ViewMode::Week)}>{"Week"}</option> | ||||
|                     </select> | ||||
|                 </div> | ||||
|                  | ||||
|  | ||||
|                 <div class="theme-selector"> | ||||
|                     <select class="theme-selector-dropdown" onchange={on_theme_change}> | ||||
|                         <option value="default" selected={matches!(props.current_theme, Theme::Default)}>{"Default"}</option> | ||||
| @@ -187,9 +186,9 @@ pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|                         <option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</option> | ||||
|                     </select> | ||||
|                 </div> | ||||
|                  | ||||
|  | ||||
|                 <button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button> | ||||
|             </div> | ||||
|         </aside> | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| use yew::prelude::*; | ||||
| use chrono::{Datelike, NaiveDate, Duration, Weekday, Local, Timelike, NaiveDateTime, NaiveTime}; | ||||
| use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal}; | ||||
| 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 web_sys::MouseEvent; | ||||
| use crate::services::calendar_service::UserInfo; | ||||
| use crate::models::ical::VEvent; | ||||
| use crate::components::{RecurringEditModal, RecurringEditAction, EventCreationData}; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct WeekViewProps { | ||||
| @@ -25,7 +25,17 @@ pub struct WeekViewProps { | ||||
|     #[prop_or_default] | ||||
|     pub on_create_event_request: Option<Callback<EventCreationData>>, | ||||
|     #[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] | ||||
|     pub context_menus_open: bool, | ||||
|     #[prop_or_default] | ||||
| @@ -47,20 +57,18 @@ struct DragState { | ||||
|     start_date: NaiveDate, | ||||
|     start_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 | ||||
| } | ||||
|  | ||||
| #[function_component(WeekView)] | ||||
| pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|     let start_of_week = get_start_of_week(props.current_date); | ||||
|     let week_days: Vec<NaiveDate> = (0..7) | ||||
|         .map(|i| start_of_week + Duration::days(i)) | ||||
|         .collect(); | ||||
|     let week_days: Vec<NaiveDate> = (0..7).map(|i| start_of_week + Duration::days(i)).collect(); | ||||
|  | ||||
|     // Drag state for event creation | ||||
|     let drag_state = use_state(|| None::<DragState>); | ||||
|      | ||||
|  | ||||
|     // State for recurring event edit modal | ||||
|     #[derive(Clone, PartialEq)] | ||||
|     struct PendingRecurringEdit { | ||||
| @@ -68,15 +76,18 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|         new_start: NaiveDateTime, | ||||
|         new_end: NaiveDateTime, | ||||
|     } | ||||
|      | ||||
|  | ||||
|     let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>); | ||||
|  | ||||
|     // Helper function to get calendar color for an event | ||||
|     let get_event_color = |event: &VEvent| -> String { | ||||
|         if let Some(user_info) = &props.user_info { | ||||
|             if let Some(calendar_path) = &event.calendar_path { | ||||
|                 if let Some(calendar) = user_info.calendars.iter() | ||||
|                     .find(|cal| &cal.path == calendar_path) { | ||||
|                 if let Some(calendar) = user_info | ||||
|                     .calendars | ||||
|                     .iter() | ||||
|                     .find(|cal| &cal.path == calendar_path) | ||||
|                 { | ||||
|                     return calendar.color.clone(); | ||||
|                 } | ||||
|             } | ||||
| @@ -85,21 +96,22 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|     }; | ||||
|  | ||||
|     // Generate time labels - 24 hours plus the final midnight boundary | ||||
|     let mut time_labels: Vec<String> = (0..24).map(|hour| { | ||||
|         if hour == 0 { | ||||
|             "12 AM".to_string() | ||||
|         } else if hour < 12 { | ||||
|             format!("{} AM", hour) | ||||
|         } else if hour == 12 { | ||||
|             "12 PM".to_string() | ||||
|         } else { | ||||
|             format!("{} PM", hour - 12) | ||||
|         } | ||||
|     }).collect(); | ||||
|      | ||||
|     let mut time_labels: Vec<String> = (0..24) | ||||
|         .map(|hour| { | ||||
|             if hour == 0 { | ||||
|                 "12 AM".to_string() | ||||
|             } else if hour < 12 { | ||||
|                 format!("{} AM", hour) | ||||
|             } else if hour == 12 { | ||||
|                 "12 PM".to_string() | ||||
|             } else { | ||||
|                 format!("{} PM", hour - 12) | ||||
|             } | ||||
|         }) | ||||
|         .collect(); | ||||
|  | ||||
|     // Add the final midnight boundary to show where the day ends | ||||
|     time_labels.push("12 AM".to_string()); | ||||
|      | ||||
|  | ||||
|     // Handlers for recurring event modification modal | ||||
|     let on_recurring_choice = { | ||||
| @@ -135,35 +147,35 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                         if let Some(update_callback) = &on_event_update { | ||||
|                             // Extract occurrence date for backend processing | ||||
|                             let occurrence_date = edit.event.dtstart.format("%Y-%m-%d").to_string(); | ||||
|                              | ||||
|  | ||||
|                             // Send single request to backend with "this_only" scope | ||||
|                             // Backend will atomically: | ||||
|                             // 1. Add EXDATE to original series (excludes this occurrence) | ||||
|                             // 2. Create exception event with RECURRENCE-ID and user's modifications | ||||
|                             update_callback.emit(( | ||||
|                                 edit.event.clone(),     // Original event (series to modify) | ||||
|                                 edit.new_start,         // Dragged start time for exception | ||||
|                                 edit.new_end,           // Dragged end time for exception | ||||
|                                 true,                   // preserve_rrule = true | ||||
|                                 None,                   // No until_date for this_only | ||||
|                                 edit.event.clone(),            // Original event (series to modify) | ||||
|                                 edit.new_start,                // Dragged start time for exception | ||||
|                                 edit.new_end,                  // Dragged end time for exception | ||||
|                                 true,                          // preserve_rrule = true | ||||
|                                 None,                          // No until_date for this_only | ||||
|                                 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 => { | ||||
|                         // RFC 5545 Compliant Series Splitting: "This and Future Events" | ||||
|                         //  | ||||
|                         // | ||||
|                         // When a user chooses to modify "this and future events" for a recurring series, | ||||
|                         // we implement a series split operation that: | ||||
|                         //  | ||||
|                         // 1. **Terminates Original Series**: The existing series is updated with an UNTIL  | ||||
|                         // | ||||
|                         // 1. **Terminates Original Series**: The existing series is updated with an UNTIL | ||||
|                         //    clause to stop before the occurrence being modified | ||||
|                         // 2. **Creates New Series**: A new recurring series is created starting from the  | ||||
|                         // 2. **Creates New Series**: A new recurring series is created starting from the | ||||
|                         //    occurrence date with the user's modifications (new time, title, etc.) | ||||
|                         // | ||||
|                         // Example: User drags Aug 22 occurrence of "Daily 9AM meeting" to 2PM: | ||||
|                         // - Original: "Daily 9AM meeting" → ends Aug 21 (UNTIL=Aug22T000000Z)  | ||||
|                         // - Original: "Daily 9AM meeting" → ends Aug 21 (UNTIL=Aug22T000000Z) | ||||
|                         // - New: "Daily 2PM meeting" → starts Aug 22, continues indefinitely | ||||
|                         // | ||||
|                         // This approach ensures: | ||||
| @@ -177,7 +189,8 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                         if let Some(update_callback) = &on_event_update { | ||||
|                             // Find the original series event (not the occurrence) | ||||
|                             // 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..]; | ||||
|                                 // Check if suffix is numeric (timestamp), if so remove it | ||||
|                                 if suffix.chars().all(|c| c.is_numeric()) { | ||||
| @@ -188,9 +201,15 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                             } else { | ||||
|                                 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 | ||||
|                             let mut original_series = None; | ||||
|                             for events_list in events.values() { | ||||
| @@ -204,12 +223,15 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                     break; | ||||
|                                 } | ||||
|                             } | ||||
|                              | ||||
|  | ||||
|                             let original_series = match original_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 | ||||
|                                 }, | ||||
|                                 } | ||||
|                                 None => { | ||||
|                                     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(); | ||||
| @@ -218,55 +240,69 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                     fallback_event | ||||
|                                 } | ||||
|                             }; | ||||
|                              | ||||
|  | ||||
|                             // Calculate the day before this occurrence for UNTIL clause | ||||
|                             let until_date = edit.event.dtstart.date_naive() - chrono::Duration::days(1); | ||||
|                             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); | ||||
|                              | ||||
|                             let until_date = | ||||
|                                 edit.event.dtstart.date_naive() - chrono::Duration::days(1); | ||||
|                             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 {}",  | ||||
|                                 until_utc.format("%Y-%m-%d %H:%M:%S UTC"), | ||||
|                                 edit.event.dtstart.format("%Y-%m-%d %H:%M:%S UTC")).into()); | ||||
|                              | ||||
|  | ||||
|                             // Critical: Use the dragged times (new_start/new_end) not the original series times | ||||
|                             // This ensures the new series reflects the user's drag operation | ||||
|                             let new_start = edit.new_start; // The dragged start time   | ||||
|                             let new_start = edit.new_start; // The dragged start time | ||||
|                             let new_end = edit.new_end; // The dragged end time | ||||
|                              | ||||
|  | ||||
|                             // Extract occurrence date from the dragged event for backend processing | ||||
|                             // Format: YYYY-MM-DD (e.g., "2025-08-22") | ||||
|                             // This tells the backend which specific occurrence is being modified | ||||
|                             let occurrence_date = edit.event.dtstart.format("%Y-%m-%d").to_string(); | ||||
|                              | ||||
|  | ||||
|                             // Send single request to backend with "this_and_future" scope | ||||
|                             // Backend will atomically: | ||||
|                             // 1. Add UNTIL clause to original series (stops before occurrence_date) | ||||
|                             // 2. Create new series starting from occurrence_date with dragged times | ||||
|                             update_callback.emit(( | ||||
|                                 original_series,    // Original event to terminate | ||||
|                                 new_start,          // Dragged start time for new series | ||||
|                                 new_end,            // Dragged end time for new series   | ||||
|                                 true,               // preserve_rrule = true | ||||
|                                 Some(until_utc),    // UNTIL date for original series | ||||
|                                 original_series,                     // Original event to terminate | ||||
|                                 new_start,       // Dragged start time for new series | ||||
|                                 new_end,         // Dragged end time for new series | ||||
|                                 true,            // preserve_rrule = true | ||||
|                                 Some(until_utc), // UNTIL date for original series | ||||
|                                 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 => { | ||||
|                         // Modify the entire series | ||||
|                         let series_event = edit.event.clone(); | ||||
|                          | ||||
|  | ||||
|                         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); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     let on_recurring_cancel = { | ||||
|         let pending_recurring_edit = pending_recurring_edit.clone(); | ||||
|         Callback::from(move |_| { | ||||
| @@ -283,7 +319,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                     week_days.iter().map(|date| { | ||||
|                         let is_today = *date == props.today; | ||||
|                         let weekday_name = get_weekday_name(date.weekday()); | ||||
|                          | ||||
|  | ||||
|                         html! { | ||||
|                             <div class={classes!("week-day-header", if is_today { Some("today") } else { None })}> | ||||
|                                 <div class="weekday-name">{weekday_name}</div> | ||||
| @@ -293,7 +329,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                     }).collect::<Html>() | ||||
|                 } | ||||
|             </div> | ||||
|              | ||||
|  | ||||
|             // Scrollable content area with time grid | ||||
|             <div class="week-content"> | ||||
|                 <div class="time-grid"> | ||||
| @@ -310,18 +346,18 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                             }).collect::<Html>() | ||||
|                         } | ||||
|                     </div> | ||||
|                      | ||||
|  | ||||
|                     // Day columns | ||||
|                     <div class="week-days-grid"> | ||||
|                         { | ||||
|                             week_days.iter().enumerate().map(|(_column_index, date)| { | ||||
|                                 let is_today = *date == props.today; | ||||
|                                 let day_events = props.events.get(date).cloned().unwrap_or_default(); | ||||
|                                  | ||||
|  | ||||
|                                 // Drag event handlers | ||||
|                                 let drag_state_clone = drag_state.clone(); | ||||
|                                 let date_for_drag = *date; | ||||
|                                  | ||||
|  | ||||
|                                 let onmousedown = { | ||||
|                                     let drag_state = drag_state_clone.clone(); | ||||
|                                     let context_menus_open = props.context_menus_open; | ||||
| @@ -331,20 +367,20 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                         if context_menus_open { | ||||
|                                             return; | ||||
|                                         } | ||||
|                                          | ||||
|  | ||||
|                                         // Only handle left-click (button 0) | ||||
|                                         if e.button() != 0 { | ||||
|                                             return; | ||||
|                                         } | ||||
|                                          | ||||
|  | ||||
|                                         // Calculate Y position relative to day column container | ||||
|                                         // Use layer_y which gives coordinates relative to positioned ancestor | ||||
|                                         let relative_y = e.layer_y() as f64; | ||||
|                                         let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 }; | ||||
|                                          | ||||
|  | ||||
|                                         // Snap to increment | ||||
|                                         let snapped_y = snap_to_increment(relative_y, time_increment); | ||||
|                                          | ||||
|  | ||||
|                                         drag_state.set(Some(DragState { | ||||
|                                             is_dragging: true, | ||||
|                                             drag_type: DragType::CreateEvent, | ||||
| @@ -357,7 +393,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                         e.prevent_default(); | ||||
|                                     }) | ||||
|                                 }; | ||||
|                                  | ||||
|  | ||||
|                                 let onmousemove = { | ||||
|                                     let drag_state = drag_state_clone.clone(); | ||||
|                                     let time_increment = props.time_increment; | ||||
| @@ -367,27 +403,27 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                 // Use layer_y for consistent coordinate calculation | ||||
|                                                 let mouse_y = e.layer_y() as f64; | ||||
|                                                 let mouse_y = if mouse_y > 0.0 { mouse_y } else { e.offset_y() as f64 }; | ||||
|                                                  | ||||
|  | ||||
|                                                 // For move operations, we now follow the mouse directly since we start at click position | ||||
|                                                 // For resize operations, we still use the mouse position directly | ||||
|                                                 let adjusted_y = mouse_y; | ||||
|                                                  | ||||
|  | ||||
|                                                 // Snap to increment | ||||
|                                                 let snapped_y = snap_to_increment(adjusted_y, time_increment); | ||||
|                                                  | ||||
|  | ||||
|                                                 // Check if we've moved enough to constitute a real drag (5 pixels minimum) | ||||
|                                                 let movement_distance = (snapped_y - current_drag.start_y).abs(); | ||||
|                                                 if movement_distance > 5.0 { | ||||
|                                                     current_drag.has_moved = true; | ||||
|                                                 } | ||||
|                                                  | ||||
|  | ||||
|                                                 current_drag.current_y = snapped_y; | ||||
|                                                 drag_state.set(Some(current_drag)); | ||||
|                                             } | ||||
|                                         } | ||||
|                                     }) | ||||
|                                 }; | ||||
|                                  | ||||
|  | ||||
|                                 let onmouseup = { | ||||
|                                     let drag_state = drag_state_clone.clone(); | ||||
|                                     let on_create_event = props.on_create_event.clone(); | ||||
| @@ -402,24 +438,24 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                         // Calculate start and end times | ||||
|                                                         let start_time = pixels_to_time(current_drag.start_y); | ||||
|                                                         let end_time = pixels_to_time(current_drag.current_y); | ||||
|                                                          | ||||
|  | ||||
|                                                         // Ensure start is before end | ||||
|                                                         let (actual_start, actual_end) = if start_time <= end_time { | ||||
|                                                             (start_time, end_time) | ||||
|                                                         } else { | ||||
|                                                             (end_time, start_time) | ||||
|                                                         }; | ||||
|                                                          | ||||
|  | ||||
|                                                         // Ensure minimum duration (15 minutes) | ||||
|                                                         let actual_end = if actual_end.signed_duration_since(actual_start).num_minutes() < 15 { | ||||
|                                                             actual_start + chrono::Duration::minutes(15) | ||||
|                                                         } else { | ||||
|                                                             actual_end | ||||
|                                                         }; | ||||
|                                                          | ||||
|  | ||||
|                                                         let start_datetime = NaiveDateTime::new(current_drag.start_date, actual_start); | ||||
|                                                         let end_datetime = NaiveDateTime::new(current_drag.start_date, actual_end); | ||||
|                                                          | ||||
|  | ||||
|                                                         if let Some(callback) = &on_create_event { | ||||
|                                                             callback.emit((current_drag.start_date, start_datetime, end_datetime)); | ||||
|                                                         } | ||||
| @@ -430,17 +466,17 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                         // Snap the final position to maintain time increment alignment | ||||
|                                                         let event_top_position = snap_to_increment(unsnapped_position, time_increment); | ||||
|                                                         let new_start_time = pixels_to_time(event_top_position); | ||||
|                                                          | ||||
|  | ||||
|                                                         // Calculate duration from original event | ||||
|                                                         let original_duration = if let Some(end) = event.dtend { | ||||
|                                                             end.signed_duration_since(event.dtstart) | ||||
|                                                         } else { | ||||
|                                                             chrono::Duration::hours(1) // Default 1 hour | ||||
|                                                         }; | ||||
|                                                          | ||||
|  | ||||
|                                                         let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time); | ||||
|                                                         let new_end_datetime = new_start_datetime + original_duration; | ||||
|                                                          | ||||
|  | ||||
|                                                         // Check if this is a recurring event | ||||
|                                                         if event.rrule.is_some() { | ||||
|                                                             // Show modal for recurring event modification | ||||
| @@ -459,7 +495,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                     DragType::ResizeEventStart(event) => { | ||||
|                                                         // Calculate new start time based on drag position | ||||
|                                                         let new_start_time = pixels_to_time(current_drag.current_y); | ||||
|                                                          | ||||
|  | ||||
|                                                         // Keep the original end time | ||||
|                                                         let original_end = if let Some(end) = event.dtend { | ||||
|                                                             end.with_timezone(&chrono::Local).naive_local() | ||||
| @@ -467,16 +503,16 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             // If no end time, use start time + 1 hour as default | ||||
|                                                             event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1) | ||||
|                                                         }; | ||||
|                                                          | ||||
|  | ||||
|                                                         let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time); | ||||
|                                                          | ||||
|  | ||||
|                                                         // Ensure start is before end (minimum 15 minutes) | ||||
|                                                         let new_end_datetime = if new_start_datetime >= original_end { | ||||
|                                                             new_start_datetime + chrono::Duration::minutes(15) | ||||
|                                                         } else { | ||||
|                                                             original_end | ||||
|                                                         }; | ||||
|                                                          | ||||
|  | ||||
|                                                         // Check if this is a recurring event | ||||
|                                                         if event.rrule.is_some() { | ||||
|                                                             // Show modal for recurring event modification | ||||
| @@ -495,19 +531,19 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                     DragType::ResizeEventEnd(event) => { | ||||
|                                                         // Calculate new end time based on drag position | ||||
|                                                         let new_end_time = pixels_to_time(current_drag.current_y); | ||||
|                                                          | ||||
|  | ||||
|                                                         // Keep the original start time | ||||
|                                                         let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local(); | ||||
|                                                          | ||||
|  | ||||
|                                                         let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time); | ||||
|                                                          | ||||
|  | ||||
|                                                         // Ensure end is after start (minimum 15 minutes) | ||||
|                                                         let new_start_datetime = if new_end_datetime <= original_start { | ||||
|                                                             new_end_datetime - chrono::Duration::minutes(15) | ||||
|                                                         } else { | ||||
|                                                             original_start | ||||
|                                                         }; | ||||
|                                                          | ||||
|  | ||||
|                                                         // Check if this is a recurring event | ||||
|                                                         if event.rrule.is_some() { | ||||
|                                                             // Show modal for recurring event modification | ||||
| @@ -524,15 +560,15 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                         } | ||||
|                                                     } | ||||
|                                                 } | ||||
|                                                  | ||||
|  | ||||
|                                                 drag_state.set(None); | ||||
|                                             } | ||||
|                                         } | ||||
|                                     }) | ||||
|                                 }; | ||||
|                                  | ||||
|  | ||||
|                                 html! { | ||||
|                                     <div  | ||||
|                                     <div | ||||
|                                         class={classes!("week-day-column", if is_today { Some("today") } else { None })} | ||||
|                                         {onmousedown} | ||||
|                                         {onmousemove} | ||||
| @@ -554,21 +590,21 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                             <div class="time-slot-half"></div> | ||||
|                                             <div class="time-slot-half"></div> | ||||
|                                         </div> | ||||
|                                          | ||||
|  | ||||
|                                         // Events positioned absolutely based on their actual times | ||||
|                                         <div class="events-container"> | ||||
|                                             { | ||||
|                                                 day_events.iter().filter_map(|event| { | ||||
|                                                     let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date); | ||||
|                                                      | ||||
|  | ||||
|                                                     // Skip events that don't belong on this date or have invalid positioning | ||||
|                                                     if start_pixels == 0.0 && duration_pixels == 0.0 && !is_all_day { | ||||
|                                                         return None; | ||||
|                                                     } | ||||
|                                                      | ||||
|  | ||||
|                                                     let event_color = get_event_color(event); | ||||
|                                                     let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid); | ||||
|                                                      | ||||
|  | ||||
|                                                     let onclick = { | ||||
|                                                         let on_event_click = props.on_event_click.clone(); | ||||
|                                                         let event = event.clone(); | ||||
| @@ -577,7 +613,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             on_event_click.emit(event.clone()); | ||||
|                                                         }) | ||||
|                                                     }; | ||||
|                                                      | ||||
|  | ||||
|                                                     let onmousedown_event = { | ||||
|                                                         let drag_state = drag_state.clone(); | ||||
|                                                         let event_for_drag = event.clone(); | ||||
| @@ -585,27 +621,27 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                         let _time_increment = props.time_increment; | ||||
|                                                         Callback::from(move |e: MouseEvent| { | ||||
|                                                             e.stop_propagation(); // Prevent drag-to-create from starting on event clicks | ||||
|                                                              | ||||
|  | ||||
|                                                             // Only handle left-click (button 0) for moving | ||||
|                                                             if e.button() != 0 { | ||||
|                                                                 return; | ||||
|                                                             } | ||||
|                                                              | ||||
|  | ||||
|                                                             // Calculate click position relative to event element | ||||
|                                                             let click_y_relative = e.layer_y() as f64; | ||||
|                                                             let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 }; | ||||
|                                                              | ||||
|  | ||||
|                                                             // Get event's current position in day column coordinates | ||||
|                                                             let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag); | ||||
|                                                             let event_start_pixels = event_start_pixels as f64; | ||||
|                                                              | ||||
|  | ||||
|                                                             // Convert click position to day column coordinates | ||||
|                                                             let click_y = event_start_pixels + click_y_relative; | ||||
|                                                              | ||||
|  | ||||
|                                                             // Store the offset from the event's top where the user clicked | ||||
|                                                             // This will be used to maintain the relative click position | ||||
|                                                             let offset_y = click_y_relative; | ||||
|                                                              | ||||
|  | ||||
|                                                             // Start drag tracking from where we clicked (in day column coordinates) | ||||
|                                                             drag_state.set(Some(DragState { | ||||
|                                                                 is_dragging: true, | ||||
| @@ -619,7 +655,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             e.prevent_default(); | ||||
|                                                         }) | ||||
|                                                     }; | ||||
|                                                      | ||||
|  | ||||
|                                                     let oncontextmenu = { | ||||
|                                                         if let Some(callback) = &props.on_event_context_menu { | ||||
|                                                             let callback = callback.clone(); | ||||
| @@ -633,7 +669,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                                         return; | ||||
|                                                                     } | ||||
|                                                                 } | ||||
|                                                                  | ||||
|  | ||||
|                                                                 e.prevent_default(); | ||||
|                                                                 e.stop_propagation(); // Prevent calendar context menu from also triggering | ||||
|                                                                 callback.emit((e, event.clone())); | ||||
| @@ -642,7 +678,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             None | ||||
|                                                         } | ||||
|                                                     }; | ||||
|                                                      | ||||
|  | ||||
|                                                     // Format time display for the event | ||||
|                                                     let time_display = if event.all_day { | ||||
|                                                         "All Day".to_string() | ||||
| @@ -650,20 +686,20 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                         let local_start = event.dtstart.with_timezone(&Local); | ||||
|                                                         if let Some(end) = event.dtend { | ||||
|                                                             let local_end = end.with_timezone(&Local); | ||||
|                                                              | ||||
|  | ||||
|                                                             // Check if both times are in same AM/PM period to avoid redundancy | ||||
|                                                             let start_is_am = local_start.hour() < 12; | ||||
|                                                             let end_is_am = local_end.hour() < 12; | ||||
|                                                              | ||||
|  | ||||
|                                                             if start_is_am == end_is_am { | ||||
|                                                                 // Same AM/PM period - show "9:00 - 10:30 AM" | ||||
|                                                                 format!("{} - {}",  | ||||
|                                                                 format!("{} - {}", | ||||
|                                                                     local_start.format("%I:%M").to_string().trim_start_matches('0'), | ||||
|                                                                     local_end.format("%I:%M %p") | ||||
|                                                                 ) | ||||
|                                                             } else { | ||||
|                                                                 // Different AM/PM periods - show "9:00 AM - 2:30 PM" | ||||
|                                                                 format!("{} - {}",  | ||||
|                                                                 format!("{} - {}", | ||||
|                                                                     local_start.format("%I:%M %p"), | ||||
|                                                                     local_end.format("%I:%M %p") | ||||
|                                                                 ) | ||||
| @@ -673,22 +709,22 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             format!("{}", local_start.format("%I:%M %p")) | ||||
|                                                         } | ||||
|                                                     }; | ||||
|                                                      | ||||
|  | ||||
|                                                     // Check if this event is currently being dragged or resized | ||||
|                                                     let is_being_dragged = if let Some(drag) = (*drag_state).clone() { | ||||
|                                                         match &drag.drag_type { | ||||
|                                                             DragType::MoveEvent(dragged_event) =>  | ||||
|                                                             DragType::MoveEvent(dragged_event) => | ||||
|                                                                 dragged_event.uid == event.uid && drag.is_dragging, | ||||
|                                                             DragType::ResizeEventStart(dragged_event) =>  | ||||
|                                                             DragType::ResizeEventStart(dragged_event) => | ||||
|                                                                 dragged_event.uid == event.uid && drag.is_dragging, | ||||
|                                                             DragType::ResizeEventEnd(dragged_event) =>  | ||||
|                                                             DragType::ResizeEventEnd(dragged_event) => | ||||
|                                                                 dragged_event.uid == event.uid && drag.is_dragging, | ||||
|                                                             _ => false, | ||||
|                                                         } | ||||
|                                                     } else { | ||||
|                                                         false | ||||
|                                                     }; | ||||
|                                                      | ||||
|  | ||||
|                                                     if is_being_dragged { | ||||
|                                                         // Hide the original event while being dragged | ||||
|                                                         Some(html! {}) | ||||
| @@ -701,11 +737,11 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             let time_increment = props.time_increment; | ||||
|                                                             Callback::from(move |e: web_sys::MouseEvent| { | ||||
|                                                                 e.stop_propagation(); | ||||
|                                                                  | ||||
|  | ||||
|                                                                 let relative_y = e.layer_y() as f64; | ||||
|                                                                 let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 }; | ||||
|                                                                 let snapped_y = snap_to_increment(relative_y, time_increment); | ||||
|                                                                  | ||||
|  | ||||
|                                                                 drag_state.set(Some(DragState { | ||||
|                                                                     is_dragging: true, | ||||
|                                                                     drag_type: DragType::ResizeEventStart(event_for_resize.clone()), | ||||
| @@ -718,7 +754,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                                 e.prevent_default(); | ||||
|                                                             }) | ||||
|                                                         }; | ||||
|                                                          | ||||
|  | ||||
|                                                         let resize_end_handler = { | ||||
|                                                             let drag_state = drag_state.clone(); | ||||
|                                                             let event_for_resize = event.clone(); | ||||
| @@ -726,11 +762,11 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             let time_increment = props.time_increment; | ||||
|                                                             Callback::from(move |e: web_sys::MouseEvent| { | ||||
|                                                                 e.stop_propagation(); | ||||
|                                                                  | ||||
|  | ||||
|                                                                 let relative_y = e.layer_y() as f64; | ||||
|                                                                 let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 }; | ||||
|                                                                 let snapped_y = snap_to_increment(relative_y, time_increment); | ||||
|                                                                  | ||||
|  | ||||
|                                                                 drag_state.set(Some(DragState { | ||||
|                                                                     is_dragging: true, | ||||
|                                                                     drag_type: DragType::ResizeEventEnd(event_for_resize.clone()), | ||||
| @@ -743,18 +779,18 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                                 e.prevent_default(); | ||||
|                                                             }) | ||||
|                                                         }; | ||||
|                                                          | ||||
|  | ||||
|                                                         Some(html! { | ||||
|                                                             <div  | ||||
|                                                             <div | ||||
|                                                                 class={classes!( | ||||
|                                                                     "week-event",  | ||||
|                                                                     "week-event", | ||||
|                                                                     if is_refreshing { Some("refreshing") } else { None }, | ||||
|                                                                     if is_all_day { Some("all-day") } else { None } | ||||
|                                                                 )} | ||||
|                                                                 style={format!( | ||||
|                                                                     "background-color: {}; top: {}px; height: {}px;",  | ||||
|                                                                     event_color,  | ||||
|                                                                     start_pixels,  | ||||
|                                                                     "background-color: {}; top: {}px; height: {}px;", | ||||
|                                                                     event_color, | ||||
|                                                                     start_pixels, | ||||
|                                                                     duration_pixels | ||||
|                                                                 )} | ||||
|                                                                 {onclick} | ||||
| @@ -764,7 +800,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                                 // Top resize handle | ||||
|                                                                 {if !is_all_day { | ||||
|                                                                     html! { | ||||
|                                                                         <div  | ||||
|                                                                         <div | ||||
|                                                                             class="resize-handle resize-handle-top" | ||||
|                                                                             onmousedown={resize_start_handler} | ||||
|                                                                         /> | ||||
| @@ -772,7 +808,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                                 } else { | ||||
|                                                                     html! {} | ||||
|                                                                 }} | ||||
|                                                                  | ||||
|  | ||||
|                                                                 // Event content | ||||
|                                                                 <div class="event-content"> | ||||
|                                                                     <div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div> | ||||
| @@ -782,11 +818,11 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                                         html! {} | ||||
|                                                                     }} | ||||
|                                                                 </div> | ||||
|                                                                  | ||||
|  | ||||
|                                                                 // Bottom resize handle | ||||
|                                                                 {if !is_all_day { | ||||
|                                                                     html! { | ||||
|                                                                         <div  | ||||
|                                                                         <div | ||||
|                                                                             class="resize-handle resize-handle-bottom" | ||||
|                                                                             onmousedown={resize_end_handler} | ||||
|                                                                         /> | ||||
| @@ -800,7 +836,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                 }).collect::<Html>() | ||||
|                                             } | ||||
|                                         </div> | ||||
|                                          | ||||
|  | ||||
|                                         // Temporary event box during drag | ||||
|                                         { | ||||
|                                             if let Some(drag) = (*drag_state).clone() { | ||||
| @@ -810,11 +846,11 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             let start_y = drag.start_y.min(drag.current_y); | ||||
|                                                             let end_y = drag.start_y.max(drag.current_y); | ||||
|                                                             let height = (drag.current_y - drag.start_y).abs().max(20.0); | ||||
|                                                              | ||||
|  | ||||
|                                                             // Convert pixels to times for display | ||||
|                                                             let start_time = pixels_to_time(start_y); | ||||
|                                                             let end_time = pixels_to_time(end_y); | ||||
|                                                              | ||||
|  | ||||
|                                                             html! { | ||||
|                                                                 <div | ||||
|                                                                     class="temp-event-box" | ||||
| @@ -837,9 +873,9 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             }; | ||||
|                                                             let duration_pixels = (original_duration.num_minutes() as f64).max(20.0); | ||||
|                                                             let new_end_time = new_start_time + original_duration; | ||||
|                                                              | ||||
|  | ||||
|                                                             let event_color = get_event_color(event); | ||||
|                                                              | ||||
|  | ||||
|                                                             html! { | ||||
|                                                                 <div | ||||
|                                                                     class="temp-event-box moving-event" | ||||
| @@ -858,17 +894,17 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             } else { | ||||
|                                                                 event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1) | ||||
|                                                             }; | ||||
|                                                              | ||||
|  | ||||
|                                                             // Calculate positions for the preview | ||||
|                                                             let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date); | ||||
|                                                             let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local()); | ||||
|                                                             let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32); | ||||
|                                                              | ||||
|  | ||||
|                                                             let new_start_pixels = drag.current_y; | ||||
|                                                             let new_height = (original_end_pixels as f64 - new_start_pixels).max(20.0); | ||||
|                                                              | ||||
|  | ||||
|                                                             let event_color = get_event_color(event); | ||||
|                                                              | ||||
|  | ||||
|                                                             html! { | ||||
|                                                                 <div | ||||
|                                                                     class="temp-event-box resizing-event" | ||||
| @@ -883,15 +919,15 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             // Show the event being resized from the end | ||||
|                                                             let new_end_time = pixels_to_time(drag.current_y); | ||||
|                                                             let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local(); | ||||
|                                                              | ||||
|  | ||||
|                                                             // Calculate positions for the preview | ||||
|                                                             let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date); | ||||
|                                                              | ||||
|  | ||||
|                                                             let new_end_pixels = drag.current_y; | ||||
|                                                             let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0); | ||||
|                                                              | ||||
|  | ||||
|                                                             let event_color = get_event_color(event); | ||||
|                                                              | ||||
|  | ||||
|                                                             html! { | ||||
|                                                                 <div | ||||
|                                                                     class="temp-event-box resizing-event" | ||||
| @@ -917,10 +953,10 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|              | ||||
|  | ||||
|             // Recurring event modification modal | ||||
|             if let Some(edit) = (*pending_recurring_edit).clone() { | ||||
|                 <RecurringEditModal  | ||||
|                 <RecurringEditModal | ||||
|                     show={true} | ||||
|                     event={edit.event} | ||||
|                     new_start={edit.new_start} | ||||
| @@ -975,46 +1011,44 @@ fn pixels_to_time(pixels: f64) -> NaiveTime { | ||||
|     let total_minutes = pixels; // 1px = 1 minute | ||||
|     let hours = (total_minutes / 60.0) as u32; | ||||
|     let minutes = (total_minutes % 60.0) as u32; | ||||
|      | ||||
|  | ||||
|     // Handle midnight boundary - if we're at exactly 1440 pixels (24:00), return midnight | ||||
|     if total_minutes >= 1440.0 { | ||||
|         return NaiveTime::from_hms_opt(0, 0, 0).unwrap(); | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // Clamp to valid time range for within-day times | ||||
|     let hours = hours.min(23); | ||||
|     let minutes = minutes.min(59); | ||||
|      | ||||
|  | ||||
|     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) { | ||||
|     // Convert UTC times to local time for display | ||||
|     let local_start = event.dtstart.with_timezone(&Local); | ||||
|     let event_date = local_start.date_naive(); | ||||
|      | ||||
|  | ||||
|     // Only position events that are on this specific date | ||||
|     if event_date != date { | ||||
|         return (0.0, 0.0, false); // Event not on this date | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // Handle all-day events - they appear at the top | ||||
|     if event.all_day { | ||||
|         return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true | ||||
|     } | ||||
|      | ||||
|  | ||||
|     // Calculate start position in pixels from midnight | ||||
|     let start_hour = local_start.hour() as f32; | ||||
|     let start_minute = local_start.minute() as f32; | ||||
|     let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour | ||||
|      | ||||
|      | ||||
|  | ||||
|     // Calculate duration and height | ||||
|     let duration_pixels = if let Some(end) = event.dtend { | ||||
|         let local_end = end.with_timezone(&Local); | ||||
|         let end_date = local_end.date_naive(); | ||||
|          | ||||
|  | ||||
|         // Handle events that span multiple days by capping at midnight | ||||
|         if end_date > date { | ||||
|             // Event continues past midnight, cap at 24:00 (1440px) | ||||
| @@ -1028,6 +1062,6 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) | ||||
|     } else { | ||||
|         60.0 // Default 1 hour if no end time | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     (start_pixels, duration_pixels, false) // is_all_day = false | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,284 +0,0 @@ | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use std::env; | ||||
| use base64::prelude::*; | ||||
|  | ||||
| /// Configuration for CalDAV server connection and authentication. | ||||
| ///  | ||||
| /// This struct holds all the necessary information to connect to a CalDAV server, | ||||
| /// including server URL, credentials, and optional collection paths. | ||||
| ///  | ||||
| /// # Security Note | ||||
| ///  | ||||
| /// The password field contains sensitive information and should be handled carefully. | ||||
| /// This struct implements `Debug` but in production, consider implementing a custom | ||||
| /// `Debug` that masks the password field. | ||||
| ///  | ||||
| /// # Example | ||||
| ///  | ||||
| /// ```rust | ||||
| /// use crate::config::CalDAVConfig; | ||||
| ///  | ||||
| /// // Load configuration from environment variables | ||||
| /// let config = CalDAVConfig::from_env()?; | ||||
| ///  | ||||
| /// // Use the configuration for HTTP requests | ||||
| /// let auth_header = format!("Basic {}", config.get_basic_auth()); | ||||
| /// ``` | ||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| pub struct CalDAVConfig { | ||||
|     /// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/") | ||||
|     pub server_url: String, | ||||
|      | ||||
|     /// Username for authentication with the CalDAV server | ||||
|     pub username: String, | ||||
|      | ||||
|     /// Password for authentication with the CalDAV server | ||||
|     ///  | ||||
|     /// **Security Note**: This contains sensitive information | ||||
|     pub password: String, | ||||
|      | ||||
|     /// Optional path to the calendar collection on the server | ||||
|     ///  | ||||
|     /// If not provided, the client will need to discover available calendars | ||||
|     /// through CalDAV PROPFIND requests | ||||
|     pub calendar_path: Option<String>, | ||||
|      | ||||
|     /// Optional path to the tasks/todo collection on the server | ||||
|     ///  | ||||
|     /// Some CalDAV servers store tasks separately from calendar events | ||||
|     pub tasks_path: Option<String>, | ||||
| } | ||||
|  | ||||
| impl CalDAVConfig { | ||||
|     /// Creates a new CalDAVConfig by loading values from environment variables. | ||||
|     ///  | ||||
|     /// This method will attempt to load a `.env` file from the current directory | ||||
|     /// and then read the following required environment variables: | ||||
|     ///  | ||||
|     /// - `CALDAV_SERVER_URL`: The CalDAV server base URL | ||||
|     /// - `CALDAV_USERNAME`: Username for authentication | ||||
|     /// - `CALDAV_PASSWORD`: Password for authentication | ||||
|     ///  | ||||
|     /// Optional environment variables: | ||||
|     ///  | ||||
|     /// - `CALDAV_CALENDAR_PATH`: Path to calendar collection | ||||
|     /// - `CALDAV_TASKS_PATH`: Path to tasks collection | ||||
|     ///  | ||||
|     /// # Errors | ||||
|     ///  | ||||
|     /// Returns `ConfigError::MissingVar` if any required environment variable | ||||
|     /// is not set or cannot be read. | ||||
|     ///  | ||||
|     /// # Example | ||||
|     ///  | ||||
|     /// ```rust | ||||
|     /// use crate::config::CalDAVConfig; | ||||
|     ///  | ||||
|     /// match CalDAVConfig::from_env() { | ||||
|     ///     Ok(config) => { | ||||
|     ///         println!("Loaded config for server: {}", config.server_url); | ||||
|     ///     } | ||||
|     ///     Err(e) => { | ||||
|     ///         eprintln!("Failed to load config: {}", e); | ||||
|     ///     } | ||||
|     /// } | ||||
|     /// ``` | ||||
|     pub fn from_env() -> Result<Self, ConfigError> { | ||||
|         // Attempt to load .env file, but don't fail if it doesn't exist | ||||
|         dotenvy::dotenv().ok(); | ||||
|  | ||||
|         let server_url = env::var("CALDAV_SERVER_URL") | ||||
|             .map_err(|_| ConfigError::MissingVar("CALDAV_SERVER_URL".to_string()))?; | ||||
|  | ||||
|         let username = env::var("CALDAV_USERNAME") | ||||
|             .map_err(|_| ConfigError::MissingVar("CALDAV_USERNAME".to_string()))?; | ||||
|  | ||||
|         let password = env::var("CALDAV_PASSWORD") | ||||
|             .map_err(|_| ConfigError::MissingVar("CALDAV_PASSWORD".to_string()))?; | ||||
|  | ||||
|         // Optional paths - it's fine if these are not set | ||||
|         let calendar_path = env::var("CALDAV_CALENDAR_PATH").ok(); | ||||
|         let tasks_path = env::var("CALDAV_TASKS_PATH").ok(); | ||||
|  | ||||
|         Ok(CalDAVConfig { | ||||
|             server_url, | ||||
|             username, | ||||
|             password, | ||||
|             calendar_path, | ||||
|             tasks_path, | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     /// Generates a Base64-encoded string for HTTP Basic Authentication. | ||||
|     ///  | ||||
|     /// This method combines the username and password in the format | ||||
|     /// `username:password` and encodes it using Base64, which is the | ||||
|     /// standard format for the `Authorization: Basic` HTTP header. | ||||
|     ///  | ||||
|     /// # Returns | ||||
|     ///  | ||||
|     /// A Base64-encoded string that can be used directly in the | ||||
|     /// `Authorization` header: `Authorization: Basic <returned_value>` | ||||
|     ///  | ||||
|     /// # Example | ||||
|     ///  | ||||
|     /// ```rust | ||||
|     /// use crate::config::CalDAVConfig; | ||||
|     ///  | ||||
|     /// let config = CalDAVConfig { | ||||
|     ///     server_url: "https://example.com".to_string(), | ||||
|     ///     username: "user".to_string(), | ||||
|     ///     password: "pass".to_string(), | ||||
|     ///     calendar_path: None, | ||||
|     ///     tasks_path: None, | ||||
|     /// }; | ||||
|     ///  | ||||
|     /// let auth_value = config.get_basic_auth(); | ||||
|     /// let auth_header = format!("Basic {}", auth_value); | ||||
|     /// ``` | ||||
|     pub fn get_basic_auth(&self) -> String { | ||||
|         let credentials = format!("{}:{}", self.username, self.password); | ||||
|         BASE64_STANDARD.encode(&credentials) | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Errors that can occur when loading or using CalDAV configuration. | ||||
| #[derive(Debug, thiserror::Error)] | ||||
| pub enum ConfigError { | ||||
|     /// A required environment variable is missing or cannot be read. | ||||
|     ///  | ||||
|     /// This error occurs when calling `CalDAVConfig::from_env()` and one of the | ||||
|     /// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`, | ||||
|     /// or `CALDAV_PASSWORD`) is not set. | ||||
|     #[error("Missing environment variable: {0}")] | ||||
|     MissingVar(String), | ||||
|      | ||||
|     /// The configuration contains invalid or malformed values. | ||||
|     ///  | ||||
|     /// This could include malformed URLs, invalid authentication credentials, | ||||
|     /// or other configuration issues that prevent proper CalDAV operation. | ||||
|     #[error("Invalid configuration: {0}")] | ||||
|     Invalid(String), | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use super::*; | ||||
|  | ||||
|     #[test] | ||||
|     fn test_basic_auth_encoding() { | ||||
|         let config = CalDAVConfig { | ||||
|             server_url: "https://example.com".to_string(), | ||||
|             username: "testuser".to_string(), | ||||
|             password: "testpass".to_string(), | ||||
|             calendar_path: None, | ||||
|             tasks_path: None, | ||||
|         }; | ||||
|  | ||||
|         let auth = config.get_basic_auth(); | ||||
|         let expected = BASE64_STANDARD.encode("testuser:testpass"); | ||||
|         assert_eq!(auth, expected); | ||||
|     } | ||||
|  | ||||
|     /// Integration test that authenticates with the actual Baikal CalDAV server | ||||
|     ///  | ||||
|     /// This test requires a valid .env file with: | ||||
|     /// - CALDAV_SERVER_URL | ||||
|     /// - CALDAV_USERNAME   | ||||
|     /// - CALDAV_PASSWORD | ||||
|     ///  | ||||
|     /// Run with: `cargo test test_baikal_auth` | ||||
|     #[tokio::test] | ||||
|     async fn test_baikal_auth() { | ||||
|         // Load config from .env | ||||
|         let config = CalDAVConfig::from_env() | ||||
|             .expect("Failed to load CalDAV config from environment"); | ||||
|  | ||||
|         println!("Testing authentication to: {}", config.server_url); | ||||
|  | ||||
|         // Create HTTP client | ||||
|         let client = reqwest::Client::new(); | ||||
|  | ||||
|         // Make a simple OPTIONS request to test authentication | ||||
|         let response = client | ||||
|             .request(reqwest::Method::OPTIONS, &config.server_url) | ||||
|             .header("Authorization", format!("Basic {}", config.get_basic_auth())) | ||||
|             .header("User-Agent", "calendar-app/0.1.0") | ||||
|             .send() | ||||
|             .await | ||||
|             .expect("Failed to send request to CalDAV server"); | ||||
|  | ||||
|         println!("Response status: {}", response.status()); | ||||
|         println!("Response headers: {:#?}", response.headers()); | ||||
|  | ||||
|         // Check if we got a successful response or at least not a 401 Unauthorized | ||||
|         assert!( | ||||
|             response.status().is_success() || response.status() != 401, | ||||
|             "Authentication failed with status: {}. Check your credentials in .env", | ||||
|             response.status() | ||||
|         ); | ||||
|  | ||||
|         // For Baikal/CalDAV servers, we should see DAV headers | ||||
|         assert!( | ||||
|             response.headers().contains_key("dav") ||  | ||||
|             response.headers().contains_key("DAV") || | ||||
|             response.status().is_success(), | ||||
|             "Server doesn't appear to be a CalDAV server - missing DAV headers" | ||||
|         ); | ||||
|  | ||||
|         println!("✓ Authentication test passed!"); | ||||
|     } | ||||
|  | ||||
|     /// Test making a PROPFIND request to discover calendars | ||||
|     ///  | ||||
|     /// This test requires a valid .env file and makes an actual CalDAV PROPFIND request | ||||
|     ///  | ||||
|     /// Run with: `cargo test test_propfind_calendars` | ||||
|     #[tokio::test] | ||||
|     async fn test_propfind_calendars() { | ||||
|         let config = CalDAVConfig::from_env() | ||||
|             .expect("Failed to load CalDAV config from environment"); | ||||
|  | ||||
|         let client = reqwest::Client::new(); | ||||
|  | ||||
|         // CalDAV PROPFIND request to discover calendars | ||||
|         let propfind_body = r#"<?xml version="1.0" encoding="utf-8" ?> | ||||
| <d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> | ||||
|     <d:prop> | ||||
|         <d:resourcetype /> | ||||
|         <d:displayname /> | ||||
|         <c:calendar-description /> | ||||
|         <c:supported-calendar-component-set /> | ||||
|     </d:prop> | ||||
| </d:propfind>"#; | ||||
|  | ||||
|         let response = client | ||||
|             .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url) | ||||
|             .header("Authorization", format!("Basic {}", config.get_basic_auth())) | ||||
|             .header("Content-Type", "application/xml") | ||||
|             .header("Depth", "1") | ||||
|             .header("User-Agent", "calendar-app/0.1.0") | ||||
|             .body(propfind_body) | ||||
|             .send() | ||||
|             .await | ||||
|             .expect("Failed to send PROPFIND request"); | ||||
|  | ||||
|         let status = response.status(); | ||||
|         println!("PROPFIND Response status: {}", status); | ||||
|          | ||||
|         let body = response.text().await.expect("Failed to read response body"); | ||||
|         println!("PROPFIND Response body: {}", body); | ||||
|  | ||||
|         // We should get a 207 Multi-Status for PROPFIND | ||||
|         assert_eq!( | ||||
|             status, | ||||
|             reqwest::StatusCode::from_u16(207).unwrap(), | ||||
|             "PROPFIND should return 207 Multi-Status" | ||||
|         ); | ||||
|  | ||||
|         // The response should contain XML with calendar information | ||||
|         assert!(body.contains("calendar"), "Response should contain calendar information"); | ||||
|  | ||||
|         println!("✓ PROPFIND calendars test passed!"); | ||||
|     } | ||||
| } | ||||
| @@ -1,4 +1,3 @@ | ||||
|  | ||||
| mod app; | ||||
| mod auth; | ||||
| mod components; | ||||
| @@ -9,4 +8,4 @@ use app::App; | ||||
|  | ||||
| fn main() { | ||||
|     yew::Renderer::<App>::new().render(); | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,2 +1,2 @@ | ||||
| // Re-export from shared calendar-models library for backward compatibility | ||||
| pub use calendar_models::*; | ||||
| pub use calendar_models::*; | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| // RFC 5545 Compliant iCalendar Models | ||||
| pub mod ical; | ||||
|  | ||||
| // Re-export commonly used types   | ||||
| // pub use ical::VEvent; | ||||
| // Re-export commonly used types | ||||
| // pub use ical::VEvent; | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,3 +1,5 @@ | ||||
| pub mod calendar_service; | ||||
| pub mod preferences; | ||||
|  | ||||
| pub use calendar_service::CalendarService; | ||||
| pub use calendar_service::CalendarService; | ||||
| pub use preferences::PreferencesService; | ||||
|   | ||||
							
								
								
									
										177
									
								
								frontend/src/services/preferences.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								frontend/src/services/preferences.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,177 @@ | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serde_json; | ||||
| use wasm_bindgen::JsCast; | ||||
| use wasm_bindgen_futures::JsFuture; | ||||
| use web_sys::{Request, RequestInit, RequestMode, Response}; | ||||
|  | ||||
| #[derive(Debug, Serialize, Deserialize, Clone)] | ||||
| pub struct UserPreferences { | ||||
|     pub calendar_selected_date: Option<String>, | ||||
|     pub calendar_time_increment: Option<i32>, | ||||
|     pub calendar_view_mode: Option<String>, | ||||
|     pub calendar_theme: Option<String>, | ||||
|     pub calendar_colors: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Serialize)] | ||||
| pub struct UpdatePreferencesRequest { | ||||
|     pub calendar_selected_date: Option<String>, | ||||
|     pub calendar_time_increment: Option<i32>, | ||||
|     pub calendar_view_mode: Option<String>, | ||||
|     pub calendar_theme: Option<String>, | ||||
|     pub calendar_colors: Option<String>, | ||||
| } | ||||
|  | ||||
| pub struct PreferencesService { | ||||
|     base_url: String, | ||||
| } | ||||
|  | ||||
| impl PreferencesService { | ||||
|     pub fn new() -> Self { | ||||
|         let base_url = option_env!("BACKEND_API_URL") | ||||
|             .unwrap_or("http://localhost:3000/api") | ||||
|             .to_string(); | ||||
|          | ||||
|         Self { base_url } | ||||
|     } | ||||
|      | ||||
|     /// Load preferences from LocalStorage (cached from login) | ||||
|     pub fn load_cached() -> Option<UserPreferences> { | ||||
|         if let Ok(prefs_json) = LocalStorage::get::<String>("user_preferences") { | ||||
|             serde_json::from_str(&prefs_json).ok() | ||||
|         } else { | ||||
|             None | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /// Update a single preference field and sync with backend | ||||
|     pub async fn update_preference(&self, field: &str, value: serde_json::Value) -> Result<(), String> { | ||||
|         // Get session token | ||||
|         let session_token = LocalStorage::get::<String>("session_token") | ||||
|             .map_err(|_| "No session token found".to_string())?; | ||||
|          | ||||
|         // Load current preferences | ||||
|         let mut preferences = Self::load_cached().unwrap_or(UserPreferences { | ||||
|             calendar_selected_date: None, | ||||
|             calendar_time_increment: None, | ||||
|             calendar_view_mode: None, | ||||
|             calendar_theme: None, | ||||
|             calendar_colors: None, | ||||
|         }); | ||||
|          | ||||
|         // Update the specific field | ||||
|         match field { | ||||
|             "calendar_selected_date" => { | ||||
|                 preferences.calendar_selected_date = value.as_str().map(|s| s.to_string()); | ||||
|             } | ||||
|             "calendar_time_increment" => { | ||||
|                 preferences.calendar_time_increment = value.as_i64().map(|i| i as i32); | ||||
|             } | ||||
|             "calendar_view_mode" => { | ||||
|                 preferences.calendar_view_mode = value.as_str().map(|s| s.to_string()); | ||||
|             } | ||||
|             "calendar_theme" => { | ||||
|                 preferences.calendar_theme = value.as_str().map(|s| s.to_string()); | ||||
|             } | ||||
|             "calendar_colors" => { | ||||
|                 preferences.calendar_colors = value.as_str().map(|s| s.to_string()); | ||||
|             } | ||||
|             _ => return Err(format!("Unknown preference field: {}", field)), | ||||
|         } | ||||
|          | ||||
|         // Save to LocalStorage cache | ||||
|         if let Ok(prefs_json) = serde_json::to_string(&preferences) { | ||||
|             let _ = LocalStorage::set("user_preferences", &prefs_json); | ||||
|         } | ||||
|          | ||||
|         // Sync with backend | ||||
|         let request = UpdatePreferencesRequest { | ||||
|             calendar_selected_date: preferences.calendar_selected_date.clone(), | ||||
|             calendar_time_increment: preferences.calendar_time_increment, | ||||
|             calendar_view_mode: preferences.calendar_view_mode.clone(), | ||||
|             calendar_theme: preferences.calendar_theme.clone(), | ||||
|             calendar_colors: preferences.calendar_colors.clone(), | ||||
|         }; | ||||
|          | ||||
|         self.sync_preferences(&session_token, &request).await | ||||
|     } | ||||
|      | ||||
|     /// Sync all preferences with backend | ||||
|     async fn sync_preferences( | ||||
|         &self, | ||||
|         session_token: &str, | ||||
|         request: &UpdatePreferencesRequest, | ||||
|     ) -> Result<(), String> { | ||||
|         let window = web_sys::window().ok_or("No global window exists")?; | ||||
|          | ||||
|         let json_body = serde_json::to_string(request) | ||||
|             .map_err(|e| format!("JSON serialization failed: {}", e))?; | ||||
|          | ||||
|         let opts = RequestInit::new(); | ||||
|         opts.set_method("POST"); | ||||
|         opts.set_mode(RequestMode::Cors); | ||||
|         opts.set_body(&wasm_bindgen::JsValue::from_str(&json_body)); | ||||
|          | ||||
|         let url = format!("{}/preferences", self.base_url); | ||||
|         let request = Request::new_with_str_and_init(&url, &opts) | ||||
|             .map_err(|e| format!("Request creation failed: {:?}", e))?; | ||||
|          | ||||
|         request | ||||
|             .headers() | ||||
|             .set("Content-Type", "application/json") | ||||
|             .map_err(|e| format!("Header setting failed: {:?}", e))?; | ||||
|          | ||||
|         request | ||||
|             .headers() | ||||
|             .set("X-Session-Token", session_token) | ||||
|             .map_err(|e| format!("Header setting failed: {:?}", e))?; | ||||
|          | ||||
|         let resp_value = JsFuture::from(window.fetch_with_request(&request)) | ||||
|             .await | ||||
|             .map_err(|e| format!("Network request failed: {:?}", e))?; | ||||
|          | ||||
|         let resp: Response = resp_value | ||||
|             .dyn_into() | ||||
|             .map_err(|e| format!("Response cast failed: {:?}", e))?; | ||||
|          | ||||
|         if resp.ok() { | ||||
|             Ok(()) | ||||
|         } else { | ||||
|             Err(format!("Failed to update preferences: {}", resp.status())) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /// Migrate preferences from LocalStorage to backend (on first login after update) | ||||
|     pub async fn migrate_from_local_storage(&self) -> Result<(), String> { | ||||
|         let session_token = LocalStorage::get::<String>("session_token") | ||||
|             .map_err(|_| "No session token found".to_string())?; | ||||
|          | ||||
|         let mut request = UpdatePreferencesRequest { | ||||
|             calendar_selected_date: LocalStorage::get::<String>("calendar_selected_date").ok(), | ||||
|             calendar_time_increment: LocalStorage::get::<u32>("calendar_time_increment").ok().map(|i| i as i32), | ||||
|             calendar_view_mode: LocalStorage::get::<String>("calendar_view_mode").ok(), | ||||
|             calendar_theme: LocalStorage::get::<String>("calendar_theme").ok(), | ||||
|             calendar_colors: LocalStorage::get::<String>("calendar_colors").ok(), | ||||
|         }; | ||||
|          | ||||
|         // Only migrate if we have some preferences to migrate | ||||
|         if request.calendar_selected_date.is_some() | ||||
|             || request.calendar_time_increment.is_some() | ||||
|             || request.calendar_view_mode.is_some() | ||||
|             || request.calendar_theme.is_some() | ||||
|             || request.calendar_colors.is_some() | ||||
|         { | ||||
|             self.sync_preferences(&session_token, &request).await?; | ||||
|              | ||||
|             // Clear old LocalStorage entries after successful migration | ||||
|             let _ = LocalStorage::delete("calendar_selected_date"); | ||||
|             let _ = LocalStorage::delete("calendar_time_increment"); | ||||
|             let _ = LocalStorage::delete("calendar_view_mode"); | ||||
|             let _ = LocalStorage::delete("calendar_theme"); | ||||
|             let _ = LocalStorage::delete("calendar_colors"); | ||||
|         } | ||||
|          | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
| @@ -289,6 +289,30 @@ body { | ||||
|     cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| .remember-checkbox { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 0.375rem; | ||||
|     margin-top: 0.375rem; | ||||
|     opacity: 0.7; | ||||
| } | ||||
|  | ||||
| .remember-checkbox input[type="checkbox"] { | ||||
|     width: auto; | ||||
|     margin: 0; | ||||
|     cursor: pointer; | ||||
|     transform: scale(0.85); | ||||
| } | ||||
|  | ||||
| .remember-checkbox label { | ||||
|     margin: 0; | ||||
|     font-size: 0.75rem; | ||||
|     color: #888; | ||||
|     cursor: pointer; | ||||
|     user-select: none; | ||||
|     font-weight: 400; | ||||
| } | ||||
|  | ||||
| .login-button, .register-button { | ||||
|     width: 100%; | ||||
|     padding: 0.75rem; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user