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:
Connor Johnstone
2025-09-03 18:22:52 -04:00
parent 289284a532
commit 8caa1f45ae
16 changed files with 1207 additions and 18 deletions

View 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
}