Fix calendar event fetching to use visible date range
Some checks failed
Build and Push Docker Image / docker (push) Failing after 1m7s
Some checks failed
Build and Push Docker Image / docker (push) Failing after 1m7s
Moved event fetching logic from CalendarView to Calendar component to properly use the visible date range instead of hardcoded current month. The Calendar component already tracks the current visible date through navigation, so events now load correctly for August and other months when navigating. Changes: - Calendar component now manages its own events state and fetching - Event fetching responds to current_date changes from navigation - CalendarView simplified to just render Calendar component - Fixed cargo fmt/clippy formatting across codebase 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,16 +2,16 @@ use chrono::{Duration, Utc};
|
|||||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::models::{CalDAVLoginRequest, AuthResponse, ApiError};
|
|
||||||
use crate::config::CalDAVConfig;
|
|
||||||
use crate::calendar::CalDAVClient;
|
use crate::calendar::CalDAVClient;
|
||||||
|
use crate::config::CalDAVConfig;
|
||||||
|
use crate::models::{ApiError, AuthResponse, CalDAVLoginRequest};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
pub exp: i64, // Expiration time
|
pub exp: i64, // Expiration time
|
||||||
pub iat: i64, // Issued at
|
pub iat: i64, // Issued at
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -33,22 +33,25 @@ impl AuthService {
|
|||||||
// Create CalDAV config with provided credentials
|
// Create CalDAV config with provided credentials
|
||||||
let caldav_config = CalDAVConfig::new(
|
let caldav_config = CalDAVConfig::new(
|
||||||
request.server_url.clone(),
|
request.server_url.clone(),
|
||||||
request.username.clone(),
|
request.username.clone(),
|
||||||
request.password.clone()
|
request.password.clone(),
|
||||||
);
|
);
|
||||||
println!("📝 Created CalDAV config");
|
println!("📝 Created CalDAV config");
|
||||||
|
|
||||||
// Test authentication against CalDAV server
|
// Test authentication against CalDAV server
|
||||||
let caldav_client = CalDAVClient::new(caldav_config.clone());
|
let caldav_client = CalDAVClient::new(caldav_config.clone());
|
||||||
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
||||||
|
|
||||||
// Try to discover calendars as an authentication test
|
// Try to discover calendars as an authentication test
|
||||||
match caldav_client.discover_calendars().await {
|
match caldav_client.discover_calendars().await {
|
||||||
Ok(calendars) => {
|
Ok(calendars) => {
|
||||||
println!("✅ Authentication successful! Found {} calendars", calendars.len());
|
println!(
|
||||||
|
"✅ Authentication successful! Found {} calendars",
|
||||||
|
calendars.len()
|
||||||
|
);
|
||||||
// Authentication successful, generate JWT token
|
// Authentication successful, generate JWT token
|
||||||
let token = self.generate_token(&request.username, &request.server_url)?;
|
let token = self.generate_token(&request.username, &request.server_url)?;
|
||||||
|
|
||||||
Ok(AuthResponse {
|
Ok(AuthResponse {
|
||||||
token,
|
token,
|
||||||
username: request.username,
|
username: request.username,
|
||||||
@@ -58,7 +61,9 @@ impl AuthService {
|
|||||||
Err(err) => {
|
Err(err) => {
|
||||||
println!("❌ Authentication failed: {:?}", err);
|
println!("❌ Authentication failed: {:?}", err);
|
||||||
// Authentication failed
|
// Authentication failed
|
||||||
Err(ApiError::Unauthorized("Invalid CalDAV credentials or server unavailable".to_string()))
|
Err(ApiError::Unauthorized(
|
||||||
|
"Invalid CalDAV credentials or server unavailable".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,13 +74,17 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create CalDAV config from token
|
/// Create CalDAV config from token
|
||||||
pub fn caldav_config_from_token(&self, token: &str, password: &str) -> Result<CalDAVConfig, ApiError> {
|
pub fn caldav_config_from_token(
|
||||||
|
&self,
|
||||||
|
token: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<CalDAVConfig, ApiError> {
|
||||||
let claims = self.verify_token(token)?;
|
let claims = self.verify_token(token)?;
|
||||||
|
|
||||||
Ok(CalDAVConfig::new(
|
Ok(CalDAVConfig::new(
|
||||||
claims.server_url,
|
claims.server_url,
|
||||||
claims.username,
|
claims.username,
|
||||||
password.to_string()
|
password.to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +102,11 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Basic URL validation
|
// Basic URL validation
|
||||||
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://") {
|
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://")
|
||||||
return Err(ApiError::BadRequest("Server URL must start with http:// or https://".to_string()));
|
{
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"Server URL must start with http:// or https://".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -131,4 +143,4 @@ impl AuthService {
|
|||||||
|
|
||||||
Ok(token_data.claims)
|
Ok(token_data.claims)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,30 @@
|
|||||||
|
use base64::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::env;
|
use std::env;
|
||||||
use base64::prelude::*;
|
|
||||||
|
|
||||||
/// Configuration for CalDAV server connection and authentication.
|
/// Configuration for CalDAV server connection and authentication.
|
||||||
///
|
///
|
||||||
/// This struct holds all the necessary information to connect to a CalDAV server,
|
/// This struct holds all the necessary information to connect to a CalDAV server,
|
||||||
/// including server URL, credentials, and optional collection paths.
|
/// including server URL, credentials, and optional collection paths.
|
||||||
///
|
///
|
||||||
/// # Security Note
|
/// # Security Note
|
||||||
///
|
///
|
||||||
/// The password field contains sensitive information and should be handled carefully.
|
/// The password field contains sensitive information and should be handled carefully.
|
||||||
/// This struct implements `Debug` but in production, consider implementing a custom
|
/// This struct implements `Debug` but in production, consider implementing a custom
|
||||||
/// `Debug` that masks the password field.
|
/// `Debug` that masks the password field.
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// # use calendar_backend::config::CalDAVConfig;
|
/// # use calendar_backend::config::CalDAVConfig;
|
||||||
/// let config = CalDAVConfig {
|
/// let config = CalDAVConfig {
|
||||||
/// server_url: "https://caldav.example.com".to_string(),
|
/// server_url: "https://caldav.example.com".to_string(),
|
||||||
/// username: "user@example.com".to_string(),
|
/// username: "user@example.com".to_string(),
|
||||||
/// password: "password".to_string(),
|
/// password: "password".to_string(),
|
||||||
/// calendar_path: None,
|
/// calendar_path: None,
|
||||||
/// tasks_path: None,
|
/// tasks_path: None,
|
||||||
/// };
|
/// };
|
||||||
///
|
///
|
||||||
/// // Use the configuration for HTTP requests
|
/// // Use the configuration for HTTP requests
|
||||||
/// let auth_header = format!("Basic {}", config.get_basic_auth());
|
/// let auth_header = format!("Basic {}", config.get_basic_auth());
|
||||||
/// ```
|
/// ```
|
||||||
@@ -32,17 +32,17 @@ use base64::prelude::*;
|
|||||||
pub struct CalDAVConfig {
|
pub struct CalDAVConfig {
|
||||||
/// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/")
|
/// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/")
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
|
|
||||||
/// Username for authentication with the CalDAV server
|
/// Username for authentication with the CalDAV server
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
|
||||||
/// Password for authentication with the CalDAV server
|
/// Password for authentication with the CalDAV server
|
||||||
///
|
///
|
||||||
/// **Security Note**: This contains sensitive information
|
/// **Security Note**: This contains sensitive information
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
|
||||||
/// Optional path to the calendar collection on the server
|
/// Optional path to the calendar collection on the server
|
||||||
///
|
///
|
||||||
/// If not provided, the client will discover available calendars
|
/// If not provided, the client will discover available calendars
|
||||||
/// through CalDAV PROPFIND requests
|
/// through CalDAV PROPFIND requests
|
||||||
pub calendar_path: Option<String>,
|
pub calendar_path: Option<String>,
|
||||||
@@ -50,20 +50,20 @@ pub struct CalDAVConfig {
|
|||||||
|
|
||||||
impl CalDAVConfig {
|
impl CalDAVConfig {
|
||||||
/// Creates a new CalDAVConfig with the given credentials.
|
/// Creates a new CalDAVConfig with the given credentials.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
/// * `server_url` - The base URL of the CalDAV server
|
/// * `server_url` - The base URL of the CalDAV server
|
||||||
/// * `username` - Username for authentication
|
/// * `username` - Username for authentication
|
||||||
/// * `password` - Password for authentication
|
/// * `password` - Password for authentication
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// # use calendar_backend::config::CalDAVConfig;
|
/// # use calendar_backend::config::CalDAVConfig;
|
||||||
/// let config = CalDAVConfig::new(
|
/// let config = CalDAVConfig::new(
|
||||||
/// "https://caldav.example.com".to_string(),
|
/// "https://caldav.example.com".to_string(),
|
||||||
/// "user@example.com".to_string(),
|
/// "user@example.com".to_string(),
|
||||||
/// "password".to_string()
|
/// "password".to_string()
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
@@ -77,21 +77,21 @@ impl CalDAVConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a Base64-encoded string for HTTP Basic Authentication.
|
/// Generates a Base64-encoded string for HTTP Basic Authentication.
|
||||||
///
|
///
|
||||||
/// This method combines the username and password in the format
|
/// This method combines the username and password in the format
|
||||||
/// `username:password` and encodes it using Base64, which is the
|
/// `username:password` and encodes it using Base64, which is the
|
||||||
/// standard format for the `Authorization: Basic` HTTP header.
|
/// standard format for the `Authorization: Basic` HTTP header.
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// A Base64-encoded string that can be used directly in the
|
/// A Base64-encoded string that can be used directly in the
|
||||||
/// `Authorization` header: `Authorization: Basic <returned_value>`
|
/// `Authorization` header: `Authorization: Basic <returned_value>`
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// # use calendar_backend::config::CalDAVConfig;
|
/// # use calendar_backend::config::CalDAVConfig;
|
||||||
///
|
///
|
||||||
/// let config = CalDAVConfig {
|
/// let config = CalDAVConfig {
|
||||||
/// server_url: "https://example.com".to_string(),
|
/// server_url: "https://example.com".to_string(),
|
||||||
/// username: "user".to_string(),
|
/// username: "user".to_string(),
|
||||||
@@ -99,7 +99,7 @@ impl CalDAVConfig {
|
|||||||
/// calendar_path: None,
|
/// calendar_path: None,
|
||||||
/// tasks_path: None,
|
/// tasks_path: None,
|
||||||
/// };
|
/// };
|
||||||
///
|
///
|
||||||
/// let auth_value = config.get_basic_auth();
|
/// let auth_value = config.get_basic_auth();
|
||||||
/// let auth_header = format!("Basic {}", auth_value);
|
/// let auth_header = format!("Basic {}", auth_value);
|
||||||
/// ```
|
/// ```
|
||||||
@@ -113,15 +113,15 @@ impl CalDAVConfig {
|
|||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ConfigError {
|
pub enum ConfigError {
|
||||||
/// A required environment variable is missing or cannot be read.
|
/// A required environment variable is missing or cannot be read.
|
||||||
///
|
///
|
||||||
/// This error occurs when calling `CalDAVConfig::from_env()` and one of the
|
/// This error occurs when calling `CalDAVConfig::from_env()` and one of the
|
||||||
/// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`,
|
/// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`,
|
||||||
/// or `CALDAV_PASSWORD`) is not set.
|
/// or `CALDAV_PASSWORD`) is not set.
|
||||||
#[error("Missing environment variable: {0}")]
|
#[error("Missing environment variable: {0}")]
|
||||||
MissingVar(String),
|
MissingVar(String),
|
||||||
|
|
||||||
/// The configuration contains invalid or malformed values.
|
/// The configuration contains invalid or malformed values.
|
||||||
///
|
///
|
||||||
/// This could include malformed URLs, invalid authentication credentials,
|
/// This could include malformed URLs, invalid authentication credentials,
|
||||||
/// or other configuration issues that prevent proper CalDAV operation.
|
/// or other configuration issues that prevent proper CalDAV operation.
|
||||||
#[error("Invalid configuration: {0}")]
|
#[error("Invalid configuration: {0}")]
|
||||||
@@ -139,7 +139,6 @@ mod tests {
|
|||||||
username: "testuser".to_string(),
|
username: "testuser".to_string(),
|
||||||
password: "testpass".to_string(),
|
password: "testpass".to_string(),
|
||||||
calendar_path: None,
|
calendar_path: None,
|
||||||
tasks_path: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth = config.get_basic_auth();
|
let auth = config.get_basic_auth();
|
||||||
@@ -148,12 +147,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Integration test that authenticates with the actual Baikal CalDAV server
|
/// Integration test that authenticates with the actual Baikal CalDAV server
|
||||||
///
|
///
|
||||||
/// This test requires a valid .env file with:
|
/// This test requires a valid .env file with:
|
||||||
/// - CALDAV_SERVER_URL
|
/// - CALDAV_SERVER_URL
|
||||||
/// - CALDAV_USERNAME
|
/// - CALDAV_USERNAME
|
||||||
/// - CALDAV_PASSWORD
|
/// - CALDAV_PASSWORD
|
||||||
///
|
///
|
||||||
/// Run with: `cargo test test_baikal_auth`
|
/// Run with: `cargo test test_baikal_auth`
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_baikal_auth() {
|
async fn test_baikal_auth() {
|
||||||
@@ -161,7 +160,7 @@ mod tests {
|
|||||||
let config = CalDAVConfig::new(
|
let config = CalDAVConfig::new(
|
||||||
"https://example.com".to_string(),
|
"https://example.com".to_string(),
|
||||||
"test_user".to_string(),
|
"test_user".to_string(),
|
||||||
"test_password".to_string()
|
"test_password".to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
println!("Testing authentication to: {}", config.server_url);
|
println!("Testing authentication to: {}", config.server_url);
|
||||||
@@ -172,7 +171,10 @@ mod tests {
|
|||||||
// Make a simple OPTIONS request to test authentication
|
// Make a simple OPTIONS request to test authentication
|
||||||
let response = client
|
let response = client
|
||||||
.request(reqwest::Method::OPTIONS, &config.server_url)
|
.request(reqwest::Method::OPTIONS, &config.server_url)
|
||||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -190,9 +192,9 @@ mod tests {
|
|||||||
|
|
||||||
// For Baikal/CalDAV servers, we should see DAV headers
|
// For Baikal/CalDAV servers, we should see DAV headers
|
||||||
assert!(
|
assert!(
|
||||||
response.headers().contains_key("dav") ||
|
response.headers().contains_key("dav")
|
||||||
response.headers().contains_key("DAV") ||
|
|| response.headers().contains_key("DAV")
|
||||||
response.status().is_success(),
|
|| response.status().is_success(),
|
||||||
"Server doesn't appear to be a CalDAV server - missing DAV headers"
|
"Server doesn't appear to be a CalDAV server - missing DAV headers"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -200,17 +202,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Test making a PROPFIND request to discover calendars
|
/// Test making a PROPFIND request to discover calendars
|
||||||
///
|
///
|
||||||
/// This test requires a valid .env file and makes an actual CalDAV PROPFIND request
|
/// This test requires a valid .env file and makes an actual CalDAV PROPFIND request
|
||||||
///
|
///
|
||||||
/// Run with: `cargo test test_propfind_calendars`
|
/// Run with: `cargo test test_propfind_calendars`
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_propfind_calendars() {
|
async fn test_propfind_calendars() {
|
||||||
// Use test config - update these values to test with real server
|
// Use test config - update these values to test with real server
|
||||||
let config = CalDAVConfig::new(
|
let config = CalDAVConfig::new(
|
||||||
"https://example.com".to_string(),
|
"https://example.com".to_string(),
|
||||||
"test_user".to_string(),
|
"test_user".to_string(),
|
||||||
"test_password".to_string()
|
"test_password".to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
@@ -227,8 +229,14 @@ mod tests {
|
|||||||
</d:propfind>"#;
|
</d:propfind>"#;
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url)
|
.request(
|
||||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
reqwest::Method::from_bytes(b"PROPFIND").unwrap(),
|
||||||
|
&config.server_url,
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("Content-Type", "application/xml")
|
.header("Content-Type", "application/xml")
|
||||||
.header("Depth", "1")
|
.header("Depth", "1")
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
@@ -239,7 +247,7 @@ mod tests {
|
|||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
println!("PROPFIND Response status: {}", status);
|
println!("PROPFIND Response status: {}", status);
|
||||||
|
|
||||||
let body = response.text().await.expect("Failed to read response body");
|
let body = response.text().await.expect("Failed to read response body");
|
||||||
println!("PROPFIND Response body: {}", body);
|
println!("PROPFIND Response body: {}", body);
|
||||||
|
|
||||||
@@ -251,8 +259,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// The response should contain XML with calendar information
|
// The response should contain XML with calendar information
|
||||||
assert!(body.contains("calendar"), "Response should contain calendar information");
|
assert!(
|
||||||
|
body.contains("calendar"),
|
||||||
|
"Response should contain calendar information"
|
||||||
|
);
|
||||||
|
|
||||||
println!("✓ PROPFIND calendars test passed!");
|
println!("✓ PROPFIND calendars test passed!");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ mod calendar;
|
|||||||
mod events;
|
mod events;
|
||||||
mod series;
|
mod series;
|
||||||
|
|
||||||
pub use auth::{login, verify_token, get_user_info};
|
pub use auth::{get_user_info, login, verify_token};
|
||||||
pub use calendar::{create_calendar, delete_calendar};
|
pub use calendar::{create_calendar, delete_calendar};
|
||||||
pub use events::{get_calendar_events, refresh_event, create_event, update_event, delete_event};
|
pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event};
|
||||||
pub use series::{create_event_series, update_event_series, delete_event_series};
|
pub use series::{create_event_series, delete_event_series, update_event_series};
|
||||||
|
|||||||
@@ -1,33 +1,38 @@
|
|||||||
use axum::{
|
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||||
extract::State,
|
|
||||||
http::HeaderMap,
|
|
||||||
response::Json,
|
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo}};
|
|
||||||
use crate::calendar::CalDAVClient;
|
use crate::calendar::CalDAVClient;
|
||||||
use crate::config::CalDAVConfig;
|
use crate::config::CalDAVConfig;
|
||||||
|
use crate::{
|
||||||
|
models::{ApiError, AuthResponse, CalDAVLoginRequest, CalendarInfo, UserInfo},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
|
pub fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
|
||||||
let auth_header = headers.get("authorization")
|
let auth_header = headers
|
||||||
|
.get("authorization")
|
||||||
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
|
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
|
||||||
|
|
||||||
let auth_str = auth_header.to_str()
|
let auth_str = auth_header
|
||||||
|
.to_str()
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid Authorization header".to_string()))?;
|
.map_err(|_| ApiError::BadRequest("Invalid Authorization header".to_string()))?;
|
||||||
|
|
||||||
if let Some(token) = auth_str.strip_prefix("Bearer ") {
|
if let Some(token) = auth_str.strip_prefix("Bearer ") {
|
||||||
Ok(token.to_string())
|
Ok(token.to_string())
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::BadRequest("Authorization header must be Bearer token".to_string()))
|
Err(ApiError::BadRequest(
|
||||||
|
"Authorization header must be Bearer token".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
|
pub fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
|
||||||
let password_header = headers.get("x-caldav-password")
|
let password_header = headers
|
||||||
|
.get("x-caldav-password")
|
||||||
.ok_or_else(|| ApiError::BadRequest("Missing X-CalDAV-Password header".to_string()))?;
|
.ok_or_else(|| ApiError::BadRequest("Missing X-CalDAV-Password header".to_string()))?;
|
||||||
|
|
||||||
password_header.to_str()
|
password_header
|
||||||
|
.to_str()
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string()))
|
.map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string()))
|
||||||
}
|
}
|
||||||
@@ -40,32 +45,37 @@ pub async fn login(
|
|||||||
println!(" Server URL: {}", request.server_url);
|
println!(" Server URL: {}", request.server_url);
|
||||||
println!(" Username: {}", request.username);
|
println!(" Username: {}", request.username);
|
||||||
println!(" Password length: {}", request.password.len());
|
println!(" Password length: {}", request.password.len());
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
if request.username.is_empty() || request.password.is_empty() || request.server_url.is_empty() {
|
if request.username.is_empty() || request.password.is_empty() || request.server_url.is_empty() {
|
||||||
return Err(ApiError::BadRequest("Username, password, and server URL are required".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Username, password, and server URL are required".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("✅ Input validation passed");
|
println!("✅ Input validation passed");
|
||||||
|
|
||||||
// Create a token using the auth service
|
// Create a token using the auth service
|
||||||
println!("📝 Created CalDAV config");
|
println!("📝 Created CalDAV config");
|
||||||
|
|
||||||
// First verify the credentials are valid by attempting to discover calendars
|
// First verify the credentials are valid by attempting to discover calendars
|
||||||
let config = CalDAVConfig::new(
|
let config = CalDAVConfig::new(
|
||||||
request.server_url.clone(),
|
request.server_url.clone(),
|
||||||
request.username.clone(),
|
request.username.clone(),
|
||||||
request.password.clone()
|
request.password.clone(),
|
||||||
);
|
);
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
client.discover_calendars()
|
client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Unauthorized(format!("Authentication failed: {}", e)))?;
|
.map_err(|e| ApiError::Unauthorized(format!("Authentication failed: {}", e)))?;
|
||||||
|
|
||||||
let token = state.auth_service.generate_token(&request.username, &request.server_url)?;
|
let token = state
|
||||||
|
.auth_service
|
||||||
|
.generate_token(&request.username, &request.server_url)?;
|
||||||
|
|
||||||
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
Ok(Json(AuthResponse {
|
||||||
token,
|
token,
|
||||||
username: request.username,
|
username: request.username,
|
||||||
@@ -79,7 +89,7 @@ pub async fn verify_token(
|
|||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
let is_valid = state.auth_service.verify_token(&token).is_ok();
|
let is_valid = state.auth_service.verify_token(&token).is_ok();
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "valid": is_valid })))
|
Ok(Json(serde_json::json!({ "valid": is_valid })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,26 +99,33 @@ pub async fn get_user_info(
|
|||||||
) -> Result<Json<UserInfo>, ApiError> {
|
) -> Result<Json<UserInfo>, ApiError> {
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config.clone());
|
let client = CalDAVClient::new(config.clone());
|
||||||
|
|
||||||
// Discover calendars
|
// Discover calendars
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
println!("✅ Authentication successful! Found {} calendars", calendar_paths.len());
|
println!(
|
||||||
|
"✅ Authentication successful! Found {} calendars",
|
||||||
let calendars: Vec<CalendarInfo> = calendar_paths.iter().map(|path| {
|
calendar_paths.len()
|
||||||
CalendarInfo {
|
);
|
||||||
|
|
||||||
|
let calendars: Vec<CalendarInfo> = calendar_paths
|
||||||
|
.iter()
|
||||||
|
.map(|path| CalendarInfo {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
display_name: extract_calendar_name(path),
|
display_name: extract_calendar_name(path),
|
||||||
color: generate_calendar_color(path),
|
color: generate_calendar_color(path),
|
||||||
}
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(UserInfo {
|
Ok(Json(UserInfo {
|
||||||
username: config.username,
|
username: config.username,
|
||||||
server_url: config.server_url,
|
server_url: config.server_url,
|
||||||
@@ -123,15 +140,14 @@ fn generate_calendar_color(path: &str) -> String {
|
|||||||
for byte in path.bytes() {
|
for byte in path.bytes() {
|
||||||
hash = hash.wrapping_mul(31).wrapping_add(byte as u32);
|
hash = hash.wrapping_mul(31).wrapping_add(byte as u32);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define a set of pleasant colors
|
// Define a set of pleasant colors
|
||||||
let colors = [
|
let colors = [
|
||||||
"#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6",
|
"#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#06B6D4", "#84CC16", "#F97316",
|
||||||
"#06B6D4", "#84CC16", "#F97316", "#EC4899", "#6366F1",
|
"#EC4899", "#6366F1", "#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626", "#7C3AED",
|
||||||
"#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626",
|
"#059669", "#D97706", "#BE185D", "#4F46E5",
|
||||||
"#7C3AED", "#059669", "#D97706", "#BE185D", "#4F46E5"
|
|
||||||
];
|
];
|
||||||
|
|
||||||
colors[(hash as usize) % colors.len()].to_string()
|
colors[(hash as usize) % colors.len()].to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,4 +170,4 @@ fn extract_calendar_name(path: &str) -> String {
|
|||||||
})
|
})
|
||||||
.collect::<Vec<String>>()
|
.collect::<Vec<String>>()
|
||||||
.join(" ")
|
.join(" ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
use axum::{
|
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||||
extract::State,
|
|
||||||
http::HeaderMap,
|
|
||||||
response::Json,
|
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{AppState, models::{ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse}};
|
|
||||||
use crate::calendar::CalDAVClient;
|
use crate::calendar::CalDAVClient;
|
||||||
|
use crate::{
|
||||||
|
models::{
|
||||||
|
ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest,
|
||||||
|
DeleteCalendarResponse,
|
||||||
|
},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
use super::auth::{extract_bearer_token, extract_password_header};
|
use super::auth::{extract_bearer_token, extract_password_header};
|
||||||
|
|
||||||
@@ -20,22 +22,36 @@ pub async fn create_calendar(
|
|||||||
|
|
||||||
// Validate request
|
// Validate request
|
||||||
if request.name.trim().is_empty() {
|
if request.name.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest("Calendar name is required".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Calendar name is required".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Create calendar on CalDAV server
|
// Create calendar on CalDAV server
|
||||||
match client.create_calendar(&request.name, request.description.as_deref(), request.color.as_deref()).await {
|
match client
|
||||||
|
.create_calendar(
|
||||||
|
&request.name,
|
||||||
|
request.description.as_deref(),
|
||||||
|
request.color.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(_) => Ok(Json(CreateCalendarResponse {
|
Ok(_) => Ok(Json(CreateCalendarResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Calendar created successfully".to_string(),
|
message: "Calendar created successfully".to_string(),
|
||||||
})),
|
})),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to create calendar: {}", e);
|
eprintln!("Failed to create calendar: {}", e);
|
||||||
Err(ApiError::Internal(format!("Failed to create calendar: {}", e)))
|
Err(ApiError::Internal(format!(
|
||||||
|
"Failed to create calendar: {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,11 +66,15 @@ pub async fn delete_calendar(
|
|||||||
|
|
||||||
// Validate request
|
// Validate request
|
||||||
if request.path.trim().is_empty() {
|
if request.path.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest("Calendar path is required".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Calendar path is required".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Delete calendar on CalDAV server
|
// Delete calendar on CalDAV server
|
||||||
@@ -65,7 +85,10 @@ pub async fn delete_calendar(
|
|||||||
})),
|
})),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to delete calendar: {}", e);
|
eprintln!("Failed to delete calendar: {}", e);
|
||||||
Err(ApiError::Internal(format!("Failed to delete calendar: {}", e)))
|
Err(ApiError::Internal(format!(
|
||||||
|
"Failed to delete calendar: {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{State, Query, Path},
|
extract::{Path, Query, State},
|
||||||
http::HeaderMap,
|
http::HeaderMap,
|
||||||
response::Json,
|
response::Json,
|
||||||
};
|
};
|
||||||
|
use chrono::Datelike;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use chrono::Datelike;
|
|
||||||
|
|
||||||
use calendar_models::{VEvent, EventStatus, EventClass, CalendarUser, Attendee, VAlarm, AlarmAction, AlarmTrigger};
|
|
||||||
use crate::{AppState, models::{ApiError, DeleteEventRequest, DeleteEventResponse, CreateEventRequest, CreateEventResponse, UpdateEventRequest, UpdateEventResponse}};
|
|
||||||
use crate::calendar::{CalDAVClient, CalendarEvent};
|
use crate::calendar::{CalDAVClient, CalendarEvent};
|
||||||
|
use crate::{
|
||||||
|
models::{
|
||||||
|
ApiError, CreateEventRequest, CreateEventResponse, DeleteEventRequest, DeleteEventResponse,
|
||||||
|
UpdateEventRequest, UpdateEventResponse,
|
||||||
|
},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
use calendar_models::{
|
||||||
|
AlarmAction, AlarmTrigger, Attendee, CalendarUser, EventClass, EventStatus, VAlarm, VEvent,
|
||||||
|
};
|
||||||
|
|
||||||
use super::auth::{extract_bearer_token, extract_password_header};
|
use super::auth::{extract_bearer_token, extract_password_header};
|
||||||
|
|
||||||
@@ -28,20 +36,23 @@ pub async fn get_calendar_events(
|
|||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
println!("🔑 API call with password length: {}", password.len());
|
println!("🔑 API call with password length: {}", password.len());
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Discover calendars if needed
|
// Discover calendars if needed
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
if calendar_paths.is_empty() {
|
if calendar_paths.is_empty() {
|
||||||
return Ok(Json(vec![])); // No calendars found
|
return Ok(Json(vec![])); // No calendars found
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch events from all calendars
|
// Fetch events from all calendars
|
||||||
let mut all_events = Vec::new();
|
let mut all_events = Vec::new();
|
||||||
for calendar_path in &calendar_paths {
|
for calendar_path in &calendar_paths {
|
||||||
@@ -54,12 +65,15 @@ pub async fn get_calendar_events(
|
|||||||
all_events.extend(events);
|
all_events.extend(events);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e);
|
eprintln!(
|
||||||
|
"Failed to fetch events from calendar {}: {}",
|
||||||
|
calendar_path, e
|
||||||
|
);
|
||||||
// Continue with other calendars instead of failing completely
|
// Continue with other calendars instead of failing completely
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If year and month are specified, filter events
|
// If year and month are specified, filter events
|
||||||
if let (Some(year), Some(month)) = (params.year, params.month) {
|
if let (Some(year), Some(month)) = (params.year, params.month) {
|
||||||
all_events.retain(|event| {
|
all_events.retain(|event| {
|
||||||
@@ -68,7 +82,7 @@ pub async fn get_calendar_events(
|
|||||||
event_year == year && event_month == month
|
event_year == year && event_month == month
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("📅 Returning {} events", all_events.len());
|
println!("📅 Returning {} events", all_events.len());
|
||||||
Ok(Json(all_events))
|
Ok(Json(all_events))
|
||||||
}
|
}
|
||||||
@@ -80,16 +94,19 @@ pub async fn refresh_event(
|
|||||||
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
|
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Discover calendars
|
// Discover calendars
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
// Search for the event by UID across all calendars
|
// Search for the event by UID across all calendars
|
||||||
for calendar_path in &calendar_paths {
|
for calendar_path in &calendar_paths {
|
||||||
if let Ok(Some(mut event)) = client.fetch_event_by_uid(calendar_path, &uid).await {
|
if let Ok(Some(mut event)) = client.fetch_event_by_uid(calendar_path, &uid).await {
|
||||||
@@ -97,18 +114,25 @@ pub async fn refresh_event(
|
|||||||
return Ok(Json(Some(event)));
|
return Ok(Json(Some(event)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(None))
|
Ok(Json(None))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_href: &str) -> Result<Option<CalendarEvent>, crate::calendar::CalDAVError> {
|
async fn fetch_event_by_href(
|
||||||
|
client: &CalDAVClient,
|
||||||
|
calendar_path: &str,
|
||||||
|
event_href: &str,
|
||||||
|
) -> Result<Option<CalendarEvent>, crate::calendar::CalDAVError> {
|
||||||
// This is a simplified implementation - in a real scenario, you'd want to fetch the specific event by href
|
// This is a simplified implementation - in a real scenario, you'd want to fetch the specific event by href
|
||||||
// For now, we'll fetch all events and find the matching one by href (inefficient but functional)
|
// For now, we'll fetch all events and find the matching one by href (inefficient but functional)
|
||||||
let events = client.fetch_events(calendar_path).await?;
|
let events = client.fetch_events(calendar_path).await?;
|
||||||
|
|
||||||
println!("🔍 fetch_event_by_href: looking for href='{}'", event_href);
|
println!("🔍 fetch_event_by_href: looking for href='{}'", event_href);
|
||||||
println!("🔍 Available events with hrefs: {:?}", events.iter().map(|e| (&e.uid, &e.href)).collect::<Vec<_>>());
|
println!(
|
||||||
|
"🔍 Available events with hrefs: {:?}",
|
||||||
|
events.iter().map(|e| (&e.uid, &e.href)).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
// First try to match by exact href
|
// First try to match by exact href
|
||||||
for event in &events {
|
for event in &events {
|
||||||
if let Some(stored_href) = &event.href {
|
if let Some(stored_href) = &event.href {
|
||||||
@@ -118,22 +142,25 @@ async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_h
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: try to match by UID extracted from href filename
|
// Fallback: try to match by UID extracted from href filename
|
||||||
let filename = event_href.split('/').last().unwrap_or(event_href);
|
let filename = event_href.split('/').last().unwrap_or(event_href);
|
||||||
let uid_from_href = filename.trim_end_matches(".ics");
|
let uid_from_href = filename.trim_end_matches(".ics");
|
||||||
|
|
||||||
println!("🔍 Fallback: trying UID match. filename='{}', uid='{}'", filename, uid_from_href);
|
println!(
|
||||||
|
"🔍 Fallback: trying UID match. filename='{}', uid='{}'",
|
||||||
|
filename, uid_from_href
|
||||||
|
);
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
if event.uid == uid_from_href {
|
if event.uid == uid_from_href {
|
||||||
println!("✅ Found matching event by UID: {}", event.uid);
|
println!("✅ Found matching event by UID: {}", event.uid);
|
||||||
return Ok(Some(event));
|
return Ok(Some(event));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("❌ No matching event found for href: {}", event_href);
|
println!("❌ No matching event found for href: {}", event_href);
|
||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,41 +173,63 @@ pub async fn delete_event(
|
|||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Handle different delete actions for recurring events
|
// Handle different delete actions for recurring events
|
||||||
match request.delete_action.as_str() {
|
match request.delete_action.as_str() {
|
||||||
"delete_this" => {
|
"delete_this" => {
|
||||||
if let Some(event) = fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await
|
if let Some(event) =
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? {
|
fetch_event_by_href(&client, &request.calendar_path, &request.event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?
|
||||||
|
{
|
||||||
// Check if this is a recurring event
|
// Check if this is a recurring event
|
||||||
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
|
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
|
||||||
// Recurring event - add EXDATE for this occurrence
|
// Recurring event - add EXDATE for this occurrence
|
||||||
if let Some(occurrence_date) = &request.occurrence_date {
|
if let Some(occurrence_date) = &request.occurrence_date {
|
||||||
let exception_utc = if let Ok(date) = chrono::DateTime::parse_from_rfc3339(occurrence_date) {
|
let exception_utc = if let Ok(date) =
|
||||||
|
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
||||||
|
{
|
||||||
// RFC3339 format (with time and timezone)
|
// RFC3339 format (with time and timezone)
|
||||||
date.with_timezone(&chrono::Utc)
|
date.with_timezone(&chrono::Utc)
|
||||||
} else if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") {
|
} else if let Ok(naive_date) =
|
||||||
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||||
|
{
|
||||||
// Simple date format (YYYY-MM-DD)
|
// Simple date format (YYYY-MM-DD)
|
||||||
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
||||||
} else {
|
} else {
|
||||||
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
|
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut updated_event = event;
|
let mut updated_event = event;
|
||||||
updated_event.exdate.push(exception_utc);
|
updated_event.exdate.push(exception_utc);
|
||||||
|
|
||||||
println!("🔄 Adding EXDATE {} to recurring event {}", exception_utc.format("%Y%m%dT%H%M%SZ"), updated_event.uid);
|
println!(
|
||||||
|
"🔄 Adding EXDATE {} to recurring event {}",
|
||||||
|
exception_utc.format("%Y%m%dT%H%M%SZ"),
|
||||||
|
updated_event.uid
|
||||||
|
);
|
||||||
|
|
||||||
// Update the event with the new EXDATE
|
// Update the event with the new EXDATE
|
||||||
client.update_event(&request.calendar_path, &updated_event, &request.event_href)
|
client
|
||||||
|
.update_event(
|
||||||
|
&request.calendar_path,
|
||||||
|
&updated_event,
|
||||||
|
&request.event_href,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to update event with EXDATE: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!(
|
||||||
|
"Failed to update event with EXDATE: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
println!("✅ Successfully updated recurring event with EXDATE");
|
println!("✅ Successfully updated recurring event with EXDATE");
|
||||||
|
|
||||||
Ok(Json(DeleteEventResponse {
|
Ok(Json(DeleteEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Single occurrence deleted successfully".to_string(),
|
message: "Single occurrence deleted successfully".to_string(),
|
||||||
@@ -191,13 +240,16 @@ pub async fn delete_event(
|
|||||||
} else {
|
} else {
|
||||||
// Non-recurring event - delete the entire event
|
// Non-recurring event - delete the entire event
|
||||||
println!("🗑️ Deleting non-recurring event: {}", event.uid);
|
println!("🗑️ Deleting non-recurring event: {}", event.uid);
|
||||||
|
|
||||||
client.delete_event(&request.calendar_path, &request.event_href)
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to delete event: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
println!("✅ Successfully deleted non-recurring event");
|
println!("✅ Successfully deleted non-recurring event");
|
||||||
|
|
||||||
Ok(Json(DeleteEventResponse {
|
Ok(Json(DeleteEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Event deleted successfully".to_string(),
|
message: "Event deleted successfully".to_string(),
|
||||||
@@ -206,70 +258,99 @@ pub async fn delete_event(
|
|||||||
} else {
|
} else {
|
||||||
Err(ApiError::NotFound("Event not found".to_string()))
|
Err(ApiError::NotFound("Event not found".to_string()))
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"delete_following" => {
|
"delete_following" => {
|
||||||
// For "this and following" deletion, we need to:
|
// For "this and following" deletion, we need to:
|
||||||
// 1. Fetch the recurring event
|
// 1. Fetch the recurring event
|
||||||
// 2. Modify the RRULE to end before this occurrence
|
// 2. Modify the RRULE to end before this occurrence
|
||||||
// 3. Update the event
|
// 3. Update the event
|
||||||
|
|
||||||
if let Some(mut event) = fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await
|
if let Some(mut event) =
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? {
|
fetch_event_by_href(&client, &request.calendar_path, &request.event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?
|
||||||
|
{
|
||||||
if let Some(occurrence_date) = &request.occurrence_date {
|
if let Some(occurrence_date) = &request.occurrence_date {
|
||||||
let until_date = if let Ok(date) = chrono::DateTime::parse_from_rfc3339(occurrence_date) {
|
let until_date = if let Ok(date) =
|
||||||
|
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
||||||
|
{
|
||||||
// RFC3339 format (with time and timezone)
|
// RFC3339 format (with time and timezone)
|
||||||
date.with_timezone(&chrono::Utc)
|
date.with_timezone(&chrono::Utc)
|
||||||
} else if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") {
|
} else if let Ok(naive_date) =
|
||||||
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||||
|
{
|
||||||
// Simple date format (YYYY-MM-DD)
|
// Simple date format (YYYY-MM-DD)
|
||||||
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
||||||
} else {
|
} else {
|
||||||
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
|
return Err(ApiError::BadRequest(format!(
|
||||||
|
"Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD",
|
||||||
|
occurrence_date
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Modify the RRULE to add an UNTIL clause
|
// Modify the RRULE to add an UNTIL clause
|
||||||
if let Some(rrule) = &event.rrule {
|
if let Some(rrule) = &event.rrule {
|
||||||
// Remove existing UNTIL if present and add new one
|
// Remove existing UNTIL if present and add new one
|
||||||
let parts: Vec<&str> = rrule.split(';').filter(|part| {
|
let parts: Vec<&str> = rrule
|
||||||
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
.split(';')
|
||||||
}).collect();
|
.filter(|part| {
|
||||||
|
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
||||||
let new_rrule = format!("{};UNTIL={}", parts.join(";"), until_date.format("%Y%m%dT%H%M%SZ"));
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let new_rrule = format!(
|
||||||
|
"{};UNTIL={}",
|
||||||
|
parts.join(";"),
|
||||||
|
until_date.format("%Y%m%dT%H%M%SZ")
|
||||||
|
);
|
||||||
event.rrule = Some(new_rrule);
|
event.rrule = Some(new_rrule);
|
||||||
|
|
||||||
// Update the event with the modified RRULE
|
// Update the event with the modified RRULE
|
||||||
client.update_event(&request.calendar_path, &event, &request.event_href)
|
client
|
||||||
|
.update_event(&request.calendar_path, &event, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to update event with modified RRULE: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!(
|
||||||
|
"Failed to update event with modified RRULE: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(DeleteEventResponse {
|
Ok(Json(DeleteEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "This and following occurrences deleted successfully".to_string(),
|
message: "This and following occurrences deleted successfully"
|
||||||
|
.to_string(),
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
// No RRULE, just delete the single event
|
// No RRULE, just delete the single event
|
||||||
client.delete_event(&request.calendar_path, &request.event_href)
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to delete event: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(DeleteEventResponse {
|
Ok(Json(DeleteEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Event deleted successfully".to_string(),
|
message: "Event deleted successfully".to_string(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::BadRequest("Occurrence date is required for following deletion".to_string()))
|
Err(ApiError::BadRequest(
|
||||||
|
"Occurrence date is required for following deletion".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::NotFound("Event not found".to_string()))
|
Err(ApiError::NotFound("Event not found".to_string()))
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"delete_series" | _ => {
|
"delete_series" | _ => {
|
||||||
// Delete the entire event/series
|
// Delete the entire event/series
|
||||||
client.delete_event(&request.calendar_path, &request.event_href)
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
||||||
|
|
||||||
Ok(Json(DeleteEventResponse {
|
Ok(Json(DeleteEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Event deleted successfully".to_string(),
|
message: "Event deleted successfully".to_string(),
|
||||||
@@ -283,9 +364,11 @@ pub async fn create_event(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(request): Json<CreateEventRequest>,
|
Json(request): Json<CreateEventRequest>,
|
||||||
) -> Result<Json<CreateEventResponse>, ApiError> {
|
) -> Result<Json<CreateEventResponse>, ApiError> {
|
||||||
println!("📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",
|
println!(
|
||||||
request.title, request.all_day, request.calendar_path);
|
"📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",
|
||||||
|
request.title, request.all_day, request.calendar_path
|
||||||
|
);
|
||||||
|
|
||||||
// Extract and verify token
|
// Extract and verify token
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
@@ -294,13 +377,17 @@ pub async fn create_event(
|
|||||||
if request.title.trim().is_empty() {
|
if request.title.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest("Event title is required".to_string()));
|
return Err(ApiError::BadRequest("Event title is required".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.title.len() > 200 {
|
if request.title.len() > 200 {
|
||||||
return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Event title too long (max 200 characters)".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Determine which calendar to use
|
// Determine which calendar to use
|
||||||
@@ -308,31 +395,41 @@ pub async fn create_event(
|
|||||||
path
|
path
|
||||||
} else {
|
} else {
|
||||||
// Use the first available calendar
|
// Use the first available calendar
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
if calendar_paths.is_empty() {
|
if calendar_paths.is_empty() {
|
||||||
return Err(ApiError::BadRequest("No calendars available for event creation".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"No calendars available for event creation".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
calendar_paths[0].clone()
|
calendar_paths[0].clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse dates and times
|
// Parse dates and times
|
||||||
let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
let start_datetime =
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
||||||
|
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
||||||
|
|
||||||
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
|
|
||||||
// Validate that end is after start
|
// Validate that end is after start
|
||||||
if end_datetime <= start_datetime {
|
if end_datetime <= start_datetime {
|
||||||
return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"End date/time must be after start date/time".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a unique UID for the event
|
// Generate a unique UID for the event
|
||||||
let uid = format!("{}-{}", uuid::Uuid::new_v4(), chrono::Utc::now().timestamp());
|
let uid = format!(
|
||||||
|
"{}-{}",
|
||||||
|
uuid::Uuid::new_v4(),
|
||||||
|
chrono::Utc::now().timestamp()
|
||||||
|
);
|
||||||
|
|
||||||
// Parse status
|
// Parse status
|
||||||
let status = match request.status.to_lowercase().as_str() {
|
let status = match request.status.to_lowercase().as_str() {
|
||||||
@@ -352,7 +449,8 @@ pub async fn create_event(
|
|||||||
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
|
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
request.attendees
|
request
|
||||||
|
.attendees
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
@@ -363,7 +461,8 @@ pub async fn create_event(
|
|||||||
let categories: Vec<String> = if request.categories.trim().is_empty() {
|
let categories: Vec<String> = if request.categories.trim().is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
request.categories
|
request
|
||||||
|
.categories
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
@@ -399,10 +498,11 @@ pub async fn create_event(
|
|||||||
"WEEKLY" => {
|
"WEEKLY" => {
|
||||||
// Handle weekly recurrence with optional BYDAY parameter
|
// Handle weekly recurrence with optional BYDAY parameter
|
||||||
let mut rrule = "FREQ=WEEKLY".to_string();
|
let mut rrule = "FREQ=WEEKLY".to_string();
|
||||||
|
|
||||||
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
|
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
|
||||||
if request.recurrence_days.len() == 7 {
|
if request.recurrence_days.len() == 7 {
|
||||||
let selected_days: Vec<&str> = request.recurrence_days
|
let selected_days: Vec<&str> = request
|
||||||
|
.recurrence_days
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(i, &selected)| {
|
.filter_map(|(i, &selected)| {
|
||||||
@@ -416,20 +516,20 @@ pub async fn create_event(
|
|||||||
5 => "FR", // Friday
|
5 => "FR", // Friday
|
||||||
6 => "SA", // Saturday
|
6 => "SA", // Saturday
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !selected_days.is_empty() {
|
if !selected_days.is_empty() {
|
||||||
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
|
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Some(rrule)
|
Some(rrule)
|
||||||
},
|
}
|
||||||
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
|
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
|
||||||
"YEARLY" => Some("FREQ=YEARLY".to_string()),
|
"YEARLY" => Some("FREQ=YEARLY".to_string()),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -439,15 +539,27 @@ pub async fn create_event(
|
|||||||
// Create the VEvent struct (RFC 5545 compliant)
|
// Create the VEvent struct (RFC 5545 compliant)
|
||||||
let mut event = VEvent::new(uid, start_datetime);
|
let mut event = VEvent::new(uid, start_datetime);
|
||||||
event.dtend = Some(end_datetime);
|
event.dtend = Some(end_datetime);
|
||||||
event.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) };
|
event.summary = if request.title.trim().is_empty() {
|
||||||
event.description = if request.description.trim().is_empty() { None } else { Some(request.description) };
|
None
|
||||||
event.location = if request.location.trim().is_empty() { None } else { Some(request.location) };
|
} else {
|
||||||
|
Some(request.title.clone())
|
||||||
|
};
|
||||||
|
event.description = if request.description.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.description)
|
||||||
|
};
|
||||||
|
event.location = if request.location.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.location)
|
||||||
|
};
|
||||||
event.status = Some(status);
|
event.status = Some(status);
|
||||||
event.class = Some(class);
|
event.class = Some(class);
|
||||||
event.priority = request.priority;
|
event.priority = request.priority;
|
||||||
event.organizer = if request.organizer.trim().is_empty() {
|
event.organizer = if request.organizer.trim().is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(CalendarUser {
|
Some(CalendarUser {
|
||||||
cal_address: request.organizer,
|
cal_address: request.organizer,
|
||||||
common_name: None,
|
common_name: None,
|
||||||
@@ -456,41 +568,53 @@ pub async fn create_event(
|
|||||||
language: None,
|
language: None,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
event.attendees = attendees.into_iter().map(|email| Attendee {
|
event.attendees = attendees
|
||||||
cal_address: email,
|
.into_iter()
|
||||||
common_name: None,
|
.map(|email| Attendee {
|
||||||
role: None,
|
cal_address: email,
|
||||||
part_stat: None,
|
common_name: None,
|
||||||
rsvp: None,
|
role: None,
|
||||||
cu_type: None,
|
part_stat: None,
|
||||||
member: Vec::new(),
|
rsvp: None,
|
||||||
delegated_to: Vec::new(),
|
cu_type: None,
|
||||||
delegated_from: Vec::new(),
|
member: Vec::new(),
|
||||||
sent_by: None,
|
delegated_to: Vec::new(),
|
||||||
dir_entry_ref: None,
|
delegated_from: Vec::new(),
|
||||||
language: None,
|
sent_by: None,
|
||||||
}).collect();
|
dir_entry_ref: None,
|
||||||
|
language: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
event.categories = categories;
|
event.categories = categories;
|
||||||
event.rrule = rrule;
|
event.rrule = rrule;
|
||||||
event.all_day = request.all_day;
|
event.all_day = request.all_day;
|
||||||
event.alarms = alarms.into_iter().map(|reminder| VAlarm {
|
event.alarms = alarms
|
||||||
action: AlarmAction::Display,
|
.into_iter()
|
||||||
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(-reminder.minutes_before as i64)),
|
.map(|reminder| VAlarm {
|
||||||
duration: None,
|
action: AlarmAction::Display,
|
||||||
repeat: None,
|
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(
|
||||||
description: reminder.description,
|
-reminder.minutes_before as i64,
|
||||||
summary: None,
|
)),
|
||||||
attendees: Vec::new(),
|
duration: None,
|
||||||
attach: Vec::new(),
|
repeat: None,
|
||||||
}).collect();
|
description: reminder.description,
|
||||||
|
summary: None,
|
||||||
|
attendees: Vec::new(),
|
||||||
|
attach: Vec::new(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
event.calendar_path = Some(calendar_path.clone());
|
event.calendar_path = Some(calendar_path.clone());
|
||||||
|
|
||||||
// Create the event on the CalDAV server
|
// Create the event on the CalDAV server
|
||||||
let event_href = client.create_event(&calendar_path, &event)
|
let event_href = client
|
||||||
|
.create_event(&calendar_path, &event)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
|
||||||
|
|
||||||
println!("✅ Event created successfully with UID: {} at href: {}", event.uid, event_href);
|
println!(
|
||||||
|
"✅ Event created successfully with UID: {} at href: {}",
|
||||||
|
event.uid, event_href
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Json(CreateEventResponse {
|
Ok(Json(CreateEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -505,7 +629,7 @@ pub async fn update_event(
|
|||||||
Json(request): Json<UpdateEventRequest>,
|
Json(request): Json<UpdateEventRequest>,
|
||||||
) -> Result<Json<UpdateEventResponse>, ApiError> {
|
) -> Result<Json<UpdateEventResponse>, ApiError> {
|
||||||
// Handle update request
|
// Handle update request
|
||||||
|
|
||||||
// Extract and verify token
|
// Extract and verify token
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
@@ -514,37 +638,45 @@ pub async fn update_event(
|
|||||||
if request.uid.trim().is_empty() {
|
if request.uid.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest("Event UID is required".to_string()));
|
return Err(ApiError::BadRequest("Event UID is required".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.title.trim().is_empty() {
|
if request.title.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest("Event title is required".to_string()));
|
return Err(ApiError::BadRequest("Event title is required".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.title.len() > 200 {
|
if request.title.len() > 200 {
|
||||||
return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Event title too long (max 200 characters)".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Find the event across all calendars (or in the specified calendar)
|
// Find the event across all calendars (or in the specified calendar)
|
||||||
let calendar_paths = if let Some(path) = &request.calendar_path {
|
let calendar_paths = if let Some(path) = &request.calendar_path {
|
||||||
vec![path.clone()]
|
vec![path.clone()]
|
||||||
} else {
|
} else {
|
||||||
client.discover_calendars()
|
client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, event_href)
|
let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, event_href)
|
||||||
|
|
||||||
for calendar_path in &calendar_paths {
|
for calendar_path in &calendar_paths {
|
||||||
match client.fetch_events(calendar_path).await {
|
match client.fetch_events(calendar_path).await {
|
||||||
Ok(events) => {
|
Ok(events) => {
|
||||||
for event in events {
|
for event in events {
|
||||||
if event.uid == request.uid {
|
if event.uid == request.uid {
|
||||||
// Use the actual href from the event, or generate one if missing
|
// Use the actual href from the event, or generate one if missing
|
||||||
let event_href = event.href.clone().unwrap_or_else(|| format!("{}.ics", event.uid));
|
let event_href = event
|
||||||
|
.href
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{}.ics", event.uid));
|
||||||
println!("🔍 Found event {} with href: {}", event.uid, event_href);
|
println!("🔍 Found event {} with href: {}", event.uid, event_href);
|
||||||
found_event = Some((event, calendar_path.clone(), event_href));
|
found_event = Some((event, calendar_path.clone(), event_href));
|
||||||
break;
|
break;
|
||||||
@@ -553,9 +685,12 @@ pub async fn update_event(
|
|||||||
if found_event.is_some() {
|
if found_event.is_some() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e);
|
eprintln!(
|
||||||
|
"Failed to fetch events from calendar {}: {}",
|
||||||
|
calendar_path, e
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,23 +700,38 @@ pub async fn update_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", request.uid)))?;
|
||||||
|
|
||||||
// Parse dates and times
|
// Parse dates and times
|
||||||
let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
let start_datetime =
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
||||||
|
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
||||||
|
|
||||||
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
|
|
||||||
// Validate that end is after start
|
// Validate that end is after start
|
||||||
if end_datetime <= start_datetime {
|
if end_datetime <= start_datetime {
|
||||||
return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"End date/time must be after start date/time".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update event properties
|
// Update event properties
|
||||||
event.dtstart = start_datetime;
|
event.dtstart = start_datetime;
|
||||||
event.dtend = Some(end_datetime);
|
event.dtend = Some(end_datetime);
|
||||||
event.summary = if request.title.trim().is_empty() { None } else { Some(request.title) };
|
event.summary = if request.title.trim().is_empty() {
|
||||||
event.description = if request.description.trim().is_empty() { None } else { Some(request.description) };
|
None
|
||||||
event.location = if request.location.trim().is_empty() { None } else { Some(request.location) };
|
} else {
|
||||||
|
Some(request.title)
|
||||||
|
};
|
||||||
|
event.description = if request.description.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.description)
|
||||||
|
};
|
||||||
|
event.location = if request.location.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.location)
|
||||||
|
};
|
||||||
event.all_day = request.all_day;
|
event.all_day = request.all_day;
|
||||||
|
|
||||||
// Parse and update status
|
// Parse and update status
|
||||||
@@ -601,11 +751,15 @@ pub async fn update_event(
|
|||||||
event.priority = request.priority;
|
event.priority = request.priority;
|
||||||
|
|
||||||
// Update the event on the CalDAV server
|
// Update the event on the CalDAV server
|
||||||
println!("📝 Updating event {} at calendar_path: {}, event_href: {}", event.uid, calendar_path, event_href);
|
println!(
|
||||||
client.update_event(&calendar_path, &event, &event_href)
|
"📝 Updating event {} at calendar_path: {}, event_href: {}",
|
||||||
|
event.uid, calendar_path, event_href
|
||||||
|
);
|
||||||
|
client
|
||||||
|
.update_event(&calendar_path, &event, &event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?;
|
||||||
|
|
||||||
println!("✅ Successfully updated event {}", event.uid);
|
println!("✅ Successfully updated event {}", event.uid);
|
||||||
|
|
||||||
Ok(Json(UpdateEventResponse {
|
Ok(Json(UpdateEventResponse {
|
||||||
@@ -614,27 +768,32 @@ pub async fn update_event(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result<chrono::DateTime<chrono::Utc>, String> {
|
fn parse_event_datetime(
|
||||||
use chrono::{NaiveDate, NaiveTime, NaiveDateTime, Utc, TimeZone};
|
date_str: &str,
|
||||||
|
time_str: &str,
|
||||||
|
all_day: bool,
|
||||||
|
) -> Result<chrono::DateTime<chrono::Utc>, String> {
|
||||||
|
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
|
||||||
|
|
||||||
// Parse the date
|
// Parse the date
|
||||||
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||||
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
|
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
|
||||||
|
|
||||||
if all_day {
|
if all_day {
|
||||||
// For all-day events, use midnight UTC
|
// For all-day events, use midnight UTC
|
||||||
let datetime = date.and_hms_opt(0, 0, 0)
|
let datetime = date
|
||||||
|
.and_hms_opt(0, 0, 0)
|
||||||
.ok_or_else(|| "Failed to create midnight datetime".to_string())?;
|
.ok_or_else(|| "Failed to create midnight datetime".to_string())?;
|
||||||
Ok(Utc.from_utc_datetime(&datetime))
|
Ok(Utc.from_utc_datetime(&datetime))
|
||||||
} else {
|
} else {
|
||||||
// Parse the time
|
// Parse the time
|
||||||
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
|
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
|
||||||
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
|
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
|
||||||
|
|
||||||
// Combine date and time
|
// Combine date and time
|
||||||
let datetime = NaiveDateTime::new(date, time);
|
let datetime = NaiveDateTime::new(date, time);
|
||||||
|
|
||||||
// Assume local time and convert to UTC (in a real app, you'd want timezone support)
|
// Assume local time and convert to UTC (in a real app, you'd want timezone support)
|
||||||
Ok(Utc.from_utc_datetime(&datetime))
|
Ok(Utc.from_utc_datetime(&datetime))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,14 @@ use axum::{
|
|||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tower_http::cors::{CorsLayer, Any};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod models;
|
|
||||||
pub mod handlers;
|
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod handlers;
|
||||||
|
pub mod models;
|
||||||
|
|
||||||
use auth::AuthService;
|
use auth::AuthService;
|
||||||
|
|
||||||
@@ -22,13 +22,13 @@ pub struct AppState {
|
|||||||
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
println!("🚀 Starting Calendar Backend Server");
|
println!("🚀 Starting Calendar Backend Server");
|
||||||
|
|
||||||
// Create auth service
|
// Create auth service
|
||||||
let jwt_secret = std::env::var("JWT_SECRET")
|
let jwt_secret = std::env::var("JWT_SECRET")
|
||||||
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
|
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
|
||||||
|
|
||||||
let auth_service = AuthService::new(jwt_secret);
|
let auth_service = AuthService::new(jwt_secret);
|
||||||
|
|
||||||
let app_state = AppState { auth_service };
|
let app_state = AppState { auth_service };
|
||||||
|
|
||||||
// Build our application with routes
|
// Build our application with routes
|
||||||
@@ -46,9 +46,18 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.route("/api/calendar/events/delete", post(handlers::delete_event))
|
.route("/api/calendar/events/delete", post(handlers::delete_event))
|
||||||
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
|
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
|
||||||
// Event series-specific endpoints
|
// Event series-specific endpoints
|
||||||
.route("/api/calendar/events/series/create", post(handlers::create_event_series))
|
.route(
|
||||||
.route("/api/calendar/events/series/update", post(handlers::update_event_series))
|
"/api/calendar/events/series/create",
|
||||||
.route("/api/calendar/events/series/delete", post(handlers::delete_event_series))
|
post(handlers::create_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/update",
|
||||||
|
post(handlers::update_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/delete",
|
||||||
|
post(handlers::delete_event_series),
|
||||||
|
)
|
||||||
.layer(
|
.layer(
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
@@ -60,7 +69,7 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Start server
|
// Start server
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
||||||
println!("📡 Server listening on http://0.0.0.0:3000");
|
println!("📡 Server listening on http://0.0.0.0:3000");
|
||||||
|
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -76,4 +85,4 @@ async fn health_check() -> Json<serde_json::Value> {
|
|||||||
"service": "calendar-backend",
|
"service": "calendar-backend",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ use calendar_backend::*;
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
run_server().await
|
run_server().await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,21 +76,21 @@ pub struct DeleteEventResponse {
|
|||||||
pub struct CreateEventRequest {
|
pub struct CreateEventRequest {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
pub recurrence: String, // recurrence type
|
pub recurrence: String, // recurrence type
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,24 +103,24 @@ pub struct CreateEventResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateEventRequest {
|
pub struct UpdateEventRequest {
|
||||||
pub uid: String, // Event UID to identify which event to update
|
pub uid: String, // Event UID to identify which event to update
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
pub recurrence: String, // recurrence type
|
pub recurrence: String, // recurrence type
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
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 calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
pub update_action: Option<String>, // "update_series" for recurring events
|
pub update_action: Option<String>, // "update_series" for recurring events
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -139,22 +139,22 @@ pub struct UpdateEventResponse {
|
|||||||
pub struct CreateEventSeriesRequest {
|
pub struct CreateEventSeriesRequest {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
|
|
||||||
// Series-specific fields
|
// Series-specific fields
|
||||||
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
||||||
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||||
@@ -173,33 +173,33 @@ pub struct CreateEventSeriesResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateEventSeriesRequest {
|
pub struct UpdateEventSeriesRequest {
|
||||||
pub series_uid: String, // Series UID to identify which series to update
|
pub series_uid: String, // Series UID to identify which series to update
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
|
|
||||||
// Series-specific fields
|
// Series-specific fields
|
||||||
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
||||||
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||||
pub recurrence_count: Option<u32>, // Number of occurrences
|
pub recurrence_count: Option<u32>, // Number of occurrences
|
||||||
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
|
|
||||||
// Update scope control
|
// Update scope control
|
||||||
pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
|
pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
|
||||||
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
|
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
|
||||||
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
|
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
|
||||||
}
|
}
|
||||||
@@ -214,12 +214,12 @@ pub struct UpdateEventSeriesResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct DeleteEventSeriesRequest {
|
pub struct DeleteEventSeriesRequest {
|
||||||
pub series_uid: String, // Series UID to identify which series to delete
|
pub series_uid: String, // Series UID to identify which series to delete
|
||||||
pub calendar_path: String,
|
pub calendar_path: String,
|
||||||
pub event_href: String,
|
pub event_href: String,
|
||||||
|
|
||||||
// Delete scope control
|
// Delete scope control
|
||||||
pub delete_scope: String, // "this_only", "this_and_future", "all_in_series"
|
pub delete_scope: String, // "this_only", "this_and_future", "all_in_series"
|
||||||
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being deleted
|
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being deleted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,4 +274,4 @@ impl std::fmt::Display for ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for ApiError {}
|
impl std::error::Error for ApiError {}
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
use calendar_backend::AppState;
|
|
||||||
use calendar_backend::auth::AuthService;
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::time::sleep;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
response::Json,
|
response::Json,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tower_http::cors::{CorsLayer, Any};
|
use calendar_backend::auth::AuthService;
|
||||||
|
use calendar_backend::AppState;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde_json::json;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
/// Test utilities for integration testing
|
/// Test utilities for integration testing
|
||||||
mod test_utils {
|
mod test_utils {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
pub struct TestServer {
|
pub struct TestServer {
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub client: Client,
|
pub client: Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestServer {
|
impl TestServer {
|
||||||
pub async fn start() -> Self {
|
pub async fn start() -> Self {
|
||||||
// Create auth service
|
// Create auth service
|
||||||
@@ -33,19 +33,55 @@ mod test_utils {
|
|||||||
.route("/", get(root))
|
.route("/", get(root))
|
||||||
.route("/api/health", get(health_check))
|
.route("/api/health", get(health_check))
|
||||||
.route("/api/auth/login", post(calendar_backend::handlers::login))
|
.route("/api/auth/login", post(calendar_backend::handlers::login))
|
||||||
.route("/api/auth/verify", get(calendar_backend::handlers::verify_token))
|
.route(
|
||||||
.route("/api/user/info", get(calendar_backend::handlers::get_user_info))
|
"/api/auth/verify",
|
||||||
.route("/api/calendar/create", post(calendar_backend::handlers::create_calendar))
|
get(calendar_backend::handlers::verify_token),
|
||||||
.route("/api/calendar/delete", post(calendar_backend::handlers::delete_calendar))
|
)
|
||||||
.route("/api/calendar/events", get(calendar_backend::handlers::get_calendar_events))
|
.route(
|
||||||
.route("/api/calendar/events/create", post(calendar_backend::handlers::create_event))
|
"/api/user/info",
|
||||||
.route("/api/calendar/events/update", post(calendar_backend::handlers::update_event))
|
get(calendar_backend::handlers::get_user_info),
|
||||||
.route("/api/calendar/events/delete", post(calendar_backend::handlers::delete_event))
|
)
|
||||||
.route("/api/calendar/events/:uid", get(calendar_backend::handlers::refresh_event))
|
.route(
|
||||||
|
"/api/calendar/create",
|
||||||
|
post(calendar_backend::handlers::create_calendar),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/delete",
|
||||||
|
post(calendar_backend::handlers::delete_calendar),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events",
|
||||||
|
get(calendar_backend::handlers::get_calendar_events),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/create",
|
||||||
|
post(calendar_backend::handlers::create_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/update",
|
||||||
|
post(calendar_backend::handlers::update_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/delete",
|
||||||
|
post(calendar_backend::handlers::delete_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/:uid",
|
||||||
|
get(calendar_backend::handlers::refresh_event),
|
||||||
|
)
|
||||||
// Event series-specific endpoints
|
// Event series-specific endpoints
|
||||||
.route("/api/calendar/events/series/create", post(calendar_backend::handlers::create_event_series))
|
.route(
|
||||||
.route("/api/calendar/events/series/update", post(calendar_backend::handlers::update_event_series))
|
"/api/calendar/events/series/create",
|
||||||
.route("/api/calendar/events/series/delete", post(calendar_backend::handlers::delete_event_series))
|
post(calendar_backend::handlers::create_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/update",
|
||||||
|
post(calendar_backend::handlers::update_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/delete",
|
||||||
|
post(calendar_backend::handlers::delete_event_series),
|
||||||
|
)
|
||||||
.layer(
|
.layer(
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
@@ -58,39 +94,47 @@ mod test_utils {
|
|||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
let base_url = format!("http://127.0.0.1:{}", addr.port());
|
let base_url = format!("http://127.0.0.1:{}", addr.port());
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for server to start
|
// Wait for server to start
|
||||||
sleep(Duration::from_millis(100)).await;
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
TestServer { base_url, client }
|
TestServer { base_url, client }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(&self) -> String {
|
pub async fn login(&self) -> String {
|
||||||
let login_payload = json!({
|
let login_payload = json!({
|
||||||
"username": "test".to_string(),
|
"username": "test".to_string(),
|
||||||
"password": "test".to_string(),
|
"password": "test".to_string(),
|
||||||
"server_url": "https://example.com".to_string()
|
"server_url": "https://example.com".to_string()
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = self.client
|
let response = self
|
||||||
|
.client
|
||||||
.post(&format!("{}/api/auth/login", self.base_url))
|
.post(&format!("{}/api/auth/login", self.base_url))
|
||||||
.json(&login_payload)
|
.json(&login_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("Failed to send login request");
|
.expect("Failed to send login request");
|
||||||
|
|
||||||
assert!(response.status().is_success(), "Login failed with status: {}", response.status());
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"Login failed with status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
let login_response: serde_json::Value = response.json().await.unwrap();
|
let login_response: serde_json::Value = response.json().await.unwrap();
|
||||||
login_response["token"].as_str().expect("Login response should contain token").to_string()
|
login_response["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("Login response should contain token")
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn root() -> &'static str {
|
async fn root() -> &'static str {
|
||||||
"Calendar Backend API v0.1.0"
|
"Calendar Backend API v0.1.0"
|
||||||
}
|
}
|
||||||
@@ -106,26 +150,27 @@ mod test_utils {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
|
||||||
use super::test_utils::*;
|
use super::test_utils::*;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
/// Test the health endpoint
|
/// Test the health endpoint
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_health_endpoint() {
|
async fn test_health_endpoint() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/health", server.base_url))
|
.get(&format!("{}/api/health", server.base_url))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), 200);
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
let health_response: serde_json::Value = response.json().await.unwrap();
|
let health_response: serde_json::Value = response.json().await.unwrap();
|
||||||
assert_eq!(health_response["status"], "healthy");
|
assert_eq!(health_response["status"], "healthy");
|
||||||
assert_eq!(health_response["service"], "calendar-backend");
|
assert_eq!(health_response["service"], "calendar-backend");
|
||||||
|
|
||||||
println!("✓ Health endpoint test passed");
|
println!("✓ Health endpoint test passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,31 +178,42 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_auth_login() {
|
async fn test_auth_login() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// Use test credentials
|
// Use test credentials
|
||||||
let username = "test".to_string();
|
let username = "test".to_string();
|
||||||
let password = "test".to_string();
|
let password = "test".to_string();
|
||||||
let server_url = "https://example.com".to_string();
|
let server_url = "https://example.com".to_string();
|
||||||
|
|
||||||
let login_payload = json!({
|
let login_payload = json!({
|
||||||
"username": username,
|
"username": username,
|
||||||
"password": password,
|
"password": password,
|
||||||
"server_url": server_url
|
"server_url": server_url
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.post(&format!("{}/api/auth/login", server.base_url))
|
.post(&format!("{}/api/auth/login", server.base_url))
|
||||||
.json(&login_payload)
|
.json(&login_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(response.status().is_success(), "Login failed with status: {}", response.status());
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"Login failed with status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
let login_response: serde_json::Value = response.json().await.unwrap();
|
let login_response: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(login_response["token"].is_string(), "Login response should contain a token");
|
assert!(
|
||||||
assert!(login_response["username"].is_string(), "Login response should contain username");
|
login_response["token"].is_string(),
|
||||||
|
"Login response should contain a token"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
login_response["username"].is_string(),
|
||||||
|
"Login response should contain username"
|
||||||
|
);
|
||||||
|
|
||||||
println!("✓ Authentication login test passed");
|
println!("✓ Authentication login test passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,52 +221,57 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_auth_verify() {
|
async fn test_auth_verify() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/auth/verify", server.base_url))
|
.get(&format!("{}/api/auth/verify", server.base_url))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), 200);
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
let verify_response: serde_json::Value = response.json().await.unwrap();
|
let verify_response: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(verify_response["valid"].as_bool().unwrap_or(false));
|
assert!(verify_response["valid"].as_bool().unwrap_or(false));
|
||||||
|
|
||||||
println!("✓ Authentication verify test passed");
|
println!("✓ Authentication verify test passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test user info endpoint
|
/// Test user info endpoint
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_user_info() {
|
async fn test_user_info() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = "test".to_string();
|
let password = "test".to_string();
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/user/info", server.base_url))
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Note: This might fail if CalDAV server discovery fails, which can happen
|
// Note: This might fail if CalDAV server discovery fails, which can happen
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
let user_info: serde_json::Value = response.json().await.unwrap();
|
let user_info: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(user_info["username"].is_string());
|
assert!(user_info["username"].is_string());
|
||||||
println!("✓ User info test passed");
|
println!("✓ User info test passed");
|
||||||
} else {
|
} else {
|
||||||
println!("⚠ User info test skipped (CalDAV server issues): {}", response.status());
|
println!(
|
||||||
|
"⚠ User info test skipped (CalDAV server issues): {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,48 +279,59 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_calendar_events() {
|
async fn test_get_calendar_events() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = "test".to_string();
|
let password = "test".to_string();
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.get(&format!("{}/api/calendar/events?year=2024&month=12", server.base_url))
|
.client
|
||||||
|
.get(&format!(
|
||||||
|
"{}/api/calendar/events?year=2024&month=12",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(response.status().is_success(), "Get events failed with status: {}", response.status());
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"Get events failed with status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
let events: serde_json::Value = response.json().await.unwrap();
|
let events: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(events.is_array());
|
assert!(events.is_array());
|
||||||
|
|
||||||
println!("✓ Get calendar events test passed (found {} events)", events.as_array().unwrap().len());
|
println!(
|
||||||
|
"✓ Get calendar events test passed (found {} events)",
|
||||||
|
events.as_array().unwrap().len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test event creation endpoint
|
/// Test event creation endpoint
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_create_event() {
|
async fn test_create_event() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = "test".to_string();
|
let password = "test".to_string();
|
||||||
|
|
||||||
let create_payload = json!({
|
let create_payload = json!({
|
||||||
"title": "Integration Test Event",
|
"title": "Integration Test Event",
|
||||||
"description": "Created by integration test",
|
"description": "Created by integration test",
|
||||||
"start_date": "2024-12-25",
|
"start_date": "2024-12-25",
|
||||||
"start_time": "10:00",
|
"start_time": "10:00",
|
||||||
"end_date": "2024-12-25",
|
"end_date": "2024-12-25",
|
||||||
"end_time": "11:00",
|
"end_time": "11:00",
|
||||||
"location": "Test Location",
|
"location": "Test Location",
|
||||||
"all_day": false,
|
"all_day": false,
|
||||||
@@ -273,8 +345,9 @@ mod tests {
|
|||||||
"recurrence": "none",
|
"recurrence": "none",
|
||||||
"recurrence_days": [false, false, false, false, false, false, false]
|
"recurrence_days": [false, false, false, false, false, false, false]
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.post(&format!("{}/api/calendar/events/create", server.base_url))
|
.post(&format!("{}/api/calendar/events/create", server.base_url))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
@@ -282,10 +355,10 @@ mod tests {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
println!("Create event response status: {}", status);
|
println!("Create event response status: {}", status);
|
||||||
|
|
||||||
// Note: This might fail if CalDAV server is not accessible, which is expected in CI
|
// Note: This might fail if CalDAV server is not accessible, which is expected in CI
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
let create_response: serde_json::Value = response.json().await.unwrap();
|
let create_response: serde_json::Value = response.json().await.unwrap();
|
||||||
@@ -300,47 +373,58 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_refresh_event() {
|
async fn test_refresh_event() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = "test".to_string();
|
let password = "test".to_string();
|
||||||
|
|
||||||
// Use a dummy UID for testing - this will likely return 404 but we're testing the endpoint structure
|
// Use a dummy UID for testing - this will likely return 404 but we're testing the endpoint structure
|
||||||
let test_uid = "test-event-uid";
|
let test_uid = "test-event-uid";
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.get(&format!("{}/api/calendar/events/{}", server.base_url, test_uid))
|
.client
|
||||||
|
.get(&format!(
|
||||||
|
"{}/api/calendar/events/{}",
|
||||||
|
server.base_url, test_uid
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// We expect either 200 (if event exists) or 404 (if not found) - both are valid responses
|
// We expect either 200 (if event exists) or 404 (if not found) - both are valid responses
|
||||||
assert!(response.status() == 200 || response.status() == 404,
|
assert!(
|
||||||
"Refresh event failed with unexpected status: {}", response.status());
|
response.status() == 200 || response.status() == 404,
|
||||||
|
"Refresh event failed with unexpected status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
println!("✓ Refresh event endpoint test passed");
|
println!("✓ Refresh event endpoint test passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test invalid authentication
|
/// Test invalid authentication
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_invalid_auth() {
|
async fn test_invalid_auth() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/user/info", server.base_url))
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
.header("Authorization", "Bearer invalid-token")
|
.header("Authorization", "Bearer invalid-token")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Accept both 400 and 401 as valid responses for invalid tokens
|
// Accept both 400 and 401 as valid responses for invalid tokens
|
||||||
assert!(response.status() == 401 || response.status() == 400,
|
assert!(
|
||||||
"Expected 401 or 400 for invalid token, got {}", response.status());
|
response.status() == 401 || response.status() == 400,
|
||||||
|
"Expected 401 or 400 for invalid token, got {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
println!("✓ Invalid authentication test passed");
|
println!("✓ Invalid authentication test passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,13 +432,14 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_missing_auth() {
|
async fn test_missing_auth() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/user/info", server.base_url))
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), 401);
|
assert_eq!(response.status(), 401);
|
||||||
println!("✓ Missing authentication test passed");
|
println!("✓ Missing authentication test passed");
|
||||||
}
|
}
|
||||||
@@ -365,20 +450,20 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_create_event_series() {
|
async fn test_create_event_series() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = "test".to_string();
|
let password = "test".to_string();
|
||||||
|
|
||||||
let create_payload = json!({
|
let create_payload = json!({
|
||||||
"title": "Integration Test Series",
|
"title": "Integration Test Series",
|
||||||
"description": "Created by integration test for series",
|
"description": "Created by integration test for series",
|
||||||
"start_date": "2024-12-25",
|
"start_date": "2024-12-25",
|
||||||
"start_time": "10:00",
|
"start_time": "10:00",
|
||||||
"end_date": "2024-12-25",
|
"end_date": "2024-12-25",
|
||||||
"end_time": "11:00",
|
"end_time": "11:00",
|
||||||
"location": "Test Series Location",
|
"location": "Test Series Location",
|
||||||
"all_day": false,
|
"all_day": false,
|
||||||
@@ -395,19 +480,23 @@ mod tests {
|
|||||||
"recurrence_count": 4,
|
"recurrence_count": 4,
|
||||||
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/create", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/create",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.json(&create_payload)
|
.json(&create_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
println!("Create series response status: {}", status);
|
println!("Create series response status: {}", status);
|
||||||
|
|
||||||
// Note: This might fail if CalDAV server is not accessible, which is expected in CI
|
// Note: This might fail if CalDAV server is not accessible, which is expected in CI
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
let create_response: serde_json::Value = response.json().await.unwrap();
|
let create_response: serde_json::Value = response.json().await.unwrap();
|
||||||
@@ -420,24 +509,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Test event series update endpoint
|
/// Test event series update endpoint
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_update_event_series() {
|
async fn test_update_event_series() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = "test".to_string();
|
let password = "test".to_string();
|
||||||
|
|
||||||
let update_payload = json!({
|
let update_payload = json!({
|
||||||
"series_uid": "test-series-uid",
|
"series_uid": "test-series-uid",
|
||||||
"title": "Updated Series Title",
|
"title": "Updated Series Title",
|
||||||
"description": "Updated by integration test",
|
"description": "Updated by integration test",
|
||||||
"start_date": "2024-12-26",
|
"start_date": "2024-12-26",
|
||||||
"start_time": "14:00",
|
"start_time": "14:00",
|
||||||
"end_date": "2024-12-26",
|
"end_date": "2024-12-26",
|
||||||
"end_time": "15:00",
|
"end_time": "15:00",
|
||||||
"location": "Updated Location",
|
"location": "Updated Location",
|
||||||
"all_day": false,
|
"all_day": false,
|
||||||
@@ -455,27 +544,36 @@ mod tests {
|
|||||||
"update_scope": "all_in_series",
|
"update_scope": "all_in_series",
|
||||||
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/update", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/update",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.json(&update_payload)
|
.json(&update_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
println!("Update series response status: {}", status);
|
println!("Update series response status: {}", status);
|
||||||
|
|
||||||
// Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI
|
// Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
let update_response: serde_json::Value = response.json().await.unwrap();
|
let update_response: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(update_response["success"].as_bool().unwrap_or(false));
|
assert!(update_response["success"].as_bool().unwrap_or(false));
|
||||||
assert_eq!(update_response["series_uid"].as_str().unwrap(), "test-series-uid");
|
assert_eq!(
|
||||||
|
update_response["series_uid"].as_str().unwrap(),
|
||||||
|
"test-series-uid"
|
||||||
|
);
|
||||||
println!("✓ Update event series test passed");
|
println!("✓ Update event series test passed");
|
||||||
} else if status == 404 {
|
} else if status == 404 {
|
||||||
println!("⚠ Update event series test skipped (event not found - expected for test data)");
|
println!(
|
||||||
|
"⚠ Update event series test skipped (event not found - expected for test data)"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!("⚠ Update event series test skipped (CalDAV server not accessible)");
|
println!("⚠ Update event series test skipped (CalDAV server not accessible)");
|
||||||
}
|
}
|
||||||
@@ -485,40 +583,46 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_delete_event_series() {
|
async fn test_delete_event_series() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = "test".to_string();
|
let password = "test".to_string();
|
||||||
|
|
||||||
let delete_payload = json!({
|
let delete_payload = json!({
|
||||||
"series_uid": "test-series-to-delete",
|
"series_uid": "test-series-to-delete",
|
||||||
"calendar_path": "/calendars/test/default/",
|
"calendar_path": "/calendars/test/default/",
|
||||||
"event_href": "test-series.ics",
|
"event_href": "test-series.ics",
|
||||||
"delete_scope": "all_in_series"
|
"delete_scope": "all_in_series"
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/delete", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/delete",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.json(&delete_payload)
|
.json(&delete_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
println!("Delete series response status: {}", status);
|
println!("Delete series response status: {}", status);
|
||||||
|
|
||||||
// Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI
|
// Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
let delete_response: serde_json::Value = response.json().await.unwrap();
|
let delete_response: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(delete_response["success"].as_bool().unwrap_or(false));
|
assert!(delete_response["success"].as_bool().unwrap_or(false));
|
||||||
println!("✓ Delete event series test passed");
|
println!("✓ Delete event series test passed");
|
||||||
} else if status == 404 {
|
} else if status == 404 {
|
||||||
println!("⚠ Delete event series test skipped (event not found - expected for test data)");
|
println!(
|
||||||
|
"⚠ Delete event series test skipped (event not found - expected for test data)"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!("⚠ Delete event series test skipped (CalDAV server not accessible)");
|
println!("⚠ Delete event series test skipped (CalDAV server not accessible)");
|
||||||
}
|
}
|
||||||
@@ -528,17 +632,17 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_invalid_update_scope() {
|
async fn test_invalid_update_scope() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
let invalid_payload = json!({
|
let invalid_payload = json!({
|
||||||
"series_uid": "test-series-uid",
|
"series_uid": "test-series-uid",
|
||||||
"title": "Test Title",
|
"title": "Test Title",
|
||||||
"description": "Test",
|
"description": "Test",
|
||||||
"start_date": "2024-12-25",
|
"start_date": "2024-12-25",
|
||||||
"start_time": "10:00",
|
"start_time": "10:00",
|
||||||
"end_date": "2024-12-25",
|
"end_date": "2024-12-25",
|
||||||
"end_time": "11:00",
|
"end_time": "11:00",
|
||||||
"location": "Test",
|
"location": "Test",
|
||||||
"all_day": false,
|
"all_day": false,
|
||||||
@@ -552,16 +656,24 @@ mod tests {
|
|||||||
"recurrence_days": [false, false, false, false, false, false, false],
|
"recurrence_days": [false, false, false, false, false, false, false],
|
||||||
"update_scope": "invalid_scope" // This should cause a 400 error
|
"update_scope": "invalid_scope" // This should cause a 400 error
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/update", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/update",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.json(&invalid_payload)
|
.json(&invalid_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), 400, "Expected 400 for invalid update scope");
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
400,
|
||||||
|
"Expected 400 for invalid update scope"
|
||||||
|
);
|
||||||
println!("✓ Invalid update scope test passed");
|
println!("✓ Invalid update scope test passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,16 +681,16 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_non_recurring_series_rejection() {
|
async fn test_non_recurring_series_rejection() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
let non_recurring_payload = json!({
|
let non_recurring_payload = json!({
|
||||||
"title": "Non-recurring Event",
|
"title": "Non-recurring Event",
|
||||||
"description": "This should be rejected",
|
"description": "This should be rejected",
|
||||||
"start_date": "2024-12-25",
|
"start_date": "2024-12-25",
|
||||||
"start_time": "10:00",
|
"start_time": "10:00",
|
||||||
"end_date": "2024-12-25",
|
"end_date": "2024-12-25",
|
||||||
"end_time": "11:00",
|
"end_time": "11:00",
|
||||||
"location": "Test",
|
"location": "Test",
|
||||||
"all_day": false,
|
"all_day": false,
|
||||||
@@ -591,16 +703,24 @@ mod tests {
|
|||||||
"recurrence": "none", // This should cause rejection
|
"recurrence": "none", // This should cause rejection
|
||||||
"recurrence_days": [false, false, false, false, false, false, false]
|
"recurrence_days": [false, false, false, false, false, false, false]
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/create", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/create",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.json(&non_recurring_payload)
|
.json(&non_recurring_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), 400, "Expected 400 for non-recurring event in series endpoint");
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
400,
|
||||||
|
"Expected 400 for non-recurring event in series endpoint"
|
||||||
|
);
|
||||||
println!("✓ Non-recurring series rejection test passed");
|
println!("✓ Non-recurring series rejection test passed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Common types and enums used across calendar components
|
//! Common types and enums used across calendar components
|
||||||
|
|
||||||
use chrono::{DateTime, Utc, Duration};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ==================== ENUMS AND COMMON TYPES ====================
|
// ==================== ENUMS AND COMMON TYPES ====================
|
||||||
@@ -22,7 +22,7 @@ pub enum EventClass {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub enum TimeTransparency {
|
pub enum TimeTransparency {
|
||||||
Opaque, // OPAQUE - time is not available
|
Opaque, // OPAQUE - time is not available
|
||||||
Transparent, // TRANSPARENT - time is available
|
Transparent, // TRANSPARENT - time is available
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -64,11 +64,11 @@ pub enum AlarmAction {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct CalendarUser {
|
pub struct CalendarUser {
|
||||||
pub cal_address: String, // Calendar user address (usually email)
|
pub cal_address: String, // Calendar user address (usually email)
|
||||||
pub common_name: Option<String>, // CN parameter - display name
|
pub common_name: Option<String>, // CN parameter - display name
|
||||||
pub dir_entry_ref: Option<String>, // DIR parameter - directory entry
|
pub dir_entry_ref: Option<String>, // DIR parameter - directory entry
|
||||||
pub sent_by: Option<String>, // SENT-BY parameter
|
pub sent_by: Option<String>, // SENT-BY parameter
|
||||||
pub language: Option<String>, // LANGUAGE parameter
|
pub language: Option<String>, // LANGUAGE parameter
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -78,130 +78,130 @@ pub struct Attendee {
|
|||||||
pub role: Option<AttendeeRole>, // ROLE parameter
|
pub role: Option<AttendeeRole>, // ROLE parameter
|
||||||
pub part_stat: Option<ParticipationStatus>, // PARTSTAT parameter
|
pub part_stat: Option<ParticipationStatus>, // PARTSTAT parameter
|
||||||
pub rsvp: Option<bool>, // RSVP parameter
|
pub rsvp: Option<bool>, // RSVP parameter
|
||||||
pub cu_type: Option<String>, // CUTYPE parameter (INDIVIDUAL, GROUP, RESOURCE, ROOM, UNKNOWN)
|
pub cu_type: Option<String>, // CUTYPE parameter (INDIVIDUAL, GROUP, RESOURCE, ROOM, UNKNOWN)
|
||||||
pub member: Vec<String>, // MEMBER parameter
|
pub member: Vec<String>, // MEMBER parameter
|
||||||
pub delegated_to: Vec<String>, // DELEGATED-TO parameter
|
pub delegated_to: Vec<String>, // DELEGATED-TO parameter
|
||||||
pub delegated_from: Vec<String>, // DELEGATED-FROM parameter
|
pub delegated_from: Vec<String>, // DELEGATED-FROM parameter
|
||||||
pub sent_by: Option<String>, // SENT-BY parameter
|
pub sent_by: Option<String>, // SENT-BY parameter
|
||||||
pub dir_entry_ref: Option<String>, // DIR parameter
|
pub dir_entry_ref: Option<String>, // DIR parameter
|
||||||
pub language: Option<String>, // LANGUAGE parameter
|
pub language: Option<String>, // LANGUAGE parameter
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VAlarm {
|
pub struct VAlarm {
|
||||||
pub action: AlarmAction, // Action (ACTION) - REQUIRED
|
pub action: AlarmAction, // Action (ACTION) - REQUIRED
|
||||||
pub trigger: AlarmTrigger, // Trigger (TRIGGER) - REQUIRED
|
pub trigger: AlarmTrigger, // Trigger (TRIGGER) - REQUIRED
|
||||||
pub duration: Option<Duration>, // Duration (DURATION)
|
pub duration: Option<Duration>, // Duration (DURATION)
|
||||||
pub repeat: Option<u32>, // Repeat count (REPEAT)
|
pub repeat: Option<u32>, // Repeat count (REPEAT)
|
||||||
pub description: Option<String>, // Description for DISPLAY/EMAIL
|
pub description: Option<String>, // Description for DISPLAY/EMAIL
|
||||||
pub summary: Option<String>, // Summary for EMAIL
|
pub summary: Option<String>, // Summary for EMAIL
|
||||||
pub attendees: Vec<Attendee>, // Attendees for EMAIL
|
pub attendees: Vec<Attendee>, // Attendees for EMAIL
|
||||||
pub attach: Vec<Attachment>, // Attachments for AUDIO/EMAIL
|
pub attach: Vec<Attachment>, // Attachments for AUDIO/EMAIL
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub enum AlarmTrigger {
|
pub enum AlarmTrigger {
|
||||||
DateTime(DateTime<Utc>), // Absolute trigger time
|
DateTime(DateTime<Utc>), // Absolute trigger time
|
||||||
Duration(Duration), // Duration relative to start/end
|
Duration(Duration), // Duration relative to start/end
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct Attachment {
|
pub struct Attachment {
|
||||||
pub format_type: Option<String>, // FMTTYPE parameter (MIME type)
|
pub format_type: Option<String>, // FMTTYPE parameter (MIME type)
|
||||||
pub encoding: Option<String>, // ENCODING parameter
|
pub encoding: Option<String>, // ENCODING parameter
|
||||||
pub value: Option<String>, // VALUE parameter (BINARY or URI)
|
pub value: Option<String>, // VALUE parameter (BINARY or URI)
|
||||||
pub uri: Option<String>, // URI reference
|
pub uri: Option<String>, // URI reference
|
||||||
pub binary_data: Option<Vec<u8>>, // Binary data (when ENCODING=BASE64)
|
pub binary_data: Option<Vec<u8>>, // Binary data (when ENCODING=BASE64)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct GeographicPosition {
|
pub struct GeographicPosition {
|
||||||
pub latitude: f64, // Latitude in decimal degrees
|
pub latitude: f64, // Latitude in decimal degrees
|
||||||
pub longitude: f64, // Longitude in decimal degrees
|
pub longitude: f64, // Longitude in decimal degrees
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VTimeZone {
|
pub struct VTimeZone {
|
||||||
pub tzid: String, // Time zone ID (TZID) - REQUIRED
|
pub tzid: String, // Time zone ID (TZID) - REQUIRED
|
||||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||||
pub tzurl: Option<String>, // Time zone URL (TZURL)
|
pub tzurl: Option<String>, // Time zone URL (TZURL)
|
||||||
pub standard_components: Vec<TimeZoneComponent>, // STANDARD components
|
pub standard_components: Vec<TimeZoneComponent>, // STANDARD components
|
||||||
pub daylight_components: Vec<TimeZoneComponent>, // DAYLIGHT components
|
pub daylight_components: Vec<TimeZoneComponent>, // DAYLIGHT components
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct TimeZoneComponent {
|
pub struct TimeZoneComponent {
|
||||||
pub dtstart: DateTime<Utc>, // Start of this time zone definition
|
pub dtstart: DateTime<Utc>, // Start of this time zone definition
|
||||||
pub tzoffset_to: String, // UTC offset for this component
|
pub tzoffset_to: String, // UTC offset for this component
|
||||||
pub tzoffset_from: String, // UTC offset before this component
|
pub tzoffset_from: String, // UTC offset before this component
|
||||||
pub rrule: Option<String>, // Recurrence rule
|
pub rrule: Option<String>, // Recurrence rule
|
||||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates
|
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates
|
||||||
pub tzname: Vec<String>, // Time zone names
|
pub tzname: Vec<String>, // Time zone names
|
||||||
pub comment: Vec<String>, // Comments
|
pub comment: Vec<String>, // Comments
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VJournal {
|
pub struct VJournal {
|
||||||
// Required properties
|
// Required properties
|
||||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
|
|
||||||
// Optional properties
|
// Optional properties
|
||||||
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||||
pub description: Option<String>, // Description (DESCRIPTION)
|
pub description: Option<String>, // Description (DESCRIPTION)
|
||||||
|
|
||||||
// Classification and status
|
// Classification and status
|
||||||
pub class: Option<EventClass>, // Classification (CLASS)
|
pub class: Option<EventClass>, // Classification (CLASS)
|
||||||
pub status: Option<String>, // Status (STATUS)
|
pub status: Option<String>, // Status (STATUS)
|
||||||
|
|
||||||
// People and organization
|
// People and organization
|
||||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
|
|
||||||
// Categorization
|
// Categorization
|
||||||
pub categories: Vec<String>, // Categories (CATEGORIES)
|
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||||
|
|
||||||
// Versioning and modification
|
// Versioning and modification
|
||||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||||
|
|
||||||
// Recurrence
|
// Recurrence
|
||||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||||
|
|
||||||
// Attachments
|
// Attachments
|
||||||
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VFreeBusy {
|
pub struct VFreeBusy {
|
||||||
// Required properties
|
// Required properties
|
||||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
|
|
||||||
// Optional date-time properties
|
// Optional date-time properties
|
||||||
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||||
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
||||||
|
|
||||||
// People
|
// People
|
||||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
|
|
||||||
// Free/busy time
|
// Free/busy time
|
||||||
pub freebusy: Vec<FreeBusyTime>, // Free/busy time periods
|
pub freebusy: Vec<FreeBusyTime>, // Free/busy time periods
|
||||||
pub url: Option<String>, // URL (URL)
|
pub url: Option<String>, // URL (URL)
|
||||||
pub comment: Vec<String>, // Comments (COMMENT)
|
pub comment: Vec<String>, // Comments (COMMENT)
|
||||||
pub contact: Option<String>, // Contact information (CONTACT)
|
pub contact: Option<String>, // Contact information (CONTACT)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct FreeBusyTime {
|
pub struct FreeBusyTime {
|
||||||
pub fb_type: FreeBusyType, // Free/busy type
|
pub fb_type: FreeBusyType, // Free/busy type
|
||||||
pub periods: Vec<Period>, // Time periods
|
pub periods: Vec<Period>, // Time periods
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -214,7 +214,7 @@ pub enum FreeBusyType {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct Period {
|
pub struct Period {
|
||||||
pub start: DateTime<Utc>, // Period start
|
pub start: DateTime<Utc>, // Period start
|
||||||
pub end: Option<DateTime<Utc>>, // Period end
|
pub end: Option<DateTime<Utc>>, // Period end
|
||||||
pub duration: Option<Duration>, // Period duration (alternative to end)
|
pub duration: Option<Duration>, // Period duration (alternative to end)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//! RFC 5545 Compliant Calendar Models
|
//! RFC 5545 Compliant Calendar Models
|
||||||
//!
|
//!
|
||||||
//! This crate provides shared data structures for calendar applications
|
//! This crate provides shared data structures for calendar applications
|
||||||
//! that comply with RFC 5545 (iCalendar) specification.
|
//! that comply with RFC 5545 (iCalendar) specification.
|
||||||
|
|
||||||
pub mod vevent;
|
|
||||||
pub mod common;
|
pub mod common;
|
||||||
|
pub mod vevent;
|
||||||
|
|
||||||
|
pub use common::*;
|
||||||
pub use vevent::*;
|
pub use vevent::*;
|
||||||
pub use common::*;
|
|
||||||
@@ -1,66 +1,66 @@
|
|||||||
//! VEvent - RFC 5545 compliant calendar event structure
|
//! VEvent - RFC 5545 compliant calendar event structure
|
||||||
|
|
||||||
use chrono::{DateTime, Utc, Duration};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use crate::common::*;
|
use crate::common::*;
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ==================== VEVENT COMPONENT ====================
|
// ==================== VEVENT COMPONENT ====================
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VEvent {
|
pub struct VEvent {
|
||||||
// Required properties
|
// Required properties
|
||||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED
|
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED
|
||||||
|
|
||||||
// Optional properties (commonly used)
|
// Optional properties (commonly used)
|
||||||
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
||||||
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
||||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||||
pub description: Option<String>, // Description (DESCRIPTION)
|
pub description: Option<String>, // Description (DESCRIPTION)
|
||||||
pub location: Option<String>, // Location (LOCATION)
|
pub location: Option<String>, // Location (LOCATION)
|
||||||
|
|
||||||
// Classification and status
|
// Classification and status
|
||||||
pub class: Option<EventClass>, // Classification (CLASS)
|
pub class: Option<EventClass>, // Classification (CLASS)
|
||||||
pub status: Option<EventStatus>, // Status (STATUS)
|
pub status: Option<EventStatus>, // Status (STATUS)
|
||||||
pub transp: Option<TimeTransparency>, // Time transparency (TRANSP)
|
pub transp: Option<TimeTransparency>, // Time transparency (TRANSP)
|
||||||
pub priority: Option<u8>, // Priority 0-9 (PRIORITY)
|
pub priority: Option<u8>, // Priority 0-9 (PRIORITY)
|
||||||
|
|
||||||
// People and organization
|
// People and organization
|
||||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
pub contact: Option<String>, // Contact information (CONTACT)
|
pub contact: Option<String>, // Contact information (CONTACT)
|
||||||
|
|
||||||
// Categorization and relationships
|
// Categorization and relationships
|
||||||
pub categories: Vec<String>, // Categories (CATEGORIES)
|
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||||
pub comment: Option<String>, // Comment (COMMENT)
|
pub comment: Option<String>, // Comment (COMMENT)
|
||||||
pub resources: Vec<String>, // Resources (RESOURCES)
|
pub resources: Vec<String>, // Resources (RESOURCES)
|
||||||
pub related_to: Option<String>, // Related component (RELATED-TO)
|
pub related_to: Option<String>, // Related component (RELATED-TO)
|
||||||
pub url: Option<String>, // URL (URL)
|
pub url: Option<String>, // URL (URL)
|
||||||
|
|
||||||
// Geographical
|
// Geographical
|
||||||
pub geo: Option<GeographicPosition>, // Geographic position (GEO)
|
pub geo: Option<GeographicPosition>, // Geographic position (GEO)
|
||||||
|
|
||||||
// Versioning and modification
|
// Versioning and modification
|
||||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||||
|
|
||||||
// Recurrence
|
// Recurrence
|
||||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||||
|
|
||||||
// Alarms and attachments
|
// Alarms and attachments
|
||||||
pub alarms: Vec<VAlarm>, // VALARM components
|
pub alarms: Vec<VAlarm>, // VALARM components
|
||||||
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||||
|
|
||||||
// CalDAV specific (for implementation)
|
// CalDAV specific (for implementation)
|
||||||
pub etag: Option<String>, // ETag for CalDAV
|
pub etag: Option<String>, // ETag for CalDAV
|
||||||
pub href: Option<String>, // Href for CalDAV
|
pub href: Option<String>, // Href for CalDAV
|
||||||
pub calendar_path: Option<String>, // Calendar path
|
pub calendar_path: Option<String>, // Calendar path
|
||||||
pub all_day: bool, // All-day event flag
|
pub all_day: bool, // All-day event flag
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VEvent {
|
impl VEvent {
|
||||||
@@ -129,7 +129,9 @@ impl VEvent {
|
|||||||
|
|
||||||
/// Helper method to get display title (summary or "Untitled Event")
|
/// Helper method to get display title (summary or "Untitled Event")
|
||||||
pub fn get_title(&self) -> String {
|
pub fn get_title(&self) -> String {
|
||||||
self.summary.clone().unwrap_or_else(|| "Untitled Event".to_string())
|
self.summary
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Untitled Event".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper method to get start date for UI compatibility
|
/// Helper method to get start date for UI compatibility
|
||||||
@@ -151,7 +153,7 @@ impl VEvent {
|
|||||||
pub fn get_status_display(&self) -> &'static str {
|
pub fn get_status_display(&self) -> &'static str {
|
||||||
match &self.status {
|
match &self.status {
|
||||||
Some(EventStatus::Tentative) => "Tentative",
|
Some(EventStatus::Tentative) => "Tentative",
|
||||||
Some(EventStatus::Confirmed) => "Confirmed",
|
Some(EventStatus::Confirmed) => "Confirmed",
|
||||||
Some(EventStatus::Cancelled) => "Cancelled",
|
Some(EventStatus::Cancelled) => "Cancelled",
|
||||||
None => "Confirmed", // Default
|
None => "Confirmed", // Default
|
||||||
}
|
}
|
||||||
@@ -180,4 +182,4 @@ impl VEvent {
|
|||||||
Some(p) => format!("Priority {}", p),
|
Some(p) => format!("Priority {}", p),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,14 +34,14 @@ impl AuthService {
|
|||||||
let base_url = option_env!("BACKEND_API_URL")
|
let base_url = option_env!("BACKEND_API_URL")
|
||||||
.unwrap_or("http://localhost:3000/api")
|
.unwrap_or("http://localhost:3000/api")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
Self { base_url }
|
Self { base_url }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, String> {
|
pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, String> {
|
||||||
self.post_json("/auth/login", &request).await
|
self.post_json("/auth/login", &request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method for POST requests with JSON body
|
// Helper method for POST requests with JSON body
|
||||||
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
|
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
|
||||||
&self,
|
&self,
|
||||||
@@ -49,9 +49,9 @@ impl AuthService {
|
|||||||
body: &T,
|
body: &T,
|
||||||
) -> Result<R, String> {
|
) -> Result<R, String> {
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
let json_body = serde_json::to_string(body)
|
let json_body =
|
||||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
serde_json::to_string(body).map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||||
|
|
||||||
let opts = RequestInit::new();
|
let opts = RequestInit::new();
|
||||||
opts.set_method("POST");
|
opts.set_method("POST");
|
||||||
@@ -62,23 +62,27 @@ impl AuthService {
|
|||||||
let request = Request::new_with_str_and_init(&url, &opts)
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
request.headers().set("Content-Type", "application/json")
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||||
|
|
||||||
let resp: Response = resp_value.dyn_into()
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||||
|
|
||||||
let text = JsFuture::from(resp.text()
|
let text = JsFuture::from(
|
||||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
resp.text()
|
||||||
.await
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?,
|
||||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||||
|
|
||||||
let text_string = text.as_string()
|
let text_string = text.as_string().ok_or("Response text is not a string")?;
|
||||||
.ok_or("Response text is not a string")?;
|
|
||||||
|
|
||||||
if resp.ok() {
|
if resp.ok() {
|
||||||
serde_json::from_str::<R>(&text_string)
|
serde_json::from_str::<R>(&text_string)
|
||||||
@@ -92,4 +96,4 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
use yew::prelude::*;
|
use crate::components::{
|
||||||
use chrono::{Datelike, Local, NaiveDate, Duration};
|
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
|
||||||
|
};
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::{calendar_service::UserInfo, CalendarService};
|
||||||
|
use chrono::{Datelike, Duration, Local, NaiveDate};
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
use crate::services::calendar_service::UserInfo;
|
use yew::prelude::*;
|
||||||
use crate::models::ical::VEvent;
|
|
||||||
use crate::components::{EventModal, ViewMode, CalendarHeader, MonthView, WeekView, CreateEventModal, EventCreationData};
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarProps {
|
pub struct CalendarProps {
|
||||||
#[prop_or_default]
|
|
||||||
pub events: HashMap<NaiveDate, Vec<VEvent>>,
|
|
||||||
pub on_event_click: Callback<VEvent>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub refreshing_event_uid: Option<String>,
|
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
@@ -25,7 +22,17 @@ pub struct CalendarProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
|
pub on_event_update_request: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
}
|
}
|
||||||
@@ -33,6 +40,12 @@ pub struct CalendarProps {
|
|||||||
#[function_component]
|
#[function_component]
|
||||||
pub fn Calendar(props: &CalendarProps) -> Html {
|
pub fn Calendar(props: &CalendarProps) -> Html {
|
||||||
let today = Local::now().date_naive();
|
let today = Local::now().date_naive();
|
||||||
|
|
||||||
|
// Event management state
|
||||||
|
let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new());
|
||||||
|
let loading = use_state(|| true);
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
let refreshing_event_uid = use_state(|| None::<String>);
|
||||||
// Track the currently selected date (the actual day the user has selected)
|
// Track the currently selected date (the actual day the user has selected)
|
||||||
let selected_date = use_state(|| {
|
let selected_date = use_state(|| {
|
||||||
// Try to load saved selected date from localStorage
|
// Try to load saved selected date from localStorage
|
||||||
@@ -55,20 +68,19 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track the display date (what to show in the view)
|
// Track the display date (what to show in the view)
|
||||||
let current_date = use_state(|| {
|
let current_date = use_state(|| match props.view {
|
||||||
match props.view {
|
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
|
||||||
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
|
ViewMode::Week => *selected_date,
|
||||||
ViewMode::Week => *selected_date,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
let selected_event = use_state(|| None::<VEvent>);
|
let selected_event = use_state(|| None::<VEvent>);
|
||||||
|
|
||||||
// State for create event modal
|
// State for create event modal
|
||||||
let show_create_modal = use_state(|| false);
|
let show_create_modal = use_state(|| false);
|
||||||
let create_event_data = use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>);
|
let create_event_data =
|
||||||
|
use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>);
|
||||||
|
|
||||||
// State for time increment snapping (15 or 30 minutes)
|
// State for time increment snapping (15 or 30 minutes)
|
||||||
let time_increment = use_state(|| {
|
let time_increment = use_state(|| {
|
||||||
// Try to load saved time increment from localStorage
|
// Try to load saved time increment from localStorage
|
||||||
@@ -82,7 +94,155 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
15
|
15
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch events when current_date changes
|
||||||
|
{
|
||||||
|
let events = events.clone();
|
||||||
|
let loading = loading.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let current_date = current_date.clone();
|
||||||
|
|
||||||
|
use_effect_with((*current_date, props.view.clone()), move |(date, _view)| {
|
||||||
|
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||||
|
let date = *date; // Clone the date to avoid lifetime issues
|
||||||
|
|
||||||
|
if let Some(token) = auth_token {
|
||||||
|
let events = events.clone();
|
||||||
|
let loading = loading.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let calendar_service = CalendarService::new();
|
||||||
|
|
||||||
|
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 {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_year = date.year();
|
||||||
|
let current_month = date.month();
|
||||||
|
|
||||||
|
match calendar_service
|
||||||
|
.fetch_events_for_month_vevent(
|
||||||
|
&token,
|
||||||
|
&password,
|
||||||
|
current_year,
|
||||||
|
current_month,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(vevents) => {
|
||||||
|
let grouped_events = CalendarService::group_events_by_date(vevents);
|
||||||
|
events.set(grouped_events);
|
||||||
|
loading.set(false);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error.set(Some(format!("Failed to load events: {}", err)));
|
||||||
|
loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
loading.set(false);
|
||||||
|
error.set(Some("No authentication token found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
|| ()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle event click to refresh individual events
|
||||||
|
let on_event_click = {
|
||||||
|
let events = events.clone();
|
||||||
|
let refreshing_event_uid = refreshing_event_uid.clone();
|
||||||
|
|
||||||
|
Callback::from(move |event: VEvent| {
|
||||||
|
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||||
|
|
||||||
|
if let Some(token) = auth_token {
|
||||||
|
let events = events.clone();
|
||||||
|
let refreshing_event_uid = refreshing_event_uid.clone();
|
||||||
|
let uid = event.uid.clone();
|
||||||
|
|
||||||
|
refreshing_event_uid.set(Some(uid.clone()));
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let calendar_service = CalendarService::new();
|
||||||
|
|
||||||
|
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 {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
match calendar_service
|
||||||
|
.refresh_event(&token, &password, &uid)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(refreshed_event)) => {
|
||||||
|
let refreshed_vevent = refreshed_event;
|
||||||
|
let mut updated_events = (*events).clone();
|
||||||
|
|
||||||
|
for (_, day_events) in updated_events.iter_mut() {
|
||||||
|
day_events.retain(|e| e.uid != uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if refreshed_vevent.rrule.is_some() {
|
||||||
|
let new_occurrences =
|
||||||
|
CalendarService::expand_recurring_events(vec![
|
||||||
|
refreshed_vevent.clone(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for occurrence in new_occurrences {
|
||||||
|
let date = occurrence.get_date();
|
||||||
|
updated_events
|
||||||
|
.entry(date)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(occurrence);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let date = refreshed_vevent.get_date();
|
||||||
|
updated_events
|
||||||
|
.entry(date)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(refreshed_vevent);
|
||||||
|
}
|
||||||
|
|
||||||
|
events.set(updated_events);
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
let mut updated_events = (*events).clone();
|
||||||
|
for (_, day_events) in updated_events.iter_mut() {
|
||||||
|
day_events.retain(|e| e.uid != uid);
|
||||||
|
}
|
||||||
|
events.set(updated_events);
|
||||||
|
}
|
||||||
|
Err(_err) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshing_event_uid.set(None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
// Handle view mode changes - adjust current_date format when switching between month/week
|
// Handle view mode changes - adjust current_date format when switching between month/week
|
||||||
{
|
{
|
||||||
let current_date = current_date.clone();
|
let current_date = current_date.clone();
|
||||||
@@ -98,7 +258,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
|| {}
|
|| {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let on_prev = {
|
let on_prev = {
|
||||||
let current_date = current_date.clone();
|
let current_date = current_date.clone();
|
||||||
let selected_date = selected_date.clone();
|
let selected_date = selected_date.clone();
|
||||||
@@ -110,19 +270,22 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let prev_month = *current_date - Duration::days(1);
|
let prev_month = *current_date - Duration::days(1);
|
||||||
let first_of_prev = prev_month.with_day(1).unwrap();
|
let first_of_prev = prev_month.with_day(1).unwrap();
|
||||||
(first_of_prev, first_of_prev)
|
(first_of_prev, first_of_prev)
|
||||||
},
|
}
|
||||||
ViewMode::Week => {
|
ViewMode::Week => {
|
||||||
// Go to previous week
|
// Go to previous week
|
||||||
let prev_week = *selected_date - Duration::weeks(1);
|
let prev_week = *selected_date - Duration::weeks(1);
|
||||||
(prev_week, prev_week)
|
(prev_week, prev_week)
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
selected_date.set(new_selected);
|
selected_date.set(new_selected);
|
||||||
current_date.set(new_display);
|
current_date.set(new_display);
|
||||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_next = {
|
let on_next = {
|
||||||
let current_date = current_date.clone();
|
let current_date = current_date.clone();
|
||||||
let selected_date = selected_date.clone();
|
let selected_date = selected_date.clone();
|
||||||
@@ -134,19 +297,23 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let next_month = if current_date.month() == 12 {
|
let next_month = if current_date.month() == 12 {
|
||||||
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
|
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
|
||||||
} else {
|
} else {
|
||||||
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1).unwrap()
|
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1)
|
||||||
|
.unwrap()
|
||||||
};
|
};
|
||||||
(next_month, next_month)
|
(next_month, next_month)
|
||||||
},
|
}
|
||||||
ViewMode::Week => {
|
ViewMode::Week => {
|
||||||
// Go to next week
|
// Go to next week
|
||||||
let next_week = *selected_date + Duration::weeks(1);
|
let next_week = *selected_date + Duration::weeks(1);
|
||||||
(next_week, next_week)
|
(next_week, next_week)
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
selected_date.set(new_selected);
|
selected_date.set(new_selected);
|
||||||
current_date.set(new_display);
|
current_date.set(new_display);
|
||||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,15 +327,18 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
ViewMode::Month => {
|
ViewMode::Month => {
|
||||||
let first_of_today = today.with_day(1).unwrap();
|
let first_of_today = today.with_day(1).unwrap();
|
||||||
(today, first_of_today) // Select today, but display the month
|
(today, first_of_today) // Select today, but display the month
|
||||||
},
|
}
|
||||||
ViewMode::Week => (today, today), // Select and display today
|
ViewMode::Week => (today, today), // Select and display today
|
||||||
};
|
};
|
||||||
selected_date.set(new_selected);
|
selected_date.set(new_selected);
|
||||||
current_date.set(new_display);
|
current_date.set(new_display);
|
||||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle time increment toggle
|
// Handle time increment toggle
|
||||||
let on_time_increment_toggle = {
|
let on_time_increment_toggle = {
|
||||||
let time_increment = time_increment.clone();
|
let time_increment = time_increment.clone();
|
||||||
@@ -179,32 +349,68 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let _ = LocalStorage::set("calendar_time_increment", next);
|
let _ = LocalStorage::set("calendar_time_increment", next);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle drag-to-create event
|
// Handle drag-to-create event
|
||||||
let on_create_event = {
|
let on_create_event = {
|
||||||
let show_create_modal = show_create_modal.clone();
|
let show_create_modal = show_create_modal.clone();
|
||||||
let create_event_data = create_event_data.clone();
|
let create_event_data = create_event_data.clone();
|
||||||
Callback::from(move |(_date, start_datetime, end_datetime): (NaiveDate, chrono::NaiveDateTime, chrono::NaiveDateTime)| {
|
Callback::from(
|
||||||
// For drag-to-create, we don't need the temporary event approach
|
move |(_date, start_datetime, end_datetime): (
|
||||||
// Instead, just pass the local times directly via initial_time props
|
NaiveDate,
|
||||||
create_event_data.set(Some((start_datetime.date(), start_datetime.time(), end_datetime.time())));
|
chrono::NaiveDateTime,
|
||||||
show_create_modal.set(true);
|
chrono::NaiveDateTime,
|
||||||
})
|
)| {
|
||||||
|
// For drag-to-create, we don't need the temporary event approach
|
||||||
|
// Instead, just pass the local times directly via initial_time props
|
||||||
|
create_event_data.set(Some((
|
||||||
|
start_datetime.date(),
|
||||||
|
start_datetime.time(),
|
||||||
|
end_datetime.time(),
|
||||||
|
)));
|
||||||
|
show_create_modal.set(true);
|
||||||
|
},
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle drag-to-move event
|
// Handle drag-to-move event
|
||||||
let on_event_update = {
|
let on_event_update = {
|
||||||
let on_event_update_request = props.on_event_update_request.clone();
|
let on_event_update_request = props.on_event_update_request.clone();
|
||||||
Callback::from(move |(event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)| {
|
Callback::from(
|
||||||
if let Some(callback) = &on_event_update_request {
|
move |(
|
||||||
callback.emit((event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date));
|
event,
|
||||||
}
|
new_start,
|
||||||
})
|
new_end,
|
||||||
|
preserve_rrule,
|
||||||
|
until_date,
|
||||||
|
update_scope,
|
||||||
|
occurrence_date,
|
||||||
|
): (
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)| {
|
||||||
|
if let Some(callback) = &on_event_update_request {
|
||||||
|
callback.emit((
|
||||||
|
event,
|
||||||
|
new_start,
|
||||||
|
new_end,
|
||||||
|
preserve_rrule,
|
||||||
|
until_date,
|
||||||
|
update_scope,
|
||||||
|
occurrence_date,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class={classes!("calendar", match props.view { ViewMode::Week => Some("week-view"), _ => None })}>
|
<div class={classes!("calendar", match props.view { ViewMode::Week => Some("week-view"), _ => None })}>
|
||||||
<CalendarHeader
|
<CalendarHeader
|
||||||
current_date={*current_date}
|
current_date={*current_date}
|
||||||
view_mode={props.view.clone()}
|
view_mode={props.view.clone()}
|
||||||
on_prev={on_prev}
|
on_prev={on_prev}
|
||||||
@@ -213,9 +419,22 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
time_increment={Some(*time_increment)}
|
time_increment={Some(*time_increment)}
|
||||||
on_time_increment_toggle={Some(on_time_increment_toggle)}
|
on_time_increment_toggle={Some(on_time_increment_toggle)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{
|
{
|
||||||
match props.view {
|
if *loading {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-loading">
|
||||||
|
<p>{"Loading calendar events..."}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else if let Some(err) = (*error).clone() {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-error">
|
||||||
|
<p>{format!("Error: {}", err)}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match props.view {
|
||||||
ViewMode::Month => {
|
ViewMode::Month => {
|
||||||
let on_day_select = {
|
let on_day_select = {
|
||||||
let selected_date = selected_date.clone();
|
let selected_date = selected_date.clone();
|
||||||
@@ -224,14 +443,14 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let _ = LocalStorage::set("calendar_selected_date", date.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set("calendar_selected_date", date.format("%Y-%m-%d").to_string());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<MonthView
|
<MonthView
|
||||||
current_month={*current_date}
|
current_month={*current_date}
|
||||||
today={today}
|
today={today}
|
||||||
events={props.events.clone()}
|
events={(*events).clone()}
|
||||||
on_event_click={props.on_event_click.clone()}
|
on_event_click={on_event_click.clone()}
|
||||||
refreshing_event_uid={props.refreshing_event_uid.clone()}
|
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
||||||
user_info={props.user_info.clone()}
|
user_info={props.user_info.clone()}
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
@@ -244,9 +463,9 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
<WeekView
|
<WeekView
|
||||||
current_date={*current_date}
|
current_date={*current_date}
|
||||||
today={today}
|
today={today}
|
||||||
events={props.events.clone()}
|
events={(*events).clone()}
|
||||||
on_event_click={props.on_event_click.clone()}
|
on_event_click={on_event_click.clone()}
|
||||||
refreshing_event_uid={props.refreshing_event_uid.clone()}
|
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
||||||
user_info={props.user_info.clone()}
|
user_info={props.user_info.clone()}
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
@@ -257,11 +476,12 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
time_increment={*time_increment}
|
time_increment={*time_increment}
|
||||||
/>
|
/>
|
||||||
},
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event details modal
|
// Event details modal
|
||||||
<EventModal
|
<EventModal
|
||||||
event={(*selected_event).clone()}
|
event={(*selected_event).clone()}
|
||||||
on_close={{
|
on_close={{
|
||||||
let selected_event_clone = selected_event.clone();
|
let selected_event_clone = selected_event.clone();
|
||||||
@@ -270,7 +490,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
// Create event modal
|
// Create event modal
|
||||||
<CreateEventModal
|
<CreateEventModal
|
||||||
is_open={*show_create_modal}
|
is_open={*show_create_modal}
|
||||||
@@ -294,7 +514,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
Callback::from(move |event_data: EventCreationData| {
|
Callback::from(move |event_data: EventCreationData| {
|
||||||
show_create_modal.set(false);
|
show_create_modal.set(false);
|
||||||
create_event_data.set(None);
|
create_event_data.set(None);
|
||||||
|
|
||||||
// Emit the create event request to parent
|
// Emit the create event request to parent
|
||||||
if let Some(callback) = &on_create_event_request {
|
if let Some(callback) = &on_create_event_request {
|
||||||
callback.emit(event_data);
|
callback.emit(event_data);
|
||||||
@@ -313,4 +533,4 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarContextMenuProps {
|
pub struct CalendarContextMenuProps {
|
||||||
@@ -13,7 +13,7 @@ pub struct CalendarContextMenuProps {
|
|||||||
#[function_component(CalendarContextMenu)]
|
#[function_component(CalendarContextMenu)]
|
||||||
pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
||||||
let menu_ref = use_node_ref();
|
let menu_ref = use_node_ref();
|
||||||
|
|
||||||
if !props.is_open {
|
if !props.is_open {
|
||||||
return html! {};
|
return html! {};
|
||||||
}
|
}
|
||||||
@@ -33,9 +33,9 @@ pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
ref={menu_ref}
|
ref={menu_ref}
|
||||||
class="context-menu"
|
class="context-menu"
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<div class="context-menu-item context-menu-create" onclick={on_create_event_click}>
|
<div class="context-menu-item context-menu-create" onclick={on_create_event_click}>
|
||||||
@@ -44,4 +44,4 @@ pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use chrono::{NaiveDate, Datelike};
|
|
||||||
use crate::components::ViewMode;
|
use crate::components::ViewMode;
|
||||||
|
use chrono::{Datelike, NaiveDate};
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarHeaderProps {
|
pub struct CalendarHeaderProps {
|
||||||
@@ -18,7 +18,11 @@ pub struct CalendarHeaderProps {
|
|||||||
|
|
||||||
#[function_component(CalendarHeader)]
|
#[function_component(CalendarHeader)]
|
||||||
pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
||||||
let title = format!("{} {}", get_month_name(props.current_date.month()), props.current_date.year());
|
let title = format!(
|
||||||
|
"{} {}",
|
||||||
|
get_month_name(props.current_date.month()),
|
||||||
|
props.current_date.year()
|
||||||
|
);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="calendar-header">
|
<div class="calendar-header">
|
||||||
@@ -48,7 +52,7 @@ pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
|||||||
fn get_month_name(month: u32) -> &'static str {
|
fn get_month_name(month: u32) -> &'static str {
|
||||||
match month {
|
match month {
|
||||||
1 => "January",
|
1 => "January",
|
||||||
2 => "February",
|
2 => "February",
|
||||||
3 => "March",
|
3 => "March",
|
||||||
4 => "April",
|
4 => "April",
|
||||||
5 => "May",
|
5 => "May",
|
||||||
@@ -59,6 +63,6 @@ fn get_month_name(month: u32) -> &'static str {
|
|||||||
10 => "October",
|
10 => "October",
|
||||||
11 => "November",
|
11 => "November",
|
||||||
12 => "December",
|
12 => "December",
|
||||||
_ => "Invalid"
|
_ => "Invalid",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
|
||||||
use crate::services::calendar_service::CalendarInfo;
|
use crate::services::calendar_service::CalendarInfo;
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarListItemProps {
|
pub struct CalendarListItemProps {
|
||||||
pub calendar: CalendarInfo,
|
pub calendar: CalendarInfo,
|
||||||
pub color_picker_open: bool,
|
pub color_picker_open: bool,
|
||||||
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
|
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
|
||||||
pub on_color_picker_toggle: Callback<String>, // calendar_path
|
pub on_color_picker_toggle: Callback<String>, // calendar_path
|
||||||
pub available_colors: Vec<String>,
|
pub available_colors: Vec<String>,
|
||||||
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
|||||||
|
|
||||||
html! {
|
html! {
|
||||||
<li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}>
|
<li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}>
|
||||||
<span class="calendar-color"
|
<span class="calendar-color"
|
||||||
style={format!("background-color: {}", props.calendar.color)}
|
style={format!("background-color: {}", props.calendar.color)}
|
||||||
onclick={on_color_click}>
|
onclick={on_color_click}>
|
||||||
{
|
{
|
||||||
@@ -46,14 +46,14 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
|||||||
let color_str = color.clone();
|
let color_str = color.clone();
|
||||||
let cal_path = props.calendar.path.clone();
|
let cal_path = props.calendar.path.clone();
|
||||||
let on_color_change = props.on_color_change.clone();
|
let on_color_change = props.on_color_change.clone();
|
||||||
|
|
||||||
let on_color_select = Callback::from(move |_: MouseEvent| {
|
let on_color_select = Callback::from(move |_: MouseEvent| {
|
||||||
on_color_change.emit((cal_path.clone(), color_str.clone()));
|
on_color_change.emit((cal_path.clone(), color_str.clone()));
|
||||||
});
|
});
|
||||||
|
|
||||||
let is_selected = props.calendar.color == *color;
|
let is_selected = props.calendar.color == *color;
|
||||||
let class_name = if is_selected { "color-option selected" } else { "color-option" };
|
let class_name = if is_selected { "color-option selected" } else { "color-option" };
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class={class_name}
|
<div class={class_name}
|
||||||
style={format!("background-color: {}", color)}
|
style={format!("background-color: {}", color)}
|
||||||
@@ -72,4 +72,4 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
|||||||
<span class="calendar-name">{&props.calendar.display_name}</span>
|
<span class="calendar-name">{&props.calendar.display_name}</span>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct ContextMenuProps {
|
pub struct ContextMenuProps {
|
||||||
@@ -13,7 +13,7 @@ pub struct ContextMenuProps {
|
|||||||
#[function_component(ContextMenu)]
|
#[function_component(ContextMenu)]
|
||||||
pub fn context_menu(props: &ContextMenuProps) -> Html {
|
pub fn context_menu(props: &ContextMenuProps) -> Html {
|
||||||
let menu_ref = use_node_ref();
|
let menu_ref = use_node_ref();
|
||||||
|
|
||||||
// Close menu when clicking outside (handled by parent component)
|
// Close menu when clicking outside (handled by parent component)
|
||||||
|
|
||||||
if !props.is_open {
|
if !props.is_open {
|
||||||
@@ -35,9 +35,9 @@ pub fn context_menu(props: &ContextMenuProps) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
ref={menu_ref}
|
ref={menu_ref}
|
||||||
class="context-menu"
|
class="context-menu"
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<div class="context-menu-item context-menu-delete" onclick={on_delete_click}>
|
<div class="context-menu-item context-menu-delete" onclick={on_delete_click}>
|
||||||
@@ -45,4 +45,4 @@ pub fn context_menu(props: &ContextMenuProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,30 +39,32 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
let error_message = error_message.clone();
|
let error_message = error_message.clone();
|
||||||
let is_creating = is_creating.clone();
|
let is_creating = is_creating.clone();
|
||||||
let on_create = props.on_create.clone();
|
let on_create = props.on_create.clone();
|
||||||
|
|
||||||
Callback::from(move |e: SubmitEvent| {
|
Callback::from(move |e: SubmitEvent| {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
|
|
||||||
let name = (*calendar_name).trim();
|
let name = (*calendar_name).trim();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
error_message.set(Some("Calendar name is required".to_string()));
|
error_message.set(Some("Calendar name is required".to_string()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if name.len() > 100 {
|
if name.len() > 100 {
|
||||||
error_message.set(Some("Calendar name too long (max 100 characters)".to_string()));
|
error_message.set(Some(
|
||||||
|
"Calendar name too long (max 100 characters)".to_string(),
|
||||||
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
error_message.set(None);
|
error_message.set(None);
|
||||||
is_creating.set(true);
|
is_creating.set(true);
|
||||||
|
|
||||||
let desc = if (*description).trim().is_empty() {
|
let desc = if (*description).trim().is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some((*description).clone())
|
Some((*description).clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
on_create.emit((name.to_string(), desc, (*selected_color).clone()));
|
on_create.emit((name.to_string(), desc, (*selected_color).clone()));
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -90,7 +92,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
{"×"}
|
{"×"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="modal-body" onsubmit={on_submit}>
|
<form class="modal-body" onsubmit={on_submit}>
|
||||||
{
|
{
|
||||||
if let Some(ref error) = *error_message {
|
if let Some(ref error) = *error_message {
|
||||||
@@ -103,10 +105,10 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="calendar-name">{"Calendar Name *"}</label>
|
<label for="calendar-name">{"Calendar Name *"}</label>
|
||||||
<input
|
<input
|
||||||
id="calendar-name"
|
id="calendar-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={(*calendar_name).clone()}
|
value={(*calendar_name).clone()}
|
||||||
@@ -116,7 +118,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
disabled={*is_creating}
|
disabled={*is_creating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="calendar-description">{"Description"}</label>
|
<label for="calendar-description">{"Description"}</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -128,7 +130,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
disabled={*is_creating}
|
disabled={*is_creating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{"Calendar Color"}</label>
|
<label>{"Calendar Color"}</label>
|
||||||
<div class="color-grid">
|
<div class="color-grid">
|
||||||
@@ -143,13 +145,13 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
selected_color.set(Some(color.clone()));
|
selected_color.set(Some(color.clone()));
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let class_name = if is_selected {
|
let class_name = if is_selected {
|
||||||
"color-option selected"
|
"color-option selected"
|
||||||
} else {
|
} else {
|
||||||
"color-option"
|
"color-option"
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
@@ -165,18 +167,18 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
<p class="color-help-text">{"Optional: Choose a color for your calendar"}</p>
|
<p class="color-help-text">{"Optional: Choose a color for your calendar"}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="cancel-button"
|
class="cancel-button"
|
||||||
onclick={props.on_close.reform(|_| ())}
|
onclick={props.on_close.reform(|_| ())}
|
||||||
disabled={*is_creating}
|
disabled={*is_creating}
|
||||||
>
|
>
|
||||||
{"Cancel"}
|
{"Cancel"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="create-button"
|
class="create-button"
|
||||||
disabled={*is_creating}
|
disabled={*is_creating}
|
||||||
>
|
>
|
||||||
@@ -193,4 +195,4 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
pub enum DeleteAction {
|
pub enum DeleteAction {
|
||||||
@@ -30,7 +30,7 @@ pub struct EventContextMenuProps {
|
|||||||
#[function_component(EventContextMenu)]
|
#[function_component(EventContextMenu)]
|
||||||
pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
||||||
let menu_ref = use_node_ref();
|
let menu_ref = use_node_ref();
|
||||||
|
|
||||||
if !props.is_open {
|
if !props.is_open {
|
||||||
return html! {};
|
return html! {};
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,9 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Check if the event is recurring
|
// Check if the event is recurring
|
||||||
let is_recurring = props.event.as_ref()
|
let is_recurring = props
|
||||||
|
.event
|
||||||
|
.as_ref()
|
||||||
.map(|event| event.rrule.is_some())
|
.map(|event| event.rrule.is_some())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
@@ -64,9 +66,9 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
ref={menu_ref}
|
ref={menu_ref}
|
||||||
class="context-menu"
|
class="context-menu"
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
@@ -117,4 +119,4 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct EventModalProps {
|
pub struct EventModalProps {
|
||||||
@@ -16,7 +16,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
on_close.emit(());
|
on_close.emit(());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let backdrop_click = {
|
let backdrop_click = {
|
||||||
let on_close = props.on_close.clone();
|
let on_close = props.on_close.clone();
|
||||||
Callback::from(move |e: MouseEvent| {
|
Callback::from(move |e: MouseEvent| {
|
||||||
@@ -39,7 +39,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
<strong>{"Title:"}</strong>
|
<strong>{"Title:"}</strong>
|
||||||
<span>{event.get_title()}</span>
|
<span>{event.get_title()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref description) = event.description {
|
if let Some(ref description) = event.description {
|
||||||
html! {
|
html! {
|
||||||
@@ -52,12 +52,12 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Start:"}</strong>
|
<strong>{"Start:"}</strong>
|
||||||
<span>{format_datetime(&event.dtstart, event.all_day)}</span>
|
<span>{format_datetime(&event.dtstart, event.all_day)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref end) = event.dtend {
|
if let Some(ref end) = event.dtend {
|
||||||
html! {
|
html! {
|
||||||
@@ -70,12 +70,12 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"All Day:"}</strong>
|
<strong>{"All Day:"}</strong>
|
||||||
<span>{if event.all_day { "Yes" } else { "No" }}</span>
|
<span>{if event.all_day { "Yes" } else { "No" }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref location) = event.location {
|
if let Some(ref location) = event.location {
|
||||||
html! {
|
html! {
|
||||||
@@ -88,22 +88,22 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Status:"}</strong>
|
<strong>{"Status:"}</strong>
|
||||||
<span>{event.get_status_display()}</span>
|
<span>{event.get_status_display()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Privacy:"}</strong>
|
<strong>{"Privacy:"}</strong>
|
||||||
<span>{event.get_class_display()}</span>
|
<span>{event.get_class_display()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Priority:"}</strong>
|
<strong>{"Priority:"}</strong>
|
||||||
<span>{event.get_priority_display()}</span>
|
<span>{event.get_priority_display()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref organizer) = event.organizer {
|
if let Some(ref organizer) = event.organizer {
|
||||||
html! {
|
html! {
|
||||||
@@ -116,7 +116,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if !event.attendees.is_empty() {
|
if !event.attendees.is_empty() {
|
||||||
html! {
|
html! {
|
||||||
@@ -129,7 +129,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if !event.categories.is_empty() {
|
if !event.categories.is_empty() {
|
||||||
html! {
|
html! {
|
||||||
@@ -142,7 +142,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref recurrence) = event.rrule {
|
if let Some(ref recurrence) = event.rrule {
|
||||||
html! {
|
html! {
|
||||||
@@ -160,7 +160,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if !event.alarms.is_empty() {
|
if !event.alarms.is_empty() {
|
||||||
html! {
|
html! {
|
||||||
@@ -178,7 +178,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref created) = event.created {
|
if let Some(ref created) = event.created {
|
||||||
html! {
|
html! {
|
||||||
@@ -191,7 +191,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref modified) = event.last_modified {
|
if let Some(ref modified) = event.last_modified {
|
||||||
html! {
|
html! {
|
||||||
@@ -236,4 +236,3 @@ fn format_recurrence_rule(rrule: &str) -> String {
|
|||||||
format!("Custom ({})", rrule)
|
format!("Custom ({})", rrule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::HtmlInputElement;
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct LoginProps {
|
pub struct LoginProps {
|
||||||
@@ -53,7 +53,7 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
|
|
||||||
Callback::from(move |e: SubmitEvent| {
|
Callback::from(move |e: SubmitEvent| {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
|
|
||||||
let server_url = (*server_url).clone();
|
let server_url = (*server_url).clone();
|
||||||
let username = (*username).clone();
|
let username = (*username).clone();
|
||||||
let password = (*password).clone();
|
let password = (*password).clone();
|
||||||
@@ -77,7 +77,8 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
web_sys::console::log_1(&"✅ Login successful!".into());
|
web_sys::console::log_1(&"✅ Login successful!".into());
|
||||||
// Store token and credentials in local storage
|
// Store token and credentials in local storage
|
||||||
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
||||||
error_message.set(Some("Failed to store authentication token".to_string()));
|
error_message
|
||||||
|
.set(Some("Failed to store authentication token".to_string()));
|
||||||
is_loading.set(false);
|
is_loading.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,7 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
is_loading.set(false);
|
is_loading.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
is_loading.set(false);
|
is_loading.set(false);
|
||||||
on_login.emit(token);
|
on_login.emit(token);
|
||||||
}
|
}
|
||||||
@@ -172,21 +173,25 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Perform login using the CalDAV auth service
|
/// Perform login using the CalDAV auth service
|
||||||
async fn perform_login(server_url: String, username: String, password: String) -> Result<(String, String), String> {
|
async fn perform_login(
|
||||||
|
server_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<(String, String), String> {
|
||||||
use crate::auth::{AuthService, CalDAVLoginRequest};
|
use crate::auth::{AuthService, CalDAVLoginRequest};
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("📡 Creating auth service and request...").into());
|
web_sys::console::log_1(&format!("📡 Creating auth service and request...").into());
|
||||||
|
|
||||||
let auth_service = AuthService::new();
|
let auth_service = AuthService::new();
|
||||||
let request = CalDAVLoginRequest {
|
let request = CalDAVLoginRequest {
|
||||||
server_url: server_url.clone(),
|
server_url: server_url.clone(),
|
||||||
username: username.clone(),
|
username: username.clone(),
|
||||||
password: password.clone()
|
password: password.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
|
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
|
||||||
|
|
||||||
match auth_service.login(request).await {
|
match auth_service.login(request).await {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
web_sys::console::log_1(&format!("✅ Backend responded successfully").into());
|
web_sys::console::log_1(&format!("✅ Backend responded successfully").into());
|
||||||
@@ -197,10 +202,10 @@ async fn perform_login(server_url: String, username: String, password: String) -
|
|||||||
"password": password
|
"password": password
|
||||||
});
|
});
|
||||||
Ok((response.token, credentials.to_string()))
|
Ok((response.token, credentials.to_string()))
|
||||||
},
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
|
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
|
||||||
Err(err)
|
Err(err)
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,33 @@
|
|||||||
pub mod login;
|
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod calendar_header;
|
|
||||||
pub mod month_view;
|
|
||||||
pub mod week_view;
|
|
||||||
pub mod event_modal;
|
|
||||||
pub mod create_calendar_modal;
|
|
||||||
pub mod context_menu;
|
|
||||||
pub mod event_context_menu;
|
|
||||||
pub mod calendar_context_menu;
|
pub mod calendar_context_menu;
|
||||||
pub mod create_event_modal;
|
pub mod calendar_header;
|
||||||
pub mod sidebar;
|
|
||||||
pub mod calendar_list_item;
|
pub mod calendar_list_item;
|
||||||
pub mod route_handler;
|
pub mod context_menu;
|
||||||
|
pub mod create_calendar_modal;
|
||||||
|
pub mod create_event_modal;
|
||||||
|
pub mod event_context_menu;
|
||||||
|
pub mod event_modal;
|
||||||
|
pub mod login;
|
||||||
|
pub mod month_view;
|
||||||
pub mod recurring_edit_modal;
|
pub mod recurring_edit_modal;
|
||||||
|
pub mod route_handler;
|
||||||
|
pub mod sidebar;
|
||||||
|
pub mod week_view;
|
||||||
|
|
||||||
pub use login::Login;
|
|
||||||
pub use calendar::Calendar;
|
pub use calendar::Calendar;
|
||||||
pub use calendar_header::CalendarHeader;
|
|
||||||
pub use month_view::MonthView;
|
|
||||||
pub use week_view::WeekView;
|
|
||||||
pub use event_modal::EventModal;
|
|
||||||
pub use create_calendar_modal::CreateCalendarModal;
|
|
||||||
pub use context_menu::ContextMenu;
|
|
||||||
pub use event_context_menu::{EventContextMenu, DeleteAction, EditAction};
|
|
||||||
pub use calendar_context_menu::CalendarContextMenu;
|
pub use calendar_context_menu::CalendarContextMenu;
|
||||||
pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
|
pub use calendar_header::CalendarHeader;
|
||||||
pub use sidebar::{Sidebar, ViewMode, Theme};
|
|
||||||
pub use calendar_list_item::CalendarListItem;
|
pub use calendar_list_item::CalendarListItem;
|
||||||
|
pub use context_menu::ContextMenu;
|
||||||
|
pub use create_calendar_modal::CreateCalendarModal;
|
||||||
|
pub use create_event_modal::{
|
||||||
|
CreateEventModal, EventClass, EventCreationData, EventStatus, RecurrenceType, ReminderType,
|
||||||
|
};
|
||||||
|
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
|
||||||
|
pub use event_modal::EventModal;
|
||||||
|
pub use login::Login;
|
||||||
|
pub use month_view::MonthView;
|
||||||
|
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
|
||||||
pub use route_handler::RouteHandler;
|
pub use route_handler::RouteHandler;
|
||||||
pub use recurring_edit_modal::{RecurringEditModal, RecurringEditAction};
|
pub use sidebar::{Sidebar, Theme, ViewMode};
|
||||||
|
pub use week_view::WeekView;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use yew::prelude::*;
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
use chrono::{Datelike, NaiveDate, Weekday};
|
use chrono::{Datelike, NaiveDate, Weekday};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use web_sys::window;
|
|
||||||
use wasm_bindgen::{prelude::*, JsCast};
|
use wasm_bindgen::{prelude::*, JsCast};
|
||||||
use crate::services::calendar_service::UserInfo;
|
use web_sys::window;
|
||||||
use crate::models::ical::VEvent;
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct MonthViewProps {
|
pub struct MonthViewProps {
|
||||||
@@ -52,30 +52,33 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
let calculate_max_events = calculate_max_events.clone();
|
let calculate_max_events = calculate_max_events.clone();
|
||||||
use_effect_with((), move |_| {
|
use_effect_with((), move |_| {
|
||||||
let calculate_max_events_clone = calculate_max_events.clone();
|
let calculate_max_events_clone = calculate_max_events.clone();
|
||||||
|
|
||||||
// Initial calculation with a slight delay to ensure DOM is ready
|
// Initial calculation with a slight delay to ensure DOM is ready
|
||||||
if let Some(window) = window() {
|
if let Some(window) = window() {
|
||||||
let timeout_closure = Closure::wrap(Box::new(move || {
|
let timeout_closure = Closure::wrap(Box::new(move || {
|
||||||
calculate_max_events_clone();
|
calculate_max_events_clone();
|
||||||
}) as Box<dyn FnMut()>);
|
}) as Box<dyn FnMut()>);
|
||||||
|
|
||||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||||
timeout_closure.as_ref().unchecked_ref(),
|
timeout_closure.as_ref().unchecked_ref(),
|
||||||
100,
|
100,
|
||||||
);
|
);
|
||||||
timeout_closure.forget();
|
timeout_closure.forget();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup resize listener
|
// Setup resize listener
|
||||||
let resize_closure = Closure::wrap(Box::new(move || {
|
let resize_closure = Closure::wrap(Box::new(move || {
|
||||||
calculate_max_events();
|
calculate_max_events();
|
||||||
}) as Box<dyn Fn()>);
|
}) as Box<dyn Fn()>);
|
||||||
|
|
||||||
if let Some(window) = window() {
|
if let Some(window) = window() {
|
||||||
let _ = window.add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref());
|
let _ = window.add_event_listener_with_callback(
|
||||||
|
"resize",
|
||||||
|
resize_closure.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
resize_closure.forget(); // Keep the closure alive
|
resize_closure.forget(); // Keep the closure alive
|
||||||
}
|
}
|
||||||
|
|
||||||
|| {}
|
|| {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -84,8 +87,11 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
let get_event_color = |event: &VEvent| -> String {
|
let get_event_color = |event: &VEvent| -> String {
|
||||||
if let Some(user_info) = &props.user_info {
|
if let Some(user_info) = &props.user_info {
|
||||||
if let Some(calendar_path) = &event.calendar_path {
|
if let Some(calendar_path) = &event.calendar_path {
|
||||||
if let Some(calendar) = user_info.calendars.iter()
|
if let Some(calendar) = user_info
|
||||||
.find(|cal| &cal.path == calendar_path) {
|
.calendars
|
||||||
|
.iter()
|
||||||
|
.find(|cal| &cal.path == calendar_path)
|
||||||
|
{
|
||||||
return calendar.color.clone();
|
return calendar.color.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +109,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
<div class="weekday-header">{"Thu"}</div>
|
<div class="weekday-header">{"Thu"}</div>
|
||||||
<div class="weekday-header">{"Fri"}</div>
|
<div class="weekday-header">{"Fri"}</div>
|
||||||
<div class="weekday-header">{"Sat"}</div>
|
<div class="weekday-header">{"Sat"}</div>
|
||||||
|
|
||||||
// Days from previous month (grayed out)
|
// Days from previous month (grayed out)
|
||||||
{
|
{
|
||||||
days_from_prev_month.iter().map(|day| {
|
days_from_prev_month.iter().map(|day| {
|
||||||
@@ -112,7 +118,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Days of the current month
|
// Days of the current month
|
||||||
{
|
{
|
||||||
(1..=days_in_month).map(|day| {
|
(1..=days_in_month).map(|day| {
|
||||||
@@ -120,16 +126,16 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
let is_today = date == props.today;
|
let is_today = date == props.today;
|
||||||
let is_selected = props.selected_date == Some(date);
|
let is_selected = props.selected_date == Some(date);
|
||||||
let day_events = props.events.get(&date).cloned().unwrap_or_default();
|
let day_events = props.events.get(&date).cloned().unwrap_or_default();
|
||||||
|
|
||||||
// Calculate visible events and overflow
|
// Calculate visible events and overflow
|
||||||
let max_events = *max_events_per_day as usize;
|
let max_events = *max_events_per_day as usize;
|
||||||
let visible_events: Vec<_> = day_events.iter().take(max_events).collect();
|
let visible_events: Vec<_> = day_events.iter().take(max_events).collect();
|
||||||
let hidden_count = day_events.len().saturating_sub(max_events);
|
let hidden_count = day_events.len().saturating_sub(max_events);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class={classes!(
|
class={classes!(
|
||||||
"calendar-day",
|
"calendar-day",
|
||||||
if is_today { Some("today") } else { None },
|
if is_today { Some("today") } else { None },
|
||||||
if is_selected { Some("selected") } else { None }
|
if is_selected { Some("selected") } else { None }
|
||||||
)}
|
)}
|
||||||
@@ -162,7 +168,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
visible_events.iter().map(|event| {
|
visible_events.iter().map(|event| {
|
||||||
let event_color = get_event_color(event);
|
let event_color = get_event_color(event);
|
||||||
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
||||||
|
|
||||||
let onclick = {
|
let onclick = {
|
||||||
let on_event_click = props.on_event_click.clone();
|
let on_event_click = props.on_event_click.clone();
|
||||||
let event = (*event).clone();
|
let event = (*event).clone();
|
||||||
@@ -170,7 +176,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
on_event_click.emit(event.clone());
|
on_event_click.emit(event.clone());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let oncontextmenu = {
|
let oncontextmenu = {
|
||||||
if let Some(callback) = &props.on_event_context_menu {
|
if let Some(callback) = &props.on_event_context_menu {
|
||||||
let callback = callback.clone();
|
let callback = callback.clone();
|
||||||
@@ -183,9 +189,9 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })}
|
class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })}
|
||||||
style={format!("background-color: {}", event_color)}
|
style={format!("background-color: {}", event_color)}
|
||||||
{onclick}
|
{onclick}
|
||||||
@@ -212,7 +218,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
}
|
}
|
||||||
|
|
||||||
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
|
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -221,20 +227,34 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
|
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
|
||||||
let total_slots = 42; // 6 rows x 7 days
|
let total_slots = 42; // 6 rows x 7 days
|
||||||
let used_slots = prev_days_count + current_days_count as usize;
|
let used_slots = prev_days_count + current_days_count as usize;
|
||||||
let remaining_slots = if used_slots < total_slots { total_slots - used_slots } else { 0 };
|
let remaining_slots = if used_slots < total_slots {
|
||||||
|
total_slots - used_slots
|
||||||
(1..=remaining_slots).map(|day| {
|
} else {
|
||||||
html! {
|
0
|
||||||
<div class="calendar-day next-month">{day}</div>
|
};
|
||||||
}
|
|
||||||
}).collect::<Html>()
|
(1..=remaining_slots)
|
||||||
|
.map(|day| {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-day next-month">{day}</div>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Html>()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_days_in_month(date: NaiveDate) -> u32 {
|
fn get_days_in_month(date: NaiveDate) -> u32 {
|
||||||
NaiveDate::from_ymd_opt(
|
NaiveDate::from_ymd_opt(
|
||||||
if date.month() == 12 { date.year() + 1 } else { date.year() },
|
if date.month() == 12 {
|
||||||
if date.month() == 12 { 1 } else { date.month() + 1 },
|
date.year() + 1
|
||||||
1
|
} else {
|
||||||
|
date.year()
|
||||||
|
},
|
||||||
|
if date.month() == 12 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
date.month() + 1
|
||||||
|
},
|
||||||
|
1,
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.pred_opt()
|
.pred_opt()
|
||||||
@@ -252,7 +272,7 @@ fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday
|
|||||||
Weekday::Fri => 5,
|
Weekday::Fri => 5,
|
||||||
Weekday::Sat => 6,
|
Weekday::Sat => 6,
|
||||||
};
|
};
|
||||||
|
|
||||||
if days_before == 0 {
|
if days_before == 0 {
|
||||||
vec![]
|
vec![]
|
||||||
} else {
|
} else {
|
||||||
@@ -261,8 +281,8 @@ fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday
|
|||||||
} else {
|
} else {
|
||||||
NaiveDate::from_ymd_opt(current_month.year(), current_month.month() - 1, 1).unwrap()
|
NaiveDate::from_ymd_opt(current_month.year(), current_month.month() - 1, 1).unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
let prev_month_days = get_days_in_month(prev_month);
|
let prev_month_days = get_days_in_month(prev_month);
|
||||||
((prev_month_days - days_before as u32 + 1)..=prev_month_days).collect()
|
((prev_month_days - days_before as u32 + 1)..=prev_month_days).collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use chrono::NaiveDateTime;
|
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
pub enum RecurringEditAction {
|
pub enum RecurringEditAction {
|
||||||
@@ -25,29 +25,34 @@ pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
|
|||||||
return html! {};
|
return html! {};
|
||||||
}
|
}
|
||||||
|
|
||||||
let event_title = props.event.summary.as_ref().map(|s| s.as_str()).unwrap_or("Untitled Event");
|
let event_title = props
|
||||||
|
.event
|
||||||
|
.summary
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("Untitled Event");
|
||||||
|
|
||||||
let on_this_event = {
|
let on_this_event = {
|
||||||
let on_choice = props.on_choice.clone();
|
let on_choice = props.on_choice.clone();
|
||||||
Callback::from(move |_| {
|
Callback::from(move |_| {
|
||||||
on_choice.emit(RecurringEditAction::ThisEvent);
|
on_choice.emit(RecurringEditAction::ThisEvent);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_future_events = {
|
let on_future_events = {
|
||||||
let on_choice = props.on_choice.clone();
|
let on_choice = props.on_choice.clone();
|
||||||
Callback::from(move |_| {
|
Callback::from(move |_| {
|
||||||
on_choice.emit(RecurringEditAction::FutureEvents);
|
on_choice.emit(RecurringEditAction::FutureEvents);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_all_events = {
|
let on_all_events = {
|
||||||
let on_choice = props.on_choice.clone();
|
let on_choice = props.on_choice.clone();
|
||||||
Callback::from(move |_| {
|
Callback::from(move |_| {
|
||||||
on_choice.emit(RecurringEditAction::AllEvents);
|
on_choice.emit(RecurringEditAction::AllEvents);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_cancel = {
|
let on_cancel = {
|
||||||
let on_cancel = props.on_cancel.clone();
|
let on_cancel = props.on_cancel.clone();
|
||||||
Callback::from(move |_| {
|
Callback::from(move |_| {
|
||||||
@@ -64,18 +69,18 @@ pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>{format!("You're modifying \"{}\" which is part of a recurring series.", event_title)}</p>
|
<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>
|
<p>{"How would you like to apply this change?"}</p>
|
||||||
|
|
||||||
<div class="recurring-edit-options">
|
<div class="recurring-edit-options">
|
||||||
<button class="btn btn-primary recurring-option" onclick={on_this_event}>
|
<button class="btn btn-primary recurring-option" onclick={on_this_event}>
|
||||||
<div class="option-title">{"This event only"}</div>
|
<div class="option-title">{"This event only"}</div>
|
||||||
<div class="option-description">{"Change only this occurrence"}</div>
|
<div class="option-description">{"Change only this occurrence"}</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="btn btn-primary recurring-option" onclick={on_future_events}>
|
<button class="btn btn-primary recurring-option" onclick={on_future_events}>
|
||||||
<div class="option-title">{"This and future events"}</div>
|
<div class="option-title">{"This and future events"}</div>
|
||||||
<div class="option-description">{"Change this occurrence and all future occurrences"}</div>
|
<div class="option-description">{"Change this occurrence and all future occurrences"}</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="btn btn-primary recurring-option" onclick={on_all_events}>
|
<button class="btn btn-primary recurring-option" onclick={on_all_events}>
|
||||||
<div class="option-title">{"All events in series"}</div>
|
<div class="option-title">{"All events in series"}</div>
|
||||||
<div class="option-description">{"Change all occurrences in the series"}</div>
|
<div class="option-description">{"Change all occurrences in the series"}</div>
|
||||||
@@ -90,4 +95,4 @@ pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
use crate::components::{Login, ViewMode};
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
use crate::components::{Login, ViewMode};
|
|
||||||
use crate::services::calendar_service::UserInfo;
|
|
||||||
use crate::models::ical::VEvent;
|
|
||||||
|
|
||||||
#[derive(Clone, Routable, PartialEq)]
|
#[derive(Clone, Routable, PartialEq)]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
@@ -28,7 +28,17 @@ pub struct RouteHandlerProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
|
pub on_event_update_request: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
}
|
}
|
||||||
@@ -44,7 +54,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
|||||||
let on_create_event_request = props.on_create_event_request.clone();
|
let on_create_event_request = props.on_create_event_request.clone();
|
||||||
let on_event_update_request = props.on_event_update_request.clone();
|
let on_event_update_request = props.on_event_update_request.clone();
|
||||||
let context_menus_open = props.context_menus_open;
|
let context_menus_open = props.context_menus_open;
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<Switch<Route> render={move |route| {
|
<Switch<Route> render={move |route| {
|
||||||
let auth_token = auth_token.clone();
|
let auth_token = auth_token.clone();
|
||||||
@@ -56,7 +66,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
|||||||
let on_create_event_request = on_create_event_request.clone();
|
let on_create_event_request = on_create_event_request.clone();
|
||||||
let on_event_update_request = on_event_update_request.clone();
|
let on_event_update_request = on_event_update_request.clone();
|
||||||
let context_menus_open = context_menus_open;
|
let context_menus_open = context_menus_open;
|
||||||
|
|
||||||
match route {
|
match route {
|
||||||
Route::Home => {
|
Route::Home => {
|
||||||
if auth_token.is_some() {
|
if auth_token.is_some() {
|
||||||
@@ -74,16 +84,16 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
|||||||
}
|
}
|
||||||
Route::Calendar => {
|
Route::Calendar => {
|
||||||
if auth_token.is_some() {
|
if auth_token.is_some() {
|
||||||
html! {
|
html! {
|
||||||
<CalendarView
|
<CalendarView
|
||||||
user_info={user_info}
|
user_info={user_info}
|
||||||
on_event_context_menu={on_event_context_menu}
|
on_event_context_menu={on_event_context_menu}
|
||||||
on_calendar_context_menu={on_calendar_context_menu}
|
on_calendar_context_menu={on_calendar_context_menu}
|
||||||
view={view}
|
view={view}
|
||||||
on_create_event_request={on_create_event_request}
|
on_create_event_request={on_create_event_request}
|
||||||
on_event_update_request={on_event_update_request}
|
on_event_update_request={on_event_update_request}
|
||||||
context_menus_open={context_menus_open}
|
context_menus_open={context_menus_open}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! { <Redirect<Route> to={Route::Login}/> }
|
html! { <Redirect<Route> to={Route::Login}/> }
|
||||||
@@ -106,192 +116,36 @@ pub struct CalendarViewProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
|
pub on_event_update_request: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
|
||||||
use crate::services::CalendarService;
|
|
||||||
use crate::components::Calendar;
|
use crate::components::Calendar;
|
||||||
use std::collections::HashMap;
|
|
||||||
use chrono::{Local, NaiveDate, Datelike};
|
|
||||||
|
|
||||||
#[function_component(CalendarView)]
|
#[function_component(CalendarView)]
|
||||||
pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
||||||
let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new());
|
|
||||||
let loading = use_state(|| true);
|
|
||||||
let error = use_state(|| None::<String>);
|
|
||||||
let refreshing_event = use_state(|| None::<String>);
|
|
||||||
|
|
||||||
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
|
||||||
|
|
||||||
|
|
||||||
let today = Local::now().date_naive();
|
|
||||||
let current_year = today.year();
|
|
||||||
let current_month = today.month();
|
|
||||||
|
|
||||||
let on_event_click = {
|
|
||||||
let events = events.clone();
|
|
||||||
let refreshing_event = refreshing_event.clone();
|
|
||||||
let auth_token = auth_token.clone();
|
|
||||||
|
|
||||||
Callback::from(move |event: VEvent| {
|
|
||||||
if let Some(token) = auth_token.clone() {
|
|
||||||
let events = events.clone();
|
|
||||||
let refreshing_event = refreshing_event.clone();
|
|
||||||
let uid = event.uid.clone();
|
|
||||||
|
|
||||||
refreshing_event.set(Some(uid.clone()));
|
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
let calendar_service = CalendarService::new();
|
|
||||||
|
|
||||||
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 {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
match calendar_service.refresh_event(&token, &password, &uid).await {
|
|
||||||
Ok(Some(refreshed_event)) => {
|
|
||||||
let refreshed_vevent = refreshed_event; // CalendarEvent is now VEvent
|
|
||||||
let mut updated_events = (*events).clone();
|
|
||||||
|
|
||||||
for (_, day_events) in updated_events.iter_mut() {
|
|
||||||
day_events.retain(|e| e.uid != uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if refreshed_vevent.rrule.is_some() {
|
|
||||||
let new_occurrences = CalendarService::expand_recurring_events(vec![refreshed_vevent.clone()]);
|
|
||||||
|
|
||||||
for occurrence in new_occurrences {
|
|
||||||
let date = occurrence.get_date();
|
|
||||||
updated_events.entry(date)
|
|
||||||
.or_insert_with(Vec::new)
|
|
||||||
.push(occurrence);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let date = refreshed_vevent.get_date();
|
|
||||||
updated_events.entry(date)
|
|
||||||
.or_insert_with(Vec::new)
|
|
||||||
.push(refreshed_vevent);
|
|
||||||
}
|
|
||||||
|
|
||||||
events.set(updated_events);
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
let mut updated_events = (*events).clone();
|
|
||||||
for (_, day_events) in updated_events.iter_mut() {
|
|
||||||
day_events.retain(|e| e.uid != uid);
|
|
||||||
}
|
|
||||||
events.set(updated_events);
|
|
||||||
}
|
|
||||||
Err(_err) => {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshing_event.set(None);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
{
|
|
||||||
let events = events.clone();
|
|
||||||
let loading = loading.clone();
|
|
||||||
let error = error.clone();
|
|
||||||
let auth_token = auth_token.clone();
|
|
||||||
|
|
||||||
use_effect_with((), move |_| {
|
|
||||||
if let Some(token) = auth_token {
|
|
||||||
let events = events.clone();
|
|
||||||
let loading = loading.clone();
|
|
||||||
let error = error.clone();
|
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
let calendar_service = CalendarService::new();
|
|
||||||
|
|
||||||
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 {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
match calendar_service.fetch_events_for_month_vevent(&token, &password, current_year, current_month).await {
|
|
||||||
Ok(vevents) => {
|
|
||||||
let grouped_events = CalendarService::group_events_by_date(vevents);
|
|
||||||
events.set(grouped_events);
|
|
||||||
loading.set(false);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
error.set(Some(format!("Failed to load events: {}", err)));
|
|
||||||
loading.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
loading.set(false);
|
|
||||||
error.set(Some("No authentication token found".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
|| ()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="calendar-view">
|
<div class="calendar-view">
|
||||||
{
|
<Calendar
|
||||||
if *loading {
|
user_info={props.user_info.clone()}
|
||||||
html! {
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
<div class="calendar-loading">
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
<p>{"Loading calendar events..."}</p>
|
view={props.view.clone()}
|
||||||
</div>
|
on_create_event_request={props.on_create_event_request.clone()}
|
||||||
}
|
on_event_update_request={props.on_event_update_request.clone()}
|
||||||
} else if let Some(err) = (*error).clone() {
|
context_menus_open={props.context_menus_open}
|
||||||
let dummy_callback = Callback::from(|_: VEvent| {});
|
/>
|
||||||
html! {
|
|
||||||
<div class="calendar-error">
|
|
||||||
<p>{format!("Error: {}", err)}</p>
|
|
||||||
<Calendar
|
|
||||||
events={HashMap::new()}
|
|
||||||
on_event_click={dummy_callback}
|
|
||||||
refreshing_event_uid={(*refreshing_event).clone()}
|
|
||||||
user_info={props.user_info.clone()}
|
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
|
||||||
view={props.view.clone()}
|
|
||||||
on_create_event_request={props.on_create_event_request.clone()}
|
|
||||||
on_event_update_request={props.on_event_update_request.clone()}
|
|
||||||
context_menus_open={props.context_menus_open}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
<Calendar
|
|
||||||
events={(*events).clone()}
|
|
||||||
on_event_click={on_event_click}
|
|
||||||
refreshing_event_uid={(*refreshing_event).clone()}
|
|
||||||
user_info={props.user_info.clone()}
|
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
|
||||||
view={props.view.clone()}
|
|
||||||
on_create_event_request={props.on_create_event_request.clone()}
|
|
||||||
on_event_update_request={props.on_event_update_request.clone()}
|
|
||||||
context_menus_open={props.context_menus_open}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
use crate::components::CalendarListItem;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
|
use web_sys::HtmlSelectElement;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
use web_sys::HtmlSelectElement;
|
|
||||||
use crate::services::calendar_service::UserInfo;
|
|
||||||
use crate::components::CalendarListItem;
|
|
||||||
|
|
||||||
#[derive(Clone, Routable, PartialEq)]
|
#[derive(Clone, Routable, PartialEq)]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
@@ -33,12 +33,11 @@ pub enum Theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Theme {
|
impl Theme {
|
||||||
|
|
||||||
pub fn value(&self) -> &'static str {
|
pub fn value(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Theme::Default => "default",
|
Theme::Default => "default",
|
||||||
Theme::Ocean => "ocean",
|
Theme::Ocean => "ocean",
|
||||||
Theme::Forest => "forest",
|
Theme::Forest => "forest",
|
||||||
Theme::Sunset => "sunset",
|
Theme::Sunset => "sunset",
|
||||||
Theme::Purple => "purple",
|
Theme::Purple => "purple",
|
||||||
Theme::Dark => "dark",
|
Theme::Dark => "dark",
|
||||||
@@ -46,7 +45,7 @@ impl Theme {
|
|||||||
Theme::Mint => "mint",
|
Theme::Mint => "mint",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_value(value: &str) -> Self {
|
pub fn from_value(value: &str) -> Self {
|
||||||
match value {
|
match value {
|
||||||
"ocean" => Theme::Ocean,
|
"ocean" => Theme::Ocean,
|
||||||
@@ -167,14 +166,14 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
|
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
|
||||||
{"+ Create Calendar"}
|
{"+ Create Calendar"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="view-selector">
|
<div class="view-selector">
|
||||||
<select class="view-selector-dropdown" onchange={on_view_change}>
|
<select class="view-selector-dropdown" onchange={on_view_change}>
|
||||||
<option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option>
|
<option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option>
|
||||||
<option value="week" selected={matches!(props.current_view, ViewMode::Week)}>{"Week"}</option>
|
<option value="week" selected={matches!(props.current_view, ViewMode::Week)}>{"Week"}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="theme-selector">
|
<div class="theme-selector">
|
||||||
<select class="theme-selector-dropdown" onchange={on_theme_change}>
|
<select class="theme-selector-dropdown" onchange={on_theme_change}>
|
||||||
<option value="default" selected={matches!(props.current_theme, Theme::Default)}>{"Default"}</option>
|
<option value="default" selected={matches!(props.current_theme, Theme::Default)}>{"Default"}</option>
|
||||||
@@ -187,9 +186,9 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
<option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</option>
|
<option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
|
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use yew::prelude::*;
|
use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal};
|
||||||
use chrono::{Datelike, NaiveDate, Duration, Weekday, Local, Timelike, NaiveDateTime, NaiveTime};
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
|
use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Weekday};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
use crate::services::calendar_service::UserInfo;
|
use yew::prelude::*;
|
||||||
use crate::models::ical::VEvent;
|
|
||||||
use crate::components::{RecurringEditModal, RecurringEditAction, EventCreationData};
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct WeekViewProps {
|
pub struct WeekViewProps {
|
||||||
@@ -25,7 +25,17 @@ pub struct WeekViewProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update: Option<Callback<(VEvent, NaiveDateTime, NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
|
pub on_event_update: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
NaiveDateTime,
|
||||||
|
NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
@@ -47,20 +57,18 @@ struct DragState {
|
|||||||
start_date: NaiveDate,
|
start_date: NaiveDate,
|
||||||
start_y: f64,
|
start_y: f64,
|
||||||
current_y: f64,
|
current_y: f64,
|
||||||
offset_y: f64, // For event moves, this is the offset from the event's top
|
offset_y: f64, // For event moves, this is the offset from the event's top
|
||||||
has_moved: bool, // Track if we've moved enough to constitute a real drag
|
has_moved: bool, // Track if we've moved enough to constitute a real drag
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component(WeekView)]
|
#[function_component(WeekView)]
|
||||||
pub fn week_view(props: &WeekViewProps) -> Html {
|
pub fn week_view(props: &WeekViewProps) -> Html {
|
||||||
let start_of_week = get_start_of_week(props.current_date);
|
let start_of_week = get_start_of_week(props.current_date);
|
||||||
let week_days: Vec<NaiveDate> = (0..7)
|
let week_days: Vec<NaiveDate> = (0..7).map(|i| start_of_week + Duration::days(i)).collect();
|
||||||
.map(|i| start_of_week + Duration::days(i))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Drag state for event creation
|
// Drag state for event creation
|
||||||
let drag_state = use_state(|| None::<DragState>);
|
let drag_state = use_state(|| None::<DragState>);
|
||||||
|
|
||||||
// State for recurring event edit modal
|
// State for recurring event edit modal
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
struct PendingRecurringEdit {
|
struct PendingRecurringEdit {
|
||||||
@@ -68,15 +76,18 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
new_start: NaiveDateTime,
|
new_start: NaiveDateTime,
|
||||||
new_end: NaiveDateTime,
|
new_end: NaiveDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>);
|
let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>);
|
||||||
|
|
||||||
// Helper function to get calendar color for an event
|
// Helper function to get calendar color for an event
|
||||||
let get_event_color = |event: &VEvent| -> String {
|
let get_event_color = |event: &VEvent| -> String {
|
||||||
if let Some(user_info) = &props.user_info {
|
if let Some(user_info) = &props.user_info {
|
||||||
if let Some(calendar_path) = &event.calendar_path {
|
if let Some(calendar_path) = &event.calendar_path {
|
||||||
if let Some(calendar) = user_info.calendars.iter()
|
if let Some(calendar) = user_info
|
||||||
.find(|cal| &cal.path == calendar_path) {
|
.calendars
|
||||||
|
.iter()
|
||||||
|
.find(|cal| &cal.path == calendar_path)
|
||||||
|
{
|
||||||
return calendar.color.clone();
|
return calendar.color.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,21 +96,22 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Generate time labels - 24 hours plus the final midnight boundary
|
// Generate time labels - 24 hours plus the final midnight boundary
|
||||||
let mut time_labels: Vec<String> = (0..24).map(|hour| {
|
let mut time_labels: Vec<String> = (0..24)
|
||||||
if hour == 0 {
|
.map(|hour| {
|
||||||
"12 AM".to_string()
|
if hour == 0 {
|
||||||
} else if hour < 12 {
|
"12 AM".to_string()
|
||||||
format!("{} AM", hour)
|
} else if hour < 12 {
|
||||||
} else if hour == 12 {
|
format!("{} AM", hour)
|
||||||
"12 PM".to_string()
|
} else if hour == 12 {
|
||||||
} else {
|
"12 PM".to_string()
|
||||||
format!("{} PM", hour - 12)
|
} else {
|
||||||
}
|
format!("{} PM", hour - 12)
|
||||||
}).collect();
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Add the final midnight boundary to show where the day ends
|
// Add the final midnight boundary to show where the day ends
|
||||||
time_labels.push("12 AM".to_string());
|
time_labels.push("12 AM".to_string());
|
||||||
|
|
||||||
|
|
||||||
// Handlers for recurring event modification modal
|
// Handlers for recurring event modification modal
|
||||||
let on_recurring_choice = {
|
let on_recurring_choice = {
|
||||||
@@ -135,35 +147,35 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
if let Some(update_callback) = &on_event_update {
|
if let Some(update_callback) = &on_event_update {
|
||||||
// Extract occurrence date for backend processing
|
// Extract occurrence date for backend processing
|
||||||
let occurrence_date = edit.event.dtstart.format("%Y-%m-%d").to_string();
|
let occurrence_date = edit.event.dtstart.format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
// Send single request to backend with "this_only" scope
|
// Send single request to backend with "this_only" scope
|
||||||
// Backend will atomically:
|
// Backend will atomically:
|
||||||
// 1. Add EXDATE to original series (excludes this occurrence)
|
// 1. Add EXDATE to original series (excludes this occurrence)
|
||||||
// 2. Create exception event with RECURRENCE-ID and user's modifications
|
// 2. Create exception event with RECURRENCE-ID and user's modifications
|
||||||
update_callback.emit((
|
update_callback.emit((
|
||||||
edit.event.clone(), // Original event (series to modify)
|
edit.event.clone(), // Original event (series to modify)
|
||||||
edit.new_start, // Dragged start time for exception
|
edit.new_start, // Dragged start time for exception
|
||||||
edit.new_end, // Dragged end time for exception
|
edit.new_end, // Dragged end time for exception
|
||||||
true, // preserve_rrule = true
|
true, // preserve_rrule = true
|
||||||
None, // No until_date for this_only
|
None, // No until_date for this_only
|
||||||
Some("this_only".to_string()), // Update scope
|
Some("this_only".to_string()), // Update scope
|
||||||
Some(occurrence_date) // Date of occurrence being modified
|
Some(occurrence_date), // Date of occurrence being modified
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
RecurringEditAction::FutureEvents => {
|
RecurringEditAction::FutureEvents => {
|
||||||
// RFC 5545 Compliant Series Splitting: "This and Future Events"
|
// RFC 5545 Compliant Series Splitting: "This and Future Events"
|
||||||
//
|
//
|
||||||
// When a user chooses to modify "this and future events" for a recurring series,
|
// When a user chooses to modify "this and future events" for a recurring series,
|
||||||
// we implement a series split operation that:
|
// we implement a series split operation that:
|
||||||
//
|
//
|
||||||
// 1. **Terminates Original Series**: The existing series is updated with an UNTIL
|
// 1. **Terminates Original Series**: The existing series is updated with an UNTIL
|
||||||
// clause to stop before the occurrence being modified
|
// clause to stop before the occurrence being modified
|
||||||
// 2. **Creates New Series**: A new recurring series is created starting from the
|
// 2. **Creates New Series**: A new recurring series is created starting from the
|
||||||
// occurrence date with the user's modifications (new time, title, etc.)
|
// occurrence date with the user's modifications (new time, title, etc.)
|
||||||
//
|
//
|
||||||
// Example: User drags Aug 22 occurrence of "Daily 9AM meeting" to 2PM:
|
// Example: User drags Aug 22 occurrence of "Daily 9AM meeting" to 2PM:
|
||||||
// - Original: "Daily 9AM meeting" → ends Aug 21 (UNTIL=Aug22T000000Z)
|
// - Original: "Daily 9AM meeting" → ends Aug 21 (UNTIL=Aug22T000000Z)
|
||||||
// - New: "Daily 2PM meeting" → starts Aug 22, continues indefinitely
|
// - New: "Daily 2PM meeting" → starts Aug 22, continues indefinitely
|
||||||
//
|
//
|
||||||
// This approach ensures:
|
// This approach ensures:
|
||||||
@@ -177,7 +189,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
if let Some(update_callback) = &on_event_update {
|
if let Some(update_callback) = &on_event_update {
|
||||||
// Find the original series event (not the occurrence)
|
// Find the original series event (not the occurrence)
|
||||||
// UIDs like "uuid-timestamp" need to split on the last hyphen, not the first
|
// 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 base_uid = if let Some(last_hyphen_pos) = edit.event.uid.rfind('-')
|
||||||
|
{
|
||||||
let suffix = &edit.event.uid[last_hyphen_pos + 1..];
|
let suffix = &edit.event.uid[last_hyphen_pos + 1..];
|
||||||
// Check if suffix is numeric (timestamp), if so remove it
|
// Check if suffix is numeric (timestamp), if so remove it
|
||||||
if suffix.chars().all(|c| c.is_numeric()) {
|
if suffix.chars().all(|c| c.is_numeric()) {
|
||||||
@@ -188,9 +201,15 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
} else {
|
} else {
|
||||||
edit.event.uid.clone()
|
edit.event.uid.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🔍 Looking for original series: '{}' from occurrence: '{}'", base_uid, edit.event.uid).into());
|
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
|
// Find the original series event by searching for the base UID
|
||||||
let mut original_series = None;
|
let mut original_series = None;
|
||||||
for events_list in events.values() {
|
for events_list in events.values() {
|
||||||
@@ -204,12 +223,15 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let original_series = match original_series {
|
let original_series = match original_series {
|
||||||
Some(series) => {
|
Some(series) => {
|
||||||
web_sys::console::log_1(&format!("✅ Found original series: '{}'", series.uid).into());
|
web_sys::console::log_1(
|
||||||
|
&format!("✅ Found original series: '{}'", series.uid)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
series
|
series
|
||||||
},
|
}
|
||||||
None => {
|
None => {
|
||||||
web_sys::console::log_1(&format!("⚠️ Could not find original series '{}', using occurrence but fixing UID", base_uid).into());
|
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();
|
let mut fallback_event = edit.event.clone();
|
||||||
@@ -218,55 +240,69 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
fallback_event
|
fallback_event
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate the day before this occurrence for UNTIL clause
|
// Calculate the day before this occurrence for UNTIL clause
|
||||||
let until_date = edit.event.dtstart.date_naive() - chrono::Duration::days(1);
|
let until_date =
|
||||||
let until_datetime = until_date.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
|
edit.event.dtstart.date_naive() - chrono::Duration::days(1);
|
||||||
let until_utc = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(until_datetime, chrono::Utc);
|
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 {}",
|
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"),
|
until_utc.format("%Y-%m-%d %H:%M:%S UTC"),
|
||||||
edit.event.dtstart.format("%Y-%m-%d %H:%M:%S UTC")).into());
|
edit.event.dtstart.format("%Y-%m-%d %H:%M:%S UTC")).into());
|
||||||
|
|
||||||
// Critical: Use the dragged times (new_start/new_end) not the original series times
|
// Critical: Use the dragged times (new_start/new_end) not the original series times
|
||||||
// This ensures the new series reflects the user's drag operation
|
// This ensures the new series reflects the user's drag operation
|
||||||
let new_start = edit.new_start; // The dragged start time
|
let new_start = edit.new_start; // The dragged start time
|
||||||
let new_end = edit.new_end; // The dragged end time
|
let new_end = edit.new_end; // The dragged end time
|
||||||
|
|
||||||
// Extract occurrence date from the dragged event for backend processing
|
// Extract occurrence date from the dragged event for backend processing
|
||||||
// Format: YYYY-MM-DD (e.g., "2025-08-22")
|
// Format: YYYY-MM-DD (e.g., "2025-08-22")
|
||||||
// This tells the backend which specific occurrence is being modified
|
// This tells the backend which specific occurrence is being modified
|
||||||
let occurrence_date = edit.event.dtstart.format("%Y-%m-%d").to_string();
|
let occurrence_date = edit.event.dtstart.format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
// Send single request to backend with "this_and_future" scope
|
// Send single request to backend with "this_and_future" scope
|
||||||
// Backend will atomically:
|
// Backend will atomically:
|
||||||
// 1. Add UNTIL clause to original series (stops before occurrence_date)
|
// 1. Add UNTIL clause to original series (stops before occurrence_date)
|
||||||
// 2. Create new series starting from occurrence_date with dragged times
|
// 2. Create new series starting from occurrence_date with dragged times
|
||||||
update_callback.emit((
|
update_callback.emit((
|
||||||
original_series, // Original event to terminate
|
original_series, // Original event to terminate
|
||||||
new_start, // Dragged start time for new series
|
new_start, // Dragged start time for new series
|
||||||
new_end, // Dragged end time for new series
|
new_end, // Dragged end time for new series
|
||||||
true, // preserve_rrule = true
|
true, // preserve_rrule = true
|
||||||
Some(until_utc), // UNTIL date for original series
|
Some(until_utc), // UNTIL date for original series
|
||||||
Some("this_and_future".to_string()), // Update scope
|
Some("this_and_future".to_string()), // Update scope
|
||||||
Some(occurrence_date) // Date of occurrence being modified
|
Some(occurrence_date), // Date of occurrence being modified
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
RecurringEditAction::AllEvents => {
|
RecurringEditAction::AllEvents => {
|
||||||
// Modify the entire series
|
// Modify the entire series
|
||||||
let series_event = edit.event.clone();
|
let series_event = edit.event.clone();
|
||||||
|
|
||||||
if let Some(callback) = &on_event_update {
|
if let Some(callback) = &on_event_update {
|
||||||
callback.emit((series_event, edit.new_start, edit.new_end, true, None, Some("all_in_series".to_string()), None)); // Regular drag operation - preserve RRULE, update_scope = all_in_series
|
callback.emit((
|
||||||
|
series_event,
|
||||||
|
edit.new_start,
|
||||||
|
edit.new_end,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
Some("all_in_series".to_string()),
|
||||||
|
None,
|
||||||
|
)); // Regular drag operation - preserve RRULE, update_scope = all_in_series
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pending_recurring_edit.set(None);
|
pending_recurring_edit.set(None);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_recurring_cancel = {
|
let on_recurring_cancel = {
|
||||||
let pending_recurring_edit = pending_recurring_edit.clone();
|
let pending_recurring_edit = pending_recurring_edit.clone();
|
||||||
Callback::from(move |_| {
|
Callback::from(move |_| {
|
||||||
@@ -283,7 +319,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
week_days.iter().map(|date| {
|
week_days.iter().map(|date| {
|
||||||
let is_today = *date == props.today;
|
let is_today = *date == props.today;
|
||||||
let weekday_name = get_weekday_name(date.weekday());
|
let weekday_name = get_weekday_name(date.weekday());
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
|
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
|
||||||
<div class="weekday-name">{weekday_name}</div>
|
<div class="weekday-name">{weekday_name}</div>
|
||||||
@@ -293,7 +329,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Scrollable content area with time grid
|
// Scrollable content area with time grid
|
||||||
<div class="week-content">
|
<div class="week-content">
|
||||||
<div class="time-grid">
|
<div class="time-grid">
|
||||||
@@ -310,18 +346,18 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Day columns
|
// Day columns
|
||||||
<div class="week-days-grid">
|
<div class="week-days-grid">
|
||||||
{
|
{
|
||||||
week_days.iter().enumerate().map(|(_column_index, date)| {
|
week_days.iter().enumerate().map(|(_column_index, date)| {
|
||||||
let is_today = *date == props.today;
|
let is_today = *date == props.today;
|
||||||
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
||||||
|
|
||||||
// Drag event handlers
|
// Drag event handlers
|
||||||
let drag_state_clone = drag_state.clone();
|
let drag_state_clone = drag_state.clone();
|
||||||
let date_for_drag = *date;
|
let date_for_drag = *date;
|
||||||
|
|
||||||
let onmousedown = {
|
let onmousedown = {
|
||||||
let drag_state = drag_state_clone.clone();
|
let drag_state = drag_state_clone.clone();
|
||||||
let context_menus_open = props.context_menus_open;
|
let context_menus_open = props.context_menus_open;
|
||||||
@@ -331,20 +367,20 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
if context_menus_open {
|
if context_menus_open {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only handle left-click (button 0)
|
// Only handle left-click (button 0)
|
||||||
if e.button() != 0 {
|
if e.button() != 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate Y position relative to day column container
|
// Calculate Y position relative to day column container
|
||||||
// Use layer_y which gives coordinates relative to positioned ancestor
|
// Use layer_y which gives coordinates relative to positioned ancestor
|
||||||
let relative_y = e.layer_y() as f64;
|
let relative_y = e.layer_y() as f64;
|
||||||
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
||||||
|
|
||||||
// Snap to increment
|
// Snap to increment
|
||||||
let snapped_y = snap_to_increment(relative_y, time_increment);
|
let snapped_y = snap_to_increment(relative_y, time_increment);
|
||||||
|
|
||||||
drag_state.set(Some(DragState {
|
drag_state.set(Some(DragState {
|
||||||
is_dragging: true,
|
is_dragging: true,
|
||||||
drag_type: DragType::CreateEvent,
|
drag_type: DragType::CreateEvent,
|
||||||
@@ -357,7 +393,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let onmousemove = {
|
let onmousemove = {
|
||||||
let drag_state = drag_state_clone.clone();
|
let drag_state = drag_state_clone.clone();
|
||||||
let time_increment = props.time_increment;
|
let time_increment = props.time_increment;
|
||||||
@@ -367,27 +403,27 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Use layer_y for consistent coordinate calculation
|
// Use layer_y for consistent coordinate calculation
|
||||||
let mouse_y = e.layer_y() as f64;
|
let mouse_y = e.layer_y() as f64;
|
||||||
let mouse_y = if mouse_y > 0.0 { mouse_y } else { e.offset_y() as f64 };
|
let mouse_y = if mouse_y > 0.0 { mouse_y } else { e.offset_y() as f64 };
|
||||||
|
|
||||||
// For move operations, we now follow the mouse directly since we start at click position
|
// For move operations, we now follow the mouse directly since we start at click position
|
||||||
// For resize operations, we still use the mouse position directly
|
// For resize operations, we still use the mouse position directly
|
||||||
let adjusted_y = mouse_y;
|
let adjusted_y = mouse_y;
|
||||||
|
|
||||||
// Snap to increment
|
// Snap to increment
|
||||||
let snapped_y = snap_to_increment(adjusted_y, time_increment);
|
let snapped_y = snap_to_increment(adjusted_y, time_increment);
|
||||||
|
|
||||||
// Check if we've moved enough to constitute a real drag (5 pixels minimum)
|
// Check if we've moved enough to constitute a real drag (5 pixels minimum)
|
||||||
let movement_distance = (snapped_y - current_drag.start_y).abs();
|
let movement_distance = (snapped_y - current_drag.start_y).abs();
|
||||||
if movement_distance > 5.0 {
|
if movement_distance > 5.0 {
|
||||||
current_drag.has_moved = true;
|
current_drag.has_moved = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
current_drag.current_y = snapped_y;
|
current_drag.current_y = snapped_y;
|
||||||
drag_state.set(Some(current_drag));
|
drag_state.set(Some(current_drag));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let onmouseup = {
|
let onmouseup = {
|
||||||
let drag_state = drag_state_clone.clone();
|
let drag_state = drag_state_clone.clone();
|
||||||
let on_create_event = props.on_create_event.clone();
|
let on_create_event = props.on_create_event.clone();
|
||||||
@@ -402,24 +438,24 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Calculate start and end times
|
// Calculate start and end times
|
||||||
let start_time = pixels_to_time(current_drag.start_y);
|
let start_time = pixels_to_time(current_drag.start_y);
|
||||||
let end_time = pixels_to_time(current_drag.current_y);
|
let end_time = pixels_to_time(current_drag.current_y);
|
||||||
|
|
||||||
// Ensure start is before end
|
// Ensure start is before end
|
||||||
let (actual_start, actual_end) = if start_time <= end_time {
|
let (actual_start, actual_end) = if start_time <= end_time {
|
||||||
(start_time, end_time)
|
(start_time, end_time)
|
||||||
} else {
|
} else {
|
||||||
(end_time, start_time)
|
(end_time, start_time)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure minimum duration (15 minutes)
|
// Ensure minimum duration (15 minutes)
|
||||||
let actual_end = if actual_end.signed_duration_since(actual_start).num_minutes() < 15 {
|
let actual_end = if actual_end.signed_duration_since(actual_start).num_minutes() < 15 {
|
||||||
actual_start + chrono::Duration::minutes(15)
|
actual_start + chrono::Duration::minutes(15)
|
||||||
} else {
|
} else {
|
||||||
actual_end
|
actual_end
|
||||||
};
|
};
|
||||||
|
|
||||||
let start_datetime = NaiveDateTime::new(current_drag.start_date, actual_start);
|
let start_datetime = NaiveDateTime::new(current_drag.start_date, actual_start);
|
||||||
let end_datetime = NaiveDateTime::new(current_drag.start_date, actual_end);
|
let end_datetime = NaiveDateTime::new(current_drag.start_date, actual_end);
|
||||||
|
|
||||||
if let Some(callback) = &on_create_event {
|
if let Some(callback) = &on_create_event {
|
||||||
callback.emit((current_drag.start_date, start_datetime, end_datetime));
|
callback.emit((current_drag.start_date, start_datetime, end_datetime));
|
||||||
}
|
}
|
||||||
@@ -430,17 +466,17 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Snap the final position to maintain time increment alignment
|
// Snap the final position to maintain time increment alignment
|
||||||
let event_top_position = snap_to_increment(unsnapped_position, time_increment);
|
let event_top_position = snap_to_increment(unsnapped_position, time_increment);
|
||||||
let new_start_time = pixels_to_time(event_top_position);
|
let new_start_time = pixels_to_time(event_top_position);
|
||||||
|
|
||||||
// Calculate duration from original event
|
// Calculate duration from original event
|
||||||
let original_duration = if let Some(end) = event.dtend {
|
let original_duration = if let Some(end) = event.dtend {
|
||||||
end.signed_duration_since(event.dtstart)
|
end.signed_duration_since(event.dtstart)
|
||||||
} else {
|
} else {
|
||||||
chrono::Duration::hours(1) // Default 1 hour
|
chrono::Duration::hours(1) // Default 1 hour
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
||||||
let new_end_datetime = new_start_datetime + original_duration;
|
let new_end_datetime = new_start_datetime + original_duration;
|
||||||
|
|
||||||
// Check if this is a recurring event
|
// Check if this is a recurring event
|
||||||
if event.rrule.is_some() {
|
if event.rrule.is_some() {
|
||||||
// Show modal for recurring event modification
|
// Show modal for recurring event modification
|
||||||
@@ -459,7 +495,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
DragType::ResizeEventStart(event) => {
|
DragType::ResizeEventStart(event) => {
|
||||||
// Calculate new start time based on drag position
|
// Calculate new start time based on drag position
|
||||||
let new_start_time = pixels_to_time(current_drag.current_y);
|
let new_start_time = pixels_to_time(current_drag.current_y);
|
||||||
|
|
||||||
// Keep the original end time
|
// Keep the original end time
|
||||||
let original_end = if let Some(end) = event.dtend {
|
let original_end = if let Some(end) = event.dtend {
|
||||||
end.with_timezone(&chrono::Local).naive_local()
|
end.with_timezone(&chrono::Local).naive_local()
|
||||||
@@ -467,16 +503,16 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// If no end time, use start time + 1 hour as default
|
// If no end time, use start time + 1 hour as default
|
||||||
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
|
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
||||||
|
|
||||||
// Ensure start is before end (minimum 15 minutes)
|
// Ensure start is before end (minimum 15 minutes)
|
||||||
let new_end_datetime = if new_start_datetime >= original_end {
|
let new_end_datetime = if new_start_datetime >= original_end {
|
||||||
new_start_datetime + chrono::Duration::minutes(15)
|
new_start_datetime + chrono::Duration::minutes(15)
|
||||||
} else {
|
} else {
|
||||||
original_end
|
original_end
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if this is a recurring event
|
// Check if this is a recurring event
|
||||||
if event.rrule.is_some() {
|
if event.rrule.is_some() {
|
||||||
// Show modal for recurring event modification
|
// Show modal for recurring event modification
|
||||||
@@ -495,19 +531,19 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
DragType::ResizeEventEnd(event) => {
|
DragType::ResizeEventEnd(event) => {
|
||||||
// Calculate new end time based on drag position
|
// Calculate new end time based on drag position
|
||||||
let new_end_time = pixels_to_time(current_drag.current_y);
|
let new_end_time = pixels_to_time(current_drag.current_y);
|
||||||
|
|
||||||
// Keep the original start time
|
// Keep the original start time
|
||||||
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
||||||
|
|
||||||
let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time);
|
let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time);
|
||||||
|
|
||||||
// Ensure end is after start (minimum 15 minutes)
|
// Ensure end is after start (minimum 15 minutes)
|
||||||
let new_start_datetime = if new_end_datetime <= original_start {
|
let new_start_datetime = if new_end_datetime <= original_start {
|
||||||
new_end_datetime - chrono::Duration::minutes(15)
|
new_end_datetime - chrono::Duration::minutes(15)
|
||||||
} else {
|
} else {
|
||||||
original_start
|
original_start
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if this is a recurring event
|
// Check if this is a recurring event
|
||||||
if event.rrule.is_some() {
|
if event.rrule.is_some() {
|
||||||
// Show modal for recurring event modification
|
// Show modal for recurring event modification
|
||||||
@@ -524,15 +560,15 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
drag_state.set(None);
|
drag_state.set(None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class={classes!("week-day-column", if is_today { Some("today") } else { None })}
|
class={classes!("week-day-column", if is_today { Some("today") } else { None })}
|
||||||
{onmousedown}
|
{onmousedown}
|
||||||
{onmousemove}
|
{onmousemove}
|
||||||
@@ -554,21 +590,21 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
<div class="time-slot-half"></div>
|
<div class="time-slot-half"></div>
|
||||||
<div class="time-slot-half"></div>
|
<div class="time-slot-half"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Events positioned absolutely based on their actual times
|
// Events positioned absolutely based on their actual times
|
||||||
<div class="events-container">
|
<div class="events-container">
|
||||||
{
|
{
|
||||||
day_events.iter().filter_map(|event| {
|
day_events.iter().filter_map(|event| {
|
||||||
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
|
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
|
||||||
|
|
||||||
// Skip events that don't belong on this date or have invalid positioning
|
// Skip events that don't belong on this date or have invalid positioning
|
||||||
if start_pixels == 0.0 && duration_pixels == 0.0 && !is_all_day {
|
if start_pixels == 0.0 && duration_pixels == 0.0 && !is_all_day {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let event_color = get_event_color(event);
|
let event_color = get_event_color(event);
|
||||||
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
||||||
|
|
||||||
let onclick = {
|
let onclick = {
|
||||||
let on_event_click = props.on_event_click.clone();
|
let on_event_click = props.on_event_click.clone();
|
||||||
let event = event.clone();
|
let event = event.clone();
|
||||||
@@ -577,7 +613,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
on_event_click.emit(event.clone());
|
on_event_click.emit(event.clone());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let onmousedown_event = {
|
let onmousedown_event = {
|
||||||
let drag_state = drag_state.clone();
|
let drag_state = drag_state.clone();
|
||||||
let event_for_drag = event.clone();
|
let event_for_drag = event.clone();
|
||||||
@@ -585,27 +621,27 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let _time_increment = props.time_increment;
|
let _time_increment = props.time_increment;
|
||||||
Callback::from(move |e: MouseEvent| {
|
Callback::from(move |e: MouseEvent| {
|
||||||
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
|
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
|
||||||
|
|
||||||
// Only handle left-click (button 0) for moving
|
// Only handle left-click (button 0) for moving
|
||||||
if e.button() != 0 {
|
if e.button() != 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate click position relative to event element
|
// Calculate click position relative to event element
|
||||||
let click_y_relative = e.layer_y() as f64;
|
let click_y_relative = e.layer_y() as f64;
|
||||||
let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 };
|
let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 };
|
||||||
|
|
||||||
// Get event's current position in day column coordinates
|
// Get event's current position in day column coordinates
|
||||||
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag);
|
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag);
|
||||||
let event_start_pixels = event_start_pixels as f64;
|
let event_start_pixels = event_start_pixels as f64;
|
||||||
|
|
||||||
// Convert click position to day column coordinates
|
// Convert click position to day column coordinates
|
||||||
let click_y = event_start_pixels + click_y_relative;
|
let click_y = event_start_pixels + click_y_relative;
|
||||||
|
|
||||||
// Store the offset from the event's top where the user clicked
|
// Store the offset from the event's top where the user clicked
|
||||||
// This will be used to maintain the relative click position
|
// This will be used to maintain the relative click position
|
||||||
let offset_y = click_y_relative;
|
let offset_y = click_y_relative;
|
||||||
|
|
||||||
// Start drag tracking from where we clicked (in day column coordinates)
|
// Start drag tracking from where we clicked (in day column coordinates)
|
||||||
drag_state.set(Some(DragState {
|
drag_state.set(Some(DragState {
|
||||||
is_dragging: true,
|
is_dragging: true,
|
||||||
@@ -619,7 +655,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let oncontextmenu = {
|
let oncontextmenu = {
|
||||||
if let Some(callback) = &props.on_event_context_menu {
|
if let Some(callback) = &props.on_event_context_menu {
|
||||||
let callback = callback.clone();
|
let callback = callback.clone();
|
||||||
@@ -633,7 +669,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
e.stop_propagation(); // Prevent calendar context menu from also triggering
|
e.stop_propagation(); // Prevent calendar context menu from also triggering
|
||||||
callback.emit((e, event.clone()));
|
callback.emit((e, event.clone()));
|
||||||
@@ -642,7 +678,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format time display for the event
|
// Format time display for the event
|
||||||
let time_display = if event.all_day {
|
let time_display = if event.all_day {
|
||||||
"All Day".to_string()
|
"All Day".to_string()
|
||||||
@@ -650,20 +686,20 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let local_start = event.dtstart.with_timezone(&Local);
|
let local_start = event.dtstart.with_timezone(&Local);
|
||||||
if let Some(end) = event.dtend {
|
if let Some(end) = event.dtend {
|
||||||
let local_end = end.with_timezone(&Local);
|
let local_end = end.with_timezone(&Local);
|
||||||
|
|
||||||
// Check if both times are in same AM/PM period to avoid redundancy
|
// Check if both times are in same AM/PM period to avoid redundancy
|
||||||
let start_is_am = local_start.hour() < 12;
|
let start_is_am = local_start.hour() < 12;
|
||||||
let end_is_am = local_end.hour() < 12;
|
let end_is_am = local_end.hour() < 12;
|
||||||
|
|
||||||
if start_is_am == end_is_am {
|
if start_is_am == end_is_am {
|
||||||
// Same AM/PM period - show "9:00 - 10:30 AM"
|
// Same AM/PM period - show "9:00 - 10:30 AM"
|
||||||
format!("{} - {}",
|
format!("{} - {}",
|
||||||
local_start.format("%I:%M").to_string().trim_start_matches('0'),
|
local_start.format("%I:%M").to_string().trim_start_matches('0'),
|
||||||
local_end.format("%I:%M %p")
|
local_end.format("%I:%M %p")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Different AM/PM periods - show "9:00 AM - 2:30 PM"
|
// Different AM/PM periods - show "9:00 AM - 2:30 PM"
|
||||||
format!("{} - {}",
|
format!("{} - {}",
|
||||||
local_start.format("%I:%M %p"),
|
local_start.format("%I:%M %p"),
|
||||||
local_end.format("%I:%M %p")
|
local_end.format("%I:%M %p")
|
||||||
)
|
)
|
||||||
@@ -673,22 +709,22 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
format!("{}", local_start.format("%I:%M %p"))
|
format!("{}", local_start.format("%I:%M %p"))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if this event is currently being dragged or resized
|
// Check if this event is currently being dragged or resized
|
||||||
let is_being_dragged = if let Some(drag) = (*drag_state).clone() {
|
let is_being_dragged = if let Some(drag) = (*drag_state).clone() {
|
||||||
match &drag.drag_type {
|
match &drag.drag_type {
|
||||||
DragType::MoveEvent(dragged_event) =>
|
DragType::MoveEvent(dragged_event) =>
|
||||||
dragged_event.uid == event.uid && drag.is_dragging,
|
dragged_event.uid == event.uid && drag.is_dragging,
|
||||||
DragType::ResizeEventStart(dragged_event) =>
|
DragType::ResizeEventStart(dragged_event) =>
|
||||||
dragged_event.uid == event.uid && drag.is_dragging,
|
dragged_event.uid == event.uid && drag.is_dragging,
|
||||||
DragType::ResizeEventEnd(dragged_event) =>
|
DragType::ResizeEventEnd(dragged_event) =>
|
||||||
dragged_event.uid == event.uid && drag.is_dragging,
|
dragged_event.uid == event.uid && drag.is_dragging,
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
if is_being_dragged {
|
if is_being_dragged {
|
||||||
// Hide the original event while being dragged
|
// Hide the original event while being dragged
|
||||||
Some(html! {})
|
Some(html! {})
|
||||||
@@ -701,11 +737,11 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let time_increment = props.time_increment;
|
let time_increment = props.time_increment;
|
||||||
Callback::from(move |e: web_sys::MouseEvent| {
|
Callback::from(move |e: web_sys::MouseEvent| {
|
||||||
e.stop_propagation();
|
e.stop_propagation();
|
||||||
|
|
||||||
let relative_y = e.layer_y() as f64;
|
let relative_y = e.layer_y() as f64;
|
||||||
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
||||||
let snapped_y = snap_to_increment(relative_y, time_increment);
|
let snapped_y = snap_to_increment(relative_y, time_increment);
|
||||||
|
|
||||||
drag_state.set(Some(DragState {
|
drag_state.set(Some(DragState {
|
||||||
is_dragging: true,
|
is_dragging: true,
|
||||||
drag_type: DragType::ResizeEventStart(event_for_resize.clone()),
|
drag_type: DragType::ResizeEventStart(event_for_resize.clone()),
|
||||||
@@ -718,7 +754,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let resize_end_handler = {
|
let resize_end_handler = {
|
||||||
let drag_state = drag_state.clone();
|
let drag_state = drag_state.clone();
|
||||||
let event_for_resize = event.clone();
|
let event_for_resize = event.clone();
|
||||||
@@ -726,11 +762,11 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let time_increment = props.time_increment;
|
let time_increment = props.time_increment;
|
||||||
Callback::from(move |e: web_sys::MouseEvent| {
|
Callback::from(move |e: web_sys::MouseEvent| {
|
||||||
e.stop_propagation();
|
e.stop_propagation();
|
||||||
|
|
||||||
let relative_y = e.layer_y() as f64;
|
let relative_y = e.layer_y() as f64;
|
||||||
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
||||||
let snapped_y = snap_to_increment(relative_y, time_increment);
|
let snapped_y = snap_to_increment(relative_y, time_increment);
|
||||||
|
|
||||||
drag_state.set(Some(DragState {
|
drag_state.set(Some(DragState {
|
||||||
is_dragging: true,
|
is_dragging: true,
|
||||||
drag_type: DragType::ResizeEventEnd(event_for_resize.clone()),
|
drag_type: DragType::ResizeEventEnd(event_for_resize.clone()),
|
||||||
@@ -743,18 +779,18 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(html! {
|
Some(html! {
|
||||||
<div
|
<div
|
||||||
class={classes!(
|
class={classes!(
|
||||||
"week-event",
|
"week-event",
|
||||||
if is_refreshing { Some("refreshing") } else { None },
|
if is_refreshing { Some("refreshing") } else { None },
|
||||||
if is_all_day { Some("all-day") } else { None }
|
if is_all_day { Some("all-day") } else { None }
|
||||||
)}
|
)}
|
||||||
style={format!(
|
style={format!(
|
||||||
"background-color: {}; top: {}px; height: {}px;",
|
"background-color: {}; top: {}px; height: {}px;",
|
||||||
event_color,
|
event_color,
|
||||||
start_pixels,
|
start_pixels,
|
||||||
duration_pixels
|
duration_pixels
|
||||||
)}
|
)}
|
||||||
{onclick}
|
{onclick}
|
||||||
@@ -764,7 +800,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Top resize handle
|
// Top resize handle
|
||||||
{if !is_all_day {
|
{if !is_all_day {
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="resize-handle resize-handle-top"
|
class="resize-handle resize-handle-top"
|
||||||
onmousedown={resize_start_handler}
|
onmousedown={resize_start_handler}
|
||||||
/>
|
/>
|
||||||
@@ -772,7 +808,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
} else {
|
} else {
|
||||||
html! {}
|
html! {}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Event content
|
// Event content
|
||||||
<div class="event-content">
|
<div class="event-content">
|
||||||
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
||||||
@@ -782,11 +818,11 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Bottom resize handle
|
// Bottom resize handle
|
||||||
{if !is_all_day {
|
{if !is_all_day {
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="resize-handle resize-handle-bottom"
|
class="resize-handle resize-handle-bottom"
|
||||||
onmousedown={resize_end_handler}
|
onmousedown={resize_end_handler}
|
||||||
/>
|
/>
|
||||||
@@ -800,7 +836,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Temporary event box during drag
|
// Temporary event box during drag
|
||||||
{
|
{
|
||||||
if let Some(drag) = (*drag_state).clone() {
|
if let Some(drag) = (*drag_state).clone() {
|
||||||
@@ -810,11 +846,11 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let start_y = drag.start_y.min(drag.current_y);
|
let start_y = drag.start_y.min(drag.current_y);
|
||||||
let end_y = drag.start_y.max(drag.current_y);
|
let end_y = drag.start_y.max(drag.current_y);
|
||||||
let height = (drag.current_y - drag.start_y).abs().max(20.0);
|
let height = (drag.current_y - drag.start_y).abs().max(20.0);
|
||||||
|
|
||||||
// Convert pixels to times for display
|
// Convert pixels to times for display
|
||||||
let start_time = pixels_to_time(start_y);
|
let start_time = pixels_to_time(start_y);
|
||||||
let end_time = pixels_to_time(end_y);
|
let end_time = pixels_to_time(end_y);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="temp-event-box"
|
class="temp-event-box"
|
||||||
@@ -837,9 +873,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
};
|
};
|
||||||
let duration_pixels = (original_duration.num_minutes() as f64).max(20.0);
|
let duration_pixels = (original_duration.num_minutes() as f64).max(20.0);
|
||||||
let new_end_time = new_start_time + original_duration;
|
let new_end_time = new_start_time + original_duration;
|
||||||
|
|
||||||
let event_color = get_event_color(event);
|
let event_color = get_event_color(event);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="temp-event-box moving-event"
|
class="temp-event-box moving-event"
|
||||||
@@ -858,17 +894,17 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
} else {
|
} else {
|
||||||
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
|
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate positions for the preview
|
// Calculate positions for the preview
|
||||||
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date);
|
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date);
|
||||||
let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local());
|
let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local());
|
||||||
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
|
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
|
||||||
|
|
||||||
let new_start_pixels = drag.current_y;
|
let new_start_pixels = drag.current_y;
|
||||||
let new_height = (original_end_pixels as f64 - new_start_pixels).max(20.0);
|
let new_height = (original_end_pixels as f64 - new_start_pixels).max(20.0);
|
||||||
|
|
||||||
let event_color = get_event_color(event);
|
let event_color = get_event_color(event);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="temp-event-box resizing-event"
|
class="temp-event-box resizing-event"
|
||||||
@@ -883,15 +919,15 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Show the event being resized from the end
|
// Show the event being resized from the end
|
||||||
let new_end_time = pixels_to_time(drag.current_y);
|
let new_end_time = pixels_to_time(drag.current_y);
|
||||||
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
||||||
|
|
||||||
// Calculate positions for the preview
|
// Calculate positions for the preview
|
||||||
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date);
|
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date);
|
||||||
|
|
||||||
let new_end_pixels = drag.current_y;
|
let new_end_pixels = drag.current_y;
|
||||||
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
|
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
|
||||||
|
|
||||||
let event_color = get_event_color(event);
|
let event_color = get_event_color(event);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class="temp-event-box resizing-event"
|
class="temp-event-box resizing-event"
|
||||||
@@ -917,10 +953,10 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Recurring event modification modal
|
// Recurring event modification modal
|
||||||
if let Some(edit) = (*pending_recurring_edit).clone() {
|
if let Some(edit) = (*pending_recurring_edit).clone() {
|
||||||
<RecurringEditModal
|
<RecurringEditModal
|
||||||
show={true}
|
show={true}
|
||||||
event={edit.event}
|
event={edit.event}
|
||||||
new_start={edit.new_start}
|
new_start={edit.new_start}
|
||||||
@@ -975,46 +1011,44 @@ fn pixels_to_time(pixels: f64) -> NaiveTime {
|
|||||||
let total_minutes = pixels; // 1px = 1 minute
|
let total_minutes = pixels; // 1px = 1 minute
|
||||||
let hours = (total_minutes / 60.0) as u32;
|
let hours = (total_minutes / 60.0) as u32;
|
||||||
let minutes = (total_minutes % 60.0) as u32;
|
let minutes = (total_minutes % 60.0) as u32;
|
||||||
|
|
||||||
// Handle midnight boundary - if we're at exactly 1440 pixels (24:00), return midnight
|
// Handle midnight boundary - if we're at exactly 1440 pixels (24:00), return midnight
|
||||||
if total_minutes >= 1440.0 {
|
if total_minutes >= 1440.0 {
|
||||||
return NaiveTime::from_hms_opt(0, 0, 0).unwrap();
|
return NaiveTime::from_hms_opt(0, 0, 0).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp to valid time range for within-day times
|
// Clamp to valid time range for within-day times
|
||||||
let hours = hours.min(23);
|
let hours = hours.min(23);
|
||||||
let minutes = minutes.min(59);
|
let minutes = minutes.min(59);
|
||||||
|
|
||||||
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) {
|
fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) {
|
||||||
// Convert UTC times to local time for display
|
// Convert UTC times to local time for display
|
||||||
let local_start = event.dtstart.with_timezone(&Local);
|
let local_start = event.dtstart.with_timezone(&Local);
|
||||||
let event_date = local_start.date_naive();
|
let event_date = local_start.date_naive();
|
||||||
|
|
||||||
// Only position events that are on this specific date
|
// Only position events that are on this specific date
|
||||||
if event_date != date {
|
if event_date != date {
|
||||||
return (0.0, 0.0, false); // Event not on this date
|
return (0.0, 0.0, false); // Event not on this date
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle all-day events - they appear at the top
|
// Handle all-day events - they appear at the top
|
||||||
if event.all_day {
|
if event.all_day {
|
||||||
return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true
|
return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate start position in pixels from midnight
|
// Calculate start position in pixels from midnight
|
||||||
let start_hour = local_start.hour() as f32;
|
let start_hour = local_start.hour() as f32;
|
||||||
let start_minute = local_start.minute() as f32;
|
let start_minute = local_start.minute() as f32;
|
||||||
let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour
|
let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour
|
||||||
|
|
||||||
|
|
||||||
// Calculate duration and height
|
// Calculate duration and height
|
||||||
let duration_pixels = if let Some(end) = event.dtend {
|
let duration_pixels = if let Some(end) = event.dtend {
|
||||||
let local_end = end.with_timezone(&Local);
|
let local_end = end.with_timezone(&Local);
|
||||||
let end_date = local_end.date_naive();
|
let end_date = local_end.date_naive();
|
||||||
|
|
||||||
// Handle events that span multiple days by capping at midnight
|
// Handle events that span multiple days by capping at midnight
|
||||||
if end_date > date {
|
if end_date > date {
|
||||||
// Event continues past midnight, cap at 24:00 (1440px)
|
// Event continues past midnight, cap at 24:00 (1440px)
|
||||||
@@ -1028,6 +1062,6 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool)
|
|||||||
} else {
|
} else {
|
||||||
60.0 // Default 1 hour if no end time
|
60.0 // Default 1 hour if no end time
|
||||||
};
|
};
|
||||||
|
|
||||||
(start_pixels, duration_pixels, false) // is_all_day = false
|
(start_pixels, duration_pixels, false) // is_all_day = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod components;
|
mod components;
|
||||||
@@ -9,4 +8,4 @@ use app::App;
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
yew::Renderer::<App>::new().render();
|
yew::Renderer::<App>::new().render();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Re-export from shared calendar-models library for backward compatibility
|
// Re-export from shared calendar-models library for backward compatibility
|
||||||
pub use calendar_models::*;
|
pub use calendar_models::*;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// RFC 5545 Compliant iCalendar Models
|
// RFC 5545 Compliant iCalendar Models
|
||||||
pub mod ical;
|
pub mod ical;
|
||||||
|
|
||||||
// Re-export commonly used types
|
// Re-export commonly used types
|
||||||
// pub use ical::VEvent;
|
// pub use ical::VEvent;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
|||||||
pub mod calendar_service;
|
pub mod calendar_service;
|
||||||
|
|
||||||
pub use calendar_service::CalendarService;
|
pub use calendar_service::CalendarService;
|
||||||
|
|||||||
Reference in New Issue
Block a user