Add external calendars feature: display read-only ICS calendars alongside CalDAV calendars
- Database: Add external_calendars table with user relationships and CRUD operations - Backend: Implement REST API endpoints for external calendar management and ICS fetching - Frontend: Add external calendar modal, sidebar section with visibility toggles - Calendar integration: Merge external events with regular events in unified view - ICS parsing: Support multiple datetime formats, recurring events, and timezone handling - Authentication: Integrate with existing JWT token system for user-specific calendars - UI: Visual distinction with 📅 indicator and separate management section 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
238
backend/src/handlers/ics_fetcher.rs
Normal file
238
backend/src/handlers/ics_fetcher.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::Json,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use ical::parser::ical::component::IcalEvent;
|
||||
use reqwest::Client;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
db::ExternalCalendarRepository,
|
||||
models::ApiError,
|
||||
AppState,
|
||||
};
|
||||
|
||||
// Import VEvent from calendar-models shared crate
|
||||
use calendar_models::VEvent;
|
||||
|
||||
use super::auth::{extract_bearer_token};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExternalCalendarEventsResponse {
|
||||
pub events: Vec<VEvent>,
|
||||
pub last_fetched: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn fetch_external_calendar_events(
|
||||
headers: axum::http::HeaderMap,
|
||||
State(app_state): State<Arc<AppState>>,
|
||||
Path(id): Path<i32>,
|
||||
) -> Result<Json<ExternalCalendarEventsResponse>, ApiError> {
|
||||
let token = extract_bearer_token(&headers)?;
|
||||
let user = app_state.auth_service.get_user_from_token(&token).await?;
|
||||
|
||||
let repo = ExternalCalendarRepository::new(&app_state.db);
|
||||
|
||||
// Get user's external calendars to verify ownership and get URL
|
||||
let calendars = repo
|
||||
.get_by_user(&user.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to get external calendars: {}", e)))?;
|
||||
|
||||
let calendar = calendars
|
||||
.into_iter()
|
||||
.find(|c| c.id == id)
|
||||
.ok_or_else(|| ApiError::NotFound("External calendar not found".to_string()))?;
|
||||
|
||||
if !calendar.is_visible {
|
||||
return Ok(Json(ExternalCalendarEventsResponse {
|
||||
events: vec![],
|
||||
last_fetched: Utc::now(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Fetch ICS content from URL
|
||||
let client = Client::new();
|
||||
let response = client
|
||||
.get(&calendar.url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch calendar: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(ApiError::Internal(format!("Calendar server returned: {}", response.status())));
|
||||
}
|
||||
|
||||
let ics_content = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to read calendar content: {}", e)))?;
|
||||
|
||||
// Parse ICS content
|
||||
let events = parse_ics_content(&ics_content)
|
||||
.map_err(|e| ApiError::BadRequest(format!("Failed to parse calendar: {}", e)))?;
|
||||
|
||||
// Update last_fetched timestamp
|
||||
repo.update_last_fetched(id, &user.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to update last_fetched: {}", e)))?;
|
||||
|
||||
Ok(Json(ExternalCalendarEventsResponse {
|
||||
events,
|
||||
last_fetched: Utc::now(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_ics_content(ics_content: &str) -> Result<Vec<VEvent>, Box<dyn std::error::Error>> {
|
||||
let reader = ical::IcalParser::new(ics_content.as_bytes());
|
||||
let mut events = Vec::new();
|
||||
|
||||
for calendar in reader {
|
||||
let calendar = calendar?;
|
||||
for component in calendar.events {
|
||||
if let Ok(vevent) = convert_ical_to_vevent(component) {
|
||||
events.push(vevent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::error::Error>> {
|
||||
use uuid::Uuid;
|
||||
|
||||
let mut summary = None;
|
||||
let mut description = None;
|
||||
let mut location = None;
|
||||
let mut dtstart = None;
|
||||
let mut dtend = None;
|
||||
let mut uid = None;
|
||||
let mut all_day = false;
|
||||
let mut rrule = None;
|
||||
|
||||
// Extract properties
|
||||
for property in ical_event.properties {
|
||||
match property.name.as_str() {
|
||||
"SUMMARY" => {
|
||||
summary = property.value;
|
||||
}
|
||||
"DESCRIPTION" => {
|
||||
description = property.value;
|
||||
}
|
||||
"LOCATION" => {
|
||||
location = property.value;
|
||||
}
|
||||
"DTSTART" => {
|
||||
if let Some(value) = property.value {
|
||||
// Check if it's a date-only value (all-day event)
|
||||
if value.len() == 8 && !value.contains('T') {
|
||||
all_day = true;
|
||||
// Parse YYYYMMDD format
|
||||
if let Ok(date) = chrono::NaiveDate::parse_from_str(&value, "%Y%m%d") {
|
||||
dtstart = Some(chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap()));
|
||||
}
|
||||
} else {
|
||||
// Parse datetime - could be various formats
|
||||
if let Some(dt) = parse_datetime(&value) {
|
||||
dtstart = Some(dt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"DTEND" => {
|
||||
if let Some(value) = property.value {
|
||||
if all_day && value.len() == 8 && !value.contains('T') {
|
||||
// For all-day events, DTEND is exclusive so use the date as-is at noon
|
||||
if let Ok(date) = chrono::NaiveDate::parse_from_str(&value, "%Y%m%d") {
|
||||
dtend = Some(chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap()));
|
||||
}
|
||||
} else {
|
||||
if let Some(dt) = parse_datetime(&value) {
|
||||
dtend = Some(dt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"UID" => {
|
||||
uid = property.value;
|
||||
}
|
||||
"RRULE" => {
|
||||
rrule = property.value;
|
||||
}
|
||||
_ => {} // Ignore other properties for now
|
||||
}
|
||||
}
|
||||
|
||||
let vevent = VEvent {
|
||||
uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||
dtstart: dtstart.ok_or("Missing DTSTART")?,
|
||||
dtend,
|
||||
summary,
|
||||
description,
|
||||
location,
|
||||
all_day,
|
||||
rrule,
|
||||
exdate: Vec::new(), // External calendars don't need exception handling
|
||||
recurrence_id: None,
|
||||
created: None,
|
||||
last_modified: None,
|
||||
dtstamp: Utc::now(),
|
||||
sequence: Some(0),
|
||||
status: None,
|
||||
transp: None,
|
||||
organizer: None,
|
||||
attendees: Vec::new(),
|
||||
url: None,
|
||||
attachments: Vec::new(),
|
||||
categories: Vec::new(),
|
||||
priority: None,
|
||||
resources: Vec::new(),
|
||||
related_to: None,
|
||||
geo: None,
|
||||
duration: None,
|
||||
class: None,
|
||||
contact: None,
|
||||
comment: None,
|
||||
rdate: Vec::new(),
|
||||
alarms: Vec::new(),
|
||||
etag: None,
|
||||
href: None,
|
||||
calendar_path: None,
|
||||
};
|
||||
|
||||
Ok(vevent)
|
||||
}
|
||||
|
||||
fn parse_datetime(datetime_str: &str) -> Option<DateTime<Utc>> {
|
||||
// Try various datetime formats commonly found in ICS files
|
||||
|
||||
// Format: 20231201T103000Z (UTC)
|
||||
if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%SZ") {
|
||||
return Some(dt.with_timezone(&Utc));
|
||||
}
|
||||
|
||||
// Format: 20231201T103000 (floating time - assume UTC)
|
||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S") {
|
||||
return Some(chrono::TimeZone::from_utc_datetime(&Utc, &dt));
|
||||
}
|
||||
|
||||
// Format: 20231201T103000-0500 (with timezone offset)
|
||||
if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S%z") {
|
||||
return Some(dt.with_timezone(&Utc));
|
||||
}
|
||||
|
||||
// Format: 2023-12-01T10:30:00Z (ISO format)
|
||||
if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%SZ") {
|
||||
return Some(dt.with_timezone(&Utc));
|
||||
}
|
||||
|
||||
// Format: 2023-12-01T10:30:00 (ISO without timezone)
|
||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%S") {
|
||||
return Some(chrono::TimeZone::from_utc_datetime(&Utc, &dt));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
Reference in New Issue
Block a user