diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1c5e46b..274d216 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -22,6 +22,7 @@ hyper = { version = "1.0", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = { version = "0.4", features = ["serde"] } +chrono-tz = "0.8" uuid = { version = "1.0", features = ["v4", "serde"] } anyhow = "1.0" diff --git a/backend/migrations/006_create_external_calendars_table.sql b/backend/migrations/006_create_external_calendars_table.sql new file mode 100644 index 0000000..d1a934c --- /dev/null +++ b/backend/migrations/006_create_external_calendars_table.sql @@ -0,0 +1,16 @@ +-- Create external_calendars table +CREATE TABLE external_calendars ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + url TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#4285f4', + is_visible BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_fetched DATETIME, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- Create index for performance +CREATE INDEX idx_external_calendars_user_id ON external_calendars(user_id); \ No newline at end of file diff --git a/backend/migrations/007_create_external_calendar_cache_table.sql b/backend/migrations/007_create_external_calendar_cache_table.sql new file mode 100644 index 0000000..0b7aa10 --- /dev/null +++ b/backend/migrations/007_create_external_calendar_cache_table.sql @@ -0,0 +1,14 @@ +-- Create external calendar cache table for storing ICS data +CREATE TABLE external_calendar_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + external_calendar_id INTEGER NOT NULL, + ics_data TEXT NOT NULL, + cached_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + etag TEXT, + FOREIGN KEY (external_calendar_id) REFERENCES external_calendars(id) ON DELETE CASCADE, + UNIQUE(external_calendar_id) +); + +-- Index for faster lookups +CREATE INDEX idx_external_calendar_cache_calendar_id ON external_calendar_cache(external_calendar_id); +CREATE INDEX idx_external_calendar_cache_cached_at ON external_calendar_cache(cached_at); \ No newline at end of file diff --git a/backend/src/auth.rs b/backend/src/auth.rs index ce2c6af..d5c2bde 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -112,6 +112,17 @@ impl AuthService { self.decode_token(token) } + /// Get user from token + pub async fn get_user_from_token(&self, token: &str) -> Result { + let claims = self.verify_token(token)?; + + let user_repo = UserRepository::new(&self.db); + user_repo + .find_or_create(&claims.username, &claims.server_url) + .await + .map_err(|e| ApiError::Database(format!("Failed to get user: {}", e))) + } + /// Create CalDAV config from token pub fn caldav_config_from_token( &self, diff --git a/backend/src/db.rs b/backend/src/db.rs index 86bc8b5..95f5973 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -1,4 +1,4 @@ -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; use sqlx::{FromRow, Result}; @@ -99,6 +99,38 @@ pub struct UserPreferences { pub updated_at: DateTime, } +/// External calendar model +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct ExternalCalendar { + pub id: i32, + pub user_id: String, + pub name: String, + pub url: String, + pub color: String, + pub is_visible: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub last_fetched: Option>, +} + +impl ExternalCalendar { + /// Create a new external calendar + pub fn new(user_id: String, name: String, url: String, color: String) -> Self { + let now = Utc::now(); + Self { + id: 0, // Will be set by database + user_id, + name, + url, + color, + is_visible: true, + created_at: now, + updated_at: now, + last_fetched: None, + } + } +} + impl UserPreferences { /// Create default preferences for a new user pub fn default_for_user(user_id: String) -> Self { @@ -308,6 +340,149 @@ impl<'a> PreferencesRepository<'a> { .execute(self.db.pool()) .await?; + Ok(()) + } +} + +/// Repository for ExternalCalendar operations +pub struct ExternalCalendarRepository<'a> { + db: &'a Database, +} + +impl<'a> ExternalCalendarRepository<'a> { + pub fn new(db: &'a Database) -> Self { + Self { db } + } + + /// Get all external calendars for a user + pub async fn get_by_user(&self, user_id: &str) -> Result> { + sqlx::query_as::<_, ExternalCalendar>( + "SELECT * FROM external_calendars WHERE user_id = ? ORDER BY created_at ASC", + ) + .bind(user_id) + .fetch_all(self.db.pool()) + .await + } + + /// Create a new external calendar + pub async fn create(&self, calendar: &ExternalCalendar) -> Result { + let result = sqlx::query( + "INSERT INTO external_calendars (user_id, name, url, color, is_visible, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&calendar.user_id) + .bind(&calendar.name) + .bind(&calendar.url) + .bind(&calendar.color) + .bind(&calendar.is_visible) + .bind(&calendar.created_at) + .bind(&calendar.updated_at) + .execute(self.db.pool()) + .await?; + + Ok(result.last_insert_rowid() as i32) + } + + /// Update an external calendar + pub async fn update(&self, id: i32, calendar: &ExternalCalendar) -> Result<()> { + sqlx::query( + "UPDATE external_calendars + SET name = ?, url = ?, color = ?, is_visible = ?, updated_at = ? + WHERE id = ? AND user_id = ?", + ) + .bind(&calendar.name) + .bind(&calendar.url) + .bind(&calendar.color) + .bind(&calendar.is_visible) + .bind(Utc::now()) + .bind(id) + .bind(&calendar.user_id) + .execute(self.db.pool()) + .await?; + + Ok(()) + } + + /// Delete an external calendar + pub async fn delete(&self, id: i32, user_id: &str) -> Result<()> { + sqlx::query("DELETE FROM external_calendars WHERE id = ? AND user_id = ?") + .bind(id) + .bind(user_id) + .execute(self.db.pool()) + .await?; + + Ok(()) + } + + /// Update last_fetched timestamp + pub async fn update_last_fetched(&self, id: i32, user_id: &str) -> Result<()> { + sqlx::query( + "UPDATE external_calendars SET last_fetched = ? WHERE id = ? AND user_id = ?", + ) + .bind(Utc::now()) + .bind(id) + .bind(user_id) + .execute(self.db.pool()) + .await?; + + Ok(()) + } + + /// Get cached ICS data for an external calendar + pub async fn get_cached_data(&self, external_calendar_id: i32) -> Result)>> { + let result = sqlx::query_as::<_, (String, DateTime)>( + "SELECT ics_data, cached_at FROM external_calendar_cache WHERE external_calendar_id = ?", + ) + .bind(external_calendar_id) + .fetch_optional(self.db.pool()) + .await?; + + Ok(result) + } + + /// Update cache with new ICS data + pub async fn update_cache(&self, external_calendar_id: i32, ics_data: &str, etag: Option<&str>) -> Result<()> { + sqlx::query( + "INSERT INTO external_calendar_cache (external_calendar_id, ics_data, etag, cached_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(external_calendar_id) DO UPDATE SET + ics_data = excluded.ics_data, + etag = excluded.etag, + cached_at = excluded.cached_at", + ) + .bind(external_calendar_id) + .bind(ics_data) + .bind(etag) + .bind(Utc::now()) + .execute(self.db.pool()) + .await?; + + Ok(()) + } + + /// Check if cache is stale (older than max_age_minutes) + pub async fn is_cache_stale(&self, external_calendar_id: i32, max_age_minutes: i64) -> Result { + let cutoff_time = Utc::now() - Duration::minutes(max_age_minutes); + + let result = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM external_calendar_cache + WHERE external_calendar_id = ? AND cached_at > ?", + ) + .bind(external_calendar_id) + .bind(cutoff_time) + .fetch_one(self.db.pool()) + .await?; + + Ok(result == 0) + } + + /// Clear cache for an external calendar + pub async fn clear_cache(&self, external_calendar_id: i32) -> Result<()> { + sqlx::query("DELETE FROM external_calendar_cache WHERE external_calendar_id = ?") + .bind(external_calendar_id) + .execute(self.db.pool()) + .await?; + Ok(()) } } \ No newline at end of file diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs deleted file mode 100644 index 9d278c7..0000000 --- a/backend/src/handlers.rs +++ /dev/null @@ -1,12 +0,0 @@ -// Re-export all handlers from the modular structure -mod auth; -mod calendar; -mod events; -mod preferences; -mod series; - -pub use auth::{get_user_info, login, verify_token}; -pub use calendar::{create_calendar, delete_calendar}; -pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event}; -pub use preferences::{get_preferences, logout, update_preferences}; -pub use series::{create_event_series, delete_event_series, update_event_series}; diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index 5c6107e..89ab1af 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -93,6 +93,7 @@ pub async fn get_user_info( path: path.clone(), display_name: extract_calendar_name(path), color: generate_calendar_color(path), + is_visible: true, // Default to visible }) .collect(); diff --git a/backend/src/handlers/external_calendars.rs b/backend/src/handlers/external_calendars.rs new file mode 100644 index 0000000..05cc91f --- /dev/null +++ b/backend/src/handlers/external_calendars.rs @@ -0,0 +1,142 @@ +use axum::{ + extract::{Path, State}, + response::Json, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::{ + db::{ExternalCalendar, ExternalCalendarRepository}, + models::ApiError, + AppState, +}; + +use super::auth::{extract_bearer_token}; + +#[derive(Debug, Deserialize)] +pub struct CreateExternalCalendarRequest { + pub name: String, + pub url: String, + pub color: String, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateExternalCalendarRequest { + pub name: String, + pub url: String, + pub color: String, + pub is_visible: bool, +} + +#[derive(Debug, Serialize)] +pub struct ExternalCalendarResponse { + pub id: i32, + pub name: String, + pub url: String, + pub color: String, + pub is_visible: bool, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub last_fetched: Option>, +} + +impl From for ExternalCalendarResponse { + fn from(calendar: ExternalCalendar) -> Self { + Self { + id: calendar.id, + name: calendar.name, + url: calendar.url, + color: calendar.color, + is_visible: calendar.is_visible, + created_at: calendar.created_at, + updated_at: calendar.updated_at, + last_fetched: calendar.last_fetched, + } + } +} + +pub async fn get_external_calendars( + headers: axum::http::HeaderMap, + State(app_state): State>, +) -> Result>, ApiError> { + // Extract and verify token, get user + let token = extract_bearer_token(&headers)?; + let user = app_state.auth_service.get_user_from_token(&token).await?; + + let repo = ExternalCalendarRepository::new(&app_state.db); + let calendars = repo + .get_by_user(&user.id) + .await + .map_err(|e| ApiError::Database(format!("Failed to get external calendars: {}", e)))?; + + let response: Vec = calendars.into_iter().map(Into::into).collect(); + Ok(Json(response)) +} + +pub async fn create_external_calendar( + headers: axum::http::HeaderMap, + State(app_state): State>, + Json(request): Json, +) -> Result, ApiError> { + let token = extract_bearer_token(&headers)?; + let user = app_state.auth_service.get_user_from_token(&token).await?; + + let calendar = ExternalCalendar::new( + user.id, + request.name, + request.url, + request.color, + ); + + let repo = ExternalCalendarRepository::new(&app_state.db); + let id = repo + .create(&calendar) + .await + .map_err(|e| ApiError::Database(format!("Failed to create external calendar: {}", e)))?; + + let mut created_calendar = calendar; + created_calendar.id = id; + + Ok(Json(created_calendar.into())) +} + +pub async fn update_external_calendar( + headers: axum::http::HeaderMap, + State(app_state): State>, + Path(id): Path, + Json(request): Json, +) -> Result, ApiError> { + let token = extract_bearer_token(&headers)?; + let user = app_state.auth_service.get_user_from_token(&token).await?; + + let mut calendar = ExternalCalendar::new( + user.id, + request.name, + request.url, + request.color, + ); + calendar.is_visible = request.is_visible; + + let repo = ExternalCalendarRepository::new(&app_state.db); + repo.update(id, &calendar) + .await + .map_err(|e| ApiError::Database(format!("Failed to update external calendar: {}", e)))?; + + Ok(Json(())) +} + +pub async fn delete_external_calendar( + headers: axum::http::HeaderMap, + State(app_state): State>, + Path(id): Path, +) -> Result, ApiError> { + let token = extract_bearer_token(&headers)?; + let user = app_state.auth_service.get_user_from_token(&token).await?; + + let repo = ExternalCalendarRepository::new(&app_state.db); + repo.delete(id, &user.id) + .await + .map_err(|e| ApiError::Database(format!("Failed to delete external calendar: {}", e)))?; + + Ok(Json(())) +} \ No newline at end of file diff --git a/backend/src/handlers/ics_fetcher.rs b/backend/src/handlers/ics_fetcher.rs new file mode 100644 index 0000000..1800e14 --- /dev/null +++ b/backend/src/handlers/ics_fetcher.rs @@ -0,0 +1,410 @@ +use axum::{ + extract::{Path, State}, + response::Json, +}; +use chrono::{DateTime, Utc}; +use ical::parser::ical::component::IcalEvent; +use reqwest::Client; +use serde::Serialize; +use std::sync::Arc; + +use crate::{ + db::ExternalCalendarRepository, + models::ApiError, + AppState, +}; + +// Import VEvent from calendar-models shared crate +use calendar_models::VEvent; + +use super::auth::{extract_bearer_token}; + +#[derive(Debug, Serialize)] +pub struct ExternalCalendarEventsResponse { + pub events: Vec, + pub last_fetched: DateTime, +} + +pub async fn fetch_external_calendar_events( + headers: axum::http::HeaderMap, + State(app_state): State>, + Path(id): Path, +) -> Result, ApiError> { + let token = extract_bearer_token(&headers)?; + let user = app_state.auth_service.get_user_from_token(&token).await?; + + let repo = ExternalCalendarRepository::new(&app_state.db); + + // Get user's external calendars to verify ownership and get URL + let calendars = repo + .get_by_user(&user.id) + .await + .map_err(|e| ApiError::Database(format!("Failed to get external calendars: {}", e)))?; + + let calendar = calendars + .into_iter() + .find(|c| c.id == id) + .ok_or_else(|| ApiError::NotFound("External calendar not found".to_string()))?; + + if !calendar.is_visible { + return Ok(Json(ExternalCalendarEventsResponse { + events: vec![], + last_fetched: Utc::now(), + })); + } + + // Check cache first + let cache_max_age_minutes = 5; + let mut ics_content = String::new(); + let mut last_fetched = Utc::now(); + let mut fetched_from_cache = false; + + // Try to get from cache if not stale + match repo.is_cache_stale(id, cache_max_age_minutes).await { + Ok(is_stale) => { + if !is_stale { + // Cache is fresh, use it + if let Ok(Some((cached_data, cached_at))) = repo.get_cached_data(id).await { + ics_content = cached_data; + last_fetched = cached_at; + fetched_from_cache = true; + } + } + } + Err(_) => { + // If cache check fails, proceed to fetch from URL + } + } + + // If not fetched from cache, get from external URL + if !fetched_from_cache { + let client = Client::new(); + let response = client + .get(&calendar.url) + .send() + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch calendar: {}", e)))?; + + if !response.status().is_success() { + return Err(ApiError::Internal(format!("Calendar server returned: {}", response.status()))); + } + + ics_content = response + .text() + .await + .map_err(|e| ApiError::Internal(format!("Failed to read calendar content: {}", e)))?; + + // Store in cache for future requests + let etag = None; // TODO: Extract ETag from response headers if available + if let Err(_) = repo.update_cache(id, &ics_content, etag).await { + // Log error but don't fail the request + } + + // Update last_fetched timestamp + if let Err(_) = repo.update_last_fetched(id, &user.id).await { + } + + last_fetched = Utc::now(); + } + + // Parse ICS content + let events = parse_ics_content(&ics_content) + .map_err(|e| ApiError::BadRequest(format!("Failed to parse calendar: {}", e)))?; + + Ok(Json(ExternalCalendarEventsResponse { + events, + last_fetched, + })) +} + +fn parse_ics_content(ics_content: &str) -> Result, Box> { + let reader = ical::IcalParser::new(ics_content.as_bytes()); + let mut events = Vec::new(); + let mut _total_components = 0; + let mut _failed_conversions = 0; + + for calendar in reader { + let calendar = calendar?; + for component in calendar.events { + _total_components += 1; + match convert_ical_to_vevent(component) { + Ok(vevent) => { + events.push(vevent); + } + Err(_) => { + _failed_conversions += 1; + } + } + } + } + + + Ok(events) +} + +fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result> { + use uuid::Uuid; + + let mut summary = None; + let mut description = None; + let mut location = None; + let mut dtstart = None; + let mut dtend = None; + let mut uid = None; + let mut all_day = false; + let mut rrule = None; + + + // Extract properties + for property in ical_event.properties { + match property.name.as_str() { + "SUMMARY" => { + summary = property.value; + } + "DESCRIPTION" => { + description = property.value; + } + "LOCATION" => { + location = property.value; + } + "DTSTART" => { + if let Some(value) = property.value { + // Check if it's a date-only value (all-day event) + if value.len() == 8 && !value.contains('T') { + all_day = true; + // Parse YYYYMMDD format + if let Ok(date) = chrono::NaiveDate::parse_from_str(&value, "%Y%m%d") { + dtstart = Some(chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap())); + } + } else { + // Extract timezone info from parameters + let tzid = property.params.as_ref() + .and_then(|params| params.iter().find(|(k, _)| k == "TZID")) + .and_then(|(_, v)| v.first().cloned()); + + // Parse datetime with timezone information + if let Some(dt) = parse_datetime_with_tz(&value, tzid.as_deref()) { + dtstart = Some(dt); + } + } + } + } + "DTEND" => { + if let Some(value) = property.value { + if all_day && value.len() == 8 && !value.contains('T') { + // For all-day events, DTEND is exclusive so use the date as-is at noon + if let Ok(date) = chrono::NaiveDate::parse_from_str(&value, "%Y%m%d") { + dtend = Some(chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap())); + } + } else { + // Extract timezone info from parameters + let tzid = property.params.as_ref() + .and_then(|params| params.iter().find(|(k, _)| k == "TZID")) + .and_then(|(_, v)| v.first().cloned()); + + // Parse datetime with timezone information + if let Some(dt) = parse_datetime_with_tz(&value, tzid.as_deref()) { + dtend = Some(dt); + } + } + } + } + "UID" => { + uid = property.value; + } + "RRULE" => { + rrule = property.value; + } + _ => {} // Ignore other properties for now + } + } + + + let dtstart = dtstart.ok_or("Missing DTSTART")?; + + let vevent = VEvent { + uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()), + dtstart, + dtend, + summary, + description, + location, + all_day, + rrule, + exdate: Vec::new(), // External calendars don't need exception handling + recurrence_id: None, + created: None, + last_modified: None, + dtstamp: Utc::now(), + sequence: Some(0), + status: None, + transp: None, + organizer: None, + attendees: Vec::new(), + url: None, + attachments: Vec::new(), + categories: Vec::new(), + priority: None, + resources: Vec::new(), + related_to: None, + geo: None, + duration: None, + class: None, + contact: None, + comment: None, + rdate: Vec::new(), + alarms: Vec::new(), + etag: None, + href: None, + calendar_path: None, + }; + + Ok(vevent) +} + +fn parse_datetime_with_tz(datetime_str: &str, tzid: Option<&str>) -> Option> { + use chrono::TimeZone; + use chrono_tz::Tz; + + + // Try various datetime formats commonly found in ICS files + + // Format: 20231201T103000Z (UTC) - handle as naive datetime first + if datetime_str.ends_with('Z') { + let datetime_without_z = &datetime_str[..datetime_str.len()-1]; + if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_without_z, "%Y%m%dT%H%M%S") { + return Some(naive_dt.and_utc()); + } + } + + // Format: 20231201T103000-0500 (with timezone offset) + if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S%z") { + return Some(dt.with_timezone(&Utc)); + } + + // Format: 2023-12-01T10:30:00Z (ISO format) + if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%SZ") { + return Some(dt.with_timezone(&Utc)); + } + + // Handle naive datetime with timezone parameter + let naive_dt = if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S") { + Some(dt) + } else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%S") { + Some(dt) + } else { + None + }; + + if let Some(naive_dt) = naive_dt { + // If TZID is provided, try to parse it + if let Some(tzid_str) = tzid { + // Handle common timezone formats + let tz_result = if tzid_str.starts_with("/mozilla.org/") { + // Mozilla/Thunderbird format: /mozilla.org/20070129_1/Europe/London + tzid_str.split('/').last().and_then(|tz_name| tz_name.parse::().ok()) + } else if tzid_str.contains('/') { + // Standard timezone format: America/New_York, Europe/London + tzid_str.parse::().ok() + } else { + // Try common abbreviations and Windows timezone names + match tzid_str { + // Standard abbreviations + "EST" => Some(Tz::America__New_York), + "PST" => Some(Tz::America__Los_Angeles), + "MST" => Some(Tz::America__Denver), + "CST" => Some(Tz::America__Chicago), + + // North America - Windows timezone names to IANA mapping + "Mountain Standard Time" => Some(Tz::America__Denver), + "Eastern Standard Time" => Some(Tz::America__New_York), + "Central Standard Time" => Some(Tz::America__Chicago), + "Pacific Standard Time" => Some(Tz::America__Los_Angeles), + "Mountain Daylight Time" => Some(Tz::America__Denver), + "Eastern Daylight Time" => Some(Tz::America__New_York), + "Central Daylight Time" => Some(Tz::America__Chicago), + "Pacific Daylight Time" => Some(Tz::America__Los_Angeles), + "Hawaiian Standard Time" => Some(Tz::Pacific__Honolulu), + "Alaskan Standard Time" => Some(Tz::America__Anchorage), + "Alaskan Daylight Time" => Some(Tz::America__Anchorage), + "Atlantic Standard Time" => Some(Tz::America__Halifax), + "Newfoundland Standard Time" => Some(Tz::America__St_Johns), + + // Europe + "GMT Standard Time" => Some(Tz::Europe__London), + "Greenwich Standard Time" => Some(Tz::UTC), + "W. Europe Standard Time" => Some(Tz::Europe__Berlin), + "Central Europe Standard Time" => Some(Tz::Europe__Warsaw), + "Romance Standard Time" => Some(Tz::Europe__Paris), + "Central European Standard Time" => Some(Tz::Europe__Belgrade), + "E. Europe Standard Time" => Some(Tz::Europe__Bucharest), + "FLE Standard Time" => Some(Tz::Europe__Helsinki), + "GTB Standard Time" => Some(Tz::Europe__Athens), + "Russian Standard Time" => Some(Tz::Europe__Moscow), + "Turkey Standard Time" => Some(Tz::Europe__Istanbul), + + // Asia + "China Standard Time" => Some(Tz::Asia__Shanghai), + "Tokyo Standard Time" => Some(Tz::Asia__Tokyo), + "Korea Standard Time" => Some(Tz::Asia__Seoul), + "Singapore Standard Time" => Some(Tz::Asia__Singapore), + "India Standard Time" => Some(Tz::Asia__Kolkata), + "Pakistan Standard Time" => Some(Tz::Asia__Karachi), + "Bangladesh Standard Time" => Some(Tz::Asia__Dhaka), + "Thailand Standard Time" => Some(Tz::Asia__Bangkok), + "SE Asia Standard Time" => Some(Tz::Asia__Bangkok), + "Myanmar Standard Time" => Some(Tz::Asia__Yangon), + "Sri Lanka Standard Time" => Some(Tz::Asia__Colombo), + "Nepal Standard Time" => Some(Tz::Asia__Kathmandu), + "Central Asia Standard Time" => Some(Tz::Asia__Almaty), + "West Asia Standard Time" => Some(Tz::Asia__Tashkent), + "Afghanistan Standard Time" => Some(Tz::Asia__Kabul), + "Iran Standard Time" => Some(Tz::Asia__Tehran), + "Arabian Standard Time" => Some(Tz::Asia__Dubai), + "Arab Standard Time" => Some(Tz::Asia__Riyadh), + "Israel Standard Time" => Some(Tz::Asia__Jerusalem), + "Jordan Standard Time" => Some(Tz::Asia__Amman), + "Syria Standard Time" => Some(Tz::Asia__Damascus), + "Middle East Standard Time" => Some(Tz::Asia__Beirut), + "Egypt Standard Time" => Some(Tz::Africa__Cairo), + "South Africa Standard Time" => Some(Tz::Africa__Johannesburg), + "E. Africa Standard Time" => Some(Tz::Africa__Nairobi), + "W. Central Africa Standard Time" => Some(Tz::Africa__Lagos), + + // Asia Pacific + "AUS Eastern Standard Time" => Some(Tz::Australia__Sydney), + "AUS Central Standard Time" => Some(Tz::Australia__Darwin), + "W. Australia Standard Time" => Some(Tz::Australia__Perth), + "Tasmania Standard Time" => Some(Tz::Australia__Hobart), + "New Zealand Standard Time" => Some(Tz::Pacific__Auckland), + "Fiji Standard Time" => Some(Tz::Pacific__Fiji), + "Tonga Standard Time" => Some(Tz::Pacific__Tongatapu), + + // South America + "Argentina Standard Time" => Some(Tz::America__Buenos_Aires), + "E. South America Standard Time" => Some(Tz::America__Sao_Paulo), + "SA Eastern Standard Time" => Some(Tz::America__Cayenne), + "SA Pacific Standard Time" => Some(Tz::America__Bogota), + "SA Western Standard Time" => Some(Tz::America__La_Paz), + "Pacific SA Standard Time" => Some(Tz::America__Santiago), + "Venezuela Standard Time" => Some(Tz::America__Caracas), + "Montevideo Standard Time" => Some(Tz::America__Montevideo), + + // Try parsing as IANA name + _ => tzid_str.parse::().ok() + } + }; + + if let Some(tz) = tz_result { + if let Some(dt_with_tz) = tz.from_local_datetime(&naive_dt).single() { + return Some(dt_with_tz.with_timezone(&Utc)); + } + } + } + + // If no timezone info or parsing failed, treat as UTC (safer than local time assumptions) + return Some(chrono::TimeZone::from_utc_datetime(&Utc, &naive_dt)); + } + + None +} \ No newline at end of file diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs new file mode 100644 index 0000000..d905b89 --- /dev/null +++ b/backend/src/handlers/mod.rs @@ -0,0 +1,15 @@ +pub mod auth; +pub mod calendar; +pub mod events; +pub mod external_calendars; +pub mod ics_fetcher; +pub mod preferences; +pub mod series; + +pub use auth::*; +pub use calendar::*; +pub use events::*; +pub use external_calendars::*; +pub use ics_fetcher::*; +pub use preferences::*; +pub use series::*; \ No newline at end of file diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 7b719e3..2b9e395 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,6 +1,6 @@ use axum::{ response::Json, - routing::{get, post}, + routing::{delete, get, post}, Router, }; use std::sync::Arc; @@ -72,6 +72,12 @@ pub async fn run_server() -> Result<(), Box> { .route("/api/preferences", get(handlers::get_preferences)) .route("/api/preferences", post(handlers::update_preferences)) .route("/api/auth/logout", post(handlers::logout)) + // External calendars endpoints + .route("/api/external-calendars", get(handlers::get_external_calendars)) + .route("/api/external-calendars", post(handlers::create_external_calendar)) + .route("/api/external-calendars/:id", post(handlers::update_external_calendar)) + .route("/api/external-calendars/:id", delete(handlers::delete_external_calendar)) + .route("/api/external-calendars/:id/events", get(handlers::fetch_external_calendar_events)) .layer( CorsLayer::new() .allow_origin(Any) diff --git a/backend/src/models.rs b/backend/src/models.rs index a001b4a..2b77fde 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -56,6 +56,7 @@ pub struct CalendarInfo { pub path: String, pub display_name: String, pub color: String, + pub is_visible: bool, } #[derive(Debug, Deserialize)] diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 1cfeaa0..e6f55b5 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -37,6 +37,7 @@ reqwest = { version = "0.11", features = ["json"] } ical = "0.7" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde-wasm-bindgen = "0.6" # Date and time handling chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] } diff --git a/frontend/src/app.rs b/frontend/src/app.rs index a628517..e003453 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -1,12 +1,14 @@ use crate::components::{ CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction, - EditAction, EventContextMenu, EventCreationData, RouteHandler, Sidebar, Theme, ViewMode, + EditAction, EventContextMenu, EventCreationData, ExternalCalendarModal, RouteHandler, + Sidebar, Theme, ViewMode, }; use crate::components::sidebar::{Style}; use crate::models::ical::VEvent; -use crate::services::{calendar_service::UserInfo, CalendarService}; +use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; use chrono::NaiveDate; use gloo_storage::{LocalStorage, Storage}; +use gloo_timers::callback::Interval; use wasm_bindgen::JsCast; use web_sys::MouseEvent; use yew::prelude::*; @@ -73,6 +75,12 @@ pub fn App() -> Html { let _recurring_edit_modal_open = use_state(|| false); let _recurring_edit_event = use_state(|| -> Option { None }); let _recurring_edit_data = use_state(|| -> Option { None }); + + // External calendar state + let external_calendars = use_state(|| -> Vec { Vec::new() }); + let external_calendar_events = use_state(|| -> Vec { Vec::new() }); + let external_calendar_modal_open = use_state(|| false); + let refresh_interval = use_state(|| -> Option { None }); // Calendar view state - load from localStorage if available let current_view = use_state(|| { @@ -301,6 +309,80 @@ pub fn App() -> Html { }); } + // Function to refresh external calendars + let refresh_external_calendars = { + let external_calendars = external_calendars.clone(); + let external_calendar_events = external_calendar_events.clone(); + Callback::from(move |_| { + let external_calendars = external_calendars.clone(); + let external_calendar_events = external_calendar_events.clone(); + + wasm_bindgen_futures::spawn_local(async move { + // Load external calendars + match CalendarService::get_external_calendars().await { + Ok(calendars) => { + external_calendars.set(calendars.clone()); + + // Load events for visible external calendars + let mut all_events = Vec::new(); + for calendar in calendars { + if calendar.is_visible { + if let Ok(mut events) = CalendarService::fetch_external_calendar_events(calendar.id).await { + // Set calendar_path for color matching + for event in &mut events { + event.calendar_path = Some(format!("external_{}", calendar.id)); + } + all_events.extend(events); + } + } + } + external_calendar_events.set(all_events); + } + Err(err) => { + web_sys::console::log_1( + &format!("Failed to load external calendars: {}", err).into(), + ); + } + } + }); + }) + }; + + // Load external calendars when auth token is available and set up auto-refresh + { + let auth_token = auth_token.clone(); + let refresh_external_calendars = refresh_external_calendars.clone(); + let refresh_interval = refresh_interval.clone(); + let external_calendars = external_calendars.clone(); + let external_calendar_events = external_calendar_events.clone(); + + use_effect_with((*auth_token).clone(), move |token| { + if let Some(_) = token { + // Initial load + refresh_external_calendars.emit(()); + + // Set up 5-minute refresh interval + let refresh_external_calendars = refresh_external_calendars.clone(); + let interval = Interval::new(5 * 60 * 1000, move || { + refresh_external_calendars.emit(()); + }); + refresh_interval.set(Some(interval)); + } else { + // Clear data and interval when logged out + external_calendars.set(Vec::new()); + external_calendar_events.set(Vec::new()); + refresh_interval.set(None); + } + + // Cleanup function + let refresh_interval = refresh_interval.clone(); + move || { + // Clear interval on cleanup + refresh_interval.set(None); + } + }); + } + let on_outside_click = { let color_picker_open = color_picker_open.clone(); let context_menu_open = context_menu_open.clone(); @@ -924,11 +1006,146 @@ pub fn App() -> Html { let create_modal_open = create_modal_open.clone(); move |_| create_modal_open.set(true) })} + on_create_external_calendar={Callback::from({ + let external_calendar_modal_open = external_calendar_modal_open.clone(); + move |_| external_calendar_modal_open.set(true) + })} + external_calendars={(*external_calendars).clone()} + on_external_calendar_toggle={Callback::from({ + let external_calendars = external_calendars.clone(); + let external_calendar_events = external_calendar_events.clone(); + move |id: i32| { + let external_calendars = external_calendars.clone(); + let external_calendar_events = external_calendar_events.clone(); + wasm_bindgen_futures::spawn_local(async move { + // Find the calendar and toggle its visibility + let mut calendars = (*external_calendars).clone(); + if let Some(calendar) = calendars.iter_mut().find(|c| c.id == id) { + calendar.is_visible = !calendar.is_visible; + + // Update on server + if let Err(err) = CalendarService::update_external_calendar( + calendar.id, + &calendar.name, + &calendar.url, + &calendar.color, + calendar.is_visible, + ).await { + web_sys::console::log_1( + &format!("Failed to update external calendar: {}", err).into(), + ); + return; + } + + external_calendars.set(calendars.clone()); + + // Reload events for all visible external calendars + let mut all_events = Vec::new(); + for cal in calendars { + if cal.is_visible { + if let Ok(mut events) = CalendarService::fetch_external_calendar_events(cal.id).await { + // Set calendar_path for color matching + for event in &mut events { + event.calendar_path = Some(format!("external_{}", cal.id)); + } + all_events.extend(events); + } + } + } + external_calendar_events.set(all_events); + } + }); + } + })} + on_external_calendar_delete={Callback::from({ + let external_calendars = external_calendars.clone(); + let external_calendar_events = external_calendar_events.clone(); + move |id: i32| { + let external_calendars = external_calendars.clone(); + let external_calendar_events = external_calendar_events.clone(); + wasm_bindgen_futures::spawn_local(async move { + // Delete the external calendar from the server + if let Err(err) = CalendarService::delete_external_calendar(id).await { + web_sys::console::log_1( + &format!("Failed to delete external calendar: {}", err).into(), + ); + return; + } + + // Remove calendar from local state + let mut calendars = (*external_calendars).clone(); + calendars.retain(|c| c.id != id); + external_calendars.set(calendars.clone()); + + // Remove events from this calendar + let mut events = (*external_calendar_events).clone(); + events.retain(|e| { + if let Some(ref calendar_path) = e.calendar_path { + calendar_path != &format!("external_{}", id) + } else { + true + } + }); + external_calendar_events.set(events); + }); + } + })} + on_external_calendar_refresh={Callback::from({ + let external_calendar_events = external_calendar_events.clone(); + let external_calendars = external_calendars.clone(); + move |id: i32| { + let external_calendar_events = external_calendar_events.clone(); + let external_calendars = external_calendars.clone(); + wasm_bindgen_futures::spawn_local(async move { + // Force refresh of this specific calendar + if let Ok(mut events) = CalendarService::fetch_external_calendar_events(id).await { + // Set calendar_path for color matching + for event in &mut events { + event.calendar_path = Some(format!("external_{}", id)); + } + + // Update events for this calendar + let mut all_events = (*external_calendar_events).clone(); + // Remove old events from this calendar + all_events.retain(|e| { + if let Some(ref calendar_path) = e.calendar_path { + calendar_path != &format!("external_{}", id) + } else { + true + } + }); + // Add new events + all_events.extend(events); + external_calendar_events.set(all_events); + + // Update the last_fetched timestamp in calendars list + if let Ok(calendars) = CalendarService::get_external_calendars().await { + external_calendars.set(calendars); + } + } + }); + } + })} color_picker_open={(*color_picker_open).clone()} on_color_change={on_color_change} on_color_picker_toggle={on_color_picker_toggle} available_colors={(*available_colors).clone()} on_calendar_context_menu={on_calendar_context_menu} + on_calendar_visibility_toggle={Callback::from({ + let user_info = user_info.clone(); + move |calendar_path: String| { + let user_info = user_info.clone(); + wasm_bindgen_futures::spawn_local(async move { + if let Some(mut info) = (*user_info).clone() { + // Toggle the visibility + if let Some(calendar) = info.calendars.iter_mut().find(|c| c.path == calendar_path) { + calendar.is_visible = !calendar.is_visible; + user_info.set(Some(info)); + } + } + }); + } + })} current_view={(*current_view).clone()} on_view_change={on_view_change} current_theme={(*current_theme).clone()} @@ -941,6 +1158,8 @@ pub fn App() -> Html { auth_token={(*auth_token).clone()} user_info={(*user_info).clone()} on_login={on_login.clone()} + external_calendar_events={(*external_calendar_events).clone()} + external_calendars={(*external_calendars).clone()} on_event_context_menu={Some(on_event_context_menu.clone())} on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())} view={(*current_view).clone()} @@ -1193,6 +1412,59 @@ pub fn App() -> Html { on_create={on_event_create} available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()} /> + + { + external_calendars.set(calendars.clone()); + + // Then immediately fetch events for the new calendar if it's visible + if let Some(new_calendar) = calendars.iter().find(|c| c.id == new_calendar_id) { + if new_calendar.is_visible { + match CalendarService::fetch_external_calendar_events(new_calendar_id).await { + Ok(mut events) => { + // Set calendar_path for color matching + for event in &mut events { + event.calendar_path = Some(format!("external_{}", new_calendar_id)); + } + + // Add the new calendar's events to existing events + let mut all_events = (*external_calendar_events).clone(); + all_events.extend(events); + external_calendar_events.set(all_events); + } + Err(e) => { + web_sys::console::log_1( + &format!("Failed to fetch events for new calendar {}: {}", new_calendar_id, e).into(), + ); + } + } + } + } + } + Err(err) => { + web_sys::console::log_1( + &format!("Failed to refresh calendars after creation: {}", err).into(), + ); + } + } + }); + } + })} + /> } diff --git a/frontend/src/components/calendar.rs b/frontend/src/components/calendar.rs index b60cdf0..8608e00 100644 --- a/frontend/src/components/calendar.rs +++ b/frontend/src/components/calendar.rs @@ -2,7 +2,7 @@ use crate::components::{ CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView, }; use crate::models::ical::VEvent; -use crate::services::{calendar_service::UserInfo, CalendarService}; +use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; use chrono::{Datelike, Duration, Local, NaiveDate}; use gloo_storage::{LocalStorage, Storage}; use std::collections::HashMap; @@ -14,6 +14,10 @@ pub struct CalendarProps { #[prop_or_default] pub user_info: Option, #[prop_or_default] + pub external_calendar_events: Vec, + #[prop_or_default] + pub external_calendars: Vec, + #[prop_or_default] pub on_event_context_menu: Option>, #[prop_or_default] pub on_calendar_context_menu: Option>, @@ -101,10 +105,14 @@ pub fn Calendar(props: &CalendarProps) -> Html { let loading = loading.clone(); let error = error.clone(); let current_date = current_date.clone(); + let external_events = props.external_calendar_events.clone(); // Clone before the effect + let view = props.view.clone(); // Clone before the effect - use_effect_with((*current_date, props.view.clone()), move |(date, _view)| { + use_effect_with((*current_date, view.clone(), external_events.len(), props.user_info.clone()), move |(date, _view, _external_len, user_info)| { let auth_token: Option = LocalStorage::get("auth_token").ok(); let date = *date; // Clone the date to avoid lifetime issues + let external_events = external_events.clone(); // Clone external events to avoid lifetime issues + let user_info = user_info.clone(); // Clone user_info to avoid lifetime issues if let Some(token) = auth_token { let events = events.clone(); @@ -141,7 +149,38 @@ pub fn Calendar(props: &CalendarProps) -> Html { .await { Ok(vevents) => { - let grouped_events = CalendarService::group_events_by_date(vevents); + // Filter CalDAV events based on calendar visibility + let mut filtered_events = if let Some(user_info) = user_info.as_ref() { + vevents.into_iter() + .filter(|event| { + if let Some(calendar_path) = event.calendar_path.as_ref() { + // Find the calendar info for this event + user_info.calendars.iter() + .find(|cal| &cal.path == calendar_path) + .map(|cal| cal.is_visible) + .unwrap_or(true) // Default to visible if not found + } else { + true // Show events without calendar path + } + }) + .collect() + } else { + vevents // Show all events if no user info + }; + + // Mark external events as external by adding a special category + let marked_external_events: Vec = external_events + .into_iter() + .map(|mut event| { + // Add a special category to identify external events + event.categories.push("__EXTERNAL_CALENDAR__".to_string()); + event + }) + .collect(); + + filtered_events.extend(marked_external_events); + + let grouped_events = CalendarService::group_events_by_date(filtered_events); events.set(grouped_events); loading.set(false); } @@ -452,6 +491,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { on_event_click={on_event_click.clone()} refreshing_event_uid={(*refreshing_event_uid).clone()} user_info={props.user_info.clone()} + external_calendars={props.external_calendars.clone()} on_event_context_menu={props.on_event_context_menu.clone()} on_calendar_context_menu={props.on_calendar_context_menu.clone()} selected_date={Some(*selected_date)} @@ -467,6 +507,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { on_event_click={on_event_click.clone()} refreshing_event_uid={(*refreshing_event_uid).clone()} user_info={props.user_info.clone()} + external_calendars={props.external_calendars.clone()} on_event_context_menu={props.on_event_context_menu.clone()} on_calendar_context_menu={props.on_calendar_context_menu.clone()} on_create_event={Some(on_create_event)} diff --git a/frontend/src/components/calendar_list_item.rs b/frontend/src/components/calendar_list_item.rs index 6119bcb..555bd31 100644 --- a/frontend/src/components/calendar_list_item.rs +++ b/frontend/src/components/calendar_list_item.rs @@ -10,6 +10,7 @@ pub struct CalendarListItemProps { pub on_color_picker_toggle: Callback, // calendar_path pub available_colors: Vec, pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path) + pub on_visibility_toggle: Callback, // calendar_path } #[function_component(CalendarListItem)] @@ -32,44 +33,59 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html { }) }; + let on_visibility_toggle = { + let cal_path = props.calendar.path.clone(); + let on_visibility_toggle = props.on_visibility_toggle.clone(); + Callback::from(move |_| { + on_visibility_toggle.emit(cal_path.clone()); + }) + }; + html! {
  • - - { - if props.color_picker_open { - html! { -
    - { - props.available_colors.iter().map(|color| { - let color_str = color.clone(); - let cal_path = props.calendar.path.clone(); - let on_color_change = props.on_color_change.clone(); +
    + + + { + if props.color_picker_open { + html! { +
    + { + props.available_colors.iter().map(|color| { + let color_str = color.clone(); + let cal_path = props.calendar.path.clone(); + let on_color_change = props.on_color_change.clone(); - let on_color_select = Callback::from(move |_: MouseEvent| { - on_color_change.emit((cal_path.clone(), color_str.clone())); - }); + let on_color_select = Callback::from(move |_: MouseEvent| { + on_color_change.emit((cal_path.clone(), color_str.clone())); + }); - let is_selected = props.calendar.color == *color; - let class_name = if is_selected { "color-option selected" } else { "color-option" }; + let is_selected = props.calendar.color == *color; + let class_name = if is_selected { "color-option selected" } else { "color-option" }; - html! { -
    -
    - } - }).collect::() - } -
    + html! { +
    +
    + } + }).collect::() + } +
    + } + } else { + html! {} } - } else { - html! {} } - } - - {&props.calendar.display_name} + + {&props.calendar.display_name} +
  • } } diff --git a/frontend/src/components/external_calendar_modal.rs b/frontend/src/components/external_calendar_modal.rs new file mode 100644 index 0000000..b8971e4 --- /dev/null +++ b/frontend/src/components/external_calendar_modal.rs @@ -0,0 +1,222 @@ +use web_sys::HtmlInputElement; +use yew::prelude::*; + +use crate::services::calendar_service::CalendarService; + +#[derive(Properties, PartialEq)] +pub struct ExternalCalendarModalProps { + pub is_open: bool, + pub on_close: Callback<()>, + pub on_success: Callback, // Pass the newly created calendar ID +} + +#[function_component(ExternalCalendarModal)] +pub fn external_calendar_modal(props: &ExternalCalendarModalProps) -> Html { + let name_ref = use_node_ref(); + let url_ref = use_node_ref(); + let color_ref = use_node_ref(); + let is_loading = use_state(|| false); + let error_message = use_state(|| None::); + + let on_submit = { + let name_ref = name_ref.clone(); + let url_ref = url_ref.clone(); + let color_ref = color_ref.clone(); + let is_loading = is_loading.clone(); + let error_message = error_message.clone(); + let on_close = props.on_close.clone(); + let on_success = props.on_success.clone(); + + Callback::from(move |e: SubmitEvent| { + e.prevent_default(); + + let name = name_ref + .cast::() + .map(|input| input.value()) + .unwrap_or_default() + .trim() + .to_string(); + + let url = url_ref + .cast::() + .map(|input| input.value()) + .unwrap_or_default() + .trim() + .to_string(); + + let color = color_ref + .cast::() + .map(|input| input.value()) + .unwrap_or_else(|| "#4285f4".to_string()); + + if name.is_empty() { + error_message.set(Some("Calendar name is required".to_string())); + return; + } + + if url.is_empty() { + error_message.set(Some("Calendar URL is required".to_string())); + return; + } + + // Basic URL validation + if !url.starts_with("http://") && !url.starts_with("https://") { + error_message.set(Some("Please enter a valid HTTP or HTTPS URL".to_string())); + return; + } + + error_message.set(None); + is_loading.set(true); + + let is_loading = is_loading.clone(); + let error_message = error_message.clone(); + let on_close = on_close.clone(); + let on_success = on_success.clone(); + + wasm_bindgen_futures::spawn_local(async move { + match CalendarService::create_external_calendar(&name, &url, &color).await { + Ok(new_calendar) => { + is_loading.set(false); + on_success.emit(new_calendar.id); + on_close.emit(()); + } + Err(e) => { + is_loading.set(false); + error_message.set(Some(format!("Failed to add calendar: {}", e))); + } + } + }); + }) + }; + + let on_cancel = { + let on_close = props.on_close.clone(); + Callback::from(move |_| { + on_close.emit(()); + }) + }; + + let on_cancel_clone = on_cancel.clone(); + + if !props.is_open { + return html! {}; + } + + html! { + + } +} \ No newline at end of file diff --git a/frontend/src/components/mod.rs b/frontend/src/components/mod.rs index 889444a..a0624bf 100644 --- a/frontend/src/components/mod.rs +++ b/frontend/src/components/mod.rs @@ -8,6 +8,7 @@ pub mod create_event_modal; pub mod event_context_menu; pub mod event_form; pub mod event_modal; +pub mod external_calendar_modal; pub mod login; pub mod month_view; pub mod recurring_edit_modal; @@ -26,6 +27,7 @@ pub use create_event_modal::CreateEventModal; pub use event_form::EventCreationData; pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu}; pub use event_modal::EventModal; +pub use external_calendar_modal::ExternalCalendarModal; pub use login::Login; pub use month_view::MonthView; pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal}; diff --git a/frontend/src/components/month_view.rs b/frontend/src/components/month_view.rs index 9956880..c7cdc62 100644 --- a/frontend/src/components/month_view.rs +++ b/frontend/src/components/month_view.rs @@ -1,5 +1,5 @@ use crate::models::ical::VEvent; -use crate::services::calendar_service::UserInfo; +use crate::services::calendar_service::{UserInfo, ExternalCalendar}; use chrono::{Datelike, NaiveDate, Weekday}; use std::collections::HashMap; use wasm_bindgen::{prelude::*, JsCast}; @@ -17,6 +17,8 @@ pub struct MonthViewProps { #[prop_or_default] pub user_info: Option, #[prop_or_default] + pub external_calendars: Vec, + #[prop_or_default] pub on_event_context_menu: Option>, #[prop_or_default] pub on_calendar_context_menu: Option>, @@ -85,8 +87,20 @@ pub fn month_view(props: &MonthViewProps) -> Html { // Helper function to get calendar color for an event let get_event_color = |event: &VEvent| -> String { - if let Some(user_info) = &props.user_info { - if let Some(calendar_path) = &event.calendar_path { + if let Some(calendar_path) = &event.calendar_path { + // Check external calendars first (path format: "external_{id}") + if calendar_path.starts_with("external_") { + if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::() { + if let Some(external_calendar) = props.external_calendars + .iter() + .find(|cal| cal.id == id_str) + { + return external_calendar.color.clone(); + } + } + } + // Check regular calendars + else if let Some(user_info) = &props.user_info { if let Some(calendar) = user_info .calendars .iter() @@ -194,6 +208,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
    diff --git a/frontend/src/components/route_handler.rs b/frontend/src/components/route_handler.rs index 1759a3f..bf4bf7f 100644 --- a/frontend/src/components/route_handler.rs +++ b/frontend/src/components/route_handler.rs @@ -1,6 +1,6 @@ use crate::components::{Login, ViewMode}; use crate::models::ical::VEvent; -use crate::services::calendar_service::UserInfo; +use crate::services::calendar_service::{UserInfo, ExternalCalendar}; use yew::prelude::*; use yew_router::prelude::*; @@ -20,6 +20,10 @@ pub struct RouteHandlerProps { pub user_info: Option, pub on_login: Callback, #[prop_or_default] + pub external_calendar_events: Vec, + #[prop_or_default] + pub external_calendars: Vec, + #[prop_or_default] pub on_event_context_menu: Option>, #[prop_or_default] pub on_calendar_context_menu: Option>, @@ -48,6 +52,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { let auth_token = props.auth_token.clone(); let user_info = props.user_info.clone(); let on_login = props.on_login.clone(); + let external_calendar_events = props.external_calendar_events.clone(); + let external_calendars = props.external_calendars.clone(); let on_event_context_menu = props.on_event_context_menu.clone(); let on_calendar_context_menu = props.on_calendar_context_menu.clone(); let view = props.view.clone(); @@ -60,6 +66,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { let auth_token = auth_token.clone(); let user_info = user_info.clone(); let on_login = on_login.clone(); + let external_calendar_events = external_calendar_events.clone(); + let external_calendars = external_calendars.clone(); let on_event_context_menu = on_event_context_menu.clone(); let on_calendar_context_menu = on_calendar_context_menu.clone(); let view = view.clone(); @@ -87,6 +95,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { html! { Html { pub struct CalendarViewProps { pub user_info: Option, #[prop_or_default] + pub external_calendar_events: Vec, + #[prop_or_default] + pub external_calendars: Vec, + #[prop_or_default] pub on_event_context_menu: Option>, #[prop_or_default] pub on_calendar_context_menu: Option>, @@ -139,6 +153,8 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html {
    , pub on_logout: Callback<()>, pub on_create_calendar: Callback<()>, + pub on_create_external_calendar: Callback<()>, + pub external_calendars: Vec, + pub on_external_calendar_toggle: Callback, + pub on_external_calendar_delete: Callback, + pub on_external_calendar_refresh: Callback, pub color_picker_open: Option, pub on_color_change: Callback<(String, String)>, pub on_color_picker_toggle: Callback, pub available_colors: Vec, pub on_calendar_context_menu: Callback<(MouseEvent, String)>, + pub on_calendar_visibility_toggle: Callback, pub current_view: ViewMode, pub on_view_change: Callback, pub current_theme: Theme, @@ -116,6 +122,7 @@ pub struct SidebarProps { #[function_component(Sidebar)] pub fn sidebar(props: &SidebarProps) -> Html { + let external_context_menu_open = use_state(|| None::); let on_view_change = { let on_view_change = props.on_view_change.clone(); Callback::from(move |e: Event| { @@ -155,6 +162,30 @@ pub fn sidebar(props: &SidebarProps) -> Html { }) }; + let on_external_calendar_context_menu = { + let external_context_menu_open = external_context_menu_open.clone(); + Callback::from(move |(e, cal_id): (MouseEvent, i32)| { + e.prevent_default(); + external_context_menu_open.set(Some(cal_id)); + }) + }; + + let on_external_calendar_delete = { + let on_external_calendar_delete = props.on_external_calendar_delete.clone(); + let external_context_menu_open = external_context_menu_open.clone(); + Callback::from(move |cal_id: i32| { + on_external_calendar_delete.emit(cal_id); + external_context_menu_open.set(None); + }) + }; + + let close_external_context_menu = { + let external_context_menu_open = external_context_menu_open.clone(); + Callback::from(move |_| { + external_context_menu_open.set(None); + }) + }; + html! {