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:
16
backend/migrations/006_create_external_calendars_table.sql
Normal file
16
backend/migrations/006_create_external_calendars_table.sql
Normal file
@@ -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);
|
||||||
@@ -112,6 +112,17 @@ impl AuthService {
|
|||||||
self.decode_token(token)
|
self.decode_token(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get user from token
|
||||||
|
pub async fn get_user_from_token(&self, token: &str) -> Result<crate::db::User, ApiError> {
|
||||||
|
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
|
/// Create CalDAV config from token
|
||||||
pub fn caldav_config_from_token(
|
pub fn caldav_config_from_token(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -99,6 +99,38 @@ pub struct UserPreferences {
|
|||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub last_fetched: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
impl UserPreferences {
|
||||||
/// Create default preferences for a new user
|
/// Create default preferences for a new user
|
||||||
pub fn default_for_user(user_id: String) -> Self {
|
pub fn default_for_user(user_id: String) -> Self {
|
||||||
@@ -308,6 +340,91 @@ impl<'a> PreferencesRepository<'a> {
|
|||||||
.execute(self.db.pool())
|
.execute(self.db.pool())
|
||||||
.await?;
|
.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<Vec<ExternalCalendar>> {
|
||||||
|
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<i32> {
|
||||||
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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};
|
|
||||||
142
backend/src/handlers/external_calendars.rs
Normal file
142
backend/src/handlers/external_calendars.rs
Normal file
@@ -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<chrono::Utc>,
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub last_fetched: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ExternalCalendar> 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<Arc<AppState>>,
|
||||||
|
) -> Result<Json<Vec<ExternalCalendarResponse>>, 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<ExternalCalendarResponse> = calendars.into_iter().map(Into::into).collect();
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_external_calendar(
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
State(app_state): State<Arc<AppState>>,
|
||||||
|
Json(request): Json<CreateExternalCalendarRequest>,
|
||||||
|
) -> Result<Json<ExternalCalendarResponse>, 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<Arc<AppState>>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
Json(request): Json<UpdateExternalCalendarRequest>,
|
||||||
|
) -> Result<Json<()>, 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<Arc<AppState>>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<Json<()>, 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(()))
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
15
backend/src/handlers/mod.rs
Normal file
15
backend/src/handlers/mod.rs
Normal file
@@ -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::*;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
response::Json,
|
response::Json,
|
||||||
routing::{get, post},
|
routing::{delete, get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -72,6 +72,12 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.route("/api/preferences", get(handlers::get_preferences))
|
.route("/api/preferences", get(handlers::get_preferences))
|
||||||
.route("/api/preferences", post(handlers::update_preferences))
|
.route("/api/preferences", post(handlers::update_preferences))
|
||||||
.route("/api/auth/logout", post(handlers::logout))
|
.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(
|
.layer(
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ reqwest = { version = "0.11", features = ["json"] }
|
|||||||
ical = "0.7"
|
ical = "0.7"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
serde-wasm-bindgen = "0.6"
|
||||||
|
|
||||||
# Date and time handling
|
# Date and time handling
|
||||||
chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] }
|
chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] }
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use crate::components::{
|
use crate::components::{
|
||||||
CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction,
|
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::components::sidebar::{Style};
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
use crate::services::{calendar_service::UserInfo, CalendarService};
|
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
@@ -73,6 +74,11 @@ pub fn App() -> Html {
|
|||||||
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 });
|
||||||
|
|
||||||
|
// External calendar state
|
||||||
|
let external_calendars = use_state(|| -> Vec<ExternalCalendar> { Vec::new() });
|
||||||
|
let external_calendar_events = use_state(|| -> Vec<VEvent> { Vec::new() });
|
||||||
|
let external_calendar_modal_open = use_state(|| false);
|
||||||
|
|
||||||
// Calendar view state - load from localStorage if available
|
// Calendar view state - load from localStorage if available
|
||||||
let current_view = use_state(|| {
|
let current_view = use_state(|| {
|
||||||
@@ -301,6 +307,50 @@ pub fn App() -> Html {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load external calendars when auth token is available
|
||||||
|
{
|
||||||
|
let auth_token = auth_token.clone();
|
||||||
|
let external_calendars = external_calendars.clone();
|
||||||
|
let external_calendar_events = external_calendar_events.clone();
|
||||||
|
|
||||||
|
use_effect_with((*auth_token).clone(), move |token| {
|
||||||
|
if token.is_some() {
|
||||||
|
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(events) = CalendarService::fetch_external_calendar_events(calendar.id).await {
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
external_calendars.set(Vec::new());
|
||||||
|
external_calendar_events.set(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
|| ()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let on_outside_click = {
|
let on_outside_click = {
|
||||||
let color_picker_open = color_picker_open.clone();
|
let color_picker_open = color_picker_open.clone();
|
||||||
let context_menu_open = context_menu_open.clone();
|
let context_menu_open = context_menu_open.clone();
|
||||||
@@ -924,6 +974,53 @@ pub fn App() -> Html {
|
|||||||
let create_modal_open = create_modal_open.clone();
|
let create_modal_open = create_modal_open.clone();
|
||||||
move |_| create_modal_open.set(true)
|
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(events) = CalendarService::fetch_external_calendar_events(cal.id).await {
|
||||||
|
all_events.extend(events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
external_calendar_events.set(all_events);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})}
|
||||||
color_picker_open={(*color_picker_open).clone()}
|
color_picker_open={(*color_picker_open).clone()}
|
||||||
on_color_change={on_color_change}
|
on_color_change={on_color_change}
|
||||||
on_color_picker_toggle={on_color_picker_toggle}
|
on_color_picker_toggle={on_color_picker_toggle}
|
||||||
@@ -941,6 +1038,7 @@ pub fn App() -> Html {
|
|||||||
auth_token={(*auth_token).clone()}
|
auth_token={(*auth_token).clone()}
|
||||||
user_info={(*user_info).clone()}
|
user_info={(*user_info).clone()}
|
||||||
on_login={on_login.clone()}
|
on_login={on_login.clone()}
|
||||||
|
external_calendar_events={(*external_calendar_events).clone()}
|
||||||
on_event_context_menu={Some(on_event_context_menu.clone())}
|
on_event_context_menu={Some(on_event_context_menu.clone())}
|
||||||
on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())}
|
on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())}
|
||||||
view={(*current_view).clone()}
|
view={(*current_view).clone()}
|
||||||
@@ -1193,6 +1291,46 @@ pub fn App() -> Html {
|
|||||||
on_create={on_event_create}
|
on_create={on_event_create}
|
||||||
available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()}
|
available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ExternalCalendarModal
|
||||||
|
is_open={*external_calendar_modal_open}
|
||||||
|
on_close={Callback::from({
|
||||||
|
let external_calendar_modal_open = external_calendar_modal_open.clone();
|
||||||
|
move |_| external_calendar_modal_open.set(false)
|
||||||
|
})}
|
||||||
|
on_success={Callback::from({
|
||||||
|
let external_calendars = external_calendars.clone();
|
||||||
|
let external_calendar_events = external_calendar_events.clone();
|
||||||
|
move |_| {
|
||||||
|
// Reload external calendars
|
||||||
|
let external_calendars = external_calendars.clone();
|
||||||
|
let external_calendar_events = external_calendar_events.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
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(events) = CalendarService::fetch_external_calendar_events(calendar.id).await {
|
||||||
|
all_events.extend(events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
external_calendar_events.set(all_events);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("Failed to reload external calendars: {}", err).into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ pub struct CalendarProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
|
pub external_calendar_events: Vec<VEvent>,
|
||||||
|
#[prop_or_default]
|
||||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
||||||
@@ -101,10 +103,13 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let loading = loading.clone();
|
let loading = loading.clone();
|
||||||
let error = error.clone();
|
let error = error.clone();
|
||||||
let current_date = current_date.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()), move |(date, _view, _external_len)| {
|
||||||
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||||
let date = *date; // Clone the date to avoid lifetime issues
|
let date = *date; // Clone the date to avoid lifetime issues
|
||||||
|
let external_events = external_events.clone(); // Clone external events to avoid lifetime issues
|
||||||
|
|
||||||
if let Some(token) = auth_token {
|
if let Some(token) = auth_token {
|
||||||
let events = events.clone();
|
let events = events.clone();
|
||||||
@@ -141,7 +146,11 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(vevents) => {
|
Ok(vevents) => {
|
||||||
let grouped_events = CalendarService::group_events_by_date(vevents);
|
// Combine regular events with external calendar events
|
||||||
|
let mut all_events = vevents;
|
||||||
|
all_events.extend(external_events);
|
||||||
|
|
||||||
|
let grouped_events = CalendarService::group_events_by_date(all_events);
|
||||||
events.set(grouped_events);
|
events.set(grouped_events);
|
||||||
loading.set(false);
|
loading.set(false);
|
||||||
}
|
}
|
||||||
|
|||||||
193
frontend/src/components/external_calendar_modal.rs
Normal file
193
frontend/src/components/external_calendar_modal.rs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
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<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::<String>);
|
||||||
|
|
||||||
|
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::<HtmlInputElement>()
|
||||||
|
.map(|input| input.value())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let url = url_ref
|
||||||
|
.cast::<HtmlInputElement>()
|
||||||
|
.map(|input| input.value())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let color = color_ref
|
||||||
|
.cast::<HtmlInputElement>()
|
||||||
|
.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(_) => {
|
||||||
|
is_loading.set(false);
|
||||||
|
on_success.emit(());
|
||||||
|
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! {
|
||||||
|
<div class="modal-overlay" onclick={on_cancel_clone}>
|
||||||
|
<div class="modal-content" onclick={Callback::from(|e: MouseEvent| e.stop_propagation())}>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{"Add External Calendar"}</h3>
|
||||||
|
<button class="modal-close" onclick={on_cancel.clone()}>{"×"}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onsubmit={on_submit}>
|
||||||
|
<div class="modal-body">
|
||||||
|
{
|
||||||
|
if let Some(error) = (*error_message).as_ref() {
|
||||||
|
html! {
|
||||||
|
<div class="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="external-calendar-name">{"Calendar Name"}</label>
|
||||||
|
<input
|
||||||
|
ref={name_ref}
|
||||||
|
id="external-calendar-name"
|
||||||
|
type="text"
|
||||||
|
placeholder="My External Calendar"
|
||||||
|
disabled={*is_loading}
|
||||||
|
required={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="external-calendar-url">{"ICS URL"}</label>
|
||||||
|
<input
|
||||||
|
ref={url_ref}
|
||||||
|
id="external-calendar-url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/calendar.ics"
|
||||||
|
disabled={*is_loading}
|
||||||
|
required={true}
|
||||||
|
/>
|
||||||
|
<small class="form-help">
|
||||||
|
{"Enter the public ICS URL for the calendar you want to add"}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="external-calendar-color">{"Color"}</label>
|
||||||
|
<input
|
||||||
|
ref={color_ref}
|
||||||
|
id="external-calendar-color"
|
||||||
|
type="color"
|
||||||
|
value="#4285f4"
|
||||||
|
disabled={*is_loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
onclick={on_cancel}
|
||||||
|
disabled={*is_loading}
|
||||||
|
>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={*is_loading}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
if *is_loading {
|
||||||
|
"Adding..."
|
||||||
|
} else {
|
||||||
|
"Add Calendar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ pub mod create_event_modal;
|
|||||||
pub mod event_context_menu;
|
pub mod event_context_menu;
|
||||||
pub mod event_form;
|
pub mod event_form;
|
||||||
pub mod event_modal;
|
pub mod event_modal;
|
||||||
|
pub mod external_calendar_modal;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod month_view;
|
pub mod month_view;
|
||||||
pub mod recurring_edit_modal;
|
pub mod recurring_edit_modal;
|
||||||
@@ -26,6 +27,7 @@ pub use create_event_modal::CreateEventModal;
|
|||||||
pub use event_form::EventCreationData;
|
pub use event_form::EventCreationData;
|
||||||
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
|
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
|
||||||
pub use event_modal::EventModal;
|
pub use event_modal::EventModal;
|
||||||
|
pub use external_calendar_modal::ExternalCalendarModal;
|
||||||
pub use login::Login;
|
pub use login::Login;
|
||||||
pub use month_view::MonthView;
|
pub use month_view::MonthView;
|
||||||
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
|
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ pub struct RouteHandlerProps {
|
|||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
pub on_login: Callback<String>,
|
pub on_login: Callback<String>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
|
pub external_calendar_events: Vec<VEvent>,
|
||||||
|
#[prop_or_default]
|
||||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
||||||
@@ -48,6 +50,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
|||||||
let auth_token = props.auth_token.clone();
|
let auth_token = props.auth_token.clone();
|
||||||
let user_info = props.user_info.clone();
|
let user_info = props.user_info.clone();
|
||||||
let on_login = props.on_login.clone();
|
let on_login = props.on_login.clone();
|
||||||
|
let external_calendar_events = props.external_calendar_events.clone();
|
||||||
let on_event_context_menu = props.on_event_context_menu.clone();
|
let on_event_context_menu = props.on_event_context_menu.clone();
|
||||||
let on_calendar_context_menu = props.on_calendar_context_menu.clone();
|
let on_calendar_context_menu = props.on_calendar_context_menu.clone();
|
||||||
let view = props.view.clone();
|
let view = props.view.clone();
|
||||||
@@ -60,6 +63,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
|||||||
let auth_token = auth_token.clone();
|
let auth_token = auth_token.clone();
|
||||||
let user_info = user_info.clone();
|
let user_info = user_info.clone();
|
||||||
let on_login = on_login.clone();
|
let on_login = on_login.clone();
|
||||||
|
let external_calendar_events = external_calendar_events.clone();
|
||||||
let on_event_context_menu = on_event_context_menu.clone();
|
let on_event_context_menu = on_event_context_menu.clone();
|
||||||
let on_calendar_context_menu = on_calendar_context_menu.clone();
|
let on_calendar_context_menu = on_calendar_context_menu.clone();
|
||||||
let view = view.clone();
|
let view = view.clone();
|
||||||
@@ -87,6 +91,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
|||||||
html! {
|
html! {
|
||||||
<CalendarView
|
<CalendarView
|
||||||
user_info={user_info}
|
user_info={user_info}
|
||||||
|
external_calendar_events={external_calendar_events}
|
||||||
on_event_context_menu={on_event_context_menu}
|
on_event_context_menu={on_event_context_menu}
|
||||||
on_calendar_context_menu={on_calendar_context_menu}
|
on_calendar_context_menu={on_calendar_context_menu}
|
||||||
view={view}
|
view={view}
|
||||||
@@ -108,6 +113,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
|||||||
pub struct CalendarViewProps {
|
pub struct CalendarViewProps {
|
||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
|
pub external_calendar_events: Vec<VEvent>,
|
||||||
|
#[prop_or_default]
|
||||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
||||||
@@ -139,6 +146,7 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
|||||||
<div class="calendar-view">
|
<div class="calendar-view">
|
||||||
<Calendar
|
<Calendar
|
||||||
user_info={props.user_info.clone()}
|
user_info={props.user_info.clone()}
|
||||||
|
external_calendar_events={props.external_calendar_events.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()}
|
||||||
view={props.view.clone()}
|
view={props.view.clone()}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::components::CalendarListItem;
|
use crate::components::CalendarListItem;
|
||||||
use crate::services::calendar_service::UserInfo;
|
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||||
use web_sys::HtmlSelectElement;
|
use web_sys::HtmlSelectElement;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
@@ -101,6 +101,9 @@ pub struct SidebarProps {
|
|||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
pub on_logout: Callback<()>,
|
pub on_logout: Callback<()>,
|
||||||
pub on_create_calendar: Callback<()>,
|
pub on_create_calendar: Callback<()>,
|
||||||
|
pub on_create_external_calendar: Callback<()>,
|
||||||
|
pub external_calendars: Vec<ExternalCalendar>,
|
||||||
|
pub on_external_calendar_toggle: Callback<i32>,
|
||||||
pub color_picker_open: Option<String>,
|
pub color_picker_open: Option<String>,
|
||||||
pub on_color_change: Callback<(String, String)>,
|
pub on_color_change: Callback<(String, String)>,
|
||||||
pub on_color_picker_toggle: Callback<String>,
|
pub on_color_picker_toggle: Callback<String>,
|
||||||
@@ -206,10 +209,59 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// External calendars section
|
||||||
|
<div class="external-calendar-list">
|
||||||
|
<h3>{"External Calendars"}</h3>
|
||||||
|
{
|
||||||
|
if !props.external_calendars.is_empty() {
|
||||||
|
html! {
|
||||||
|
<ul class="external-calendar-items">
|
||||||
|
{
|
||||||
|
props.external_calendars.iter().map(|cal| {
|
||||||
|
let on_toggle = {
|
||||||
|
let on_external_calendar_toggle = props.on_external_calendar_toggle.clone();
|
||||||
|
let cal_id = cal.id;
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_external_calendar_toggle.emit(cal_id);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<li class="external-calendar-item">
|
||||||
|
<div class="external-calendar-info">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={cal.is_visible}
|
||||||
|
onchange={on_toggle}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="external-calendar-color"
|
||||||
|
style={format!("background-color: {}", cal.color)}
|
||||||
|
/>
|
||||||
|
<span class="external-calendar-name">{&cal.name}</span>
|
||||||
|
<span class="external-calendar-indicator">{"📅"}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
|
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
|
||||||
{"+ Create Calendar"}
|
{"+ Create Calendar"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button onclick={props.on_create_external_calendar.reform(|_| ())} class="create-external-calendar-button">
|
||||||
|
{"+ Add External Calendar"}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="view-selector">
|
<div class="view-selector">
|
||||||
<select class="view-selector-dropdown" onchange={on_view_change}>
|
<select class="view-selector-dropdown" onchange={on_view_change}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday};
|
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday};
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
@@ -1854,4 +1855,256 @@ impl CalendarService {
|
|||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== EXTERNAL CALENDAR METHODS ====================
|
||||||
|
|
||||||
|
pub async fn get_external_calendars() -> Result<Vec<ExternalCalendar>, String> {
|
||||||
|
let token = LocalStorage::get::<String>("auth_token")
|
||||||
|
.map_err(|_| "No authentication token found".to_string())?;
|
||||||
|
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("GET");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
|
||||||
|
let service = Self::new();
|
||||||
|
let url = format!("{}/external-calendars", service.base_url);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(format!("HTTP error: {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = JsFuture::from(resp.json().unwrap())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let external_calendars: Vec<ExternalCalendar> = serde_wasm_bindgen::from_value(json)
|
||||||
|
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
|
||||||
|
|
||||||
|
Ok(external_calendars)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_external_calendar(name: &str, url: &str, color: &str) -> Result<ExternalCalendar, String> {
|
||||||
|
let token = LocalStorage::get::<String>("auth_token")
|
||||||
|
.map_err(|_| "No authentication token found".to_string())?;
|
||||||
|
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("POST");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"url": url,
|
||||||
|
"color": color
|
||||||
|
});
|
||||||
|
|
||||||
|
let service = Self::new();
|
||||||
|
let body_string = serde_json::to_string(&body)
|
||||||
|
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||||
|
opts.set_body(&body_string.into());
|
||||||
|
|
||||||
|
let url = format!("{}/external-calendars", service.base_url);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(format!("HTTP error: {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = JsFuture::from(resp.json().unwrap())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let external_calendar: ExternalCalendar = serde_wasm_bindgen::from_value(json)
|
||||||
|
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
|
||||||
|
|
||||||
|
Ok(external_calendar)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_external_calendar(
|
||||||
|
id: i32,
|
||||||
|
name: &str,
|
||||||
|
url: &str,
|
||||||
|
color: &str,
|
||||||
|
is_visible: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let token = LocalStorage::get::<String>("auth_token")
|
||||||
|
.map_err(|_| "No authentication token found".to_string())?;
|
||||||
|
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("POST");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"url": url,
|
||||||
|
"color": color,
|
||||||
|
"is_visible": is_visible
|
||||||
|
});
|
||||||
|
|
||||||
|
let service = Self::new();
|
||||||
|
let body_string = serde_json::to_string(&body)
|
||||||
|
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||||
|
opts.set_body(&body_string.into());
|
||||||
|
|
||||||
|
let url = format!("{}/external-calendars/{}", service.base_url, id);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(format!("HTTP error: {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_external_calendar(id: i32) -> Result<(), String> {
|
||||||
|
let token = LocalStorage::get::<String>("auth_token")
|
||||||
|
.map_err(|_| "No authentication token found".to_string())?;
|
||||||
|
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("DELETE");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
|
||||||
|
let service = Self::new();
|
||||||
|
let url = format!("{}/external-calendars/{}", service.base_url, id);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(format!("HTTP error: {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_external_calendar_events(id: i32) -> Result<Vec<VEvent>, String> {
|
||||||
|
let token = LocalStorage::get::<String>("auth_token")
|
||||||
|
.map_err(|_| "No authentication token found".to_string())?;
|
||||||
|
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("GET");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
|
||||||
|
let service = Self::new();
|
||||||
|
let url = format!("{}/external-calendars/{}/events", service.base_url, id);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(format!("HTTP error: {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = JsFuture::from(resp.json().unwrap())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ExternalCalendarEventsResponse {
|
||||||
|
events: Vec<VEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json)
|
||||||
|
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
|
||||||
|
|
||||||
|
Ok(response.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ExternalCalendar {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub color: String,
|
||||||
|
pub is_visible: bool,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub last_fetched: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user