Compare commits
	
		
			11 Commits
		
	
	
		
			74d636117d
			...
			bugfix/sql
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | cd6e9c3619 | ||
|   | d8c3997f24 | ||
|   | e44d49e190 | ||
|   | 0453763c98 | ||
|   | 03c0011445 | ||
|   | 79f287ed61 | ||
|   | e55e6bf4dd | ||
| 1fa3bf44b6 | |||
|   | 51d5552156 | ||
|   | 5a12c0e0d0 | ||
|   | ee181cf6cb | 
							
								
								
									
										34
									
								
								.gitea/workflows/docker.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								.gitea/workflows/docker.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | |||||||
|  | name: Build and Push Docker Image | ||||||
|  |  | ||||||
|  | on: | ||||||
|  |   push: | ||||||
|  |     branches: | ||||||
|  |       - main | ||||||
|  |  | ||||||
|  | jobs: | ||||||
|  |   docker: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     steps: | ||||||
|  |       - name: Checkout code | ||||||
|  |         uses: actions/checkout@v4 | ||||||
|  |  | ||||||
|  |       - name: Set up Docker Buildx | ||||||
|  |         uses: docker/setup-buildx-action@v3 | ||||||
|  |  | ||||||
|  |       - name: Login to Docker Registry | ||||||
|  |         uses: docker/login-action@v3 | ||||||
|  |         with: | ||||||
|  |           registry: ${{ secrets.DOCKER_REGISTRY }} | ||||||
|  |           username: ${{ secrets.DOCKER_USERNAME }} | ||||||
|  |           password: ${{ secrets.DOCKER_PASSWORD }} | ||||||
|  |  | ||||||
|  |       - name: Build and push | ||||||
|  |         uses: docker/build-push-action@v5 | ||||||
|  |         with: | ||||||
|  |           context: . | ||||||
|  |           push: true | ||||||
|  |           tags: | | ||||||
|  |             ${{ secrets.DOCKER_REGISTRY }}/calendar:latest | ||||||
|  |             ${{ secrets.DOCKER_REGISTRY }}/calendar:${{ github.sha }} | ||||||
|  |           cache-from: type=gha | ||||||
|  |           cache-to: type=gha,mode=max | ||||||
							
								
								
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -22,3 +22,9 @@ dist/ | |||||||
| CLAUDE.md | CLAUDE.md | ||||||
|  |  | ||||||
| data/ | data/ | ||||||
|  |  | ||||||
|  | # SQLite database | ||||||
|  | *.db | ||||||
|  | *.db-shm | ||||||
|  | *.db-wal | ||||||
|  | calendar.db | ||||||
|   | |||||||
							
								
								
									
										21
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -47,6 +47,9 @@ FROM rust:alpine AS backend-builder | |||||||
| WORKDIR /app | WORKDIR /app | ||||||
| RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static | RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static | ||||||
|  |  | ||||||
|  | # Install sqlx-cli for migrations | ||||||
|  | RUN cargo install sqlx-cli --no-default-features --features sqlite | ||||||
|  |  | ||||||
| # Copy shared models | # Copy shared models | ||||||
| COPY calendar-models ./calendar-models | COPY calendar-models ./calendar-models | ||||||
|  |  | ||||||
| @@ -76,19 +79,29 @@ RUN cargo build --release --bin backend | |||||||
| FROM alpine:latest | FROM alpine:latest | ||||||
|  |  | ||||||
| # Install runtime dependencies | # Install runtime dependencies | ||||||
| RUN apk add --no-cache ca-certificates tzdata | RUN apk add --no-cache ca-certificates tzdata sqlite | ||||||
|  |  | ||||||
| # Copy frontend files to temporary location | # Copy frontend files to temporary location | ||||||
| COPY --from=builder /app/frontend/dist /app/frontend-dist | COPY --from=builder /app/frontend/dist /app/frontend-dist | ||||||
|  |  | ||||||
| # Copy backend binary (built in workspace root) | # Copy backend binary and sqlx-cli | ||||||
| COPY --from=backend-builder /app/target/release/backend /usr/local/bin/backend | COPY --from=backend-builder /app/target/release/backend /usr/local/bin/backend | ||||||
|  | COPY --from=backend-builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx | ||||||
|  |  | ||||||
| # Create startup script to copy frontend files to shared volume | # Copy migrations for database setup | ||||||
| RUN mkdir -p /srv/www | COPY --from=backend-builder /app/backend/migrations /migrations | ||||||
|  |  | ||||||
|  | # Create startup script to copy frontend files, run migrations, and start backend | ||||||
|  | RUN mkdir -p /srv/www /db | ||||||
| RUN echo '#!/bin/sh' > /usr/local/bin/start.sh && \ | RUN echo '#!/bin/sh' > /usr/local/bin/start.sh && \ | ||||||
|  |     echo 'echo "Copying frontend files..."' >> /usr/local/bin/start.sh && \ | ||||||
|     echo 'cp -r /app/frontend-dist/* /srv/www/' >> /usr/local/bin/start.sh && \ |     echo 'cp -r /app/frontend-dist/* /srv/www/' >> /usr/local/bin/start.sh && \ | ||||||
|  |     echo 'echo "Ensuring database directory exists..."' >> /usr/local/bin/start.sh && \ | ||||||
|  |     echo 'mkdir -p /db && chmod 755 /db' >> /usr/local/bin/start.sh && \ | ||||||
|  |     echo 'echo "Running database migrations..."' >> /usr/local/bin/start.sh && \ | ||||||
|  |     echo 'sqlx migrate run --database-url "sqlite:///db/calendar.db" --source /migrations || echo "Migration failed but continuing..."' >> /usr/local/bin/start.sh && \ | ||||||
|     echo 'echo "Starting backend server..."' >> /usr/local/bin/start.sh && \ |     echo 'echo "Starting backend server..."' >> /usr/local/bin/start.sh && \ | ||||||
|  |     echo 'export DATABASE_URL="sqlite:///db/calendar.db"' >> /usr/local/bin/start.sh && \ | ||||||
|     echo '/usr/local/bin/backend' >> /usr/local/bin/start.sh && \ |     echo '/usr/local/bin/backend' >> /usr/local/bin/start.sh && \ | ||||||
|     chmod +x /usr/local/bin/start.sh |     chmod +x /usr/local/bin/start.sh | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										48
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										48
									
								
								README.md
									
									
									
									
									
								
							| @@ -29,6 +29,12 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl | |||||||
| - **Real-time Updates**: Seamless synchronization with CalDAV servers | - **Real-time Updates**: Seamless synchronization with CalDAV servers | ||||||
| - **Timezone Aware**: Proper local time display with UTC storage | - **Timezone Aware**: Proper local time display with UTC storage | ||||||
|  |  | ||||||
|  | ### User Experience | ||||||
|  | - **Persistent Preferences**: Settings sync across devices and sessions | ||||||
|  | - **Remember Me**: Optional server/username remembering for convenience | ||||||
|  | - **Session Management**: Secure session tokens with automatic expiry | ||||||
|  | - **Cross-Device Sync**: User preferences stored in database, not just browser | ||||||
|  |  | ||||||
| ## Architecture | ## Architecture | ||||||
|  |  | ||||||
| ### Frontend (Yew WebAssembly) | ### Frontend (Yew WebAssembly) | ||||||
| @@ -40,7 +46,8 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl | |||||||
|  |  | ||||||
| ### Backend (Axum)   | ### Backend (Axum)   | ||||||
| - **Framework**: Axum async web framework with CORS support | - **Framework**: Axum async web framework with CORS support | ||||||
| - **Authentication**: JWT token management and validation | - **Authentication**: SQLite-backed session management with JWT tokens | ||||||
|  | - **Database**: SQLite for user preferences and session storage | ||||||
| - **CalDAV Client**: Full CalDAV protocol implementation for server sync | - **CalDAV Client**: Full CalDAV protocol implementation for server sync | ||||||
| - **API Design**: RESTful endpoints following calendar operation patterns | - **API Design**: RESTful endpoints following calendar operation patterns | ||||||
| - **Data Flow**: Proxy between frontend and CalDAV servers with proper authentication | - **Data Flow**: Proxy between frontend and CalDAV servers with proper authentication | ||||||
| @@ -54,12 +61,36 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl | |||||||
|  |  | ||||||
| ## Getting Started | ## Getting Started | ||||||
|  |  | ||||||
| ### Prerequisites | ### Docker Deployment (Recommended) | ||||||
|  |  | ||||||
|  | The easiest way to run the calendar is using Docker Compose: | ||||||
|  |  | ||||||
|  | 1. **Clone the repository**: | ||||||
|  |    ```bash | ||||||
|  |    git clone <repository-url> | ||||||
|  |    cd calendar | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 2. **Start the application**: | ||||||
|  |    ```bash | ||||||
|  |    docker compose up | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 3. **Access the application** at `http://localhost` | ||||||
|  |  | ||||||
|  | The Docker setup includes: | ||||||
|  | - **Automatic database migrations** on startup | ||||||
|  | - **Persistent data storage** in `./data/db/` volume | ||||||
|  | - **Frontend served via Caddy** on port 80 | ||||||
|  | - **Backend API** accessible on port 3000 | ||||||
|  |  | ||||||
|  | ### Development Setup | ||||||
|  |  | ||||||
|  | #### Prerequisites | ||||||
| - Rust (latest stable version) | - Rust (latest stable version) | ||||||
| - Trunk (`cargo install trunk`) | - Trunk (`cargo install trunk`) | ||||||
|  |  | ||||||
| ### Development Setup | #### Local Development | ||||||
|  |  | ||||||
| 1. **Start the backend server** (serves API at http://localhost:3000): | 1. **Start the backend server** (serves API at http://localhost:3000): | ||||||
|    ```bash |    ```bash | ||||||
| @@ -73,6 +104,17 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl | |||||||
|  |  | ||||||
| 3. **Access the application** at `http://localhost:8080` | 3. **Access the application** at `http://localhost:8080` | ||||||
|  |  | ||||||
|  | #### Database Setup | ||||||
|  |  | ||||||
|  | For local development, run the database migrations: | ||||||
|  | ```bash | ||||||
|  | # Install sqlx-cli if not already installed | ||||||
|  | cargo install sqlx-cli --features sqlite | ||||||
|  |  | ||||||
|  | # Run migrations | ||||||
|  | sqlx migrate run --database-url "sqlite:calendar.db" --source backend/migrations | ||||||
|  | ``` | ||||||
|  |  | ||||||
| ### Building for Production | ### Building for Production | ||||||
|  |  | ||||||
| ```bash | ```bash | ||||||
|   | |||||||
| @@ -34,6 +34,10 @@ base64 = "0.21" | |||||||
| thiserror = "1.0" | thiserror = "1.0" | ||||||
| lazy_static = "1.4" | lazy_static = "1.4" | ||||||
|  |  | ||||||
|  | # Database dependencies | ||||||
|  | sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "uuid", "chrono", "json"] } | ||||||
|  | tokio-rusqlite = "0.5" | ||||||
|  |  | ||||||
| [dev-dependencies] | [dev-dependencies] | ||||||
| tokio = { version = "1.0", features = ["macros", "rt"] } | tokio = { version = "1.0", features = ["macros", "rt"] } | ||||||
| reqwest = { version = "0.11", features = ["json"] } | 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,10 +1,12 @@ | |||||||
| use chrono::{Duration, Utc}; | use chrono::{Duration, Utc}; | ||||||
| use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; | use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
|  | use uuid::Uuid; | ||||||
|  |  | ||||||
| use crate::models::{CalDAVLoginRequest, AuthResponse, ApiError}; |  | ||||||
| use crate::config::CalDAVConfig; |  | ||||||
| use crate::calendar::CalDAVClient; | 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)] | #[derive(Debug, Serialize, Deserialize)] | ||||||
| pub struct Claims { | pub struct Claims { | ||||||
| @@ -17,11 +19,12 @@ pub struct Claims { | |||||||
| #[derive(Clone)] | #[derive(Clone)] | ||||||
| pub struct AuthService { | pub struct AuthService { | ||||||
|     jwt_secret: String, |     jwt_secret: String, | ||||||
|  |     db: Database, | ||||||
| } | } | ||||||
|  |  | ||||||
| impl AuthService { | impl AuthService { | ||||||
|     pub fn new(jwt_secret: String) -> Self { |     pub fn new(jwt_secret: String, db: Database) -> Self { | ||||||
|         Self { jwt_secret } |         Self { jwt_secret, db } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Authenticate user directly against CalDAV server |     /// Authenticate user directly against CalDAV server | ||||||
| @@ -31,13 +34,11 @@ impl AuthService { | |||||||
|         println!("✅ Input validation passed"); |         println!("✅ Input validation passed"); | ||||||
|  |  | ||||||
|         // Create CalDAV config with provided credentials |         // Create CalDAV config with provided credentials | ||||||
|         let caldav_config = CalDAVConfig { |         let caldav_config = CalDAVConfig::new( | ||||||
|             server_url: request.server_url.clone(), |             request.server_url.clone(), | ||||||
|             username: request.username.clone(), |             request.username.clone(), | ||||||
|             password: request.password.clone(), |             request.password.clone(), | ||||||
|             calendar_path: None, |         ); | ||||||
|             tasks_path: None, |  | ||||||
|         }; |  | ||||||
|         println!("📝 Created CalDAV config"); |         println!("📝 Created CalDAV config"); | ||||||
|  |  | ||||||
|         // Test authentication against CalDAV server |         // Test authentication against CalDAV server | ||||||
| @@ -47,20 +48,59 @@ impl AuthService { | |||||||
|         // Try to discover calendars as an authentication test |         // Try to discover calendars as an authentication test | ||||||
|         match caldav_client.discover_calendars().await { |         match caldav_client.discover_calendars().await { | ||||||
|             Ok(calendars) => { |             Ok(calendars) => { | ||||||
|                 println!("✅ Authentication successful! Found {} calendars", calendars.len()); |                 println!( | ||||||
|                 // Authentication successful, generate JWT token |                     "✅ Authentication successful! Found {} calendars", | ||||||
|                 let token = self.generate_token(&request.username, &request.server_url)?; |                     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 { |                 Ok(AuthResponse { | ||||||
|                     token, |                     token: jwt_token, | ||||||
|  |                     session_token, | ||||||
|                     username: request.username, |                     username: request.username, | ||||||
|                     server_url: request.server_url, |                     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) => { |             Err(err) => { | ||||||
|                 println!("❌ Authentication failed: {:?}", err); |                 println!("❌ Authentication failed: {:?}", err); | ||||||
|                 // Authentication failed |                 // Authentication failed | ||||||
|                 Err(ApiError::Unauthorized("Invalid CalDAV credentials or server unavailable".to_string())) |                 Err(ApiError::Unauthorized( | ||||||
|  |                     "Invalid CalDAV credentials or server unavailable".to_string(), | ||||||
|  |                 )) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| @@ -71,16 +111,18 @@ impl AuthService { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Create CalDAV config from token |     /// Create CalDAV config from token | ||||||
|     pub fn caldav_config_from_token(&self, token: &str, password: &str) -> Result<CalDAVConfig, ApiError> { |     pub fn caldav_config_from_token( | ||||||
|  |         &self, | ||||||
|  |         token: &str, | ||||||
|  |         password: &str, | ||||||
|  |     ) -> Result<CalDAVConfig, ApiError> { | ||||||
|         let claims = self.verify_token(token)?; |         let claims = self.verify_token(token)?; | ||||||
|  |  | ||||||
|         Ok(CalDAVConfig { |         Ok(CalDAVConfig::new( | ||||||
|             server_url: claims.server_url, |             claims.server_url, | ||||||
|             username: claims.username, |             claims.username, | ||||||
|             password: password.to_string(), |             password.to_string(), | ||||||
|             calendar_path: None, |         )) | ||||||
|             tasks_path: None, |  | ||||||
|         }) |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> { |     fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> { | ||||||
| @@ -97,8 +139,11 @@ impl AuthService { | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Basic URL validation |         // Basic URL validation | ||||||
|         if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://") { |         if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://") | ||||||
|             return Err(ApiError::BadRequest("Server URL must start with http:// or https://".to_string())); |         { | ||||||
|  |             return Err(ApiError::BadRequest( | ||||||
|  |                 "Server URL must start with http:// or https://".to_string(), | ||||||
|  |             )); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         Ok(()) |         Ok(()) | ||||||
| @@ -135,4 +180,33 @@ impl AuthService { | |||||||
|  |  | ||||||
|         Ok(token_data.claims) |         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(()) | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -1,9 +1,9 @@ | |||||||
|  | use calendar_models::{CalendarUser, EventClass, EventStatus, VAlarm, VEvent}; | ||||||
| use chrono::{DateTime, Utc}; | use chrono::{DateTime, Utc}; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use std::sync::Arc; | use std::sync::Arc; | ||||||
| use tokio::sync::Mutex; | use tokio::sync::Mutex; | ||||||
| use calendar_models::{VEvent, EventStatus, EventClass, CalendarUser, VAlarm}; |  | ||||||
|  |  | ||||||
| // Global mutex to serialize CalDAV HTTP requests to prevent race conditions | // Global mutex to serialize CalDAV HTTP requests to prevent race conditions | ||||||
| lazy_static::lazy_static! { | lazy_static::lazy_static! { | ||||||
| @@ -128,7 +128,10 @@ impl CalDAVClient { | |||||||
|     /// |     /// | ||||||
|     /// This method performs a REPORT request to get calendar data and parses |     /// This method performs a REPORT request to get calendar data and parses | ||||||
|     /// the returned iCalendar format into CalendarEvent structs. |     /// the returned iCalendar format into CalendarEvent structs. | ||||||
|     pub async fn fetch_events(&self, calendar_path: &str) -> Result<Vec<CalendarEvent>, CalDAVError> { |     pub async fn fetch_events( | ||||||
|  |         &self, | ||||||
|  |         calendar_path: &str, | ||||||
|  |     ) -> Result<Vec<CalendarEvent>, CalDAVError> { | ||||||
|         // CalDAV REPORT request to get calendar events |         // CalDAV REPORT request to get calendar events | ||||||
|         let report_body = r#"<?xml version="1.0" encoding="utf-8" ?> |         let report_body = r#"<?xml version="1.0" encoding="utf-8" ?> | ||||||
| <c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> | <c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> | ||||||
| @@ -149,7 +152,11 @@ impl CalDAVClient { | |||||||
|             // Extract the base URL (scheme + host + port) from server_url |             // Extract the base URL (scheme + host + port) from server_url | ||||||
|             let server_url = &self.config.server_url; |             let server_url = &self.config.server_url; | ||||||
|             // Find the first '/' after "https://" or "http://" |             // Find the first '/' after "https://" or "http://" | ||||||
|             let scheme_end = if server_url.starts_with("https://") { 8 } else { 7 }; |             let scheme_end = if server_url.starts_with("https://") { | ||||||
|  |                 8 | ||||||
|  |             } else { | ||||||
|  |                 7 | ||||||
|  |             }; | ||||||
|             if let Some(path_start) = server_url[scheme_end..].find('/') { |             if let Some(path_start) = server_url[scheme_end..].find('/') { | ||||||
|                 let base_url = &server_url[..scheme_end + path_start]; |                 let base_url = &server_url[..scheme_end + path_start]; | ||||||
|                 format!("{}{}", base_url, calendar_path) |                 format!("{}{}", base_url, calendar_path) | ||||||
| @@ -163,7 +170,8 @@ impl CalDAVClient { | |||||||
|         println!("🔑 REPORT Basic Auth: Basic {}", basic_auth); |         println!("🔑 REPORT Basic Auth: Basic {}", basic_auth); | ||||||
|         println!("🌐 REPORT URL: {}", url); |         println!("🌐 REPORT URL: {}", url); | ||||||
|  |  | ||||||
|         let response = self.http_client |         let response = self | ||||||
|  |             .http_client | ||||||
|             .request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url) |             .request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url) | ||||||
|             .header("Authorization", format!("Basic {}", basic_auth)) |             .header("Authorization", format!("Basic {}", basic_auth)) | ||||||
|             .header("Content-Type", "application/xml") |             .header("Content-Type", "application/xml") | ||||||
| @@ -183,7 +191,11 @@ impl CalDAVClient { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Parse CalDAV XML response containing calendar data |     /// Parse CalDAV XML response containing calendar data | ||||||
|     fn parse_calendar_response(&self, xml_response: &str, calendar_path: &str) -> Result<Vec<CalendarEvent>, CalDAVError> { |     fn parse_calendar_response( | ||||||
|  |         &self, | ||||||
|  |         xml_response: &str, | ||||||
|  |         calendar_path: &str, | ||||||
|  |     ) -> Result<Vec<CalendarEvent>, CalDAVError> { | ||||||
|         let mut events = Vec::new(); |         let mut events = Vec::new(); | ||||||
|  |  | ||||||
|         // Extract calendar data from XML response |         // Extract calendar data from XML response | ||||||
| @@ -205,7 +217,11 @@ impl CalDAVClient { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Fetch a single calendar event by UID from the CalDAV server |     /// Fetch a single calendar event by UID from the CalDAV server | ||||||
|     pub async fn fetch_event_by_uid(&self, calendar_path: &str, uid: &str) -> Result<Option<CalendarEvent>, CalDAVError> { |     pub async fn fetch_event_by_uid( | ||||||
|  |         &self, | ||||||
|  |         calendar_path: &str, | ||||||
|  |         uid: &str, | ||||||
|  |     ) -> Result<Option<CalendarEvent>, CalDAVError> { | ||||||
|         // First fetch all events and find the one with matching UID |         // First fetch all events and find the one with matching UID | ||||||
|         let events = self.fetch_events(calendar_path).await?; |         let events = self.fetch_events(calendar_path).await?; | ||||||
|  |  | ||||||
| @@ -225,10 +241,16 @@ impl CalDAVClient { | |||||||
|             if let Some(end_pos) = response_block.find("</d:response>") { |             if let Some(end_pos) = response_block.find("</d:response>") { | ||||||
|                 let response_content = &response_block[..end_pos]; |                 let response_content = &response_block[..end_pos]; | ||||||
|  |  | ||||||
|                 let href = self.extract_xml_content(response_content, "href").unwrap_or_default(); |                 let href = self | ||||||
|                 let etag = self.extract_xml_content(response_content, "getetag").unwrap_or_default(); |                     .extract_xml_content(response_content, "href") | ||||||
|  |                     .unwrap_or_default(); | ||||||
|  |                 let etag = self | ||||||
|  |                     .extract_xml_content(response_content, "getetag") | ||||||
|  |                     .unwrap_or_default(); | ||||||
|  |  | ||||||
|                 if let Some(calendar_data) = self.extract_xml_content(response_content, "cal:calendar-data") { |                 if let Some(calendar_data) = | ||||||
|  |                     self.extract_xml_content(response_content, "cal:calendar-data") | ||||||
|  |                 { | ||||||
|                     sections.push(CalendarDataSection { |                     sections.push(CalendarDataSection { | ||||||
|                         href: if href.is_empty() { None } else { Some(href) }, |                         href: if href.is_empty() { None } else { Some(href) }, | ||||||
|                         etag: if etag.is_empty() { None } else { Some(etag) }, |                         etag: if etag.is_empty() { None } else { Some(etag) }, | ||||||
| @@ -246,11 +268,27 @@ impl CalDAVClient { | |||||||
|         // Handle both with and without namespace prefixes |         // Handle both with and without namespace prefixes | ||||||
|         let patterns = [ |         let patterns = [ | ||||||
|             format!("(?s)<{}>(.*?)</{}>", tag, tag), // <tag>content</tag> |             format!("(?s)<{}>(.*?)</{}>", tag, tag), // <tag>content</tag> | ||||||
|             format!("(?s)<{}>(.*?)</.*:{}>", tag, tag.split(':').last().unwrap_or(tag)),  // <tag>content</ns:tag> |             format!( | ||||||
|             format!("(?s)<.*:{}>(.*?)</{}>", tag.split(':').last().unwrap_or(tag), tag), // <ns:tag>content</tag> |                 "(?s)<{}>(.*?)</.*:{}>", | ||||||
|             format!("(?s)<.*:{}>(.*?)</.*:{}>", tag.split(':').last().unwrap_or(tag), tag.split(':').last().unwrap_or(tag)), // <ns:tag>content</ns:tag> |                 tag, | ||||||
|  |                 tag.split(':').last().unwrap_or(tag) | ||||||
|  |             ), // <tag>content</ns:tag> | ||||||
|  |             format!( | ||||||
|  |                 "(?s)<.*:{}>(.*?)</{}>", | ||||||
|  |                 tag.split(':').last().unwrap_or(tag), | ||||||
|  |                 tag | ||||||
|  |             ), // <ns:tag>content</tag> | ||||||
|  |             format!( | ||||||
|  |                 "(?s)<.*:{}>(.*?)</.*:{}>", | ||||||
|  |                 tag.split(':').last().unwrap_or(tag), | ||||||
|  |                 tag.split(':').last().unwrap_or(tag) | ||||||
|  |             ), // <ns:tag>content</ns:tag> | ||||||
|             format!("(?s)<{}[^>]*>(.*?)</{}>", tag, tag), // <tag attr>content</tag> |             format!("(?s)<{}[^>]*>(.*?)</{}>", tag, tag), // <tag attr>content</tag> | ||||||
|             format!("(?s)<{}[^>]*>(.*?)</.*:{}>", tag, tag.split(':').last().unwrap_or(tag)), |             format!( | ||||||
|  |                 "(?s)<{}[^>]*>(.*?)</.*:{}>", | ||||||
|  |                 tag, | ||||||
|  |                 tag.split(':').last().unwrap_or(tag) | ||||||
|  |             ), | ||||||
|         ]; |         ]; | ||||||
|  |  | ||||||
|         for pattern in &patterns { |         for pattern in &patterns { | ||||||
| @@ -287,21 +325,29 @@ impl CalDAVClient { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Parse a single iCal event into a CalendarEvent struct |     /// Parse a single iCal event into a CalendarEvent struct | ||||||
|     fn parse_ical_event(&self, event: ical::parser::ical::component::IcalEvent) -> Result<CalendarEvent, CalDAVError> { |     fn parse_ical_event( | ||||||
|  |         &self, | ||||||
|  |         event: ical::parser::ical::component::IcalEvent, | ||||||
|  |     ) -> Result<CalendarEvent, CalDAVError> { | ||||||
|         let mut properties: HashMap<String, String> = HashMap::new(); |         let mut properties: HashMap<String, String> = HashMap::new(); | ||||||
|  |  | ||||||
|         // Extract all properties from the event |         // Extract all properties from the event | ||||||
|         for property in &event.properties { |         for property in &event.properties { | ||||||
|             properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default()); |             properties.insert( | ||||||
|  |                 property.name.to_uppercase(), | ||||||
|  |                 property.value.clone().unwrap_or_default(), | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Required UID field |         // Required UID field | ||||||
|         let uid = properties.get("UID") |         let uid = properties | ||||||
|  |             .get("UID") | ||||||
|             .ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))? |             .ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))? | ||||||
|             .clone(); |             .clone(); | ||||||
|  |  | ||||||
|         // Parse start time (required) |         // Parse start time (required) | ||||||
|         let start = properties.get("DTSTART") |         let start = properties | ||||||
|  |             .get("DTSTART") | ||||||
|             .ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?; |             .ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?; | ||||||
|         let start = self.parse_datetime(start, properties.get("DTSTART"))?; |         let start = self.parse_datetime(start, properties.get("DTSTART"))?; | ||||||
|  |  | ||||||
| @@ -316,12 +362,14 @@ impl CalDAVClient { | |||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         // Determine if it's an all-day event |         // Determine if it's an all-day event | ||||||
|         let all_day = properties.get("DTSTART") |         let all_day = properties | ||||||
|  |             .get("DTSTART") | ||||||
|             .map(|s| !s.contains("T")) |             .map(|s| !s.contains("T")) | ||||||
|             .unwrap_or(false); |             .unwrap_or(false); | ||||||
|  |  | ||||||
|         // Parse status |         // Parse status | ||||||
|         let status = properties.get("STATUS") |         let status = properties | ||||||
|  |             .get("STATUS") | ||||||
|             .map(|s| match s.to_uppercase().as_str() { |             .map(|s| match s.to_uppercase().as_str() { | ||||||
|                 "TENTATIVE" => EventStatus::Tentative, |                 "TENTATIVE" => EventStatus::Tentative, | ||||||
|                 "CANCELLED" => EventStatus::Cancelled, |                 "CANCELLED" => EventStatus::Cancelled, | ||||||
| @@ -330,7 +378,8 @@ impl CalDAVClient { | |||||||
|             .unwrap_or(EventStatus::Confirmed); |             .unwrap_or(EventStatus::Confirmed); | ||||||
|  |  | ||||||
|         // Parse classification |         // Parse classification | ||||||
|         let class = properties.get("CLASS") |         let class = properties | ||||||
|  |             .get("CLASS") | ||||||
|             .map(|s| match s.to_uppercase().as_str() { |             .map(|s| match s.to_uppercase().as_str() { | ||||||
|                 "PRIVATE" => EventClass::Private, |                 "PRIVATE" => EventClass::Private, | ||||||
|                 "CONFIDENTIAL" => EventClass::Confidential, |                 "CONFIDENTIAL" => EventClass::Confidential, | ||||||
| @@ -339,20 +388,24 @@ impl CalDAVClient { | |||||||
|             .unwrap_or(EventClass::Public); |             .unwrap_or(EventClass::Public); | ||||||
|  |  | ||||||
|         // Parse priority |         // Parse priority | ||||||
|         let priority = properties.get("PRIORITY") |         let priority = properties | ||||||
|  |             .get("PRIORITY") | ||||||
|             .and_then(|s| s.parse::<u8>().ok()) |             .and_then(|s| s.parse::<u8>().ok()) | ||||||
|             .filter(|&p| p <= 9); |             .filter(|&p| p <= 9); | ||||||
|  |  | ||||||
|         // Parse categories |         // Parse categories | ||||||
|         let categories = properties.get("CATEGORIES") |         let categories = properties | ||||||
|  |             .get("CATEGORIES") | ||||||
|             .map(|s| s.split(',').map(|c| c.trim().to_string()).collect()) |             .map(|s| s.split(',').map(|c| c.trim().to_string()).collect()) | ||||||
|             .unwrap_or_default(); |             .unwrap_or_default(); | ||||||
|  |  | ||||||
|         // Parse dates |         // Parse dates | ||||||
|         let created = properties.get("CREATED") |         let created = properties | ||||||
|  |             .get("CREATED") | ||||||
|             .and_then(|s| self.parse_datetime(s, None).ok()); |             .and_then(|s| self.parse_datetime(s, None).ok()); | ||||||
|  |  | ||||||
|         let last_modified = properties.get("LAST-MODIFIED") |         let last_modified = properties | ||||||
|  |             .get("LAST-MODIFIED") | ||||||
|             .and_then(|s| self.parse_datetime(s, None).ok()); |             .and_then(|s| self.parse_datetime(s, None).ok()); | ||||||
|  |  | ||||||
|         // Parse exception dates (EXDATE) |         // Parse exception dates (EXDATE) | ||||||
| @@ -403,7 +456,10 @@ impl CalDAVClient { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Parse VALARM components from an iCal event |     /// Parse VALARM components from an iCal event | ||||||
|     fn parse_valarms(&self, event: &ical::parser::ical::component::IcalEvent) -> Result<Vec<VAlarm>, CalDAVError> { |     fn parse_valarms( | ||||||
|  |         &self, | ||||||
|  |         event: &ical::parser::ical::component::IcalEvent, | ||||||
|  |     ) -> Result<Vec<VAlarm>, CalDAVError> { | ||||||
|         let mut alarms = Vec::new(); |         let mut alarms = Vec::new(); | ||||||
|  |  | ||||||
|         for alarm in &event.alarms { |         for alarm in &event.alarms { | ||||||
| @@ -416,20 +472,30 @@ impl CalDAVClient { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Parse a single VALARM component into a VAlarm |     /// Parse a single VALARM component into a VAlarm | ||||||
|     fn parse_single_valarm(&self, alarm: &ical::parser::ical::component::IcalAlarm) -> Result<VAlarm, CalDAVError> { |     fn parse_single_valarm( | ||||||
|  |         &self, | ||||||
|  |         alarm: &ical::parser::ical::component::IcalAlarm, | ||||||
|  |     ) -> Result<VAlarm, CalDAVError> { | ||||||
|         let mut properties: HashMap<String, String> = HashMap::new(); |         let mut properties: HashMap<String, String> = HashMap::new(); | ||||||
|  |  | ||||||
|         // Extract all properties from the alarm |         // Extract all properties from the alarm | ||||||
|         for property in &alarm.properties { |         for property in &alarm.properties { | ||||||
|             properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default()); |             properties.insert( | ||||||
|  |                 property.name.to_uppercase(), | ||||||
|  |                 property.value.clone().unwrap_or_default(), | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Parse ACTION (required) |         // Parse ACTION (required) | ||||||
|         let action = match properties.get("ACTION").map(|s| s.to_uppercase()) { |         let action = match properties.get("ACTION").map(|s| s.to_uppercase()) { | ||||||
|             Some(ref action_str) if action_str == "DISPLAY" => calendar_models::AlarmAction::Display, |             Some(ref action_str) if action_str == "DISPLAY" => { | ||||||
|  |                 calendar_models::AlarmAction::Display | ||||||
|  |             } | ||||||
|             Some(ref action_str) if action_str == "EMAIL" => calendar_models::AlarmAction::Email, |             Some(ref action_str) if action_str == "EMAIL" => calendar_models::AlarmAction::Email, | ||||||
|             Some(ref action_str) if action_str == "AUDIO" => calendar_models::AlarmAction::Audio, |             Some(ref action_str) if action_str == "AUDIO" => calendar_models::AlarmAction::Audio, | ||||||
|             Some(ref action_str) if action_str == "PROCEDURE" => calendar_models::AlarmAction::Procedure, |             Some(ref action_str) if action_str == "PROCEDURE" => { | ||||||
|  |                 calendar_models::AlarmAction::Procedure | ||||||
|  |             } | ||||||
|             _ => calendar_models::AlarmAction::Display, // Default |             _ => calendar_models::AlarmAction::Display, // Default | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
| @@ -498,10 +564,7 @@ impl CalDAVClient { | |||||||
|         // Note: paths should be relative to the server URL base |         // Note: paths should be relative to the server URL base | ||||||
|         let user_calendar_path = format!("/calendars/{}/", self.config.username); |         let user_calendar_path = format!("/calendars/{}/", self.config.username); | ||||||
|  |  | ||||||
|         let discovery_paths = vec![ |         let discovery_paths = vec!["/calendars/", user_calendar_path.as_str()]; | ||||||
|             "/calendars/", |  | ||||||
|             user_calendar_path.as_str(), |  | ||||||
|         ]; |  | ||||||
|  |  | ||||||
|         let mut all_calendars = Vec::new(); |         let mut all_calendars = Vec::new(); | ||||||
|  |  | ||||||
| @@ -533,9 +596,13 @@ impl CalDAVClient { | |||||||
|  |  | ||||||
|         let url = format!("{}{}", self.config.server_url.trim_end_matches('/'), path); |         let url = format!("{}{}", self.config.server_url.trim_end_matches('/'), path); | ||||||
|  |  | ||||||
|         let response = self.http_client |         let response = self | ||||||
|  |             .http_client | ||||||
|             .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url) |             .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url) | ||||||
|             .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) |             .header( | ||||||
|  |                 "Authorization", | ||||||
|  |                 format!("Basic {}", self.config.get_basic_auth()), | ||||||
|  |             ) | ||||||
|             .header("Content-Type", "application/xml") |             .header("Content-Type", "application/xml") | ||||||
|             .header("Depth", "2") // Deeper search to find actual calendars |             .header("Depth", "2") // Deeper search to find actual calendars | ||||||
|             .header("User-Agent", "calendar-app/0.1.0") |             .header("User-Agent", "calendar-app/0.1.0") | ||||||
| @@ -545,7 +612,11 @@ impl CalDAVClient { | |||||||
|             .map_err(CalDAVError::RequestError)?; |             .map_err(CalDAVError::RequestError)?; | ||||||
|  |  | ||||||
|         if response.status().as_u16() != 207 { |         if response.status().as_u16() != 207 { | ||||||
|             println!("❌ Discovery PROPFIND failed for {}: HTTP {}", path, response.status().as_u16()); |             println!( | ||||||
|  |                 "❌ Discovery PROPFIND failed for {}: HTTP {}", | ||||||
|  |                 path, | ||||||
|  |                 response.status().as_u16() | ||||||
|  |             ); | ||||||
|             return Err(CalDAVError::ServerError(response.status().as_u16())); |             return Err(CalDAVError::ServerError(response.status().as_u16())); | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -565,19 +636,26 @@ impl CalDAVClient { | |||||||
|  |  | ||||||
|                     // Check if this is a calendar collection by looking for supported-calendar-component-set |                     // Check if this is a calendar collection by looking for supported-calendar-component-set | ||||||
|                     // This indicates it's an actual calendar that can contain events |                     // This indicates it's an actual calendar that can contain events | ||||||
|                     let has_supported_components = response_content.contains("supported-calendar-component-set") && |                     let has_supported_components = response_content | ||||||
|                                                   (response_content.contains("VEVENT") || response_content.contains("VTODO")); |                         .contains("supported-calendar-component-set") | ||||||
|                     let has_calendar_resourcetype = response_content.contains("<cal:calendar") || response_content.contains("<c:calendar"); |                         && (response_content.contains("VEVENT") | ||||||
|  |                             || response_content.contains("VTODO")); | ||||||
|  |                     let has_calendar_resourcetype = response_content.contains("<cal:calendar") | ||||||
|  |                         || response_content.contains("<c:calendar"); | ||||||
|  |  | ||||||
|                     let is_calendar = has_supported_components || has_calendar_resourcetype; |                     let is_calendar = has_supported_components || has_calendar_resourcetype; | ||||||
|  |  | ||||||
|                     // Also check resourcetype for collection |                     // Also check resourcetype for collection | ||||||
|                     let has_collection = response_content.contains("<d:collection") || response_content.contains("<collection"); |                     let has_collection = response_content.contains("<d:collection") | ||||||
|  |                         || response_content.contains("<collection"); | ||||||
|  |  | ||||||
|                     if is_calendar && has_collection { |                     if is_calendar && has_collection { | ||||||
|                         // Exclude system directories like inbox, outbox, and root calendar directories |                         // Exclude system directories like inbox, outbox, and root calendar directories | ||||||
|                         if !href.contains("/inbox/") && !href.contains("/outbox/") &&  |                         if !href.contains("/inbox/") | ||||||
|                            !href.ends_with("/calendars/") && href.ends_with('/') { |                             && !href.contains("/outbox/") | ||||||
|  |                             && !href.ends_with("/calendars/") | ||||||
|  |                             && href.ends_with('/') | ||||||
|  |                         { | ||||||
|                             println!("📅 Found calendar collection: {}", href); |                             println!("📅 Found calendar collection: {}", href); | ||||||
|                             calendar_paths.push(href); |                             calendar_paths.push(href); | ||||||
|                         } else { |                         } else { | ||||||
| @@ -595,7 +673,11 @@ impl CalDAVClient { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Parse iCal datetime format |     /// Parse iCal datetime format | ||||||
|     fn parse_datetime(&self, datetime_str: &str, _original_property: Option<&String>) -> Result<DateTime<Utc>, CalDAVError> { |     fn parse_datetime( | ||||||
|  |         &self, | ||||||
|  |         datetime_str: &str, | ||||||
|  |         _original_property: Option<&String>, | ||||||
|  |     ) -> Result<DateTime<Utc>, CalDAVError> { | ||||||
|         use chrono::TimeZone; |         use chrono::TimeZone; | ||||||
|  |  | ||||||
|         // Handle different iCal datetime formats |         // Handle different iCal datetime formats | ||||||
| @@ -617,7 +699,10 @@ impl CalDAVClient { | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         Err(CalDAVError::ParseError(format!("Unable to parse datetime: {}", datetime_str))) |         Err(CalDAVError::ParseError(format!( | ||||||
|  |             "Unable to parse datetime: {}", | ||||||
|  |             datetime_str | ||||||
|  |         ))) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Parse EXDATE properties from an iCal event |     /// Parse EXDATE properties from an iCal event | ||||||
| @@ -643,7 +728,12 @@ impl CalDAVClient { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Create a new calendar on the CalDAV server using MKCALENDAR |     /// Create a new calendar on the CalDAV server using MKCALENDAR | ||||||
|     pub async fn create_calendar(&self, name: &str, description: Option<&str>, color: Option<&str>) -> Result<(), CalDAVError> { |     pub async fn create_calendar( | ||||||
|  |         &self, | ||||||
|  |         name: &str, | ||||||
|  |         description: Option<&str>, | ||||||
|  |         color: Option<&str>, | ||||||
|  |     ) -> Result<(), CalDAVError> { | ||||||
|         // Sanitize calendar name for URL path |         // Sanitize calendar name for URL path | ||||||
|         let calendar_id = name |         let calendar_id = name | ||||||
|             .chars() |             .chars() | ||||||
| @@ -652,17 +742,27 @@ impl CalDAVClient { | |||||||
|             .to_lowercase(); |             .to_lowercase(); | ||||||
|  |  | ||||||
|         let calendar_path = format!("/calendars/{}/{}/", self.config.username, calendar_id); |         let calendar_path = format!("/calendars/{}/{}/", self.config.username, calendar_id); | ||||||
|         let full_url = format!("{}{}", self.config.server_url.trim_end_matches('/'), calendar_path); |         let full_url = format!( | ||||||
|  |             "{}{}", | ||||||
|  |             self.config.server_url.trim_end_matches('/'), | ||||||
|  |             calendar_path | ||||||
|  |         ); | ||||||
|  |  | ||||||
|         // Build color property if provided |         // Build color property if provided | ||||||
|         let color_property = if let Some(color) = color { |         let color_property = if let Some(color) = color { | ||||||
|             format!(r#"<ic:calendar-color xmlns:ic="http://apple.com/ns/ical/">{}</ic:calendar-color>"#, color) |             format!( | ||||||
|  |                 r#"<ic:calendar-color xmlns:ic="http://apple.com/ns/ical/">{}</ic:calendar-color>"#, | ||||||
|  |                 color | ||||||
|  |             ) | ||||||
|         } else { |         } else { | ||||||
|             String::new() |             String::new() | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         let description_property = if let Some(desc) = description { |         let description_property = if let Some(desc) = description { | ||||||
|             format!(r#"<c:calendar-description xmlns:c="urn:ietf:params:xml:ns:caldav">{}</c:calendar-description>"#, desc) |             format!( | ||||||
|  |                 r#"<c:calendar-description xmlns:c="urn:ietf:params:xml:ns:caldav">{}</c:calendar-description>"#, | ||||||
|  |                 desc | ||||||
|  |             ) | ||||||
|         } else { |         } else { | ||||||
|             String::new() |             String::new() | ||||||
|         }; |         }; | ||||||
| @@ -688,10 +788,17 @@ impl CalDAVClient { | |||||||
|         println!("Creating calendar at: {}", full_url); |         println!("Creating calendar at: {}", full_url); | ||||||
|         println!("MKCALENDAR body: {}", mkcalendar_body); |         println!("MKCALENDAR body: {}", mkcalendar_body); | ||||||
|  |  | ||||||
|         let response = self.http_client |         let response = self | ||||||
|             .request(reqwest::Method::from_bytes(b"MKCALENDAR").unwrap(), &full_url) |             .http_client | ||||||
|  |             .request( | ||||||
|  |                 reqwest::Method::from_bytes(b"MKCALENDAR").unwrap(), | ||||||
|  |                 &full_url, | ||||||
|  |             ) | ||||||
|             .header("Content-Type", "application/xml; charset=utf-8") |             .header("Content-Type", "application/xml; charset=utf-8") | ||||||
|             .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) |             .header( | ||||||
|  |                 "Authorization", | ||||||
|  |                 format!("Basic {}", self.config.get_basic_auth()), | ||||||
|  |             ) | ||||||
|             .body(mkcalendar_body) |             .body(mkcalendar_body) | ||||||
|             .send() |             .send() | ||||||
|             .await |             .await | ||||||
| @@ -721,14 +828,22 @@ impl CalDAVClient { | |||||||
|             } else { |             } else { | ||||||
|                 calendar_path |                 calendar_path | ||||||
|             }; |             }; | ||||||
|             format!("{}{}", self.config.server_url.trim_end_matches('/'), clean_path) |             format!( | ||||||
|  |                 "{}{}", | ||||||
|  |                 self.config.server_url.trim_end_matches('/'), | ||||||
|  |                 clean_path | ||||||
|  |             ) | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         println!("Deleting calendar at: {}", full_url); |         println!("Deleting calendar at: {}", full_url); | ||||||
|  |  | ||||||
|         let response = self.http_client |         let response = self | ||||||
|  |             .http_client | ||||||
|             .delete(&full_url) |             .delete(&full_url) | ||||||
|             .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) |             .header( | ||||||
|  |                 "Authorization", | ||||||
|  |                 format!("Basic {}", self.config.get_basic_auth()), | ||||||
|  |             ) | ||||||
|             .send() |             .send() | ||||||
|             .await |             .await | ||||||
|             .map_err(|e| CalDAVError::ParseError(e.to_string()))?; |             .map_err(|e| CalDAVError::ParseError(e.to_string()))?; | ||||||
| @@ -747,7 +862,11 @@ impl CalDAVClient { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Create a new event in a CalDAV calendar |     /// Create a new event in a CalDAV calendar | ||||||
|     pub async fn create_event(&self, calendar_path: &str, event: &CalendarEvent) -> Result<String, CalDAVError> { |     pub async fn create_event( | ||||||
|  |         &self, | ||||||
|  |         calendar_path: &str, | ||||||
|  |         event: &CalendarEvent, | ||||||
|  |     ) -> Result<String, CalDAVError> { | ||||||
|         // Generate a unique filename for the event (using UID + .ics extension) |         // Generate a unique filename for the event (using UID + .ics extension) | ||||||
|         let event_filename = format!("{}.ics", event.uid); |         let event_filename = format!("{}.ics", event.uid); | ||||||
|  |  | ||||||
| @@ -790,9 +909,13 @@ impl CalDAVClient { | |||||||
|         let _lock = CALDAV_HTTP_MUTEX.lock().await; |         let _lock = CALDAV_HTTP_MUTEX.lock().await; | ||||||
|         println!("📡 Lock acquired, sending CREATE request to CalDAV server..."); |         println!("📡 Lock acquired, sending CREATE request to CalDAV server..."); | ||||||
|  |  | ||||||
|         let response = self.http_client |         let response = self | ||||||
|  |             .http_client | ||||||
|             .put(&full_url) |             .put(&full_url) | ||||||
|             .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) |             .header( | ||||||
|  |                 "Authorization", | ||||||
|  |                 format!("Basic {}", self.config.get_basic_auth()), | ||||||
|  |             ) | ||||||
|             .header("Content-Type", "text/calendar; charset=utf-8") |             .header("Content-Type", "text/calendar; charset=utf-8") | ||||||
|             .header("User-Agent", "calendar-app/0.1.0") |             .header("User-Agent", "calendar-app/0.1.0") | ||||||
|             .body(ical_data) |             .body(ical_data) | ||||||
| @@ -814,13 +937,22 @@ impl CalDAVClient { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Update an existing event on the CalDAV server |     /// Update an existing event on the CalDAV server | ||||||
|     pub async fn update_event(&self, calendar_path: &str, event: &CalendarEvent, event_href: &str) -> Result<(), CalDAVError> { |     pub async fn update_event( | ||||||
|  |         &self, | ||||||
|  |         calendar_path: &str, | ||||||
|  |         event: &CalendarEvent, | ||||||
|  |         event_href: &str, | ||||||
|  |     ) -> Result<(), CalDAVError> { | ||||||
|         // Construct the full URL for the event |         // Construct the full URL for the event | ||||||
|         let full_url = if event_href.starts_with("http") { |         let full_url = if event_href.starts_with("http") { | ||||||
|             event_href.to_string() |             event_href.to_string() | ||||||
|         } else if event_href.starts_with("/dav.php") { |         } else if event_href.starts_with("/dav.php") { | ||||||
|             // Event href is already a full path, combine with base server URL (without /dav.php) |             // Event href is already a full path, combine with base server URL (without /dav.php) | ||||||
|             let base_url = self.config.server_url.trim_end_matches('/').trim_end_matches("/dav.php"); |             let base_url = self | ||||||
|  |                 .config | ||||||
|  |                 .server_url | ||||||
|  |                 .trim_end_matches('/') | ||||||
|  |                 .trim_end_matches("/dav.php"); | ||||||
|             format!("{}{}", base_url, event_href) |             format!("{}{}", base_url, event_href) | ||||||
|         } else { |         } else { | ||||||
|             // Event href is just a filename, combine with calendar path |             // Event href is just a filename, combine with calendar path | ||||||
| @@ -829,7 +961,12 @@ impl CalDAVClient { | |||||||
|             } else { |             } else { | ||||||
|                 calendar_path |                 calendar_path | ||||||
|             }; |             }; | ||||||
|             format!("{}/dav.php{}/{}", self.config.server_url.trim_end_matches('/'), clean_path, event_href) |             format!( | ||||||
|  |                 "{}/dav.php{}/{}", | ||||||
|  |                 self.config.server_url.trim_end_matches('/'), | ||||||
|  |                 clean_path, | ||||||
|  |                 event_href | ||||||
|  |             ) | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         println!("📝 Updating event at: {}", full_url); |         println!("📝 Updating event at: {}", full_url); | ||||||
| @@ -846,9 +983,13 @@ impl CalDAVClient { | |||||||
|         println!("🔗 PUT URL: {}", full_url); |         println!("🔗 PUT URL: {}", full_url); | ||||||
|         println!("🔍 Request headers: Authorization: Basic [HIDDEN], Content-Type: text/calendar; charset=utf-8"); |         println!("🔍 Request headers: Authorization: Basic [HIDDEN], Content-Type: text/calendar; charset=utf-8"); | ||||||
|  |  | ||||||
|         let response = self.http_client |         let response = self | ||||||
|  |             .http_client | ||||||
|             .put(&full_url) |             .put(&full_url) | ||||||
|             .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) |             .header( | ||||||
|  |                 "Authorization", | ||||||
|  |                 format!("Basic {}", self.config.get_basic_auth()), | ||||||
|  |             ) | ||||||
|             .header("Content-Type", "text/calendar; charset=utf-8") |             .header("Content-Type", "text/calendar; charset=utf-8") | ||||||
|             .header("User-Agent", "calendar-app/0.1.0") |             .header("User-Agent", "calendar-app/0.1.0") | ||||||
|             .timeout(std::time::Duration::from_secs(30)) |             .timeout(std::time::Duration::from_secs(30)) | ||||||
| @@ -862,7 +1003,10 @@ impl CalDAVClient { | |||||||
|  |  | ||||||
|         println!("Event update response status: {}", response.status()); |         println!("Event update response status: {}", response.status()); | ||||||
|  |  | ||||||
|         if response.status().is_success() || response.status().as_u16() == 201 || response.status().as_u16() == 204 { |         if response.status().is_success() | ||||||
|  |             || response.status().as_u16() == 201 | ||||||
|  |             || response.status().as_u16() == 204 | ||||||
|  |         { | ||||||
|             println!("✅ Event updated successfully"); |             println!("✅ Event updated successfully"); | ||||||
|             Ok(()) |             Ok(()) | ||||||
|         } else { |         } else { | ||||||
| @@ -878,13 +1022,10 @@ impl CalDAVClient { | |||||||
|         let now = chrono::Utc::now(); |         let now = chrono::Utc::now(); | ||||||
|  |  | ||||||
|         // Format datetime for iCal (YYYYMMDDTHHMMSSZ format) |         // Format datetime for iCal (YYYYMMDDTHHMMSSZ format) | ||||||
|         let format_datetime = |dt: &DateTime<Utc>| -> String { |         let format_datetime = | ||||||
|             dt.format("%Y%m%dT%H%M%SZ").to_string() |             |dt: &DateTime<Utc>| -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() }; | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         let format_date = |dt: &DateTime<Utc>| -> String { |         let format_date = |dt: &DateTime<Utc>| -> String { dt.format("%Y%m%d").to_string() }; | ||||||
|             dt.format("%Y%m%d").to_string() |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         // Start building the iCal event |         // Start building the iCal event | ||||||
|         let mut ical = String::new(); |         let mut ical = String::new(); | ||||||
| @@ -899,7 +1040,10 @@ impl CalDAVClient { | |||||||
|  |  | ||||||
|         // Start and end times |         // Start and end times | ||||||
|         if event.all_day { |         if event.all_day { | ||||||
|             ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", format_date(&event.dtstart))); |             ical.push_str(&format!( | ||||||
|  |                 "DTSTART;VALUE=DATE:{}\r\n", | ||||||
|  |                 format_date(&event.dtstart) | ||||||
|  |             )); | ||||||
|             if let Some(end) = &event.dtend { |             if let Some(end) = &event.dtend { | ||||||
|                 ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end))); |                 ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end))); | ||||||
|             } |             } | ||||||
| @@ -916,7 +1060,10 @@ impl CalDAVClient { | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         if let Some(description) = &event.description { |         if let Some(description) = &event.description { | ||||||
|             ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(description))); |             ical.push_str(&format!( | ||||||
|  |                 "DESCRIPTION:{}\r\n", | ||||||
|  |                 self.escape_ical_text(description) | ||||||
|  |             )); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if let Some(location) = &event.location { |         if let Some(location) = &event.location { | ||||||
| @@ -951,7 +1098,10 @@ impl CalDAVClient { | |||||||
|         // Categories |         // Categories | ||||||
|         if !event.categories.is_empty() { |         if !event.categories.is_empty() { | ||||||
|             let categories = event.categories.join(","); |             let categories = event.categories.join(","); | ||||||
|             ical.push_str(&format!("CATEGORIES:{}\r\n", self.escape_ical_text(&categories))); |             ical.push_str(&format!( | ||||||
|  |                 "CATEGORIES:{}\r\n", | ||||||
|  |                 self.escape_ical_text(&categories) | ||||||
|  |             )); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Creation and modification times |         // Creation and modification times | ||||||
| @@ -989,9 +1139,15 @@ impl CalDAVClient { | |||||||
|             } |             } | ||||||
|  |  | ||||||
|             if let Some(description) = &alarm.description { |             if let Some(description) = &alarm.description { | ||||||
|                 ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(description))); |                 ical.push_str(&format!( | ||||||
|  |                     "DESCRIPTION:{}\r\n", | ||||||
|  |                     self.escape_ical_text(description) | ||||||
|  |                 )); | ||||||
|             } else if let Some(summary) = &event.summary { |             } else if let Some(summary) = &event.summary { | ||||||
|                 ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(summary))); |                 ical.push_str(&format!( | ||||||
|  |                     "DESCRIPTION:{}\r\n", | ||||||
|  |                     self.escape_ical_text(summary) | ||||||
|  |                 )); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             ical.push_str("END:VALARM\r\n"); |             ical.push_str("END:VALARM\r\n"); | ||||||
| @@ -1005,7 +1161,10 @@ impl CalDAVClient { | |||||||
|         // Exception dates (EXDATE) |         // Exception dates (EXDATE) | ||||||
|         for exception_date in &event.exdate { |         for exception_date in &event.exdate { | ||||||
|             if event.all_day { |             if event.all_day { | ||||||
|                 ical.push_str(&format!("EXDATE;VALUE=DATE:{}\r\n", format_date(exception_date))); |                 ical.push_str(&format!( | ||||||
|  |                     "EXDATE;VALUE=DATE:{}\r\n", | ||||||
|  |                     format_date(exception_date) | ||||||
|  |                 )); | ||||||
|             } else { |             } else { | ||||||
|                 ical.push_str(&format!("EXDATE:{}\r\n", format_datetime(exception_date))); |                 ical.push_str(&format!("EXDATE:{}\r\n", format_datetime(exception_date))); | ||||||
|             } |             } | ||||||
| @@ -1027,13 +1186,21 @@ impl CalDAVClient { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Delete an event from a CalDAV calendar |     /// Delete an event from a CalDAV calendar | ||||||
|     pub async fn delete_event(&self, calendar_path: &str, event_href: &str) -> Result<(), CalDAVError> { |     pub async fn delete_event( | ||||||
|  |         &self, | ||||||
|  |         calendar_path: &str, | ||||||
|  |         event_href: &str, | ||||||
|  |     ) -> Result<(), CalDAVError> { | ||||||
|         // Construct the full URL for the event |         // Construct the full URL for the event | ||||||
|         let full_url = if event_href.starts_with("http") { |         let full_url = if event_href.starts_with("http") { | ||||||
|             event_href.to_string() |             event_href.to_string() | ||||||
|         } else if event_href.starts_with("/dav.php") { |         } else if event_href.starts_with("/dav.php") { | ||||||
|             // Event href is already a full path, combine with base server URL (without /dav.php) |             // Event href is already a full path, combine with base server URL (without /dav.php) | ||||||
|             let base_url = self.config.server_url.trim_end_matches('/').trim_end_matches("/dav.php"); |             let base_url = self | ||||||
|  |                 .config | ||||||
|  |                 .server_url | ||||||
|  |                 .trim_end_matches('/') | ||||||
|  |                 .trim_end_matches("/dav.php"); | ||||||
|             format!("{}{}", base_url, event_href) |             format!("{}{}", base_url, event_href) | ||||||
|         } else { |         } else { | ||||||
|             // Event href is just a filename, combine with calendar path |             // Event href is just a filename, combine with calendar path | ||||||
| @@ -1042,7 +1209,12 @@ impl CalDAVClient { | |||||||
|             } else { |             } else { | ||||||
|                 calendar_path |                 calendar_path | ||||||
|             }; |             }; | ||||||
|             format!("{}/dav.php{}/{}", self.config.server_url.trim_end_matches('/'), clean_path, event_href) |             format!( | ||||||
|  |                 "{}/dav.php{}/{}", | ||||||
|  |                 self.config.server_url.trim_end_matches('/'), | ||||||
|  |                 clean_path, | ||||||
|  |                 event_href | ||||||
|  |             ) | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         println!("Deleting event at: {}", full_url); |         println!("Deleting event at: {}", full_url); | ||||||
| @@ -1051,9 +1223,13 @@ impl CalDAVClient { | |||||||
|         let _lock = CALDAV_HTTP_MUTEX.lock().await; |         let _lock = CALDAV_HTTP_MUTEX.lock().await; | ||||||
|         println!("📡 Lock acquired, sending DELETE request to CalDAV server..."); |         println!("📡 Lock acquired, sending DELETE request to CalDAV server..."); | ||||||
|  |  | ||||||
|         let response = self.http_client |         let response = self | ||||||
|  |             .http_client | ||||||
|             .delete(&full_url) |             .delete(&full_url) | ||||||
|             .header("Authorization", format!("Basic {}", self.config.get_basic_auth())) |             .header( | ||||||
|  |                 "Authorization", | ||||||
|  |                 format!("Basic {}", self.config.get_basic_auth()), | ||||||
|  |             ) | ||||||
|             .send() |             .send() | ||||||
|             .await |             .await | ||||||
|             .map_err(|e| CalDAVError::ParseError(e.to_string()))?; |             .map_err(|e| CalDAVError::ParseError(e.to_string()))?; | ||||||
| @@ -1103,8 +1279,11 @@ mod tests { | |||||||
|     /// This test requires a valid .env file and a calendar with some events |     /// This test requires a valid .env file and a calendar with some events | ||||||
|     #[tokio::test] |     #[tokio::test] | ||||||
|     async fn test_fetch_calendar_events() { |     async fn test_fetch_calendar_events() { | ||||||
|         let config = CalDAVConfig::from_env() |         let config = CalDAVConfig::new( | ||||||
|             .expect("Failed to load CalDAV config from environment"); |             "https://example.com".to_string(), | ||||||
|  |             "test_user".to_string(), | ||||||
|  |             "test_password".to_string(), | ||||||
|  |         ); | ||||||
|  |  | ||||||
|         let client = CalDAVClient::new(config); |         let client = CalDAVClient::new(config); | ||||||
|  |  | ||||||
| @@ -1147,7 +1326,10 @@ mod tests { | |||||||
|                         for event in &events { |                         for event in &events { | ||||||
|                             assert!(!event.uid.is_empty(), "Event UID should not be empty"); |                             assert!(!event.uid.is_empty(), "Event UID should not be empty"); | ||||||
|                             // All events should have a start time |                             // All events should have a start time | ||||||
|                             assert!(event.dtstart > DateTime::from_timestamp(0, 0).unwrap(), "Event should have valid start time"); |                             assert!( | ||||||
|  |                                 event.dtstart > DateTime::from_timestamp(0, 0).unwrap(), | ||||||
|  |                                 "Event should have valid start time" | ||||||
|  |                             ); | ||||||
|                         } |                         } | ||||||
|  |  | ||||||
|                         println!("\n✓ Calendar event fetching test passed!"); |                         println!("\n✓ Calendar event fetching test passed!"); | ||||||
| @@ -1192,11 +1374,11 @@ END:VCALENDAR"#; | |||||||
|             username: "test".to_string(), |             username: "test".to_string(), | ||||||
|             password: "test".to_string(), |             password: "test".to_string(), | ||||||
|             calendar_path: None, |             calendar_path: None, | ||||||
|             tasks_path: None, |  | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         let client = CalDAVClient::new(config); |         let client = CalDAVClient::new(config); | ||||||
|         let events = client.parse_ical_data(sample_ical) |         let events = client | ||||||
|  |             .parse_ical_data(sample_ical) | ||||||
|             .expect("Should be able to parse sample iCal data"); |             .expect("Should be able to parse sample iCal data"); | ||||||
|  |  | ||||||
|         assert_eq!(events.len(), 1); |         assert_eq!(events.len(), 1); | ||||||
| @@ -1223,23 +1405,25 @@ END:VCALENDAR"#; | |||||||
|             username: "test".to_string(), |             username: "test".to_string(), | ||||||
|             password: "test".to_string(), |             password: "test".to_string(), | ||||||
|             calendar_path: None, |             calendar_path: None, | ||||||
|             tasks_path: None, |  | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         let client = CalDAVClient::new(config); |         let client = CalDAVClient::new(config); | ||||||
|  |  | ||||||
|         // Test UTC format |         // Test UTC format | ||||||
|         let dt1 = client.parse_datetime("20231225T120000Z", None) |         let dt1 = client | ||||||
|  |             .parse_datetime("20231225T120000Z", None) | ||||||
|             .expect("Should parse UTC datetime"); |             .expect("Should parse UTC datetime"); | ||||||
|         println!("Parsed UTC datetime: {}", dt1); |         println!("Parsed UTC datetime: {}", dt1); | ||||||
|  |  | ||||||
|         // Test date-only format (should be treated as all-day) |         // Test date-only format (should be treated as all-day) | ||||||
|         let dt2 = client.parse_datetime("20231225", None) |         let dt2 = client | ||||||
|  |             .parse_datetime("20231225", None) | ||||||
|             .expect("Should parse date-only"); |             .expect("Should parse date-only"); | ||||||
|         println!("Parsed date-only: {}", dt2); |         println!("Parsed date-only: {}", dt2); | ||||||
|  |  | ||||||
|         // Test local format |         // Test local format | ||||||
|         let dt3 = client.parse_datetime("20231225T120000", None) |         let dt3 = client | ||||||
|  |             .parse_datetime("20231225T120000", None) | ||||||
|             .expect("Should parse local datetime"); |             .expect("Should parse local datetime"); | ||||||
|         println!("Parsed local datetime: {}", dt3); |         println!("Parsed local datetime: {}", dt3); | ||||||
|  |  | ||||||
| @@ -1259,5 +1443,4 @@ END:VCALENDAR"#; | |||||||
|  |  | ||||||
|         println!("✓ Event enum tests passed!"); |         println!("✓ Event enum tests passed!"); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
|  | use base64::prelude::*; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use std::env; | use std::env; | ||||||
| use base64::prelude::*; |  | ||||||
|  |  | ||||||
| /// Configuration for CalDAV server connection and authentication. | /// Configuration for CalDAV server connection and authentication. | ||||||
| /// | /// | ||||||
| @@ -17,14 +17,16 @@ use base64::prelude::*; | |||||||
| /// | /// | ||||||
| /// ```rust | /// ```rust | ||||||
| /// # use calendar_backend::config::CalDAVConfig; | /// # use calendar_backend::config::CalDAVConfig; | ||||||
| /// # fn example() -> Result<(), Box<dyn std::error::Error>> { | /// let config = CalDAVConfig { | ||||||
| /// // Load configuration from environment variables | ///     server_url: "https://caldav.example.com".to_string(), | ||||||
| /// let config = CalDAVConfig::from_env()?; | ///     username: "user@example.com".to_string(), | ||||||
|  | ///     password: "password".to_string(), | ||||||
|  | ///     calendar_path: None, | ||||||
|  | ///     tasks_path: None, | ||||||
|  | /// }; | ||||||
| /// | /// | ||||||
| /// // Use the configuration for HTTP requests | /// // Use the configuration for HTTP requests | ||||||
| /// let auth_header = format!("Basic {}", config.get_basic_auth()); | /// let auth_header = format!("Basic {}", config.get_basic_auth()); | ||||||
| /// # Ok(()) |  | ||||||
| /// # } |  | ||||||
| /// ``` | /// ``` | ||||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | #[derive(Debug, Clone, Serialize, Deserialize)] | ||||||
| pub struct CalDAVConfig { | pub struct CalDAVConfig { | ||||||
| @@ -41,74 +43,37 @@ pub struct CalDAVConfig { | |||||||
|  |  | ||||||
|     /// Optional path to the calendar collection on the server |     /// 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 |     /// through CalDAV PROPFIND requests | ||||||
|     pub calendar_path: Option<String>, |     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 { | impl CalDAVConfig { | ||||||
|     /// Creates a new CalDAVConfig by loading values from environment variables. |     /// Creates a new CalDAVConfig with the given credentials. | ||||||
|     /// |     /// | ||||||
|     /// This method will attempt to load a `.env` file from the current directory |     /// # Arguments | ||||||
|     /// and then read the following required environment variables: |  | ||||||
|     /// |     /// | ||||||
|     /// - `CALDAV_SERVER_URL`: The CalDAV server base URL |     /// * `server_url` - The base URL of the CalDAV server | ||||||
|     /// - `CALDAV_USERNAME`: Username for authentication |     /// * `username` - Username for authentication | ||||||
|     /// - `CALDAV_PASSWORD`: Password for authentication |     /// * `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 |     /// # Example | ||||||
|     /// |     /// | ||||||
|     /// ```rust |     /// ```rust | ||||||
|     /// # use calendar_backend::config::CalDAVConfig; |     /// # use calendar_backend::config::CalDAVConfig; | ||||||
|     ///  |     /// let config = CalDAVConfig::new( | ||||||
|     /// match CalDAVConfig::from_env() { |     ///     "https://caldav.example.com".to_string(), | ||||||
|     ///     Ok(config) => { |     ///     "user@example.com".to_string(), | ||||||
|     ///         println!("Loaded config for server: {}", config.server_url); |     ///     "password".to_string() | ||||||
|     ///     } |     /// ); | ||||||
|     ///     Err(e) => { |  | ||||||
|     ///         eprintln!("Failed to load config: {}", e); |  | ||||||
|     ///     } |  | ||||||
|     /// } |  | ||||||
|     /// ``` |     /// ``` | ||||||
|     pub fn from_env() -> Result<Self, ConfigError> { |     pub fn new(server_url: String, username: String, password: String) -> Self { | ||||||
|         // Attempt to load .env file, but don't fail if it doesn't exist |         Self { | ||||||
|         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, |             server_url, | ||||||
|             username, |             username, | ||||||
|             password, |             password, | ||||||
|             calendar_path, |             calendar_path: env::var("CALDAV_CALENDAR_PATH").ok(), // Optional override from env | ||||||
|             tasks_path, |         } | ||||||
|         }) |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Generates a Base64-encoded string for HTTP Basic Authentication. |     /// Generates a Base64-encoded string for HTTP Basic Authentication. | ||||||
| @@ -174,7 +139,6 @@ mod tests { | |||||||
|             username: "testuser".to_string(), |             username: "testuser".to_string(), | ||||||
|             password: "testpass".to_string(), |             password: "testpass".to_string(), | ||||||
|             calendar_path: None, |             calendar_path: None, | ||||||
|             tasks_path: None, |  | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         let auth = config.get_basic_auth(); |         let auth = config.get_basic_auth(); | ||||||
| @@ -192,9 +156,12 @@ mod tests { | |||||||
|     /// Run with: `cargo test test_baikal_auth` |     /// Run with: `cargo test test_baikal_auth` | ||||||
|     #[tokio::test] |     #[tokio::test] | ||||||
|     async fn test_baikal_auth() { |     async fn test_baikal_auth() { | ||||||
|         // Load config from .env |         // Use test config - update these values to test with real server | ||||||
|         let config = CalDAVConfig::from_env() |         let config = CalDAVConfig::new( | ||||||
|             .expect("Failed to load CalDAV config from environment"); |             "https://example.com".to_string(), | ||||||
|  |             "test_user".to_string(), | ||||||
|  |             "test_password".to_string(), | ||||||
|  |         ); | ||||||
|  |  | ||||||
|         println!("Testing authentication to: {}", config.server_url); |         println!("Testing authentication to: {}", config.server_url); | ||||||
|  |  | ||||||
| @@ -204,7 +171,10 @@ mod tests { | |||||||
|         // Make a simple OPTIONS request to test authentication |         // Make a simple OPTIONS request to test authentication | ||||||
|         let response = client |         let response = client | ||||||
|             .request(reqwest::Method::OPTIONS, &config.server_url) |             .request(reqwest::Method::OPTIONS, &config.server_url) | ||||||
|             .header("Authorization", format!("Basic {}", config.get_basic_auth())) |             .header( | ||||||
|  |                 "Authorization", | ||||||
|  |                 format!("Basic {}", config.get_basic_auth()), | ||||||
|  |             ) | ||||||
|             .header("User-Agent", "calendar-app/0.1.0") |             .header("User-Agent", "calendar-app/0.1.0") | ||||||
|             .send() |             .send() | ||||||
|             .await |             .await | ||||||
| @@ -222,9 +192,9 @@ mod tests { | |||||||
|  |  | ||||||
|         // For Baikal/CalDAV servers, we should see DAV headers |         // For Baikal/CalDAV servers, we should see DAV headers | ||||||
|         assert!( |         assert!( | ||||||
|             response.headers().contains_key("dav") ||  |             response.headers().contains_key("dav") | ||||||
|             response.headers().contains_key("DAV") || |                 || response.headers().contains_key("DAV") | ||||||
|             response.status().is_success(), |                 || response.status().is_success(), | ||||||
|             "Server doesn't appear to be a CalDAV server - missing DAV headers" |             "Server doesn't appear to be a CalDAV server - missing DAV headers" | ||||||
|         ); |         ); | ||||||
|  |  | ||||||
| @@ -238,8 +208,12 @@ mod tests { | |||||||
|     /// Run with: `cargo test test_propfind_calendars` |     /// Run with: `cargo test test_propfind_calendars` | ||||||
|     #[tokio::test] |     #[tokio::test] | ||||||
|     async fn test_propfind_calendars() { |     async fn test_propfind_calendars() { | ||||||
|         let config = CalDAVConfig::from_env() |         // Use test config - update these values to test with real server | ||||||
|             .expect("Failed to load CalDAV config from environment"); |         let config = CalDAVConfig::new( | ||||||
|  |             "https://example.com".to_string(), | ||||||
|  |             "test_user".to_string(), | ||||||
|  |             "test_password".to_string(), | ||||||
|  |         ); | ||||||
|  |  | ||||||
|         let client = reqwest::Client::new(); |         let client = reqwest::Client::new(); | ||||||
|  |  | ||||||
| @@ -255,8 +229,14 @@ mod tests { | |||||||
| </d:propfind>"#; | </d:propfind>"#; | ||||||
|  |  | ||||||
|         let response = client |         let response = client | ||||||
|             .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url) |             .request( | ||||||
|             .header("Authorization", format!("Basic {}", config.get_basic_auth())) |                 reqwest::Method::from_bytes(b"PROPFIND").unwrap(), | ||||||
|  |                 &config.server_url, | ||||||
|  |             ) | ||||||
|  |             .header( | ||||||
|  |                 "Authorization", | ||||||
|  |                 format!("Basic {}", config.get_basic_auth()), | ||||||
|  |             ) | ||||||
|             .header("Content-Type", "application/xml") |             .header("Content-Type", "application/xml") | ||||||
|             .header("Depth", "1") |             .header("Depth", "1") | ||||||
|             .header("User-Agent", "calendar-app/0.1.0") |             .header("User-Agent", "calendar-app/0.1.0") | ||||||
| @@ -279,7 +259,10 @@ mod tests { | |||||||
|         ); |         ); | ||||||
|  |  | ||||||
|         // The response should contain XML with calendar information |         // The response should contain XML with calendar information | ||||||
|         assert!(body.contains("calendar"), "Response should contain calendar information"); |         assert!( | ||||||
|  |             body.contains("calendar"), | ||||||
|  |             "Response should contain calendar information" | ||||||
|  |         ); | ||||||
|  |  | ||||||
|         println!("✓ PROPFIND calendars test passed!"); |         println!("✓ PROPFIND calendars test passed!"); | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										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; | use crate::config::CalDAVConfig; | ||||||
|  |  | ||||||
| pub async fn debug_caldav_fetch() -> Result<(), Box<dyn std::error::Error>> { | 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); |     let client = CalDAVClient::new(config); | ||||||
|      |      | ||||||
|     println!("=== DEBUG: CalDAV Fetch ==="); |     println!("=== DEBUG: CalDAV Fetch ==="); | ||||||
|   | |||||||
| @@ -2,9 +2,11 @@ | |||||||
| mod auth; | mod auth; | ||||||
| mod calendar; | mod calendar; | ||||||
| mod events; | mod events; | ||||||
|  | mod preferences; | ||||||
| mod series; | mod series; | ||||||
|  |  | ||||||
| pub use auth::{login, verify_token, get_user_info}; | pub use auth::{get_user_info, login, verify_token}; | ||||||
| pub use calendar::{create_calendar, delete_calendar}; | pub use calendar::{create_calendar, delete_calendar}; | ||||||
| pub use events::{get_calendar_events, refresh_event, create_event, update_event, delete_event}; | pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event}; | ||||||
| pub use series::{create_event_series, update_event_series, delete_event_series}; | pub use preferences::{get_preferences, logout, update_preferences}; | ||||||
|  | pub use series::{create_event_series, delete_event_series, update_event_series}; | ||||||
|   | |||||||
| @@ -1,33 +1,38 @@ | |||||||
| use axum::{ | use axum::{extract::State, http::HeaderMap, response::Json}; | ||||||
|     extract::State, |  | ||||||
|     http::HeaderMap, |  | ||||||
|     response::Json, |  | ||||||
| }; |  | ||||||
| use std::sync::Arc; | use std::sync::Arc; | ||||||
|  |  | ||||||
| use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo}}; |  | ||||||
| use crate::calendar::CalDAVClient; | use crate::calendar::CalDAVClient; | ||||||
| use crate::config::CalDAVConfig; | use crate::config::CalDAVConfig; | ||||||
|  | use crate::{ | ||||||
|  |     models::{ApiError, AuthResponse, CalDAVLoginRequest, CalendarInfo, UserInfo}, | ||||||
|  |     AppState, | ||||||
|  | }; | ||||||
|  |  | ||||||
| pub fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> { | pub fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> { | ||||||
|     let auth_header = headers.get("authorization") |     let auth_header = headers | ||||||
|  |         .get("authorization") | ||||||
|         .ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?; |         .ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?; | ||||||
|  |  | ||||||
|     let auth_str = auth_header.to_str() |     let auth_str = auth_header | ||||||
|  |         .to_str() | ||||||
|         .map_err(|_| ApiError::BadRequest("Invalid Authorization header".to_string()))?; |         .map_err(|_| ApiError::BadRequest("Invalid Authorization header".to_string()))?; | ||||||
|  |  | ||||||
|     if let Some(token) = auth_str.strip_prefix("Bearer ") { |     if let Some(token) = auth_str.strip_prefix("Bearer ") { | ||||||
|         Ok(token.to_string()) |         Ok(token.to_string()) | ||||||
|     } else { |     } else { | ||||||
|         Err(ApiError::BadRequest("Authorization header must be Bearer token".to_string())) |         Err(ApiError::BadRequest( | ||||||
|  |             "Authorization header must be Bearer token".to_string(), | ||||||
|  |         )) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| pub fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> { | pub fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> { | ||||||
|     let password_header = headers.get("x-caldav-password") |     let password_header = headers | ||||||
|  |         .get("x-caldav-password") | ||||||
|         .ok_or_else(|| ApiError::BadRequest("Missing X-CalDAV-Password header".to_string()))?; |         .ok_or_else(|| ApiError::BadRequest("Missing X-CalDAV-Password header".to_string()))?; | ||||||
|  |  | ||||||
|     password_header.to_str() |     password_header | ||||||
|  |         .to_str() | ||||||
|         .map(|s| s.to_string()) |         .map(|s| s.to_string()) | ||||||
|         .map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string())) |         .map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string())) | ||||||
| } | } | ||||||
| @@ -41,38 +46,12 @@ pub async fn login( | |||||||
|     println!("  Username: {}", request.username); |     println!("  Username: {}", request.username); | ||||||
|     println!("  Password length: {}", request.password.len()); |     println!("  Password length: {}", request.password.len()); | ||||||
|  |  | ||||||
|     // Basic validation |     // Use the auth service login method which now handles database, sessions, and preferences | ||||||
|     if request.username.is_empty() || request.password.is_empty() || request.server_url.is_empty() { |     let response = state.auth_service.login(request).await?; | ||||||
|         return Err(ApiError::BadRequest("Username, password, and server URL are required".to_string())); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     println!("✅ Input validation passed"); |     println!("✅ Login successful with session management"); | ||||||
|  |  | ||||||
|     // Create a token using the auth service |     Ok(Json(response)) | ||||||
|     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, |  | ||||||
|     })) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| pub async fn verify_token( | pub async fn verify_token( | ||||||
| @@ -93,23 +72,30 @@ pub async fn get_user_info( | |||||||
|     let password = extract_password_header(&headers)?; |     let password = extract_password_header(&headers)?; | ||||||
|  |  | ||||||
|     // Create CalDAV config from token and password |     // Create CalDAV config from token and password | ||||||
|     let config = state.auth_service.caldav_config_from_token(&token, &password)?; |     let config = state | ||||||
|  |         .auth_service | ||||||
|  |         .caldav_config_from_token(&token, &password)?; | ||||||
|     let client = CalDAVClient::new(config.clone()); |     let client = CalDAVClient::new(config.clone()); | ||||||
|  |  | ||||||
|     // Discover calendars |     // Discover calendars | ||||||
|     let calendar_paths = client.discover_calendars() |     let calendar_paths = client | ||||||
|  |         .discover_calendars() | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; |         .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; | ||||||
|  |  | ||||||
|     println!("✅ Authentication successful! Found {} calendars", calendar_paths.len()); |     println!( | ||||||
|  |         "✅ Authentication successful! Found {} calendars", | ||||||
|  |         calendar_paths.len() | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     let calendars: Vec<CalendarInfo> = calendar_paths.iter().map(|path| { |     let calendars: Vec<CalendarInfo> = calendar_paths | ||||||
|         CalendarInfo { |         .iter() | ||||||
|  |         .map(|path| CalendarInfo { | ||||||
|             path: path.clone(), |             path: path.clone(), | ||||||
|             display_name: extract_calendar_name(path), |             display_name: extract_calendar_name(path), | ||||||
|             color: generate_calendar_color(path), |             color: generate_calendar_color(path), | ||||||
|         } |         }) | ||||||
|     }).collect(); |         .collect(); | ||||||
|  |  | ||||||
|     Ok(Json(UserInfo { |     Ok(Json(UserInfo { | ||||||
|         username: config.username, |         username: config.username, | ||||||
| @@ -128,10 +114,9 @@ fn generate_calendar_color(path: &str) -> String { | |||||||
|  |  | ||||||
|     // Define a set of pleasant colors |     // Define a set of pleasant colors | ||||||
|     let colors = [ |     let colors = [ | ||||||
|         "#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", |         "#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#06B6D4", "#84CC16", "#F97316", | ||||||
|         "#06B6D4", "#84CC16", "#F97316", "#EC4899", "#6366F1", |         "#EC4899", "#6366F1", "#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626", "#7C3AED", | ||||||
|         "#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626", |         "#059669", "#D97706", "#BE185D", "#4F46E5", | ||||||
|         "#7C3AED", "#059669", "#D97706", "#BE185D", "#4F46E5" |  | ||||||
|     ]; |     ]; | ||||||
|  |  | ||||||
|     colors[(hash as usize) % colors.len()].to_string() |     colors[(hash as usize) % colors.len()].to_string() | ||||||
|   | |||||||
| @@ -1,12 +1,14 @@ | |||||||
| use axum::{ | use axum::{extract::State, http::HeaderMap, response::Json}; | ||||||
|     extract::State, |  | ||||||
|     http::HeaderMap, |  | ||||||
|     response::Json, |  | ||||||
| }; |  | ||||||
| use std::sync::Arc; | use std::sync::Arc; | ||||||
|  |  | ||||||
| use crate::{AppState, models::{ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse}}; |  | ||||||
| use crate::calendar::CalDAVClient; | use crate::calendar::CalDAVClient; | ||||||
|  | use crate::{ | ||||||
|  |     models::{ | ||||||
|  |         ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, | ||||||
|  |         DeleteCalendarResponse, | ||||||
|  |     }, | ||||||
|  |     AppState, | ||||||
|  | }; | ||||||
|  |  | ||||||
| use super::auth::{extract_bearer_token, extract_password_header}; | use super::auth::{extract_bearer_token, extract_password_header}; | ||||||
|  |  | ||||||
| @@ -20,22 +22,36 @@ pub async fn create_calendar( | |||||||
|  |  | ||||||
|     // Validate request |     // Validate request | ||||||
|     if request.name.trim().is_empty() { |     if request.name.trim().is_empty() { | ||||||
|         return Err(ApiError::BadRequest("Calendar name is required".to_string())); |         return Err(ApiError::BadRequest( | ||||||
|  |             "Calendar name is required".to_string(), | ||||||
|  |         )); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Create CalDAV config from token and password |     // Create CalDAV config from token and password | ||||||
|     let config = state.auth_service.caldav_config_from_token(&token, &password)?; |     let config = state | ||||||
|  |         .auth_service | ||||||
|  |         .caldav_config_from_token(&token, &password)?; | ||||||
|     let client = CalDAVClient::new(config); |     let client = CalDAVClient::new(config); | ||||||
|  |  | ||||||
|     // Create calendar on CalDAV server |     // Create calendar on CalDAV server | ||||||
|     match client.create_calendar(&request.name, request.description.as_deref(), request.color.as_deref()).await { |     match client | ||||||
|  |         .create_calendar( | ||||||
|  |             &request.name, | ||||||
|  |             request.description.as_deref(), | ||||||
|  |             request.color.as_deref(), | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |     { | ||||||
|         Ok(_) => Ok(Json(CreateCalendarResponse { |         Ok(_) => Ok(Json(CreateCalendarResponse { | ||||||
|             success: true, |             success: true, | ||||||
|             message: "Calendar created successfully".to_string(), |             message: "Calendar created successfully".to_string(), | ||||||
|         })), |         })), | ||||||
|         Err(e) => { |         Err(e) => { | ||||||
|             eprintln!("Failed to create calendar: {}", e); |             eprintln!("Failed to create calendar: {}", e); | ||||||
|             Err(ApiError::Internal(format!("Failed to create calendar: {}", e))) |             Err(ApiError::Internal(format!( | ||||||
|  |                 "Failed to create calendar: {}", | ||||||
|  |                 e | ||||||
|  |             ))) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -50,11 +66,15 @@ pub async fn delete_calendar( | |||||||
|  |  | ||||||
|     // Validate request |     // Validate request | ||||||
|     if request.path.trim().is_empty() { |     if request.path.trim().is_empty() { | ||||||
|         return Err(ApiError::BadRequest("Calendar path is required".to_string())); |         return Err(ApiError::BadRequest( | ||||||
|  |             "Calendar path is required".to_string(), | ||||||
|  |         )); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Create CalDAV config from token and password |     // Create CalDAV config from token and password | ||||||
|     let config = state.auth_service.caldav_config_from_token(&token, &password)?; |     let config = state | ||||||
|  |         .auth_service | ||||||
|  |         .caldav_config_from_token(&token, &password)?; | ||||||
|     let client = CalDAVClient::new(config); |     let client = CalDAVClient::new(config); | ||||||
|  |  | ||||||
|     // Delete calendar on CalDAV server |     // Delete calendar on CalDAV server | ||||||
| @@ -65,7 +85,10 @@ pub async fn delete_calendar( | |||||||
|         })), |         })), | ||||||
|         Err(e) => { |         Err(e) => { | ||||||
|             eprintln!("Failed to delete calendar: {}", e); |             eprintln!("Failed to delete calendar: {}", e); | ||||||
|             Err(ApiError::Internal(format!("Failed to delete calendar: {}", e))) |             Err(ApiError::Internal(format!( | ||||||
|  |                 "Failed to delete calendar: {}", | ||||||
|  |                 e | ||||||
|  |             ))) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,15 +1,23 @@ | |||||||
| use axum::{ | use axum::{ | ||||||
|     extract::{State, Query, Path}, |     extract::{Path, Query, State}, | ||||||
|     http::HeaderMap, |     http::HeaderMap, | ||||||
|     response::Json, |     response::Json, | ||||||
| }; | }; | ||||||
|  | use chrono::Datelike; | ||||||
| use serde::Deserialize; | use serde::Deserialize; | ||||||
| use std::sync::Arc; | use std::sync::Arc; | ||||||
| use chrono::Datelike; |  | ||||||
|  |  | ||||||
| use calendar_models::{VEvent, EventStatus, EventClass, CalendarUser, Attendee, VAlarm, AlarmAction, AlarmTrigger}; |  | ||||||
| use crate::{AppState, models::{ApiError, DeleteEventRequest, DeleteEventResponse, CreateEventRequest, CreateEventResponse, UpdateEventRequest, UpdateEventResponse}}; |  | ||||||
| use crate::calendar::{CalDAVClient, CalendarEvent}; | use crate::calendar::{CalDAVClient, CalendarEvent}; | ||||||
|  | use crate::{ | ||||||
|  |     models::{ | ||||||
|  |         ApiError, CreateEventRequest, CreateEventResponse, DeleteEventRequest, DeleteEventResponse, | ||||||
|  |         UpdateEventRequest, UpdateEventResponse, | ||||||
|  |     }, | ||||||
|  |     AppState, | ||||||
|  | }; | ||||||
|  | use calendar_models::{ | ||||||
|  |     AlarmAction, AlarmTrigger, Attendee, CalendarUser, EventClass, EventStatus, VAlarm, VEvent, | ||||||
|  | }; | ||||||
|  |  | ||||||
| use super::auth::{extract_bearer_token, extract_password_header}; | use super::auth::{extract_bearer_token, extract_password_header}; | ||||||
|  |  | ||||||
| @@ -30,11 +38,14 @@ pub async fn get_calendar_events( | |||||||
|     println!("🔑 API call with password length: {}", password.len()); |     println!("🔑 API call with password length: {}", password.len()); | ||||||
|  |  | ||||||
|     // Create CalDAV config from token and password |     // Create CalDAV config from token and password | ||||||
|     let config = state.auth_service.caldav_config_from_token(&token, &password)?; |     let config = state | ||||||
|  |         .auth_service | ||||||
|  |         .caldav_config_from_token(&token, &password)?; | ||||||
|     let client = CalDAVClient::new(config); |     let client = CalDAVClient::new(config); | ||||||
|  |  | ||||||
|     // Discover calendars if needed |     // Discover calendars if needed | ||||||
|     let calendar_paths = client.discover_calendars() |     let calendar_paths = client | ||||||
|  |         .discover_calendars() | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; |         .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; | ||||||
|  |  | ||||||
| @@ -54,7 +65,10 @@ pub async fn get_calendar_events( | |||||||
|                 all_events.extend(events); |                 all_events.extend(events); | ||||||
|             } |             } | ||||||
|             Err(e) => { |             Err(e) => { | ||||||
|                 eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e); |                 eprintln!( | ||||||
|  |                     "Failed to fetch events from calendar {}: {}", | ||||||
|  |                     calendar_path, e | ||||||
|  |                 ); | ||||||
|                 // Continue with other calendars instead of failing completely |                 // Continue with other calendars instead of failing completely | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| @@ -82,11 +96,14 @@ pub async fn refresh_event( | |||||||
|     let password = extract_password_header(&headers)?; |     let password = extract_password_header(&headers)?; | ||||||
|  |  | ||||||
|     // Create CalDAV config from token and password |     // Create CalDAV config from token and password | ||||||
|     let config = state.auth_service.caldav_config_from_token(&token, &password)?; |     let config = state | ||||||
|  |         .auth_service | ||||||
|  |         .caldav_config_from_token(&token, &password)?; | ||||||
|     let client = CalDAVClient::new(config); |     let client = CalDAVClient::new(config); | ||||||
|  |  | ||||||
|     // Discover calendars |     // Discover calendars | ||||||
|     let calendar_paths = client.discover_calendars() |     let calendar_paths = client | ||||||
|  |         .discover_calendars() | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; |         .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; | ||||||
|  |  | ||||||
| @@ -101,13 +118,20 @@ pub async fn refresh_event( | |||||||
|     Ok(Json(None)) |     Ok(Json(None)) | ||||||
| } | } | ||||||
|  |  | ||||||
| async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_href: &str) -> Result<Option<CalendarEvent>, crate::calendar::CalDAVError> { | async fn fetch_event_by_href( | ||||||
|  |     client: &CalDAVClient, | ||||||
|  |     calendar_path: &str, | ||||||
|  |     event_href: &str, | ||||||
|  | ) -> Result<Option<CalendarEvent>, crate::calendar::CalDAVError> { | ||||||
|     // This is a simplified implementation - in a real scenario, you'd want to fetch the specific event by href |     // This is a simplified implementation - in a real scenario, you'd want to fetch the specific event by href | ||||||
|     // For now, we'll fetch all events and find the matching one by href (inefficient but functional) |     // For now, we'll fetch all events and find the matching one by href (inefficient but functional) | ||||||
|     let events = client.fetch_events(calendar_path).await?; |     let events = client.fetch_events(calendar_path).await?; | ||||||
|  |  | ||||||
|     println!("🔍 fetch_event_by_href: looking for href='{}'", event_href); |     println!("🔍 fetch_event_by_href: looking for href='{}'", event_href); | ||||||
|     println!("🔍 Available events with hrefs: {:?}", events.iter().map(|e| (&e.uid, &e.href)).collect::<Vec<_>>()); |     println!( | ||||||
|  |         "🔍 Available events with hrefs: {:?}", | ||||||
|  |         events.iter().map(|e| (&e.uid, &e.href)).collect::<Vec<_>>() | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     // First try to match by exact href |     // First try to match by exact href | ||||||
|     for event in &events { |     for event in &events { | ||||||
| @@ -123,7 +147,10 @@ async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_h | |||||||
|     let filename = event_href.split('/').last().unwrap_or(event_href); |     let filename = event_href.split('/').last().unwrap_or(event_href); | ||||||
|     let uid_from_href = filename.trim_end_matches(".ics"); |     let uid_from_href = filename.trim_end_matches(".ics"); | ||||||
|  |  | ||||||
|     println!("🔍 Fallback: trying UID match. filename='{}', uid='{}'", filename, uid_from_href); |     println!( | ||||||
|  |         "🔍 Fallback: trying UID match. filename='{}', uid='{}'", | ||||||
|  |         filename, uid_from_href | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     for event in events { |     for event in events { | ||||||
|         if event.uid == uid_from_href { |         if event.uid == uid_from_href { | ||||||
| @@ -146,23 +173,31 @@ pub async fn delete_event( | |||||||
|     let password = extract_password_header(&headers)?; |     let password = extract_password_header(&headers)?; | ||||||
|  |  | ||||||
|     // Create CalDAV config from token and password |     // Create CalDAV config from token and password | ||||||
|     let config = state.auth_service.caldav_config_from_token(&token, &password)?; |     let config = state | ||||||
|  |         .auth_service | ||||||
|  |         .caldav_config_from_token(&token, &password)?; | ||||||
|     let client = CalDAVClient::new(config); |     let client = CalDAVClient::new(config); | ||||||
|  |  | ||||||
|     // Handle different delete actions for recurring events |     // Handle different delete actions for recurring events | ||||||
|     match request.delete_action.as_str() { |     match request.delete_action.as_str() { | ||||||
|         "delete_this" => { |         "delete_this" => { | ||||||
|             if let Some(event) = fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await |             if let Some(event) = | ||||||
|                 .map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? { |                 fetch_event_by_href(&client, &request.calendar_path, &request.event_href) | ||||||
|                  |                     .await | ||||||
|  |                     .map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? | ||||||
|  |             { | ||||||
|                 // Check if this is a recurring event |                 // Check if this is a recurring event | ||||||
|                 if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() { |                 if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() { | ||||||
|                     // Recurring event - add EXDATE for this occurrence |                     // Recurring event - add EXDATE for this occurrence | ||||||
|                     if let Some(occurrence_date) = &request.occurrence_date { |                     if let Some(occurrence_date) = &request.occurrence_date { | ||||||
|                         let exception_utc = if let Ok(date) = chrono::DateTime::parse_from_rfc3339(occurrence_date) { |                         let exception_utc = if let Ok(date) = | ||||||
|  |                             chrono::DateTime::parse_from_rfc3339(occurrence_date) | ||||||
|  |                         { | ||||||
|                             // RFC3339 format (with time and timezone) |                             // RFC3339 format (with time and timezone) | ||||||
|                             date.with_timezone(&chrono::Utc) |                             date.with_timezone(&chrono::Utc) | ||||||
|                         } else if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") { |                         } else if let Ok(naive_date) = | ||||||
|  |                             chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") | ||||||
|  |                         { | ||||||
|                             // Simple date format (YYYY-MM-DD) |                             // Simple date format (YYYY-MM-DD) | ||||||
|                             naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc() |                             naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc() | ||||||
|                         } else { |                         } else { | ||||||
| @@ -172,12 +207,26 @@ pub async fn delete_event( | |||||||
|                         let mut updated_event = event; |                         let mut updated_event = event; | ||||||
|                         updated_event.exdate.push(exception_utc); |                         updated_event.exdate.push(exception_utc); | ||||||
|  |  | ||||||
|                         println!("🔄 Adding EXDATE {} to recurring event {}", exception_utc.format("%Y%m%dT%H%M%SZ"), updated_event.uid); |                         println!( | ||||||
|  |                             "🔄 Adding EXDATE {} to recurring event {}", | ||||||
|  |                             exception_utc.format("%Y%m%dT%H%M%SZ"), | ||||||
|  |                             updated_event.uid | ||||||
|  |                         ); | ||||||
|  |  | ||||||
|                         // Update the event with the new EXDATE |                         // Update the event with the new EXDATE | ||||||
|                         client.update_event(&request.calendar_path, &updated_event, &request.event_href) |                         client | ||||||
|  |                             .update_event( | ||||||
|  |                                 &request.calendar_path, | ||||||
|  |                                 &updated_event, | ||||||
|  |                                 &request.event_href, | ||||||
|  |                             ) | ||||||
|                             .await |                             .await | ||||||
|                             .map_err(|e| ApiError::Internal(format!("Failed to update event with EXDATE: {}", e)))?; |                             .map_err(|e| { | ||||||
|  |                                 ApiError::Internal(format!( | ||||||
|  |                                     "Failed to update event with EXDATE: {}", | ||||||
|  |                                     e | ||||||
|  |                                 )) | ||||||
|  |                             })?; | ||||||
|  |  | ||||||
|                         println!("✅ Successfully updated recurring event with EXDATE"); |                         println!("✅ Successfully updated recurring event with EXDATE"); | ||||||
|  |  | ||||||
| @@ -192,9 +241,12 @@ pub async fn delete_event( | |||||||
|                     // Non-recurring event - delete the entire event |                     // Non-recurring event - delete the entire event | ||||||
|                     println!("🗑️ Deleting non-recurring event: {}", event.uid); |                     println!("🗑️ Deleting non-recurring event: {}", event.uid); | ||||||
|  |  | ||||||
|                     client.delete_event(&request.calendar_path, &request.event_href) |                     client | ||||||
|  |                         .delete_event(&request.calendar_path, &request.event_href) | ||||||
|                         .await |                         .await | ||||||
|                         .map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?; |                         .map_err(|e| { | ||||||
|  |                             ApiError::Internal(format!("Failed to delete event: {}", e)) | ||||||
|  |                         })?; | ||||||
|  |  | ||||||
|                     println!("✅ Successfully deleted non-recurring event"); |                     println!("✅ Successfully deleted non-recurring event"); | ||||||
|  |  | ||||||
| @@ -206,51 +258,77 @@ pub async fn delete_event( | |||||||
|             } else { |             } else { | ||||||
|                 Err(ApiError::NotFound("Event not found".to_string())) |                 Err(ApiError::NotFound("Event not found".to_string())) | ||||||
|             } |             } | ||||||
|         }, |         } | ||||||
|         "delete_following" => { |         "delete_following" => { | ||||||
|             // For "this and following" deletion, we need to: |             // For "this and following" deletion, we need to: | ||||||
|             // 1. Fetch the recurring event |             // 1. Fetch the recurring event | ||||||
|             // 2. Modify the RRULE to end before this occurrence |             // 2. Modify the RRULE to end before this occurrence | ||||||
|             // 3. Update the event |             // 3. Update the event | ||||||
|  |  | ||||||
|             if let Some(mut event) = fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await |             if let Some(mut event) = | ||||||
|                 .map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? { |                 fetch_event_by_href(&client, &request.calendar_path, &request.event_href) | ||||||
|                  |                     .await | ||||||
|  |                     .map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? | ||||||
|  |             { | ||||||
|                 if let Some(occurrence_date) = &request.occurrence_date { |                 if let Some(occurrence_date) = &request.occurrence_date { | ||||||
|                     let until_date = if let Ok(date) = chrono::DateTime::parse_from_rfc3339(occurrence_date) { |                     let until_date = if let Ok(date) = | ||||||
|  |                         chrono::DateTime::parse_from_rfc3339(occurrence_date) | ||||||
|  |                     { | ||||||
|                         // RFC3339 format (with time and timezone) |                         // RFC3339 format (with time and timezone) | ||||||
|                         date.with_timezone(&chrono::Utc) |                         date.with_timezone(&chrono::Utc) | ||||||
|                     } else if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") { |                     } else if let Ok(naive_date) = | ||||||
|  |                         chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") | ||||||
|  |                     { | ||||||
|                         // Simple date format (YYYY-MM-DD) |                         // Simple date format (YYYY-MM-DD) | ||||||
|                         naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc() |                         naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc() | ||||||
|                     } else { |                     } else { | ||||||
|                         return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date))); |                         return Err(ApiError::BadRequest(format!( | ||||||
|  |                             "Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", | ||||||
|  |                             occurrence_date | ||||||
|  |                         ))); | ||||||
|                     }; |                     }; | ||||||
|  |  | ||||||
|                     // Modify the RRULE to add an UNTIL clause |                     // Modify the RRULE to add an UNTIL clause | ||||||
|                     if let Some(rrule) = &event.rrule { |                     if let Some(rrule) = &event.rrule { | ||||||
|                         // Remove existing UNTIL if present and add new one |                         // Remove existing UNTIL if present and add new one | ||||||
|                         let parts: Vec<&str> = rrule.split(';').filter(|part| { |                         let parts: Vec<&str> = rrule | ||||||
|  |                             .split(';') | ||||||
|  |                             .filter(|part| { | ||||||
|                                 !part.starts_with("UNTIL=") && !part.starts_with("COUNT=") |                                 !part.starts_with("UNTIL=") && !part.starts_with("COUNT=") | ||||||
|                         }).collect(); |                             }) | ||||||
|  |                             .collect(); | ||||||
|  |  | ||||||
|                         let new_rrule = format!("{};UNTIL={}", parts.join(";"), until_date.format("%Y%m%dT%H%M%SZ")); |                         let new_rrule = format!( | ||||||
|  |                             "{};UNTIL={}", | ||||||
|  |                             parts.join(";"), | ||||||
|  |                             until_date.format("%Y%m%dT%H%M%SZ") | ||||||
|  |                         ); | ||||||
|                         event.rrule = Some(new_rrule); |                         event.rrule = Some(new_rrule); | ||||||
|  |  | ||||||
|                         // Update the event with the modified RRULE |                         // Update the event with the modified RRULE | ||||||
|                         client.update_event(&request.calendar_path, &event, &request.event_href) |                         client | ||||||
|  |                             .update_event(&request.calendar_path, &event, &request.event_href) | ||||||
|                             .await |                             .await | ||||||
|                             .map_err(|e| ApiError::Internal(format!("Failed to update event with modified RRULE: {}", e)))?; |                             .map_err(|e| { | ||||||
|  |                                 ApiError::Internal(format!( | ||||||
|  |                                     "Failed to update event with modified RRULE: {}", | ||||||
|  |                                     e | ||||||
|  |                                 )) | ||||||
|  |                             })?; | ||||||
|  |  | ||||||
|                         Ok(Json(DeleteEventResponse { |                         Ok(Json(DeleteEventResponse { | ||||||
|                             success: true, |                             success: true, | ||||||
|                             message: "This and following occurrences deleted successfully".to_string(), |                             message: "This and following occurrences deleted successfully" | ||||||
|  |                                 .to_string(), | ||||||
|                         })) |                         })) | ||||||
|                     } else { |                     } else { | ||||||
|                         // No RRULE, just delete the single event |                         // No RRULE, just delete the single event | ||||||
|                         client.delete_event(&request.calendar_path, &request.event_href) |                         client | ||||||
|  |                             .delete_event(&request.calendar_path, &request.event_href) | ||||||
|                             .await |                             .await | ||||||
|                             .map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?; |                             .map_err(|e| { | ||||||
|  |                                 ApiError::Internal(format!("Failed to delete event: {}", e)) | ||||||
|  |                             })?; | ||||||
|  |  | ||||||
|                         Ok(Json(DeleteEventResponse { |                         Ok(Json(DeleteEventResponse { | ||||||
|                             success: true, |                             success: true, | ||||||
| @@ -258,15 +336,18 @@ pub async fn delete_event( | |||||||
|                         })) |                         })) | ||||||
|                     } |                     } | ||||||
|                 } else { |                 } else { | ||||||
|                     Err(ApiError::BadRequest("Occurrence date is required for following deletion".to_string())) |                     Err(ApiError::BadRequest( | ||||||
|  |                         "Occurrence date is required for following deletion".to_string(), | ||||||
|  |                     )) | ||||||
|                 } |                 } | ||||||
|             } else { |             } else { | ||||||
|                 Err(ApiError::NotFound("Event not found".to_string())) |                 Err(ApiError::NotFound("Event not found".to_string())) | ||||||
|             } |             } | ||||||
|         }, |         } | ||||||
|         "delete_series" | _ => { |         "delete_series" | _ => { | ||||||
|             // Delete the entire event/series |             // Delete the entire event/series | ||||||
|             client.delete_event(&request.calendar_path, &request.event_href) |             client | ||||||
|  |                 .delete_event(&request.calendar_path, &request.event_href) | ||||||
|                 .await |                 .await | ||||||
|                 .map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?; |                 .map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?; | ||||||
|  |  | ||||||
| @@ -283,8 +364,10 @@ pub async fn create_event( | |||||||
|     headers: HeaderMap, |     headers: HeaderMap, | ||||||
|     Json(request): Json<CreateEventRequest>, |     Json(request): Json<CreateEventRequest>, | ||||||
| ) -> Result<Json<CreateEventResponse>, ApiError> { | ) -> Result<Json<CreateEventResponse>, ApiError> { | ||||||
|     println!("📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",  |     println!( | ||||||
|              request.title, request.all_day, request.calendar_path); |         "📝 Create event request received: title='{}', all_day={}, calendar_path={:?}", | ||||||
|  |         request.title, request.all_day, request.calendar_path | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     // Extract and verify token |     // Extract and verify token | ||||||
|     let token = extract_bearer_token(&headers)?; |     let token = extract_bearer_token(&headers)?; | ||||||
| @@ -296,11 +379,15 @@ pub async fn create_event( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     if request.title.len() > 200 { |     if request.title.len() > 200 { | ||||||
|         return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string())); |         return Err(ApiError::BadRequest( | ||||||
|  |             "Event title too long (max 200 characters)".to_string(), | ||||||
|  |         )); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Create CalDAV config from token and password |     // Create CalDAV config from token and password | ||||||
|     let config = state.auth_service.caldav_config_from_token(&token, &password)?; |     let config = state | ||||||
|  |         .auth_service | ||||||
|  |         .caldav_config_from_token(&token, &password)?; | ||||||
|     let client = CalDAVClient::new(config); |     let client = CalDAVClient::new(config); | ||||||
|  |  | ||||||
|     // Determine which calendar to use |     // Determine which calendar to use | ||||||
| @@ -308,19 +395,23 @@ pub async fn create_event( | |||||||
|         path |         path | ||||||
|     } else { |     } else { | ||||||
|         // Use the first available calendar |         // Use the first available calendar | ||||||
|         let calendar_paths = client.discover_calendars() |         let calendar_paths = client | ||||||
|  |             .discover_calendars() | ||||||
|             .await |             .await | ||||||
|             .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; |             .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; | ||||||
|  |  | ||||||
|         if calendar_paths.is_empty() { |         if calendar_paths.is_empty() { | ||||||
|             return Err(ApiError::BadRequest("No calendars available for event creation".to_string())); |             return Err(ApiError::BadRequest( | ||||||
|  |                 "No calendars available for event creation".to_string(), | ||||||
|  |             )); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         calendar_paths[0].clone() |         calendar_paths[0].clone() | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Parse dates and times |     // Parse dates and times | ||||||
|     let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day) |     let start_datetime = | ||||||
|  |         parse_event_datetime(&request.start_date, &request.start_time, request.all_day) | ||||||
|             .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; |             .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; | ||||||
|  |  | ||||||
|     let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day) |     let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day) | ||||||
| @@ -328,11 +419,17 @@ pub async fn create_event( | |||||||
|  |  | ||||||
|     // Validate that end is after start |     // Validate that end is after start | ||||||
|     if end_datetime <= start_datetime { |     if end_datetime <= start_datetime { | ||||||
|         return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string())); |         return Err(ApiError::BadRequest( | ||||||
|  |             "End date/time must be after start date/time".to_string(), | ||||||
|  |         )); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Generate a unique UID for the event |     // Generate a unique UID for the event | ||||||
|     let uid = format!("{}-{}", uuid::Uuid::new_v4(), chrono::Utc::now().timestamp()); |     let uid = format!( | ||||||
|  |         "{}-{}", | ||||||
|  |         uuid::Uuid::new_v4(), | ||||||
|  |         chrono::Utc::now().timestamp() | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     // Parse status |     // Parse status | ||||||
|     let status = match request.status.to_lowercase().as_str() { |     let status = match request.status.to_lowercase().as_str() { | ||||||
| @@ -352,7 +449,8 @@ pub async fn create_event( | |||||||
|     let attendees: Vec<String> = if request.attendees.trim().is_empty() { |     let attendees: Vec<String> = if request.attendees.trim().is_empty() { | ||||||
|         Vec::new() |         Vec::new() | ||||||
|     } else { |     } else { | ||||||
|         request.attendees |         request | ||||||
|  |             .attendees | ||||||
|             .split(',') |             .split(',') | ||||||
|             .map(|s| s.trim().to_string()) |             .map(|s| s.trim().to_string()) | ||||||
|             .filter(|s| !s.is_empty()) |             .filter(|s| !s.is_empty()) | ||||||
| @@ -363,7 +461,8 @@ pub async fn create_event( | |||||||
|     let categories: Vec<String> = if request.categories.trim().is_empty() { |     let categories: Vec<String> = if request.categories.trim().is_empty() { | ||||||
|         Vec::new() |         Vec::new() | ||||||
|     } else { |     } else { | ||||||
|         request.categories |         request | ||||||
|  |             .categories | ||||||
|             .split(',') |             .split(',') | ||||||
|             .map(|s| s.trim().to_string()) |             .map(|s| s.trim().to_string()) | ||||||
|             .filter(|s| !s.is_empty()) |             .filter(|s| !s.is_empty()) | ||||||
| @@ -402,7 +501,8 @@ pub async fn create_event( | |||||||
|  |  | ||||||
|                 // Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]) |                 // Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]) | ||||||
|                 if request.recurrence_days.len() == 7 { |                 if request.recurrence_days.len() == 7 { | ||||||
|                     let selected_days: Vec<&str> = request.recurrence_days |                     let selected_days: Vec<&str> = request | ||||||
|  |                         .recurrence_days | ||||||
|                         .iter() |                         .iter() | ||||||
|                         .enumerate() |                         .enumerate() | ||||||
|                         .filter_map(|(i, &selected)| { |                         .filter_map(|(i, &selected)| { | ||||||
| @@ -429,7 +529,7 @@ pub async fn create_event( | |||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 Some(rrule) |                 Some(rrule) | ||||||
|             }, |             } | ||||||
|             "MONTHLY" => Some("FREQ=MONTHLY".to_string()), |             "MONTHLY" => Some("FREQ=MONTHLY".to_string()), | ||||||
|             "YEARLY" => Some("FREQ=YEARLY".to_string()), |             "YEARLY" => Some("FREQ=YEARLY".to_string()), | ||||||
|             _ => None, |             _ => None, | ||||||
| @@ -439,9 +539,21 @@ pub async fn create_event( | |||||||
|     // Create the VEvent struct (RFC 5545 compliant) |     // Create the VEvent struct (RFC 5545 compliant) | ||||||
|     let mut event = VEvent::new(uid, start_datetime); |     let mut event = VEvent::new(uid, start_datetime); | ||||||
|     event.dtend = Some(end_datetime); |     event.dtend = Some(end_datetime); | ||||||
|     event.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) }; |     event.summary = if request.title.trim().is_empty() { | ||||||
|     event.description = if request.description.trim().is_empty() { None } else { Some(request.description) }; |         None | ||||||
|     event.location = if request.location.trim().is_empty() { None } else { Some(request.location) }; |     } else { | ||||||
|  |         Some(request.title.clone()) | ||||||
|  |     }; | ||||||
|  |     event.description = if request.description.trim().is_empty() { | ||||||
|  |         None | ||||||
|  |     } else { | ||||||
|  |         Some(request.description) | ||||||
|  |     }; | ||||||
|  |     event.location = if request.location.trim().is_empty() { | ||||||
|  |         None | ||||||
|  |     } else { | ||||||
|  |         Some(request.location) | ||||||
|  |     }; | ||||||
|     event.status = Some(status); |     event.status = Some(status); | ||||||
|     event.class = Some(class); |     event.class = Some(class); | ||||||
|     event.priority = request.priority; |     event.priority = request.priority; | ||||||
| @@ -456,7 +568,9 @@ pub async fn create_event( | |||||||
|             language: None, |             language: None, | ||||||
|         }) |         }) | ||||||
|     }; |     }; | ||||||
|     event.attendees = attendees.into_iter().map(|email| Attendee { |     event.attendees = attendees | ||||||
|  |         .into_iter() | ||||||
|  |         .map(|email| Attendee { | ||||||
|             cal_address: email, |             cal_address: email, | ||||||
|             common_name: None, |             common_name: None, | ||||||
|             role: None, |             role: None, | ||||||
| @@ -469,28 +583,38 @@ pub async fn create_event( | |||||||
|             sent_by: None, |             sent_by: None, | ||||||
|             dir_entry_ref: None, |             dir_entry_ref: None, | ||||||
|             language: None, |             language: None, | ||||||
|     }).collect(); |         }) | ||||||
|  |         .collect(); | ||||||
|     event.categories = categories; |     event.categories = categories; | ||||||
|     event.rrule = rrule; |     event.rrule = rrule; | ||||||
|     event.all_day = request.all_day; |     event.all_day = request.all_day; | ||||||
|     event.alarms = alarms.into_iter().map(|reminder| VAlarm { |     event.alarms = alarms | ||||||
|  |         .into_iter() | ||||||
|  |         .map(|reminder| VAlarm { | ||||||
|             action: AlarmAction::Display, |             action: AlarmAction::Display, | ||||||
|         trigger: AlarmTrigger::Duration(chrono::Duration::minutes(-reminder.minutes_before as i64)), |             trigger: AlarmTrigger::Duration(chrono::Duration::minutes( | ||||||
|  |                 -reminder.minutes_before as i64, | ||||||
|  |             )), | ||||||
|             duration: None, |             duration: None, | ||||||
|             repeat: None, |             repeat: None, | ||||||
|             description: reminder.description, |             description: reminder.description, | ||||||
|             summary: None, |             summary: None, | ||||||
|             attendees: Vec::new(), |             attendees: Vec::new(), | ||||||
|             attach: Vec::new(), |             attach: Vec::new(), | ||||||
|     }).collect(); |         }) | ||||||
|  |         .collect(); | ||||||
|     event.calendar_path = Some(calendar_path.clone()); |     event.calendar_path = Some(calendar_path.clone()); | ||||||
|  |  | ||||||
|     // Create the event on the CalDAV server |     // Create the event on the CalDAV server | ||||||
|     let event_href = client.create_event(&calendar_path, &event) |     let event_href = client | ||||||
|  |         .create_event(&calendar_path, &event) | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?; |         .map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?; | ||||||
|  |  | ||||||
|     println!("✅ Event created successfully with UID: {} at href: {}", event.uid, event_href); |     println!( | ||||||
|  |         "✅ Event created successfully with UID: {} at href: {}", | ||||||
|  |         event.uid, event_href | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     Ok(Json(CreateEventResponse { |     Ok(Json(CreateEventResponse { | ||||||
|         success: true, |         success: true, | ||||||
| @@ -520,18 +644,23 @@ pub async fn update_event( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     if request.title.len() > 200 { |     if request.title.len() > 200 { | ||||||
|         return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string())); |         return Err(ApiError::BadRequest( | ||||||
|  |             "Event title too long (max 200 characters)".to_string(), | ||||||
|  |         )); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Create CalDAV config from token and password |     // Create CalDAV config from token and password | ||||||
|     let config = state.auth_service.caldav_config_from_token(&token, &password)?; |     let config = state | ||||||
|  |         .auth_service | ||||||
|  |         .caldav_config_from_token(&token, &password)?; | ||||||
|     let client = CalDAVClient::new(config); |     let client = CalDAVClient::new(config); | ||||||
|  |  | ||||||
|     // Find the event across all calendars (or in the specified calendar) |     // Find the event across all calendars (or in the specified calendar) | ||||||
|     let calendar_paths = if let Some(path) = &request.calendar_path { |     let calendar_paths = if let Some(path) = &request.calendar_path { | ||||||
|         vec![path.clone()] |         vec![path.clone()] | ||||||
|     } else { |     } else { | ||||||
|         client.discover_calendars() |         client | ||||||
|  |             .discover_calendars() | ||||||
|             .await |             .await | ||||||
|             .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))? |             .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))? | ||||||
|     }; |     }; | ||||||
| @@ -544,7 +673,10 @@ pub async fn update_event( | |||||||
|                 for event in events { |                 for event in events { | ||||||
|                     if event.uid == request.uid { |                     if event.uid == request.uid { | ||||||
|                         // Use the actual href from the event, or generate one if missing |                         // Use the actual href from the event, or generate one if missing | ||||||
|                         let event_href = event.href.clone().unwrap_or_else(|| format!("{}.ics", event.uid)); |                         let event_href = event | ||||||
|  |                             .href | ||||||
|  |                             .clone() | ||||||
|  |                             .unwrap_or_else(|| format!("{}.ics", event.uid)); | ||||||
|                         println!("🔍 Found event {} with href: {}", event.uid, event_href); |                         println!("🔍 Found event {} with href: {}", event.uid, event_href); | ||||||
|                         found_event = Some((event, calendar_path.clone(), event_href)); |                         found_event = Some((event, calendar_path.clone(), event_href)); | ||||||
|                         break; |                         break; | ||||||
| @@ -553,9 +685,12 @@ pub async fn update_event( | |||||||
|                 if found_event.is_some() { |                 if found_event.is_some() { | ||||||
|                     break; |                     break; | ||||||
|                 } |                 } | ||||||
|             }, |             } | ||||||
|             Err(e) => { |             Err(e) => { | ||||||
|                 eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e); |                 eprintln!( | ||||||
|  |                     "Failed to fetch events from calendar {}: {}", | ||||||
|  |                     calendar_path, e | ||||||
|  |                 ); | ||||||
|                 continue; |                 continue; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| @@ -565,7 +700,8 @@ pub async fn update_event( | |||||||
|         .ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?; |         .ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?; | ||||||
|  |  | ||||||
|     // Parse dates and times |     // Parse dates and times | ||||||
|     let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day) |     let start_datetime = | ||||||
|  |         parse_event_datetime(&request.start_date, &request.start_time, request.all_day) | ||||||
|             .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; |             .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; | ||||||
|  |  | ||||||
|     let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day) |     let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day) | ||||||
| @@ -573,15 +709,29 @@ pub async fn update_event( | |||||||
|  |  | ||||||
|     // Validate that end is after start |     // Validate that end is after start | ||||||
|     if end_datetime <= start_datetime { |     if end_datetime <= start_datetime { | ||||||
|         return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string())); |         return Err(ApiError::BadRequest( | ||||||
|  |             "End date/time must be after start date/time".to_string(), | ||||||
|  |         )); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Update event properties |     // Update event properties | ||||||
|     event.dtstart = start_datetime; |     event.dtstart = start_datetime; | ||||||
|     event.dtend = Some(end_datetime); |     event.dtend = Some(end_datetime); | ||||||
|     event.summary = if request.title.trim().is_empty() { None } else { Some(request.title) }; |     event.summary = if request.title.trim().is_empty() { | ||||||
|     event.description = if request.description.trim().is_empty() { None } else { Some(request.description) }; |         None | ||||||
|     event.location = if request.location.trim().is_empty() { None } else { Some(request.location) }; |     } else { | ||||||
|  |         Some(request.title) | ||||||
|  |     }; | ||||||
|  |     event.description = if request.description.trim().is_empty() { | ||||||
|  |         None | ||||||
|  |     } else { | ||||||
|  |         Some(request.description) | ||||||
|  |     }; | ||||||
|  |     event.location = if request.location.trim().is_empty() { | ||||||
|  |         None | ||||||
|  |     } else { | ||||||
|  |         Some(request.location) | ||||||
|  |     }; | ||||||
|     event.all_day = request.all_day; |     event.all_day = request.all_day; | ||||||
|  |  | ||||||
|     // Parse and update status |     // Parse and update status | ||||||
| @@ -601,8 +751,12 @@ pub async fn update_event( | |||||||
|     event.priority = request.priority; |     event.priority = request.priority; | ||||||
|  |  | ||||||
|     // Update the event on the CalDAV server |     // Update the event on the CalDAV server | ||||||
|     println!("📝 Updating event {} at calendar_path: {}, event_href: {}", event.uid, calendar_path, event_href); |     println!( | ||||||
|     client.update_event(&calendar_path, &event, &event_href) |         "📝 Updating event {} at calendar_path: {}, event_href: {}", | ||||||
|  |         event.uid, calendar_path, event_href | ||||||
|  |     ); | ||||||
|  |     client | ||||||
|  |         .update_event(&calendar_path, &event, &event_href) | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?; |         .map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?; | ||||||
|  |  | ||||||
| @@ -614,8 +768,12 @@ pub async fn update_event( | |||||||
|     })) |     })) | ||||||
| } | } | ||||||
|  |  | ||||||
| fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result<chrono::DateTime<chrono::Utc>, String> { | fn parse_event_datetime( | ||||||
|     use chrono::{NaiveDate, NaiveTime, NaiveDateTime, Utc, TimeZone}; |     date_str: &str, | ||||||
|  |     time_str: &str, | ||||||
|  |     all_day: bool, | ||||||
|  | ) -> Result<chrono::DateTime<chrono::Utc>, String> { | ||||||
|  |     use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; | ||||||
|  |  | ||||||
|     // Parse the date |     // Parse the date | ||||||
|     let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") |     let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") | ||||||
| @@ -623,7 +781,8 @@ fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result | |||||||
|  |  | ||||||
|     if all_day { |     if all_day { | ||||||
|         // For all-day events, use midnight UTC |         // For all-day events, use midnight UTC | ||||||
|         let datetime = date.and_hms_opt(0, 0, 0) |         let datetime = date | ||||||
|  |             .and_hms_opt(0, 0, 0) | ||||||
|             .ok_or_else(|| "Failed to create midnight datetime".to_string())?; |             .ok_or_else(|| "Failed to create midnight datetime".to_string())?; | ||||||
|         Ok(Utc.from_utc_datetime(&datetime)) |         Ok(Utc.from_utc_datetime(&datetime)) | ||||||
|     } else { |     } else { | ||||||
|   | |||||||
							
								
								
									
										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" | ||||||
|  |         })), | ||||||
|  |     )) | ||||||
|  | } | ||||||
| @@ -1,14 +1,16 @@ | |||||||
| use axum::{ | use axum::{extract::State, http::HeaderMap, response::Json}; | ||||||
|     extract::State, |  | ||||||
|     http::HeaderMap, |  | ||||||
|     response::Json, |  | ||||||
| }; |  | ||||||
| use std::sync::Arc; |  | ||||||
| use chrono::TimeZone; | use chrono::TimeZone; | ||||||
|  | use std::sync::Arc; | ||||||
|  |  | ||||||
| use crate::{AppState, models::{ApiError, CreateEventSeriesRequest, CreateEventSeriesResponse, UpdateEventSeriesRequest, UpdateEventSeriesResponse, DeleteEventSeriesRequest, DeleteEventSeriesResponse}}; |  | ||||||
| use crate::calendar::CalDAVClient; | use crate::calendar::CalDAVClient; | ||||||
| use calendar_models::{VEvent, EventStatus, EventClass}; | use crate::{ | ||||||
|  |     models::{ | ||||||
|  |         ApiError, CreateEventSeriesRequest, CreateEventSeriesResponse, DeleteEventSeriesRequest, | ||||||
|  |         DeleteEventSeriesResponse, UpdateEventSeriesRequest, UpdateEventSeriesResponse, | ||||||
|  |     }, | ||||||
|  |     AppState, | ||||||
|  | }; | ||||||
|  | use calendar_models::{EventClass, EventStatus, VEvent}; | ||||||
|  |  | ||||||
| use super::auth::{extract_bearer_token, extract_password_header}; | use super::auth::{extract_bearer_token, extract_password_header}; | ||||||
|  |  | ||||||
| @@ -18,8 +20,10 @@ pub async fn create_event_series( | |||||||
|     headers: HeaderMap, |     headers: HeaderMap, | ||||||
|     Json(request): Json<CreateEventSeriesRequest>, |     Json(request): Json<CreateEventSeriesRequest>, | ||||||
| ) -> Result<Json<CreateEventSeriesResponse>, ApiError> { | ) -> Result<Json<CreateEventSeriesResponse>, ApiError> { | ||||||
|     println!("📝 Create event series request received: title='{}', recurrence='{}', all_day={}",  |     println!( | ||||||
|              request.title, request.recurrence, request.all_day); |         "📝 Create event series request received: title='{}', recurrence='{}', all_day={}", | ||||||
|  |         request.title, request.recurrence, request.all_day | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     // Extract and verify token |     // Extract and verify token | ||||||
|     let token = extract_bearer_token(&headers)?; |     let token = extract_bearer_token(&headers)?; | ||||||
| @@ -31,11 +35,15 @@ pub async fn create_event_series( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     if request.title.len() > 200 { |     if request.title.len() > 200 { | ||||||
|         return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string())); |         return Err(ApiError::BadRequest( | ||||||
|  |             "Event title too long (max 200 characters)".to_string(), | ||||||
|  |         )); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if request.recurrence == "none" { |     if request.recurrence == "none" { | ||||||
|         return Err(ApiError::BadRequest("Use regular create endpoint for non-recurring events".to_string())); |         return Err(ApiError::BadRequest( | ||||||
|  |             "Use regular create endpoint for non-recurring events".to_string(), | ||||||
|  |         )); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Validate recurrence type - handle both simple strings and RRULE strings |     // Validate recurrence type - handle both simple strings and RRULE strings | ||||||
| @@ -50,7 +58,9 @@ pub async fn create_event_series( | |||||||
|         } else if request.recurrence.contains("FREQ=YEARLY") { |         } else if request.recurrence.contains("FREQ=YEARLY") { | ||||||
|             "yearly" |             "yearly" | ||||||
|         } else { |         } else { | ||||||
|             return Err(ApiError::BadRequest("Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string())); |             return Err(ApiError::BadRequest( | ||||||
|  |                 "Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string(), | ||||||
|  |             )); | ||||||
|         } |         } | ||||||
|     } else { |     } else { | ||||||
|         // Handle simple strings |         // Handle simple strings | ||||||
| @@ -60,12 +70,19 @@ pub async fn create_event_series( | |||||||
|             "weekly" => "weekly", |             "weekly" => "weekly", | ||||||
|             "monthly" => "monthly", |             "monthly" => "monthly", | ||||||
|             "yearly" => "yearly", |             "yearly" => "yearly", | ||||||
|             _ => return Err(ApiError::BadRequest("Invalid recurrence type. Must be daily, weekly, monthly, or yearly".to_string())), |             _ => { | ||||||
|  |                 return Err(ApiError::BadRequest( | ||||||
|  |                     "Invalid recurrence type. Must be daily, weekly, monthly, or yearly" | ||||||
|  |                         .to_string(), | ||||||
|  |                 )) | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Create CalDAV config from token and password |     // Create CalDAV config from token and password | ||||||
|     let config = state.auth_service.caldav_config_from_token(&token, &password)?; |     let config = state | ||||||
|  |         .auth_service | ||||||
|  |         .caldav_config_from_token(&token, &password)?; | ||||||
|     let client = CalDAVClient::new(config); |     let client = CalDAVClient::new(config); | ||||||
|  |  | ||||||
|     // Determine which calendar to use |     // Determine which calendar to use | ||||||
| @@ -73,12 +90,15 @@ pub async fn create_event_series( | |||||||
|         path.clone() |         path.clone() | ||||||
|     } else { |     } else { | ||||||
|         // Use the first available calendar |         // Use the first available calendar | ||||||
|         let calendar_paths = client.discover_calendars() |         let calendar_paths = client | ||||||
|  |             .discover_calendars() | ||||||
|             .await |             .await | ||||||
|             .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; |             .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; | ||||||
|  |  | ||||||
|         if calendar_paths.is_empty() { |         if calendar_paths.is_empty() { | ||||||
|             return Err(ApiError::BadRequest("No calendars available for event creation".to_string())); |             return Err(ApiError::BadRequest( | ||||||
|  |                 "No calendars available for event creation".to_string(), | ||||||
|  |             )); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         calendar_paths[0].clone() |         calendar_paths[0].clone() | ||||||
| @@ -87,37 +107,47 @@ pub async fn create_event_series( | |||||||
|     println!("📅 Using calendar path: {}", calendar_path); |     println!("📅 Using calendar path: {}", calendar_path); | ||||||
|  |  | ||||||
|     // Parse datetime components |     // Parse datetime components | ||||||
|     let start_date = chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d") |     let start_date = | ||||||
|         .map_err(|_| ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string()))?; |         chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d").map_err(|_| { | ||||||
|  |             ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string()) | ||||||
|  |         })?; | ||||||
|  |  | ||||||
|     let (start_datetime, end_datetime) = if request.all_day { |     let (start_datetime, end_datetime) = if request.all_day { | ||||||
|         // For all-day events, use the dates as-is |         // For all-day events, use the dates as-is | ||||||
|         let start_dt = start_date.and_hms_opt(0, 0, 0) |         let start_dt = start_date | ||||||
|  |             .and_hms_opt(0, 0, 0) | ||||||
|             .ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?; |             .ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?; | ||||||
|  |  | ||||||
|         let end_date = if !request.end_date.is_empty() { |         let end_date = if !request.end_date.is_empty() { | ||||||
|             chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d") |             chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d").map_err(|_| { | ||||||
|                 .map_err(|_| ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string()))? |                 ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string()) | ||||||
|  |             })? | ||||||
|         } else { |         } else { | ||||||
|             start_date |             start_date | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         let end_dt = end_date.and_hms_opt(23, 59, 59) |         let end_dt = end_date | ||||||
|  |             .and_hms_opt(23, 59, 59) | ||||||
|             .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; |             .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; | ||||||
|  |  | ||||||
|         (chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt)) |         ( | ||||||
|  |             chrono::Utc.from_utc_datetime(&start_dt), | ||||||
|  |             chrono::Utc.from_utc_datetime(&end_dt), | ||||||
|  |         ) | ||||||
|     } else { |     } else { | ||||||
|         // Parse times for timed events |         // Parse times for timed events | ||||||
|         let start_time = if !request.start_time.is_empty() { |         let start_time = if !request.start_time.is_empty() { | ||||||
|             chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M") |             chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| { | ||||||
|                 .map_err(|_| ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string()))? |                 ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string()) | ||||||
|  |             })? | ||||||
|         } else { |         } else { | ||||||
|             chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap() // Default to 9 AM |             chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap() // Default to 9 AM | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         let end_time = if !request.end_time.is_empty() { |         let end_time = if !request.end_time.is_empty() { | ||||||
|             chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M") |             chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M").map_err(|_| { | ||||||
|                 .map_err(|_| ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string()))? |                 ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string()) | ||||||
|  |             })? | ||||||
|         } else { |         } else { | ||||||
|             chrono::NaiveTime::from_hms_opt(10, 0, 0).unwrap() // Default to 1 hour duration |             chrono::NaiveTime::from_hms_opt(10, 0, 0).unwrap() // Default to 1 hour duration | ||||||
|         }; |         }; | ||||||
| @@ -125,13 +155,18 @@ pub async fn create_event_series( | |||||||
|         let start_dt = start_date.and_time(start_time); |         let start_dt = start_date.and_time(start_time); | ||||||
|         let end_dt = if !request.end_date.is_empty() { |         let end_dt = if !request.end_date.is_empty() { | ||||||
|             let end_date = chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d") |             let end_date = chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d") | ||||||
|                 .map_err(|_| ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string()))?; |                 .map_err(|_| { | ||||||
|  |                     ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string()) | ||||||
|  |                 })?; | ||||||
|             end_date.and_time(end_time) |             end_date.and_time(end_time) | ||||||
|         } else { |         } else { | ||||||
|             start_date.and_time(end_time) |             start_date.and_time(end_time) | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         (chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt)) |         ( | ||||||
|  |             chrono::Utc.from_utc_datetime(&start_dt), | ||||||
|  |             chrono::Utc.from_utc_datetime(&end_dt), | ||||||
|  |         ) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Generate a unique UID for the series |     // Generate a unique UID for the series | ||||||
| @@ -140,9 +175,21 @@ pub async fn create_event_series( | |||||||
|     // Create the VEvent for the series |     // Create the VEvent for the series | ||||||
|     let mut event = VEvent::new(uid.clone(), start_datetime); |     let mut event = VEvent::new(uid.clone(), start_datetime); | ||||||
|     event.dtend = Some(end_datetime); |     event.dtend = Some(end_datetime); | ||||||
|     event.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) }; |     event.summary = if request.title.trim().is_empty() { | ||||||
|     event.description = if request.description.trim().is_empty() { None } else { Some(request.description.clone()) }; |         None | ||||||
|     event.location = if request.location.trim().is_empty() { None } else { Some(request.location.clone()) }; |     } else { | ||||||
|  |         Some(request.title.clone()) | ||||||
|  |     }; | ||||||
|  |     event.description = if request.description.trim().is_empty() { | ||||||
|  |         None | ||||||
|  |     } else { | ||||||
|  |         Some(request.description.clone()) | ||||||
|  |     }; | ||||||
|  |     event.location = if request.location.trim().is_empty() { | ||||||
|  |         None | ||||||
|  |     } else { | ||||||
|  |         Some(request.location.clone()) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|     // Set event status |     // Set event status | ||||||
|     event.status = Some(match request.status.to_lowercase().as_str() { |     event.status = Some(match request.status.to_lowercase().as_str() { | ||||||
| @@ -171,13 +218,16 @@ pub async fn create_event_series( | |||||||
|     }; |     }; | ||||||
|     event.rrule = Some(rrule); |     event.rrule = Some(rrule); | ||||||
|  |  | ||||||
|  |  | ||||||
|     // Create the event on the CalDAV server |     // Create the event on the CalDAV server | ||||||
|     let event_href = client.create_event(&calendar_path, &event) |     let event_href = client | ||||||
|  |         .create_event(&calendar_path, &event) | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| ApiError::Internal(format!("Failed to create event series: {}", e)))?; |         .map_err(|e| ApiError::Internal(format!("Failed to create event series: {}", e)))?; | ||||||
|  |  | ||||||
|     println!("✅ Event series created successfully with UID: {}, href: {}", uid, event_href); |     println!( | ||||||
|  |         "✅ Event series created successfully with UID: {}, href: {}", | ||||||
|  |         uid, event_href | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     Ok(Json(CreateEventSeriesResponse { |     Ok(Json(CreateEventSeriesResponse { | ||||||
|         success: true, |         success: true, | ||||||
| @@ -194,8 +244,10 @@ pub async fn update_event_series( | |||||||
|     headers: HeaderMap, |     headers: HeaderMap, | ||||||
|     Json(request): Json<UpdateEventSeriesRequest>, |     Json(request): Json<UpdateEventSeriesRequest>, | ||||||
| ) -> Result<Json<UpdateEventSeriesResponse>, ApiError> { | ) -> Result<Json<UpdateEventSeriesResponse>, ApiError> { | ||||||
|     println!("🔄 Update event series request received: series_uid='{}', update_scope='{}'",  |     println!( | ||||||
|              request.series_uid, request.update_scope); |         "🔄 Update event series request received: series_uid='{}', update_scope='{}'", | ||||||
|  |         request.series_uid, request.update_scope | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     // Extract and verify token |     // Extract and verify token | ||||||
|     let token = extract_bearer_token(&headers)?; |     let token = extract_bearer_token(&headers)?; | ||||||
| @@ -211,13 +263,20 @@ pub async fn update_event_series( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     if request.title.len() > 200 { |     if request.title.len() > 200 { | ||||||
|         return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string())); |         return Err(ApiError::BadRequest( | ||||||
|  |             "Event title too long (max 200 characters)".to_string(), | ||||||
|  |         )); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Validate update scope |     // Validate update scope | ||||||
|     match request.update_scope.as_str() { |     match request.update_scope.as_str() { | ||||||
|         "this_only" | "this_and_future" | "all_in_series" => {}, |         "this_only" | "this_and_future" | "all_in_series" => {} | ||||||
|         _ => return Err(ApiError::BadRequest("Invalid update_scope. Must be: this_only, this_and_future, or all_in_series".to_string())), |         _ => { | ||||||
|  |             return Err(ApiError::BadRequest( | ||||||
|  |                 "Invalid update_scope. Must be: this_only, this_and_future, or all_in_series" | ||||||
|  |                     .to_string(), | ||||||
|  |             )) | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Validate recurrence type - handle both simple strings and RRULE strings |     // Validate recurrence type - handle both simple strings and RRULE strings | ||||||
| @@ -232,7 +291,9 @@ pub async fn update_event_series( | |||||||
|         } else if request.recurrence.contains("FREQ=YEARLY") { |         } else if request.recurrence.contains("FREQ=YEARLY") { | ||||||
|             "yearly" |             "yearly" | ||||||
|         } else { |         } else { | ||||||
|             return Err(ApiError::BadRequest("Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string())); |             return Err(ApiError::BadRequest( | ||||||
|  |                 "Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string(), | ||||||
|  |             )); | ||||||
|         } |         } | ||||||
|     } else { |     } else { | ||||||
|         // Handle simple strings |         // Handle simple strings | ||||||
| @@ -242,12 +303,19 @@ pub async fn update_event_series( | |||||||
|             "weekly" => "weekly", |             "weekly" => "weekly", | ||||||
|             "monthly" => "monthly", |             "monthly" => "monthly", | ||||||
|             "yearly" => "yearly", |             "yearly" => "yearly", | ||||||
|             _ => return Err(ApiError::BadRequest("Invalid recurrence type. Must be daily, weekly, monthly, or yearly".to_string())), |             _ => { | ||||||
|  |                 return Err(ApiError::BadRequest( | ||||||
|  |                     "Invalid recurrence type. Must be daily, weekly, monthly, or yearly" | ||||||
|  |                         .to_string(), | ||||||
|  |                 )) | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Create CalDAV config from token and password |     // Create CalDAV config from token and password | ||||||
|     let config = state.auth_service.caldav_config_from_token(&token, &password)?; |     let config = state | ||||||
|  |         .auth_service | ||||||
|  |         .caldav_config_from_token(&token, &password)?; | ||||||
|     let client = CalDAVClient::new(config); |     let client = CalDAVClient::new(config); | ||||||
|  |  | ||||||
|     // Use the parsed frequency for further processing (avoiding unused variable warning) |     // Use the parsed frequency for further processing (avoiding unused variable warning) | ||||||
| @@ -257,13 +325,16 @@ pub async fn update_event_series( | |||||||
|     let calendar_paths = if let Some(ref path) = request.calendar_path { |     let calendar_paths = if let Some(ref path) = request.calendar_path { | ||||||
|         vec![path.clone()] |         vec![path.clone()] | ||||||
|     } else { |     } else { | ||||||
|         client.discover_calendars() |         client | ||||||
|  |             .discover_calendars() | ||||||
|             .await |             .await | ||||||
|             .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))? |             .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))? | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     if calendar_paths.is_empty() { |     if calendar_paths.is_empty() { | ||||||
|         return Err(ApiError::BadRequest("No calendars available for event update".to_string())); |         return Err(ApiError::BadRequest( | ||||||
|  |             "No calendars available for event update".to_string(), | ||||||
|  |         )); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Find the series event across all specified calendars |     // Find the series event across all specified calendars | ||||||
| @@ -278,34 +349,46 @@ pub async fn update_event_series( | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     let mut existing_event = existing_event |     let mut existing_event = existing_event.ok_or_else(|| { | ||||||
|         .ok_or_else(|| ApiError::NotFound(format!("Event series with UID '{}' not found", request.series_uid)))?; |         ApiError::NotFound(format!( | ||||||
|  |             "Event series with UID '{}' not found", | ||||||
|  |             request.series_uid | ||||||
|  |         )) | ||||||
|  |     })?; | ||||||
|  |  | ||||||
|     println!("📅 Found series event in calendar: {}", calendar_path); |     println!("📅 Found series event in calendar: {}", calendar_path); | ||||||
|     println!("📅 Event details: UID={}, summary={:?}, dtstart={}",  |     println!( | ||||||
|              existing_event.uid, existing_event.summary, existing_event.dtstart); |         "📅 Event details: UID={}, summary={:?}, dtstart={}", | ||||||
|  |         existing_event.uid, existing_event.summary, existing_event.dtstart | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     // Parse datetime components for the update |     // Parse datetime components for the update | ||||||
|     let original_start_date = existing_event.dtstart.date_naive(); |     let original_start_date = existing_event.dtstart.date_naive(); | ||||||
|  |  | ||||||
|     // For "this_and_future" and "this_only" updates, use the occurrence date for the modified event |     // For "this_and_future" and "this_only" updates, use the occurrence date for the modified event | ||||||
|     // For "all_in_series" updates, preserve the original series start date |     // For "all_in_series" updates, preserve the original series start date | ||||||
|     let start_date = if (request.update_scope == "this_and_future" || request.update_scope == "this_only") && request.occurrence_date.is_some() { |     let start_date = if (request.update_scope == "this_and_future" | ||||||
|  |         || request.update_scope == "this_only") | ||||||
|  |         && request.occurrence_date.is_some() | ||||||
|  |     { | ||||||
|         let occurrence_date_str = request.occurrence_date.as_ref().unwrap(); |         let occurrence_date_str = request.occurrence_date.as_ref().unwrap(); | ||||||
|         chrono::NaiveDate::parse_from_str(occurrence_date_str, "%Y-%m-%d") |         chrono::NaiveDate::parse_from_str(occurrence_date_str, "%Y-%m-%d").map_err(|_| { | ||||||
|             .map_err(|_| ApiError::BadRequest("Invalid occurrence_date format. Expected YYYY-MM-DD".to_string()))? |             ApiError::BadRequest("Invalid occurrence_date format. Expected YYYY-MM-DD".to_string()) | ||||||
|  |         })? | ||||||
|     } else { |     } else { | ||||||
|         original_start_date |         original_start_date | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     let (start_datetime, end_datetime) = if request.all_day { |     let (start_datetime, end_datetime) = if request.all_day { | ||||||
|         let start_dt = start_date.and_hms_opt(0, 0, 0) |         let start_dt = start_date | ||||||
|  |             .and_hms_opt(0, 0, 0) | ||||||
|             .ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?; |             .ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?; | ||||||
|  |  | ||||||
|         // For all-day events, also preserve the original date pattern |         // For all-day events, also preserve the original date pattern | ||||||
|         let end_date = if !request.end_date.is_empty() { |         let end_date = if !request.end_date.is_empty() { | ||||||
|             // Calculate the duration from the original event |             // Calculate the duration from the original event | ||||||
|             let original_duration_days = existing_event.dtend |             let original_duration_days = existing_event | ||||||
|  |                 .dtend | ||||||
|                 .map(|end| (end.date_naive() - existing_event.dtstart.date_naive()).num_days()) |                 .map(|end| (end.date_naive() - existing_event.dtstart.date_naive()).num_days()) | ||||||
|                 .unwrap_or(0); |                 .unwrap_or(0); | ||||||
|             start_date + chrono::Duration::days(original_duration_days) |             start_date + chrono::Duration::days(original_duration_days) | ||||||
| @@ -313,25 +396,32 @@ pub async fn update_event_series( | |||||||
|             start_date |             start_date | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         let end_dt = end_date.and_hms_opt(23, 59, 59) |         let end_dt = end_date | ||||||
|  |             .and_hms_opt(23, 59, 59) | ||||||
|             .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; |             .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; | ||||||
|  |  | ||||||
|         (chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt)) |         ( | ||||||
|  |             chrono::Utc.from_utc_datetime(&start_dt), | ||||||
|  |             chrono::Utc.from_utc_datetime(&end_dt), | ||||||
|  |         ) | ||||||
|     } else { |     } else { | ||||||
|         let start_time = if !request.start_time.is_empty() { |         let start_time = if !request.start_time.is_empty() { | ||||||
|             chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M") |             chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| { | ||||||
|                 .map_err(|_| ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string()))? |                 ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string()) | ||||||
|  |             })? | ||||||
|         } else { |         } else { | ||||||
|             existing_event.dtstart.time() |             existing_event.dtstart.time() | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         let end_time = if !request.end_time.is_empty() { |         let end_time = if !request.end_time.is_empty() { | ||||||
|             chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M") |             chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M").map_err(|_| { | ||||||
|                 .map_err(|_| ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string()))? |                 ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string()) | ||||||
|  |             })? | ||||||
|         } else { |         } else { | ||||||
|             existing_event.dtend.map(|dt| dt.time()).unwrap_or_else(|| { |             existing_event | ||||||
|                 existing_event.dtstart.time() + chrono::Duration::hours(1) |                 .dtend | ||||||
|             }) |                 .map(|dt| dt.time()) | ||||||
|  |                 .unwrap_or_else(|| existing_event.dtstart.time() + chrono::Duration::hours(1)) | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         let start_dt = start_date.and_time(start_time); |         let start_dt = start_date.and_time(start_time); | ||||||
| @@ -340,13 +430,17 @@ pub async fn update_event_series( | |||||||
|             start_date.and_time(end_time) |             start_date.and_time(end_time) | ||||||
|         } else { |         } else { | ||||||
|             // Calculate end time based on original duration |             // Calculate end time based on original duration | ||||||
|             let original_duration = existing_event.dtend |             let original_duration = existing_event | ||||||
|  |                 .dtend | ||||||
|                 .map(|end| end - existing_event.dtstart) |                 .map(|end| end - existing_event.dtstart) | ||||||
|                 .unwrap_or_else(|| chrono::Duration::hours(1)); |                 .unwrap_or_else(|| chrono::Duration::hours(1)); | ||||||
|             (chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc() |             (chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc() | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         (chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt)) |         ( | ||||||
|  |             chrono::Utc.from_utc_datetime(&start_dt), | ||||||
|  |             chrono::Utc.from_utc_datetime(&end_dt), | ||||||
|  |         ) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Handle different update scopes |     // Handle different update scopes | ||||||
| @@ -354,39 +448,73 @@ pub async fn update_event_series( | |||||||
|         "all_in_series" => { |         "all_in_series" => { | ||||||
|             // Update the entire series - modify the master event |             // Update the entire series - modify the master event | ||||||
|             update_entire_series(&mut existing_event, &request, start_datetime, end_datetime)? |             update_entire_series(&mut existing_event, &request, start_datetime, end_datetime)? | ||||||
|         }, |         } | ||||||
|         "this_and_future" => { |         "this_and_future" => { | ||||||
|             // Split the series: keep past occurrences, create new series from occurrence date |             // Split the series: keep past occurrences, create new series from occurrence date | ||||||
|             update_this_and_future(&mut existing_event, &request, start_datetime, end_datetime, &client, &calendar_path).await? |             update_this_and_future( | ||||||
|         }, |                 &mut existing_event, | ||||||
|  |                 &request, | ||||||
|  |                 start_datetime, | ||||||
|  |                 end_datetime, | ||||||
|  |                 &client, | ||||||
|  |                 &calendar_path, | ||||||
|  |             ) | ||||||
|  |             .await? | ||||||
|  |         } | ||||||
|         "this_only" => { |         "this_only" => { | ||||||
|             // Create exception for single occurrence, keep original series |             // Create exception for single occurrence, keep original series | ||||||
|             let event_href = existing_event.href.as_ref() |             let event_href = existing_event | ||||||
|                 .ok_or_else(|| ApiError::Internal("Event missing href for single occurrence update".to_string()))? |                 .href | ||||||
|  |                 .as_ref() | ||||||
|  |                 .ok_or_else(|| { | ||||||
|  |                     ApiError::Internal( | ||||||
|  |                         "Event missing href for single occurrence update".to_string(), | ||||||
|  |                     ) | ||||||
|  |                 })? | ||||||
|                 .clone(); |                 .clone(); | ||||||
|             update_single_occurrence(&mut existing_event, &request, start_datetime, end_datetime, &client, &calendar_path, &event_href).await? |             update_single_occurrence( | ||||||
|         }, |                 &mut existing_event, | ||||||
|  |                 &request, | ||||||
|  |                 start_datetime, | ||||||
|  |                 end_datetime, | ||||||
|  |                 &client, | ||||||
|  |                 &calendar_path, | ||||||
|  |                 &event_href, | ||||||
|  |             ) | ||||||
|  |             .await? | ||||||
|  |         } | ||||||
|         _ => unreachable!(), // Already validated above |         _ => unreachable!(), // Already validated above | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Update the event on the CalDAV server using the original event's href |     // Update the event on the CalDAV server using the original event's href | ||||||
|     println!("📤 Updating event on CalDAV server..."); |     println!("📤 Updating event on CalDAV server..."); | ||||||
|     let event_href = existing_event.href.as_ref() |     let event_href = existing_event | ||||||
|  |         .href | ||||||
|  |         .as_ref() | ||||||
|         .ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?; |         .ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?; | ||||||
|     println!("📤 Using event href: {}", event_href); |     println!("📤 Using event href: {}", event_href); | ||||||
|     println!("📤 Calendar path: {}", calendar_path); |     println!("📤 Calendar path: {}", calendar_path); | ||||||
|  |  | ||||||
|     match client.update_event(&calendar_path, &updated_event, event_href).await { |     match client | ||||||
|  |         .update_event(&calendar_path, &updated_event, event_href) | ||||||
|  |         .await | ||||||
|  |     { | ||||||
|         Ok(_) => { |         Ok(_) => { | ||||||
|             println!("✅ CalDAV update completed successfully"); |             println!("✅ CalDAV update completed successfully"); | ||||||
|         } |         } | ||||||
|         Err(e) => { |         Err(e) => { | ||||||
|             println!("❌ CalDAV update failed: {}", e); |             println!("❌ CalDAV update failed: {}", e); | ||||||
|             return Err(ApiError::Internal(format!("Failed to update event series: {}", e))); |             return Err(ApiError::Internal(format!( | ||||||
|  |                 "Failed to update event series: {}", | ||||||
|  |                 e | ||||||
|  |             ))); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     println!("✅ Event series updated successfully with UID: {}", request.series_uid); |     println!( | ||||||
|  |         "✅ Event series updated successfully with UID: {}", | ||||||
|  |         request.series_uid | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     Ok(Json(UpdateEventSeriesResponse { |     Ok(Json(UpdateEventSeriesResponse { | ||||||
|         success: true, |         success: true, | ||||||
| @@ -402,8 +530,10 @@ pub async fn delete_event_series( | |||||||
|     headers: HeaderMap, |     headers: HeaderMap, | ||||||
|     Json(request): Json<DeleteEventSeriesRequest>, |     Json(request): Json<DeleteEventSeriesRequest>, | ||||||
| ) -> Result<Json<DeleteEventSeriesResponse>, ApiError> { | ) -> Result<Json<DeleteEventSeriesResponse>, ApiError> { | ||||||
|     println!("🗑️ Delete event series request received: series_uid='{}', delete_scope='{}'",  |     println!( | ||||||
|              request.series_uid, request.delete_scope); |         "🗑️ Delete event series request received: series_uid='{}', delete_scope='{}'", | ||||||
|  |         request.series_uid, request.delete_scope | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     // Extract and verify token |     // Extract and verify token | ||||||
|     let token = extract_bearer_token(&headers)?; |     let token = extract_bearer_token(&headers)?; | ||||||
| @@ -415,7 +545,9 @@ pub async fn delete_event_series( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     if request.calendar_path.trim().is_empty() { |     if request.calendar_path.trim().is_empty() { | ||||||
|         return Err(ApiError::BadRequest("Calendar path is required".to_string())); |         return Err(ApiError::BadRequest( | ||||||
|  |             "Calendar path is required".to_string(), | ||||||
|  |         )); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if request.event_href.trim().is_empty() { |     if request.event_href.trim().is_empty() { | ||||||
| @@ -424,12 +556,19 @@ pub async fn delete_event_series( | |||||||
|  |  | ||||||
|     // Validate delete scope |     // Validate delete scope | ||||||
|     match request.delete_scope.as_str() { |     match request.delete_scope.as_str() { | ||||||
|         "this_only" | "this_and_future" | "all_in_series" => {}, |         "this_only" | "this_and_future" | "all_in_series" => {} | ||||||
|         _ => return Err(ApiError::BadRequest("Invalid delete_scope. Must be: this_only, this_and_future, or all_in_series".to_string())), |         _ => { | ||||||
|  |             return Err(ApiError::BadRequest( | ||||||
|  |                 "Invalid delete_scope. Must be: this_only, this_and_future, or all_in_series" | ||||||
|  |                     .to_string(), | ||||||
|  |             )) | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Create CalDAV config from token and password |     // Create CalDAV config from token and password | ||||||
|     let config = state.auth_service.caldav_config_from_token(&token, &password)?; |     let config = state | ||||||
|  |         .auth_service | ||||||
|  |         .caldav_config_from_token(&token, &password)?; | ||||||
|     let client = CalDAVClient::new(config); |     let client = CalDAVClient::new(config); | ||||||
|  |  | ||||||
|     // Handle different deletion scopes |     // Handle different deletion scopes | ||||||
| @@ -437,19 +576,22 @@ pub async fn delete_event_series( | |||||||
|         "all_in_series" => { |         "all_in_series" => { | ||||||
|             // Delete the entire series - simply delete the event |             // Delete the entire series - simply delete the event | ||||||
|             delete_entire_series(&client, &request).await? |             delete_entire_series(&client, &request).await? | ||||||
|         }, |         } | ||||||
|         "this_and_future" => { |         "this_and_future" => { | ||||||
|             // Modify RRULE to end before this occurrence |             // Modify RRULE to end before this occurrence | ||||||
|             delete_this_and_future(&client, &request).await? |             delete_this_and_future(&client, &request).await? | ||||||
|         }, |         } | ||||||
|         "this_only" => { |         "this_only" => { | ||||||
|             // Add EXDATE for single occurrence |             // Add EXDATE for single occurrence | ||||||
|             delete_single_occurrence(&client, &request).await? |             delete_single_occurrence(&client, &request).await? | ||||||
|         }, |         } | ||||||
|         _ => unreachable!(), // Already validated above |         _ => unreachable!(), // Already validated above | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     println!("✅ Event series deletion completed with {} occurrences affected", occurrences_affected); |     println!( | ||||||
|  |         "✅ Event series deletion completed with {} occurrences affected", | ||||||
|  |         occurrences_affected | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     Ok(Json(DeleteEventSeriesResponse { |     Ok(Json(DeleteEventSeriesResponse { | ||||||
|         success: true, |         success: true, | ||||||
| @@ -460,8 +602,10 @@ pub async fn delete_event_series( | |||||||
|  |  | ||||||
| // Helper functions | // Helper functions | ||||||
|  |  | ||||||
|  | fn build_series_rrule_with_freq( | ||||||
| fn build_series_rrule_with_freq(request: &CreateEventSeriesRequest, freq: &str) -> Result<String, ApiError> { |     request: &CreateEventSeriesRequest, | ||||||
|  |     freq: &str, | ||||||
|  | ) -> Result<String, ApiError> { | ||||||
|     let mut rrule_parts = Vec::new(); |     let mut rrule_parts = Vec::new(); | ||||||
|  |  | ||||||
|     // Add frequency |     // Add frequency | ||||||
| @@ -470,7 +614,11 @@ fn build_series_rrule_with_freq(request: &CreateEventSeriesRequest, freq: &str) | |||||||
|         "weekly" => rrule_parts.push("FREQ=WEEKLY".to_string()), |         "weekly" => rrule_parts.push("FREQ=WEEKLY".to_string()), | ||||||
|         "monthly" => rrule_parts.push("FREQ=MONTHLY".to_string()), |         "monthly" => rrule_parts.push("FREQ=MONTHLY".to_string()), | ||||||
|         "yearly" => rrule_parts.push("FREQ=YEARLY".to_string()), |         "yearly" => rrule_parts.push("FREQ=YEARLY".to_string()), | ||||||
|         _ => return Err(ApiError::BadRequest("Invalid recurrence frequency".to_string())), |         _ => { | ||||||
|  |             return Err(ApiError::BadRequest( | ||||||
|  |                 "Invalid recurrence frequency".to_string(), | ||||||
|  |             )) | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Add interval if specified and greater than 1 |     // Add interval if specified and greater than 1 | ||||||
| @@ -482,7 +630,8 @@ fn build_series_rrule_with_freq(request: &CreateEventSeriesRequest, freq: &str) | |||||||
|  |  | ||||||
|     // Handle weekly recurrence with specific days (BYDAY) |     // Handle weekly recurrence with specific days (BYDAY) | ||||||
|     if freq == "weekly" && request.recurrence_days.len() == 7 { |     if freq == "weekly" && request.recurrence_days.len() == 7 { | ||||||
|         let selected_days: Vec<&str> = request.recurrence_days |         let selected_days: Vec<&str> = request | ||||||
|  |             .recurrence_days | ||||||
|             .iter() |             .iter() | ||||||
|             .enumerate() |             .enumerate() | ||||||
|             .filter_map(|(i, &selected)| { |             .filter_map(|(i, &selected)| { | ||||||
| @@ -513,12 +662,17 @@ fn build_series_rrule_with_freq(request: &CreateEventSeriesRequest, freq: &str) | |||||||
|         // Parse the end date and convert to RRULE format (YYYYMMDDTHHMMSSZ) |         // Parse the end date and convert to RRULE format (YYYYMMDDTHHMMSSZ) | ||||||
|         match chrono::NaiveDate::parse_from_str(end_date, "%Y-%m-%d") { |         match chrono::NaiveDate::parse_from_str(end_date, "%Y-%m-%d") { | ||||||
|             Ok(date) => { |             Ok(date) => { | ||||||
|                 let end_datetime = date.and_hms_opt(23, 59, 59) |                 let end_datetime = date | ||||||
|  |                     .and_hms_opt(23, 59, 59) | ||||||
|                     .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; |                     .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; | ||||||
|                 let utc_end = chrono::Utc.from_utc_datetime(&end_datetime); |                 let utc_end = chrono::Utc.from_utc_datetime(&end_datetime); | ||||||
|                 rrule_parts.push(format!("UNTIL={}", utc_end.format("%Y%m%dT%H%M%SZ"))); |                 rrule_parts.push(format!("UNTIL={}", utc_end.format("%Y%m%dT%H%M%SZ"))); | ||||||
|             }, |             } | ||||||
|             Err(_) => return Err(ApiError::BadRequest("Invalid recurrence_end_date format. Expected YYYY-MM-DD".to_string())), |             Err(_) => { | ||||||
|  |                 return Err(ApiError::BadRequest( | ||||||
|  |                     "Invalid recurrence_end_date format. Expected YYYY-MM-DD".to_string(), | ||||||
|  |                 )) | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     } else if let Some(count) = request.recurrence_count { |     } else if let Some(count) = request.recurrence_count { | ||||||
|         if count > 0 { |         if count > 0 { | ||||||
| @@ -641,30 +795,42 @@ async fn update_this_and_future( | |||||||
|     client: &CalDAVClient, |     client: &CalDAVClient, | ||||||
|     calendar_path: &str, |     calendar_path: &str, | ||||||
| ) -> Result<(VEvent, u32), ApiError> { | ) -> Result<(VEvent, u32), ApiError> { | ||||||
|      |  | ||||||
|     // Clone the existing event to create the new series before modifying the RRULE of the |     // Clone the existing event to create the new series before modifying the RRULE of the | ||||||
|     // original, because we'd like to preserve the original UNTIL logic |     // original, because we'd like to preserve the original UNTIL logic | ||||||
|     let mut new_series = existing_event.clone(); |     let mut new_series = existing_event.clone(); | ||||||
|     let occurrence_date = request.occurrence_date.as_ref() |     let occurrence_date = request.occurrence_date.as_ref().ok_or_else(|| { | ||||||
|         .ok_or_else(|| ApiError::BadRequest("occurrence_date is required for this_and_future updates".to_string()))?; |         ApiError::BadRequest("occurrence_date is required for this_and_future updates".to_string()) | ||||||
|  |     })?; | ||||||
|  |  | ||||||
|     // Parse occurrence date |     // Parse occurrence date | ||||||
|     let occurrence_date_parsed = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") |     let occurrence_date_parsed = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") | ||||||
|         .map_err(|_| ApiError::BadRequest("Invalid occurrence date format".to_string()))?; |         .map_err(|_| ApiError::BadRequest("Invalid occurrence date format".to_string()))?; | ||||||
|  |  | ||||||
|     // Step 1: Add UNTIL to the original series to stop before the occurrence date |     // Step 1: Add UNTIL to the original series to stop before the occurrence date | ||||||
|     let until_datetime = occurrence_date_parsed.and_hms_opt(0, 0, 0) |     let until_datetime = occurrence_date_parsed | ||||||
|  |         .and_hms_opt(0, 0, 0) | ||||||
|         .ok_or_else(|| ApiError::BadRequest("Invalid occurrence date".to_string()))?; |         .ok_or_else(|| ApiError::BadRequest("Invalid occurrence date".to_string()))?; | ||||||
|     let utc_until = chrono::Utc.from_utc_datetime(&until_datetime); |     let utc_until = chrono::Utc.from_utc_datetime(&until_datetime); | ||||||
|  |  | ||||||
|     // Create modified RRULE with UNTIL clause for the original series |     // Create modified RRULE with UNTIL clause for the original series | ||||||
|     let original_rrule = existing_event.rrule.clone().unwrap_or_else(|| "FREQ=WEEKLY".to_string()); |     let original_rrule = existing_event | ||||||
|     let parts: Vec<&str> = original_rrule.split(';').filter(|part| { |         .rrule | ||||||
|         !part.starts_with("UNTIL=") && !part.starts_with("COUNT=") |         .clone() | ||||||
|     }).collect(); |         .unwrap_or_else(|| "FREQ=WEEKLY".to_string()); | ||||||
|  |     let parts: Vec<&str> = original_rrule | ||||||
|  |         .split(';') | ||||||
|  |         .filter(|part| !part.starts_with("UNTIL=") && !part.starts_with("COUNT=")) | ||||||
|  |         .collect(); | ||||||
|  |  | ||||||
|     existing_event.rrule = Some(format!("{};UNTIL={}", parts.join(";"), utc_until.format("%Y%m%dT%H%M%SZ"))); |     existing_event.rrule = Some(format!( | ||||||
|     println!("🔄 this_and_future: Updated original series RRULE: {:?}", existing_event.rrule); |         "{};UNTIL={}", | ||||||
|  |         parts.join(";"), | ||||||
|  |         utc_until.format("%Y%m%dT%H%M%SZ") | ||||||
|  |     )); | ||||||
|  |     println!( | ||||||
|  |         "🔄 this_and_future: Updated original series RRULE: {:?}", | ||||||
|  |         existing_event.rrule | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     // Step 2: Create a new series starting from the occurrence date with updated properties |     // Step 2: Create a new series starting from the occurrence date with updated properties | ||||||
|     let new_series_uid = format!("series-{}", uuid::Uuid::new_v4()); |     let new_series_uid = format!("series-{}", uuid::Uuid::new_v4()); | ||||||
| @@ -673,9 +839,21 @@ async fn update_this_and_future( | |||||||
|     new_series.uid = new_series_uid.clone(); |     new_series.uid = new_series_uid.clone(); | ||||||
|     new_series.dtstart = start_datetime; |     new_series.dtstart = start_datetime; | ||||||
|     new_series.dtend = Some(end_datetime); |     new_series.dtend = Some(end_datetime); | ||||||
|     new_series.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) }; |     new_series.summary = if request.title.trim().is_empty() { | ||||||
|     new_series.description = if request.description.trim().is_empty() { None } else { Some(request.description.clone()) }; |         None | ||||||
|     new_series.location = if request.location.trim().is_empty() { None } else { Some(request.location.clone()) }; |     } else { | ||||||
|  |         Some(request.title.clone()) | ||||||
|  |     }; | ||||||
|  |     new_series.description = if request.description.trim().is_empty() { | ||||||
|  |         None | ||||||
|  |     } else { | ||||||
|  |         Some(request.description.clone()) | ||||||
|  |     }; | ||||||
|  |     new_series.location = if request.location.trim().is_empty() { | ||||||
|  |         None | ||||||
|  |     } else { | ||||||
|  |         Some(request.location.clone()) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|     new_series.status = Some(match request.status.to_lowercase().as_str() { |     new_series.status = Some(match request.status.to_lowercase().as_str() { | ||||||
|         "tentative" => EventStatus::Tentative, |         "tentative" => EventStatus::Tentative, | ||||||
| @@ -698,11 +876,18 @@ async fn update_this_and_future( | |||||||
|     new_series.last_modified = Some(now); |     new_series.last_modified = Some(now); | ||||||
|     new_series.href = None; // Will be set when created |     new_series.href = None; // Will be set when created | ||||||
|  |  | ||||||
|     println!("🔄 this_and_future: Creating new series with UID: {}", new_series_uid); |     println!( | ||||||
|     println!("🔄 this_and_future: New series RRULE: {:?}", new_series.rrule); |         "🔄 this_and_future: Creating new series with UID: {}", | ||||||
|  |         new_series_uid | ||||||
|  |     ); | ||||||
|  |     println!( | ||||||
|  |         "🔄 this_and_future: New series RRULE: {:?}", | ||||||
|  |         new_series.rrule | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     // Create the new series on CalDAV server |     // Create the new series on CalDAV server | ||||||
|     client.create_event(calendar_path, &new_series) |     client | ||||||
|  |         .create_event(calendar_path, &new_series) | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| ApiError::Internal(format!("Failed to create new series: {}", e)))?; |         .map_err(|e| ApiError::Internal(format!("Failed to create new series: {}", e)))?; | ||||||
|  |  | ||||||
| @@ -727,12 +912,17 @@ async fn update_single_occurrence( | |||||||
|     // 2. Create a new exception event with RECURRENCE-ID pointing to the original occurrence |     // 2. Create a new exception event with RECURRENCE-ID pointing to the original occurrence | ||||||
|  |  | ||||||
|     // First, add EXDATE to the original series |     // First, add EXDATE to the original series | ||||||
|     let occurrence_date = request.occurrence_date.as_ref() |     let occurrence_date = request.occurrence_date.as_ref().ok_or_else(|| { | ||||||
|         .ok_or_else(|| ApiError::BadRequest("occurrence_date is required for single occurrence updates".to_string()))?; |         ApiError::BadRequest( | ||||||
|  |             "occurrence_date is required for single occurrence updates".to_string(), | ||||||
|  |         ) | ||||||
|  |     })?; | ||||||
|  |  | ||||||
|     // Parse the occurrence date |     // Parse the occurrence date | ||||||
|     let exception_date = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") |     let exception_date = | ||||||
|         .map_err(|_| ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()))?; |         chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d").map_err(|_| { | ||||||
|  |             ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()) | ||||||
|  |         })?; | ||||||
|  |  | ||||||
|     // Create the EXDATE datetime using the original event's time |     // Create the EXDATE datetime using the original event's time | ||||||
|     let original_time = existing_event.dtstart.time(); |     let original_time = existing_event.dtstart.time(); | ||||||
| @@ -740,10 +930,19 @@ async fn update_single_occurrence( | |||||||
|     let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime); |     let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime); | ||||||
|  |  | ||||||
|     // Add the exception date to the original series |     // Add the exception date to the original series | ||||||
|     println!("📝 BEFORE adding EXDATE: existing_event.exdate = {:?}", existing_event.exdate); |     println!( | ||||||
|  |         "📝 BEFORE adding EXDATE: existing_event.exdate = {:?}", | ||||||
|  |         existing_event.exdate | ||||||
|  |     ); | ||||||
|     existing_event.exdate.push(exception_utc); |     existing_event.exdate.push(exception_utc); | ||||||
|     println!("📝 AFTER adding EXDATE: existing_event.exdate = {:?}", existing_event.exdate); |     println!( | ||||||
|     println!("🚫 Added EXDATE for single occurrence modification: {}", exception_utc.format("%Y-%m-%d %H:%M:%S")); |         "📝 AFTER adding EXDATE: existing_event.exdate = {:?}", | ||||||
|  |         existing_event.exdate | ||||||
|  |     ); | ||||||
|  |     println!( | ||||||
|  |         "🚫 Added EXDATE for single occurrence modification: {}", | ||||||
|  |         exception_utc.format("%Y-%m-%d %H:%M:%S") | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     // Create exception event by cloning the existing event to preserve all metadata |     // Create exception event by cloning the existing event to preserve all metadata | ||||||
|     let mut exception_event = existing_event.clone(); |     let mut exception_event = existing_event.clone(); | ||||||
| @@ -801,10 +1000,14 @@ async fn update_single_occurrence( | |||||||
|     // Set calendar path for the exception event |     // Set calendar path for the exception event | ||||||
|     exception_event.calendar_path = Some(calendar_path.to_string()); |     exception_event.calendar_path = Some(calendar_path.to_string()); | ||||||
|  |  | ||||||
|     println!("✨ Created exception event with RECURRENCE-ID: {}", exception_utc.format("%Y-%m-%d %H:%M:%S")); |     println!( | ||||||
|  |         "✨ Created exception event with RECURRENCE-ID: {}", | ||||||
|  |         exception_utc.format("%Y-%m-%d %H:%M:%S") | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     // Create the exception event as a new event (original series will be updated by main handler) |     // Create the exception event as a new event (original series will be updated by main handler) | ||||||
|     client.create_event(calendar_path, &exception_event) |     client | ||||||
|  |         .create_event(calendar_path, &exception_event) | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| ApiError::Internal(format!("Failed to create exception event: {}", e)))?; |         .map_err(|e| ApiError::Internal(format!("Failed to create exception event: {}", e)))?; | ||||||
|  |  | ||||||
| @@ -820,7 +1023,8 @@ async fn delete_entire_series( | |||||||
|     request: &DeleteEventSeriesRequest, |     request: &DeleteEventSeriesRequest, | ||||||
| ) -> Result<u32, ApiError> { | ) -> Result<u32, ApiError> { | ||||||
|     // Simply delete the entire event from the CalDAV server |     // Simply delete the entire event from the CalDAV server | ||||||
|     client.delete_event(&request.calendar_path, &request.event_href) |     client | ||||||
|  |         .delete_event(&request.calendar_path, &request.event_href) | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| ApiError::Internal(format!("Failed to delete event series: {}", e)))?; |         .map_err(|e| ApiError::Internal(format!("Failed to delete event series: {}", e)))?; | ||||||
|  |  | ||||||
| @@ -835,10 +1039,13 @@ async fn delete_this_and_future( | |||||||
| ) -> Result<u32, ApiError> { | ) -> Result<u32, ApiError> { | ||||||
|     // Fetch the existing event to modify its RRULE |     // Fetch the existing event to modify its RRULE | ||||||
|     let event_uid = request.series_uid.clone(); |     let event_uid = request.series_uid.clone(); | ||||||
|     let existing_event = client.fetch_event_by_uid(&request.calendar_path, &event_uid) |     let existing_event = client | ||||||
|  |         .fetch_event_by_uid(&request.calendar_path, &event_uid) | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| ApiError::Internal(format!("Failed to fetch event for modification: {}", e)))? |         .map_err(|e| ApiError::Internal(format!("Failed to fetch event for modification: {}", e)))? | ||||||
|         .ok_or_else(|| ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid)))?; |         .ok_or_else(|| { | ||||||
|  |             ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid)) | ||||||
|  |         })?; | ||||||
|  |  | ||||||
|     // If no occurrence_date is provided, delete the entire series |     // If no occurrence_date is provided, delete the entire series | ||||||
|     let Some(occurrence_date) = &request.occurrence_date else { |     let Some(occurrence_date) = &request.occurrence_date else { | ||||||
| @@ -846,12 +1053,17 @@ async fn delete_this_and_future( | |||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Parse occurrence date to set as UNTIL for the RRULE |     // Parse occurrence date to set as UNTIL for the RRULE | ||||||
|     let until_date = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") |     let until_date = | ||||||
|         .map_err(|_| ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()))?; |         chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d").map_err(|_| { | ||||||
|  |             ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()) | ||||||
|  |         })?; | ||||||
|  |  | ||||||
|     // Set UNTIL to the day before the occurrence to exclude it and all future occurrences |     // Set UNTIL to the day before the occurrence to exclude it and all future occurrences | ||||||
|     let until_datetime = until_date.pred_opt() |     let until_datetime = until_date | ||||||
|         .ok_or_else(|| ApiError::BadRequest("Cannot delete from the first possible date".to_string()))? |         .pred_opt() | ||||||
|  |         .ok_or_else(|| { | ||||||
|  |             ApiError::BadRequest("Cannot delete from the first possible date".to_string()) | ||||||
|  |         })? | ||||||
|         .and_hms_opt(23, 59, 59) |         .and_hms_opt(23, 59, 59) | ||||||
|         .ok_or_else(|| ApiError::BadRequest("Invalid date calculation".to_string()))?; |         .ok_or_else(|| ApiError::BadRequest("Invalid date calculation".to_string()))?; | ||||||
|     let utc_until = chrono::Utc.from_utc_datetime(&until_datetime); |     let utc_until = chrono::Utc.from_utc_datetime(&until_datetime); | ||||||
| @@ -860,19 +1072,30 @@ async fn delete_this_and_future( | |||||||
|     let mut updated_event = existing_event; |     let mut updated_event = existing_event; | ||||||
|     if let Some(rrule) = &updated_event.rrule { |     if let Some(rrule) = &updated_event.rrule { | ||||||
|         // Remove existing UNTIL or COUNT if present and add new UNTIL |         // Remove existing UNTIL or COUNT if present and add new UNTIL | ||||||
|         let parts: Vec<&str> = rrule.split(';').filter(|part| { |         let parts: Vec<&str> = rrule | ||||||
|             !part.starts_with("UNTIL=") && !part.starts_with("COUNT=") |             .split(';') | ||||||
|         }).collect(); |             .filter(|part| !part.starts_with("UNTIL=") && !part.starts_with("COUNT=")) | ||||||
|  |             .collect(); | ||||||
|  |  | ||||||
|         updated_event.rrule = Some(format!("{};UNTIL={}", parts.join(";"), utc_until.format("%Y%m%dT%H%M%SZ"))); |         updated_event.rrule = Some(format!( | ||||||
|  |             "{};UNTIL={}", | ||||||
|  |             parts.join(";"), | ||||||
|  |             utc_until.format("%Y%m%dT%H%M%SZ") | ||||||
|  |         )); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Update the event on the CalDAV server |     // Update the event on the CalDAV server | ||||||
|     client.update_event(&request.calendar_path, &updated_event, &request.event_href) |     client | ||||||
|  |         .update_event(&request.calendar_path, &updated_event, &request.event_href) | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| ApiError::Internal(format!("Failed to update event series for deletion: {}", e)))?; |         .map_err(|e| { | ||||||
|  |             ApiError::Internal(format!("Failed to update event series for deletion: {}", e)) | ||||||
|  |         })?; | ||||||
|  |  | ||||||
|     println!("🗑️ Series modified with UNTIL for this_and_future deletion: {}", utc_until.format("%Y-%m-%d")); |     println!( | ||||||
|  |         "🗑️ Series modified with UNTIL for this_and_future deletion: {}", | ||||||
|  |         utc_until.format("%Y-%m-%d") | ||||||
|  |     ); | ||||||
|     Ok(1) // 1 series modified |     Ok(1) // 1 series modified | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -883,19 +1106,26 @@ async fn delete_single_occurrence( | |||||||
| ) -> Result<u32, ApiError> { | ) -> Result<u32, ApiError> { | ||||||
|     // Fetch the existing event to add EXDATE |     // Fetch the existing event to add EXDATE | ||||||
|     let event_uid = request.series_uid.clone(); |     let event_uid = request.series_uid.clone(); | ||||||
|     let existing_event = client.fetch_event_by_uid(&request.calendar_path, &event_uid) |     let existing_event = client | ||||||
|  |         .fetch_event_by_uid(&request.calendar_path, &event_uid) | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| ApiError::Internal(format!("Failed to fetch event for modification: {}", e)))? |         .map_err(|e| ApiError::Internal(format!("Failed to fetch event for modification: {}", e)))? | ||||||
|         .ok_or_else(|| ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid)))?; |         .ok_or_else(|| { | ||||||
|  |             ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid)) | ||||||
|  |         })?; | ||||||
|  |  | ||||||
|     // If no occurrence_date is provided, cannot delete single occurrence |     // If no occurrence_date is provided, cannot delete single occurrence | ||||||
|     let Some(occurrence_date) = &request.occurrence_date else { |     let Some(occurrence_date) = &request.occurrence_date else { | ||||||
|         return Err(ApiError::BadRequest("occurrence_date is required for single occurrence deletion".to_string())); |         return Err(ApiError::BadRequest( | ||||||
|  |             "occurrence_date is required for single occurrence deletion".to_string(), | ||||||
|  |         )); | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Parse occurrence date |     // Parse occurrence date | ||||||
|     let exception_date = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") |     let exception_date = | ||||||
|         .map_err(|_| ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()))?; |         chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d").map_err(|_| { | ||||||
|  |             ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()) | ||||||
|  |         })?; | ||||||
|  |  | ||||||
|     // Create the EXDATE datetime (use the same time as the original event) |     // Create the EXDATE datetime (use the same time as the original event) | ||||||
|     let original_time = existing_event.dtstart.time(); |     let original_time = existing_event.dtstart.time(); | ||||||
| @@ -906,12 +1136,21 @@ async fn delete_single_occurrence( | |||||||
|     let mut updated_event = existing_event; |     let mut updated_event = existing_event; | ||||||
|     updated_event.exdate.push(exception_utc); |     updated_event.exdate.push(exception_utc); | ||||||
|  |  | ||||||
|     println!("🗑️ Added EXDATE for single occurrence deletion: {}", exception_utc.format("%Y%m%dT%H%M%SZ")); |     println!( | ||||||
|  |         "🗑️ Added EXDATE for single occurrence deletion: {}", | ||||||
|  |         exception_utc.format("%Y%m%dT%H%M%SZ") | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     // Update the event on the CalDAV server |     // Update the event on the CalDAV server | ||||||
|     client.update_event(&request.calendar_path, &updated_event, &request.event_href) |     client | ||||||
|  |         .update_event(&request.calendar_path, &updated_event, &request.event_href) | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| ApiError::Internal(format!("Failed to update event series for single deletion: {}", e)))?; |         .map_err(|e| { | ||||||
|  |             ApiError::Internal(format!( | ||||||
|  |                 "Failed to update event series for single deletion: {}", | ||||||
|  |                 e | ||||||
|  |             )) | ||||||
|  |         })?; | ||||||
|  |  | ||||||
|     Ok(1) // 1 occurrence excluded |     Ok(1) // 1 occurrence excluded | ||||||
| } | } | ||||||
|   | |||||||
| @@ -3,33 +3,43 @@ use axum::{ | |||||||
|     routing::{get, post}, |     routing::{get, post}, | ||||||
|     Router, |     Router, | ||||||
| }; | }; | ||||||
| use tower_http::cors::{CorsLayer, Any}; |  | ||||||
| use std::sync::Arc; | use std::sync::Arc; | ||||||
|  | use tower_http::cors::{Any, CorsLayer}; | ||||||
|  |  | ||||||
| pub mod auth; | pub mod auth; | ||||||
| pub mod models; |  | ||||||
| pub mod handlers; |  | ||||||
| pub mod calendar; | pub mod calendar; | ||||||
| pub mod config; | pub mod config; | ||||||
|  | pub mod db; | ||||||
|  | pub mod handlers; | ||||||
|  | pub mod models; | ||||||
|  |  | ||||||
| use auth::AuthService; | use auth::AuthService; | ||||||
|  | use db::Database; | ||||||
|  |  | ||||||
| #[derive(Clone)] | #[derive(Clone)] | ||||||
| pub struct AppState { | pub struct AppState { | ||||||
|     pub auth_service: AuthService, |     pub auth_service: AuthService, | ||||||
|  |     pub db: Database, | ||||||
| } | } | ||||||
|  |  | ||||||
| pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> { | pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> { | ||||||
|     // Initialize logging |     // Initialize logging | ||||||
|     println!("🚀 Starting Calendar Backend Server"); |     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 |     // Create auth service | ||||||
|     let jwt_secret = std::env::var("JWT_SECRET") |     let jwt_secret = std::env::var("JWT_SECRET") | ||||||
|         .unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string()); |         .unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string()); | ||||||
|  |  | ||||||
|     let auth_service = AuthService::new(jwt_secret); |     let auth_service = AuthService::new(jwt_secret, db.clone()); | ||||||
|  |  | ||||||
|     let app_state = AppState { auth_service }; |     let app_state = AppState { auth_service, db }; | ||||||
|  |  | ||||||
|     // Build our application with routes |     // Build our application with routes | ||||||
|     let app = Router::new() |     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/delete", post(handlers::delete_event)) | ||||||
|         .route("/api/calendar/events/:uid", get(handlers::refresh_event)) |         .route("/api/calendar/events/:uid", get(handlers::refresh_event)) | ||||||
|         // Event series-specific endpoints |         // Event series-specific endpoints | ||||||
|         .route("/api/calendar/events/series/create", post(handlers::create_event_series)) |         .route( | ||||||
|         .route("/api/calendar/events/series/update", post(handlers::update_event_series)) |             "/api/calendar/events/series/create", | ||||||
|         .route("/api/calendar/events/series/delete", post(handlers::delete_event_series)) |             post(handlers::create_event_series), | ||||||
|  |         ) | ||||||
|  |         .route( | ||||||
|  |             "/api/calendar/events/series/update", | ||||||
|  |             post(handlers::update_event_series), | ||||||
|  |         ) | ||||||
|  |         .route( | ||||||
|  |             "/api/calendar/events/series/delete", | ||||||
|  |             post(handlers::delete_event_series), | ||||||
|  |         ) | ||||||
|  |         // 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( |         .layer( | ||||||
|             CorsLayer::new() |             CorsLayer::new() | ||||||
|                 .allow_origin(Any) |                 .allow_origin(Any) | ||||||
|   | |||||||
| @@ -16,8 +16,28 @@ pub struct CalDAVLoginRequest { | |||||||
| #[derive(Debug, Serialize)] | #[derive(Debug, Serialize)] | ||||||
| pub struct AuthResponse { | pub struct AuthResponse { | ||||||
|     pub token: String, |     pub token: String, | ||||||
|  |     pub session_token: String, | ||||||
|     pub username: String, |     pub username: String, | ||||||
|     pub server_url: 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)] | #[derive(Debug, Serialize)] | ||||||
| @@ -201,6 +221,7 @@ pub struct UpdateEventSeriesRequest { | |||||||
|     // Update scope control |     // Update scope control | ||||||
|     pub update_scope: String, // "this_only", "this_and_future", "all_in_series" |     pub update_scope: String, // "this_only", "this_and_future", "all_in_series" | ||||||
|     pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated |     pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated | ||||||
|  |     pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization) | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Debug, Serialize)] | #[derive(Debug, Serialize)] | ||||||
|   | |||||||
| @@ -1,16 +1,16 @@ | |||||||
| use calendar_backend::AppState; |  | ||||||
| use calendar_backend::auth::AuthService; |  | ||||||
| use reqwest::Client; |  | ||||||
| use serde_json::json; |  | ||||||
| use std::time::Duration; |  | ||||||
| use tokio::time::sleep; |  | ||||||
| use axum::{ | use axum::{ | ||||||
|     response::Json, |     response::Json, | ||||||
|     routing::{get, post}, |     routing::{get, post}, | ||||||
|     Router, |     Router, | ||||||
| }; | }; | ||||||
| use tower_http::cors::{CorsLayer, Any}; | use calendar_backend::auth::AuthService; | ||||||
|  | use calendar_backend::AppState; | ||||||
|  | use reqwest::Client; | ||||||
|  | use serde_json::json; | ||||||
| use std::sync::Arc; | use std::sync::Arc; | ||||||
|  | use std::time::Duration; | ||||||
|  | use tokio::time::sleep; | ||||||
|  | use tower_http::cors::{Any, CorsLayer}; | ||||||
|  |  | ||||||
| /// Test utilities for integration testing | /// Test utilities for integration testing | ||||||
| mod test_utils { | mod test_utils { | ||||||
| @@ -33,19 +33,55 @@ mod test_utils { | |||||||
|                 .route("/", get(root)) |                 .route("/", get(root)) | ||||||
|                 .route("/api/health", get(health_check)) |                 .route("/api/health", get(health_check)) | ||||||
|                 .route("/api/auth/login", post(calendar_backend::handlers::login)) |                 .route("/api/auth/login", post(calendar_backend::handlers::login)) | ||||||
|                 .route("/api/auth/verify", get(calendar_backend::handlers::verify_token)) |                 .route( | ||||||
|                 .route("/api/user/info", get(calendar_backend::handlers::get_user_info)) |                     "/api/auth/verify", | ||||||
|                 .route("/api/calendar/create", post(calendar_backend::handlers::create_calendar)) |                     get(calendar_backend::handlers::verify_token), | ||||||
|                 .route("/api/calendar/delete", post(calendar_backend::handlers::delete_calendar)) |                 ) | ||||||
|                 .route("/api/calendar/events", get(calendar_backend::handlers::get_calendar_events)) |                 .route( | ||||||
|                 .route("/api/calendar/events/create", post(calendar_backend::handlers::create_event)) |                     "/api/user/info", | ||||||
|                 .route("/api/calendar/events/update", post(calendar_backend::handlers::update_event)) |                     get(calendar_backend::handlers::get_user_info), | ||||||
|                 .route("/api/calendar/events/delete", post(calendar_backend::handlers::delete_event)) |                 ) | ||||||
|                 .route("/api/calendar/events/:uid", get(calendar_backend::handlers::refresh_event)) |                 .route( | ||||||
|  |                     "/api/calendar/create", | ||||||
|  |                     post(calendar_backend::handlers::create_calendar), | ||||||
|  |                 ) | ||||||
|  |                 .route( | ||||||
|  |                     "/api/calendar/delete", | ||||||
|  |                     post(calendar_backend::handlers::delete_calendar), | ||||||
|  |                 ) | ||||||
|  |                 .route( | ||||||
|  |                     "/api/calendar/events", | ||||||
|  |                     get(calendar_backend::handlers::get_calendar_events), | ||||||
|  |                 ) | ||||||
|  |                 .route( | ||||||
|  |                     "/api/calendar/events/create", | ||||||
|  |                     post(calendar_backend::handlers::create_event), | ||||||
|  |                 ) | ||||||
|  |                 .route( | ||||||
|  |                     "/api/calendar/events/update", | ||||||
|  |                     post(calendar_backend::handlers::update_event), | ||||||
|  |                 ) | ||||||
|  |                 .route( | ||||||
|  |                     "/api/calendar/events/delete", | ||||||
|  |                     post(calendar_backend::handlers::delete_event), | ||||||
|  |                 ) | ||||||
|  |                 .route( | ||||||
|  |                     "/api/calendar/events/:uid", | ||||||
|  |                     get(calendar_backend::handlers::refresh_event), | ||||||
|  |                 ) | ||||||
|                 // Event series-specific endpoints |                 // Event series-specific endpoints | ||||||
|                 .route("/api/calendar/events/series/create", post(calendar_backend::handlers::create_event_series)) |                 .route( | ||||||
|                 .route("/api/calendar/events/series/update", post(calendar_backend::handlers::update_event_series)) |                     "/api/calendar/events/series/create", | ||||||
|                 .route("/api/calendar/events/series/delete", post(calendar_backend::handlers::delete_event_series)) |                     post(calendar_backend::handlers::create_event_series), | ||||||
|  |                 ) | ||||||
|  |                 .route( | ||||||
|  |                     "/api/calendar/events/series/update", | ||||||
|  |                     post(calendar_backend::handlers::update_event_series), | ||||||
|  |                 ) | ||||||
|  |                 .route( | ||||||
|  |                     "/api/calendar/events/series/delete", | ||||||
|  |                     post(calendar_backend::handlers::delete_event_series), | ||||||
|  |                 ) | ||||||
|                 .layer( |                 .layer( | ||||||
|                     CorsLayer::new() |                     CorsLayer::new() | ||||||
|                         .allow_origin(Any) |                         .allow_origin(Any) | ||||||
| @@ -72,22 +108,30 @@ mod test_utils { | |||||||
|  |  | ||||||
|         pub async fn login(&self) -> String { |         pub async fn login(&self) -> String { | ||||||
|             let login_payload = json!({ |             let login_payload = json!({ | ||||||
|                 "username": std::env::var("CALDAV_USERNAME").unwrap_or("test".to_string()), |                 "username": "test".to_string(), | ||||||
|                 "password": std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()), |                 "password": "test".to_string(), | ||||||
|                 "server_url": std::env::var("CALDAV_SERVER_URL").unwrap_or("https://example.com".to_string()) |                 "server_url": "https://example.com".to_string() | ||||||
|             }); |             }); | ||||||
|  |  | ||||||
|             let response = self.client |             let response = self | ||||||
|  |                 .client | ||||||
|                 .post(&format!("{}/api/auth/login", self.base_url)) |                 .post(&format!("{}/api/auth/login", self.base_url)) | ||||||
|                 .json(&login_payload) |                 .json(&login_payload) | ||||||
|                 .send() |                 .send() | ||||||
|                 .await |                 .await | ||||||
|                 .expect("Failed to send login request"); |                 .expect("Failed to send login request"); | ||||||
|  |  | ||||||
|             assert!(response.status().is_success(), "Login failed with status: {}", response.status()); |             assert!( | ||||||
|  |                 response.status().is_success(), | ||||||
|  |                 "Login failed with status: {}", | ||||||
|  |                 response.status() | ||||||
|  |             ); | ||||||
|  |  | ||||||
|             let login_response: serde_json::Value = response.json().await.unwrap(); |             let login_response: serde_json::Value = response.json().await.unwrap(); | ||||||
|             login_response["token"].as_str().expect("Login response should contain token").to_string() |             login_response["token"] | ||||||
|  |                 .as_str() | ||||||
|  |                 .expect("Login response should contain token") | ||||||
|  |                 .to_string() | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -106,15 +150,16 @@ mod test_utils { | |||||||
|  |  | ||||||
| #[cfg(test)] | #[cfg(test)] | ||||||
| mod tests { | mod tests { | ||||||
|     use super::*; |  | ||||||
|     use super::test_utils::*; |     use super::test_utils::*; | ||||||
|  |     use super::*; | ||||||
|  |  | ||||||
|     /// Test the health endpoint |     /// Test the health endpoint | ||||||
|     #[tokio::test] |     #[tokio::test] | ||||||
|     async fn test_health_endpoint() { |     async fn test_health_endpoint() { | ||||||
|         let server = TestServer::start().await; |         let server = TestServer::start().await; | ||||||
|  |  | ||||||
|         let response = server.client |         let response = server | ||||||
|  |             .client | ||||||
|             .get(&format!("{}/api/health", server.base_url)) |             .get(&format!("{}/api/health", server.base_url)) | ||||||
|             .send() |             .send() | ||||||
|             .await |             .await | ||||||
| @@ -134,12 +179,10 @@ mod tests { | |||||||
|     async fn test_auth_login() { |     async fn test_auth_login() { | ||||||
|         let server = TestServer::start().await; |         let server = TestServer::start().await; | ||||||
|  |  | ||||||
|         // Load credentials from .env  |         // Use test credentials | ||||||
|         dotenvy::dotenv().ok(); |         let username = "test".to_string(); | ||||||
|         let username = std::env::var("CALDAV_USERNAME").unwrap_or("test".to_string()); |         let password = "test".to_string(); | ||||||
|         let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()); |         let server_url = "https://example.com".to_string(); | ||||||
|          |  | ||||||
|         let server_url = std::env::var("CALDAV_SERVER_URL").unwrap_or("https://example.com".to_string()); |  | ||||||
|  |  | ||||||
|         let login_payload = json!({ |         let login_payload = json!({ | ||||||
|             "username": username, |             "username": username, | ||||||
| @@ -147,18 +190,29 @@ mod tests { | |||||||
|             "server_url": server_url |             "server_url": server_url | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         let response = server.client |         let response = server | ||||||
|  |             .client | ||||||
|             .post(&format!("{}/api/auth/login", server.base_url)) |             .post(&format!("{}/api/auth/login", server.base_url)) | ||||||
|             .json(&login_payload) |             .json(&login_payload) | ||||||
|             .send() |             .send() | ||||||
|             .await |             .await | ||||||
|             .unwrap(); |             .unwrap(); | ||||||
|  |  | ||||||
|         assert!(response.status().is_success(), "Login failed with status: {}", response.status()); |         assert!( | ||||||
|  |             response.status().is_success(), | ||||||
|  |             "Login failed with status: {}", | ||||||
|  |             response.status() | ||||||
|  |         ); | ||||||
|  |  | ||||||
|         let login_response: serde_json::Value = response.json().await.unwrap(); |         let login_response: serde_json::Value = response.json().await.unwrap(); | ||||||
|         assert!(login_response["token"].is_string(), "Login response should contain a token"); |         assert!( | ||||||
|         assert!(login_response["username"].is_string(), "Login response should contain username"); |             login_response["token"].is_string(), | ||||||
|  |             "Login response should contain a token" | ||||||
|  |         ); | ||||||
|  |         assert!( | ||||||
|  |             login_response["username"].is_string(), | ||||||
|  |             "Login response should contain username" | ||||||
|  |         ); | ||||||
|  |  | ||||||
|         println!("✓ Authentication login test passed"); |         println!("✓ Authentication login test passed"); | ||||||
|     } |     } | ||||||
| @@ -171,7 +225,8 @@ mod tests { | |||||||
|         // First login to get a token |         // First login to get a token | ||||||
|         let token = server.login().await; |         let token = server.login().await; | ||||||
|  |  | ||||||
|         let response = server.client |         let response = server | ||||||
|  |             .client | ||||||
|             .get(&format!("{}/api/auth/verify", server.base_url)) |             .get(&format!("{}/api/auth/verify", server.base_url)) | ||||||
|             .header("Authorization", format!("Bearer {}", token)) |             .header("Authorization", format!("Bearer {}", token)) | ||||||
|             .send() |             .send() | ||||||
| @@ -196,9 +251,10 @@ mod tests { | |||||||
|  |  | ||||||
|         // Load password from env for CalDAV requests |         // Load password from env for CalDAV requests | ||||||
|         dotenvy::dotenv().ok(); |         dotenvy::dotenv().ok(); | ||||||
|         let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()); |         let password = "test".to_string(); | ||||||
|  |  | ||||||
|         let response = server.client |         let response = server | ||||||
|  |             .client | ||||||
|             .get(&format!("{}/api/user/info", server.base_url)) |             .get(&format!("{}/api/user/info", server.base_url)) | ||||||
|             .header("Authorization", format!("Bearer {}", token)) |             .header("Authorization", format!("Bearer {}", token)) | ||||||
|             .header("X-CalDAV-Password", password) |             .header("X-CalDAV-Password", password) | ||||||
| @@ -212,7 +268,10 @@ mod tests { | |||||||
|             assert!(user_info["username"].is_string()); |             assert!(user_info["username"].is_string()); | ||||||
|             println!("✓ User info test passed"); |             println!("✓ User info test passed"); | ||||||
|         } else { |         } else { | ||||||
|             println!("⚠ User info test skipped (CalDAV server issues): {}", response.status()); |             println!( | ||||||
|  |                 "⚠ User info test skipped (CalDAV server issues): {}", | ||||||
|  |                 response.status() | ||||||
|  |             ); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -226,22 +285,33 @@ mod tests { | |||||||
|  |  | ||||||
|         // Load password from env for CalDAV requests |         // Load password from env for CalDAV requests | ||||||
|         dotenvy::dotenv().ok(); |         dotenvy::dotenv().ok(); | ||||||
|         let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()); |         let password = "test".to_string(); | ||||||
|  |  | ||||||
|         let response = server.client |         let response = server | ||||||
|             .get(&format!("{}/api/calendar/events?year=2024&month=12", server.base_url)) |             .client | ||||||
|  |             .get(&format!( | ||||||
|  |                 "{}/api/calendar/events?year=2024&month=12", | ||||||
|  |                 server.base_url | ||||||
|  |             )) | ||||||
|             .header("Authorization", format!("Bearer {}", token)) |             .header("Authorization", format!("Bearer {}", token)) | ||||||
|             .header("X-CalDAV-Password", password) |             .header("X-CalDAV-Password", password) | ||||||
|             .send() |             .send() | ||||||
|             .await |             .await | ||||||
|             .unwrap(); |             .unwrap(); | ||||||
|  |  | ||||||
|         assert!(response.status().is_success(), "Get events failed with status: {}", response.status()); |         assert!( | ||||||
|  |             response.status().is_success(), | ||||||
|  |             "Get events failed with status: {}", | ||||||
|  |             response.status() | ||||||
|  |         ); | ||||||
|  |  | ||||||
|         let events: serde_json::Value = response.json().await.unwrap(); |         let events: serde_json::Value = response.json().await.unwrap(); | ||||||
|         assert!(events.is_array()); |         assert!(events.is_array()); | ||||||
|  |  | ||||||
|         println!("✓ Get calendar events test passed (found {} events)", events.as_array().unwrap().len()); |         println!( | ||||||
|  |             "✓ Get calendar events test passed (found {} events)", | ||||||
|  |             events.as_array().unwrap().len() | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Test event creation endpoint |     /// Test event creation endpoint | ||||||
| @@ -254,7 +324,7 @@ mod tests { | |||||||
|  |  | ||||||
|         // Load password from env for CalDAV requests |         // Load password from env for CalDAV requests | ||||||
|         dotenvy::dotenv().ok(); |         dotenvy::dotenv().ok(); | ||||||
|         let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()); |         let password = "test".to_string(); | ||||||
|  |  | ||||||
|         let create_payload = json!({ |         let create_payload = json!({ | ||||||
|             "title": "Integration Test Event", |             "title": "Integration Test Event", | ||||||
| @@ -276,7 +346,8 @@ mod tests { | |||||||
|             "recurrence_days": [false, false, false, false, false, false, false] |             "recurrence_days": [false, false, false, false, false, false, false] | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         let response = server.client |         let response = server | ||||||
|  |             .client | ||||||
|             .post(&format!("{}/api/calendar/events/create", server.base_url)) |             .post(&format!("{}/api/calendar/events/create", server.base_url)) | ||||||
|             .header("Authorization", format!("Bearer {}", token)) |             .header("Authorization", format!("Bearer {}", token)) | ||||||
|             .header("X-CalDAV-Password", password) |             .header("X-CalDAV-Password", password) | ||||||
| @@ -308,13 +379,17 @@ mod tests { | |||||||
|  |  | ||||||
|         // Load password from env for CalDAV requests |         // Load password from env for CalDAV requests | ||||||
|         dotenvy::dotenv().ok(); |         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 |         // Use a dummy UID for testing - this will likely return 404 but we're testing the endpoint structure | ||||||
|         let test_uid = "test-event-uid"; |         let test_uid = "test-event-uid"; | ||||||
|  |  | ||||||
|         let response = server.client |         let response = server | ||||||
|             .get(&format!("{}/api/calendar/events/{}", server.base_url, test_uid)) |             .client | ||||||
|  |             .get(&format!( | ||||||
|  |                 "{}/api/calendar/events/{}", | ||||||
|  |                 server.base_url, test_uid | ||||||
|  |             )) | ||||||
|             .header("Authorization", format!("Bearer {}", token)) |             .header("Authorization", format!("Bearer {}", token)) | ||||||
|             .header("X-CalDAV-Password", password) |             .header("X-CalDAV-Password", password) | ||||||
|             .send() |             .send() | ||||||
| @@ -322,8 +397,11 @@ mod tests { | |||||||
|             .unwrap(); |             .unwrap(); | ||||||
|  |  | ||||||
|         // We expect either 200 (if event exists) or 404 (if not found) - both are valid responses |         // We expect either 200 (if event exists) or 404 (if not found) - both are valid responses | ||||||
|         assert!(response.status() == 200 || response.status() == 404,  |         assert!( | ||||||
|                "Refresh event failed with unexpected status: {}", response.status()); |             response.status() == 200 || response.status() == 404, | ||||||
|  |             "Refresh event failed with unexpected status: {}", | ||||||
|  |             response.status() | ||||||
|  |         ); | ||||||
|  |  | ||||||
|         println!("✓ Refresh event endpoint test passed"); |         println!("✓ Refresh event endpoint test passed"); | ||||||
|     } |     } | ||||||
| @@ -333,7 +411,8 @@ mod tests { | |||||||
|     async fn test_invalid_auth() { |     async fn test_invalid_auth() { | ||||||
|         let server = TestServer::start().await; |         let server = TestServer::start().await; | ||||||
|  |  | ||||||
|         let response = server.client |         let response = server | ||||||
|  |             .client | ||||||
|             .get(&format!("{}/api/user/info", server.base_url)) |             .get(&format!("{}/api/user/info", server.base_url)) | ||||||
|             .header("Authorization", "Bearer invalid-token") |             .header("Authorization", "Bearer invalid-token") | ||||||
|             .send() |             .send() | ||||||
| @@ -341,8 +420,11 @@ mod tests { | |||||||
|             .unwrap(); |             .unwrap(); | ||||||
|  |  | ||||||
|         // Accept both 400 and 401 as valid responses for invalid tokens |         // Accept both 400 and 401 as valid responses for invalid tokens | ||||||
|         assert!(response.status() == 401 || response.status() == 400,  |         assert!( | ||||||
|                "Expected 401 or 400 for invalid token, got {}", response.status()); |             response.status() == 401 || response.status() == 400, | ||||||
|  |             "Expected 401 or 400 for invalid token, got {}", | ||||||
|  |             response.status() | ||||||
|  |         ); | ||||||
|         println!("✓ Invalid authentication test passed"); |         println!("✓ Invalid authentication test passed"); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -351,7 +433,8 @@ mod tests { | |||||||
|     async fn test_missing_auth() { |     async fn test_missing_auth() { | ||||||
|         let server = TestServer::start().await; |         let server = TestServer::start().await; | ||||||
|  |  | ||||||
|         let response = server.client |         let response = server | ||||||
|  |             .client | ||||||
|             .get(&format!("{}/api/user/info", server.base_url)) |             .get(&format!("{}/api/user/info", server.base_url)) | ||||||
|             .send() |             .send() | ||||||
|             .await |             .await | ||||||
| @@ -373,7 +456,7 @@ mod tests { | |||||||
|  |  | ||||||
|         // Load password from env for CalDAV requests |         // Load password from env for CalDAV requests | ||||||
|         dotenvy::dotenv().ok(); |         dotenvy::dotenv().ok(); | ||||||
|         let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()); |         let password = "test".to_string(); | ||||||
|  |  | ||||||
|         let create_payload = json!({ |         let create_payload = json!({ | ||||||
|             "title": "Integration Test Series", |             "title": "Integration Test Series", | ||||||
| @@ -398,8 +481,12 @@ mod tests { | |||||||
|             "calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery |             "calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         let response = server.client |         let response = server | ||||||
|             .post(&format!("{}/api/calendar/events/series/create", server.base_url)) |             .client | ||||||
|  |             .post(&format!( | ||||||
|  |                 "{}/api/calendar/events/series/create", | ||||||
|  |                 server.base_url | ||||||
|  |             )) | ||||||
|             .header("Authorization", format!("Bearer {}", token)) |             .header("Authorization", format!("Bearer {}", token)) | ||||||
|             .header("X-CalDAV-Password", password) |             .header("X-CalDAV-Password", password) | ||||||
|             .json(&create_payload) |             .json(&create_payload) | ||||||
| @@ -431,7 +518,7 @@ mod tests { | |||||||
|  |  | ||||||
|         // Load password from env for CalDAV requests |         // Load password from env for CalDAV requests | ||||||
|         dotenvy::dotenv().ok(); |         dotenvy::dotenv().ok(); | ||||||
|         let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()); |         let password = "test".to_string(); | ||||||
|  |  | ||||||
|         let update_payload = json!({ |         let update_payload = json!({ | ||||||
|             "series_uid": "test-series-uid", |             "series_uid": "test-series-uid", | ||||||
| @@ -458,8 +545,12 @@ mod tests { | |||||||
|             "calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery |             "calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         let response = server.client |         let response = server | ||||||
|             .post(&format!("{}/api/calendar/events/series/update", server.base_url)) |             .client | ||||||
|  |             .post(&format!( | ||||||
|  |                 "{}/api/calendar/events/series/update", | ||||||
|  |                 server.base_url | ||||||
|  |             )) | ||||||
|             .header("Authorization", format!("Bearer {}", token)) |             .header("Authorization", format!("Bearer {}", token)) | ||||||
|             .header("X-CalDAV-Password", password) |             .header("X-CalDAV-Password", password) | ||||||
|             .json(&update_payload) |             .json(&update_payload) | ||||||
| @@ -474,10 +565,15 @@ mod tests { | |||||||
|         if status.is_success() { |         if status.is_success() { | ||||||
|             let update_response: serde_json::Value = response.json().await.unwrap(); |             let update_response: serde_json::Value = response.json().await.unwrap(); | ||||||
|             assert!(update_response["success"].as_bool().unwrap_or(false)); |             assert!(update_response["success"].as_bool().unwrap_or(false)); | ||||||
|             assert_eq!(update_response["series_uid"].as_str().unwrap(), "test-series-uid"); |             assert_eq!( | ||||||
|  |                 update_response["series_uid"].as_str().unwrap(), | ||||||
|  |                 "test-series-uid" | ||||||
|  |             ); | ||||||
|             println!("✓ Update event series test passed"); |             println!("✓ Update event series test passed"); | ||||||
|         } else if status == 404 { |         } else if status == 404 { | ||||||
|             println!("⚠ Update event series test skipped (event not found - expected for test data)"); |             println!( | ||||||
|  |                 "⚠ Update event series test skipped (event not found - expected for test data)" | ||||||
|  |             ); | ||||||
|         } else { |         } else { | ||||||
|             println!("⚠ Update event series test skipped (CalDAV server not accessible)"); |             println!("⚠ Update event series test skipped (CalDAV server not accessible)"); | ||||||
|         } |         } | ||||||
| @@ -493,7 +589,7 @@ mod tests { | |||||||
|  |  | ||||||
|         // Load password from env for CalDAV requests |         // Load password from env for CalDAV requests | ||||||
|         dotenvy::dotenv().ok(); |         dotenvy::dotenv().ok(); | ||||||
|         let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()); |         let password = "test".to_string(); | ||||||
|  |  | ||||||
|         let delete_payload = json!({ |         let delete_payload = json!({ | ||||||
|             "series_uid": "test-series-to-delete", |             "series_uid": "test-series-to-delete", | ||||||
| @@ -502,8 +598,12 @@ mod tests { | |||||||
|             "delete_scope": "all_in_series" |             "delete_scope": "all_in_series" | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         let response = server.client |         let response = server | ||||||
|             .post(&format!("{}/api/calendar/events/series/delete", server.base_url)) |             .client | ||||||
|  |             .post(&format!( | ||||||
|  |                 "{}/api/calendar/events/series/delete", | ||||||
|  |                 server.base_url | ||||||
|  |             )) | ||||||
|             .header("Authorization", format!("Bearer {}", token)) |             .header("Authorization", format!("Bearer {}", token)) | ||||||
|             .header("X-CalDAV-Password", password) |             .header("X-CalDAV-Password", password) | ||||||
|             .json(&delete_payload) |             .json(&delete_payload) | ||||||
| @@ -520,7 +620,9 @@ mod tests { | |||||||
|             assert!(delete_response["success"].as_bool().unwrap_or(false)); |             assert!(delete_response["success"].as_bool().unwrap_or(false)); | ||||||
|             println!("✓ Delete event series test passed"); |             println!("✓ Delete event series test passed"); | ||||||
|         } else if status == 404 { |         } else if status == 404 { | ||||||
|             println!("⚠ Delete event series test skipped (event not found - expected for test data)"); |             println!( | ||||||
|  |                 "⚠ Delete event series test skipped (event not found - expected for test data)" | ||||||
|  |             ); | ||||||
|         } else { |         } else { | ||||||
|             println!("⚠ Delete event series test skipped (CalDAV server not accessible)"); |             println!("⚠ Delete event series test skipped (CalDAV server not accessible)"); | ||||||
|         } |         } | ||||||
| @@ -555,15 +657,23 @@ mod tests { | |||||||
|             "update_scope": "invalid_scope" // This should cause a 400 error |             "update_scope": "invalid_scope" // This should cause a 400 error | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         let response = server.client |         let response = server | ||||||
|             .post(&format!("{}/api/calendar/events/series/update", server.base_url)) |             .client | ||||||
|  |             .post(&format!( | ||||||
|  |                 "{}/api/calendar/events/series/update", | ||||||
|  |                 server.base_url | ||||||
|  |             )) | ||||||
|             .header("Authorization", format!("Bearer {}", token)) |             .header("Authorization", format!("Bearer {}", token)) | ||||||
|             .json(&invalid_payload) |             .json(&invalid_payload) | ||||||
|             .send() |             .send() | ||||||
|             .await |             .await | ||||||
|             .unwrap(); |             .unwrap(); | ||||||
|  |  | ||||||
|         assert_eq!(response.status(), 400, "Expected 400 for invalid update scope"); |         assert_eq!( | ||||||
|  |             response.status(), | ||||||
|  |             400, | ||||||
|  |             "Expected 400 for invalid update scope" | ||||||
|  |         ); | ||||||
|         println!("✓ Invalid update scope test passed"); |         println!("✓ Invalid update scope test passed"); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -594,15 +704,23 @@ mod tests { | |||||||
|             "recurrence_days": [false, false, false, false, false, false, false] |             "recurrence_days": [false, false, false, false, false, false, false] | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         let response = server.client |         let response = server | ||||||
|             .post(&format!("{}/api/calendar/events/series/create", server.base_url)) |             .client | ||||||
|  |             .post(&format!( | ||||||
|  |                 "{}/api/calendar/events/series/create", | ||||||
|  |                 server.base_url | ||||||
|  |             )) | ||||||
|             .header("Authorization", format!("Bearer {}", token)) |             .header("Authorization", format!("Bearer {}", token)) | ||||||
|             .json(&non_recurring_payload) |             .json(&non_recurring_payload) | ||||||
|             .send() |             .send() | ||||||
|             .await |             .await | ||||||
|             .unwrap(); |             .unwrap(); | ||||||
|  |  | ||||||
|         assert_eq!(response.status(), 400, "Expected 400 for non-recurring event in series endpoint"); |         assert_eq!( | ||||||
|  |             response.status(), | ||||||
|  |             400, | ||||||
|  |             "Expected 400 for non-recurring event in series endpoint" | ||||||
|  |         ); | ||||||
|         println!("✓ Non-recurring series rejection test passed"); |         println!("✓ Non-recurring series rejection test passed"); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,6 +1,6 @@ | |||||||
| //! Common types and enums used across calendar components | //! Common types and enums used across calendar components | ||||||
|  |  | ||||||
| use chrono::{DateTime, Utc, Duration}; | use chrono::{DateTime, Duration, Utc}; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
|  |  | ||||||
| // ==================== ENUMS AND COMMON TYPES ==================== | // ==================== ENUMS AND COMMON TYPES ==================== | ||||||
|   | |||||||
| @@ -3,8 +3,8 @@ | |||||||
| //! This crate provides shared data structures for calendar applications | //! This crate provides shared data structures for calendar applications | ||||||
| //! that comply with RFC 5545 (iCalendar) specification. | //! that comply with RFC 5545 (iCalendar) specification. | ||||||
|  |  | ||||||
| pub mod vevent; |  | ||||||
| pub mod common; | pub mod common; | ||||||
|  | pub mod vevent; | ||||||
|  |  | ||||||
| pub use vevent::*; |  | ||||||
| pub use common::*; | pub use common::*; | ||||||
|  | pub use vevent::*; | ||||||
|   | |||||||
| @@ -1,8 +1,8 @@ | |||||||
| //! VEvent - RFC 5545 compliant calendar event structure | //! VEvent - RFC 5545 compliant calendar event structure | ||||||
|  |  | ||||||
| use chrono::{DateTime, Utc, Duration}; |  | ||||||
| use serde::{Deserialize, Serialize}; |  | ||||||
| use crate::common::*; | use crate::common::*; | ||||||
|  | use chrono::{DateTime, Duration, Utc}; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  |  | ||||||
| // ==================== VEVENT COMPONENT ==================== | // ==================== VEVENT COMPONENT ==================== | ||||||
|  |  | ||||||
| @@ -129,7 +129,9 @@ impl VEvent { | |||||||
|  |  | ||||||
|     /// Helper method to get display title (summary or "Untitled Event") |     /// Helper method to get display title (summary or "Untitled Event") | ||||||
|     pub fn get_title(&self) -> String { |     pub fn get_title(&self) -> String { | ||||||
|         self.summary.clone().unwrap_or_else(|| "Untitled Event".to_string()) |         self.summary | ||||||
|  |             .clone() | ||||||
|  |             .unwrap_or_else(|| "Untitled Event".to_string()) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Helper method to get start date for UI compatibility |     /// Helper method to get start date for UI compatibility | ||||||
|   | |||||||
| @@ -1,11 +1,15 @@ | |||||||
| use yew::prelude::*; | use crate::components::{ | ||||||
| use yew_router::prelude::*; |     CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction, | ||||||
|  |     EditAction, EventClass, EventContextMenu, EventCreationData, EventStatus, RecurrenceType, | ||||||
|  |     ReminderType, RouteHandler, Sidebar, Theme, ViewMode, | ||||||
|  | }; | ||||||
|  | use crate::models::ical::VEvent; | ||||||
|  | use crate::services::{calendar_service::UserInfo, CalendarService}; | ||||||
|  | use chrono::NaiveDate; | ||||||
| use gloo_storage::{LocalStorage, Storage}; | use gloo_storage::{LocalStorage, Storage}; | ||||||
| use web_sys::MouseEvent; | use web_sys::MouseEvent; | ||||||
| use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction}; | use yew::prelude::*; | ||||||
| use crate::services::{CalendarService, calendar_service::UserInfo}; | use yew_router::prelude::*; | ||||||
| use crate::models::ical::VEvent; |  | ||||||
| use chrono::NaiveDate; |  | ||||||
|  |  | ||||||
| fn get_theme_event_colors() -> Vec<String> { | fn get_theme_event_colors() -> Vec<String> { | ||||||
|     if let Some(window) = web_sys::window() { |     if let Some(window) = web_sys::window() { | ||||||
| @@ -27,18 +31,28 @@ fn get_theme_event_colors() -> Vec<String> { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     vec![ |     vec![ | ||||||
|         "#3B82F6".to_string(), "#10B981".to_string(), "#F59E0B".to_string(), "#EF4444".to_string(),  |         "#3B82F6".to_string(), | ||||||
|         "#8B5CF6".to_string(), "#06B6D4".to_string(), "#84CC16".to_string(), "#F97316".to_string(), |         "#10B981".to_string(), | ||||||
|         "#EC4899".to_string(), "#6366F1".to_string(), "#14B8A6".to_string(), "#F3B806".to_string(), |         "#F59E0B".to_string(), | ||||||
|         "#8B5A2B".to_string(), "#6B7280".to_string(), "#DC2626".to_string(), "#7C3AED".to_string() |         "#EF4444".to_string(), | ||||||
|  |         "#8B5CF6".to_string(), | ||||||
|  |         "#06B6D4".to_string(), | ||||||
|  |         "#84CC16".to_string(), | ||||||
|  |         "#F97316".to_string(), | ||||||
|  |         "#EC4899".to_string(), | ||||||
|  |         "#6366F1".to_string(), | ||||||
|  |         "#14B8A6".to_string(), | ||||||
|  |         "#F3B806".to_string(), | ||||||
|  |         "#8B5A2B".to_string(), | ||||||
|  |         "#6B7280".to_string(), | ||||||
|  |         "#DC2626".to_string(), | ||||||
|  |         "#7C3AED".to_string(), | ||||||
|     ] |     ] | ||||||
| } | } | ||||||
|  |  | ||||||
| #[function_component] | #[function_component] | ||||||
| pub fn App() -> Html { | pub fn App() -> Html { | ||||||
|     let auth_token = use_state(|| -> Option<String> { |     let auth_token = use_state(|| -> Option<String> { LocalStorage::get("auth_token").ok() }); | ||||||
|         LocalStorage::get("auth_token").ok() |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     let user_info = use_state(|| -> Option<UserInfo> { None }); |     let user_info = use_state(|| -> Option<UserInfo> { None }); | ||||||
|     let color_picker_open = use_state(|| -> Option<String> { None }); |     let color_picker_open = use_state(|| -> Option<String> { None }); | ||||||
| @@ -54,6 +68,7 @@ pub fn App() -> Html { | |||||||
|     let calendar_context_menu_date = use_state(|| -> Option<NaiveDate> { None }); |     let calendar_context_menu_date = use_state(|| -> Option<NaiveDate> { None }); | ||||||
|     let create_event_modal_open = use_state(|| false); |     let create_event_modal_open = use_state(|| false); | ||||||
|     let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None }); |     let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None }); | ||||||
|  |     let event_edit_scope = use_state(|| -> Option<EditAction> { None }); | ||||||
|     let _recurring_edit_modal_open = use_state(|| false); |     let _recurring_edit_modal_open = use_state(|| false); | ||||||
|     let _recurring_edit_event = use_state(|| -> Option<VEvent> { None }); |     let _recurring_edit_event = use_state(|| -> Option<VEvent> { None }); | ||||||
|     let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { None }); |     let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { None }); | ||||||
| @@ -163,8 +178,12 @@ pub fn App() -> Html { | |||||||
|                 wasm_bindgen_futures::spawn_local(async move { |                 wasm_bindgen_futures::spawn_local(async move { | ||||||
|                     let calendar_service = CalendarService::new(); |                     let calendar_service = CalendarService::new(); | ||||||
|  |  | ||||||
|                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { |                     let password = if let Ok(credentials_str) = | ||||||
|                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { |                         LocalStorage::get::<String>("caldav_credentials") | ||||||
|  |                     { | ||||||
|  |                         if let Ok(credentials) = | ||||||
|  |                             serde_json::from_str::<serde_json::Value>(&credentials_str) | ||||||
|  |                         { | ||||||
|                             credentials["password"].as_str().unwrap_or("").to_string() |                             credentials["password"].as_str().unwrap_or("").to_string() | ||||||
|                         } else { |                         } else { | ||||||
|                             String::new() |                             String::new() | ||||||
| @@ -176,8 +195,12 @@ pub fn App() -> Html { | |||||||
|                     if !password.is_empty() { |                     if !password.is_empty() { | ||||||
|                         match calendar_service.fetch_user_info(&token, &password).await { |                         match calendar_service.fetch_user_info(&token, &password).await { | ||||||
|                             Ok(mut info) => { |                             Ok(mut info) => { | ||||||
|                                 if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") { |                                 if let Ok(saved_colors_json) = | ||||||
|                                     if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) { |                                     LocalStorage::get::<String>("calendar_colors") | ||||||
|  |                                 { | ||||||
|  |                                     if let Ok(saved_info) = | ||||||
|  |                                         serde_json::from_str::<UserInfo>(&saved_colors_json) | ||||||
|  |                                     { | ||||||
|                                         for saved_cal in &saved_info.calendars { |                                         for saved_cal in &saved_info.calendars { | ||||||
|                                             for cal in &mut info.calendars { |                                             for cal in &mut info.calendars { | ||||||
|                                                 if cal.path == saved_cal.path { |                                                 if cal.path == saved_cal.path { | ||||||
| @@ -190,7 +213,9 @@ pub fn App() -> Html { | |||||||
|                                 user_info.set(Some(info)); |                                 user_info.set(Some(info)); | ||||||
|                             } |                             } | ||||||
|                             Err(err) => { |                             Err(err) => { | ||||||
|                                 web_sys::console::log_1(&format!("Failed to fetch user info: {}", err).into()); |                                 web_sys::console::log_1( | ||||||
|  |                                     &format!("Failed to fetch user info: {}", err).into(), | ||||||
|  |                                 ); | ||||||
|                             } |                             } | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
| @@ -210,10 +235,10 @@ pub fn App() -> Html { | |||||||
|         let calendar_context_menu_open = calendar_context_menu_open.clone(); |         let calendar_context_menu_open = calendar_context_menu_open.clone(); | ||||||
|         Callback::from(move |e: MouseEvent| { |         Callback::from(move |e: MouseEvent| { | ||||||
|             // Check if any context menu or color picker is open |             // Check if any context menu or color picker is open | ||||||
|             let any_menu_open = color_picker_open.is_some() ||  |             let any_menu_open = color_picker_open.is_some() | ||||||
|                                *context_menu_open ||  |                 || *context_menu_open | ||||||
|                                *event_context_menu_open ||  |                 || *event_context_menu_open | ||||||
|                                *calendar_context_menu_open; |                 || *calendar_context_menu_open; | ||||||
|  |  | ||||||
|             if any_menu_open { |             if any_menu_open { | ||||||
|                 // Prevent the default action and stop event propagation |                 // Prevent the default action and stop event propagation | ||||||
| @@ -230,10 +255,10 @@ pub fn App() -> Html { | |||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Compute if any context menu is open |     // Compute if any context menu is open | ||||||
|     let any_context_menu_open = color_picker_open.is_some() ||  |     let any_context_menu_open = color_picker_open.is_some() | ||||||
|                                *context_menu_open ||  |         || *context_menu_open | ||||||
|                                *event_context_menu_open ||  |         || *event_context_menu_open | ||||||
|                                *calendar_context_menu_open; |         || *calendar_context_menu_open; | ||||||
|  |  | ||||||
|     let on_color_change = { |     let on_color_change = { | ||||||
|         let user_info = user_info.clone(); |         let user_info = user_info.clone(); | ||||||
| @@ -322,8 +347,12 @@ pub fn App() -> Html { | |||||||
|                     let _calendar_service = CalendarService::new(); |                     let _calendar_service = CalendarService::new(); | ||||||
|  |  | ||||||
|                     // Get CalDAV password from storage |                     // Get CalDAV password from storage | ||||||
|                     let _password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { |                     let _password = if let Ok(credentials_str) = | ||||||
|                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { |                         LocalStorage::get::<String>("caldav_credentials") | ||||||
|  |                     { | ||||||
|  |                         if let Ok(credentials) = | ||||||
|  |                             serde_json::from_str::<serde_json::Value>(&credentials_str) | ||||||
|  |                         { | ||||||
|                             credentials["password"].as_str().unwrap_or("").to_string() |                             credentials["password"].as_str().unwrap_or("").to_string() | ||||||
|                         } else { |                         } else { | ||||||
|                             String::new() |                             String::new() | ||||||
| @@ -333,10 +362,9 @@ pub fn App() -> Html { | |||||||
|                     }; |                     }; | ||||||
|  |  | ||||||
|                     let params = event_data.to_create_event_params(); |                     let params = event_data.to_create_event_params(); | ||||||
|                     let create_result = _calendar_service.create_event( |                     let create_result = _calendar_service | ||||||
|                         &_token, |                         .create_event( | ||||||
|                         &_password, |                             &_token, &_password, params.0,  // title | ||||||
|                         params.0,  // title |  | ||||||
|                             params.1,  // description |                             params.1,  // description | ||||||
|                             params.2,  // start_date |                             params.2,  // start_date | ||||||
|                             params.3,  // start_time |                             params.3,  // start_time | ||||||
| @@ -353,8 +381,9 @@ pub fn App() -> Html { | |||||||
|                             params.14, // reminder |                             params.14, // reminder | ||||||
|                             params.15, // recurrence |                             params.15, // recurrence | ||||||
|                             params.16, // recurrence_days |                             params.16, // recurrence_days | ||||||
|                         params.17  // calendar_path |                             params.17, // calendar_path | ||||||
|                     ).await; |                         ) | ||||||
|  |                         .await; | ||||||
|                     match create_result { |                     match create_result { | ||||||
|                         Ok(_) => { |                         Ok(_) => { | ||||||
|                             web_sys::console::log_1(&"Event created successfully".into()); |                             web_sys::console::log_1(&"Event created successfully".into()); | ||||||
| @@ -363,8 +392,13 @@ pub fn App() -> Html { | |||||||
|                             web_sys::window().unwrap().location().reload().unwrap(); |                             web_sys::window().unwrap().location().reload().unwrap(); | ||||||
|                         } |                         } | ||||||
|                         Err(err) => { |                         Err(err) => { | ||||||
|                             web_sys::console::error_1(&format!("Failed to create event: {}", err).into()); |                             web_sys::console::error_1( | ||||||
|                             web_sys::window().unwrap().alert_with_message(&format!("Failed to create event: {}", err)).unwrap(); |                                 &format!("Failed to create event: {}", err).into(), | ||||||
|  |                             ); | ||||||
|  |                             web_sys::window() | ||||||
|  |                                 .unwrap() | ||||||
|  |                                 .alert_with_message(&format!("Failed to create event: {}", err)) | ||||||
|  |                                 .unwrap(); | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 }); |                 }); | ||||||
| @@ -374,11 +408,33 @@ pub fn App() -> Html { | |||||||
|  |  | ||||||
|     let on_event_update = { |     let on_event_update = { | ||||||
|         let auth_token = auth_token.clone(); |         let auth_token = auth_token.clone(); | ||||||
|         Callback::from(move |(original_event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)| { |         Callback::from( | ||||||
|             web_sys::console::log_1(&format!("Updating event: {} to new times: {} - {}",  |             move |( | ||||||
|  |                 original_event, | ||||||
|  |                 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>, | ||||||
|  |             )| { | ||||||
|  |                 web_sys::console::log_1( | ||||||
|  |                     &format!( | ||||||
|  |                         "Updating event: {} to new times: {} - {}", | ||||||
|                         original_event.uid, |                         original_event.uid, | ||||||
|                         new_start.format("%Y-%m-%d %H:%M"), |                         new_start.format("%Y-%m-%d %H:%M"), | ||||||
|                 new_end.format("%Y-%m-%d %H:%M")).into()); |                         new_end.format("%Y-%m-%d %H:%M") | ||||||
|  |                     ) | ||||||
|  |                     .into(), | ||||||
|  |                 ); | ||||||
|  |  | ||||||
|                 // Use the original UID for all updates |                 // Use the original UID for all updates | ||||||
|                 let backend_uid = original_event.uid.clone(); |                 let backend_uid = original_event.uid.clone(); | ||||||
| @@ -390,8 +446,12 @@ pub fn App() -> Html { | |||||||
|                         let calendar_service = CalendarService::new(); |                         let calendar_service = CalendarService::new(); | ||||||
|  |  | ||||||
|                         // Get CalDAV password from storage |                         // Get CalDAV password from storage | ||||||
|                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { |                         let password = if let Ok(credentials_str) = | ||||||
|                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { |                             LocalStorage::get::<String>("caldav_credentials") | ||||||
|  |                         { | ||||||
|  |                             if let Ok(credentials) = | ||||||
|  |                                 serde_json::from_str::<serde_json::Value>(&credentials_str) | ||||||
|  |                             { | ||||||
|                                 credentials["password"].as_str().unwrap_or("").to_string() |                                 credentials["password"].as_str().unwrap_or("").to_string() | ||||||
|                             } else { |                             } else { | ||||||
|                                 String::new() |                                 String::new() | ||||||
| @@ -401,7 +461,10 @@ pub fn App() -> Html { | |||||||
|                         }; |                         }; | ||||||
|  |  | ||||||
|                         // Convert local times to UTC for backend storage |                         // Convert local times to UTC for backend storage | ||||||
|                     let start_utc = new_start.and_local_timezone(chrono::Local).unwrap().to_utc(); |                         let start_utc = new_start | ||||||
|  |                             .and_local_timezone(chrono::Local) | ||||||
|  |                             .unwrap() | ||||||
|  |                             .to_utc(); | ||||||
|                         let end_utc = new_end.and_local_timezone(chrono::Local).unwrap().to_utc(); |                         let end_utc = new_end.and_local_timezone(chrono::Local).unwrap().to_utc(); | ||||||
|  |  | ||||||
|                         // Format UTC date and time strings for backend |                         // Format UTC date and time strings for backend | ||||||
| @@ -412,16 +475,24 @@ pub fn App() -> Html { | |||||||
|  |  | ||||||
|                         // Convert existing event data to string formats for the API |                         // Convert existing event data to string formats for the API | ||||||
|                         let status_str = match original_event.status { |                         let status_str = match original_event.status { | ||||||
|                         Some(crate::models::ical::EventStatus::Tentative) => "TENTATIVE".to_string(), |                             Some(crate::models::ical::EventStatus::Tentative) => { | ||||||
|                         Some(crate::models::ical::EventStatus::Confirmed) => "CONFIRMED".to_string(), |                                 "TENTATIVE".to_string() | ||||||
|                         Some(crate::models::ical::EventStatus::Cancelled) => "CANCELLED".to_string(), |                             } | ||||||
|  |                             Some(crate::models::ical::EventStatus::Confirmed) => { | ||||||
|  |                                 "CONFIRMED".to_string() | ||||||
|  |                             } | ||||||
|  |                             Some(crate::models::ical::EventStatus::Cancelled) => { | ||||||
|  |                                 "CANCELLED".to_string() | ||||||
|  |                             } | ||||||
|                             None => "CONFIRMED".to_string(), // Default status |                             None => "CONFIRMED".to_string(), // Default status | ||||||
|                         }; |                         }; | ||||||
|  |  | ||||||
|                         let class_str = match original_event.class { |                         let class_str = match original_event.class { | ||||||
|                             Some(crate::models::ical::EventClass::Public) => "PUBLIC".to_string(), |                             Some(crate::models::ical::EventClass::Public) => "PUBLIC".to_string(), | ||||||
|                             Some(crate::models::ical::EventClass::Private) => "PRIVATE".to_string(), |                             Some(crate::models::ical::EventClass::Private) => "PRIVATE".to_string(), | ||||||
|                         Some(crate::models::ical::EventClass::Confidential) => "CONFIDENTIAL".to_string(), |                             Some(crate::models::ical::EventClass::Confidential) => { | ||||||
|  |                                 "CONFIDENTIAL".to_string() | ||||||
|  |                             } | ||||||
|                             None => "PUBLIC".to_string(), // Default class |                             None => "PUBLIC".to_string(), // Default class | ||||||
|                         }; |                         }; | ||||||
|  |  | ||||||
| @@ -438,7 +509,8 @@ pub fn App() -> Html { | |||||||
|                         let recurrence_days = vec![false; 7]; // Default - could be enhanced to parse existing recurrence |                         let recurrence_days = vec![false; 7]; // Default - could be enhanced to parse existing recurrence | ||||||
|  |  | ||||||
|                         // Determine if this is a recurring event that needs series endpoint |                         // Determine if this is a recurring event that needs series endpoint | ||||||
|                     let has_recurrence = !recurrence_str.is_empty() && recurrence_str.to_uppercase() != "NONE"; |                         let has_recurrence = | ||||||
|  |                             !recurrence_str.is_empty() && recurrence_str.to_uppercase() != "NONE"; | ||||||
|  |  | ||||||
|                         let result = if let Some(scope) = update_scope.as_ref() { |                         let result = if let Some(scope) = update_scope.as_ref() { | ||||||
|                             // Use series endpoint for recurring event operations |                             // Use series endpoint for recurring event operations | ||||||
| @@ -447,7 +519,9 @@ pub fn App() -> Html { | |||||||
|                                 // Fall through to regular endpoint |                                 // Fall through to regular endpoint | ||||||
|                                 None |                                 None | ||||||
|                             } else { |                             } else { | ||||||
|                             Some(calendar_service.update_series( |                                 Some( | ||||||
|  |                                     calendar_service | ||||||
|  |                                         .update_series( | ||||||
|                                             &token, |                                             &token, | ||||||
|                                             &password, |                                             &password, | ||||||
|                                             backend_uid.clone(), |                                             backend_uid.clone(), | ||||||
| @@ -462,15 +536,26 @@ pub fn App() -> Html { | |||||||
|                                             status_str.clone(), |                                             status_str.clone(), | ||||||
|                                             class_str.clone(), |                                             class_str.clone(), | ||||||
|                                             original_event.priority, |                                             original_event.priority, | ||||||
|                                 original_event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(), |                                             original_event | ||||||
|                                 original_event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(","), |                                                 .organizer | ||||||
|  |                                                 .as_ref() | ||||||
|  |                                                 .map(|o| o.cal_address.clone()) | ||||||
|  |                                                 .unwrap_or_default(), | ||||||
|  |                                             original_event | ||||||
|  |                                                 .attendees | ||||||
|  |                                                 .iter() | ||||||
|  |                                                 .map(|a| a.cal_address.clone()) | ||||||
|  |                                                 .collect::<Vec<_>>() | ||||||
|  |                                                 .join(","), | ||||||
|                                             original_event.categories.join(","), |                                             original_event.categories.join(","), | ||||||
|                                             reminder_str.clone(), |                                             reminder_str.clone(), | ||||||
|                                             recurrence_str.clone(), |                                             recurrence_str.clone(), | ||||||
|                                             original_event.calendar_path.clone(), |                                             original_event.calendar_path.clone(), | ||||||
|                                             scope.clone(), |                                             scope.clone(), | ||||||
|                                             occurrence_date, |                                             occurrence_date, | ||||||
|                             ).await) |                                         ) | ||||||
|  |                                         .await, | ||||||
|  |                                 ) | ||||||
|                             } |                             } | ||||||
|                         } else { |                         } else { | ||||||
|                             None |                             None | ||||||
| @@ -480,7 +565,8 @@ pub fn App() -> Html { | |||||||
|                             series_result |                             series_result | ||||||
|                         } else { |                         } else { | ||||||
|                             // Use regular endpoint |                             // Use regular endpoint | ||||||
|                         calendar_service.update_event( |                             calendar_service | ||||||
|  |                                 .update_event( | ||||||
|                                     &token, |                                     &token, | ||||||
|                                     &password, |                                     &password, | ||||||
|                                     backend_uid, |                                     backend_uid, | ||||||
| @@ -495,8 +581,17 @@ pub fn App() -> Html { | |||||||
|                                     status_str, |                                     status_str, | ||||||
|                                     class_str, |                                     class_str, | ||||||
|                                     original_event.priority, |                                     original_event.priority, | ||||||
|                             original_event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(), |                                     original_event | ||||||
|                             original_event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(","), |                                         .organizer | ||||||
|  |                                         .as_ref() | ||||||
|  |                                         .map(|o| o.cal_address.clone()) | ||||||
|  |                                         .unwrap_or_default(), | ||||||
|  |                                     original_event | ||||||
|  |                                         .attendees | ||||||
|  |                                         .iter() | ||||||
|  |                                         .map(|a| a.cal_address.clone()) | ||||||
|  |                                         .collect::<Vec<_>>() | ||||||
|  |                                         .join(","), | ||||||
|                                     original_event.categories.join(","), |                                     original_event.categories.join(","), | ||||||
|                                     reminder_str, |                                     reminder_str, | ||||||
|                                     recurrence_str, |                                     recurrence_str, | ||||||
| @@ -508,8 +603,9 @@ pub fn App() -> Html { | |||||||
|                                     } else { |                                     } else { | ||||||
|                                         Some("this_and_future".to_string()) |                                         Some("this_and_future".to_string()) | ||||||
|                                     }, |                                     }, | ||||||
|                             until_date |                                     until_date, | ||||||
|                         ).await |                                 ) | ||||||
|  |                                 .await | ||||||
|                         }; |                         }; | ||||||
|  |  | ||||||
|                         match result { |                         match result { | ||||||
| @@ -517,18 +613,27 @@ pub fn App() -> Html { | |||||||
|                                 web_sys::console::log_1(&"Event updated successfully".into()); |                                 web_sys::console::log_1(&"Event updated successfully".into()); | ||||||
|                                 // Add small delay before reload to let any pending requests complete |                                 // Add small delay before reload to let any pending requests complete | ||||||
|                                 wasm_bindgen_futures::spawn_local(async { |                                 wasm_bindgen_futures::spawn_local(async { | ||||||
|                                 gloo_timers::future::sleep(std::time::Duration::from_millis(100)).await; |                                     gloo_timers::future::sleep(std::time::Duration::from_millis( | ||||||
|  |                                         100, | ||||||
|  |                                     )) | ||||||
|  |                                     .await; | ||||||
|                                     web_sys::window().unwrap().location().reload().unwrap(); |                                     web_sys::window().unwrap().location().reload().unwrap(); | ||||||
|                                 }); |                                 }); | ||||||
|                             } |                             } | ||||||
|                             Err(err) => { |                             Err(err) => { | ||||||
|                             web_sys::console::error_1(&format!("Failed to update event: {}", err).into()); |                                 web_sys::console::error_1( | ||||||
|                             web_sys::window().unwrap().alert_with_message(&format!("Failed to update event: {}", err)).unwrap(); |                                     &format!("Failed to update event: {}", err).into(), | ||||||
|  |                                 ); | ||||||
|  |                                 web_sys::window() | ||||||
|  |                                     .unwrap() | ||||||
|  |                                     .alert_with_message(&format!("Failed to update event: {}", err)) | ||||||
|  |                                     .unwrap(); | ||||||
|                             } |                             } | ||||||
|                         } |                         } | ||||||
|                     }); |                     }); | ||||||
|                 } |                 } | ||||||
|         }) |             }, | ||||||
|  |         ) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     let refresh_calendars = { |     let refresh_calendars = { | ||||||
| @@ -541,8 +646,12 @@ pub fn App() -> Html { | |||||||
|                 wasm_bindgen_futures::spawn_local(async move { |                 wasm_bindgen_futures::spawn_local(async move { | ||||||
|                     let calendar_service = CalendarService::new(); |                     let calendar_service = CalendarService::new(); | ||||||
|  |  | ||||||
|                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { |                     let password = if let Ok(credentials_str) = | ||||||
|                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { |                         LocalStorage::get::<String>("caldav_credentials") | ||||||
|  |                     { | ||||||
|  |                         if let Ok(credentials) = | ||||||
|  |                             serde_json::from_str::<serde_json::Value>(&credentials_str) | ||||||
|  |                         { | ||||||
|                             credentials["password"].as_str().unwrap_or("").to_string() |                             credentials["password"].as_str().unwrap_or("").to_string() | ||||||
|                         } else { |                         } else { | ||||||
|                             String::new() |                             String::new() | ||||||
| @@ -553,8 +662,12 @@ pub fn App() -> Html { | |||||||
|  |  | ||||||
|                     match calendar_service.fetch_user_info(&token, &password).await { |                     match calendar_service.fetch_user_info(&token, &password).await { | ||||||
|                         Ok(mut info) => { |                         Ok(mut info) => { | ||||||
|                             if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") { |                             if let Ok(saved_colors_json) = | ||||||
|                                 if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) { |                                 LocalStorage::get::<String>("calendar_colors") | ||||||
|  |                             { | ||||||
|  |                                 if let Ok(saved_info) = | ||||||
|  |                                     serde_json::from_str::<UserInfo>(&saved_colors_json) | ||||||
|  |                                 { | ||||||
|                                     for saved_cal in &saved_info.calendars { |                                     for saved_cal in &saved_info.calendars { | ||||||
|                                         for cal in &mut info.calendars { |                                         for cal in &mut info.calendars { | ||||||
|                                             if cal.path == saved_cal.path { |                                             if cal.path == saved_cal.path { | ||||||
| @@ -567,7 +680,9 @@ pub fn App() -> Html { | |||||||
|                             user_info.set(Some(info)); |                             user_info.set(Some(info)); | ||||||
|                         } |                         } | ||||||
|                         Err(err) => { |                         Err(err) => { | ||||||
|                             web_sys::console::log_1(&format!("Failed to refresh calendars: {}", err).into()); |                             web_sys::console::log_1( | ||||||
|  |                                 &format!("Failed to refresh calendars: {}", err).into(), | ||||||
|  |                             ); | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 }); |                 }); | ||||||
| @@ -576,7 +691,9 @@ pub fn App() -> Html { | |||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Debug logging |     // Debug logging | ||||||
|     web_sys::console::log_1(&format!("App rendering: auth_token = {:?}", auth_token.is_some()).into()); |     web_sys::console::log_1( | ||||||
|  |         &format!("App rendering: auth_token = {:?}", auth_token.is_some()).into(), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     html! { |     html! { | ||||||
|         <BrowserRouter> |         <BrowserRouter> | ||||||
| @@ -738,8 +855,10 @@ pub fn App() -> Html { | |||||||
|                         let _event_context_menu_event = event_context_menu_event.clone(); |                         let _event_context_menu_event = event_context_menu_event.clone(); | ||||||
|                         let event_context_menu_open = event_context_menu_open.clone(); |                         let event_context_menu_open = event_context_menu_open.clone(); | ||||||
|                         let create_event_modal_open = create_event_modal_open.clone(); |                         let create_event_modal_open = create_event_modal_open.clone(); | ||||||
|                         move |_| { |                         let event_edit_scope = event_edit_scope.clone(); | ||||||
|                             // Close the context menu and open the edit modal |                         move |edit_action: EditAction| { | ||||||
|  |                             // Set the edit scope and close the context menu | ||||||
|  |                             event_edit_scope.set(Some(edit_action)); | ||||||
|                             event_context_menu_open.set(false); |                             event_context_menu_open.set(false); | ||||||
|                             create_event_modal_open.set(true); |                             create_event_modal_open.set(true); | ||||||
|                         } |                         } | ||||||
| @@ -840,13 +959,16 @@ pub fn App() -> Html { | |||||||
|                     is_open={*create_event_modal_open} |                     is_open={*create_event_modal_open} | ||||||
|                     selected_date={(*selected_date_for_event).clone()} |                     selected_date={(*selected_date_for_event).clone()} | ||||||
|                     event_to_edit={(*event_context_menu_event).clone()} |                     event_to_edit={(*event_context_menu_event).clone()} | ||||||
|  |                     edit_scope={(*event_edit_scope).clone()} | ||||||
|                     on_close={Callback::from({ |                     on_close={Callback::from({ | ||||||
|                         let create_event_modal_open = create_event_modal_open.clone(); |                         let create_event_modal_open = create_event_modal_open.clone(); | ||||||
|                         let event_context_menu_event = event_context_menu_event.clone(); |                         let event_context_menu_event = event_context_menu_event.clone(); | ||||||
|  |                         let event_edit_scope = event_edit_scope.clone(); | ||||||
|                         move |_| { |                         move |_| { | ||||||
|                             create_event_modal_open.set(false); |                             create_event_modal_open.set(false); | ||||||
|                             // Clear the event being edited |                             // Clear the event being edited and edit scope | ||||||
|                             event_context_menu_event.set(None); |                             event_context_menu_event.set(None); | ||||||
|  |                             event_edit_scope.set(None); | ||||||
|                         } |                         } | ||||||
|                     })} |                     })} | ||||||
|                     on_create={on_event_create} |                     on_create={on_event_create} | ||||||
| @@ -854,10 +976,12 @@ pub fn App() -> Html { | |||||||
|                         let auth_token = auth_token.clone(); |                         let auth_token = auth_token.clone(); | ||||||
|                         let create_event_modal_open = create_event_modal_open.clone(); |                         let create_event_modal_open = create_event_modal_open.clone(); | ||||||
|                         let event_context_menu_event = event_context_menu_event.clone(); |                         let event_context_menu_event = event_context_menu_event.clone(); | ||||||
|  |                         let event_edit_scope = event_edit_scope.clone(); | ||||||
|                         move |(original_event, updated_data): (VEvent, EventCreationData)| { |                         move |(original_event, updated_data): (VEvent, EventCreationData)| { | ||||||
|                             web_sys::console::log_1(&format!("Updating event: {:?}", updated_data).into()); |                             web_sys::console::log_1(&format!("Updating event: {:?}, edit_scope: {:?}", updated_data, updated_data.edit_scope).into()); | ||||||
|                             create_event_modal_open.set(false); |                             create_event_modal_open.set(false); | ||||||
|                             event_context_menu_event.set(None); |                             event_context_menu_event.set(None); | ||||||
|  |                             event_edit_scope.set(None); | ||||||
|  |  | ||||||
|                             if let Some(token) = (*auth_token).clone() { |                             if let Some(token) = (*auth_token).clone() { | ||||||
|                                 wasm_bindgen_futures::spawn_local(async move { |                                 wasm_bindgen_futures::spawn_local(async move { | ||||||
| @@ -988,7 +1112,60 @@ pub fn App() -> Html { | |||||||
|                                             web_sys::window().unwrap().alert_with_message("Cannot move event - original event missing calendar path").unwrap(); |                                             web_sys::window().unwrap().alert_with_message("Cannot move event - original event missing calendar path").unwrap(); | ||||||
|                                         } |                                         } | ||||||
|                                     } else { |                                     } else { | ||||||
|                                         // Calendar hasn't changed - normal update |                                         // Calendar hasn't changed - check if we should use series endpoint | ||||||
|  |                                         let use_series_endpoint = updated_data.edit_scope.is_some() && original_event.rrule.is_some(); | ||||||
|  |  | ||||||
|  |                                         if use_series_endpoint { | ||||||
|  |                                             // Use series endpoint for recurring event modal edits | ||||||
|  |                                             let update_scope = match updated_data.edit_scope.as_ref().unwrap() { | ||||||
|  |                                                 EditAction::EditThis => "this_only", | ||||||
|  |                                                 EditAction::EditFuture => "this_and_future", | ||||||
|  |                                                 EditAction::EditAll => "all_in_series", | ||||||
|  |                                             }; | ||||||
|  |  | ||||||
|  |                                             // For single occurrence edits, we need the occurrence date | ||||||
|  |                                             let occurrence_date = if update_scope == "this_only" || update_scope == "this_and_future" { | ||||||
|  |                                                 // Use the original event's start date as the occurrence date | ||||||
|  |                                                 Some(original_event.dtstart.format("%Y-%m-%d").to_string()) | ||||||
|  |                                             } else { | ||||||
|  |                                                 None | ||||||
|  |                                             }; | ||||||
|  |  | ||||||
|  |                                             match calendar_service.update_series( | ||||||
|  |                                                 &token, | ||||||
|  |                                                 &password, | ||||||
|  |                                                 original_event.uid, | ||||||
|  |                                                 updated_data.title, | ||||||
|  |                                                 updated_data.description, | ||||||
|  |                                                 start_date, | ||||||
|  |                                                 start_time, | ||||||
|  |                                                 end_date, | ||||||
|  |                                                 end_time, | ||||||
|  |                                                 updated_data.location, | ||||||
|  |                                                 updated_data.all_day, | ||||||
|  |                                                 status_str, | ||||||
|  |                                                 class_str, | ||||||
|  |                                                 updated_data.priority, | ||||||
|  |                                                 updated_data.organizer, | ||||||
|  |                                                 updated_data.attendees, | ||||||
|  |                                                 updated_data.categories, | ||||||
|  |                                                 reminder_str, | ||||||
|  |                                                 recurrence_str, | ||||||
|  |                                                 updated_data.selected_calendar, | ||||||
|  |                                                 update_scope.to_string(), | ||||||
|  |                                                 occurrence_date, | ||||||
|  |                                             ).await { | ||||||
|  |                                                 Ok(_) => { | ||||||
|  |                                                     web_sys::console::log_1(&"Series updated successfully".into()); | ||||||
|  |                                                     web_sys::window().unwrap().location().reload().unwrap(); | ||||||
|  |                                                 } | ||||||
|  |                                                 Err(err) => { | ||||||
|  |                                                     web_sys::console::error_1(&format!("Failed to update series: {}", err).into()); | ||||||
|  |                                                     web_sys::window().unwrap().alert_with_message(&format!("Failed to update series: {}", err)).unwrap(); | ||||||
|  |                                                 } | ||||||
|  |                                             } | ||||||
|  |                                         } else { | ||||||
|  |                                             // Use regular event endpoint for non-recurring events or legacy updates | ||||||
|                                             match calendar_service.update_event( |                                             match calendar_service.update_event( | ||||||
|                                             &token, |                                             &token, | ||||||
|                                             &password, |                                             &password, | ||||||
| @@ -1026,6 +1203,7 @@ pub fn App() -> Html { | |||||||
|                                             } |                                             } | ||||||
|                                         } |                                         } | ||||||
|                                         } |                                         } | ||||||
|  |                                     } | ||||||
|                                 }); |                                 }); | ||||||
|                             } |                             } | ||||||
|                         } |                         } | ||||||
|   | |||||||
| @@ -11,11 +11,22 @@ pub struct CalDAVLoginRequest { | |||||||
|     pub password: String, |     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)] | #[derive(Debug, Serialize, Deserialize)] | ||||||
| pub struct AuthResponse { | pub struct AuthResponse { | ||||||
|     pub token: String, |     pub token: String, | ||||||
|  |     pub session_token: String, | ||||||
|     pub username: String, |     pub username: String, | ||||||
|     pub server_url: String, |     pub server_url: String, | ||||||
|  |     pub preferences: UserPreferencesResponse, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Debug, Deserialize)] | #[derive(Debug, Deserialize)] | ||||||
| @@ -50,8 +61,8 @@ impl AuthService { | |||||||
|     ) -> Result<R, String> { |     ) -> Result<R, String> { | ||||||
|         let window = web_sys::window().ok_or("No global window exists")?; |         let window = web_sys::window().ok_or("No global window exists")?; | ||||||
|  |  | ||||||
|         let json_body = serde_json::to_string(body) |         let json_body = | ||||||
|             .map_err(|e| format!("JSON serialization failed: {}", e))?; |             serde_json::to_string(body).map_err(|e| format!("JSON serialization failed: {}", e))?; | ||||||
|  |  | ||||||
|         let opts = RequestInit::new(); |         let opts = RequestInit::new(); | ||||||
|         opts.set_method("POST"); |         opts.set_method("POST"); | ||||||
| @@ -62,23 +73,27 @@ impl AuthService { | |||||||
|         let request = Request::new_with_str_and_init(&url, &opts) |         let request = Request::new_with_str_and_init(&url, &opts) | ||||||
|             .map_err(|e| format!("Request creation failed: {:?}", e))?; |             .map_err(|e| format!("Request creation failed: {:?}", e))?; | ||||||
|  |  | ||||||
|         request.headers().set("Content-Type", "application/json") |         request | ||||||
|  |             .headers() | ||||||
|  |             .set("Content-Type", "application/json") | ||||||
|             .map_err(|e| format!("Header setting failed: {:?}", e))?; |             .map_err(|e| format!("Header setting failed: {:?}", e))?; | ||||||
|  |  | ||||||
|         let resp_value = JsFuture::from(window.fetch_with_request(&request)) |         let resp_value = JsFuture::from(window.fetch_with_request(&request)) | ||||||
|             .await |             .await | ||||||
|             .map_err(|e| format!("Network request failed: {:?}", e))?; |             .map_err(|e| format!("Network request failed: {:?}", e))?; | ||||||
|  |  | ||||||
|         let resp: Response = resp_value.dyn_into() |         let resp: Response = resp_value | ||||||
|  |             .dyn_into() | ||||||
|             .map_err(|e| format!("Response cast failed: {:?}", e))?; |             .map_err(|e| format!("Response cast failed: {:?}", e))?; | ||||||
|  |  | ||||||
|         let text = JsFuture::from(resp.text() |         let text = JsFuture::from( | ||||||
|             .map_err(|e| format!("Text extraction failed: {:?}", e))?) |             resp.text() | ||||||
|  |                 .map_err(|e| format!("Text extraction failed: {:?}", e))?, | ||||||
|  |         ) | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| format!("Text promise failed: {:?}", e))?; |         .map_err(|e| format!("Text promise failed: {:?}", e))?; | ||||||
|  |  | ||||||
|         let text_string = text.as_string() |         let text_string = text.as_string().ok_or("Response text is not a string")?; | ||||||
|             .ok_or("Response text is not a string")?; |  | ||||||
|  |  | ||||||
|         if resp.ok() { |         if resp.ok() { | ||||||
|             serde_json::from_str::<R>(&text_string) |             serde_json::from_str::<R>(&text_string) | ||||||
|   | |||||||
| @@ -1,19 +1,16 @@ | |||||||
| use yew::prelude::*; | use crate::components::{ | ||||||
| use chrono::{Datelike, Local, NaiveDate, Duration}; |     CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView, | ||||||
|  | }; | ||||||
|  | use crate::models::ical::VEvent; | ||||||
|  | use crate::services::{calendar_service::UserInfo, CalendarService}; | ||||||
|  | use chrono::{Datelike, Duration, Local, NaiveDate}; | ||||||
|  | use gloo_storage::{LocalStorage, Storage}; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use web_sys::MouseEvent; | use web_sys::MouseEvent; | ||||||
| use crate::services::calendar_service::UserInfo; | use yew::prelude::*; | ||||||
| use crate::models::ical::VEvent; |  | ||||||
| use crate::components::{EventModal, ViewMode, CalendarHeader, MonthView, WeekView, CreateEventModal, EventCreationData}; |  | ||||||
| use gloo_storage::{LocalStorage, Storage}; |  | ||||||
|  |  | ||||||
| #[derive(Properties, PartialEq)] | #[derive(Properties, PartialEq)] | ||||||
| pub struct CalendarProps { | pub struct CalendarProps { | ||||||
|     #[prop_or_default] |  | ||||||
|     pub events: HashMap<NaiveDate, Vec<VEvent>>, |  | ||||||
|     pub on_event_click: Callback<VEvent>, |  | ||||||
|     #[prop_or_default] |  | ||||||
|     pub refreshing_event_uid: Option<String>, |  | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub user_info: Option<UserInfo>, |     pub user_info: Option<UserInfo>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
| @@ -25,7 +22,17 @@ pub struct CalendarProps { | |||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub on_create_event_request: Option<Callback<EventCreationData>>, |     pub on_create_event_request: Option<Callback<EventCreationData>>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>, |     pub on_event_update_request: Option< | ||||||
|  |         Callback<( | ||||||
|  |             VEvent, | ||||||
|  |             chrono::NaiveDateTime, | ||||||
|  |             chrono::NaiveDateTime, | ||||||
|  |             bool, | ||||||
|  |             Option<chrono::DateTime<chrono::Utc>>, | ||||||
|  |             Option<String>, | ||||||
|  |             Option<String>, | ||||||
|  |         )>, | ||||||
|  |     >, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub context_menus_open: bool, |     pub context_menus_open: bool, | ||||||
| } | } | ||||||
| @@ -33,6 +40,12 @@ pub struct CalendarProps { | |||||||
| #[function_component] | #[function_component] | ||||||
| pub fn Calendar(props: &CalendarProps) -> Html { | pub fn Calendar(props: &CalendarProps) -> Html { | ||||||
|     let today = Local::now().date_naive(); |     let today = Local::now().date_naive(); | ||||||
|  |      | ||||||
|  |     // Event management state | ||||||
|  |     let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new()); | ||||||
|  |     let loading = use_state(|| true); | ||||||
|  |     let error = use_state(|| None::<String>); | ||||||
|  |     let refreshing_event_uid = use_state(|| None::<String>); | ||||||
|     // Track the currently selected date (the actual day the user has selected) |     // Track the currently selected date (the actual day the user has selected) | ||||||
|     let selected_date = use_state(|| { |     let selected_date = use_state(|| { | ||||||
|         // Try to load saved selected date from localStorage |         // Try to load saved selected date from localStorage | ||||||
| @@ -57,17 +70,16 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     // Track the display date (what to show in the view) |     // Track the display date (what to show in the view) | ||||||
|     let current_date = use_state(|| { |     let current_date = use_state(|| match props.view { | ||||||
|         match props.view { |  | ||||||
|         ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date), |         ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date), | ||||||
|         ViewMode::Week => *selected_date, |         ViewMode::Week => *selected_date, | ||||||
|         } |  | ||||||
|     }); |     }); | ||||||
|     let selected_event = use_state(|| None::<VEvent>); |     let selected_event = use_state(|| None::<VEvent>); | ||||||
|  |  | ||||||
|     // State for create event modal |     // State for create event modal | ||||||
|     let show_create_modal = use_state(|| false); |     let show_create_modal = use_state(|| false); | ||||||
|     let create_event_data = use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>); |     let create_event_data = | ||||||
|  |         use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>); | ||||||
|  |  | ||||||
|     // State for time increment snapping (15 or 30 minutes) |     // State for time increment snapping (15 or 30 minutes) | ||||||
|     let time_increment = use_state(|| { |     let time_increment = use_state(|| { | ||||||
| @@ -83,6 +95,154 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  |     // Fetch events when current_date changes | ||||||
|  |     { | ||||||
|  |         let events = events.clone(); | ||||||
|  |         let loading = loading.clone(); | ||||||
|  |         let error = error.clone(); | ||||||
|  |         let current_date = current_date.clone(); | ||||||
|  |          | ||||||
|  |         use_effect_with((*current_date, props.view.clone()), move |(date, _view)| { | ||||||
|  |             let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); | ||||||
|  |             let date = *date; // Clone the date to avoid lifetime issues | ||||||
|  |              | ||||||
|  |             if let Some(token) = auth_token { | ||||||
|  |                 let events = events.clone(); | ||||||
|  |                 let loading = loading.clone(); | ||||||
|  |                 let error = error.clone(); | ||||||
|  |                  | ||||||
|  |                 wasm_bindgen_futures::spawn_local(async move { | ||||||
|  |                     let calendar_service = CalendarService::new(); | ||||||
|  |  | ||||||
|  |                     let password = if let Ok(credentials_str) = | ||||||
|  |                         LocalStorage::get::<String>("caldav_credentials") | ||||||
|  |                     { | ||||||
|  |                         if let Ok(credentials) = | ||||||
|  |                             serde_json::from_str::<serde_json::Value>(&credentials_str) | ||||||
|  |                         { | ||||||
|  |                             credentials["password"].as_str().unwrap_or("").to_string() | ||||||
|  |                         } else { | ||||||
|  |                             String::new() | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         String::new() | ||||||
|  |                     }; | ||||||
|  |  | ||||||
|  |                     let current_year = date.year(); | ||||||
|  |                     let current_month = date.month(); | ||||||
|  |  | ||||||
|  |                     match calendar_service | ||||||
|  |                         .fetch_events_for_month_vevent( | ||||||
|  |                             &token, | ||||||
|  |                             &password, | ||||||
|  |                             current_year, | ||||||
|  |                             current_month, | ||||||
|  |                         ) | ||||||
|  |                         .await | ||||||
|  |                     { | ||||||
|  |                         Ok(vevents) => { | ||||||
|  |                             let grouped_events = CalendarService::group_events_by_date(vevents); | ||||||
|  |                             events.set(grouped_events); | ||||||
|  |                             loading.set(false); | ||||||
|  |                         } | ||||||
|  |                         Err(err) => { | ||||||
|  |                             error.set(Some(format!("Failed to load events: {}", err))); | ||||||
|  |                             loading.set(false); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 }); | ||||||
|  |             } else { | ||||||
|  |                 loading.set(false); | ||||||
|  |                 error.set(Some("No authentication token found".to_string())); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             || () | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Handle event click to refresh individual events | ||||||
|  |     let on_event_click = { | ||||||
|  |         let events = events.clone(); | ||||||
|  |         let refreshing_event_uid = refreshing_event_uid.clone(); | ||||||
|  |          | ||||||
|  |         Callback::from(move |event: VEvent| { | ||||||
|  |             let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); | ||||||
|  |              | ||||||
|  |             if let Some(token) = auth_token { | ||||||
|  |                 let events = events.clone(); | ||||||
|  |                 let refreshing_event_uid = refreshing_event_uid.clone(); | ||||||
|  |                 let uid = event.uid.clone(); | ||||||
|  |  | ||||||
|  |                 refreshing_event_uid.set(Some(uid.clone())); | ||||||
|  |  | ||||||
|  |                 wasm_bindgen_futures::spawn_local(async move { | ||||||
|  |                     let calendar_service = CalendarService::new(); | ||||||
|  |  | ||||||
|  |                     let password = if let Ok(credentials_str) = | ||||||
|  |                         LocalStorage::get::<String>("caldav_credentials") | ||||||
|  |                     { | ||||||
|  |                         if let Ok(credentials) = | ||||||
|  |                             serde_json::from_str::<serde_json::Value>(&credentials_str) | ||||||
|  |                         { | ||||||
|  |                             credentials["password"].as_str().unwrap_or("").to_string() | ||||||
|  |                         } else { | ||||||
|  |                             String::new() | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         String::new() | ||||||
|  |                     }; | ||||||
|  |  | ||||||
|  |                     match calendar_service | ||||||
|  |                         .refresh_event(&token, &password, &uid) | ||||||
|  |                         .await | ||||||
|  |                     { | ||||||
|  |                         Ok(Some(refreshed_event)) => { | ||||||
|  |                             let refreshed_vevent = refreshed_event; | ||||||
|  |                             let mut updated_events = (*events).clone(); | ||||||
|  |  | ||||||
|  |                             for (_, day_events) in updated_events.iter_mut() { | ||||||
|  |                                 day_events.retain(|e| e.uid != uid); | ||||||
|  |                             } | ||||||
|  |  | ||||||
|  |                             if refreshed_vevent.rrule.is_some() { | ||||||
|  |                                 let new_occurrences = | ||||||
|  |                                     CalendarService::expand_recurring_events(vec![ | ||||||
|  |                                         refreshed_vevent.clone(), | ||||||
|  |                                     ]); | ||||||
|  |  | ||||||
|  |                                 for occurrence in new_occurrences { | ||||||
|  |                                     let date = occurrence.get_date(); | ||||||
|  |                                     updated_events | ||||||
|  |                                         .entry(date) | ||||||
|  |                                         .or_insert_with(Vec::new) | ||||||
|  |                                         .push(occurrence); | ||||||
|  |                                 } | ||||||
|  |                             } else { | ||||||
|  |                                 let date = refreshed_vevent.get_date(); | ||||||
|  |                                 updated_events | ||||||
|  |                                     .entry(date) | ||||||
|  |                                     .or_insert_with(Vec::new) | ||||||
|  |                                     .push(refreshed_vevent); | ||||||
|  |                             } | ||||||
|  |  | ||||||
|  |                             events.set(updated_events); | ||||||
|  |                         } | ||||||
|  |                         Ok(None) => { | ||||||
|  |                             let mut updated_events = (*events).clone(); | ||||||
|  |                             for (_, day_events) in updated_events.iter_mut() { | ||||||
|  |                                 day_events.retain(|e| e.uid != uid); | ||||||
|  |                             } | ||||||
|  |                             events.set(updated_events); | ||||||
|  |                         } | ||||||
|  |                         Err(_err) => {} | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     refreshing_event_uid.set(None); | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|     // Handle view mode changes - adjust current_date format when switching between month/week |     // Handle view mode changes - adjust current_date format when switching between month/week | ||||||
|     { |     { | ||||||
|         let current_date = current_date.clone(); |         let current_date = current_date.clone(); | ||||||
| @@ -110,16 +270,19 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                     let prev_month = *current_date - Duration::days(1); |                     let prev_month = *current_date - Duration::days(1); | ||||||
|                     let first_of_prev = prev_month.with_day(1).unwrap(); |                     let first_of_prev = prev_month.with_day(1).unwrap(); | ||||||
|                     (first_of_prev, first_of_prev) |                     (first_of_prev, first_of_prev) | ||||||
|                 }, |                 } | ||||||
|                 ViewMode::Week => { |                 ViewMode::Week => { | ||||||
|                     // Go to previous week |                     // Go to previous week | ||||||
|                     let prev_week = *selected_date - Duration::weeks(1); |                     let prev_week = *selected_date - Duration::weeks(1); | ||||||
|                     (prev_week, prev_week) |                     (prev_week, prev_week) | ||||||
|                 }, |                 } | ||||||
|             }; |             }; | ||||||
|             selected_date.set(new_selected); |             selected_date.set(new_selected); | ||||||
|             current_date.set(new_display); |             current_date.set(new_display); | ||||||
|             let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string()); |             let _ = LocalStorage::set( | ||||||
|  |                 "calendar_selected_date", | ||||||
|  |                 new_selected.format("%Y-%m-%d").to_string(), | ||||||
|  |             ); | ||||||
|         }) |         }) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
| @@ -134,19 +297,23 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                     let next_month = if current_date.month() == 12 { |                     let next_month = if current_date.month() == 12 { | ||||||
|                         NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap() |                         NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap() | ||||||
|                     } else { |                     } else { | ||||||
|                         NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1).unwrap() |                         NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1) | ||||||
|  |                             .unwrap() | ||||||
|                     }; |                     }; | ||||||
|                     (next_month, next_month) |                     (next_month, next_month) | ||||||
|                 }, |                 } | ||||||
|                 ViewMode::Week => { |                 ViewMode::Week => { | ||||||
|                     // Go to next week |                     // Go to next week | ||||||
|                     let next_week = *selected_date + Duration::weeks(1); |                     let next_week = *selected_date + Duration::weeks(1); | ||||||
|                     (next_week, next_week) |                     (next_week, next_week) | ||||||
|                 }, |                 } | ||||||
|             }; |             }; | ||||||
|             selected_date.set(new_selected); |             selected_date.set(new_selected); | ||||||
|             current_date.set(new_display); |             current_date.set(new_display); | ||||||
|             let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string()); |             let _ = LocalStorage::set( | ||||||
|  |                 "calendar_selected_date", | ||||||
|  |                 new_selected.format("%Y-%m-%d").to_string(), | ||||||
|  |             ); | ||||||
|         }) |         }) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
| @@ -160,12 +327,15 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                 ViewMode::Month => { |                 ViewMode::Month => { | ||||||
|                     let first_of_today = today.with_day(1).unwrap(); |                     let first_of_today = today.with_day(1).unwrap(); | ||||||
|                     (today, first_of_today) // Select today, but display the month |                     (today, first_of_today) // Select today, but display the month | ||||||
|                 }, |                 } | ||||||
|                 ViewMode::Week => (today, today), // Select and display today |                 ViewMode::Week => (today, today), // Select and display today | ||||||
|             }; |             }; | ||||||
|             selected_date.set(new_selected); |             selected_date.set(new_selected); | ||||||
|             current_date.set(new_display); |             current_date.set(new_display); | ||||||
|             let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string()); |             let _ = LocalStorage::set( | ||||||
|  |                 "calendar_selected_date", | ||||||
|  |                 new_selected.format("%Y-%m-%d").to_string(), | ||||||
|  |             ); | ||||||
|         }) |         }) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
| @@ -184,22 +354,58 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|     let on_create_event = { |     let on_create_event = { | ||||||
|         let show_create_modal = show_create_modal.clone(); |         let show_create_modal = show_create_modal.clone(); | ||||||
|         let create_event_data = create_event_data.clone(); |         let create_event_data = create_event_data.clone(); | ||||||
|         Callback::from(move |(_date, start_datetime, end_datetime): (NaiveDate, chrono::NaiveDateTime, chrono::NaiveDateTime)| { |         Callback::from( | ||||||
|  |             move |(_date, start_datetime, end_datetime): ( | ||||||
|  |                 NaiveDate, | ||||||
|  |                 chrono::NaiveDateTime, | ||||||
|  |                 chrono::NaiveDateTime, | ||||||
|  |             )| { | ||||||
|                 // For drag-to-create, we don't need the temporary event approach |                 // For drag-to-create, we don't need the temporary event approach | ||||||
|                 // Instead, just pass the local times directly via initial_time props |                 // 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()))); |                 create_event_data.set(Some(( | ||||||
|  |                     start_datetime.date(), | ||||||
|  |                     start_datetime.time(), | ||||||
|  |                     end_datetime.time(), | ||||||
|  |                 ))); | ||||||
|                 show_create_modal.set(true); |                 show_create_modal.set(true); | ||||||
|         }) |             }, | ||||||
|  |         ) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Handle drag-to-move event |     // Handle drag-to-move event | ||||||
|     let on_event_update = { |     let on_event_update = { | ||||||
|         let on_event_update_request = props.on_event_update_request.clone(); |         let on_event_update_request = props.on_event_update_request.clone(); | ||||||
|         Callback::from(move |(event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)| { |         Callback::from( | ||||||
|  |             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 { |                 if let Some(callback) = &on_event_update_request { | ||||||
|                 callback.emit((event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date)); |                     callback.emit(( | ||||||
|  |                         event, | ||||||
|  |                         new_start, | ||||||
|  |                         new_end, | ||||||
|  |                         preserve_rrule, | ||||||
|  |                         until_date, | ||||||
|  |                         update_scope, | ||||||
|  |                         occurrence_date, | ||||||
|  |                     )); | ||||||
|                 } |                 } | ||||||
|         }) |             }, | ||||||
|  |         ) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     html! { |     html! { | ||||||
| @@ -215,6 +421,19 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|             /> |             /> | ||||||
|  |  | ||||||
|             { |             { | ||||||
|  |                 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 { |                     match props.view { | ||||||
|                     ViewMode::Month => { |                     ViewMode::Month => { | ||||||
|                         let on_day_select = { |                         let on_day_select = { | ||||||
| @@ -229,9 +448,9 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                             <MonthView |                             <MonthView | ||||||
|                                 current_month={*current_date} |                                 current_month={*current_date} | ||||||
|                                 today={today} |                                 today={today} | ||||||
|                                 events={props.events.clone()} |                                 events={(*events).clone()} | ||||||
|                                 on_event_click={props.on_event_click.clone()} |                                 on_event_click={on_event_click.clone()} | ||||||
|                                 refreshing_event_uid={props.refreshing_event_uid.clone()} |                                 refreshing_event_uid={(*refreshing_event_uid).clone()} | ||||||
|                                 user_info={props.user_info.clone()} |                                 user_info={props.user_info.clone()} | ||||||
|                                 on_event_context_menu={props.on_event_context_menu.clone()} |                                 on_event_context_menu={props.on_event_context_menu.clone()} | ||||||
|                                 on_calendar_context_menu={props.on_calendar_context_menu.clone()} |                                 on_calendar_context_menu={props.on_calendar_context_menu.clone()} | ||||||
| @@ -244,9 +463,9 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                         <WeekView |                         <WeekView | ||||||
|                             current_date={*current_date} |                             current_date={*current_date} | ||||||
|                             today={today} |                             today={today} | ||||||
|                             events={props.events.clone()} |                             events={(*events).clone()} | ||||||
|                             on_event_click={props.on_event_click.clone()} |                             on_event_click={on_event_click.clone()} | ||||||
|                             refreshing_event_uid={props.refreshing_event_uid.clone()} |                             refreshing_event_uid={(*refreshing_event_uid).clone()} | ||||||
|                             user_info={props.user_info.clone()} |                             user_info={props.user_info.clone()} | ||||||
|                             on_event_context_menu={props.on_event_context_menu.clone()} |                             on_event_context_menu={props.on_event_context_menu.clone()} | ||||||
|                             on_calendar_context_menu={props.on_calendar_context_menu.clone()} |                             on_calendar_context_menu={props.on_calendar_context_menu.clone()} | ||||||
| @@ -259,6 +478,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                     }, |                     }, | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|             // Event details modal |             // Event details modal | ||||||
|             <EventModal |             <EventModal | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| use yew::prelude::*; |  | ||||||
| use web_sys::MouseEvent; | use web_sys::MouseEvent; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
| #[derive(Properties, PartialEq)] | #[derive(Properties, PartialEq)] | ||||||
| pub struct CalendarContextMenuProps { | pub struct CalendarContextMenuProps { | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| use yew::prelude::*; |  | ||||||
| use chrono::{NaiveDate, Datelike}; |  | ||||||
| use crate::components::ViewMode; | use crate::components::ViewMode; | ||||||
|  | use chrono::{Datelike, NaiveDate}; | ||||||
| use web_sys::MouseEvent; | use web_sys::MouseEvent; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
| #[derive(Properties, PartialEq)] | #[derive(Properties, PartialEq)] | ||||||
| pub struct CalendarHeaderProps { | pub struct CalendarHeaderProps { | ||||||
| @@ -18,7 +18,11 @@ pub struct CalendarHeaderProps { | |||||||
|  |  | ||||||
| #[function_component(CalendarHeader)] | #[function_component(CalendarHeader)] | ||||||
| pub fn calendar_header(props: &CalendarHeaderProps) -> Html { | pub fn calendar_header(props: &CalendarHeaderProps) -> Html { | ||||||
|     let title = format!("{} {}", get_month_name(props.current_date.month()), props.current_date.year()); |     let title = format!( | ||||||
|  |         "{} {}", | ||||||
|  |         get_month_name(props.current_date.month()), | ||||||
|  |         props.current_date.year() | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     html! { |     html! { | ||||||
|         <div class="calendar-header"> |         <div class="calendar-header"> | ||||||
| @@ -59,6 +63,6 @@ fn get_month_name(month: u32) -> &'static str { | |||||||
|         10 => "October", |         10 => "October", | ||||||
|         11 => "November", |         11 => "November", | ||||||
|         12 => "December", |         12 => "December", | ||||||
|         _ => "Invalid" |         _ => "Invalid", | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,6 +1,6 @@ | |||||||
| use yew::prelude::*; |  | ||||||
| use web_sys::MouseEvent; |  | ||||||
| use crate::services::calendar_service::CalendarInfo; | use crate::services::calendar_service::CalendarInfo; | ||||||
|  | use web_sys::MouseEvent; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
| #[derive(Properties, PartialEq)] | #[derive(Properties, PartialEq)] | ||||||
| pub struct CalendarListItemProps { | pub struct CalendarListItemProps { | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| use yew::prelude::*; |  | ||||||
| use web_sys::MouseEvent; | use web_sys::MouseEvent; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
| #[derive(Properties, PartialEq)] | #[derive(Properties, PartialEq)] | ||||||
| pub struct ContextMenuProps { | pub struct ContextMenuProps { | ||||||
|   | |||||||
| @@ -50,7 +50,9 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html { | |||||||
|             } |             } | ||||||
|  |  | ||||||
|             if name.len() > 100 { |             if name.len() > 100 { | ||||||
|                 error_message.set(Some("Calendar name too long (max 100 characters)".to_string())); |                 error_message.set(Some( | ||||||
|  |                     "Calendar name too long (max 100 characters)".to_string(), | ||||||
|  |                 )); | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,9 +1,10 @@ | |||||||
| use yew::prelude::*; | use crate::components::EditAction; | ||||||
| use web_sys::{HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement}; |  | ||||||
| use wasm_bindgen::JsCast; |  | ||||||
| use chrono::{NaiveDate, NaiveTime, Local, TimeZone, Utc, Datelike}; |  | ||||||
| use crate::services::calendar_service::CalendarInfo; |  | ||||||
| use crate::models::ical::VEvent; | use crate::models::ical::VEvent; | ||||||
|  | use crate::services::calendar_service::CalendarInfo; | ||||||
|  | use chrono::{Datelike, Local, NaiveDate, NaiveTime, TimeZone, Utc}; | ||||||
|  | use wasm_bindgen::JsCast; | ||||||
|  | use web_sys::{HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement}; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
| #[derive(Properties, PartialEq)] | #[derive(Properties, PartialEq)] | ||||||
| pub struct CreateEventModalProps { | pub struct CreateEventModalProps { | ||||||
| @@ -18,6 +19,8 @@ pub struct CreateEventModalProps { | |||||||
|     pub initial_start_time: Option<NaiveTime>, |     pub initial_start_time: Option<NaiveTime>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub initial_end_time: Option<NaiveTime>, |     pub initial_end_time: Option<NaiveTime>, | ||||||
|  |     #[prop_or_default] | ||||||
|  |     pub edit_scope: Option<EditAction>, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Clone, PartialEq, Debug)] | #[derive(Clone, PartialEq, Debug)] | ||||||
| @@ -33,7 +36,6 @@ impl Default for EventStatus { | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| #[derive(Clone, PartialEq, Debug)] | #[derive(Clone, PartialEq, Debug)] | ||||||
| pub enum EventClass { | pub enum EventClass { | ||||||
|     Public, |     Public, | ||||||
| @@ -47,7 +49,6 @@ impl Default for EventClass { | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| #[derive(Clone, PartialEq, Debug)] | #[derive(Clone, PartialEq, Debug)] | ||||||
| pub enum ReminderType { | pub enum ReminderType { | ||||||
|     None, |     None, | ||||||
| @@ -81,7 +82,6 @@ impl Default for RecurrenceType { | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| /// Parse RRULE string into recurrence components | /// Parse RRULE string into recurrence components | ||||||
| /// Example RRULE: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;UNTIL=20231215T000000Z" | /// Example RRULE: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;UNTIL=20231215T000000Z" | ||||||
| #[derive(Debug, Default, Clone)] | #[derive(Debug, Default, Clone)] | ||||||
| @@ -142,9 +142,7 @@ fn parse_rrule(rrule: Option<&str>) -> ParsedRrule { | |||||||
|             } |             } | ||||||
|             "BYDAY" => { |             "BYDAY" => { | ||||||
|                 // Parse BYDAY: "MO,WE,FR" or "1MO,-1SU" (with position) |                 // Parse BYDAY: "MO,WE,FR" or "1MO,-1SU" (with position) | ||||||
|                 parsed.byday = value.split(',') |                 parsed.byday = value.split(',').map(|s| s.trim().to_uppercase()).collect(); | ||||||
|                     .map(|s| s.trim().to_uppercase()) |  | ||||||
|                     .collect(); |  | ||||||
|             } |             } | ||||||
|             "BYMONTHDAY" => { |             "BYMONTHDAY" => { | ||||||
|                 // Parse BYMONTHDAY: "15" or "1,15,31" |                 // Parse BYMONTHDAY: "15" or "1,15,31" | ||||||
| @@ -158,7 +156,8 @@ fn parse_rrule(rrule: Option<&str>) -> ParsedRrule { | |||||||
|             } |             } | ||||||
|             "BYMONTH" => { |             "BYMONTH" => { | ||||||
|                 // Parse BYMONTH: "1,3,5" (January, March, May) |                 // Parse BYMONTH: "1,3,5" (January, March, May) | ||||||
|                 parsed.bymonth = value.split(',') |                 parsed.bymonth = value | ||||||
|  |                     .split(',') | ||||||
|                     .filter_map(|m| m.trim().parse::<u8>().ok()) |                     .filter_map(|m| m.trim().parse::<u8>().ok()) | ||||||
|                     .filter(|&m| m >= 1 && m <= 12) |                     .filter(|&m| m >= 1 && m <= 12) | ||||||
|                     .collect(); |                     .collect(); | ||||||
| @@ -218,7 +217,8 @@ fn bymonth_to_monthly_array(bymonth: &[u8]) -> Vec<bool> { | |||||||
| /// Extract positioned weekday from BYDAY for monthly recurrence | /// Extract positioned weekday from BYDAY for monthly recurrence | ||||||
| /// Examples: "1MO" -> Some("1MO"), "2TU" -> Some("2TU"), "-1SU" -> Some("-1SU") | /// Examples: "1MO" -> Some("1MO"), "2TU" -> Some("2TU"), "-1SU" -> Some("-1SU") | ||||||
| fn extract_monthly_byday(byday: &[String]) -> Option<String> { | fn extract_monthly_byday(byday: &[String]) -> Option<String> { | ||||||
|     byday.iter() |     byday | ||||||
|  |         .iter() | ||||||
|         .find(|day| day.len() > 2) // Positioned days have length > 2 |         .find(|day| day.len() > 2) // Positioned days have length > 2 | ||||||
|         .cloned() |         .cloned() | ||||||
| } | } | ||||||
| @@ -237,7 +237,9 @@ mod rrule_tests { | |||||||
|  |  | ||||||
|     #[test] |     #[test] | ||||||
|     fn test_parse_complex_monthly() { |     fn test_parse_complex_monthly() { | ||||||
|         let parsed = parse_rrule(Some("FREQ=MONTHLY;INTERVAL=2;BYDAY=1MO;UNTIL=20241231T000000Z")); |         let parsed = parse_rrule(Some( | ||||||
|  |             "FREQ=MONTHLY;INTERVAL=2;BYDAY=1MO;UNTIL=20241231T000000Z", | ||||||
|  |         )); | ||||||
|         assert_eq!(parsed.freq, RecurrenceType::Monthly); |         assert_eq!(parsed.freq, RecurrenceType::Monthly); | ||||||
|         assert_eq!(parsed.interval, 2); |         assert_eq!(parsed.interval, 2); | ||||||
|         assert_eq!(parsed.byday, vec!["1MO"]); |         assert_eq!(parsed.byday, vec!["1MO"]); | ||||||
| @@ -246,7 +248,8 @@ mod rrule_tests { | |||||||
|  |  | ||||||
|     #[test] |     #[test] | ||||||
|     fn test_byday_to_weekday_array() { |     fn test_byday_to_weekday_array() { | ||||||
|         let weekdays = byday_to_weekday_array(&["MO".to_string(), "WE".to_string(), "FR".to_string()]); |         let weekdays = | ||||||
|  |             byday_to_weekday_array(&["MO".to_string(), "WE".to_string(), "FR".to_string()]); | ||||||
|         // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] |         // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] | ||||||
|         assert_eq!(weekdays, vec![false, true, false, true, false, true, false]); |         assert_eq!(weekdays, vec![false, true, false, true, false, true, false]); | ||||||
|     } |     } | ||||||
| @@ -292,14 +295,15 @@ mod rrule_tests { | |||||||
|     fn test_build_rrule_yearly() { |     fn test_build_rrule_yearly() { | ||||||
|         let mut data = EventCreationData::default(); |         let mut data = EventCreationData::default(); | ||||||
|         data.recurrence = RecurrenceType::Yearly; |         data.recurrence = RecurrenceType::Yearly; | ||||||
|         data.yearly_by_month = vec![false, false, true, false, true, false, false, false, false, false, false, false]; // March, May |         data.yearly_by_month = vec![ | ||||||
|  |             false, false, true, false, true, false, false, false, false, false, false, false, | ||||||
|  |         ]; // March, May | ||||||
|  |  | ||||||
|         let rrule = data.build_rrule(); |         let rrule = data.build_rrule(); | ||||||
|         println!("YEARLY RRULE: {}", rrule); |         println!("YEARLY RRULE: {}", rrule); | ||||||
|         assert!(rrule.contains("FREQ=YEARLY")); |         assert!(rrule.contains("FREQ=YEARLY")); | ||||||
|         assert!(rrule.contains("BYMONTH=3,5")); |         assert!(rrule.contains("BYMONTH=3,5")); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Clone, PartialEq, Debug)] | #[derive(Clone, PartialEq, Debug)] | ||||||
| @@ -330,6 +334,10 @@ pub struct EventCreationData { | |||||||
|     pub monthly_by_day: Option<String>, // For monthly: "1MO" = first Monday, "2TU" = second Tuesday, etc. |     pub monthly_by_day: Option<String>, // For monthly: "1MO" = first Monday, "2TU" = second Tuesday, etc. | ||||||
|     pub monthly_by_monthday: Option<u8>, // For monthly: day of month (1-31) |     pub monthly_by_monthday: Option<u8>, // For monthly: day of month (1-31) | ||||||
|     pub yearly_by_month: Vec<bool>,     // For yearly: [Jan, Feb, Mar, ..., Dec] |     pub yearly_by_month: Vec<bool>,     // For yearly: [Jan, Feb, Mar, ..., Dec] | ||||||
|  |  | ||||||
|  |     // Edit scope and tracking fields | ||||||
|  |     pub edit_scope: Option<EditAction>, | ||||||
|  |     pub changed_fields: Vec<String>, // List of field names that were changed | ||||||
| } | } | ||||||
|  |  | ||||||
| impl Default for EventCreationData { | impl Default for EventCreationData { | ||||||
| @@ -365,6 +373,10 @@ impl Default for EventCreationData { | |||||||
|             monthly_by_day: None, |             monthly_by_day: None, | ||||||
|             monthly_by_monthday: None, |             monthly_by_monthday: None, | ||||||
|             yearly_by_month: vec![false; 12], // [Jan, Feb, ..., Dec] - all false by default |             yearly_by_month: vec![false; 12], // [Jan, Feb, ..., Dec] - all false by default | ||||||
|  |  | ||||||
|  |             // Edit scope and tracking defaults | ||||||
|  |             edit_scope: None, | ||||||
|  |             changed_fields: vec![], | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -396,9 +408,12 @@ impl EventCreationData { | |||||||
|         match self.recurrence { |         match self.recurrence { | ||||||
|             RecurrenceType::Weekly => { |             RecurrenceType::Weekly => { | ||||||
|                 // Add BYDAY for weekly recurrence |                 // Add BYDAY for weekly recurrence | ||||||
|                 let selected_days: Vec<&str> = self.recurrence_days.iter() |                 let selected_days: Vec<&str> = self | ||||||
|  |                     .recurrence_days | ||||||
|  |                     .iter() | ||||||
|                     .enumerate() |                     .enumerate() | ||||||
|                     .filter_map(|(i, &selected)| if selected { |                     .filter_map(|(i, &selected)| { | ||||||
|  |                         if selected { | ||||||
|                             Some(match i { |                             Some(match i { | ||||||
|                                 0 => "SU", // Sunday |                                 0 => "SU", // Sunday | ||||||
|                                 1 => "MO", // Monday |                                 1 => "MO", // Monday | ||||||
| @@ -411,6 +426,7 @@ impl EventCreationData { | |||||||
|                             }) |                             }) | ||||||
|                         } else { |                         } else { | ||||||
|                             None |                             None | ||||||
|  |                         } | ||||||
|                     }) |                     }) | ||||||
|                     .filter(|s| !s.is_empty()) |                     .filter(|s| !s.is_empty()) | ||||||
|                     .collect(); |                     .collect(); | ||||||
| @@ -418,7 +434,7 @@ impl EventCreationData { | |||||||
|                 if !selected_days.is_empty() { |                 if !selected_days.is_empty() { | ||||||
|                     parts.push(format!("BYDAY={}", selected_days.join(","))); |                     parts.push(format!("BYDAY={}", selected_days.join(","))); | ||||||
|                 } |                 } | ||||||
|             }, |             } | ||||||
|             RecurrenceType::Monthly => { |             RecurrenceType::Monthly => { | ||||||
|                 // Add BYDAY or BYMONTHDAY for monthly recurrence |                 // Add BYDAY or BYMONTHDAY for monthly recurrence | ||||||
|                 if let Some(ref by_day) = self.monthly_by_day { |                 if let Some(ref by_day) = self.monthly_by_day { | ||||||
| @@ -426,22 +442,26 @@ impl EventCreationData { | |||||||
|                 } else if let Some(by_monthday) = self.monthly_by_monthday { |                 } else if let Some(by_monthday) = self.monthly_by_monthday { | ||||||
|                     parts.push(format!("BYMONTHDAY={}", by_monthday)); |                     parts.push(format!("BYMONTHDAY={}", by_monthday)); | ||||||
|                 } |                 } | ||||||
|             }, |             } | ||||||
|             RecurrenceType::Yearly => { |             RecurrenceType::Yearly => { | ||||||
|                 // Add BYMONTH for yearly recurrence |                 // Add BYMONTH for yearly recurrence | ||||||
|                 let selected_months: Vec<String> = self.yearly_by_month.iter() |                 let selected_months: Vec<String> = self | ||||||
|  |                     .yearly_by_month | ||||||
|  |                     .iter() | ||||||
|                     .enumerate() |                     .enumerate() | ||||||
|                     .filter_map(|(i, &selected)| if selected { |                     .filter_map(|(i, &selected)| { | ||||||
|  |                         if selected { | ||||||
|                             Some((i + 1).to_string()) // Convert 0-based index to 1-based month |                             Some((i + 1).to_string()) // Convert 0-based index to 1-based month | ||||||
|                         } else { |                         } else { | ||||||
|                             None |                             None | ||||||
|  |                         } | ||||||
|                     }) |                     }) | ||||||
|                     .collect(); |                     .collect(); | ||||||
|  |  | ||||||
|                 if !selected_months.is_empty() { |                 if !selected_months.is_empty() { | ||||||
|                     parts.push(format!("BYMONTH={}", selected_months.join(","))); |                     parts.push(format!("BYMONTH={}", selected_months.join(","))); | ||||||
|                 } |                 } | ||||||
|             }, |             } | ||||||
|             _ => {} |             _ => {} | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -456,11 +476,36 @@ impl EventCreationData { | |||||||
|         parts.join(";") |         parts.join(";") | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub fn to_create_event_params(&self) -> (String, String, String, String, String, String, String, bool, String, String, Option<u8>, String, String, String, String, String, Vec<bool>, Option<String>) { |     pub fn to_create_event_params( | ||||||
|  |         &self, | ||||||
|  |     ) -> ( | ||||||
|  |         String, | ||||||
|  |         String, | ||||||
|  |         String, | ||||||
|  |         String, | ||||||
|  |         String, | ||||||
|  |         String, | ||||||
|  |         String, | ||||||
|  |         bool, | ||||||
|  |         String, | ||||||
|  |         String, | ||||||
|  |         Option<u8>, | ||||||
|  |         String, | ||||||
|  |         String, | ||||||
|  |         String, | ||||||
|  |         String, | ||||||
|  |         String, | ||||||
|  |         Vec<bool>, | ||||||
|  |         Option<String>, | ||||||
|  |     ) { | ||||||
|         // Convert local date/time to UTC |         // Convert local date/time to UTC | ||||||
|         let start_local = Local.from_local_datetime(&self.start_date.and_time(self.start_time)).single() |         let start_local = Local | ||||||
|  |             .from_local_datetime(&self.start_date.and_time(self.start_time)) | ||||||
|  |             .single() | ||||||
|             .unwrap_or_else(|| Local::now()); |             .unwrap_or_else(|| Local::now()); | ||||||
|         let end_local = Local.from_local_datetime(&self.end_date.and_time(self.end_time)).single() |         let end_local = Local | ||||||
|  |             .from_local_datetime(&self.end_date.and_time(self.end_time)) | ||||||
|  |             .single() | ||||||
|             .unwrap_or_else(|| Local::now()); |             .unwrap_or_else(|| Local::now()); | ||||||
|  |  | ||||||
|         let start_utc = start_local.with_timezone(&Utc); |         let start_utc = start_local.with_timezone(&Utc); | ||||||
| @@ -501,7 +546,7 @@ impl EventCreationData { | |||||||
|             }, |             }, | ||||||
|             self.build_rrule(), // Use the comprehensive RRULE builder |             self.build_rrule(), // Use the comprehensive RRULE builder | ||||||
|             self.recurrence_days.clone(), |             self.recurrence_days.clone(), | ||||||
|             self.selected_calendar.clone() |             self.selected_calendar.clone(), | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -520,23 +565,48 @@ impl EventCreationData { | |||||||
|             description: event.description.clone().unwrap_or_default(), |             description: event.description.clone().unwrap_or_default(), | ||||||
|             start_date: event.dtstart.with_timezone(&chrono::Local).date_naive(), |             start_date: event.dtstart.with_timezone(&chrono::Local).date_naive(), | ||||||
|             start_time: event.dtstart.with_timezone(&chrono::Local).time(), |             start_time: event.dtstart.with_timezone(&chrono::Local).time(), | ||||||
|             end_date: event.dtend.as_ref().map(|e| e.with_timezone(&chrono::Local).date_naive()).unwrap_or(event.dtstart.with_timezone(&chrono::Local).date_naive()), |             end_date: event | ||||||
|             end_time: event.dtend.as_ref().map(|e| e.with_timezone(&chrono::Local).time()).unwrap_or(event.dtstart.with_timezone(&chrono::Local).time()), |                 .dtend | ||||||
|  |                 .as_ref() | ||||||
|  |                 .map(|e| e.with_timezone(&chrono::Local).date_naive()) | ||||||
|  |                 .unwrap_or(event.dtstart.with_timezone(&chrono::Local).date_naive()), | ||||||
|  |             end_time: event | ||||||
|  |                 .dtend | ||||||
|  |                 .as_ref() | ||||||
|  |                 .map(|e| e.with_timezone(&chrono::Local).time()) | ||||||
|  |                 .unwrap_or(event.dtstart.with_timezone(&chrono::Local).time()), | ||||||
|             location: event.location.clone().unwrap_or_default(), |             location: event.location.clone().unwrap_or_default(), | ||||||
|             all_day: event.all_day, |             all_day: event.all_day, | ||||||
|             status: event.status.as_ref().map(|s| match s { |             status: event | ||||||
|  |                 .status | ||||||
|  |                 .as_ref() | ||||||
|  |                 .map(|s| match s { | ||||||
|                     crate::models::ical::EventStatus::Tentative => EventStatus::Tentative, |                     crate::models::ical::EventStatus::Tentative => EventStatus::Tentative, | ||||||
|                     crate::models::ical::EventStatus::Confirmed => EventStatus::Confirmed, |                     crate::models::ical::EventStatus::Confirmed => EventStatus::Confirmed, | ||||||
|                     crate::models::ical::EventStatus::Cancelled => EventStatus::Cancelled, |                     crate::models::ical::EventStatus::Cancelled => EventStatus::Cancelled, | ||||||
|             }).unwrap_or(EventStatus::Confirmed), |                 }) | ||||||
|             class: event.class.as_ref().map(|c| match c { |                 .unwrap_or(EventStatus::Confirmed), | ||||||
|  |             class: event | ||||||
|  |                 .class | ||||||
|  |                 .as_ref() | ||||||
|  |                 .map(|c| match c { | ||||||
|                     crate::models::ical::EventClass::Public => EventClass::Public, |                     crate::models::ical::EventClass::Public => EventClass::Public, | ||||||
|                     crate::models::ical::EventClass::Private => EventClass::Private, |                     crate::models::ical::EventClass::Private => EventClass::Private, | ||||||
|                     crate::models::ical::EventClass::Confidential => EventClass::Confidential, |                     crate::models::ical::EventClass::Confidential => EventClass::Confidential, | ||||||
|             }).unwrap_or(EventClass::Public), |                 }) | ||||||
|  |                 .unwrap_or(EventClass::Public), | ||||||
|             priority: event.priority, |             priority: event.priority, | ||||||
|             organizer: event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(), |             organizer: event | ||||||
|             attendees: event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(", "), |                 .organizer | ||||||
|  |                 .as_ref() | ||||||
|  |                 .map(|o| o.cal_address.clone()) | ||||||
|  |                 .unwrap_or_default(), | ||||||
|  |             attendees: event | ||||||
|  |                 .attendees | ||||||
|  |                 .iter() | ||||||
|  |                 .map(|a| a.cal_address.clone()) | ||||||
|  |                 .collect::<Vec<_>>() | ||||||
|  |                 .join(", "), | ||||||
|             categories: event.categories.join(", "), |             categories: event.categories.join(", "), | ||||||
|             reminder: ReminderType::default(), // TODO: Convert from event reminders |             reminder: ReminderType::default(), // TODO: Convert from event reminders | ||||||
|             recurrence: parsed_rrule.freq.clone(), |             recurrence: parsed_rrule.freq.clone(), | ||||||
| @@ -566,9 +636,12 @@ impl EventCreationData { | |||||||
|             } else { |             } else { | ||||||
|                 vec![false; 12] |                 vec![false; 12] | ||||||
|             }, |             }, | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|  |             // Edit scope and tracking defaults (will be set later if needed) | ||||||
|  |             edit_scope: None, | ||||||
|  |             changed_fields: vec![], | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Clone, PartialEq)] | #[derive(Clone, PartialEq)] | ||||||
| @@ -593,9 +666,27 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html { | |||||||
|     let active_tab = use_state(|| ModalTab::default()); |     let active_tab = use_state(|| ModalTab::default()); | ||||||
|  |  | ||||||
|     // Initialize with selected date or event data if provided |     // Initialize with selected date or event data if provided | ||||||
|     use_effect_with((props.selected_date, props.event_to_edit.clone(), props.is_open, props.available_calendars.clone(), props.initial_start_time, props.initial_end_time), { |     use_effect_with( | ||||||
|  |         ( | ||||||
|  |             props.selected_date, | ||||||
|  |             props.event_to_edit.clone(), | ||||||
|  |             props.is_open, | ||||||
|  |             props.available_calendars.clone(), | ||||||
|  |             props.initial_start_time, | ||||||
|  |             props.initial_end_time, | ||||||
|  |             props.edit_scope.clone(), | ||||||
|  |         ), | ||||||
|  |         { | ||||||
|             let event_data = event_data.clone(); |             let event_data = event_data.clone(); | ||||||
|         move |(selected_date, event_to_edit, is_open, available_calendars, initial_start_time, initial_end_time)| { |             move |( | ||||||
|  |                 selected_date, | ||||||
|  |                 event_to_edit, | ||||||
|  |                 is_open, | ||||||
|  |                 available_calendars, | ||||||
|  |                 initial_start_time, | ||||||
|  |                 initial_end_time, | ||||||
|  |                 edit_scope, | ||||||
|  |             )| { | ||||||
|                 if *is_open { |                 if *is_open { | ||||||
|                     let mut data = if let Some(event) = event_to_edit { |                     let mut data = if let Some(event) = event_to_edit { | ||||||
|                         // Pre-populate with event data for editing |                         // Pre-populate with event data for editing | ||||||
| @@ -625,11 +716,17 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html { | |||||||
|                         data.selected_calendar = Some(available_calendars[0].path.clone()); |                         data.selected_calendar = Some(available_calendars[0].path.clone()); | ||||||
|                     } |                     } | ||||||
|  |  | ||||||
|  |                     // Set edit scope if provided | ||||||
|  |                     if let Some(scope) = edit_scope { | ||||||
|  |                         data.edit_scope = Some(scope.clone()); | ||||||
|  |                     } | ||||||
|  |  | ||||||
|                     event_data.set(data); |                     event_data.set(data); | ||||||
|                 } |                 } | ||||||
|                 || () |                 || () | ||||||
|             } |             } | ||||||
|     }); |         }, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     if !props.is_open { |     if !props.is_open { | ||||||
|         return html! {}; |         return html! {}; | ||||||
| @@ -644,12 +741,25 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html { | |||||||
|         }) |         }) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|  |     // Helper function to track field changes | ||||||
|  |     let _track_field_change = |data: &mut EventCreationData, field_name: &str| { | ||||||
|  |         if !data.changed_fields.contains(&field_name.to_string()) { | ||||||
|  |             data.changed_fields.push(field_name.to_string()); | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|     let on_title_input = { |     let on_title_input = { | ||||||
|         let event_data = event_data.clone(); |         let event_data = event_data.clone(); | ||||||
|         Callback::from(move |e: InputEvent| { |         Callback::from(move |e: InputEvent| { | ||||||
|             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { |             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||||
|                 let mut data = (*event_data).clone(); |                 let mut data = (*event_data).clone(); | ||||||
|                 data.title = input.value(); |                 let new_value = input.value(); | ||||||
|  |                 if data.title != new_value { | ||||||
|  |                     data.title = new_value; | ||||||
|  |                     if !data.changed_fields.contains(&"title".to_string()) { | ||||||
|  |                         data.changed_fields.push("title".to_string()); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|                 event_data.set(data); |                 event_data.set(data); | ||||||
|             } |             } | ||||||
|         }) |         }) | ||||||
| @@ -661,7 +771,16 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html { | |||||||
|             if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() { |             if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() { | ||||||
|                 let mut data = (*event_data).clone(); |                 let mut data = (*event_data).clone(); | ||||||
|                 let value = select.value(); |                 let value = select.value(); | ||||||
|                 data.selected_calendar = if value.is_empty() { None } else { Some(value) }; |                 let new_calendar = if value.is_empty() { None } else { Some(value) }; | ||||||
|  |                 if data.selected_calendar != new_calendar { | ||||||
|  |                     data.selected_calendar = new_calendar; | ||||||
|  |                     if !data | ||||||
|  |                         .changed_fields | ||||||
|  |                         .contains(&"selected_calendar".to_string()) | ||||||
|  |                     { | ||||||
|  |                         data.changed_fields.push("selected_calendar".to_string()); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|                 event_data.set(data); |                 event_data.set(data); | ||||||
|             } |             } | ||||||
|         }) |         }) | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| use yew::prelude::*; |  | ||||||
| use web_sys::MouseEvent; |  | ||||||
| use crate::models::ical::VEvent; | use crate::models::ical::VEvent; | ||||||
|  | use web_sys::MouseEvent; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
| #[derive(Clone, PartialEq, Debug)] | #[derive(Clone, PartialEq, Debug)] | ||||||
| pub enum DeleteAction { | pub enum DeleteAction { | ||||||
| @@ -9,13 +9,20 @@ pub enum DeleteAction { | |||||||
|     DeleteSeries, |     DeleteSeries, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #[derive(Clone, PartialEq, Debug)] | ||||||
|  | pub enum EditAction { | ||||||
|  |     EditThis, | ||||||
|  |     EditFuture, | ||||||
|  |     EditAll, | ||||||
|  | } | ||||||
|  |  | ||||||
| #[derive(Properties, PartialEq)] | #[derive(Properties, PartialEq)] | ||||||
| pub struct EventContextMenuProps { | pub struct EventContextMenuProps { | ||||||
|     pub is_open: bool, |     pub is_open: bool, | ||||||
|     pub x: i32, |     pub x: i32, | ||||||
|     pub y: i32, |     pub y: i32, | ||||||
|     pub event: Option<VEvent>, |     pub event: Option<VEvent>, | ||||||
|     pub on_edit: Callback<()>, |     pub on_edit: Callback<EditAction>, | ||||||
|     pub on_delete: Callback<DeleteAction>, |     pub on_delete: Callback<DeleteAction>, | ||||||
|     pub on_close: Callback<()>, |     pub on_close: Callback<()>, | ||||||
| } | } | ||||||
| @@ -34,15 +41,17 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | |||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     // Check if the event is recurring |     // Check if the event is recurring | ||||||
|     let is_recurring = props.event.as_ref() |     let is_recurring = props | ||||||
|  |         .event | ||||||
|  |         .as_ref() | ||||||
|         .map(|event| event.rrule.is_some()) |         .map(|event| event.rrule.is_some()) | ||||||
|         .unwrap_or(false); |         .unwrap_or(false); | ||||||
|  |  | ||||||
|     let on_edit_click = { |     let create_edit_callback = |action: EditAction| { | ||||||
|         let on_edit = props.on_edit.clone(); |         let on_edit = props.on_edit.clone(); | ||||||
|         let on_close = props.on_close.clone(); |         let on_close = props.on_close.clone(); | ||||||
|         Callback::from(move |_: MouseEvent| { |         Callback::from(move |_: MouseEvent| { | ||||||
|             on_edit.emit(()); |             on_edit.emit(action.clone()); | ||||||
|             on_close.emit(()); |             on_close.emit(()); | ||||||
|         }) |         }) | ||||||
|     }; |     }; | ||||||
| @@ -62,9 +71,29 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | |||||||
|             class="context-menu" |             class="context-menu" | ||||||
|             style={style} |             style={style} | ||||||
|         > |         > | ||||||
|             <div class="context-menu-item" onclick={on_edit_click}> |             { | ||||||
|  |                 if is_recurring { | ||||||
|  |                     html! { | ||||||
|  |                         <> | ||||||
|  |                             <div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}> | ||||||
|  |                                 {"Edit This Event"} | ||||||
|  |                             </div> | ||||||
|  |                             <div class="context-menu-item" onclick={create_edit_callback(EditAction::EditFuture)}> | ||||||
|  |                                 {"Edit This and Future Events"} | ||||||
|  |                             </div> | ||||||
|  |                             <div class="context-menu-item" onclick={create_edit_callback(EditAction::EditAll)}> | ||||||
|  |                                 {"Edit All Events in Series"} | ||||||
|  |                             </div> | ||||||
|  |                         </> | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     html! { | ||||||
|  |                         <div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}> | ||||||
|                             {"Edit Event"} |                             {"Edit Event"} | ||||||
|                         </div> |                         </div> | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|             { |             { | ||||||
|                 if is_recurring { |                 if is_recurring { | ||||||
|                     html! { |                     html! { | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| use yew::prelude::*; |  | ||||||
| use chrono::{DateTime, Utc}; |  | ||||||
| use crate::models::ical::VEvent; | use crate::models::ical::VEvent; | ||||||
|  | use chrono::{DateTime, Utc}; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
| #[derive(Properties, PartialEq)] | #[derive(Properties, PartialEq)] | ||||||
| pub struct EventModalProps { | pub struct EventModalProps { | ||||||
| @@ -236,4 +236,3 @@ fn format_recurrence_rule(rrule: &str) -> String { | |||||||
|         format!("Custom ({})", rrule) |         format!("Custom ({})", rrule) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| use yew::prelude::*; |  | ||||||
| use web_sys::HtmlInputElement; |  | ||||||
| use gloo_storage::{LocalStorage, Storage}; | use gloo_storage::{LocalStorage, Storage}; | ||||||
|  | use web_sys::HtmlInputElement; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
| #[derive(Properties, PartialEq)] | #[derive(Properties, PartialEq)] | ||||||
| pub struct LoginProps { | pub struct LoginProps { | ||||||
| @@ -9,12 +9,21 @@ pub struct LoginProps { | |||||||
|  |  | ||||||
| #[function_component] | #[function_component] | ||||||
| pub fn Login(props: &LoginProps) -> Html { | pub fn Login(props: &LoginProps) -> Html { | ||||||
|     let server_url = use_state(String::new); |     // Load remembered values from LocalStorage on mount | ||||||
|     let username = use_state(String::new); |     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 password = use_state(String::new); | ||||||
|     let error_message = use_state(|| Option::<String>::None); |     let error_message = use_state(|| Option::<String>::None); | ||||||
|     let is_loading = use_state(|| false); |     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 server_url_ref = use_node_ref(); | ||||||
|     let username_ref = use_node_ref(); |     let username_ref = use_node_ref(); | ||||||
|     let password_ref = use_node_ref(); |     let password_ref = use_node_ref(); | ||||||
| @@ -43,6 +52,38 @@ pub fn Login(props: &LoginProps) -> Html { | |||||||
|         }) |         }) | ||||||
|     }; |     }; | ||||||
|      |      | ||||||
|  |     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 on_submit = { | ||||||
|         let server_url = server_url.clone(); |         let server_url = server_url.clone(); | ||||||
|         let username = username.clone(); |         let username = username.clone(); | ||||||
| @@ -73,11 +114,18 @@ pub fn Login(props: &LoginProps) -> Html { | |||||||
|             wasm_bindgen_futures::spawn_local(async move { |             wasm_bindgen_futures::spawn_local(async move { | ||||||
|                 web_sys::console::log_1(&"🚀 Starting login process...".into()); |                 web_sys::console::log_1(&"🚀 Starting login process...".into()); | ||||||
|                 match perform_login(server_url.clone(), username.clone(), password.clone()).await { |                 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()); |                         web_sys::console::log_1(&"✅ Login successful!".into()); | ||||||
|                         // Store token and credentials in local storage |                         // Store token and credentials in local storage | ||||||
|                         if let Err(_) = LocalStorage::set("auth_token", &token) { |                         if let Err(_) = LocalStorage::set("auth_token", &token) { | ||||||
|                             error_message.set(Some("Failed to store authentication token".to_string())); |                             error_message | ||||||
|  |                                 .set(Some("Failed to store authentication token".to_string())); | ||||||
|  |                             is_loading.set(false); | ||||||
|  |                             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); |                             is_loading.set(false); | ||||||
|                             return; |                             return; | ||||||
|                         } |                         } | ||||||
| @@ -87,6 +135,11 @@ pub fn Login(props: &LoginProps) -> Html { | |||||||
|                             return; |                             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); |                         is_loading.set(false); | ||||||
|                         on_login.emit(token); |                         on_login.emit(token); | ||||||
|                     } |                     } | ||||||
| @@ -116,6 +169,15 @@ pub fn Login(props: &LoginProps) -> Html { | |||||||
|                             onchange={on_server_url_change} |                             onchange={on_server_url_change} | ||||||
|                             disabled={*is_loading} |                             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> | ||||||
|  |  | ||||||
|                     <div class="form-group"> |                     <div class="form-group"> | ||||||
| @@ -129,6 +191,15 @@ pub fn Login(props: &LoginProps) -> Html { | |||||||
|                             onchange={on_username_change} |                             onchange={on_username_change} | ||||||
|                             disabled={*is_loading} |                             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> | ||||||
|  |  | ||||||
|                     <div class="form-group"> |                     <div class="form-group"> | ||||||
| @@ -172,7 +243,11 @@ pub fn Login(props: &LoginProps) -> Html { | |||||||
| } | } | ||||||
|  |  | ||||||
| /// Perform login using the CalDAV auth service | /// Perform login using the CalDAV auth service | ||||||
| async fn perform_login(server_url: String, username: String, password: String) -> Result<(String, String), String> { | async fn perform_login( | ||||||
|  |     server_url: String, | ||||||
|  |     username: String, | ||||||
|  |     password: String, | ||||||
|  | ) -> Result<(String, String, String, serde_json::Value), String> { | ||||||
|     use crate::auth::{AuthService, CalDAVLoginRequest}; |     use crate::auth::{AuthService, CalDAVLoginRequest}; | ||||||
|     use serde_json; |     use serde_json; | ||||||
|  |  | ||||||
| @@ -182,7 +257,7 @@ async fn perform_login(server_url: String, username: String, password: String) - | |||||||
|     let request = CalDAVLoginRequest { |     let request = CalDAVLoginRequest { | ||||||
|         server_url: server_url.clone(), |         server_url: server_url.clone(), | ||||||
|         username: username.clone(), |         username: username.clone(), | ||||||
|         password: password.clone()  |         password: password.clone(), | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into()); |     web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into()); | ||||||
| @@ -196,11 +271,21 @@ async fn perform_login(server_url: String, username: String, password: String) - | |||||||
|                 "username": username, |                 "username": username, | ||||||
|                 "password": password |                 "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) => { |         Err(err) => { | ||||||
|             web_sys::console::log_1(&format!("❌ Backend error: {}", err).into()); |             web_sys::console::log_1(&format!("❌ Backend error: {}", err).into()); | ||||||
|             Err(err) |             Err(err) | ||||||
|         }, |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,31 +1,33 @@ | |||||||
| pub mod login; |  | ||||||
| pub mod calendar; | pub mod calendar; | ||||||
| pub mod calendar_header; |  | ||||||
| pub mod month_view; |  | ||||||
| pub mod week_view; |  | ||||||
| pub mod event_modal; |  | ||||||
| pub mod create_calendar_modal; |  | ||||||
| pub mod context_menu; |  | ||||||
| pub mod event_context_menu; |  | ||||||
| pub mod calendar_context_menu; | pub mod calendar_context_menu; | ||||||
| pub mod create_event_modal; | pub mod calendar_header; | ||||||
| pub mod sidebar; |  | ||||||
| pub mod calendar_list_item; | pub mod calendar_list_item; | ||||||
| pub mod route_handler; | pub mod context_menu; | ||||||
|  | pub mod create_calendar_modal; | ||||||
|  | pub mod create_event_modal; | ||||||
|  | pub mod event_context_menu; | ||||||
|  | pub mod event_modal; | ||||||
|  | pub mod login; | ||||||
|  | pub mod month_view; | ||||||
| pub mod recurring_edit_modal; | pub mod recurring_edit_modal; | ||||||
|  | pub mod route_handler; | ||||||
|  | pub mod sidebar; | ||||||
|  | pub mod week_view; | ||||||
|  |  | ||||||
| pub use login::Login; |  | ||||||
| pub use calendar::Calendar; | pub use calendar::Calendar; | ||||||
| pub use calendar_header::CalendarHeader; |  | ||||||
| pub use month_view::MonthView; |  | ||||||
| pub use week_view::WeekView; |  | ||||||
| pub use event_modal::EventModal; |  | ||||||
| pub use create_calendar_modal::CreateCalendarModal; |  | ||||||
| pub use context_menu::ContextMenu; |  | ||||||
| pub use event_context_menu::{EventContextMenu, DeleteAction}; |  | ||||||
| pub use calendar_context_menu::CalendarContextMenu; | pub use calendar_context_menu::CalendarContextMenu; | ||||||
| pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType}; | pub use calendar_header::CalendarHeader; | ||||||
| pub use sidebar::{Sidebar, ViewMode, Theme}; |  | ||||||
| pub use calendar_list_item::CalendarListItem; | pub use calendar_list_item::CalendarListItem; | ||||||
|  | pub use context_menu::ContextMenu; | ||||||
|  | pub use create_calendar_modal::CreateCalendarModal; | ||||||
|  | pub use create_event_modal::{ | ||||||
|  |     CreateEventModal, EventClass, EventCreationData, EventStatus, RecurrenceType, ReminderType, | ||||||
|  | }; | ||||||
|  | pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu}; | ||||||
|  | pub use event_modal::EventModal; | ||||||
|  | pub use login::Login; | ||||||
|  | pub use month_view::MonthView; | ||||||
|  | pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal}; | ||||||
| pub use route_handler::RouteHandler; | pub use route_handler::RouteHandler; | ||||||
| pub use recurring_edit_modal::{RecurringEditModal, RecurringEditAction}; | pub use sidebar::{Sidebar, Theme, ViewMode}; | ||||||
|  | pub use week_view::WeekView; | ||||||
|   | |||||||
| @@ -1,10 +1,10 @@ | |||||||
| use yew::prelude::*; | use crate::models::ical::VEvent; | ||||||
|  | use crate::services::calendar_service::UserInfo; | ||||||
| use chrono::{Datelike, NaiveDate, Weekday}; | use chrono::{Datelike, NaiveDate, Weekday}; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use web_sys::window; |  | ||||||
| use wasm_bindgen::{prelude::*, JsCast}; | use wasm_bindgen::{prelude::*, JsCast}; | ||||||
| use crate::services::calendar_service::UserInfo; | use web_sys::window; | ||||||
| use crate::models::ical::VEvent; | use yew::prelude::*; | ||||||
|  |  | ||||||
| #[derive(Properties, PartialEq)] | #[derive(Properties, PartialEq)] | ||||||
| pub struct MonthViewProps { | pub struct MonthViewProps { | ||||||
| @@ -72,7 +72,10 @@ pub fn month_view(props: &MonthViewProps) -> Html { | |||||||
|             }) as Box<dyn Fn()>); |             }) as Box<dyn Fn()>); | ||||||
|  |  | ||||||
|             if let Some(window) = window() { |             if let Some(window) = window() { | ||||||
|                 let _ = window.add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref()); |                 let _ = window.add_event_listener_with_callback( | ||||||
|  |                     "resize", | ||||||
|  |                     resize_closure.as_ref().unchecked_ref(), | ||||||
|  |                 ); | ||||||
|                 resize_closure.forget(); // Keep the closure alive |                 resize_closure.forget(); // Keep the closure alive | ||||||
|             } |             } | ||||||
|  |  | ||||||
| @@ -84,8 +87,11 @@ pub fn month_view(props: &MonthViewProps) -> Html { | |||||||
|     let get_event_color = |event: &VEvent| -> String { |     let get_event_color = |event: &VEvent| -> String { | ||||||
|         if let Some(user_info) = &props.user_info { |         if let Some(user_info) = &props.user_info { | ||||||
|             if let Some(calendar_path) = &event.calendar_path { |             if let Some(calendar_path) = &event.calendar_path { | ||||||
|                 if let Some(calendar) = user_info.calendars.iter() |                 if let Some(calendar) = user_info | ||||||
|                     .find(|cal| &cal.path == calendar_path) { |                     .calendars | ||||||
|  |                     .iter() | ||||||
|  |                     .find(|cal| &cal.path == calendar_path) | ||||||
|  |                 { | ||||||
|                     return calendar.color.clone(); |                     return calendar.color.clone(); | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
| @@ -221,20 +227,34 @@ pub fn month_view(props: &MonthViewProps) -> Html { | |||||||
| fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html { | fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html { | ||||||
|     let total_slots = 42; // 6 rows x 7 days |     let total_slots = 42; // 6 rows x 7 days | ||||||
|     let used_slots = prev_days_count + current_days_count as usize; |     let used_slots = prev_days_count + current_days_count as usize; | ||||||
|     let remaining_slots = if used_slots < total_slots { total_slots - used_slots } else { 0 }; |     let remaining_slots = if used_slots < total_slots { | ||||||
|  |         total_slots - used_slots | ||||||
|  |     } else { | ||||||
|  |         0 | ||||||
|  |     }; | ||||||
|  |  | ||||||
|     (1..=remaining_slots).map(|day| { |     (1..=remaining_slots) | ||||||
|  |         .map(|day| { | ||||||
|             html! { |             html! { | ||||||
|                 <div class="calendar-day next-month">{day}</div> |                 <div class="calendar-day next-month">{day}</div> | ||||||
|             } |             } | ||||||
|     }).collect::<Html>() |         }) | ||||||
|  |         .collect::<Html>() | ||||||
| } | } | ||||||
|  |  | ||||||
| fn get_days_in_month(date: NaiveDate) -> u32 { | fn get_days_in_month(date: NaiveDate) -> u32 { | ||||||
|     NaiveDate::from_ymd_opt( |     NaiveDate::from_ymd_opt( | ||||||
|         if date.month() == 12 { date.year() + 1 } else { date.year() }, |         if date.month() == 12 { | ||||||
|         if date.month() == 12 { 1 } else { date.month() + 1 }, |             date.year() + 1 | ||||||
|  |         } else { | ||||||
|  |             date.year() | ||||||
|  |         }, | ||||||
|  |         if date.month() == 12 { | ||||||
|             1 |             1 | ||||||
|  |         } else { | ||||||
|  |             date.month() + 1 | ||||||
|  |         }, | ||||||
|  |         1, | ||||||
|     ) |     ) | ||||||
|     .unwrap() |     .unwrap() | ||||||
|     .pred_opt() |     .pred_opt() | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| use yew::prelude::*; |  | ||||||
| use chrono::NaiveDateTime; |  | ||||||
| use crate::models::ical::VEvent; | use crate::models::ical::VEvent; | ||||||
|  | use chrono::NaiveDateTime; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
| #[derive(Clone, PartialEq)] | #[derive(Clone, PartialEq)] | ||||||
| pub enum RecurringEditAction { | pub enum RecurringEditAction { | ||||||
| @@ -25,7 +25,12 @@ pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html { | |||||||
|         return html! {}; |         return html! {}; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     let event_title = props.event.summary.as_ref().map(|s| s.as_str()).unwrap_or("Untitled Event"); |     let event_title = props | ||||||
|  |         .event | ||||||
|  |         .summary | ||||||
|  |         .as_ref() | ||||||
|  |         .map(|s| s.as_str()) | ||||||
|  |         .unwrap_or("Untitled Event"); | ||||||
|  |  | ||||||
|     let on_this_event = { |     let on_this_event = { | ||||||
|         let on_choice = props.on_choice.clone(); |         let on_choice = props.on_choice.clone(); | ||||||
|   | |||||||
| @@ -1,8 +1,8 @@ | |||||||
|  | use crate::components::{Login, ViewMode}; | ||||||
|  | use crate::models::ical::VEvent; | ||||||
|  | use crate::services::calendar_service::UserInfo; | ||||||
| use yew::prelude::*; | use yew::prelude::*; | ||||||
| use yew_router::prelude::*; | use yew_router::prelude::*; | ||||||
| use crate::components::{Login, ViewMode}; |  | ||||||
| use crate::services::calendar_service::UserInfo; |  | ||||||
| use crate::models::ical::VEvent; |  | ||||||
|  |  | ||||||
| #[derive(Clone, Routable, PartialEq)] | #[derive(Clone, Routable, PartialEq)] | ||||||
| pub enum Route { | pub enum Route { | ||||||
| @@ -28,7 +28,17 @@ pub struct RouteHandlerProps { | |||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>, |     pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>, |     pub on_event_update_request: Option< | ||||||
|  |         Callback<( | ||||||
|  |             VEvent, | ||||||
|  |             chrono::NaiveDateTime, | ||||||
|  |             chrono::NaiveDateTime, | ||||||
|  |             bool, | ||||||
|  |             Option<chrono::DateTime<chrono::Utc>>, | ||||||
|  |             Option<String>, | ||||||
|  |             Option<String>, | ||||||
|  |         )>, | ||||||
|  |     >, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub context_menus_open: bool, |     pub context_menus_open: bool, | ||||||
| } | } | ||||||
| @@ -106,165 +116,28 @@ pub struct CalendarViewProps { | |||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>, |     pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>, |     pub on_event_update_request: Option< | ||||||
|  |         Callback<( | ||||||
|  |             VEvent, | ||||||
|  |             chrono::NaiveDateTime, | ||||||
|  |             chrono::NaiveDateTime, | ||||||
|  |             bool, | ||||||
|  |             Option<chrono::DateTime<chrono::Utc>>, | ||||||
|  |             Option<String>, | ||||||
|  |             Option<String>, | ||||||
|  |         )>, | ||||||
|  |     >, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub context_menus_open: bool, |     pub context_menus_open: bool, | ||||||
| } | } | ||||||
|  |  | ||||||
| use gloo_storage::{LocalStorage, Storage}; |  | ||||||
| use crate::services::CalendarService; |  | ||||||
| use crate::components::Calendar; | use crate::components::Calendar; | ||||||
| use std::collections::HashMap; |  | ||||||
| use chrono::{Local, NaiveDate, Datelike}; |  | ||||||
|  |  | ||||||
| #[function_component(CalendarView)] | #[function_component(CalendarView)] | ||||||
| pub fn calendar_view(props: &CalendarViewProps) -> Html { | pub fn calendar_view(props: &CalendarViewProps) -> Html { | ||||||
|     let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new()); |  | ||||||
|     let loading = use_state(|| true); |  | ||||||
|     let error = use_state(|| None::<String>); |  | ||||||
|     let refreshing_event = use_state(|| None::<String>); |  | ||||||
|      |  | ||||||
|     let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); |  | ||||||
|      |  | ||||||
|      |  | ||||||
|     let today = Local::now().date_naive(); |  | ||||||
|     let current_year = today.year(); |  | ||||||
|     let current_month = today.month(); |  | ||||||
|      |  | ||||||
|     let on_event_click = { |  | ||||||
|         let events = events.clone(); |  | ||||||
|         let refreshing_event = refreshing_event.clone(); |  | ||||||
|         let auth_token = auth_token.clone(); |  | ||||||
|          |  | ||||||
|         Callback::from(move |event: VEvent| { |  | ||||||
|             if let Some(token) = auth_token.clone() { |  | ||||||
|                 let events = events.clone(); |  | ||||||
|                 let refreshing_event = refreshing_event.clone(); |  | ||||||
|                 let uid = event.uid.clone(); |  | ||||||
|                  |  | ||||||
|                 refreshing_event.set(Some(uid.clone())); |  | ||||||
|                  |  | ||||||
|                 wasm_bindgen_futures::spawn_local(async move { |  | ||||||
|                     let calendar_service = CalendarService::new(); |  | ||||||
|                      |  | ||||||
|                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { |  | ||||||
|                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { |  | ||||||
|                             credentials["password"].as_str().unwrap_or("").to_string() |  | ||||||
|                         } else { |  | ||||||
|                             String::new() |  | ||||||
|                         } |  | ||||||
|                     } else { |  | ||||||
|                         String::new() |  | ||||||
|                     }; |  | ||||||
|                      |  | ||||||
|                     match calendar_service.refresh_event(&token, &password, &uid).await { |  | ||||||
|                         Ok(Some(refreshed_event)) => { |  | ||||||
|                             let refreshed_vevent = refreshed_event; // CalendarEvent is now VEvent |  | ||||||
|                             let mut updated_events = (*events).clone(); |  | ||||||
|                              |  | ||||||
|                             for (_, day_events) in updated_events.iter_mut() { |  | ||||||
|                                 day_events.retain(|e| e.uid != uid); |  | ||||||
|                             } |  | ||||||
|                              |  | ||||||
|                             if refreshed_vevent.rrule.is_some() { |  | ||||||
|                                 let new_occurrences = CalendarService::expand_recurring_events(vec![refreshed_vevent.clone()]); |  | ||||||
|                                  |  | ||||||
|                                 for occurrence in new_occurrences { |  | ||||||
|                                     let date = occurrence.get_date(); |  | ||||||
|                                     updated_events.entry(date) |  | ||||||
|                                         .or_insert_with(Vec::new) |  | ||||||
|                                         .push(occurrence); |  | ||||||
|                                 } |  | ||||||
|                             } else { |  | ||||||
|                                 let date = refreshed_vevent.get_date(); |  | ||||||
|                                 updated_events.entry(date) |  | ||||||
|                                     .or_insert_with(Vec::new) |  | ||||||
|                                     .push(refreshed_vevent); |  | ||||||
|                             } |  | ||||||
|                              |  | ||||||
|                             events.set(updated_events); |  | ||||||
|                         } |  | ||||||
|                         Ok(None) => { |  | ||||||
|                             let mut updated_events = (*events).clone(); |  | ||||||
|                             for (_, day_events) in updated_events.iter_mut() { |  | ||||||
|                                 day_events.retain(|e| e.uid != uid); |  | ||||||
|                             } |  | ||||||
|                             events.set(updated_events); |  | ||||||
|                         } |  | ||||||
|                         Err(_err) => { |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                      |  | ||||||
|                     refreshing_event.set(None); |  | ||||||
|                 }); |  | ||||||
|             } |  | ||||||
|         }) |  | ||||||
|     }; |  | ||||||
|      |  | ||||||
|     { |  | ||||||
|         let events = events.clone(); |  | ||||||
|         let loading = loading.clone(); |  | ||||||
|         let error = error.clone(); |  | ||||||
|         let auth_token = auth_token.clone(); |  | ||||||
|          |  | ||||||
|         use_effect_with((), move |_| { |  | ||||||
|             if let Some(token) = auth_token { |  | ||||||
|                 let events = events.clone(); |  | ||||||
|                 let loading = loading.clone(); |  | ||||||
|                 let error = error.clone(); |  | ||||||
|                  |  | ||||||
|                 wasm_bindgen_futures::spawn_local(async move { |  | ||||||
|                     let calendar_service = CalendarService::new(); |  | ||||||
|                      |  | ||||||
|                     let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") { |  | ||||||
|                         if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) { |  | ||||||
|                             credentials["password"].as_str().unwrap_or("").to_string() |  | ||||||
|                         } else { |  | ||||||
|                             String::new() |  | ||||||
|                         } |  | ||||||
|                     } else { |  | ||||||
|                         String::new() |  | ||||||
|                     }; |  | ||||||
|                      |  | ||||||
|                     match calendar_service.fetch_events_for_month_vevent(&token, &password, current_year, current_month).await { |  | ||||||
|                         Ok(vevents) => { |  | ||||||
|                             let grouped_events = CalendarService::group_events_by_date(vevents); |  | ||||||
|                             events.set(grouped_events); |  | ||||||
|                             loading.set(false); |  | ||||||
|                         } |  | ||||||
|                         Err(err) => { |  | ||||||
|                             error.set(Some(format!("Failed to load events: {}", err))); |  | ||||||
|                             loading.set(false); |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 }); |  | ||||||
|             } else { |  | ||||||
|                 loading.set(false); |  | ||||||
|                 error.set(Some("No authentication token found".to_string())); |  | ||||||
|             } |  | ||||||
|              |  | ||||||
|             || () |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     html! { |     html! { | ||||||
|         <div class="calendar-view"> |         <div class="calendar-view"> | ||||||
|             { |  | ||||||
|                 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 |             <Calendar | ||||||
|                                 events={HashMap::new()}  |  | ||||||
|                                 on_event_click={dummy_callback}  |  | ||||||
|                                 refreshing_event_uid={(*refreshing_event).clone()}  |  | ||||||
|                 user_info={props.user_info.clone()} |                 user_info={props.user_info.clone()} | ||||||
|                 on_event_context_menu={props.on_event_context_menu.clone()} |                 on_event_context_menu={props.on_event_context_menu.clone()} | ||||||
|                 on_calendar_context_menu={props.on_calendar_context_menu.clone()} |                 on_calendar_context_menu={props.on_calendar_context_menu.clone()} | ||||||
| @@ -275,23 +148,4 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html { | |||||||
|             /> |             /> | ||||||
|         </div> |         </div> | ||||||
|     } |     } | ||||||
|                 } else { |  | ||||||
|                     html! { |  | ||||||
|                         <Calendar  |  | ||||||
|                             events={(*events).clone()}  |  | ||||||
|                             on_event_click={on_event_click}  |  | ||||||
|                             refreshing_event_uid={(*refreshing_event).clone()}  |  | ||||||
|                             user_info={props.user_info.clone()} |  | ||||||
|                             on_event_context_menu={props.on_event_context_menu.clone()} |  | ||||||
|                             on_calendar_context_menu={props.on_calendar_context_menu.clone()} |  | ||||||
|                             view={props.view.clone()} |  | ||||||
|                             on_create_event_request={props.on_create_event_request.clone()} |  | ||||||
|                             on_event_update_request={props.on_event_update_request.clone()} |  | ||||||
|                             context_menus_open={props.context_menus_open} |  | ||||||
|                         /> |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         </div> |  | ||||||
|     } |  | ||||||
| } | } | ||||||
| @@ -1,8 +1,8 @@ | |||||||
|  | use crate::components::CalendarListItem; | ||||||
|  | use crate::services::calendar_service::UserInfo; | ||||||
|  | use web_sys::HtmlSelectElement; | ||||||
| use yew::prelude::*; | use yew::prelude::*; | ||||||
| use yew_router::prelude::*; | use yew_router::prelude::*; | ||||||
| use web_sys::HtmlSelectElement; |  | ||||||
| use crate::services::calendar_service::UserInfo; |  | ||||||
| use crate::components::CalendarListItem; |  | ||||||
|  |  | ||||||
| #[derive(Clone, Routable, PartialEq)] | #[derive(Clone, Routable, PartialEq)] | ||||||
| pub enum Route { | pub enum Route { | ||||||
| @@ -33,7 +33,6 @@ pub enum Theme { | |||||||
| } | } | ||||||
|  |  | ||||||
| impl Theme { | impl Theme { | ||||||
|      |  | ||||||
|     pub fn value(&self) -> &'static str { |     pub fn value(&self) -> &'static str { | ||||||
|         match self { |         match self { | ||||||
|             Theme::Default => "default", |             Theme::Default => "default", | ||||||
|   | |||||||
| @@ -1,10 +1,10 @@ | |||||||
| use yew::prelude::*; | use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal}; | ||||||
| use chrono::{Datelike, NaiveDate, Duration, Weekday, Local, Timelike, NaiveDateTime, NaiveTime}; | use crate::models::ical::VEvent; | ||||||
|  | use crate::services::calendar_service::UserInfo; | ||||||
|  | use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Weekday}; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use web_sys::MouseEvent; | use web_sys::MouseEvent; | ||||||
| use crate::services::calendar_service::UserInfo; | use yew::prelude::*; | ||||||
| use crate::models::ical::VEvent; |  | ||||||
| use crate::components::{RecurringEditModal, RecurringEditAction, EventCreationData}; |  | ||||||
|  |  | ||||||
| #[derive(Properties, PartialEq)] | #[derive(Properties, PartialEq)] | ||||||
| pub struct WeekViewProps { | pub struct WeekViewProps { | ||||||
| @@ -25,7 +25,17 @@ pub struct WeekViewProps { | |||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub on_create_event_request: Option<Callback<EventCreationData>>, |     pub on_create_event_request: Option<Callback<EventCreationData>>, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub on_event_update: Option<Callback<(VEvent, NaiveDateTime, NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>, |     pub on_event_update: Option< | ||||||
|  |         Callback<( | ||||||
|  |             VEvent, | ||||||
|  |             NaiveDateTime, | ||||||
|  |             NaiveDateTime, | ||||||
|  |             bool, | ||||||
|  |             Option<chrono::DateTime<chrono::Utc>>, | ||||||
|  |             Option<String>, | ||||||
|  |             Option<String>, | ||||||
|  |         )>, | ||||||
|  |     >, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
|     pub context_menus_open: bool, |     pub context_menus_open: bool, | ||||||
|     #[prop_or_default] |     #[prop_or_default] | ||||||
| @@ -54,9 +64,7 @@ struct DragState { | |||||||
| #[function_component(WeekView)] | #[function_component(WeekView)] | ||||||
| pub fn week_view(props: &WeekViewProps) -> Html { | pub fn week_view(props: &WeekViewProps) -> Html { | ||||||
|     let start_of_week = get_start_of_week(props.current_date); |     let start_of_week = get_start_of_week(props.current_date); | ||||||
|     let week_days: Vec<NaiveDate> = (0..7) |     let week_days: Vec<NaiveDate> = (0..7).map(|i| start_of_week + Duration::days(i)).collect(); | ||||||
|         .map(|i| start_of_week + Duration::days(i)) |  | ||||||
|         .collect(); |  | ||||||
|  |  | ||||||
|     // Drag state for event creation |     // Drag state for event creation | ||||||
|     let drag_state = use_state(|| None::<DragState>); |     let drag_state = use_state(|| None::<DragState>); | ||||||
| @@ -75,8 +83,11 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|     let get_event_color = |event: &VEvent| -> String { |     let get_event_color = |event: &VEvent| -> String { | ||||||
|         if let Some(user_info) = &props.user_info { |         if let Some(user_info) = &props.user_info { | ||||||
|             if let Some(calendar_path) = &event.calendar_path { |             if let Some(calendar_path) = &event.calendar_path { | ||||||
|                 if let Some(calendar) = user_info.calendars.iter() |                 if let Some(calendar) = user_info | ||||||
|                     .find(|cal| &cal.path == calendar_path) { |                     .calendars | ||||||
|  |                     .iter() | ||||||
|  |                     .find(|cal| &cal.path == calendar_path) | ||||||
|  |                 { | ||||||
|                     return calendar.color.clone(); |                     return calendar.color.clone(); | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
| @@ -85,7 +96,8 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Generate time labels - 24 hours plus the final midnight boundary |     // Generate time labels - 24 hours plus the final midnight boundary | ||||||
|     let mut time_labels: Vec<String> = (0..24).map(|hour| { |     let mut time_labels: Vec<String> = (0..24) | ||||||
|  |         .map(|hour| { | ||||||
|             if hour == 0 { |             if hour == 0 { | ||||||
|                 "12 AM".to_string() |                 "12 AM".to_string() | ||||||
|             } else if hour < 12 { |             } else if hour < 12 { | ||||||
| @@ -95,12 +107,12 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|             } else { |             } else { | ||||||
|                 format!("{} PM", hour - 12) |                 format!("{} PM", hour - 12) | ||||||
|             } |             } | ||||||
|     }).collect(); |         }) | ||||||
|  |         .collect(); | ||||||
|  |  | ||||||
|     // Add the final midnight boundary to show where the day ends |     // Add the final midnight boundary to show where the day ends | ||||||
|     time_labels.push("12 AM".to_string()); |     time_labels.push("12 AM".to_string()); | ||||||
|  |  | ||||||
|  |  | ||||||
|     // Handlers for recurring event modification modal |     // Handlers for recurring event modification modal | ||||||
|     let on_recurring_choice = { |     let on_recurring_choice = { | ||||||
|         let pending_recurring_edit = pending_recurring_edit.clone(); |         let pending_recurring_edit = pending_recurring_edit.clone(); | ||||||
| @@ -147,10 +159,10 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|                                 true,                          // preserve_rrule = true |                                 true,                          // preserve_rrule = true | ||||||
|                                 None,                          // No until_date for this_only |                                 None,                          // No until_date for this_only | ||||||
|                                 Some("this_only".to_string()), // Update scope |                                 Some("this_only".to_string()), // Update scope | ||||||
|                                 Some(occurrence_date)   // Date of occurrence being modified |                                 Some(occurrence_date),         // Date of occurrence being modified | ||||||
|                             )); |                             )); | ||||||
|                         } |                         } | ||||||
|                     }, |                     } | ||||||
|                     RecurringEditAction::FutureEvents => { |                     RecurringEditAction::FutureEvents => { | ||||||
|                         // RFC 5545 Compliant Series Splitting: "This and Future Events" |                         // RFC 5545 Compliant Series Splitting: "This and Future Events" | ||||||
|                         // |                         // | ||||||
| @@ -177,7 +189,8 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|                         if let Some(update_callback) = &on_event_update { |                         if let Some(update_callback) = &on_event_update { | ||||||
|                             // Find the original series event (not the occurrence) |                             // Find the original series event (not the occurrence) | ||||||
|                             // UIDs like "uuid-timestamp" need to split on the last hyphen, not the first |                             // UIDs like "uuid-timestamp" need to split on the last hyphen, not the first | ||||||
|                             let base_uid = if let Some(last_hyphen_pos) = edit.event.uid.rfind('-') { |                             let base_uid = if let Some(last_hyphen_pos) = edit.event.uid.rfind('-') | ||||||
|  |                             { | ||||||
|                                 let suffix = &edit.event.uid[last_hyphen_pos + 1..]; |                                 let suffix = &edit.event.uid[last_hyphen_pos + 1..]; | ||||||
|                                 // Check if suffix is numeric (timestamp), if so remove it |                                 // Check if suffix is numeric (timestamp), if so remove it | ||||||
|                                 if suffix.chars().all(|c| c.is_numeric()) { |                                 if suffix.chars().all(|c| c.is_numeric()) { | ||||||
| @@ -189,7 +202,13 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|                                 edit.event.uid.clone() |                                 edit.event.uid.clone() | ||||||
|                             }; |                             }; | ||||||
|  |  | ||||||
|                             web_sys::console::log_1(&format!("🔍 Looking for original series: '{}' from occurrence: '{}'", base_uid, edit.event.uid).into()); |                             web_sys::console::log_1( | ||||||
|  |                                 &format!( | ||||||
|  |                                     "🔍 Looking for original series: '{}' from occurrence: '{}'", | ||||||
|  |                                     base_uid, edit.event.uid | ||||||
|  |                                 ) | ||||||
|  |                                 .into(), | ||||||
|  |                             ); | ||||||
|  |  | ||||||
|                             // Find the original series event by searching for the base UID |                             // Find the original series event by searching for the base UID | ||||||
|                             let mut original_series = None; |                             let mut original_series = None; | ||||||
| @@ -207,9 +226,12 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|  |  | ||||||
|                             let original_series = match original_series { |                             let original_series = match original_series { | ||||||
|                                 Some(series) => { |                                 Some(series) => { | ||||||
|                                     web_sys::console::log_1(&format!("✅ Found original series: '{}'", series.uid).into()); |                                     web_sys::console::log_1( | ||||||
|  |                                         &format!("✅ Found original series: '{}'", series.uid) | ||||||
|  |                                             .into(), | ||||||
|  |                                     ); | ||||||
|                                     series |                                     series | ||||||
|                                 }, |                                 } | ||||||
|                                 None => { |                                 None => { | ||||||
|                                     web_sys::console::log_1(&format!("⚠️  Could not find original series '{}', using occurrence but fixing UID", base_uid).into()); |                                     web_sys::console::log_1(&format!("⚠️  Could not find original series '{}', using occurrence but fixing UID", base_uid).into()); | ||||||
|                                     let mut fallback_event = edit.event.clone(); |                                     let mut fallback_event = edit.event.clone(); | ||||||
| @@ -220,9 +242,15 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|                             }; |                             }; | ||||||
|  |  | ||||||
|                             // Calculate the day before this occurrence for UNTIL clause |                             // Calculate the day before this occurrence for UNTIL clause | ||||||
|                             let until_date = edit.event.dtstart.date_naive() - chrono::Duration::days(1); |                             let until_date = | ||||||
|                             let until_datetime = until_date.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap()); |                                 edit.event.dtstart.date_naive() - chrono::Duration::days(1); | ||||||
|                             let until_utc = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(until_datetime, chrono::Utc); |                             let until_datetime = until_date | ||||||
|  |                                 .and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap()); | ||||||
|  |                             let until_utc = | ||||||
|  |                                 chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset( | ||||||
|  |                                     until_datetime, | ||||||
|  |                                     chrono::Utc, | ||||||
|  |                                 ); | ||||||
|  |  | ||||||
|                             web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",  |                             web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",  | ||||||
|                                 until_utc.format("%Y-%m-%d %H:%M:%S UTC"), |                                 until_utc.format("%Y-%m-%d %H:%M:%S UTC"), | ||||||
| @@ -249,18 +277,26 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|                                 true,            // preserve_rrule = true |                                 true,            // preserve_rrule = true | ||||||
|                                 Some(until_utc), // UNTIL date for original series |                                 Some(until_utc), // UNTIL date for original series | ||||||
|                                 Some("this_and_future".to_string()), // Update scope |                                 Some("this_and_future".to_string()), // Update scope | ||||||
|                                 Some(occurrence_date) // Date of occurrence being modified |                                 Some(occurrence_date), // Date of occurrence being modified | ||||||
|                             )); |                             )); | ||||||
|                         } |                         } | ||||||
|                     }, |                     } | ||||||
|                     RecurringEditAction::AllEvents => { |                     RecurringEditAction::AllEvents => { | ||||||
|                         // Modify the entire series |                         // Modify the entire series | ||||||
|                         let series_event = edit.event.clone(); |                         let series_event = edit.event.clone(); | ||||||
|  |  | ||||||
|                         if let Some(callback) = &on_event_update { |                         if let Some(callback) = &on_event_update { | ||||||
|                             callback.emit((series_event, edit.new_start, edit.new_end, true, None, Some("all_in_series".to_string()), None)); // Regular drag operation - preserve RRULE, update_scope = all_in_series |                             callback.emit(( | ||||||
|  |                                 series_event, | ||||||
|  |                                 edit.new_start, | ||||||
|  |                                 edit.new_end, | ||||||
|  |                                 true, | ||||||
|  |                                 None, | ||||||
|  |                                 Some("all_in_series".to_string()), | ||||||
|  |                                 None, | ||||||
|  |                             )); // Regular drag operation - preserve RRULE, update_scope = all_in_series | ||||||
|  |                         } | ||||||
|                     } |                     } | ||||||
|                     }, |  | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|             pending_recurring_edit.set(None); |             pending_recurring_edit.set(None); | ||||||
| @@ -988,7 +1024,6 @@ fn pixels_to_time(pixels: f64) -> NaiveTime { | |||||||
|     NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap()) |     NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap()) | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) { | fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) { | ||||||
|     // Convert UTC times to local time for display |     // Convert UTC times to local time for display | ||||||
|     let local_start = event.dtstart.with_timezone(&Local); |     let local_start = event.dtstart.with_timezone(&Local); | ||||||
| @@ -1009,7 +1044,6 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) | |||||||
|     let start_minute = local_start.minute() as f32; |     let start_minute = local_start.minute() as f32; | ||||||
|     let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour |     let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour | ||||||
|  |  | ||||||
|      |  | ||||||
|     // Calculate duration and height |     // Calculate duration and height | ||||||
|     let duration_pixels = if let Some(end) = event.dtend { |     let duration_pixels = if let Some(end) = event.dtend { | ||||||
|         let local_end = end.with_timezone(&Local); |         let local_end = end.with_timezone(&Local); | ||||||
|   | |||||||
| @@ -1,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 app; | ||||||
| mod auth; | mod auth; | ||||||
| mod components; | mod components; | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,3 +1,5 @@ | |||||||
| pub mod calendar_service; | 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; |     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 { | .login-button, .register-button { | ||||||
|     width: 100%; |     width: 100%; | ||||||
|     padding: 0.75rem; |     padding: 0.75rem; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user