Compare commits
5 Commits
53ea5e3fc1
...
6887e0b389
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6887e0b389 | ||
|
|
f266d3f304 | ||
|
|
4af4aafd98 | ||
|
|
81805289e4 | ||
|
|
9f2f58e23e |
@@ -24,6 +24,7 @@ web-sys = { version = "0.3", features = [
|
||||
"RequestInit",
|
||||
"RequestMode",
|
||||
"Response",
|
||||
"CssStyleDeclaration",
|
||||
] }
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use chrono::{Datelike, TimeZone};
|
||||
|
||||
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse, DeleteEventRequest, DeleteEventResponse, CreateEventRequest, CreateEventResponse, UpdateEventRequest, UpdateEventResponse}};
|
||||
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse, DeleteEventRequest, DeleteEventResponse, CreateEventRequest, CreateEventResponse, UpdateEventRequest, UpdateEventResponse}, models_v2::{CreateEventRequestV2, CreateEventResponseV2, UpdateEventRequestV2, UpdateEventResponseV2, DeleteEventRequestV2, DeleteEventResponseV2, DeleteActionV2}};
|
||||
use crate::calendar::{CalDAVClient, CalendarEvent};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -374,6 +374,162 @@ async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_h
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Delete event using v2 API with enum-based delete actions
|
||||
pub async fn delete_event_v2(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<DeleteEventRequestV2>,
|
||||
) -> Result<Json<DeleteEventResponseV2>, ApiError> {
|
||||
println!("🗑️ Delete event v2 request received: calendar_path='{}', event_href='{}', action={:?}",
|
||||
request.calendar_path, request.event_href, request.delete_action);
|
||||
|
||||
// Extract and verify token
|
||||
let token = extract_bearer_token(&headers)?;
|
||||
let password = extract_password_header(&headers)?;
|
||||
|
||||
// Validate request
|
||||
if request.calendar_path.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("Calendar path is required".to_string()));
|
||||
}
|
||||
if request.event_href.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("Event href is required".to_string()));
|
||||
}
|
||||
|
||||
// Create CalDAV config from token and password
|
||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
||||
let client = CalDAVClient::new(config);
|
||||
|
||||
// Handle different delete actions
|
||||
match request.delete_action {
|
||||
DeleteActionV2::DeleteThis => {
|
||||
// Add EXDATE to exclude this specific occurrence
|
||||
if let Some(occurrence_date) = request.occurrence_date {
|
||||
println!("🔄 Adding EXDATE for occurrence: {}", occurrence_date);
|
||||
|
||||
// First, fetch the current event to get its data
|
||||
match fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await {
|
||||
Ok(Some(mut event)) => {
|
||||
// Check if it has recurrence rule
|
||||
if event.recurrence_rule.is_some() {
|
||||
// Calculate the exact datetime for this occurrence by using the original event's time
|
||||
let original_time = event.start.time();
|
||||
let occurrence_datetime = occurrence_date.date_naive().and_time(original_time);
|
||||
let exception_utc = chrono::Utc.from_utc_datetime(&occurrence_datetime);
|
||||
|
||||
println!("🔄 Original event start: {}", event.start);
|
||||
println!("🔄 Occurrence date: {}", occurrence_date);
|
||||
println!("🔄 Calculated EXDATE: {}", exception_utc);
|
||||
|
||||
// Add the exception date
|
||||
event.exception_dates.push(exception_utc);
|
||||
|
||||
// Update the event with the new EXDATE
|
||||
client.update_event(&request.calendar_path, &event, &request.event_href)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to update event with EXDATE: {}", e)))?;
|
||||
|
||||
Ok(Json(DeleteEventResponseV2 {
|
||||
success: true,
|
||||
message: "Individual occurrence excluded from series successfully".to_string(),
|
||||
}))
|
||||
} else {
|
||||
// Not a recurring event, just delete it completely
|
||||
client.delete_event(&request.calendar_path, &request.event_href)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
||||
|
||||
Ok(Json(DeleteEventResponseV2 {
|
||||
success: true,
|
||||
message: "Event deleted successfully".to_string(),
|
||||
}))
|
||||
}
|
||||
},
|
||||
Ok(None) => Err(ApiError::NotFound("Event not found".to_string())),
|
||||
Err(e) => Err(ApiError::Internal(format!("Failed to fetch event: {}", e))),
|
||||
}
|
||||
} else {
|
||||
Err(ApiError::BadRequest("Occurrence date is required for 'delete_this' action".to_string()))
|
||||
}
|
||||
},
|
||||
DeleteActionV2::DeleteFollowing => {
|
||||
// Modify RRULE to end before the selected occurrence
|
||||
if let Some(occurrence_date) = request.occurrence_date {
|
||||
println!("🔄 Modifying RRULE to end before: {}", occurrence_date);
|
||||
|
||||
// First, fetch the current event to get its data
|
||||
match fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await {
|
||||
Ok(Some(mut event)) => {
|
||||
// Check if it has recurrence rule
|
||||
if let Some(ref rrule) = event.recurrence_rule {
|
||||
// Calculate the datetime for the occurrence we want to stop before
|
||||
let original_time = event.start.time();
|
||||
let occurrence_datetime = occurrence_date.date_naive().and_time(original_time);
|
||||
let occurrence_utc = chrono::Utc.from_utc_datetime(&occurrence_datetime);
|
||||
|
||||
// UNTIL should be the last occurrence we want to keep (day before the selected occurrence)
|
||||
let until_date = occurrence_utc - chrono::Duration::days(1);
|
||||
let until_str = until_date.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
|
||||
println!("🔄 Original event start: {}", event.start);
|
||||
println!("🔄 Occurrence to stop before: {}", occurrence_utc);
|
||||
println!("🔄 UNTIL date (last to keep): {}", until_date);
|
||||
println!("🔄 UNTIL string: {}", until_str);
|
||||
println!("🔄 Original RRULE: {}", rrule);
|
||||
|
||||
// Modify the RRULE to add UNTIL clause
|
||||
let new_rrule = if rrule.contains("UNTIL=") {
|
||||
// Replace existing UNTIL
|
||||
regex::Regex::new(r"UNTIL=[^;]+").unwrap().replace(rrule, &format!("UNTIL={}", until_str)).to_string()
|
||||
} else {
|
||||
// Add UNTIL clause
|
||||
format!("{};UNTIL={}", rrule, until_str)
|
||||
};
|
||||
|
||||
println!("🔄 New RRULE: {}", new_rrule);
|
||||
event.recurrence_rule = Some(new_rrule);
|
||||
|
||||
// Update the event with the modified RRULE
|
||||
client.update_event(&request.calendar_path, &event, &request.event_href)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to update event with modified RRULE: {}", e)))?;
|
||||
|
||||
Ok(Json(DeleteEventResponseV2 {
|
||||
success: true,
|
||||
message: "Following occurrences removed from series successfully".to_string(),
|
||||
}))
|
||||
} else {
|
||||
// Not a recurring event, just delete it completely
|
||||
client.delete_event(&request.calendar_path, &request.event_href)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
||||
|
||||
Ok(Json(DeleteEventResponseV2 {
|
||||
success: true,
|
||||
message: "Event deleted successfully".to_string(),
|
||||
}))
|
||||
}
|
||||
},
|
||||
Ok(None) => Err(ApiError::NotFound("Event not found".to_string())),
|
||||
Err(e) => Err(ApiError::Internal(format!("Failed to fetch event: {}", e))),
|
||||
}
|
||||
} else {
|
||||
Err(ApiError::BadRequest("Occurrence date is required for 'delete_following' action".to_string()))
|
||||
}
|
||||
},
|
||||
DeleteActionV2::DeleteSeries => {
|
||||
// Delete the entire event/series (current default behavior)
|
||||
client.delete_event(&request.calendar_path, &request.event_href)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
||||
|
||||
Ok(Json(DeleteEventResponseV2 {
|
||||
success: true,
|
||||
message: "Event series deleted successfully".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_event(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
@@ -540,6 +696,142 @@ pub async fn delete_event(
|
||||
}
|
||||
}
|
||||
|
||||
/// Create event using v2 API with direct DateTime support (no string parsing)
|
||||
pub async fn create_event_v2(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<CreateEventRequestV2>,
|
||||
) -> Result<Json<CreateEventResponseV2>, ApiError> {
|
||||
println!("📝 Create event v2 request received: summary='{}', all_day={}, calendar_path={:?}",
|
||||
request.summary, request.all_day, request.calendar_path);
|
||||
|
||||
// Extract and verify token
|
||||
let token = extract_bearer_token(&headers)?;
|
||||
let password = extract_password_header(&headers)?;
|
||||
|
||||
// Validate request
|
||||
if request.summary.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("Event summary is required".to_string()));
|
||||
}
|
||||
|
||||
if request.summary.len() > 200 {
|
||||
return Err(ApiError::BadRequest("Event summary too long (max 200 characters)".to_string()));
|
||||
}
|
||||
|
||||
// Create CalDAV config from token and password
|
||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
||||
let client = CalDAVClient::new(config);
|
||||
|
||||
// Determine which calendar to use
|
||||
let calendar_path = if let Some(path) = request.calendar_path {
|
||||
path
|
||||
} else {
|
||||
// Use the first available calendar
|
||||
let calendar_paths = client.discover_calendars()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||
|
||||
if calendar_paths.is_empty() {
|
||||
return Err(ApiError::BadRequest("No calendars available for event creation".to_string()));
|
||||
}
|
||||
|
||||
calendar_paths[0].clone()
|
||||
};
|
||||
|
||||
// Validate that end is after start
|
||||
if let Some(end) = request.dtend {
|
||||
if end <= request.dtstart {
|
||||
return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a unique UID for the event
|
||||
let uid = format!("{}-{}", uuid::Uuid::new_v4(), chrono::Utc::now().timestamp());
|
||||
|
||||
// Convert V2 enums to calendar module enums
|
||||
let status = match request.status.unwrap_or_default() {
|
||||
crate::models_v2::EventStatusV2::Tentative => crate::calendar::EventStatus::Tentative,
|
||||
crate::models_v2::EventStatusV2::Cancelled => crate::calendar::EventStatus::Cancelled,
|
||||
crate::models_v2::EventStatusV2::Confirmed => crate::calendar::EventStatus::Confirmed,
|
||||
};
|
||||
|
||||
let class = match request.class.unwrap_or_default() {
|
||||
crate::models_v2::EventClassV2::Private => crate::calendar::EventClass::Private,
|
||||
crate::models_v2::EventClassV2::Confidential => crate::calendar::EventClass::Confidential,
|
||||
crate::models_v2::EventClassV2::Public => crate::calendar::EventClass::Public,
|
||||
};
|
||||
|
||||
// Convert attendees from V2 to simple email list (for now)
|
||||
let attendees: Vec<String> = request.attendees.into_iter()
|
||||
.map(|att| att.email)
|
||||
.collect();
|
||||
|
||||
// Convert alarms to reminders
|
||||
let reminders: Vec<crate::calendar::EventReminder> = request.alarms.into_iter()
|
||||
.map(|alarm| crate::calendar::EventReminder {
|
||||
minutes_before: -alarm.trigger_minutes, // Convert to positive minutes before
|
||||
action: match alarm.action {
|
||||
crate::models_v2::AlarmActionV2::Display => crate::calendar::ReminderAction::Display,
|
||||
crate::models_v2::AlarmActionV2::Email => crate::calendar::ReminderAction::Email,
|
||||
crate::models_v2::AlarmActionV2::Audio => crate::calendar::ReminderAction::Audio,
|
||||
},
|
||||
description: alarm.description,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create the CalendarEvent struct - much simpler now!
|
||||
let event = crate::calendar::CalendarEvent {
|
||||
uid,
|
||||
summary: Some(request.summary.clone()),
|
||||
description: request.description,
|
||||
start: request.dtstart,
|
||||
end: request.dtend,
|
||||
location: request.location,
|
||||
status,
|
||||
class,
|
||||
priority: request.priority,
|
||||
organizer: request.organizer,
|
||||
attendees,
|
||||
categories: request.categories,
|
||||
created: Some(chrono::Utc::now()),
|
||||
last_modified: Some(chrono::Utc::now()),
|
||||
recurrence_rule: request.rrule,
|
||||
exception_dates: Vec::new(), // No exception dates for new events
|
||||
all_day: request.all_day,
|
||||
reminders,
|
||||
etag: None,
|
||||
href: None,
|
||||
calendar_path: Some(calendar_path.clone()),
|
||||
};
|
||||
|
||||
// Create the event on the CalDAV server
|
||||
let event_href = client.create_event(&calendar_path, &event)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
|
||||
|
||||
// Fetch the created event to get its details
|
||||
let created_event = fetch_event_by_href(&client, &calendar_path, &event_href)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch created event: {}", e)))?;
|
||||
|
||||
let event_summary = created_event.map(|e| crate::models_v2::EventSummaryV2 {
|
||||
uid: e.uid,
|
||||
summary: e.summary,
|
||||
dtstart: e.start,
|
||||
dtend: e.end,
|
||||
location: e.location,
|
||||
all_day: e.all_day,
|
||||
href: e.href,
|
||||
etag: e.etag,
|
||||
});
|
||||
|
||||
Ok(Json(CreateEventResponseV2 {
|
||||
success: true,
|
||||
message: "Event created successfully".to_string(),
|
||||
event: event_summary,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn create_event(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
@@ -763,13 +1055,261 @@ pub async fn create_event(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update event using v2 API with direct DateTime support (no string parsing)
|
||||
pub async fn update_event_v2(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<UpdateEventRequestV2>,
|
||||
) -> Result<Json<UpdateEventResponseV2>, ApiError> {
|
||||
println!("🔄 Update event v2 request received: uid='{}', summary='{}', update_action={:?}",
|
||||
request.uid, request.summary, request.update_action);
|
||||
|
||||
// Extract and verify token
|
||||
let token = extract_bearer_token(&headers)?;
|
||||
let password = extract_password_header(&headers)?;
|
||||
|
||||
// Validate request
|
||||
if request.uid.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("Event UID is required".to_string()));
|
||||
}
|
||||
|
||||
if request.summary.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("Event summary is required".to_string()));
|
||||
}
|
||||
|
||||
if request.summary.len() > 200 {
|
||||
return Err(ApiError::BadRequest("Event summary too long (max 200 characters)".to_string()));
|
||||
}
|
||||
|
||||
// Create CalDAV config from token and password
|
||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
||||
let client = CalDAVClient::new(config);
|
||||
|
||||
// Find the event across all calendars (or in the specified calendar)
|
||||
let calendar_paths = if let Some(path) = &request.calendar_path {
|
||||
vec![path.clone()]
|
||||
} else {
|
||||
client.discover_calendars()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
||||
};
|
||||
|
||||
if calendar_paths.is_empty() {
|
||||
return Err(ApiError::BadRequest("No calendars available for event update".to_string()));
|
||||
}
|
||||
|
||||
// Validate that end is after start
|
||||
if let Some(end) = request.dtend {
|
||||
if end <= request.dtstart {
|
||||
return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if this is a series update
|
||||
let search_uid = request.uid.clone();
|
||||
let is_series_update = request.update_action.as_deref() == Some("update_series");
|
||||
|
||||
// Search for the event by UID across the specified calendars
|
||||
let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, href)
|
||||
for calendar_path in &calendar_paths {
|
||||
// First try exact match
|
||||
match client.fetch_event_by_uid(calendar_path, &search_uid).await {
|
||||
Ok(Some(event)) => {
|
||||
if let Some(href) = event.href.clone() {
|
||||
found_event = Some((event, calendar_path.clone(), href));
|
||||
break;
|
||||
}
|
||||
},
|
||||
Ok(None) => {
|
||||
// If exact match fails, try to find by base UID pattern for recurring events
|
||||
println!("🔍 Exact match failed for '{}', searching by base UID pattern", search_uid);
|
||||
match client.fetch_events(calendar_path).await {
|
||||
Ok(events) => {
|
||||
for event in events {
|
||||
if let Some(href) = &event.href {
|
||||
if event.uid.starts_with(&search_uid) && event.uid != search_uid {
|
||||
println!("🎯 Found recurring event by pattern: '{}' matches '{}'", event.uid, search_uid);
|
||||
found_event = Some((event.clone(), calendar_path.clone(), href.clone()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if found_event.is_some() {
|
||||
break;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Error fetching events from {}: {:?}", calendar_path, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to fetch event from calendar {}: {}", calendar_path, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (mut event, calendar_path, event_href) = found_event
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", search_uid)))?;
|
||||
|
||||
// Convert V2 enums to calendar module enums
|
||||
let status = match request.status.unwrap_or_default() {
|
||||
crate::models_v2::EventStatusV2::Tentative => crate::calendar::EventStatus::Tentative,
|
||||
crate::models_v2::EventStatusV2::Cancelled => crate::calendar::EventStatus::Cancelled,
|
||||
crate::models_v2::EventStatusV2::Confirmed => crate::calendar::EventStatus::Confirmed,
|
||||
};
|
||||
|
||||
let class = match request.class.unwrap_or_default() {
|
||||
crate::models_v2::EventClassV2::Private => crate::calendar::EventClass::Private,
|
||||
crate::models_v2::EventClassV2::Confidential => crate::calendar::EventClass::Confidential,
|
||||
crate::models_v2::EventClassV2::Public => crate::calendar::EventClass::Public,
|
||||
};
|
||||
|
||||
// Convert attendees from V2 to simple email list (for now)
|
||||
let attendees: Vec<String> = request.attendees.into_iter()
|
||||
.map(|att| att.email)
|
||||
.collect();
|
||||
|
||||
// Convert alarms to reminders
|
||||
let reminders: Vec<crate::calendar::EventReminder> = request.alarms.into_iter()
|
||||
.map(|alarm| crate::calendar::EventReminder {
|
||||
minutes_before: -alarm.trigger_minutes,
|
||||
action: match alarm.action {
|
||||
crate::models_v2::AlarmActionV2::Display => crate::calendar::ReminderAction::Display,
|
||||
crate::models_v2::AlarmActionV2::Email => crate::calendar::ReminderAction::Email,
|
||||
crate::models_v2::AlarmActionV2::Audio => crate::calendar::ReminderAction::Audio,
|
||||
},
|
||||
description: alarm.description,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Update the event fields with new data
|
||||
event.summary = Some(request.summary.clone());
|
||||
event.description = request.description;
|
||||
|
||||
// Handle date/time updates based on update type
|
||||
if is_series_update {
|
||||
// For series updates, only update the TIME, keep the original DATE
|
||||
let original_start_date = event.start.date_naive();
|
||||
let original_end_date = event.end.map(|e| e.date_naive()).unwrap_or(original_start_date);
|
||||
|
||||
let new_start_time = request.dtstart.time();
|
||||
let new_end_time = request.dtend.map(|dt| dt.time()).unwrap_or(new_start_time);
|
||||
|
||||
// Combine original date with new time
|
||||
let updated_start = original_start_date.and_time(new_start_time).and_utc();
|
||||
let updated_end = original_end_date.and_time(new_end_time).and_utc();
|
||||
|
||||
event.start = updated_start;
|
||||
event.end = Some(updated_end);
|
||||
} else {
|
||||
// For regular updates, update both date and time
|
||||
event.start = request.dtstart;
|
||||
event.end = request.dtend;
|
||||
}
|
||||
|
||||
event.location = request.location;
|
||||
event.status = status;
|
||||
event.class = class;
|
||||
event.priority = request.priority;
|
||||
event.organizer = request.organizer;
|
||||
event.attendees = attendees;
|
||||
event.categories = request.categories;
|
||||
event.last_modified = Some(chrono::Utc::now());
|
||||
event.all_day = request.all_day;
|
||||
event.reminders = reminders;
|
||||
|
||||
// Handle recurrence rule and UID for series updates
|
||||
if is_series_update {
|
||||
// For series updates, preserve existing recurrence rule and convert UID to base UID
|
||||
let parts: Vec<&str> = request.uid.split('-').collect();
|
||||
if parts.len() > 1 {
|
||||
let last_part = parts[parts.len() - 1];
|
||||
if last_part.chars().all(|c| c.is_numeric()) {
|
||||
let base_uid = parts[0..parts.len()-1].join("-");
|
||||
event.uid = base_uid;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle exception dates
|
||||
if let Some(exception_dates) = request.exception_dates {
|
||||
let mut new_exception_dates = Vec::new();
|
||||
for date in exception_dates {
|
||||
new_exception_dates.push(date);
|
||||
}
|
||||
|
||||
// Merge with existing exception dates (avoid duplicates)
|
||||
for new_date in new_exception_dates {
|
||||
if !event.exception_dates.contains(&new_date) {
|
||||
event.exception_dates.push(new_date);
|
||||
}
|
||||
}
|
||||
|
||||
println!("🔄 Updated exception dates: {} total", event.exception_dates.len());
|
||||
}
|
||||
|
||||
// Handle UNTIL date modification for "This and Future Events"
|
||||
if let Some(until_date) = request.until_date {
|
||||
println!("🔄 Adding UNTIL clause to RRULE: {}", until_date);
|
||||
|
||||
if let Some(ref rrule) = event.recurrence_rule {
|
||||
// Remove existing UNTIL if present and add new one
|
||||
let rrule_without_until = rrule.split(';')
|
||||
.filter(|part| !part.starts_with("UNTIL="))
|
||||
.collect::<Vec<&str>>()
|
||||
.join(";");
|
||||
|
||||
let until_formatted = until_date.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
|
||||
event.recurrence_rule = Some(format!("{};UNTIL={}", rrule_without_until, until_formatted));
|
||||
println!("🔄 Modified RRULE: {}", event.recurrence_rule.as_ref().unwrap());
|
||||
|
||||
// Clear exception dates since we're using UNTIL instead
|
||||
event.exception_dates.clear();
|
||||
println!("🔄 Cleared exception dates for UNTIL approach");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For regular updates, use the new recurrence rule
|
||||
event.recurrence_rule = request.rrule;
|
||||
}
|
||||
|
||||
// Update the event on the CalDAV server
|
||||
client.update_event(&calendar_path, &event, &event_href)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?;
|
||||
|
||||
// Fetch the updated event to return its details
|
||||
let updated_event = fetch_event_by_href(&client, &calendar_path, &event_href)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch updated event: {}", e)))?;
|
||||
|
||||
let event_summary = updated_event.map(|e| crate::models_v2::EventSummaryV2 {
|
||||
uid: e.uid,
|
||||
summary: e.summary,
|
||||
dtstart: e.start,
|
||||
dtend: e.end,
|
||||
location: e.location,
|
||||
all_day: e.all_day,
|
||||
href: e.href,
|
||||
etag: e.etag,
|
||||
});
|
||||
|
||||
Ok(Json(UpdateEventResponseV2 {
|
||||
success: true,
|
||||
message: "Event updated successfully".to_string(),
|
||||
event: event_summary,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update_event(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<UpdateEventRequest>,
|
||||
) -> Result<Json<UpdateEventResponse>, ApiError> {
|
||||
println!("📝 Update event request received: uid='{}', title='{}', calendar_path={:?}",
|
||||
request.uid, request.title, request.calendar_path);
|
||||
// Handle update request
|
||||
|
||||
// Extract and verify token
|
||||
let token = extract_bearer_token(&headers)?;
|
||||
@@ -805,17 +1345,48 @@ pub async fn update_event(
|
||||
return Err(ApiError::BadRequest("No calendars available for event update".to_string()));
|
||||
}
|
||||
|
||||
// Determine if this is a series update
|
||||
let search_uid = request.uid.clone();
|
||||
let is_series_update = request.update_action.as_deref() == Some("update_series");
|
||||
|
||||
// Search for the event by UID across the specified calendars
|
||||
// For recurring events, we might need to find by base UID pattern if exact match fails
|
||||
let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, href)
|
||||
for calendar_path in &calendar_paths {
|
||||
match client.fetch_event_by_uid(calendar_path, &request.uid).await {
|
||||
// First try exact match
|
||||
match client.fetch_event_by_uid(calendar_path, &search_uid).await {
|
||||
Ok(Some(event)) => {
|
||||
if let Some(href) = event.href.clone() {
|
||||
found_event = Some((event, calendar_path.clone(), href));
|
||||
break;
|
||||
}
|
||||
},
|
||||
Ok(None) => continue, // Event not found in this calendar
|
||||
Ok(None) => {
|
||||
// If exact match fails, try to find by base UID pattern for recurring events
|
||||
println!("🔍 Exact match failed for '{}', searching by base UID pattern", search_uid);
|
||||
match client.fetch_events(calendar_path).await {
|
||||
Ok(events) => {
|
||||
// Look for any event whose UID starts with the search_uid
|
||||
for event in events {
|
||||
if let Some(href) = &event.href {
|
||||
// Check if this event's UID starts with our search UID (base pattern)
|
||||
if event.uid.starts_with(&search_uid) && event.uid != search_uid {
|
||||
println!("🎯 Found recurring event by pattern: '{}' matches '{}'", event.uid, search_uid);
|
||||
found_event = Some((event.clone(), calendar_path.clone(), href.clone()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if found_event.is_some() {
|
||||
break;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Error fetching events from {}: {:?}", calendar_path, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to fetch event from calendar {}: {}", calendar_path, e);
|
||||
continue;
|
||||
@@ -824,7 +1395,7 @@ pub async fn update_event(
|
||||
}
|
||||
|
||||
let (mut event, calendar_path, event_href) = found_event
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", search_uid)))?;
|
||||
|
||||
// Parse dates and times for the updated event
|
||||
let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
||||
@@ -963,8 +1534,28 @@ pub async fn update_event(
|
||||
} else {
|
||||
Some(request.description.clone())
|
||||
};
|
||||
event.start = start_datetime;
|
||||
event.end = Some(end_datetime);
|
||||
// Handle date/time updates based on update type
|
||||
if is_series_update {
|
||||
// For series updates, only update the TIME, keep the original DATE
|
||||
let original_start_date = event.start.date_naive();
|
||||
let original_end_date = event.end.map(|e| e.date_naive()).unwrap_or(original_start_date);
|
||||
|
||||
let new_start_time = start_datetime.time();
|
||||
let new_end_time = end_datetime.time();
|
||||
|
||||
// Combine original date with new time
|
||||
let updated_start = original_start_date.and_time(new_start_time).and_utc();
|
||||
let updated_end = original_end_date.and_time(new_end_time).and_utc();
|
||||
|
||||
// Preserve original date with new time
|
||||
|
||||
event.start = updated_start;
|
||||
event.end = Some(updated_end);
|
||||
} else {
|
||||
// For regular updates, update both date and time
|
||||
event.start = start_datetime;
|
||||
event.end = Some(end_datetime);
|
||||
}
|
||||
event.location = if request.location.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -981,10 +1572,78 @@ pub async fn update_event(
|
||||
event.attendees = attendees;
|
||||
event.categories = categories;
|
||||
event.last_modified = Some(chrono::Utc::now());
|
||||
event.recurrence_rule = recurrence_rule;
|
||||
event.all_day = request.all_day;
|
||||
event.reminders = reminders;
|
||||
|
||||
// Handle recurrence rule and UID for series updates
|
||||
if is_series_update {
|
||||
// For series updates, preserve existing recurrence rule and convert UID to base UID
|
||||
let parts: Vec<&str> = request.uid.split('-').collect();
|
||||
if parts.len() > 1 {
|
||||
let last_part = parts[parts.len() - 1];
|
||||
if last_part.chars().all(|c| c.is_numeric()) {
|
||||
let base_uid = parts[0..parts.len()-1].join("-");
|
||||
event.uid = base_uid;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep existing recurrence rule (don't overwrite with recurrence_rule variable)
|
||||
// event.recurrence_rule stays as-is from the original event
|
||||
|
||||
// However, allow exception_dates to be updated - this is needed for "This and Future" events
|
||||
if let Some(exception_dates_str) = &request.exception_dates {
|
||||
// Parse the ISO datetime strings into DateTime<Utc>
|
||||
let mut new_exception_dates = Vec::new();
|
||||
for date_str in exception_dates_str {
|
||||
if let Ok(parsed_date) = chrono::DateTime::parse_from_rfc3339(date_str) {
|
||||
new_exception_dates.push(parsed_date.with_timezone(&chrono::Utc));
|
||||
} else if let Ok(parsed_date) = chrono::DateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S UTC") {
|
||||
new_exception_dates.push(parsed_date.with_timezone(&chrono::Utc));
|
||||
} else {
|
||||
eprintln!("Failed to parse exception date: {}", date_str);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with existing exception dates (avoid duplicates)
|
||||
for new_date in new_exception_dates {
|
||||
if !event.exception_dates.contains(&new_date) {
|
||||
event.exception_dates.push(new_date);
|
||||
}
|
||||
}
|
||||
|
||||
println!("🔄 Updated exception dates: {} total", event.exception_dates.len());
|
||||
}
|
||||
|
||||
// Handle UNTIL date modification for "This and Future Events"
|
||||
if let Some(until_date_str) = &request.until_date {
|
||||
println!("🔄 Adding UNTIL clause to RRULE: {}", until_date_str);
|
||||
|
||||
if let Some(ref rrule) = event.recurrence_rule {
|
||||
// Remove existing UNTIL if present and add new one
|
||||
let rrule_without_until = rrule.split(';')
|
||||
.filter(|part| !part.starts_with("UNTIL="))
|
||||
.collect::<Vec<&str>>()
|
||||
.join(";");
|
||||
|
||||
// Parse the until_date and format for RRULE
|
||||
if let Ok(until_datetime) = chrono::DateTime::parse_from_rfc3339(until_date_str) {
|
||||
let until_utc = until_datetime.with_timezone(&chrono::Utc);
|
||||
let until_formatted = until_utc.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
|
||||
event.recurrence_rule = Some(format!("{};UNTIL={}", rrule_without_until, until_formatted));
|
||||
println!("🔄 Modified RRULE: {}", event.recurrence_rule.as_ref().unwrap());
|
||||
|
||||
// Clear exception dates since we're using UNTIL instead
|
||||
event.exception_dates.clear();
|
||||
println!("🔄 Cleared exception dates for UNTIL approach");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For regular updates, use the new recurrence rule
|
||||
event.recurrence_rule = recurrence_rule;
|
||||
}
|
||||
|
||||
// Update the event on the CalDAV server
|
||||
client.update_event(&calendar_path, &event, &event_href)
|
||||
.await
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::sync::Arc;
|
||||
|
||||
mod auth;
|
||||
mod models;
|
||||
mod models_v2;
|
||||
mod handlers;
|
||||
mod calendar;
|
||||
mod config;
|
||||
@@ -44,6 +45,10 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.route("/api/calendar/events/create", post(handlers::create_event))
|
||||
.route("/api/calendar/events/update", post(handlers::update_event))
|
||||
.route("/api/calendar/events/delete", post(handlers::delete_event))
|
||||
// V2 API routes with better type safety
|
||||
.route("/api/v2/calendar/events/create", post(handlers::create_event_v2))
|
||||
.route("/api/v2/calendar/events/update", post(handlers::update_event_v2))
|
||||
.route("/api/v2/calendar/events/delete", post(handlers::delete_event_v2))
|
||||
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
|
||||
@@ -122,6 +122,11 @@ pub struct UpdateEventRequest {
|
||||
pub recurrence: String, // recurrence type
|
||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||
pub update_action: Option<String>, // "update_series" for recurring events
|
||||
pub occurrence_date: Option<String>, // ISO date string for specific occurrence
|
||||
pub exception_dates: Option<Vec<String>>, // ISO datetime strings for EXDATE
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub until_date: Option<String>, // ISO datetime string for RRULE UNTIL clause
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
295
backend/src/models_v2.rs
Normal file
295
backend/src/models_v2.rs
Normal file
@@ -0,0 +1,295 @@
|
||||
// Simplified RFC 5545-based API models
|
||||
// Axum imports removed - not needed for model definitions
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ==================== CALENDAR REQUESTS ====================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateCalendarRequestV2 {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeleteCalendarRequestV2 {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
// ==================== EVENT REQUESTS ====================
|
||||
|
||||
// Simplified create event request using proper DateTime instead of string parsing
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateEventRequestV2 {
|
||||
pub summary: String, // title -> summary (RFC 5545 term)
|
||||
pub description: Option<String>, // Optional in RFC 5545
|
||||
pub dtstart: DateTime<Utc>, // Direct DateTime, no string parsing!
|
||||
pub dtend: Option<DateTime<Utc>>, // Optional, alternative to duration
|
||||
pub location: Option<String>,
|
||||
pub all_day: bool,
|
||||
|
||||
// Status and classification
|
||||
pub status: Option<EventStatusV2>, // Use enum instead of string
|
||||
pub class: Option<EventClassV2>, // Use enum instead of string
|
||||
pub priority: Option<u8>, // 0-9 priority level
|
||||
|
||||
// People
|
||||
pub organizer: Option<String>, // Organizer email
|
||||
pub attendees: Vec<AttendeeV2>, // Rich attendee objects
|
||||
|
||||
// Categorization
|
||||
pub categories: Vec<String>, // Direct Vec instead of comma-separated
|
||||
|
||||
// Recurrence (simplified for now)
|
||||
pub rrule: Option<String>, // Standard RRULE format
|
||||
|
||||
// Reminders (simplified for now)
|
||||
pub alarms: Vec<AlarmV2>, // Structured alarms
|
||||
|
||||
// Calendar context
|
||||
pub calendar_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateEventRequestV2 {
|
||||
pub uid: String, // Event UID to identify which event to update
|
||||
pub summary: String,
|
||||
pub description: Option<String>,
|
||||
pub dtstart: DateTime<Utc>, // Direct DateTime, no string parsing!
|
||||
pub dtend: Option<DateTime<Utc>>,
|
||||
pub location: Option<String>,
|
||||
pub all_day: bool,
|
||||
|
||||
// Status and classification
|
||||
pub status: Option<EventStatusV2>,
|
||||
pub class: Option<EventClassV2>,
|
||||
pub priority: Option<u8>,
|
||||
|
||||
// People
|
||||
pub organizer: Option<String>,
|
||||
pub attendees: Vec<AttendeeV2>,
|
||||
|
||||
// Categorization
|
||||
pub categories: Vec<String>,
|
||||
|
||||
// Recurrence
|
||||
pub rrule: Option<String>,
|
||||
|
||||
// Reminders
|
||||
pub alarms: Vec<AlarmV2>,
|
||||
|
||||
// Context
|
||||
pub calendar_path: Option<String>,
|
||||
pub update_action: Option<String>, // "update_series" for recurring events
|
||||
pub occurrence_date: Option<DateTime<Utc>>, // Specific occurrence
|
||||
pub exception_dates: Option<Vec<DateTime<Utc>>>, // EXDATE
|
||||
pub until_date: Option<DateTime<Utc>>, // RRULE UNTIL clause
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeleteEventRequestV2 {
|
||||
pub calendar_path: String,
|
||||
pub event_href: String,
|
||||
pub delete_action: DeleteActionV2, // Use enum instead of string
|
||||
pub occurrence_date: Option<DateTime<Utc>>, // Direct DateTime
|
||||
}
|
||||
|
||||
// ==================== SUPPORTING TYPES ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum EventStatusV2 {
|
||||
Tentative,
|
||||
Confirmed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl Default for EventStatusV2 {
|
||||
fn default() -> Self {
|
||||
EventStatusV2::Confirmed
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum EventClassV2 {
|
||||
Public,
|
||||
Private,
|
||||
Confidential,
|
||||
}
|
||||
|
||||
impl Default for EventClassV2 {
|
||||
fn default() -> Self {
|
||||
EventClassV2::Public
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum DeleteActionV2 {
|
||||
DeleteThis, // "delete_this"
|
||||
DeleteFollowing, // "delete_following"
|
||||
DeleteSeries, // "delete_series"
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AttendeeV2 {
|
||||
pub email: String, // Calendar address
|
||||
pub name: Option<String>, // Common name (CN parameter)
|
||||
pub role: Option<AttendeeRoleV2>, // Role (ROLE parameter)
|
||||
pub status: Option<ParticipationStatusV2>, // Participation status (PARTSTAT parameter)
|
||||
pub rsvp: Option<bool>, // RSVP expectation (RSVP parameter)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AttendeeRoleV2 {
|
||||
Chair,
|
||||
Required, // REQ-PARTICIPANT
|
||||
Optional, // OPT-PARTICIPANT
|
||||
NonParticipant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ParticipationStatusV2 {
|
||||
NeedsAction,
|
||||
Accepted,
|
||||
Declined,
|
||||
Tentative,
|
||||
Delegated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AlarmV2 {
|
||||
pub action: AlarmActionV2, // Action (AUDIO, DISPLAY, EMAIL)
|
||||
pub trigger_minutes: i32, // Minutes before event (negative = before)
|
||||
pub description: Option<String>, // Description for display/email
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AlarmActionV2 {
|
||||
Audio,
|
||||
Display,
|
||||
Email,
|
||||
}
|
||||
|
||||
// ==================== RESPONSES ====================
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateEventResponseV2 {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub event: Option<EventSummaryV2>, // Return created event summary
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UpdateEventResponseV2 {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub event: Option<EventSummaryV2>, // Return updated event summary
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeleteEventResponseV2 {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct EventSummaryV2 {
|
||||
pub uid: String,
|
||||
pub summary: Option<String>,
|
||||
pub dtstart: DateTime<Utc>,
|
||||
pub dtend: Option<DateTime<Utc>>,
|
||||
pub location: Option<String>,
|
||||
pub all_day: bool,
|
||||
pub href: Option<String>,
|
||||
pub etag: Option<String>,
|
||||
}
|
||||
|
||||
// ==================== CONVERSION HELPERS ====================
|
||||
|
||||
// Convert from old request format to new for backward compatibility
|
||||
impl From<crate::models::CreateEventRequest> for CreateEventRequestV2 {
|
||||
fn from(old: crate::models::CreateEventRequest) -> Self {
|
||||
use chrono::{NaiveDate, NaiveTime, TimeZone, Utc};
|
||||
|
||||
// Parse the old string-based date/time format
|
||||
let start_date = NaiveDate::parse_from_str(&old.start_date, "%Y-%m-%d")
|
||||
.unwrap_or_else(|_| chrono::Utc::now().date_naive());
|
||||
let start_time = NaiveTime::parse_from_str(&old.start_time, "%H:%M")
|
||||
.unwrap_or_else(|_| NaiveTime::from_hms_opt(0, 0, 0).unwrap());
|
||||
let dtstart = Utc.from_utc_datetime(&start_date.and_time(start_time));
|
||||
|
||||
let end_date = NaiveDate::parse_from_str(&old.end_date, "%Y-%m-%d")
|
||||
.unwrap_or_else(|_| chrono::Utc::now().date_naive());
|
||||
let end_time = NaiveTime::parse_from_str(&old.end_time, "%H:%M")
|
||||
.unwrap_or_else(|_| NaiveTime::from_hms_opt(1, 0, 0).unwrap());
|
||||
let dtend = Some(Utc.from_utc_datetime(&end_date.and_time(end_time)));
|
||||
|
||||
// Parse comma-separated categories
|
||||
let categories: Vec<String> = if old.categories.trim().is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
old.categories.split(',').map(|s| s.trim().to_string()).collect()
|
||||
};
|
||||
|
||||
// Parse comma-separated attendees
|
||||
let attendees: Vec<AttendeeV2> = if old.attendees.trim().is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
old.attendees.split(',').map(|email| AttendeeV2 {
|
||||
email: email.trim().to_string(),
|
||||
name: None,
|
||||
role: Some(AttendeeRoleV2::Required),
|
||||
status: Some(ParticipationStatusV2::NeedsAction),
|
||||
rsvp: Some(true),
|
||||
}).collect()
|
||||
};
|
||||
|
||||
// Convert status string to enum
|
||||
let status = match old.status.as_str() {
|
||||
"tentative" => Some(EventStatusV2::Tentative),
|
||||
"confirmed" => Some(EventStatusV2::Confirmed),
|
||||
"cancelled" => Some(EventStatusV2::Cancelled),
|
||||
_ => Some(EventStatusV2::Confirmed),
|
||||
};
|
||||
|
||||
// Convert class string to enum
|
||||
let class = match old.class.as_str() {
|
||||
"public" => Some(EventClassV2::Public),
|
||||
"private" => Some(EventClassV2::Private),
|
||||
"confidential" => Some(EventClassV2::Confidential),
|
||||
_ => Some(EventClassV2::Public),
|
||||
};
|
||||
|
||||
// Create basic alarm if reminder specified
|
||||
let alarms = if old.reminder == "none" {
|
||||
Vec::new()
|
||||
} else {
|
||||
// Default to 15 minutes before for now
|
||||
vec![AlarmV2 {
|
||||
action: AlarmActionV2::Display,
|
||||
trigger_minutes: 15,
|
||||
description: Some("Event reminder".to_string()),
|
||||
}]
|
||||
};
|
||||
|
||||
Self {
|
||||
summary: old.title,
|
||||
description: if old.description.trim().is_empty() { None } else { Some(old.description) },
|
||||
dtstart,
|
||||
dtend,
|
||||
location: if old.location.trim().is_empty() { None } else { Some(old.location) },
|
||||
all_day: old.all_day,
|
||||
status,
|
||||
class,
|
||||
priority: old.priority,
|
||||
organizer: if old.organizer.trim().is_empty() { None } else { Some(old.organizer) },
|
||||
attendees,
|
||||
categories,
|
||||
rrule: None, // TODO: Convert recurrence string to RRULE
|
||||
alarms,
|
||||
calendar_path: old.calendar_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error handling - ApiError is available through crate::models::ApiError in handlers
|
||||
186
integration_plan.md
Normal file
186
integration_plan.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# RFC 5545 Integration Plan
|
||||
|
||||
## Phase 1: Core Structure Replacement (High Impact, Low Risk)
|
||||
|
||||
### 1.1 Replace Event Models
|
||||
**Files to Update:**
|
||||
- `backend/src/calendar.rs` - Replace `CalendarEvent` with `VEvent`
|
||||
- `src/services/calendar_service.rs` - Replace `CalendarEvent` with `VEvent`
|
||||
- Remove duplicate structures, use single source of truth
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Eliminate duplicate event definitions
|
||||
- ✅ Add missing DTSTAMP (RFC required)
|
||||
- ✅ Add SEQUENCE for proper versioning
|
||||
- ✅ Standardize on DateTime<Utc> instead of string parsing
|
||||
|
||||
### 1.2 Simplify Request/Response Models
|
||||
**Files to Update:**
|
||||
- `backend/src/models.rs` - Replace string-based fields
|
||||
|
||||
**Current (Complex):**
|
||||
```rust
|
||||
pub start_date: String, // YYYY-MM-DD format
|
||||
pub start_time: String, // HH:MM format
|
||||
pub categories: String, // comma-separated
|
||||
pub attendees: String, // comma-separated
|
||||
```
|
||||
|
||||
**New (Simple):**
|
||||
```rust
|
||||
pub dtstart: DateTime<Utc>,
|
||||
pub categories: Vec<String>,
|
||||
pub attendees: Vec<Attendee>,
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Remove ~50 lines of manual string parsing in handlers
|
||||
- ✅ Better type safety
|
||||
- ✅ Automatic validation
|
||||
|
||||
## Phase 2: Enhanced Functionality (Medium Impact, Medium Risk)
|
||||
|
||||
### 2.1 Add Rich Attendee Support
|
||||
**Current:** `Vec<String>` (just emails)
|
||||
**New:** `Vec<Attendee>` with roles, status, RSVP
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Proper meeting invitations
|
||||
- ✅ RSVP tracking
|
||||
- ✅ Role-based permissions (Chair, Required, Optional)
|
||||
|
||||
### 2.2 Structured Reminders/Alarms
|
||||
**Current:** Simple reminder minutes
|
||||
**New:** Full `VAlarm` component with actions, triggers
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Multiple reminder types (email, display, audio)
|
||||
- ✅ Complex trigger patterns
|
||||
- ✅ Better CalDAV compatibility
|
||||
|
||||
### 2.3 Geographic Location Support
|
||||
**New Addition:** `GEO` property for latitude/longitude
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Map integration possibilities
|
||||
- ✅ Location-based reminders
|
||||
- ✅ Travel time calculations
|
||||
|
||||
## Phase 3: Advanced Components (High Impact, Higher Risk)
|
||||
|
||||
### 3.1 Add VTODO Support
|
||||
**New Component:** Task/To-Do management
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Unified calendar + task system
|
||||
- ✅ Due dates, completion tracking
|
||||
- ✅ Priority management
|
||||
|
||||
### 3.2 Add VJOURNAL Support
|
||||
**New Component:** Journal/diary entries
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Meeting notes integration
|
||||
- ✅ Daily journaling
|
||||
- ✅ Full calendar suite
|
||||
|
||||
### 3.3 Add VFREEBUSY Support
|
||||
**New Component:** Availability tracking
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Meeting scheduling optimization
|
||||
- ✅ Conflict detection
|
||||
- ✅ Resource booking
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Immediate Actions (Can Start Now)
|
||||
1. **Add compatibility layer** in existing `CalendarEvent` to support new fields
|
||||
2. **Implement conversion functions** between old/new structures
|
||||
3. **Update backend models** to use DateTime instead of string parsing
|
||||
|
||||
### Quick Wins (1-2 hours each)
|
||||
1. **Replace string date parsing** in `backend/src/handlers.rs`
|
||||
2. **Add missing DTSTAMP** to all events (RFC compliance)
|
||||
3. **Convert categories/attendees** from comma-separated strings to vectors
|
||||
|
||||
### Medium Effort (3-5 hours each)
|
||||
1. **Unified event structure** across frontend/backend
|
||||
2. **Rich attendee management** with roles and status
|
||||
3. **Structured alarm system**
|
||||
|
||||
### Long Term (Future enhancements)
|
||||
1. **Full VTODO implementation**
|
||||
2. **VJOURNAL support**
|
||||
3. **VFREEBUSY and scheduling**
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
### Backward Compatibility
|
||||
- Keep existing API endpoints working
|
||||
- Add conversion functions between old/new formats
|
||||
- Gradual migration, not big-bang replacement
|
||||
|
||||
### Testing Strategy
|
||||
- Add tests for RFC 5545 compliance
|
||||
- Test CalDAV interoperability
|
||||
- Validate against multiple calendar clients
|
||||
|
||||
### Rollback Plan
|
||||
- Keep old structures as fallback
|
||||
- Feature flags for new functionality
|
||||
- Incremental deployment
|
||||
|
||||
## Expected Benefits
|
||||
|
||||
### Developer Experience
|
||||
- **50% reduction** in date/time parsing code
|
||||
- **Elimination** of string-based field parsing
|
||||
- **Type safety** for all calendar operations
|
||||
- **Standards compliance** reduces debugging
|
||||
|
||||
### User Experience
|
||||
- **Better CalDAV compatibility** with all clients
|
||||
- **Rich attendee management** for meetings
|
||||
- **Proper timezone handling**
|
||||
- **Future-proof** for advanced features
|
||||
|
||||
### Maintenance
|
||||
- **Single source of truth** for event data
|
||||
- **RFC 5545 compliance** eliminates compatibility issues
|
||||
- **Cleaner codebase** with less duplication
|
||||
- **Easier testing** with structured data
|
||||
|
||||
## File Impact Analysis
|
||||
|
||||
### High Impact Files (Need Updates)
|
||||
```
|
||||
backend/src/models.rs - Replace request/response structs
|
||||
backend/src/handlers.rs - Remove string parsing logic
|
||||
backend/src/calendar.rs - Replace CalendarEvent
|
||||
src/services/calendar_service.rs - Use unified structures
|
||||
```
|
||||
|
||||
### Medium Impact Files (Minor Changes)
|
||||
```
|
||||
src/components/create_event_modal.rs - Update form handling
|
||||
src/components/event_modal.rs - Display enhancements
|
||||
backend/src/lib.rs - Add new modules
|
||||
```
|
||||
|
||||
### Low Impact Files (Minimal/No Changes)
|
||||
```
|
||||
src/components/week_view.rs - Just use new event structure
|
||||
src/components/month_view.rs - Just use new event structure
|
||||
styles.css - No changes needed
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Review this plan** with team/stakeholders
|
||||
2. **Create branch** for RFC 5545 integration
|
||||
3. **Start with Phase 1.1** - Core structure replacement
|
||||
4. **Implement conversion functions** for compatibility
|
||||
5. **Update one handler at a time** to reduce risk
|
||||
|
||||
The integration will significantly simplify the codebase while adding professional-grade calendar functionality!
|
||||
237
src/app.rs
237
src/app.rs
@@ -2,10 +2,37 @@ use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use web_sys::MouseEvent;
|
||||
use crate::components::{Sidebar, ViewMode, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction};
|
||||
use crate::services::{CalendarService, calendar_service::{UserInfo, CalendarEvent}};
|
||||
use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction};
|
||||
use crate::services::{CalendarService, calendar_service::UserInfo};
|
||||
use crate::models::ical::VEvent;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
fn get_theme_event_colors() -> Vec<String> {
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
if let Some(root) = document.document_element() {
|
||||
if let Ok(Some(computed_style)) = window.get_computed_style(&root) {
|
||||
if let Ok(colors_string) = computed_style.get_property_value("--event-colors") {
|
||||
if !colors_string.is_empty() {
|
||||
return colors_string
|
||||
.split(',')
|
||||
.map(|color| color.trim().to_string())
|
||||
.filter(|color| !color.is_empty())
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vec![
|
||||
"#3B82F6".to_string(), "#10B981".to_string(), "#F59E0B".to_string(), "#EF4444".to_string(),
|
||||
"#8B5CF6".to_string(), "#06B6D4".to_string(), "#84CC16".to_string(), "#F97316".to_string(),
|
||||
"#EC4899".to_string(), "#6366F1".to_string(), "#14B8A6".to_string(), "#F3B806".to_string(),
|
||||
"#8B5A2B".to_string(), "#6B7280".to_string(), "#DC2626".to_string(), "#7C3AED".to_string()
|
||||
]
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn App() -> Html {
|
||||
@@ -21,7 +48,7 @@ pub fn App() -> Html {
|
||||
let context_menu_calendar_path = use_state(|| -> Option<String> { None });
|
||||
let event_context_menu_open = use_state(|| false);
|
||||
let event_context_menu_pos = use_state(|| (0i32, 0i32));
|
||||
let event_context_menu_event = use_state(|| -> Option<CalendarEvent> { None });
|
||||
let event_context_menu_event = use_state(|| -> Option<VEvent> { None });
|
||||
let calendar_context_menu_open = use_state(|| false);
|
||||
let calendar_context_menu_pos = use_state(|| (0i32, 0i32));
|
||||
let calendar_context_menu_date = use_state(|| -> Option<NaiveDate> { None });
|
||||
@@ -41,12 +68,17 @@ pub fn App() -> Html {
|
||||
}
|
||||
});
|
||||
|
||||
let available_colors = [
|
||||
"#3B82F6", "#10B981", "#F59E0B", "#EF4444",
|
||||
"#8B5CF6", "#06B6D4", "#84CC16", "#F97316",
|
||||
"#EC4899", "#6366F1", "#14B8A6", "#F3B806",
|
||||
"#8B5A2B", "#6B7280", "#DC2626", "#7C3AED"
|
||||
];
|
||||
// Theme state - load from localStorage if available
|
||||
let current_theme = use_state(|| {
|
||||
// Try to load saved theme from localStorage
|
||||
if let Ok(saved_theme) = LocalStorage::get::<String>("calendar_theme") {
|
||||
Theme::from_value(&saved_theme)
|
||||
} else {
|
||||
Theme::Default // Default theme
|
||||
}
|
||||
});
|
||||
|
||||
let available_colors = use_state(|| get_theme_event_colors());
|
||||
|
||||
let on_login = {
|
||||
let auth_token = auth_token.clone();
|
||||
@@ -80,6 +112,41 @@ pub fn App() -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
let on_theme_change = {
|
||||
let current_theme = current_theme.clone();
|
||||
let available_colors = available_colors.clone();
|
||||
Callback::from(move |new_theme: Theme| {
|
||||
// Save theme to localStorage
|
||||
let _ = LocalStorage::set("calendar_theme", new_theme.value());
|
||||
|
||||
// Apply theme to document root
|
||||
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
|
||||
if let Some(root) = document.document_element() {
|
||||
let _ = root.set_attribute("data-theme", new_theme.value());
|
||||
}
|
||||
}
|
||||
|
||||
// Update state
|
||||
current_theme.set(new_theme);
|
||||
|
||||
// Update available colors after theme change
|
||||
available_colors.set(get_theme_event_colors());
|
||||
})
|
||||
};
|
||||
|
||||
// Apply initial theme on mount
|
||||
{
|
||||
let current_theme = current_theme.clone();
|
||||
use_effect_with((), move |_| {
|
||||
let theme = (*current_theme).clone();
|
||||
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
|
||||
if let Some(root) = document.document_element() {
|
||||
let _ = root.set_attribute("data-theme", theme.value());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch user info when token is available
|
||||
{
|
||||
let user_info = user_info.clone();
|
||||
@@ -212,7 +279,7 @@ pub fn App() -> Html {
|
||||
let event_context_menu_open = event_context_menu_open.clone();
|
||||
let event_context_menu_pos = event_context_menu_pos.clone();
|
||||
let event_context_menu_event = event_context_menu_event.clone();
|
||||
Callback::from(move |(event, calendar_event): (MouseEvent, CalendarEvent)| {
|
||||
Callback::from(move |(event, calendar_event): (MouseEvent, VEvent)| {
|
||||
event_context_menu_open.set(true);
|
||||
event_context_menu_pos.set((event.client_x(), event.client_y()));
|
||||
event_context_menu_event.set(Some(calendar_event));
|
||||
@@ -247,12 +314,12 @@ pub fn App() -> Html {
|
||||
web_sys::console::log_1(&format!("Creating event: {:?}", event_data).into());
|
||||
create_event_modal_open.set(false);
|
||||
|
||||
if let Some(token) = (*auth_token).clone() {
|
||||
if let Some(_token) = (*auth_token).clone() {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
let _calendar_service = CalendarService::new();
|
||||
|
||||
// Get CalDAV password from storage
|
||||
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
||||
let _password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
||||
credentials["password"].as_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
@@ -262,73 +329,30 @@ pub fn App() -> Html {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Convert local times to UTC for backend storage
|
||||
let start_local = event_data.start_date.and_time(event_data.start_time);
|
||||
let end_local = event_data.end_date.and_time(event_data.end_time);
|
||||
|
||||
let start_utc = start_local.and_local_timezone(chrono::Local).unwrap().to_utc();
|
||||
let end_utc = end_local.and_local_timezone(chrono::Local).unwrap().to_utc();
|
||||
|
||||
// Format UTC date and time strings for backend
|
||||
let start_date = start_utc.format("%Y-%m-%d").to_string();
|
||||
let start_time = start_utc.format("%H:%M").to_string();
|
||||
let end_date = end_utc.format("%Y-%m-%d").to_string();
|
||||
let end_time = end_utc.format("%H:%M").to_string();
|
||||
|
||||
// Convert enums to strings for backend
|
||||
let status_str = match event_data.status {
|
||||
EventStatus::Tentative => "tentative",
|
||||
EventStatus::Cancelled => "cancelled",
|
||||
_ => "confirmed",
|
||||
}.to_string();
|
||||
|
||||
let class_str = match event_data.class {
|
||||
EventClass::Private => "private",
|
||||
EventClass::Confidential => "confidential",
|
||||
_ => "public",
|
||||
}.to_string();
|
||||
|
||||
let reminder_str = match event_data.reminder {
|
||||
ReminderType::Minutes15 => "15min",
|
||||
ReminderType::Minutes30 => "30min",
|
||||
ReminderType::Hour1 => "1hour",
|
||||
ReminderType::Hours2 => "2hours",
|
||||
ReminderType::Day1 => "1day",
|
||||
ReminderType::Days2 => "2days",
|
||||
ReminderType::Week1 => "1week",
|
||||
_ => "none",
|
||||
}.to_string();
|
||||
|
||||
let recurrence_str = match event_data.recurrence {
|
||||
RecurrenceType::Daily => "daily",
|
||||
RecurrenceType::Weekly => "weekly",
|
||||
RecurrenceType::Monthly => "monthly",
|
||||
RecurrenceType::Yearly => "yearly",
|
||||
_ => "none",
|
||||
}.to_string();
|
||||
|
||||
match calendar_service.create_event(
|
||||
&token,
|
||||
&password,
|
||||
event_data.title,
|
||||
event_data.description,
|
||||
start_date,
|
||||
start_time,
|
||||
end_date,
|
||||
end_time,
|
||||
event_data.location,
|
||||
event_data.all_day,
|
||||
status_str,
|
||||
class_str,
|
||||
event_data.priority,
|
||||
event_data.organizer,
|
||||
event_data.attendees,
|
||||
event_data.categories,
|
||||
reminder_str,
|
||||
recurrence_str,
|
||||
event_data.recurrence_days,
|
||||
event_data.selected_calendar
|
||||
).await {
|
||||
let params = event_data.to_create_event_params();
|
||||
let create_result = _calendar_service.create_event(
|
||||
&_token,
|
||||
&_password,
|
||||
params.0, // title
|
||||
params.1, // description
|
||||
params.2, // start_date
|
||||
params.3, // start_time
|
||||
params.4, // end_date
|
||||
params.5, // end_time
|
||||
params.6, // location
|
||||
params.7, // all_day
|
||||
params.8, // status
|
||||
params.9, // class
|
||||
params.10, // priority
|
||||
params.11, // organizer
|
||||
params.12, // attendees
|
||||
params.13, // categories
|
||||
params.14, // reminder
|
||||
params.15, // recurrence
|
||||
params.16, // recurrence_days
|
||||
params.17 // calendar_path
|
||||
).await;
|
||||
match create_result {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Event created successfully".into());
|
||||
// Trigger a page reload to refresh events from all calendars
|
||||
@@ -347,14 +371,18 @@ pub fn App() -> Html {
|
||||
|
||||
let on_event_update = {
|
||||
let auth_token = auth_token.clone();
|
||||
Callback::from(move |(original_event, new_start, new_end): (CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime)| {
|
||||
Callback::from(move |(original_event, new_start, new_end, preserve_rrule, until_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)| {
|
||||
web_sys::console::log_1(&format!("Updating event: {} to new times: {} - {}",
|
||||
original_event.uid,
|
||||
new_start.format("%Y-%m-%d %H:%M"),
|
||||
new_end.format("%Y-%m-%d %H:%M")).into());
|
||||
|
||||
// Use the original UID for all updates
|
||||
let backend_uid = original_event.uid.clone();
|
||||
|
||||
if let Some(token) = (*auth_token).clone() {
|
||||
let original_event = original_event.clone();
|
||||
let backend_uid = backend_uid.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
|
||||
@@ -381,32 +409,35 @@ pub fn App() -> Html {
|
||||
|
||||
// Convert existing event data to string formats for the API
|
||||
let status_str = match original_event.status {
|
||||
crate::services::calendar_service::EventStatus::Tentative => "TENTATIVE".to_string(),
|
||||
crate::services::calendar_service::EventStatus::Confirmed => "CONFIRMED".to_string(),
|
||||
crate::services::calendar_service::EventStatus::Cancelled => "CANCELLED".to_string(),
|
||||
Some(crate::models::ical::EventStatus::Tentative) => "TENTATIVE".to_string(),
|
||||
Some(crate::models::ical::EventStatus::Confirmed) => "CONFIRMED".to_string(),
|
||||
Some(crate::models::ical::EventStatus::Cancelled) => "CANCELLED".to_string(),
|
||||
None => "CONFIRMED".to_string(), // Default status
|
||||
};
|
||||
|
||||
let class_str = match original_event.class {
|
||||
crate::services::calendar_service::EventClass::Public => "PUBLIC".to_string(),
|
||||
crate::services::calendar_service::EventClass::Private => "PRIVATE".to_string(),
|
||||
crate::services::calendar_service::EventClass::Confidential => "CONFIDENTIAL".to_string(),
|
||||
Some(crate::models::ical::EventClass::Public) => "PUBLIC".to_string(),
|
||||
Some(crate::models::ical::EventClass::Private) => "PRIVATE".to_string(),
|
||||
Some(crate::models::ical::EventClass::Confidential) => "CONFIDENTIAL".to_string(),
|
||||
None => "PUBLIC".to_string(), // Default class
|
||||
};
|
||||
|
||||
// Convert reminders to string format
|
||||
let reminder_str = if !original_event.reminders.is_empty() {
|
||||
format!("{}", original_event.reminders[0].minutes_before)
|
||||
let reminder_str = if !original_event.alarms.is_empty() {
|
||||
// Convert from VAlarm to minutes before
|
||||
"15".to_string() // TODO: Convert VAlarm trigger to minutes
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
// Handle recurrence (keep existing)
|
||||
let recurrence_str = original_event.recurrence_rule.unwrap_or_default();
|
||||
let recurrence_str = original_event.rrule.unwrap_or_default();
|
||||
let recurrence_days = vec![false; 7]; // Default - could be enhanced to parse existing recurrence
|
||||
|
||||
match calendar_service.update_event(
|
||||
&token,
|
||||
&password,
|
||||
original_event.uid,
|
||||
backend_uid,
|
||||
original_event.summary.unwrap_or_default(),
|
||||
original_event.description.unwrap_or_default(),
|
||||
start_date,
|
||||
@@ -418,13 +449,16 @@ pub fn App() -> Html {
|
||||
status_str,
|
||||
class_str,
|
||||
original_event.priority,
|
||||
original_event.organizer.unwrap_or_default(),
|
||||
original_event.attendees.join(","),
|
||||
original_event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
|
||||
original_event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(","),
|
||||
original_event.categories.join(","),
|
||||
reminder_str,
|
||||
recurrence_str,
|
||||
recurrence_days,
|
||||
original_event.calendar_path
|
||||
original_event.calendar_path,
|
||||
original_event.exdate.clone(),
|
||||
if preserve_rrule { Some("update_series".to_string()) } else { None },
|
||||
until_date
|
||||
).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Event updated successfully".into());
|
||||
@@ -502,10 +536,12 @@ pub fn App() -> Html {
|
||||
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.iter().map(|c| c.to_string()).collect::<Vec<_>>()}
|
||||
available_colors={(*available_colors).clone()}
|
||||
on_calendar_context_menu={on_calendar_context_menu}
|
||||
current_view={(*current_view).clone()}
|
||||
on_view_change={on_view_change}
|
||||
current_theme={(*current_theme).clone()}
|
||||
on_theme_change={on_theme_change}
|
||||
/>
|
||||
<main class="app-main">
|
||||
<RouteHandler
|
||||
@@ -688,11 +724,11 @@ pub fn App() -> Html {
|
||||
};
|
||||
|
||||
// Get the occurrence date from the clicked event
|
||||
let occurrence_date = Some(event.start.date_naive().format("%Y-%m-%d").to_string());
|
||||
let occurrence_date = Some(event.dtstart.date_naive().format("%Y-%m-%d").to_string());
|
||||
|
||||
web_sys::console::log_1(&format!("🔄 Delete action: {}", action_str).into());
|
||||
web_sys::console::log_1(&format!("🔄 Event UID: {}", event.uid).into());
|
||||
web_sys::console::log_1(&format!("🔄 Event start: {}", event.start).into());
|
||||
web_sys::console::log_1(&format!("🔄 Event start: {}", event.dtstart).into());
|
||||
web_sys::console::log_1(&format!("🔄 Occurrence date: {:?}", occurrence_date).into());
|
||||
|
||||
match calendar_service.delete_event(
|
||||
@@ -759,7 +795,7 @@ pub fn App() -> Html {
|
||||
let auth_token = auth_token.clone();
|
||||
let create_event_modal_open = create_event_modal_open.clone();
|
||||
let event_context_menu_event = event_context_menu_event.clone();
|
||||
move |(original_event, updated_data): (CalendarEvent, EventCreationData)| {
|
||||
move |(original_event, updated_data): (VEvent, EventCreationData)| {
|
||||
web_sys::console::log_1(&format!("Updating event: {:?}", updated_data).into());
|
||||
create_event_modal_open.set(false);
|
||||
event_context_menu_event.set(None);
|
||||
@@ -915,7 +951,10 @@ pub fn App() -> Html {
|
||||
reminder_str,
|
||||
recurrence_str,
|
||||
updated_data.recurrence_days,
|
||||
updated_data.selected_calendar
|
||||
updated_data.selected_calendar,
|
||||
original_event.exdate.clone(),
|
||||
Some("update_series".to_string()), // This is for event edit modal, preserve original RRULE
|
||||
None // No until_date for edit modal
|
||||
).await {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Event updated successfully".into());
|
||||
|
||||
@@ -24,7 +24,7 @@ pub struct CalendarProps {
|
||||
#[prop_or_default]
|
||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||
#[prop_or_default]
|
||||
pub on_event_update_request: Option<Callback<(CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime)>>,
|
||||
pub on_event_update_request: Option<Callback<(CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)>>,
|
||||
#[prop_or_default]
|
||||
pub context_menus_open: bool,
|
||||
}
|
||||
@@ -194,9 +194,9 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
// Handle drag-to-move event
|
||||
let on_event_update = {
|
||||
let on_event_update_request = props.on_event_update_request.clone();
|
||||
Callback::from(move |(event, new_start, new_end): (CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime)| {
|
||||
Callback::from(move |(event, new_start, new_end, preserve_rrule, until_date): (CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)| {
|
||||
if let Some(callback) = &on_event_update_request {
|
||||
callback.emit((event, new_start, new_end));
|
||||
callback.emit((event, new_start, new_end, preserve_rrule, until_date));
|
||||
}
|
||||
})
|
||||
};
|
||||
@@ -250,6 +250,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
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)}
|
||||
on_create_event_request={props.on_create_event_request.clone()}
|
||||
on_event_update={Some(on_event_update)}
|
||||
context_menus_open={props.context_menus_open}
|
||||
time_increment={*time_increment}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::{HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement};
|
||||
use chrono::{NaiveDate, NaiveTime};
|
||||
use crate::services::calendar_service::{CalendarInfo, CalendarEvent};
|
||||
use chrono::{NaiveDate, NaiveTime, Local, TimeZone, Utc};
|
||||
use crate::services::calendar_service::CalendarInfo;
|
||||
use crate::models::ical::VEvent;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CreateEventModalProps {
|
||||
pub is_open: bool,
|
||||
pub selected_date: Option<NaiveDate>,
|
||||
pub event_to_edit: Option<CalendarEvent>,
|
||||
pub event_to_edit: Option<VEvent>,
|
||||
pub on_close: Callback<()>,
|
||||
pub on_create: Callback<EventCreationData>,
|
||||
pub on_update: Callback<(CalendarEvent, EventCreationData)>, // (original_event, updated_data)
|
||||
pub on_update: Callback<(VEvent, EventCreationData)>, // (original_event, updated_data)
|
||||
pub available_calendars: Vec<CalendarInfo>,
|
||||
#[prop_or_default]
|
||||
pub initial_start_time: Option<NaiveTime>,
|
||||
@@ -31,15 +32,6 @@ impl Default for EventStatus {
|
||||
}
|
||||
}
|
||||
|
||||
impl EventStatus {
|
||||
pub fn from_service_status(status: &crate::services::calendar_service::EventStatus) -> Self {
|
||||
match status {
|
||||
crate::services::calendar_service::EventStatus::Tentative => EventStatus::Tentative,
|
||||
crate::services::calendar_service::EventStatus::Confirmed => EventStatus::Confirmed,
|
||||
crate::services::calendar_service::EventStatus::Cancelled => EventStatus::Cancelled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum EventClass {
|
||||
@@ -54,15 +46,6 @@ impl Default for EventClass {
|
||||
}
|
||||
}
|
||||
|
||||
impl EventClass {
|
||||
pub fn from_service_class(class: &crate::services::calendar_service::EventClass) -> Self {
|
||||
match class {
|
||||
crate::services::calendar_service::EventClass::Public => EventClass::Public,
|
||||
crate::services::calendar_service::EventClass::Private => EventClass::Private,
|
||||
crate::services::calendar_service::EventClass::Confidential => EventClass::Confidential,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum ReminderType {
|
||||
@@ -161,32 +144,98 @@ impl Default for EventCreationData {
|
||||
}
|
||||
|
||||
impl EventCreationData {
|
||||
pub fn from_calendar_event(event: &CalendarEvent) -> Self {
|
||||
// Convert CalendarEvent to EventCreationData for editing
|
||||
pub fn to_create_event_params(&self) -> (String, String, String, String, String, String, String, bool, String, String, Option<u8>, String, String, String, String, String, Vec<bool>, Option<String>) {
|
||||
// Convert local date/time to UTC
|
||||
let start_local = Local.from_local_datetime(&self.start_date.and_time(self.start_time)).single()
|
||||
.unwrap_or_else(|| Local::now());
|
||||
let end_local = Local.from_local_datetime(&self.end_date.and_time(self.end_time)).single()
|
||||
.unwrap_or_else(|| Local::now());
|
||||
|
||||
let start_utc = start_local.with_timezone(&Utc);
|
||||
let end_utc = end_local.with_timezone(&Utc);
|
||||
|
||||
(
|
||||
self.title.clone(),
|
||||
self.description.clone(),
|
||||
start_utc.format("%Y-%m-%d").to_string(),
|
||||
start_utc.format("%H:%M").to_string(),
|
||||
end_utc.format("%Y-%m-%d").to_string(),
|
||||
end_utc.format("%H:%M").to_string(),
|
||||
self.location.clone(),
|
||||
self.all_day,
|
||||
match self.status {
|
||||
EventStatus::Tentative => "TENTATIVE".to_string(),
|
||||
EventStatus::Confirmed => "CONFIRMED".to_string(),
|
||||
EventStatus::Cancelled => "CANCELLED".to_string(),
|
||||
},
|
||||
match self.class {
|
||||
EventClass::Public => "PUBLIC".to_string(),
|
||||
EventClass::Private => "PRIVATE".to_string(),
|
||||
EventClass::Confidential => "CONFIDENTIAL".to_string(),
|
||||
},
|
||||
self.priority,
|
||||
self.organizer.clone(),
|
||||
self.attendees.clone(),
|
||||
self.categories.clone(),
|
||||
match self.reminder {
|
||||
ReminderType::None => "".to_string(),
|
||||
ReminderType::Minutes15 => "15".to_string(),
|
||||
ReminderType::Minutes30 => "30".to_string(),
|
||||
ReminderType::Hour1 => "60".to_string(),
|
||||
ReminderType::Hours2 => "120".to_string(),
|
||||
ReminderType::Day1 => "1440".to_string(),
|
||||
ReminderType::Days2 => "2880".to_string(),
|
||||
ReminderType::Week1 => "10080".to_string(),
|
||||
},
|
||||
match self.recurrence {
|
||||
RecurrenceType::None => "".to_string(),
|
||||
RecurrenceType::Daily => "DAILY".to_string(),
|
||||
RecurrenceType::Weekly => "WEEKLY".to_string(),
|
||||
RecurrenceType::Monthly => "MONTHLY".to_string(),
|
||||
RecurrenceType::Yearly => "YEARLY".to_string(),
|
||||
},
|
||||
self.recurrence_days.clone(),
|
||||
self.selected_calendar.clone()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventCreationData {
|
||||
pub fn from_calendar_event(event: &VEvent) -> Self {
|
||||
// Convert VEvent to EventCreationData for editing
|
||||
// All events (including temporary drag events) now have proper UTC times
|
||||
// Convert to local time for display in the modal
|
||||
|
||||
Self {
|
||||
title: event.summary.clone().unwrap_or_default(),
|
||||
description: event.description.clone().unwrap_or_default(),
|
||||
start_date: event.start.with_timezone(&chrono::Local).date_naive(),
|
||||
start_time: event.start.with_timezone(&chrono::Local).time(),
|
||||
end_date: event.end.as_ref().map(|e| e.with_timezone(&chrono::Local).date_naive()).unwrap_or(event.start.with_timezone(&chrono::Local).date_naive()),
|
||||
end_time: event.end.as_ref().map(|e| e.with_timezone(&chrono::Local).time()).unwrap_or(event.start.with_timezone(&chrono::Local).time()),
|
||||
start_date: event.dtstart.with_timezone(&chrono::Local).date_naive(),
|
||||
start_time: event.dtstart.with_timezone(&chrono::Local).time(),
|
||||
end_date: event.dtend.as_ref().map(|e| e.with_timezone(&chrono::Local).date_naive()).unwrap_or(event.dtstart.with_timezone(&chrono::Local).date_naive()),
|
||||
end_time: event.dtend.as_ref().map(|e| e.with_timezone(&chrono::Local).time()).unwrap_or(event.dtstart.with_timezone(&chrono::Local).time()),
|
||||
location: event.location.clone().unwrap_or_default(),
|
||||
all_day: event.all_day,
|
||||
status: EventStatus::from_service_status(&event.status),
|
||||
class: EventClass::from_service_class(&event.class),
|
||||
status: event.status.as_ref().map(|s| match s {
|
||||
crate::models::ical::EventStatus::Tentative => EventStatus::Tentative,
|
||||
crate::models::ical::EventStatus::Confirmed => EventStatus::Confirmed,
|
||||
crate::models::ical::EventStatus::Cancelled => EventStatus::Cancelled,
|
||||
}).unwrap_or(EventStatus::Confirmed),
|
||||
class: event.class.as_ref().map(|c| match c {
|
||||
crate::models::ical::EventClass::Public => EventClass::Public,
|
||||
crate::models::ical::EventClass::Private => EventClass::Private,
|
||||
crate::models::ical::EventClass::Confidential => EventClass::Confidential,
|
||||
}).unwrap_or(EventClass::Public),
|
||||
priority: event.priority,
|
||||
organizer: event.organizer.clone().unwrap_or_default(),
|
||||
attendees: event.attendees.join(", "),
|
||||
organizer: event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
|
||||
attendees: event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(", "),
|
||||
categories: event.categories.join(", "),
|
||||
reminder: ReminderType::default(), // TODO: Convert from event reminders
|
||||
recurrence: RecurrenceType::from_rrule(event.recurrence_rule.as_deref()),
|
||||
recurrence: RecurrenceType::from_rrule(event.rrule.as_deref()),
|
||||
recurrence_days: vec![false; 7], // TODO: Parse from RRULE
|
||||
selected_calendar: event.calendar_path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[function_component(CreateEventModal)]
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod create_event_modal;
|
||||
pub mod sidebar;
|
||||
pub mod calendar_list_item;
|
||||
pub mod route_handler;
|
||||
pub mod recurring_edit_modal;
|
||||
|
||||
pub use login::Login;
|
||||
pub use calendar::Calendar;
|
||||
@@ -24,6 +25,7 @@ pub use context_menu::ContextMenu;
|
||||
pub use event_context_menu::{EventContextMenu, DeleteAction};
|
||||
pub use calendar_context_menu::CalendarContextMenu;
|
||||
pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
|
||||
pub use sidebar::{Sidebar, ViewMode};
|
||||
pub use sidebar::{Sidebar, ViewMode, Theme};
|
||||
pub use calendar_list_item::CalendarListItem;
|
||||
pub use route_handler::RouteHandler;
|
||||
pub use route_handler::RouteHandler;
|
||||
pub use recurring_edit_modal::{RecurringEditModal, RecurringEditAction};
|
||||
93
src/components/recurring_edit_modal.rs
Normal file
93
src/components/recurring_edit_modal.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::NaiveDateTime;
|
||||
use crate::services::calendar_service::CalendarEvent;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum RecurringEditAction {
|
||||
ThisEvent,
|
||||
FutureEvents,
|
||||
AllEvents,
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct RecurringEditModalProps {
|
||||
pub show: bool,
|
||||
pub event: CalendarEvent,
|
||||
pub new_start: NaiveDateTime,
|
||||
pub new_end: NaiveDateTime,
|
||||
pub on_choice: Callback<RecurringEditAction>,
|
||||
pub on_cancel: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(RecurringEditModal)]
|
||||
pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
|
||||
if !props.show {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
let event_title = props.event.summary.as_ref().map(|s| s.as_str()).unwrap_or("Untitled Event");
|
||||
|
||||
let on_this_event = {
|
||||
let on_choice = props.on_choice.clone();
|
||||
Callback::from(move |_| {
|
||||
on_choice.emit(RecurringEditAction::ThisEvent);
|
||||
})
|
||||
};
|
||||
|
||||
let on_future_events = {
|
||||
let on_choice = props.on_choice.clone();
|
||||
Callback::from(move |_| {
|
||||
on_choice.emit(RecurringEditAction::FutureEvents);
|
||||
})
|
||||
};
|
||||
|
||||
let on_all_events = {
|
||||
let on_choice = props.on_choice.clone();
|
||||
Callback::from(move |_| {
|
||||
on_choice.emit(RecurringEditAction::AllEvents);
|
||||
})
|
||||
};
|
||||
|
||||
let on_cancel = {
|
||||
let on_cancel = props.on_cancel.clone();
|
||||
Callback::from(move |_| {
|
||||
on_cancel.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="modal-backdrop">
|
||||
<div class="modal-content recurring-edit-modal">
|
||||
<div class="modal-header">
|
||||
<h3>{"Edit Recurring Event"}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{format!("You're modifying \"{}\" which is part of a recurring series.", event_title)}</p>
|
||||
<p>{"How would you like to apply this change?"}</p>
|
||||
|
||||
<div class="recurring-edit-options">
|
||||
<button class="btn btn-primary recurring-option" onclick={on_this_event}>
|
||||
<div class="option-title">{"This event only"}</div>
|
||||
<div class="option-description">{"Change only this occurrence"}</div>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-primary recurring-option" onclick={on_future_events}>
|
||||
<div class="option-title">{"This and future events"}</div>
|
||||
<div class="option-description">{"Change this occurrence and all future occurrences"}</div>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-primary recurring-option" onclick={on_all_events}>
|
||||
<div class="option-title">{"All events in series"}</div>
|
||||
<div class="option-description">{"Change all occurrences in the series"}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick={on_cancel}>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ pub struct RouteHandlerProps {
|
||||
#[prop_or_default]
|
||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||
#[prop_or_default]
|
||||
pub on_event_update_request: Option<Callback<(CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime)>>,
|
||||
pub on_event_update_request: Option<Callback<(CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)>>,
|
||||
#[prop_or_default]
|
||||
pub context_menus_open: bool,
|
||||
}
|
||||
@@ -105,7 +105,7 @@ pub struct CalendarViewProps {
|
||||
#[prop_or_default]
|
||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||
#[prop_or_default]
|
||||
pub on_event_update_request: Option<Callback<(CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime)>>,
|
||||
pub on_event_update_request: Option<Callback<(CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)>>,
|
||||
#[prop_or_default]
|
||||
pub context_menus_open: bool,
|
||||
}
|
||||
|
||||
@@ -20,6 +20,59 @@ pub enum ViewMode {
|
||||
Week,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum Theme {
|
||||
Default,
|
||||
Ocean,
|
||||
Forest,
|
||||
Sunset,
|
||||
Purple,
|
||||
Dark,
|
||||
Rose,
|
||||
Mint,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Theme::Default => "Default",
|
||||
Theme::Ocean => "Ocean",
|
||||
Theme::Forest => "Forest",
|
||||
Theme::Sunset => "Sunset",
|
||||
Theme::Purple => "Purple",
|
||||
Theme::Dark => "Dark",
|
||||
Theme::Rose => "Rose",
|
||||
Theme::Mint => "Mint",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &'static str {
|
||||
match self {
|
||||
Theme::Default => "default",
|
||||
Theme::Ocean => "ocean",
|
||||
Theme::Forest => "forest",
|
||||
Theme::Sunset => "sunset",
|
||||
Theme::Purple => "purple",
|
||||
Theme::Dark => "dark",
|
||||
Theme::Rose => "rose",
|
||||
Theme::Mint => "mint",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_value(value: &str) -> Self {
|
||||
match value {
|
||||
"ocean" => Theme::Ocean,
|
||||
"forest" => Theme::Forest,
|
||||
"sunset" => Theme::Sunset,
|
||||
"purple" => Theme::Purple,
|
||||
"dark" => Theme::Dark,
|
||||
"rose" => Theme::Rose,
|
||||
"mint" => Theme::Mint,
|
||||
_ => Theme::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ViewMode {
|
||||
fn default() -> Self {
|
||||
ViewMode::Month
|
||||
@@ -38,6 +91,8 @@ pub struct SidebarProps {
|
||||
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
||||
pub current_view: ViewMode,
|
||||
pub on_view_change: Callback<ViewMode>,
|
||||
pub current_theme: Theme,
|
||||
pub on_theme_change: Callback<Theme>,
|
||||
}
|
||||
|
||||
#[function_component(Sidebar)]
|
||||
@@ -57,6 +112,18 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
let on_theme_change = {
|
||||
let on_theme_change = props.on_theme_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_dyn_into::<HtmlSelectElement>();
|
||||
if let Some(select) = target {
|
||||
let value = select.value();
|
||||
let new_theme = Theme::from_value(&value);
|
||||
on_theme_change.emit(new_theme);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<aside class="app-sidebar">
|
||||
<div class="sidebar-header">
|
||||
@@ -120,6 +187,19 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="theme-selector">
|
||||
<select class="theme-selector-dropdown" onchange={on_theme_change}>
|
||||
<option value="default" selected={matches!(props.current_theme, Theme::Default)}>{"🎨 Default"}</option>
|
||||
<option value="ocean" selected={matches!(props.current_theme, Theme::Ocean)}>{"🌊 Ocean"}</option>
|
||||
<option value="forest" selected={matches!(props.current_theme, Theme::Forest)}>{"🌲 Forest"}</option>
|
||||
<option value="sunset" selected={matches!(props.current_theme, Theme::Sunset)}>{"🌅 Sunset"}</option>
|
||||
<option value="purple" selected={matches!(props.current_theme, Theme::Purple)}>{"💜 Purple"}</option>
|
||||
<option value="dark" selected={matches!(props.current_theme, Theme::Dark)}>{"🌙 Dark"}</option>
|
||||
<option value="rose" selected={matches!(props.current_theme, Theme::Rose)}>{"🌹 Rose"}</option>
|
||||
<option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"🍃 Mint"}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -3,6 +3,7 @@ use chrono::{Datelike, NaiveDate, Duration, Weekday, Local, Timelike, NaiveDateT
|
||||
use std::collections::HashMap;
|
||||
use web_sys::MouseEvent;
|
||||
use crate::services::calendar_service::{CalendarEvent, UserInfo};
|
||||
use crate::components::{RecurringEditModal, RecurringEditAction, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct WeekViewProps {
|
||||
@@ -21,7 +22,9 @@ pub struct WeekViewProps {
|
||||
#[prop_or_default]
|
||||
pub on_create_event: Option<Callback<(NaiveDate, NaiveDateTime, NaiveDateTime)>>,
|
||||
#[prop_or_default]
|
||||
pub on_event_update: Option<Callback<(CalendarEvent, NaiveDateTime, NaiveDateTime)>>,
|
||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||
#[prop_or_default]
|
||||
pub on_event_update: Option<Callback<(CalendarEvent, NaiveDateTime, NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)>>,
|
||||
#[prop_or_default]
|
||||
pub context_menus_open: bool,
|
||||
#[prop_or_default]
|
||||
@@ -56,6 +59,16 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
|
||||
// Drag state for event creation
|
||||
let drag_state = use_state(|| None::<DragState>);
|
||||
|
||||
// State for recurring event edit modal
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct PendingRecurringEdit {
|
||||
event: CalendarEvent,
|
||||
new_start: NaiveDateTime,
|
||||
new_end: NaiveDateTime,
|
||||
}
|
||||
|
||||
let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>);
|
||||
|
||||
// Helper function to get calendar color for an event
|
||||
let get_event_color = |event: &CalendarEvent| -> String {
|
||||
@@ -85,6 +98,194 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
|
||||
// Add the final midnight boundary to show where the day ends
|
||||
time_labels.push("12 AM".to_string());
|
||||
|
||||
|
||||
// Handlers for recurring event modification modal
|
||||
let on_recurring_choice = {
|
||||
let pending_recurring_edit = pending_recurring_edit.clone();
|
||||
let on_event_update = props.on_event_update.clone();
|
||||
let on_create_event = props.on_create_event.clone();
|
||||
let on_create_event_request = props.on_create_event_request.clone();
|
||||
let events = props.events.clone();
|
||||
Callback::from(move |action: RecurringEditAction| {
|
||||
if let Some(edit) = (*pending_recurring_edit).clone() {
|
||||
match action {
|
||||
RecurringEditAction::ThisEvent => {
|
||||
// Create exception for this occurrence only
|
||||
|
||||
// 1. First, add EXDATE to the original series to exclude this occurrence
|
||||
if let Some(update_callback) = &on_event_update {
|
||||
let mut updated_series = edit.event.clone();
|
||||
updated_series.exception_dates.push(edit.event.start);
|
||||
|
||||
// Keep the original series times unchanged - we're only adding EXDATE
|
||||
let original_start = edit.event.start.with_timezone(&chrono::Local).naive_local();
|
||||
let original_end = edit.event.end.unwrap_or(edit.event.start).with_timezone(&chrono::Local).naive_local();
|
||||
|
||||
web_sys::console::log_1(&format!("📅 Adding EXDATE {} to series '{}'",
|
||||
edit.event.start.format("%Y-%m-%d %H:%M:%S UTC"),
|
||||
edit.event.summary.as_deref().unwrap_or("Untitled")
|
||||
).into());
|
||||
|
||||
// Update the original series with the exception (times unchanged)
|
||||
update_callback.emit((updated_series, original_start, original_end, true, None)); // preserve_rrule = true for EXDATE, no until_date
|
||||
}
|
||||
|
||||
// 2. Then create the new single event using the create callback
|
||||
if let Some(create_callback) = &on_create_event_request {
|
||||
// Convert to EventCreationData for single event
|
||||
let event_data = EventCreationData {
|
||||
title: edit.event.summary.clone().unwrap_or_default(),
|
||||
description: edit.event.description.clone().unwrap_or_default(),
|
||||
start_date: edit.new_start.date(),
|
||||
start_time: edit.new_start.time(),
|
||||
end_date: edit.new_end.date(),
|
||||
end_time: edit.new_end.time(),
|
||||
location: edit.event.location.clone().unwrap_or_default(),
|
||||
all_day: edit.event.all_day,
|
||||
status: EventStatus::Confirmed,
|
||||
class: EventClass::Public,
|
||||
priority: edit.event.priority,
|
||||
organizer: edit.event.organizer.clone().unwrap_or_default(),
|
||||
attendees: edit.event.attendees.join(","),
|
||||
categories: edit.event.categories.join(","),
|
||||
reminder: ReminderType::None,
|
||||
recurrence: RecurrenceType::None, // Single event, no recurrence
|
||||
recurrence_days: vec![false; 7],
|
||||
selected_calendar: edit.event.calendar_path.clone(),
|
||||
};
|
||||
|
||||
// Create the single event
|
||||
create_callback.emit(event_data);
|
||||
}
|
||||
},
|
||||
RecurringEditAction::FutureEvents => {
|
||||
// Split series and modify future events
|
||||
// 1. Update original series to set UNTIL to end before this occurrence
|
||||
if let Some(update_callback) = &on_event_update {
|
||||
// Find the original series event (not the occurrence)
|
||||
// UIDs like "uuid-timestamp" need to split on the last hyphen, not the first
|
||||
let base_uid = if let Some(last_hyphen_pos) = edit.event.uid.rfind('-') {
|
||||
let suffix = &edit.event.uid[last_hyphen_pos + 1..];
|
||||
// Check if suffix is numeric (timestamp), if so remove it
|
||||
if suffix.chars().all(|c| c.is_numeric()) {
|
||||
edit.event.uid[..last_hyphen_pos].to_string()
|
||||
} else {
|
||||
edit.event.uid.clone()
|
||||
}
|
||||
} else {
|
||||
edit.event.uid.clone()
|
||||
};
|
||||
|
||||
web_sys::console::log_1(&format!("🔍 Looking for original series: '{}' from occurrence: '{}'", base_uid, edit.event.uid).into());
|
||||
|
||||
// Find the original series event by searching for the base UID
|
||||
let mut original_series = None;
|
||||
for events_list in events.values() {
|
||||
for event in events_list {
|
||||
if event.uid == base_uid {
|
||||
original_series = Some(event.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if original_series.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut original_series = match original_series {
|
||||
Some(series) => {
|
||||
web_sys::console::log_1(&format!("✅ Found original series: '{}'", series.uid).into());
|
||||
series
|
||||
},
|
||||
None => {
|
||||
web_sys::console::log_1(&format!("⚠️ Could not find original series '{}', using occurrence but fixing UID", base_uid).into());
|
||||
let mut fallback_event = edit.event.clone();
|
||||
// Ensure the UID is the base UID, not the occurrence UID
|
||||
fallback_event.uid = base_uid.clone();
|
||||
fallback_event
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate the day before this occurrence for UNTIL clause
|
||||
let until_date = edit.event.start.date_naive() - chrono::Duration::days(1);
|
||||
let until_datetime = until_date.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
|
||||
let until_utc = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(until_datetime, chrono::Utc);
|
||||
|
||||
web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",
|
||||
until_utc.format("%Y-%m-%d %H:%M:%S UTC"),
|
||||
edit.event.start.format("%Y-%m-%d %H:%M:%S UTC")).into());
|
||||
|
||||
// Use the original series start time (not the dragged occurrence time)
|
||||
let original_start = original_series.start.with_timezone(&chrono::Local).naive_local();
|
||||
let original_end = original_series.end.unwrap_or(original_series.start).with_timezone(&chrono::Local).naive_local();
|
||||
|
||||
// Send until_date to backend instead of modifying RRULE on frontend
|
||||
update_callback.emit((original_series, original_start, original_end, true, Some(until_utc))); // preserve_rrule = true, backend will add UNTIL
|
||||
}
|
||||
|
||||
// 2. Create new series starting from this occurrence with modified times
|
||||
if let Some(create_callback) = &on_create_event_request {
|
||||
// Convert the recurring event to EventCreationData for the create callback
|
||||
let event_data = EventCreationData {
|
||||
title: edit.event.summary.clone().unwrap_or_default(),
|
||||
description: edit.event.description.clone().unwrap_or_default(),
|
||||
start_date: edit.new_start.date(),
|
||||
start_time: edit.new_start.time(),
|
||||
end_date: edit.new_end.date(),
|
||||
end_time: edit.new_end.time(),
|
||||
location: edit.event.location.clone().unwrap_or_default(),
|
||||
all_day: edit.event.all_day,
|
||||
status: EventStatus::Confirmed, // Default status
|
||||
class: EventClass::Public, // Default class
|
||||
priority: edit.event.priority,
|
||||
organizer: edit.event.organizer.clone().unwrap_or_default(),
|
||||
attendees: edit.event.attendees.join(","),
|
||||
categories: edit.event.categories.join(","),
|
||||
reminder: ReminderType::None, // Default reminder
|
||||
recurrence: if let Some(rrule) = &edit.event.recurrence_rule {
|
||||
if rrule.contains("FREQ=DAILY") {
|
||||
RecurrenceType::Daily
|
||||
} else if rrule.contains("FREQ=WEEKLY") {
|
||||
RecurrenceType::Weekly
|
||||
} else if rrule.contains("FREQ=MONTHLY") {
|
||||
RecurrenceType::Monthly
|
||||
} else if rrule.contains("FREQ=YEARLY") {
|
||||
RecurrenceType::Yearly
|
||||
} else {
|
||||
RecurrenceType::None
|
||||
}
|
||||
} else {
|
||||
RecurrenceType::None
|
||||
},
|
||||
recurrence_days: vec![false; 7], // Default days
|
||||
selected_calendar: edit.event.calendar_path.clone(),
|
||||
};
|
||||
|
||||
// Create the new series
|
||||
create_callback.emit(event_data);
|
||||
}
|
||||
},
|
||||
RecurringEditAction::AllEvents => {
|
||||
// Modify the entire series
|
||||
let series_event = edit.event.clone();
|
||||
|
||||
if let Some(callback) = &on_event_update {
|
||||
callback.emit((series_event, edit.new_start, edit.new_end, true, None)); // Regular drag operation - preserve RRULE, no until_date
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
pending_recurring_edit.set(None);
|
||||
})
|
||||
};
|
||||
|
||||
let on_recurring_cancel = {
|
||||
let pending_recurring_edit = pending_recurring_edit.clone();
|
||||
Callback::from(move |_| {
|
||||
pending_recurring_edit.set(None);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="week-view-container">
|
||||
@@ -204,6 +405,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
let drag_state = drag_state_clone.clone();
|
||||
let on_create_event = props.on_create_event.clone();
|
||||
let on_event_update = props.on_event_update.clone();
|
||||
let pending_recurring_edit = pending_recurring_edit.clone();
|
||||
let time_increment = props.time_increment;
|
||||
Callback::from(move |_e: MouseEvent| {
|
||||
if let Some(current_drag) = (*drag_state).clone() {
|
||||
@@ -252,8 +454,19 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
||||
let new_end_datetime = new_start_datetime + original_duration;
|
||||
|
||||
if let Some(callback) = &on_event_update {
|
||||
callback.emit((event.clone(), new_start_datetime, new_end_datetime));
|
||||
// Check if this is a recurring event
|
||||
if event.recurrence_rule.is_some() {
|
||||
// Show modal for recurring event modification
|
||||
pending_recurring_edit.set(Some(PendingRecurringEdit {
|
||||
event: event.clone(),
|
||||
new_start: new_start_datetime,
|
||||
new_end: new_end_datetime,
|
||||
}));
|
||||
} else {
|
||||
// Regular event - proceed with update
|
||||
if let Some(callback) = &on_event_update {
|
||||
callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None)); // Regular drag operation - preserve RRULE, no until_date
|
||||
}
|
||||
}
|
||||
},
|
||||
DragType::ResizeEventStart(event) => {
|
||||
@@ -277,8 +490,19 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
original_end
|
||||
};
|
||||
|
||||
if let Some(callback) = &on_event_update {
|
||||
callback.emit((event.clone(), new_start_datetime, new_end_datetime));
|
||||
// Check if this is a recurring event
|
||||
if event.recurrence_rule.is_some() {
|
||||
// Show modal for recurring event modification
|
||||
pending_recurring_edit.set(Some(PendingRecurringEdit {
|
||||
event: event.clone(),
|
||||
new_start: new_start_datetime,
|
||||
new_end: new_end_datetime,
|
||||
}));
|
||||
} else {
|
||||
// Regular event - proceed with update
|
||||
if let Some(callback) = &on_event_update {
|
||||
callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None)); // Regular drag operation - preserve RRULE, no until_date
|
||||
}
|
||||
}
|
||||
},
|
||||
DragType::ResizeEventEnd(event) => {
|
||||
@@ -297,8 +521,19 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
original_start
|
||||
};
|
||||
|
||||
if let Some(callback) = &on_event_update {
|
||||
callback.emit((event.clone(), new_start_datetime, new_end_datetime));
|
||||
// Check if this is a recurring event
|
||||
if event.recurrence_rule.is_some() {
|
||||
// Show modal for recurring event modification
|
||||
pending_recurring_edit.set(Some(PendingRecurringEdit {
|
||||
event: event.clone(),
|
||||
new_start: new_start_datetime,
|
||||
new_end: new_end_datetime,
|
||||
}));
|
||||
} else {
|
||||
// Regular event - proceed with update
|
||||
if let Some(callback) = &on_event_update {
|
||||
callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None)); // Regular drag operation - preserve RRULE, no until_date
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -695,6 +930,20 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Recurring event modification modal
|
||||
if let Some(edit) = (*pending_recurring_edit).clone() {
|
||||
<RecurringEditModal
|
||||
show={true}
|
||||
event={edit.event}
|
||||
new_start={edit.new_start}
|
||||
new_end={edit.new_end}
|
||||
on_choice={on_recurring_choice}
|
||||
on_cancel={on_recurring_cancel}
|
||||
/>
|
||||
} else {
|
||||
<></>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -773,6 +1022,7 @@ fn calculate_event_position(event: &CalendarEvent, date: NaiveDate) -> (f32, f32
|
||||
let start_minute = local_start.minute() as f32;
|
||||
let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour
|
||||
|
||||
|
||||
// Calculate duration and height
|
||||
let duration_pixels = if let Some(end) = event.end {
|
||||
let local_end = end.with_timezone(&Local);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
mod app;
|
||||
mod auth;
|
||||
mod components;
|
||||
mod models;
|
||||
mod services;
|
||||
|
||||
use app::App;
|
||||
|
||||
601
src/models/ical.rs
Normal file
601
src/models/ical.rs
Normal file
@@ -0,0 +1,601 @@
|
||||
// RFC 5545 Compliant iCalendar Data Structures
|
||||
// This file contains updated structures that fully comply with RFC 5545 iCalendar specification
|
||||
|
||||
use chrono::{DateTime, Utc, NaiveDate, Duration};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ==================== CALENDAR OBJECT (VCALENDAR) ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ICalendarObject {
|
||||
// Required calendar properties
|
||||
pub prodid: String, // Product identifier (PRODID)
|
||||
pub version: String, // Version (typically "2.0")
|
||||
|
||||
// Optional calendar properties
|
||||
pub calscale: Option<String>, // Calendar scale (CALSCALE) - default "GREGORIAN"
|
||||
pub method: Option<String>, // Method (METHOD)
|
||||
|
||||
// Components
|
||||
pub events: Vec<VEvent>, // VEVENT components
|
||||
pub todos: Vec<VTodo>, // VTODO components
|
||||
pub journals: Vec<VJournal>, // VJOURNAL components
|
||||
pub freebusys: Vec<VFreeBusy>, // VFREEBUSY components
|
||||
pub timezones: Vec<VTimeZone>, // VTIMEZONE components
|
||||
}
|
||||
|
||||
// ==================== VEVENT COMPONENT ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VEvent {
|
||||
// Required properties
|
||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED
|
||||
|
||||
// Optional properties (commonly used)
|
||||
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
||||
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||
pub description: Option<String>, // Description (DESCRIPTION)
|
||||
pub location: Option<String>, // Location (LOCATION)
|
||||
|
||||
// Classification and status
|
||||
pub class: Option<EventClass>, // Classification (CLASS)
|
||||
pub status: Option<EventStatus>, // Status (STATUS)
|
||||
pub transp: Option<TimeTransparency>, // Time transparency (TRANSP)
|
||||
pub priority: Option<u8>, // Priority 0-9 (PRIORITY)
|
||||
|
||||
// People and organization
|
||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||
pub contact: Option<String>, // Contact information (CONTACT)
|
||||
|
||||
// Categorization and relationships
|
||||
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||
pub comment: Option<String>, // Comment (COMMENT)
|
||||
pub resources: Vec<String>, // Resources (RESOURCES)
|
||||
pub related_to: Option<String>, // Related component (RELATED-TO)
|
||||
pub url: Option<String>, // URL (URL)
|
||||
|
||||
// Geographical
|
||||
pub geo: Option<GeographicPosition>, // Geographic position (GEO)
|
||||
|
||||
// Versioning and modification
|
||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||
|
||||
// Recurrence
|
||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||
|
||||
// Alarms and attachments
|
||||
pub alarms: Vec<VAlarm>, // VALARM components
|
||||
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||
|
||||
// CalDAV specific (for implementation)
|
||||
pub etag: Option<String>, // ETag for CalDAV
|
||||
pub href: Option<String>, // Href for CalDAV
|
||||
pub calendar_path: Option<String>, // Calendar path
|
||||
pub all_day: bool, // All-day event flag
|
||||
}
|
||||
|
||||
// ==================== VTODO COMPONENT ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VTodo {
|
||||
// Required properties
|
||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||
|
||||
// Optional date-time properties
|
||||
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||
pub due: Option<DateTime<Utc>>, // Due date-time (DUE)
|
||||
pub duration: Option<Duration>, // Duration (DURATION)
|
||||
pub completed: Option<DateTime<Utc>>, // Completion date-time (COMPLETED)
|
||||
|
||||
// Descriptive properties
|
||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||
pub description: Option<String>, // Description (DESCRIPTION)
|
||||
pub location: Option<String>, // Location (LOCATION)
|
||||
|
||||
// Status and completion
|
||||
pub status: Option<TodoStatus>, // Status (STATUS)
|
||||
pub percent_complete: Option<u8>, // Percent complete 0-100 (PERCENT-COMPLETE)
|
||||
pub priority: Option<u8>, // Priority 0-9 (PRIORITY)
|
||||
|
||||
// People and organization
|
||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||
pub contact: Option<String>, // Contact information (CONTACT)
|
||||
|
||||
// Categorization and relationships
|
||||
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||
pub comment: Option<String>, // Comment (COMMENT)
|
||||
pub resources: Vec<String>, // Resources (RESOURCES)
|
||||
pub related_to: Option<String>, // Related component (RELATED-TO)
|
||||
pub url: Option<String>, // URL (URL)
|
||||
|
||||
// Geographical
|
||||
pub geo: Option<GeographicPosition>, // Geographic position (GEO)
|
||||
|
||||
// Versioning and modification
|
||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||
|
||||
// Recurrence
|
||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||
|
||||
// Alarms and attachments
|
||||
pub alarms: Vec<VAlarm>, // VALARM components
|
||||
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||
}
|
||||
|
||||
// ==================== VJOURNAL COMPONENT ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VJournal {
|
||||
// Required properties
|
||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||
|
||||
// Optional properties
|
||||
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||
pub description: Option<String>, // Description (DESCRIPTION)
|
||||
|
||||
// Classification and status
|
||||
pub class: Option<EventClass>, // Classification (CLASS)
|
||||
pub status: Option<JournalStatus>, // Status (STATUS)
|
||||
|
||||
// People and organization
|
||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||
pub contact: Option<String>, // Contact information (CONTACT)
|
||||
|
||||
// Categorization and relationships
|
||||
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||
pub comment: Option<String>, // Comment (COMMENT)
|
||||
pub related_to: Option<String>, // Related component (RELATED-TO)
|
||||
pub url: Option<String>, // URL (URL)
|
||||
|
||||
// Versioning and modification
|
||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||
|
||||
// Recurrence
|
||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||
|
||||
// Attachments
|
||||
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||
}
|
||||
|
||||
// ==================== VFREEBUSY COMPONENT ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VFreeBusy {
|
||||
// Required properties
|
||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||
|
||||
// Optional properties
|
||||
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
||||
pub duration: Option<Duration>, // Duration (DURATION)
|
||||
|
||||
// Free/busy information
|
||||
pub freebusy: Vec<FreeBusyTime>, // Free/busy periods (FREEBUSY)
|
||||
|
||||
// People and organization
|
||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||
pub attendees: Vec<CalendarUser>, // Attendees (ATTENDEE)
|
||||
pub contact: Option<String>, // Contact information (CONTACT)
|
||||
|
||||
// Additional properties
|
||||
pub comment: Option<String>, // Comment (COMMENT)
|
||||
pub url: Option<String>, // URL (URL)
|
||||
}
|
||||
|
||||
// ==================== VTIMEZONE COMPONENT ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VTimeZone {
|
||||
// Required properties
|
||||
pub tzid: String, // Time zone identifier (TZID) - REQUIRED
|
||||
|
||||
// Optional properties
|
||||
pub tzname: Option<String>, // Time zone name (TZNAME)
|
||||
pub tzurl: Option<String>, // Time zone URL (TZURL)
|
||||
|
||||
// Standard and daylight components
|
||||
pub standard: Vec<TimeZoneComponent>, // Standard time components
|
||||
pub daylight: Vec<TimeZoneComponent>, // Daylight time components
|
||||
|
||||
// Last modified
|
||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TimeZoneComponent {
|
||||
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED
|
||||
pub tzoffsetfrom: String, // UTC offset from (TZOFFSETFROM) - REQUIRED
|
||||
pub tzoffsetto: String, // UTC offset to (TZOFFSETTO) - REQUIRED
|
||||
|
||||
pub tzname: Option<String>, // Time zone name (TZNAME)
|
||||
pub comment: Option<String>, // Comment (COMMENT)
|
||||
|
||||
// Recurrence
|
||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||
}
|
||||
|
||||
// ==================== VALARM COMPONENT ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VAlarm {
|
||||
pub action: AlarmAction, // Action (ACTION) - REQUIRED
|
||||
pub trigger: AlarmTrigger, // Trigger (TRIGGER) - REQUIRED
|
||||
|
||||
// Optional properties (some required based on action)
|
||||
pub description: Option<String>, // Description (DESCRIPTION)
|
||||
pub summary: Option<String>, // Summary (SUMMARY)
|
||||
pub duration: Option<Duration>, // Duration (DURATION)
|
||||
pub repeat: Option<u32>, // Repeat count (REPEAT)
|
||||
pub attendees: Vec<CalendarUser>, // Attendees (ATTENDEE) - for EMAIL action
|
||||
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||
}
|
||||
|
||||
// ==================== SUPPORTING TYPES ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum EventClass {
|
||||
Public,
|
||||
Private,
|
||||
Confidential,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum EventStatus {
|
||||
Tentative,
|
||||
Confirmed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum TodoStatus {
|
||||
NeedsAction,
|
||||
Completed,
|
||||
InProcess,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum JournalStatus {
|
||||
Draft,
|
||||
Final,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum TimeTransparency {
|
||||
Opaque, // Time is not available (default)
|
||||
Transparent, // Time is available despite event
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AlarmAction {
|
||||
Audio,
|
||||
Display,
|
||||
Email,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AlarmTrigger {
|
||||
Duration(Duration), // Relative to start/end
|
||||
DateTime(DateTime<Utc>), // Absolute time
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CalendarUser {
|
||||
pub cal_address: String, // Calendar address (email)
|
||||
pub cn: Option<String>, // Common name (CN parameter)
|
||||
pub dir: Option<String>, // Directory entry (DIR parameter)
|
||||
pub sent_by: Option<String>, // Sent by (SENT-BY parameter)
|
||||
pub language: Option<String>, // Language (LANGUAGE parameter)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Attendee {
|
||||
pub cal_address: String, // Calendar address (email)
|
||||
pub cn: Option<String>, // Common name (CN parameter)
|
||||
pub role: Option<AttendeeRole>, // Role (ROLE parameter)
|
||||
pub partstat: Option<ParticipationStatus>, // Participation status (PARTSTAT parameter)
|
||||
pub rsvp: Option<bool>, // RSVP expectation (RSVP parameter)
|
||||
pub cutype: Option<CalendarUserType>, // Calendar user type (CUTYPE parameter)
|
||||
pub member: Vec<String>, // Group/list membership (MEMBER parameter)
|
||||
pub delegated_to: Vec<String>, // Delegated to (DELEGATED-TO parameter)
|
||||
pub delegated_from: Vec<String>, // Delegated from (DELEGATED-FROM parameter)
|
||||
pub sent_by: Option<String>, // Sent by (SENT-BY parameter)
|
||||
pub dir: Option<String>, // Directory entry (DIR parameter)
|
||||
pub language: Option<String>, // Language (LANGUAGE parameter)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AttendeeRole {
|
||||
Chair,
|
||||
ReqParticipant,
|
||||
OptParticipant,
|
||||
NonParticipant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ParticipationStatus {
|
||||
NeedsAction,
|
||||
Accepted,
|
||||
Declined,
|
||||
Tentative,
|
||||
Delegated,
|
||||
Completed,
|
||||
InProcess,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum CalendarUserType {
|
||||
Individual,
|
||||
Group,
|
||||
Resource,
|
||||
Room,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct GeographicPosition {
|
||||
pub latitude: f64, // Latitude in decimal degrees
|
||||
pub longitude: f64, // Longitude in decimal degrees
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Attachment {
|
||||
pub data: AttachmentData, // Attachment data
|
||||
pub fmttype: Option<String>, // Format type (FMTTYPE parameter)
|
||||
pub encoding: Option<String>, // Encoding (ENCODING parameter)
|
||||
pub filename: Option<String>, // Filename (X-FILENAME parameter)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AttachmentData {
|
||||
Uri(String), // URI reference
|
||||
Binary(Vec<u8>), // Binary data
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct FreeBusyTime {
|
||||
pub period: (DateTime<Utc>, DateTime<Utc>), // Start and end time
|
||||
pub fbtype: Option<FreeBusyType>, // Free/busy type
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum FreeBusyType {
|
||||
Free,
|
||||
Busy,
|
||||
BusyUnavailable,
|
||||
BusyTentative,
|
||||
}
|
||||
|
||||
// ==================== COMPATIBILITY LAYER ====================
|
||||
|
||||
use crate::services::calendar_service::{CalendarEvent, EventReminder, ReminderAction};
|
||||
|
||||
// Conversion from new VEvent to existing CalendarEvent
|
||||
impl From<VEvent> for CalendarEvent {
|
||||
fn from(vevent: VEvent) -> Self {
|
||||
Self {
|
||||
uid: vevent.uid,
|
||||
summary: vevent.summary,
|
||||
description: vevent.description,
|
||||
start: vevent.dtstart,
|
||||
end: vevent.dtend,
|
||||
location: vevent.location,
|
||||
status: vevent.status.unwrap_or(EventStatus::Confirmed).into(),
|
||||
class: vevent.class.unwrap_or(EventClass::Public).into(),
|
||||
priority: vevent.priority,
|
||||
organizer: vevent.organizer.map(|o| o.cal_address),
|
||||
attendees: vevent.attendees.into_iter().map(|a| a.cal_address).collect(),
|
||||
categories: vevent.categories,
|
||||
created: vevent.created,
|
||||
last_modified: vevent.last_modified,
|
||||
recurrence_rule: vevent.rrule,
|
||||
exception_dates: vevent.exdate,
|
||||
all_day: vevent.all_day,
|
||||
reminders: vevent.alarms.into_iter().map(|a| a.into()).collect(),
|
||||
etag: vevent.etag,
|
||||
href: vevent.href,
|
||||
calendar_path: vevent.calendar_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Conversion from existing CalendarEvent to new VEvent
|
||||
impl From<CalendarEvent> for VEvent {
|
||||
fn from(event: CalendarEvent) -> Self {
|
||||
use chrono::Utc;
|
||||
|
||||
Self {
|
||||
// Required properties
|
||||
dtstamp: Utc::now(), // Add required DTSTAMP
|
||||
uid: event.uid,
|
||||
dtstart: event.start,
|
||||
|
||||
// Optional properties
|
||||
dtend: event.end,
|
||||
duration: None, // Will be calculated from dtend if needed
|
||||
summary: event.summary,
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
|
||||
// Classification and status
|
||||
class: Some(event.class.into()),
|
||||
status: Some(event.status.into()),
|
||||
transp: None, // Default to None, can be enhanced later
|
||||
priority: event.priority,
|
||||
|
||||
// People and organization
|
||||
organizer: event.organizer.map(|email| CalendarUser {
|
||||
cal_address: email,
|
||||
cn: None,
|
||||
dir: None,
|
||||
sent_by: None,
|
||||
language: None,
|
||||
}),
|
||||
attendees: event.attendees.into_iter().map(|email| Attendee {
|
||||
cal_address: email,
|
||||
cn: None,
|
||||
role: None,
|
||||
partstat: None,
|
||||
rsvp: None,
|
||||
cutype: None,
|
||||
member: Vec::new(),
|
||||
delegated_to: Vec::new(),
|
||||
delegated_from: Vec::new(),
|
||||
sent_by: None,
|
||||
dir: None,
|
||||
language: None,
|
||||
}).collect(),
|
||||
contact: None,
|
||||
|
||||
// Categorization and relationships
|
||||
categories: event.categories,
|
||||
comment: None,
|
||||
resources: Vec::new(),
|
||||
related_to: None,
|
||||
url: None,
|
||||
|
||||
// Geographical
|
||||
geo: None,
|
||||
|
||||
// Versioning and modification
|
||||
sequence: Some(0), // Start with sequence 0
|
||||
created: event.created,
|
||||
last_modified: event.last_modified,
|
||||
|
||||
// Recurrence
|
||||
rrule: event.recurrence_rule,
|
||||
rdate: Vec::new(),
|
||||
exdate: event.exception_dates,
|
||||
recurrence_id: None,
|
||||
|
||||
// Alarms and attachments
|
||||
alarms: event.reminders.into_iter().map(|r| r.into()).collect(),
|
||||
attachments: Vec::new(),
|
||||
|
||||
// CalDAV specific
|
||||
etag: event.etag,
|
||||
href: event.href,
|
||||
calendar_path: event.calendar_path,
|
||||
all_day: event.all_day,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert between status enums
|
||||
impl From<EventStatus> for crate::services::calendar_service::EventStatus {
|
||||
fn from(status: EventStatus) -> Self {
|
||||
match status {
|
||||
EventStatus::Tentative => crate::services::calendar_service::EventStatus::Tentative,
|
||||
EventStatus::Confirmed => crate::services::calendar_service::EventStatus::Confirmed,
|
||||
EventStatus::Cancelled => crate::services::calendar_service::EventStatus::Cancelled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::services::calendar_service::EventStatus> for EventStatus {
|
||||
fn from(status: crate::services::calendar_service::EventStatus) -> Self {
|
||||
match status {
|
||||
crate::services::calendar_service::EventStatus::Tentative => EventStatus::Tentative,
|
||||
crate::services::calendar_service::EventStatus::Confirmed => EventStatus::Confirmed,
|
||||
crate::services::calendar_service::EventStatus::Cancelled => EventStatus::Cancelled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert between class enums
|
||||
impl From<EventClass> for crate::services::calendar_service::EventClass {
|
||||
fn from(class: EventClass) -> Self {
|
||||
match class {
|
||||
EventClass::Public => crate::services::calendar_service::EventClass::Public,
|
||||
EventClass::Private => crate::services::calendar_service::EventClass::Private,
|
||||
EventClass::Confidential => crate::services::calendar_service::EventClass::Confidential,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::services::calendar_service::EventClass> for EventClass {
|
||||
fn from(class: crate::services::calendar_service::EventClass) -> Self {
|
||||
match class {
|
||||
crate::services::calendar_service::EventClass::Public => EventClass::Public,
|
||||
crate::services::calendar_service::EventClass::Private => EventClass::Private,
|
||||
crate::services::calendar_service::EventClass::Confidential => EventClass::Confidential,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert between reminder types
|
||||
impl From<VAlarm> for EventReminder {
|
||||
fn from(alarm: VAlarm) -> Self {
|
||||
let minutes_before = match alarm.trigger {
|
||||
AlarmTrigger::Duration(duration) => {
|
||||
// Convert duration to minutes (assuming it's negative for "before")
|
||||
(-duration.num_minutes()) as i32
|
||||
},
|
||||
AlarmTrigger::DateTime(_) => 0, // Absolute time alarms default to 0 minutes
|
||||
};
|
||||
|
||||
let action = match alarm.action {
|
||||
AlarmAction::Display => ReminderAction::Display,
|
||||
AlarmAction::Audio => ReminderAction::Audio,
|
||||
AlarmAction::Email => ReminderAction::Email,
|
||||
};
|
||||
|
||||
Self {
|
||||
minutes_before,
|
||||
action,
|
||||
description: alarm.description,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EventReminder> for VAlarm {
|
||||
fn from(reminder: EventReminder) -> Self {
|
||||
use chrono::Duration;
|
||||
|
||||
let action = match reminder.action {
|
||||
ReminderAction::Display => AlarmAction::Display,
|
||||
ReminderAction::Audio => AlarmAction::Audio,
|
||||
ReminderAction::Email => AlarmAction::Email,
|
||||
};
|
||||
|
||||
let trigger = AlarmTrigger::Duration(Duration::minutes(-reminder.minutes_before as i64));
|
||||
|
||||
Self {
|
||||
action,
|
||||
trigger,
|
||||
description: reminder.description,
|
||||
summary: None,
|
||||
duration: None,
|
||||
repeat: None,
|
||||
attendees: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/models/mod.rs
Normal file
13
src/models/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
// RFC 5545 Compliant iCalendar Models
|
||||
pub mod ical;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use ical::{
|
||||
VEvent, VTodo, VJournal, VFreeBusy, VTimeZone, VAlarm,
|
||||
ICalendarObject,
|
||||
EventStatus, EventClass, TodoStatus, JournalStatus,
|
||||
TimeTransparency, AlarmAction, AlarmTrigger,
|
||||
CalendarUser, Attendee, AttendeeRole, ParticipationStatus, CalendarUserType,
|
||||
GeographicPosition, Attachment, AttachmentData,
|
||||
FreeBusyTime, FreeBusyType, TimeZoneComponent,
|
||||
};
|
||||
@@ -90,6 +90,139 @@ impl Default for EventClass {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== V2 API MODELS ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AttendeeV2 {
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
pub role: Option<AttendeeRoleV2>,
|
||||
pub status: Option<ParticipationStatusV2>,
|
||||
pub rsvp: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AttendeeRoleV2 {
|
||||
Chair,
|
||||
Required,
|
||||
Optional,
|
||||
NonParticipant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ParticipationStatusV2 {
|
||||
NeedsAction,
|
||||
Accepted,
|
||||
Declined,
|
||||
Tentative,
|
||||
Delegated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AlarmV2 {
|
||||
pub action: AlarmActionV2,
|
||||
pub trigger_minutes: i32, // Minutes before event (negative = before)
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AlarmActionV2 {
|
||||
Audio,
|
||||
Display,
|
||||
Email,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum DeleteActionV2 {
|
||||
DeleteThis,
|
||||
DeleteFollowing,
|
||||
DeleteSeries,
|
||||
}
|
||||
|
||||
// V2 Request/Response Models
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateEventRequestV2 {
|
||||
pub summary: String,
|
||||
pub description: Option<String>,
|
||||
pub dtstart: DateTime<Utc>,
|
||||
pub dtend: Option<DateTime<Utc>>,
|
||||
pub location: Option<String>,
|
||||
pub all_day: bool,
|
||||
pub status: Option<EventStatus>,
|
||||
pub class: Option<EventClass>,
|
||||
pub priority: Option<u8>,
|
||||
pub organizer: Option<String>,
|
||||
pub attendees: Vec<AttendeeV2>,
|
||||
pub categories: Vec<String>,
|
||||
pub rrule: Option<String>,
|
||||
pub alarms: Vec<AlarmV2>,
|
||||
pub calendar_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UpdateEventRequestV2 {
|
||||
pub uid: String,
|
||||
pub summary: String,
|
||||
pub description: Option<String>,
|
||||
pub dtstart: DateTime<Utc>,
|
||||
pub dtend: Option<DateTime<Utc>>,
|
||||
pub location: Option<String>,
|
||||
pub all_day: bool,
|
||||
pub status: Option<EventStatus>,
|
||||
pub class: Option<EventClass>,
|
||||
pub priority: Option<u8>,
|
||||
pub organizer: Option<String>,
|
||||
pub attendees: Vec<AttendeeV2>,
|
||||
pub categories: Vec<String>,
|
||||
pub rrule: Option<String>,
|
||||
pub alarms: Vec<AlarmV2>,
|
||||
pub calendar_path: Option<String>,
|
||||
pub update_action: Option<String>,
|
||||
pub occurrence_date: Option<DateTime<Utc>>,
|
||||
pub exception_dates: Option<Vec<DateTime<Utc>>>,
|
||||
pub until_date: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeleteEventRequestV2 {
|
||||
pub calendar_path: String,
|
||||
pub event_href: String,
|
||||
pub delete_action: DeleteActionV2,
|
||||
pub occurrence_date: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateEventResponseV2 {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub event: Option<EventSummaryV2>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateEventResponseV2 {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub event: Option<EventSummaryV2>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeleteEventResponseV2 {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct EventSummaryV2 {
|
||||
pub uid: String,
|
||||
pub summary: Option<String>,
|
||||
pub dtstart: DateTime<Utc>,
|
||||
pub dtend: Option<DateTime<Utc>>,
|
||||
pub location: Option<String>,
|
||||
pub all_day: bool,
|
||||
pub href: Option<String>,
|
||||
pub etag: Option<String>,
|
||||
}
|
||||
|
||||
impl CalendarEvent {
|
||||
/// Get the date for this event (for calendar display)
|
||||
pub fn get_date(&self) -> NaiveDate {
|
||||
@@ -718,7 +851,7 @@ impl CalendarService {
|
||||
|
||||
let body_string = serde_json::to_string(&body)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
|
||||
|
||||
let url = format!("{}/calendar/events/create", self.base_url);
|
||||
opts.set_body(&body_string.into());
|
||||
let request = Request::new_with_str_and_init(&url, &opts)
|
||||
@@ -777,7 +910,10 @@ impl CalendarService {
|
||||
reminder: String,
|
||||
recurrence: String,
|
||||
recurrence_days: Vec<bool>,
|
||||
calendar_path: Option<String>
|
||||
calendar_path: Option<String>,
|
||||
exception_dates: Vec<DateTime<Utc>>,
|
||||
update_action: Option<String>,
|
||||
until_date: Option<DateTime<Utc>>
|
||||
) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("No global window exists")?;
|
||||
|
||||
@@ -804,12 +940,16 @@ impl CalendarService {
|
||||
"reminder": reminder,
|
||||
"recurrence": recurrence,
|
||||
"recurrence_days": recurrence_days,
|
||||
"calendar_path": calendar_path
|
||||
"calendar_path": calendar_path,
|
||||
"update_action": update_action,
|
||||
"occurrence_date": null,
|
||||
"exception_dates": exception_dates.iter().map(|dt| dt.to_rfc3339()).collect::<Vec<String>>(),
|
||||
"until_date": until_date.as_ref().map(|dt| dt.to_rfc3339())
|
||||
});
|
||||
|
||||
let body_string = serde_json::to_string(&body)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
|
||||
|
||||
let url = format!("{}/calendar/events/update", self.base_url);
|
||||
opts.set_body(&body_string.into());
|
||||
let request = Request::new_with_str_and_init(&url, &opts)
|
||||
@@ -944,4 +1084,227 @@ impl CalendarService {
|
||||
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== V2 API METHODS ====================
|
||||
|
||||
/// Create a new event using V2 API (no string parsing required)
|
||||
pub async fn create_event_v2(
|
||||
&self,
|
||||
token: &str,
|
||||
password: &str,
|
||||
request: CreateEventRequestV2,
|
||||
) -> Result<CreateEventResponseV2, 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_string = serde_json::to_string(&request)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
|
||||
let url = format!("{}/v2/calendar/events/create", self.base_url);
|
||||
opts.set_body(&body_string.into());
|
||||
let request_obj = Request::new_with_str_and_init(&url, &opts)
|
||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||
|
||||
request_obj.headers().set("Authorization", &format!("Bearer {}", token))
|
||||
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||
|
||||
request_obj.headers().set("X-CalDAV-Password", password)
|
||||
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
|
||||
|
||||
request_obj.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_obj))
|
||||
.await
|
||||
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||
|
||||
let resp: Response = resp_value.dyn_into()
|
||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||
|
||||
let text = JsFuture::from(resp.text()
|
||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
||||
.await
|
||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||
|
||||
let text_string = text.as_string()
|
||||
.ok_or("Response text is not a string")?;
|
||||
|
||||
if resp.ok() {
|
||||
let response: CreateEventResponseV2 = serde_json::from_str(&text_string)
|
||||
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
||||
Ok(response)
|
||||
} else {
|
||||
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an event using V2 API (no string parsing required)
|
||||
pub async fn update_event_v2(
|
||||
&self,
|
||||
token: &str,
|
||||
password: &str,
|
||||
request: UpdateEventRequestV2,
|
||||
) -> Result<UpdateEventResponseV2, 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_string = serde_json::to_string(&request)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
|
||||
let url = format!("{}/v2/calendar/events/update", self.base_url);
|
||||
opts.set_body(&body_string.into());
|
||||
let request_obj = Request::new_with_str_and_init(&url, &opts)
|
||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||
|
||||
request_obj.headers().set("Authorization", &format!("Bearer {}", token))
|
||||
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||
|
||||
request_obj.headers().set("X-CalDAV-Password", password)
|
||||
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
|
||||
|
||||
request_obj.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_obj))
|
||||
.await
|
||||
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||
|
||||
let resp: Response = resp_value.dyn_into()
|
||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||
|
||||
let text = JsFuture::from(resp.text()
|
||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
||||
.await
|
||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||
|
||||
let text_string = text.as_string()
|
||||
.ok_or("Response text is not a string")?;
|
||||
|
||||
if resp.ok() {
|
||||
let response: UpdateEventResponseV2 = serde_json::from_str(&text_string)
|
||||
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
||||
Ok(response)
|
||||
} else {
|
||||
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an event using V2 API (no string parsing required)
|
||||
pub async fn delete_event_v2(
|
||||
&self,
|
||||
token: &str,
|
||||
password: &str,
|
||||
request: DeleteEventRequestV2,
|
||||
) -> Result<DeleteEventResponseV2, 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_string = serde_json::to_string(&request)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
|
||||
let url = format!("{}/v2/calendar/events/delete", self.base_url);
|
||||
opts.set_body(&body_string.into());
|
||||
let request_obj = Request::new_with_str_and_init(&url, &opts)
|
||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||
|
||||
request_obj.headers().set("Authorization", &format!("Bearer {}", token))
|
||||
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||
|
||||
request_obj.headers().set("X-CalDAV-Password", password)
|
||||
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
|
||||
|
||||
request_obj.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_obj))
|
||||
.await
|
||||
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||
|
||||
let resp: Response = resp_value.dyn_into()
|
||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||
|
||||
let text = JsFuture::from(resp.text()
|
||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
||||
.await
|
||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||
|
||||
let text_string = text.as_string()
|
||||
.ok_or("Response text is not a string")?;
|
||||
|
||||
if resp.ok() {
|
||||
let response: DeleteEventResponseV2 = serde_json::from_str(&text_string)
|
||||
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
||||
Ok(response)
|
||||
} else {
|
||||
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to convert reminder string to structured alarms
|
||||
pub fn reminder_string_to_alarms(reminder: &str) -> Vec<AlarmV2> {
|
||||
match reminder.to_lowercase().as_str() {
|
||||
"15min" => vec![AlarmV2 {
|
||||
action: AlarmActionV2::Display,
|
||||
trigger_minutes: -15,
|
||||
description: Some("Event reminder".to_string()),
|
||||
}],
|
||||
"30min" => vec![AlarmV2 {
|
||||
action: AlarmActionV2::Display,
|
||||
trigger_minutes: -30,
|
||||
description: Some("Event reminder".to_string()),
|
||||
}],
|
||||
"1hour" => vec![AlarmV2 {
|
||||
action: AlarmActionV2::Display,
|
||||
trigger_minutes: -60,
|
||||
description: Some("Event reminder".to_string()),
|
||||
}],
|
||||
"2hours" => vec![AlarmV2 {
|
||||
action: AlarmActionV2::Display,
|
||||
trigger_minutes: -120,
|
||||
description: Some("Event reminder".to_string()),
|
||||
}],
|
||||
"1day" => vec![AlarmV2 {
|
||||
action: AlarmActionV2::Display,
|
||||
trigger_minutes: -1440,
|
||||
description: Some("Event reminder".to_string()),
|
||||
}],
|
||||
"2days" => vec![AlarmV2 {
|
||||
action: AlarmActionV2::Display,
|
||||
trigger_minutes: -2880,
|
||||
description: Some("Event reminder".to_string()),
|
||||
}],
|
||||
"1week" => vec![AlarmV2 {
|
||||
action: AlarmActionV2::Display,
|
||||
trigger_minutes: -10080,
|
||||
description: Some("Event reminder".to_string()),
|
||||
}],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to convert comma-separated attendees to structured attendees
|
||||
pub fn attendees_string_to_structured(attendees: &str) -> Vec<AttendeeV2> {
|
||||
if attendees.trim().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
attendees.split(',')
|
||||
.map(|email| AttendeeV2 {
|
||||
email: email.trim().to_string(),
|
||||
name: None,
|
||||
role: Some(AttendeeRoleV2::Required),
|
||||
status: Some(ParticipationStatusV2::NeedsAction),
|
||||
rsvp: Some(true),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
525
styles.css
525
styles.css
@@ -383,7 +383,7 @@ body {
|
||||
|
||||
/* Calendar Component */
|
||||
.calendar {
|
||||
background: white;
|
||||
background: var(--calendar-bg, white);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
@@ -397,8 +397,8 @@ body {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
background: var(--header-bg, linear-gradient(135deg, #667eea 0%, #764ba2 100%));
|
||||
color: var(--header-text, white);
|
||||
}
|
||||
|
||||
.month-year {
|
||||
@@ -486,7 +486,7 @@ body {
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: auto repeat(6, 1fr);
|
||||
flex: 1;
|
||||
background: white;
|
||||
background: var(--calendar-bg, white);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
@@ -495,41 +495,42 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: white;
|
||||
background: var(--calendar-bg, white);
|
||||
}
|
||||
|
||||
/* Week Header */
|
||||
.week-header {
|
||||
display: grid;
|
||||
grid-template-columns: 80px repeat(7, 1fr);
|
||||
background: #f8f9fa;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
background: var(--weekday-header-bg, #f8f9fa);
|
||||
border-bottom: 2px solid var(--time-label-border, #e9ecef);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.time-gutter {
|
||||
background: #f8f9fa;
|
||||
border-right: 1px solid #e9ecef;
|
||||
background: var(--time-label-bg, #f8f9fa);
|
||||
border-right: 1px solid var(--time-label-border, #e9ecef);
|
||||
}
|
||||
|
||||
.week-day-header {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
border-right: 1px solid #e9ecef;
|
||||
background: #f8f9fa;
|
||||
border-right: 1px solid var(--time-label-border, #e9ecef);
|
||||
background: var(--weekday-header-bg, #f8f9fa);
|
||||
color: var(--weekday-header-text, inherit);
|
||||
}
|
||||
|
||||
.week-day-header.today {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
background: var(--calendar-today-bg, #e3f2fd);
|
||||
color: var(--calendar-today-text, #1976d2);
|
||||
}
|
||||
|
||||
.weekday-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
color: var(--weekday-header-text, #666);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 0.25rem;
|
||||
@@ -541,7 +542,7 @@ body {
|
||||
}
|
||||
|
||||
.week-day-header.today .weekday-name {
|
||||
color: #1976d2;
|
||||
color: var(--calendar-today-text, #1976d2);
|
||||
}
|
||||
|
||||
/* Week Content */
|
||||
@@ -559,8 +560,8 @@ body {
|
||||
|
||||
/* Time Labels */
|
||||
.time-labels {
|
||||
background: #f8f9fa;
|
||||
border-right: 1px solid #e9ecef;
|
||||
background: var(--time-label-bg, #f8f9fa);
|
||||
border-right: 1px solid var(--time-label-border, #e9ecef);
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
@@ -573,8 +574,8 @@ body {
|
||||
justify-content: center;
|
||||
padding-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
color: var(--time-label-text, #666);
|
||||
border-bottom: 1px solid var(--calendar-border, #f0f0f0);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -593,7 +594,7 @@ body {
|
||||
|
||||
.week-day-column {
|
||||
position: relative;
|
||||
border-right: 1px solid #e9ecef;
|
||||
border-right: 1px solid var(--time-label-border, #e9ecef);
|
||||
min-height: 1500px; /* 25 time labels × 60px = 1500px total */
|
||||
}
|
||||
|
||||
@@ -602,20 +603,20 @@ body {
|
||||
}
|
||||
|
||||
.week-day-column.today {
|
||||
background: #fafffe;
|
||||
background: var(--calendar-day-hover, #fafffe);
|
||||
}
|
||||
|
||||
/* Time Slots */
|
||||
.time-slot {
|
||||
height: 60px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid var(--calendar-border, #f0f0f0);
|
||||
position: relative;
|
||||
pointer-events: none; /* Don't capture mouse events */
|
||||
}
|
||||
|
||||
.time-slot-half {
|
||||
height: 30px;
|
||||
border-bottom: 1px dotted #f5f5f5;
|
||||
border-bottom: 1px dotted var(--calendar-border, #f5f5f5);
|
||||
pointer-events: none; /* Don't capture mouse events */
|
||||
}
|
||||
|
||||
@@ -642,7 +643,7 @@ body {
|
||||
|
||||
/* Week Events */
|
||||
.week-event {
|
||||
position: absolute;
|
||||
position: absolute !important;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
min-height: 20px;
|
||||
@@ -759,9 +760,7 @@ body {
|
||||
}
|
||||
|
||||
/* Event resize zones and handles */
|
||||
.week-event {
|
||||
position: relative;
|
||||
}
|
||||
/* Remove position: relative to allow absolute positioning for correct event placement */
|
||||
|
||||
.week-event .event-content {
|
||||
padding: 2px;
|
||||
@@ -841,7 +840,7 @@ body {
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: auto 1fr;
|
||||
flex: 1;
|
||||
background: white;
|
||||
background: var(--calendar-bg, white);
|
||||
}
|
||||
|
||||
.week-view .calendar-day {
|
||||
@@ -861,7 +860,7 @@ body {
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
border: 1px solid #f0f0f0;
|
||||
border: 1px solid var(--calendar-border, #f0f0f0);
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -869,52 +868,53 @@ body {
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--calendar-day-bg, white);
|
||||
}
|
||||
|
||||
.calendar-day:hover {
|
||||
background-color: #f8f9ff;
|
||||
background-color: var(--calendar-day-hover, #f8f9ff);
|
||||
}
|
||||
|
||||
.calendar-day.current-month {
|
||||
background: white;
|
||||
background: var(--calendar-day-bg, white);
|
||||
}
|
||||
|
||||
.calendar-day.prev-month,
|
||||
.calendar-day.next-month {
|
||||
background: #fafafa;
|
||||
color: #ccc;
|
||||
background: var(--calendar-day-prev-next, #fafafa);
|
||||
color: var(--calendar-day-prev-next-text, #ccc);
|
||||
}
|
||||
|
||||
.calendar-day.today {
|
||||
background: #e3f2fd;
|
||||
border: 2px solid #2196f3;
|
||||
background: var(--calendar-today-bg, #e3f2fd);
|
||||
border: 2px solid var(--calendar-today-border, #2196f3);
|
||||
}
|
||||
|
||||
.calendar-day.has-events {
|
||||
background: #fff3e0;
|
||||
background: var(--calendar-has-events-bg, #fff3e0);
|
||||
}
|
||||
|
||||
.calendar-day.today.has-events {
|
||||
background: #e1f5fe;
|
||||
background: var(--calendar-today-bg, #e1f5fe);
|
||||
}
|
||||
|
||||
.calendar-day.selected {
|
||||
background: #e8f5e8;
|
||||
border: 2px solid #4caf50;
|
||||
background: var(--calendar-selected-bg, #e8f5e8);
|
||||
border: 2px solid var(--calendar-selected-border, #4caf50);
|
||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.calendar-day.selected.has-events {
|
||||
background: #f1f8e9;
|
||||
background: var(--calendar-selected-bg, #f1f8e9);
|
||||
}
|
||||
|
||||
.calendar-day.selected.today {
|
||||
background: #e0f2f1;
|
||||
border: 2px solid #4caf50;
|
||||
background: var(--calendar-selected-bg, #e0f2f1);
|
||||
border: 2px solid var(--calendar-selected-border, #4caf50);
|
||||
}
|
||||
|
||||
.calendar-day.selected .day-number {
|
||||
color: #2e7d32;
|
||||
color: var(--calendar-selected-text, #2e7d32);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -925,7 +925,7 @@ body {
|
||||
}
|
||||
|
||||
.calendar-day.today .day-number {
|
||||
color: #1976d2;
|
||||
color: var(--calendar-today-text, #1976d2);
|
||||
}
|
||||
|
||||
.day-events {
|
||||
@@ -1833,4 +1833,441 @@ body {
|
||||
min-width: 2.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Recurring Edit Modal */
|
||||
.recurring-edit-modal {
|
||||
max-width: 500px;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.recurring-edit-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.recurring-option {
|
||||
background: white;
|
||||
border: 2px solid #e9ecef;
|
||||
color: #495057;
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.recurring-option:hover {
|
||||
border-color: #667eea;
|
||||
background: #f8f9ff;
|
||||
color: #495057;
|
||||
transform: none;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
.recurring-option .option-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.recurring-option .option-description {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Mobile adjustments for recurring edit modal */
|
||||
@media (max-width: 768px) {
|
||||
.recurring-edit-modal {
|
||||
margin: 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
.recurring-option {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.recurring-option .option-title {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.recurring-option .option-description {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme Selector Styles */
|
||||
.theme-selector {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.theme-selector-dropdown {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-selector-dropdown:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.theme-selector-dropdown:focus {
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.theme-selector-dropdown option {
|
||||
background: #333;
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Theme Definitions */
|
||||
:root {
|
||||
/* Default Theme */
|
||||
--primary-gradient: linear-gradient(180deg, #667eea 0%, #764ba2 100%);
|
||||
--primary-bg: #f8f9fa;
|
||||
--primary-text: #333;
|
||||
--sidebar-bg: linear-gradient(180deg, #667eea 0%, #764ba2 100%);
|
||||
--sidebar-text: white;
|
||||
--header-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--header-text: white;
|
||||
--card-bg: white;
|
||||
--border-color: #e9ecef;
|
||||
--accent-color: #667eea;
|
||||
--calendar-bg: white;
|
||||
--calendar-border: #f0f0f0;
|
||||
--calendar-day-bg: white;
|
||||
--calendar-day-hover: #f8f9ff;
|
||||
--calendar-day-prev-next: #fafafa;
|
||||
--calendar-day-prev-next-text: #ccc;
|
||||
--calendar-today-bg: #e3f2fd;
|
||||
--calendar-today-border: #2196f3;
|
||||
--calendar-today-text: #1976d2;
|
||||
--calendar-selected-bg: #e8f5e8;
|
||||
--calendar-selected-border: #4caf50;
|
||||
--calendar-selected-text: #2e7d32;
|
||||
--calendar-has-events-bg: #fff3e0;
|
||||
--weekday-header-bg: #f8f9fa;
|
||||
--weekday-header-text: #666;
|
||||
--time-label-bg: #f8f9fa;
|
||||
--time-label-text: #666;
|
||||
--time-label-border: #e9ecef;
|
||||
--event-colors: #3B82F6, #10B981, #F59E0B, #EF4444, #8B5CF6, #06B6D4, #84CC16, #F97316, #EC4899, #6366F1, #14B8A6, #F3B806, #8B5A2B, #6B7280, #DC2626, #7C3AED;
|
||||
}
|
||||
|
||||
/* Ocean Theme */
|
||||
[data-theme="ocean"] {
|
||||
--primary-gradient: linear-gradient(180deg, #2196F3 0%, #0277BD 100%);
|
||||
--primary-bg: #e3f2fd;
|
||||
--primary-text: #0d47a1;
|
||||
--sidebar-bg: linear-gradient(180deg, #2196F3 0%, #0277BD 100%);
|
||||
--sidebar-text: white;
|
||||
--header-bg: linear-gradient(135deg, #2196F3 0%, #0277BD 100%);
|
||||
--header-text: white;
|
||||
--card-bg: #ffffff;
|
||||
--border-color: #bbdefb;
|
||||
--accent-color: #2196F3;
|
||||
--calendar-bg: #ffffff;
|
||||
--calendar-border: #bbdefb;
|
||||
--calendar-day-bg: #ffffff;
|
||||
--calendar-day-hover: #e1f5fe;
|
||||
--calendar-day-prev-next: #f3f8ff;
|
||||
--calendar-day-prev-next-text: #90caf9;
|
||||
--calendar-today-bg: #b3e5fc;
|
||||
--calendar-today-border: #0277BD;
|
||||
--calendar-today-text: #01579b;
|
||||
--calendar-selected-bg: #e0f7fa;
|
||||
--calendar-selected-border: #00acc1;
|
||||
--calendar-selected-text: #00695c;
|
||||
--calendar-has-events-bg: #fff8e1;
|
||||
--weekday-header-bg: #e3f2fd;
|
||||
--weekday-header-text: #0d47a1;
|
||||
--time-label-bg: #e3f2fd;
|
||||
--time-label-text: #0d47a1;
|
||||
--time-label-border: #bbdefb;
|
||||
--event-colors: #2196F3, #03DAC6, #FF9800, #F44336, #9C27B0, #00BCD4, #8BC34A, #FF5722, #E91E63, #3F51B5, #009688, #FFC107, #607D8B, #795548, #E53935, #673AB7;
|
||||
}
|
||||
|
||||
[data-theme="ocean"] body {
|
||||
background-color: var(--primary-bg);
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
[data-theme="ocean"] .app-sidebar {
|
||||
background: var(--sidebar-bg);
|
||||
}
|
||||
|
||||
/* Forest Theme */
|
||||
[data-theme="forest"] {
|
||||
--primary-gradient: linear-gradient(180deg, #4CAF50 0%, #2E7D32 100%);
|
||||
--primary-bg: #e8f5e8;
|
||||
--primary-text: #1b5e20;
|
||||
--sidebar-bg: linear-gradient(180deg, #4CAF50 0%, #2E7D32 100%);
|
||||
--sidebar-text: white;
|
||||
--header-bg: linear-gradient(135deg, #4CAF50 0%, #2E7D32 100%);
|
||||
--header-text: white;
|
||||
--card-bg: #ffffff;
|
||||
--border-color: #c8e6c9;
|
||||
--accent-color: #4CAF50;
|
||||
--calendar-bg: #ffffff;
|
||||
--calendar-border: #c8e6c9;
|
||||
--calendar-day-bg: #ffffff;
|
||||
--calendar-day-hover: #f1f8e9;
|
||||
--calendar-day-prev-next: #f9fbe7;
|
||||
--calendar-day-prev-next-text: #a5d6a7;
|
||||
--calendar-today-bg: #c8e6c9;
|
||||
--calendar-today-border: #2E7D32;
|
||||
--calendar-today-text: #1b5e20;
|
||||
--calendar-selected-bg: #e8f5e8;
|
||||
--calendar-selected-border: #388e3c;
|
||||
--calendar-selected-text: #2e7d32;
|
||||
--calendar-has-events-bg: #fff3e0;
|
||||
--weekday-header-bg: #e8f5e8;
|
||||
--weekday-header-text: #1b5e20;
|
||||
--time-label-bg: #e8f5e8;
|
||||
--time-label-text: #1b5e20;
|
||||
--time-label-border: #c8e6c9;
|
||||
--event-colors: #4CAF50, #8BC34A, #FF9800, #FF5722, #9C27B0, #03DAC6, #CDDC39, #FF6F00, #E91E63, #3F51B5, #009688, #FFC107, #795548, #607D8B, #F44336, #673AB7;
|
||||
}
|
||||
|
||||
[data-theme="forest"] body {
|
||||
background-color: var(--primary-bg);
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
[data-theme="forest"] .app-sidebar {
|
||||
background: var(--sidebar-bg);
|
||||
}
|
||||
|
||||
/* Sunset Theme */
|
||||
[data-theme="sunset"] {
|
||||
--primary-gradient: linear-gradient(180deg, #FF9800 0%, #F57C00 100%);
|
||||
--primary-bg: #fff3e0;
|
||||
--primary-text: #e65100;
|
||||
--sidebar-bg: linear-gradient(180deg, #FF9800 0%, #F57C00 100%);
|
||||
--sidebar-text: white;
|
||||
--header-bg: linear-gradient(135deg, #FF9800 0%, #F57C00 100%);
|
||||
--header-text: white;
|
||||
--card-bg: #ffffff;
|
||||
--border-color: #ffcc02;
|
||||
--accent-color: #FF9800;
|
||||
--calendar-bg: #ffffff;
|
||||
--calendar-border: #ffe0b2;
|
||||
--calendar-day-bg: #ffffff;
|
||||
--calendar-day-hover: #fff8e1;
|
||||
--calendar-day-prev-next: #fffde7;
|
||||
--calendar-day-prev-next-text: #ffcc02;
|
||||
--calendar-today-bg: #ffe0b2;
|
||||
--calendar-today-border: #F57C00;
|
||||
--calendar-today-text: #e65100;
|
||||
--calendar-selected-bg: #fff3e0;
|
||||
--calendar-selected-border: #ff8f00;
|
||||
--calendar-selected-text: #ff6f00;
|
||||
--calendar-has-events-bg: #f3e5f5;
|
||||
--weekday-header-bg: #fff3e0;
|
||||
--weekday-header-text: #e65100;
|
||||
--time-label-bg: #fff3e0;
|
||||
--time-label-text: #e65100;
|
||||
--time-label-border: #ffe0b2;
|
||||
--event-colors: #FF9800, #FF5722, #F44336, #E91E63, #9C27B0, #673AB7, #3F51B5, #2196F3, #03DAC6, #009688, #4CAF50, #8BC34A, #CDDC39, #FFC107, #FF6F00, #795548;
|
||||
}
|
||||
|
||||
[data-theme="sunset"] body {
|
||||
background-color: var(--primary-bg);
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
[data-theme="sunset"] .app-sidebar {
|
||||
background: var(--sidebar-bg);
|
||||
}
|
||||
|
||||
/* Purple Theme */
|
||||
[data-theme="purple"] {
|
||||
--primary-gradient: linear-gradient(180deg, #9C27B0 0%, #6A1B9A 100%);
|
||||
--primary-bg: #f3e5f5;
|
||||
--primary-text: #4a148c;
|
||||
--sidebar-bg: linear-gradient(180deg, #9C27B0 0%, #6A1B9A 100%);
|
||||
--sidebar-text: white;
|
||||
--header-bg: linear-gradient(135deg, #9C27B0 0%, #6A1B9A 100%);
|
||||
--header-text: white;
|
||||
--card-bg: #ffffff;
|
||||
--border-color: #ce93d8;
|
||||
--accent-color: #9C27B0;
|
||||
--calendar-bg: #ffffff;
|
||||
--calendar-border: #ce93d8;
|
||||
--calendar-day-bg: #ffffff;
|
||||
--calendar-day-hover: #f8e9fc;
|
||||
--calendar-day-prev-next: #fce4ec;
|
||||
--calendar-day-prev-next-text: #ce93d8;
|
||||
--calendar-today-bg: #e1bee7;
|
||||
--calendar-today-border: #6A1B9A;
|
||||
--calendar-today-text: #4a148c;
|
||||
--calendar-selected-bg: #f3e5f5;
|
||||
--calendar-selected-border: #8e24aa;
|
||||
--calendar-selected-text: #6a1b9a;
|
||||
--calendar-has-events-bg: #fff3e0;
|
||||
--weekday-header-bg: #f3e5f5;
|
||||
--weekday-header-text: #4a148c;
|
||||
--time-label-bg: #f3e5f5;
|
||||
--time-label-text: #4a148c;
|
||||
--time-label-border: #ce93d8;
|
||||
--event-colors: #9C27B0, #673AB7, #3F51B5, #2196F3, #03DAC6, #009688, #4CAF50, #8BC34A, #CDDC39, #FFC107, #FF9800, #FF5722, #F44336, #E91E63, #795548, #607D8B;
|
||||
}
|
||||
|
||||
[data-theme="purple"] body {
|
||||
background-color: var(--primary-bg);
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
[data-theme="purple"] .app-sidebar {
|
||||
background: var(--sidebar-bg);
|
||||
}
|
||||
|
||||
/* Dark Theme */
|
||||
[data-theme="dark"] {
|
||||
--primary-gradient: linear-gradient(180deg, #424242 0%, #212121 100%);
|
||||
--primary-bg: #121212;
|
||||
--primary-text: #ffffff;
|
||||
--sidebar-bg: linear-gradient(180deg, #424242 0%, #212121 100%);
|
||||
--sidebar-text: white;
|
||||
--header-bg: linear-gradient(135deg, #424242 0%, #212121 100%);
|
||||
--header-text: white;
|
||||
--card-bg: #1e1e1e;
|
||||
--border-color: #333333;
|
||||
--accent-color: #666666;
|
||||
--calendar-bg: #1f1f1f;
|
||||
--calendar-border: #333333;
|
||||
--calendar-day-bg: #1f1f1f;
|
||||
--calendar-day-hover: #2a2a2a;
|
||||
--calendar-day-prev-next: #1a1a1a;
|
||||
--calendar-day-prev-next-text: #555;
|
||||
--calendar-today-bg: #2d2d2d;
|
||||
--calendar-today-border: #bb86fc;
|
||||
--calendar-today-text: #bb86fc;
|
||||
--calendar-selected-bg: #2a2a2a;
|
||||
--calendar-selected-border: #bb86fc;
|
||||
--calendar-selected-text: #bb86fc;
|
||||
--calendar-has-events-bg: #272727;
|
||||
--weekday-header-bg: #1a1a1a;
|
||||
--weekday-header-text: #e0e0e0;
|
||||
--time-label-bg: #1a1a1a;
|
||||
--time-label-text: #e0e0e0;
|
||||
--time-label-border: #333333;
|
||||
--event-colors: #bb86fc, #03dac6, #cf6679, #ff9800, #4caf50, #2196f3, #9c27b0, #f44336, #795548, #607d8b, #e91e63, #3f51b5, #009688, #8bc34a, #ffc107, #ff5722;
|
||||
}
|
||||
|
||||
[data-theme="dark"] body {
|
||||
background-color: var(--primary-bg);
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .app-sidebar {
|
||||
background: var(--sidebar-bg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .app-main {
|
||||
background-color: var(--primary-bg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .calendar-day {
|
||||
background: var(--card-bg);
|
||||
border-color: var(--border-color);
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
/* Rose Theme */
|
||||
[data-theme="rose"] {
|
||||
--primary-gradient: linear-gradient(180deg, #E91E63 0%, #AD1457 100%);
|
||||
--primary-bg: #fce4ec;
|
||||
--primary-text: #880e4f;
|
||||
--sidebar-bg: linear-gradient(180deg, #E91E63 0%, #AD1457 100%);
|
||||
--sidebar-text: white;
|
||||
--header-bg: linear-gradient(135deg, #E91E63 0%, #AD1457 100%);
|
||||
--header-text: white;
|
||||
--card-bg: #ffffff;
|
||||
--border-color: #f8bbd9;
|
||||
--accent-color: #E91E63;
|
||||
--calendar-bg: #ffffff;
|
||||
--calendar-border: #f8bbd9;
|
||||
--calendar-day-bg: #ffffff;
|
||||
--calendar-day-hover: #fdf2f8;
|
||||
--calendar-day-prev-next: #fef7ff;
|
||||
--calendar-day-prev-next-text: #f8bbd9;
|
||||
--calendar-today-bg: #f48fb1;
|
||||
--calendar-today-border: #AD1457;
|
||||
--calendar-today-text: #880e4f;
|
||||
--calendar-selected-bg: #fce4ec;
|
||||
--calendar-selected-border: #c2185b;
|
||||
--calendar-selected-text: #ad1457;
|
||||
--calendar-has-events-bg: #fff3e0;
|
||||
--weekday-header-bg: #fce4ec;
|
||||
--weekday-header-text: #880e4f;
|
||||
--time-label-bg: #fce4ec;
|
||||
--time-label-text: #880e4f;
|
||||
--time-label-border: #f8bbd9;
|
||||
--event-colors: #E91E63, #9C27B0, #673AB7, #3F51B5, #2196F3, #03DAC6, #009688, #4CAF50, #8BC34A, #CDDC39, #FFC107, #FF9800, #FF5722, #F44336, #795548, #607D8B;
|
||||
}
|
||||
|
||||
[data-theme="rose"] body {
|
||||
background-color: var(--primary-bg);
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
[data-theme="rose"] .app-sidebar {
|
||||
background: var(--sidebar-bg);
|
||||
}
|
||||
|
||||
/* Mint Theme */
|
||||
[data-theme="mint"] {
|
||||
--primary-gradient: linear-gradient(180deg, #26A69A 0%, #00695C 100%);
|
||||
--primary-bg: #e0f2f1;
|
||||
--primary-text: #004d40;
|
||||
--sidebar-bg: linear-gradient(180deg, #26A69A 0%, #00695C 100%);
|
||||
--sidebar-text: white;
|
||||
--header-bg: linear-gradient(135deg, #26A69A 0%, #00695C 100%);
|
||||
--header-text: white;
|
||||
--card-bg: #ffffff;
|
||||
--border-color: #b2dfdb;
|
||||
--accent-color: #26A69A;
|
||||
--calendar-bg: #ffffff;
|
||||
--calendar-border: #b2dfdb;
|
||||
--calendar-day-bg: #ffffff;
|
||||
--calendar-day-hover: #f0fdfc;
|
||||
--calendar-day-prev-next: #f7ffff;
|
||||
--calendar-day-prev-next-text: #b2dfdb;
|
||||
--calendar-today-bg: #b2dfdb;
|
||||
--calendar-today-border: #00695C;
|
||||
--calendar-today-text: #004d40;
|
||||
--calendar-selected-bg: #e0f2f1;
|
||||
--calendar-selected-border: #00897b;
|
||||
--calendar-selected-text: #00695c;
|
||||
--calendar-has-events-bg: #fff3e0;
|
||||
--weekday-header-bg: #e0f2f1;
|
||||
--weekday-header-text: #004d40;
|
||||
--time-label-bg: #e0f2f1;
|
||||
--time-label-text: #004d40;
|
||||
--time-label-border: #b2dfdb;
|
||||
--event-colors: #26A69A, #009688, #4CAF50, #8BC34A, #CDDC39, #FFC107, #FF9800, #FF5722, #F44336, #E91E63, #9C27B0, #673AB7, #3F51B5, #2196F3, #795548, #607D8B;
|
||||
}
|
||||
|
||||
[data-theme="mint"] body {
|
||||
background-color: var(--primary-bg);
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
[data-theme="mint"] .app-sidebar {
|
||||
background: var(--sidebar-bg);
|
||||
}
|
||||
Reference in New Issue
Block a user