Compare commits
8 Commits
feature/mo
...
bugfix/sql
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd6e9c3619 | ||
|
|
d8c3997f24 | ||
|
|
e44d49e190 | ||
|
|
0453763c98 | ||
|
|
03c0011445 | ||
|
|
79f287ed61 | ||
|
|
e55e6bf4dd | ||
| 1fa3bf44b6 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -22,3 +22,9 @@ dist/
|
||||
CLAUDE.md
|
||||
|
||||
data/
|
||||
|
||||
# SQLite database
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
calendar.db
|
||||
|
||||
21
Dockerfile
21
Dockerfile
@@ -47,6 +47,9 @@ FROM rust:alpine AS backend-builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static
|
||||
|
||||
# Install sqlx-cli for migrations
|
||||
RUN cargo install sqlx-cli --no-default-features --features sqlite
|
||||
|
||||
# Copy shared models
|
||||
COPY calendar-models ./calendar-models
|
||||
|
||||
@@ -76,19 +79,29 @@ RUN cargo build --release --bin backend
|
||||
FROM alpine:latest
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite
|
||||
|
||||
# Copy frontend files to temporary location
|
||||
COPY --from=builder /app/frontend/dist /app/frontend-dist
|
||||
|
||||
# Copy backend binary (built in workspace root)
|
||||
# Copy backend binary and sqlx-cli
|
||||
COPY --from=backend-builder /app/target/release/backend /usr/local/bin/backend
|
||||
COPY --from=backend-builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx
|
||||
|
||||
# Create startup script to copy frontend files to shared volume
|
||||
RUN mkdir -p /srv/www
|
||||
# Copy migrations for database setup
|
||||
COPY --from=backend-builder /app/backend/migrations /migrations
|
||||
|
||||
# Create startup script to copy frontend files, run migrations, and start backend
|
||||
RUN mkdir -p /srv/www /db
|
||||
RUN echo '#!/bin/sh' > /usr/local/bin/start.sh && \
|
||||
echo 'echo "Copying frontend files..."' >> /usr/local/bin/start.sh && \
|
||||
echo 'cp -r /app/frontend-dist/* /srv/www/' >> /usr/local/bin/start.sh && \
|
||||
echo 'echo "Ensuring database directory exists..."' >> /usr/local/bin/start.sh && \
|
||||
echo 'mkdir -p /db && chmod 755 /db' >> /usr/local/bin/start.sh && \
|
||||
echo 'echo "Running database migrations..."' >> /usr/local/bin/start.sh && \
|
||||
echo 'sqlx migrate run --database-url "sqlite:///db/calendar.db" --source /migrations || echo "Migration failed but continuing..."' >> /usr/local/bin/start.sh && \
|
||||
echo 'echo "Starting backend server..."' >> /usr/local/bin/start.sh && \
|
||||
echo 'export DATABASE_URL="sqlite:///db/calendar.db"' >> /usr/local/bin/start.sh && \
|
||||
echo '/usr/local/bin/backend' >> /usr/local/bin/start.sh && \
|
||||
chmod +x /usr/local/bin/start.sh
|
||||
|
||||
|
||||
48
README.md
48
README.md
@@ -29,6 +29,12 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
||||
- **Real-time Updates**: Seamless synchronization with CalDAV servers
|
||||
- **Timezone Aware**: Proper local time display with UTC storage
|
||||
|
||||
### User Experience
|
||||
- **Persistent Preferences**: Settings sync across devices and sessions
|
||||
- **Remember Me**: Optional server/username remembering for convenience
|
||||
- **Session Management**: Secure session tokens with automatic expiry
|
||||
- **Cross-Device Sync**: User preferences stored in database, not just browser
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frontend (Yew WebAssembly)
|
||||
@@ -40,7 +46,8 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
||||
|
||||
### Backend (Axum)
|
||||
- **Framework**: Axum async web framework with CORS support
|
||||
- **Authentication**: JWT token management and validation
|
||||
- **Authentication**: SQLite-backed session management with JWT tokens
|
||||
- **Database**: SQLite for user preferences and session storage
|
||||
- **CalDAV Client**: Full CalDAV protocol implementation for server sync
|
||||
- **API Design**: RESTful endpoints following calendar operation patterns
|
||||
- **Data Flow**: Proxy between frontend and CalDAV servers with proper authentication
|
||||
@@ -54,12 +61,36 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
### Docker Deployment (Recommended)
|
||||
|
||||
The easiest way to run the calendar is using Docker Compose:
|
||||
|
||||
1. **Clone the repository**:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd calendar
|
||||
```
|
||||
|
||||
2. **Start the application**:
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
3. **Access the application** at `http://localhost`
|
||||
|
||||
The Docker setup includes:
|
||||
- **Automatic database migrations** on startup
|
||||
- **Persistent data storage** in `./data/db/` volume
|
||||
- **Frontend served via Caddy** on port 80
|
||||
- **Backend API** accessible on port 3000
|
||||
|
||||
### Development Setup
|
||||
|
||||
#### Prerequisites
|
||||
- Rust (latest stable version)
|
||||
- Trunk (`cargo install trunk`)
|
||||
|
||||
### Development Setup
|
||||
#### Local Development
|
||||
|
||||
1. **Start the backend server** (serves API at http://localhost:3000):
|
||||
```bash
|
||||
@@ -73,6 +104,17 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
||||
|
||||
3. **Access the application** at `http://localhost:8080`
|
||||
|
||||
#### Database Setup
|
||||
|
||||
For local development, run the database migrations:
|
||||
```bash
|
||||
# Install sqlx-cli if not already installed
|
||||
cargo install sqlx-cli --features sqlite
|
||||
|
||||
# Run migrations
|
||||
sqlx migrate run --database-url "sqlite:calendar.db" --source backend/migrations
|
||||
```
|
||||
|
||||
### Building for Production
|
||||
|
||||
```bash
|
||||
|
||||
@@ -34,6 +34,10 @@ base64 = "0.21"
|
||||
thiserror = "1.0"
|
||||
lazy_static = "1.4"
|
||||
|
||||
# Database dependencies
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "uuid", "chrono", "json"] }
|
||||
tokio-rusqlite = "0.5"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.0", features = ["macros", "rt"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
|
||||
8
backend/migrations/001_create_users_table.sql
Normal file
8
backend/migrations/001_create_users_table.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Create users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
server_url TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(username, server_url)
|
||||
);
|
||||
16
backend/migrations/002_create_sessions_table.sql
Normal file
16
backend/migrations/002_create_sessions_table.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Create sessions table
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_accessed TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Index for faster token lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||
|
||||
-- Index for cleanup of expired sessions
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
11
backend/migrations/003_create_user_preferences_table.sql
Normal file
11
backend/migrations/003_create_user_preferences_table.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Create user preferences table
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
calendar_selected_date TEXT,
|
||||
calendar_time_increment INTEGER,
|
||||
calendar_view_mode TEXT,
|
||||
calendar_theme TEXT,
|
||||
calendar_colors TEXT, -- JSON string for calendar color mappings
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -1,27 +1,30 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{CalDAVLoginRequest, AuthResponse, ApiError};
|
||||
use crate::config::CalDAVConfig;
|
||||
use crate::calendar::CalDAVClient;
|
||||
use crate::config::CalDAVConfig;
|
||||
use crate::db::{Database, PreferencesRepository, Session, SessionRepository, UserRepository};
|
||||
use crate::models::{ApiError, AuthResponse, CalDAVLoginRequest, UserPreferencesResponse};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub username: String,
|
||||
pub server_url: String,
|
||||
pub exp: i64, // Expiration time
|
||||
pub iat: i64, // Issued at
|
||||
pub exp: i64, // Expiration time
|
||||
pub iat: i64, // Issued at
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthService {
|
||||
jwt_secret: String,
|
||||
db: Database,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub fn new(jwt_secret: String) -> Self {
|
||||
Self { jwt_secret }
|
||||
pub fn new(jwt_secret: String, db: Database) -> Self {
|
||||
Self { jwt_secret, db }
|
||||
}
|
||||
|
||||
/// Authenticate user directly against CalDAV server
|
||||
@@ -31,13 +34,11 @@ impl AuthService {
|
||||
println!("✅ Input validation passed");
|
||||
|
||||
// Create CalDAV config with provided credentials
|
||||
let caldav_config = CalDAVConfig {
|
||||
server_url: request.server_url.clone(),
|
||||
username: request.username.clone(),
|
||||
password: request.password.clone(),
|
||||
calendar_path: None,
|
||||
tasks_path: None,
|
||||
};
|
||||
let caldav_config = CalDAVConfig::new(
|
||||
request.server_url.clone(),
|
||||
request.username.clone(),
|
||||
request.password.clone(),
|
||||
);
|
||||
println!("📝 Created CalDAV config");
|
||||
|
||||
// Test authentication against CalDAV server
|
||||
@@ -47,20 +48,59 @@ impl AuthService {
|
||||
// Try to discover calendars as an authentication test
|
||||
match caldav_client.discover_calendars().await {
|
||||
Ok(calendars) => {
|
||||
println!("✅ Authentication successful! Found {} calendars", calendars.len());
|
||||
// Authentication successful, generate JWT token
|
||||
let token = self.generate_token(&request.username, &request.server_url)?;
|
||||
println!(
|
||||
"✅ Authentication successful! Found {} calendars",
|
||||
calendars.len()
|
||||
);
|
||||
|
||||
// Find or create user in database
|
||||
let user_repo = UserRepository::new(&self.db);
|
||||
let user = user_repo
|
||||
.find_or_create(&request.username, &request.server_url)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to create user: {}", e)))?;
|
||||
|
||||
// Generate JWT token
|
||||
let jwt_token = self.generate_token(&request.username, &request.server_url)?;
|
||||
|
||||
// Generate session token
|
||||
let session_token = format!("sess_{}", Uuid::new_v4());
|
||||
|
||||
// Create session in database
|
||||
let session = Session::new(user.id.clone(), session_token.clone(), 24);
|
||||
let session_repo = SessionRepository::new(&self.db);
|
||||
session_repo
|
||||
.create(&session)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to create session: {}", e)))?;
|
||||
|
||||
// Get or create user preferences
|
||||
let prefs_repo = PreferencesRepository::new(&self.db);
|
||||
let preferences = prefs_repo
|
||||
.get_or_create(&user.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
|
||||
|
||||
Ok(AuthResponse {
|
||||
token,
|
||||
token: jwt_token,
|
||||
session_token,
|
||||
username: request.username,
|
||||
server_url: request.server_url,
|
||||
preferences: UserPreferencesResponse {
|
||||
calendar_selected_date: preferences.calendar_selected_date,
|
||||
calendar_time_increment: preferences.calendar_time_increment,
|
||||
calendar_view_mode: preferences.calendar_view_mode,
|
||||
calendar_theme: preferences.calendar_theme,
|
||||
calendar_colors: preferences.calendar_colors,
|
||||
},
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
println!("❌ Authentication failed: {:?}", err);
|
||||
// Authentication failed
|
||||
Err(ApiError::Unauthorized("Invalid CalDAV credentials or server unavailable".to_string()))
|
||||
Err(ApiError::Unauthorized(
|
||||
"Invalid CalDAV credentials or server unavailable".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,16 +111,18 @@ impl AuthService {
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
|
||||
Ok(CalDAVConfig {
|
||||
server_url: claims.server_url,
|
||||
username: claims.username,
|
||||
password: password.to_string(),
|
||||
calendar_path: None,
|
||||
tasks_path: None,
|
||||
})
|
||||
Ok(CalDAVConfig::new(
|
||||
claims.server_url,
|
||||
claims.username,
|
||||
password.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> {
|
||||
@@ -97,8 +139,11 @@ impl AuthService {
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
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()));
|
||||
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(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -135,4 +180,33 @@ impl AuthService {
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
/// Validate session token
|
||||
pub async fn validate_session(&self, session_token: &str) -> Result<String, ApiError> {
|
||||
let session_repo = SessionRepository::new(&self.db);
|
||||
|
||||
let session = session_repo
|
||||
.find_by_token(session_token)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to find session: {}", e)))?
|
||||
.ok_or_else(|| ApiError::Unauthorized("Invalid session token".to_string()))?;
|
||||
|
||||
if session.is_expired() {
|
||||
return Err(ApiError::Unauthorized("Session expired".to_string()));
|
||||
}
|
||||
|
||||
Ok(session.user_id)
|
||||
}
|
||||
|
||||
/// Logout user by deleting session
|
||||
pub async fn logout(&self, session_token: &str) -> Result<(), ApiError> {
|
||||
let session_repo = SessionRepository::new(&self.db);
|
||||
|
||||
session_repo
|
||||
.delete(session_token)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to delete session: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
use calendar_models::{CalendarUser, EventClass, EventStatus, VAlarm, VEvent};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use calendar_models::{VEvent, EventStatus, EventClass, CalendarUser, VAlarm};
|
||||
|
||||
// Global mutex to serialize CalDAV HTTP requests to prevent race conditions
|
||||
lazy_static::lazy_static! {
|
||||
@@ -128,7 +128,10 @@ impl CalDAVClient {
|
||||
///
|
||||
/// This method performs a REPORT request to get calendar data and parses
|
||||
/// the returned iCalendar format into CalendarEvent structs.
|
||||
pub async fn fetch_events(&self, calendar_path: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
||||
pub async fn fetch_events(
|
||||
&self,
|
||||
calendar_path: &str,
|
||||
) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
||||
// CalDAV REPORT request to get calendar events
|
||||
let report_body = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
@@ -149,7 +152,11 @@ impl CalDAVClient {
|
||||
// Extract the base URL (scheme + host + port) from server_url
|
||||
let server_url = &self.config.server_url;
|
||||
// Find the first '/' after "https://" or "http://"
|
||||
let scheme_end = if server_url.starts_with("https://") { 8 } else { 7 };
|
||||
let scheme_end = if server_url.starts_with("https://") {
|
||||
8
|
||||
} else {
|
||||
7
|
||||
};
|
||||
if let Some(path_start) = server_url[scheme_end..].find('/') {
|
||||
let base_url = &server_url[..scheme_end + path_start];
|
||||
format!("{}{}", base_url, calendar_path)
|
||||
@@ -163,7 +170,8 @@ impl CalDAVClient {
|
||||
println!("🔑 REPORT Basic Auth: Basic {}", basic_auth);
|
||||
println!("🌐 REPORT URL: {}", url);
|
||||
|
||||
let response = self.http_client
|
||||
let response = self
|
||||
.http_client
|
||||
.request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url)
|
||||
.header("Authorization", format!("Basic {}", basic_auth))
|
||||
.header("Content-Type", "application/xml")
|
||||
@@ -183,7 +191,11 @@ impl CalDAVClient {
|
||||
}
|
||||
|
||||
/// Parse CalDAV XML response containing calendar data
|
||||
fn parse_calendar_response(&self, xml_response: &str, calendar_path: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
||||
fn parse_calendar_response(
|
||||
&self,
|
||||
xml_response: &str,
|
||||
calendar_path: &str,
|
||||
) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
// Extract calendar data from XML response
|
||||
@@ -205,7 +217,11 @@ impl CalDAVClient {
|
||||
}
|
||||
|
||||
/// Fetch a single calendar event by UID from the CalDAV server
|
||||
pub async fn fetch_event_by_uid(&self, calendar_path: &str, uid: &str) -> Result<Option<CalendarEvent>, CalDAVError> {
|
||||
pub async fn fetch_event_by_uid(
|
||||
&self,
|
||||
calendar_path: &str,
|
||||
uid: &str,
|
||||
) -> Result<Option<CalendarEvent>, CalDAVError> {
|
||||
// First fetch all events and find the one with matching UID
|
||||
let events = self.fetch_events(calendar_path).await?;
|
||||
|
||||
@@ -225,10 +241,16 @@ impl CalDAVClient {
|
||||
if let Some(end_pos) = response_block.find("</d:response>") {
|
||||
let response_content = &response_block[..end_pos];
|
||||
|
||||
let href = self.extract_xml_content(response_content, "href").unwrap_or_default();
|
||||
let etag = self.extract_xml_content(response_content, "getetag").unwrap_or_default();
|
||||
let href = self
|
||||
.extract_xml_content(response_content, "href")
|
||||
.unwrap_or_default();
|
||||
let etag = self
|
||||
.extract_xml_content(response_content, "getetag")
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(calendar_data) = self.extract_xml_content(response_content, "cal:calendar-data") {
|
||||
if let Some(calendar_data) =
|
||||
self.extract_xml_content(response_content, "cal:calendar-data")
|
||||
{
|
||||
sections.push(CalendarDataSection {
|
||||
href: if href.is_empty() { None } else { Some(href) },
|
||||
etag: if etag.is_empty() { None } else { Some(etag) },
|
||||
@@ -245,12 +267,28 @@ impl CalDAVClient {
|
||||
fn extract_xml_content(&self, xml: &str, tag: &str) -> Option<String> {
|
||||
// Handle both with and without namespace prefixes
|
||||
let patterns = [
|
||||
format!("(?s)<{}>(.*?)</{}>", tag, tag), // <tag>content</tag>
|
||||
format!("(?s)<{}>(.*?)</.*:{}>", tag, tag.split(':').last().unwrap_or(tag)), // <tag>content</ns:tag>
|
||||
format!("(?s)<.*:{}>(.*?)</{}>", tag.split(':').last().unwrap_or(tag), tag), // <ns:tag>content</tag>
|
||||
format!("(?s)<.*:{}>(.*?)</.*:{}>", tag.split(':').last().unwrap_or(tag), tag.split(':').last().unwrap_or(tag)), // <ns:tag>content</ns:tag>
|
||||
format!("(?s)<{}[^>]*>(.*?)</{}>", tag, tag), // <tag attr>content</tag>
|
||||
format!("(?s)<{}[^>]*>(.*?)</.*:{}>", tag, tag.split(':').last().unwrap_or(tag)),
|
||||
format!("(?s)<{}>(.*?)</{}>", tag, tag), // <tag>content</tag>
|
||||
format!(
|
||||
"(?s)<{}>(.*?)</.*:{}>",
|
||||
tag,
|
||||
tag.split(':').last().unwrap_or(tag)
|
||||
), // <tag>content</ns:tag>
|
||||
format!(
|
||||
"(?s)<.*:{}>(.*?)</{}>",
|
||||
tag.split(':').last().unwrap_or(tag),
|
||||
tag
|
||||
), // <ns:tag>content</tag>
|
||||
format!(
|
||||
"(?s)<.*:{}>(.*?)</.*:{}>",
|
||||
tag.split(':').last().unwrap_or(tag),
|
||||
tag.split(':').last().unwrap_or(tag)
|
||||
), // <ns:tag>content</ns:tag>
|
||||
format!("(?s)<{}[^>]*>(.*?)</{}>", tag, tag), // <tag attr>content</tag>
|
||||
format!(
|
||||
"(?s)<{}[^>]*>(.*?)</.*:{}>",
|
||||
tag,
|
||||
tag.split(':').last().unwrap_or(tag)
|
||||
),
|
||||
];
|
||||
|
||||
for pattern in &patterns {
|
||||
@@ -287,21 +325,29 @@ impl CalDAVClient {
|
||||
}
|
||||
|
||||
/// Parse a single iCal event into a CalendarEvent struct
|
||||
fn parse_ical_event(&self, event: ical::parser::ical::component::IcalEvent) -> Result<CalendarEvent, CalDAVError> {
|
||||
fn parse_ical_event(
|
||||
&self,
|
||||
event: ical::parser::ical::component::IcalEvent,
|
||||
) -> Result<CalendarEvent, CalDAVError> {
|
||||
let mut properties: HashMap<String, String> = HashMap::new();
|
||||
|
||||
// Extract all properties from the event
|
||||
for property in &event.properties {
|
||||
properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default());
|
||||
properties.insert(
|
||||
property.name.to_uppercase(),
|
||||
property.value.clone().unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
// Required UID field
|
||||
let uid = properties.get("UID")
|
||||
let uid = properties
|
||||
.get("UID")
|
||||
.ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))?
|
||||
.clone();
|
||||
|
||||
// Parse start time (required)
|
||||
let start = properties.get("DTSTART")
|
||||
let start = properties
|
||||
.get("DTSTART")
|
||||
.ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?;
|
||||
let start = self.parse_datetime(start, properties.get("DTSTART"))?;
|
||||
|
||||
@@ -316,12 +362,14 @@ impl CalDAVClient {
|
||||
};
|
||||
|
||||
// Determine if it's an all-day event
|
||||
let all_day = properties.get("DTSTART")
|
||||
let all_day = properties
|
||||
.get("DTSTART")
|
||||
.map(|s| !s.contains("T"))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Parse status
|
||||
let status = properties.get("STATUS")
|
||||
let status = properties
|
||||
.get("STATUS")
|
||||
.map(|s| match s.to_uppercase().as_str() {
|
||||
"TENTATIVE" => EventStatus::Tentative,
|
||||
"CANCELLED" => EventStatus::Cancelled,
|
||||
@@ -330,7 +378,8 @@ impl CalDAVClient {
|
||||
.unwrap_or(EventStatus::Confirmed);
|
||||
|
||||
// Parse classification
|
||||
let class = properties.get("CLASS")
|
||||
let class = properties
|
||||
.get("CLASS")
|
||||
.map(|s| match s.to_uppercase().as_str() {
|
||||
"PRIVATE" => EventClass::Private,
|
||||
"CONFIDENTIAL" => EventClass::Confidential,
|
||||
@@ -339,20 +388,24 @@ impl CalDAVClient {
|
||||
.unwrap_or(EventClass::Public);
|
||||
|
||||
// Parse priority
|
||||
let priority = properties.get("PRIORITY")
|
||||
let priority = properties
|
||||
.get("PRIORITY")
|
||||
.and_then(|s| s.parse::<u8>().ok())
|
||||
.filter(|&p| p <= 9);
|
||||
|
||||
// Parse categories
|
||||
let categories = properties.get("CATEGORIES")
|
||||
let categories = properties
|
||||
.get("CATEGORIES")
|
||||
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Parse dates
|
||||
let created = properties.get("CREATED")
|
||||
let created = properties
|
||||
.get("CREATED")
|
||||
.and_then(|s| self.parse_datetime(s, None).ok());
|
||||
|
||||
let last_modified = properties.get("LAST-MODIFIED")
|
||||
let last_modified = properties
|
||||
.get("LAST-MODIFIED")
|
||||
.and_then(|s| self.parse_datetime(s, None).ok());
|
||||
|
||||
// Parse exception dates (EXDATE)
|
||||
@@ -403,7 +456,10 @@ impl CalDAVClient {
|
||||
}
|
||||
|
||||
/// Parse VALARM components from an iCal event
|
||||
fn parse_valarms(&self, event: &ical::parser::ical::component::IcalEvent) -> Result<Vec<VAlarm>, CalDAVError> {
|
||||
fn parse_valarms(
|
||||
&self,
|
||||
event: &ical::parser::ical::component::IcalEvent,
|
||||
) -> Result<Vec<VAlarm>, CalDAVError> {
|
||||
let mut alarms = Vec::new();
|
||||
|
||||
for alarm in &event.alarms {
|
||||
@@ -416,20 +472,30 @@ impl CalDAVClient {
|
||||
}
|
||||
|
||||
/// Parse a single VALARM component into a VAlarm
|
||||
fn parse_single_valarm(&self, alarm: &ical::parser::ical::component::IcalAlarm) -> Result<VAlarm, CalDAVError> {
|
||||
fn parse_single_valarm(
|
||||
&self,
|
||||
alarm: &ical::parser::ical::component::IcalAlarm,
|
||||
) -> Result<VAlarm, CalDAVError> {
|
||||
let mut properties: HashMap<String, String> = HashMap::new();
|
||||
|
||||
// Extract all properties from the alarm
|
||||
for property in &alarm.properties {
|
||||
properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default());
|
||||
properties.insert(
|
||||
property.name.to_uppercase(),
|
||||
property.value.clone().unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
// Parse ACTION (required)
|
||||
let action = match properties.get("ACTION").map(|s| s.to_uppercase()) {
|
||||
Some(ref action_str) if action_str == "DISPLAY" => calendar_models::AlarmAction::Display,
|
||||
Some(ref action_str) if action_str == "DISPLAY" => {
|
||||
calendar_models::AlarmAction::Display
|
||||
}
|
||||
Some(ref action_str) if action_str == "EMAIL" => calendar_models::AlarmAction::Email,
|
||||
Some(ref action_str) if action_str == "AUDIO" => calendar_models::AlarmAction::Audio,
|
||||
Some(ref action_str) if action_str == "PROCEDURE" => calendar_models::AlarmAction::Procedure,
|
||||
Some(ref action_str) if action_str == "PROCEDURE" => {
|
||||
calendar_models::AlarmAction::Procedure
|
||||
}
|
||||
_ => calendar_models::AlarmAction::Display, // Default
|
||||
};
|
||||
|
||||
@@ -468,15 +534,15 @@ impl CalDAVClient {
|
||||
|
||||
if trigger.starts_with("-PT") && trigger.ends_with("M") {
|
||||
// Parse "-PT15M" format (minutes)
|
||||
let minutes_str = &trigger[3..trigger.len()-1];
|
||||
let minutes_str = &trigger[3..trigger.len() - 1];
|
||||
minutes_str.parse::<i32>().ok()
|
||||
} else if trigger.starts_with("-PT") && trigger.ends_with("H") {
|
||||
// Parse "-PT1H" format (hours)
|
||||
let hours_str = &trigger[3..trigger.len()-1];
|
||||
let hours_str = &trigger[3..trigger.len() - 1];
|
||||
hours_str.parse::<i32>().ok().map(|h| h * 60)
|
||||
} else if trigger.starts_with("-P") && trigger.ends_with("D") {
|
||||
// Parse "-P1D" format (days)
|
||||
let days_str = &trigger[2..trigger.len()-1];
|
||||
let days_str = &trigger[2..trigger.len() - 1];
|
||||
days_str.parse::<i32>().ok().map(|d| d * 24 * 60)
|
||||
} else {
|
||||
// Try to parse as raw minutes
|
||||
@@ -498,10 +564,7 @@ impl CalDAVClient {
|
||||
// Note: paths should be relative to the server URL base
|
||||
let user_calendar_path = format!("/calendars/{}/", self.config.username);
|
||||
|
||||
let discovery_paths = vec![
|
||||
"/calendars/",
|
||||
user_calendar_path.as_str(),
|
||||
];
|
||||
let discovery_paths = vec!["/calendars/", user_calendar_path.as_str()];
|
||||
|
||||
let mut all_calendars = Vec::new();
|
||||
|
||||
@@ -533,9 +596,13 @@ impl CalDAVClient {
|
||||
|
||||
let url = format!("{}{}", self.config.server_url.trim_end_matches('/'), path);
|
||||
|
||||
let response = self.http_client
|
||||
let response = self
|
||||
.http_client
|
||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url)
|
||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Basic {}", self.config.get_basic_auth()),
|
||||
)
|
||||
.header("Content-Type", "application/xml")
|
||||
.header("Depth", "2") // Deeper search to find actual calendars
|
||||
.header("User-Agent", "calendar-app/0.1.0")
|
||||
@@ -545,7 +612,11 @@ impl CalDAVClient {
|
||||
.map_err(CalDAVError::RequestError)?;
|
||||
|
||||
if response.status().as_u16() != 207 {
|
||||
println!("❌ Discovery PROPFIND failed for {}: HTTP {}", path, response.status().as_u16());
|
||||
println!(
|
||||
"❌ Discovery PROPFIND failed for {}: HTTP {}",
|
||||
path,
|
||||
response.status().as_u16()
|
||||
);
|
||||
return Err(CalDAVError::ServerError(response.status().as_u16()));
|
||||
}
|
||||
|
||||
@@ -565,19 +636,26 @@ impl CalDAVClient {
|
||||
|
||||
// Check if this is a calendar collection by looking for supported-calendar-component-set
|
||||
// This indicates it's an actual calendar that can contain events
|
||||
let has_supported_components = response_content.contains("supported-calendar-component-set") &&
|
||||
(response_content.contains("VEVENT") || response_content.contains("VTODO"));
|
||||
let has_calendar_resourcetype = response_content.contains("<cal:calendar") || response_content.contains("<c:calendar");
|
||||
let has_supported_components = response_content
|
||||
.contains("supported-calendar-component-set")
|
||||
&& (response_content.contains("VEVENT")
|
||||
|| response_content.contains("VTODO"));
|
||||
let has_calendar_resourcetype = response_content.contains("<cal:calendar")
|
||||
|| response_content.contains("<c:calendar");
|
||||
|
||||
let is_calendar = has_supported_components || has_calendar_resourcetype;
|
||||
|
||||
// Also check resourcetype for collection
|
||||
let has_collection = response_content.contains("<d:collection") || response_content.contains("<collection");
|
||||
let has_collection = response_content.contains("<d:collection")
|
||||
|| response_content.contains("<collection");
|
||||
|
||||
if is_calendar && has_collection {
|
||||
// Exclude system directories like inbox, outbox, and root calendar directories
|
||||
if !href.contains("/inbox/") && !href.contains("/outbox/") &&
|
||||
!href.ends_with("/calendars/") && href.ends_with('/') {
|
||||
if !href.contains("/inbox/")
|
||||
&& !href.contains("/outbox/")
|
||||
&& !href.ends_with("/calendars/")
|
||||
&& href.ends_with('/')
|
||||
{
|
||||
println!("📅 Found calendar collection: {}", href);
|
||||
calendar_paths.push(href);
|
||||
} else {
|
||||
@@ -595,7 +673,11 @@ impl CalDAVClient {
|
||||
}
|
||||
|
||||
/// Parse iCal datetime format
|
||||
fn parse_datetime(&self, datetime_str: &str, _original_property: Option<&String>) -> Result<DateTime<Utc>, CalDAVError> {
|
||||
fn parse_datetime(
|
||||
&self,
|
||||
datetime_str: &str,
|
||||
_original_property: Option<&String>,
|
||||
) -> Result<DateTime<Utc>, CalDAVError> {
|
||||
use chrono::TimeZone;
|
||||
|
||||
// Handle different iCal datetime formats
|
||||
@@ -603,9 +685,9 @@ impl CalDAVClient {
|
||||
|
||||
// Try different parsing formats
|
||||
let formats = [
|
||||
"%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z
|
||||
"%Y%m%dT%H%M%S", // Local format: 20231225T120000
|
||||
"%Y%m%d", // Date only: 20231225
|
||||
"%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z
|
||||
"%Y%m%dT%H%M%S", // Local format: 20231225T120000
|
||||
"%Y%m%d", // Date only: 20231225
|
||||
];
|
||||
|
||||
for format in &formats {
|
||||
@@ -617,7 +699,10 @@ impl CalDAVClient {
|
||||
}
|
||||
}
|
||||
|
||||
Err(CalDAVError::ParseError(format!("Unable to parse datetime: {}", datetime_str)))
|
||||
Err(CalDAVError::ParseError(format!(
|
||||
"Unable to parse datetime: {}",
|
||||
datetime_str
|
||||
)))
|
||||
}
|
||||
|
||||
/// Parse EXDATE properties from an iCal event
|
||||
@@ -643,7 +728,12 @@ impl CalDAVClient {
|
||||
}
|
||||
|
||||
/// Create a new calendar on the CalDAV server using MKCALENDAR
|
||||
pub async fn create_calendar(&self, name: &str, description: Option<&str>, color: Option<&str>) -> Result<(), CalDAVError> {
|
||||
pub async fn create_calendar(
|
||||
&self,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
color: Option<&str>,
|
||||
) -> Result<(), CalDAVError> {
|
||||
// Sanitize calendar name for URL path
|
||||
let calendar_id = name
|
||||
.chars()
|
||||
@@ -652,17 +742,27 @@ impl CalDAVClient {
|
||||
.to_lowercase();
|
||||
|
||||
let calendar_path = format!("/calendars/{}/{}/", self.config.username, calendar_id);
|
||||
let full_url = format!("{}{}", self.config.server_url.trim_end_matches('/'), calendar_path);
|
||||
let full_url = format!(
|
||||
"{}{}",
|
||||
self.config.server_url.trim_end_matches('/'),
|
||||
calendar_path
|
||||
);
|
||||
|
||||
// Build color property if provided
|
||||
let color_property = if let Some(color) = color {
|
||||
format!(r#"<ic:calendar-color xmlns:ic="http://apple.com/ns/ical/">{}</ic:calendar-color>"#, color)
|
||||
format!(
|
||||
r#"<ic:calendar-color xmlns:ic="http://apple.com/ns/ical/">{}</ic:calendar-color>"#,
|
||||
color
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let description_property = if let Some(desc) = description {
|
||||
format!(r#"<c:calendar-description xmlns:c="urn:ietf:params:xml:ns:caldav">{}</c:calendar-description>"#, desc)
|
||||
format!(
|
||||
r#"<c:calendar-description xmlns:c="urn:ietf:params:xml:ns:caldav">{}</c:calendar-description>"#,
|
||||
desc
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
@@ -688,10 +788,17 @@ impl CalDAVClient {
|
||||
println!("Creating calendar at: {}", full_url);
|
||||
println!("MKCALENDAR body: {}", mkcalendar_body);
|
||||
|
||||
let response = self.http_client
|
||||
.request(reqwest::Method::from_bytes(b"MKCALENDAR").unwrap(), &full_url)
|
||||
let response = self
|
||||
.http_client
|
||||
.request(
|
||||
reqwest::Method::from_bytes(b"MKCALENDAR").unwrap(),
|
||||
&full_url,
|
||||
)
|
||||
.header("Content-Type", "application/xml; charset=utf-8")
|
||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Basic {}", self.config.get_basic_auth()),
|
||||
)
|
||||
.body(mkcalendar_body)
|
||||
.send()
|
||||
.await
|
||||
@@ -721,14 +828,22 @@ impl CalDAVClient {
|
||||
} else {
|
||||
calendar_path
|
||||
};
|
||||
format!("{}{}", self.config.server_url.trim_end_matches('/'), clean_path)
|
||||
format!(
|
||||
"{}{}",
|
||||
self.config.server_url.trim_end_matches('/'),
|
||||
clean_path
|
||||
)
|
||||
};
|
||||
|
||||
println!("Deleting calendar at: {}", full_url);
|
||||
|
||||
let response = self.http_client
|
||||
let response = self
|
||||
.http_client
|
||||
.delete(&full_url)
|
||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Basic {}", self.config.get_basic_auth()),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
|
||||
@@ -747,7 +862,11 @@ impl CalDAVClient {
|
||||
}
|
||||
|
||||
/// Create a new event in a CalDAV calendar
|
||||
pub async fn create_event(&self, calendar_path: &str, event: &CalendarEvent) -> Result<String, CalDAVError> {
|
||||
pub async fn create_event(
|
||||
&self,
|
||||
calendar_path: &str,
|
||||
event: &CalendarEvent,
|
||||
) -> Result<String, CalDAVError> {
|
||||
// Generate a unique filename for the event (using UID + .ics extension)
|
||||
let event_filename = format!("{}.ics", event.uid);
|
||||
|
||||
@@ -790,9 +909,13 @@ impl CalDAVClient {
|
||||
let _lock = CALDAV_HTTP_MUTEX.lock().await;
|
||||
println!("📡 Lock acquired, sending CREATE request to CalDAV server...");
|
||||
|
||||
let response = self.http_client
|
||||
let response = self
|
||||
.http_client
|
||||
.put(&full_url)
|
||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Basic {}", self.config.get_basic_auth()),
|
||||
)
|
||||
.header("Content-Type", "text/calendar; charset=utf-8")
|
||||
.header("User-Agent", "calendar-app/0.1.0")
|
||||
.body(ical_data)
|
||||
@@ -814,13 +937,22 @@ impl CalDAVClient {
|
||||
}
|
||||
|
||||
/// Update an existing event on the CalDAV server
|
||||
pub async fn update_event(&self, calendar_path: &str, event: &CalendarEvent, event_href: &str) -> Result<(), CalDAVError> {
|
||||
pub async fn update_event(
|
||||
&self,
|
||||
calendar_path: &str,
|
||||
event: &CalendarEvent,
|
||||
event_href: &str,
|
||||
) -> Result<(), CalDAVError> {
|
||||
// Construct the full URL for the event
|
||||
let full_url = if event_href.starts_with("http") {
|
||||
event_href.to_string()
|
||||
} else if event_href.starts_with("/dav.php") {
|
||||
// Event href is already a full path, combine with base server URL (without /dav.php)
|
||||
let base_url = self.config.server_url.trim_end_matches('/').trim_end_matches("/dav.php");
|
||||
let base_url = self
|
||||
.config
|
||||
.server_url
|
||||
.trim_end_matches('/')
|
||||
.trim_end_matches("/dav.php");
|
||||
format!("{}{}", base_url, event_href)
|
||||
} else {
|
||||
// Event href is just a filename, combine with calendar path
|
||||
@@ -829,7 +961,12 @@ impl CalDAVClient {
|
||||
} else {
|
||||
calendar_path
|
||||
};
|
||||
format!("{}/dav.php{}/{}", self.config.server_url.trim_end_matches('/'), clean_path, event_href)
|
||||
format!(
|
||||
"{}/dav.php{}/{}",
|
||||
self.config.server_url.trim_end_matches('/'),
|
||||
clean_path,
|
||||
event_href
|
||||
)
|
||||
};
|
||||
|
||||
println!("📝 Updating event at: {}", full_url);
|
||||
@@ -846,9 +983,13 @@ impl CalDAVClient {
|
||||
println!("🔗 PUT URL: {}", full_url);
|
||||
println!("🔍 Request headers: Authorization: Basic [HIDDEN], Content-Type: text/calendar; charset=utf-8");
|
||||
|
||||
let response = self.http_client
|
||||
let response = self
|
||||
.http_client
|
||||
.put(&full_url)
|
||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Basic {}", self.config.get_basic_auth()),
|
||||
)
|
||||
.header("Content-Type", "text/calendar; charset=utf-8")
|
||||
.header("User-Agent", "calendar-app/0.1.0")
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
@@ -862,7 +1003,10 @@ impl CalDAVClient {
|
||||
|
||||
println!("Event update response status: {}", response.status());
|
||||
|
||||
if response.status().is_success() || response.status().as_u16() == 201 || response.status().as_u16() == 204 {
|
||||
if response.status().is_success()
|
||||
|| response.status().as_u16() == 201
|
||||
|| response.status().as_u16() == 204
|
||||
{
|
||||
println!("✅ Event updated successfully");
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -878,13 +1022,10 @@ impl CalDAVClient {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
// Format datetime for iCal (YYYYMMDDTHHMMSSZ format)
|
||||
let format_datetime = |dt: &DateTime<Utc>| -> String {
|
||||
dt.format("%Y%m%dT%H%M%SZ").to_string()
|
||||
};
|
||||
let format_datetime =
|
||||
|dt: &DateTime<Utc>| -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() };
|
||||
|
||||
let format_date = |dt: &DateTime<Utc>| -> String {
|
||||
dt.format("%Y%m%d").to_string()
|
||||
};
|
||||
let format_date = |dt: &DateTime<Utc>| -> String { dt.format("%Y%m%d").to_string() };
|
||||
|
||||
// Start building the iCal event
|
||||
let mut ical = String::new();
|
||||
@@ -899,7 +1040,10 @@ impl CalDAVClient {
|
||||
|
||||
// Start and end times
|
||||
if event.all_day {
|
||||
ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", format_date(&event.dtstart)));
|
||||
ical.push_str(&format!(
|
||||
"DTSTART;VALUE=DATE:{}\r\n",
|
||||
format_date(&event.dtstart)
|
||||
));
|
||||
if let Some(end) = &event.dtend {
|
||||
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end)));
|
||||
}
|
||||
@@ -916,7 +1060,10 @@ impl CalDAVClient {
|
||||
}
|
||||
|
||||
if let Some(description) = &event.description {
|
||||
ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(description)));
|
||||
ical.push_str(&format!(
|
||||
"DESCRIPTION:{}\r\n",
|
||||
self.escape_ical_text(description)
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(location) = &event.location {
|
||||
@@ -951,7 +1098,10 @@ impl CalDAVClient {
|
||||
// Categories
|
||||
if !event.categories.is_empty() {
|
||||
let categories = event.categories.join(",");
|
||||
ical.push_str(&format!("CATEGORIES:{}\r\n", self.escape_ical_text(&categories)));
|
||||
ical.push_str(&format!(
|
||||
"CATEGORIES:{}\r\n",
|
||||
self.escape_ical_text(&categories)
|
||||
));
|
||||
}
|
||||
|
||||
// Creation and modification times
|
||||
@@ -989,9 +1139,15 @@ impl CalDAVClient {
|
||||
}
|
||||
|
||||
if let Some(description) = &alarm.description {
|
||||
ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(description)));
|
||||
ical.push_str(&format!(
|
||||
"DESCRIPTION:{}\r\n",
|
||||
self.escape_ical_text(description)
|
||||
));
|
||||
} else if let Some(summary) = &event.summary {
|
||||
ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(summary)));
|
||||
ical.push_str(&format!(
|
||||
"DESCRIPTION:{}\r\n",
|
||||
self.escape_ical_text(summary)
|
||||
));
|
||||
}
|
||||
|
||||
ical.push_str("END:VALARM\r\n");
|
||||
@@ -1005,7 +1161,10 @@ impl CalDAVClient {
|
||||
// Exception dates (EXDATE)
|
||||
for exception_date in &event.exdate {
|
||||
if event.all_day {
|
||||
ical.push_str(&format!("EXDATE;VALUE=DATE:{}\r\n", format_date(exception_date)));
|
||||
ical.push_str(&format!(
|
||||
"EXDATE;VALUE=DATE:{}\r\n",
|
||||
format_date(exception_date)
|
||||
));
|
||||
} else {
|
||||
ical.push_str(&format!("EXDATE:{}\r\n", format_datetime(exception_date)));
|
||||
}
|
||||
@@ -1027,13 +1186,21 @@ impl CalDAVClient {
|
||||
}
|
||||
|
||||
/// Delete an event from a CalDAV calendar
|
||||
pub async fn delete_event(&self, calendar_path: &str, event_href: &str) -> Result<(), CalDAVError> {
|
||||
pub async fn delete_event(
|
||||
&self,
|
||||
calendar_path: &str,
|
||||
event_href: &str,
|
||||
) -> Result<(), CalDAVError> {
|
||||
// Construct the full URL for the event
|
||||
let full_url = if event_href.starts_with("http") {
|
||||
event_href.to_string()
|
||||
} else if event_href.starts_with("/dav.php") {
|
||||
// Event href is already a full path, combine with base server URL (without /dav.php)
|
||||
let base_url = self.config.server_url.trim_end_matches('/').trim_end_matches("/dav.php");
|
||||
let base_url = self
|
||||
.config
|
||||
.server_url
|
||||
.trim_end_matches('/')
|
||||
.trim_end_matches("/dav.php");
|
||||
format!("{}{}", base_url, event_href)
|
||||
} else {
|
||||
// Event href is just a filename, combine with calendar path
|
||||
@@ -1042,7 +1209,12 @@ impl CalDAVClient {
|
||||
} else {
|
||||
calendar_path
|
||||
};
|
||||
format!("{}/dav.php{}/{}", self.config.server_url.trim_end_matches('/'), clean_path, event_href)
|
||||
format!(
|
||||
"{}/dav.php{}/{}",
|
||||
self.config.server_url.trim_end_matches('/'),
|
||||
clean_path,
|
||||
event_href
|
||||
)
|
||||
};
|
||||
|
||||
println!("Deleting event at: {}", full_url);
|
||||
@@ -1051,9 +1223,13 @@ impl CalDAVClient {
|
||||
let _lock = CALDAV_HTTP_MUTEX.lock().await;
|
||||
println!("📡 Lock acquired, sending DELETE request to CalDAV server...");
|
||||
|
||||
let response = self.http_client
|
||||
let response = self
|
||||
.http_client
|
||||
.delete(&full_url)
|
||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Basic {}", self.config.get_basic_auth()),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
|
||||
@@ -1103,8 +1279,11 @@ mod tests {
|
||||
/// This test requires a valid .env file and a calendar with some events
|
||||
#[tokio::test]
|
||||
async fn test_fetch_calendar_events() {
|
||||
let config = CalDAVConfig::from_env()
|
||||
.expect("Failed to load CalDAV config from environment");
|
||||
let config = CalDAVConfig::new(
|
||||
"https://example.com".to_string(),
|
||||
"test_user".to_string(),
|
||||
"test_password".to_string(),
|
||||
);
|
||||
|
||||
let client = CalDAVClient::new(config);
|
||||
|
||||
@@ -1147,7 +1326,10 @@ mod tests {
|
||||
for event in &events {
|
||||
assert!(!event.uid.is_empty(), "Event UID should not be empty");
|
||||
// All events should have a start time
|
||||
assert!(event.dtstart > DateTime::from_timestamp(0, 0).unwrap(), "Event should have valid start time");
|
||||
assert!(
|
||||
event.dtstart > DateTime::from_timestamp(0, 0).unwrap(),
|
||||
"Event should have valid start time"
|
||||
);
|
||||
}
|
||||
|
||||
println!("\n✓ Calendar event fetching test passed!");
|
||||
@@ -1192,11 +1374,11 @@ END:VCALENDAR"#;
|
||||
username: "test".to_string(),
|
||||
password: "test".to_string(),
|
||||
calendar_path: None,
|
||||
tasks_path: None,
|
||||
};
|
||||
|
||||
let client = CalDAVClient::new(config);
|
||||
let events = client.parse_ical_data(sample_ical)
|
||||
let events = client
|
||||
.parse_ical_data(sample_ical)
|
||||
.expect("Should be able to parse sample iCal data");
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
@@ -1223,23 +1405,25 @@ END:VCALENDAR"#;
|
||||
username: "test".to_string(),
|
||||
password: "test".to_string(),
|
||||
calendar_path: None,
|
||||
tasks_path: None,
|
||||
};
|
||||
|
||||
let client = CalDAVClient::new(config);
|
||||
|
||||
// Test UTC format
|
||||
let dt1 = client.parse_datetime("20231225T120000Z", None)
|
||||
let dt1 = client
|
||||
.parse_datetime("20231225T120000Z", None)
|
||||
.expect("Should parse UTC datetime");
|
||||
println!("Parsed UTC datetime: {}", dt1);
|
||||
|
||||
// Test date-only format (should be treated as all-day)
|
||||
let dt2 = client.parse_datetime("20231225", None)
|
||||
let dt2 = client
|
||||
.parse_datetime("20231225", None)
|
||||
.expect("Should parse date-only");
|
||||
println!("Parsed date-only: {}", dt2);
|
||||
|
||||
// Test local format
|
||||
let dt3 = client.parse_datetime("20231225T120000", None)
|
||||
let dt3 = client
|
||||
.parse_datetime("20231225T120000", None)
|
||||
.expect("Should parse local datetime");
|
||||
println!("Parsed local datetime: {}", dt3);
|
||||
|
||||
@@ -1259,5 +1443,4 @@ END:VCALENDAR"#;
|
||||
|
||||
println!("✓ Event enum tests passed!");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use base64::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use base64::prelude::*;
|
||||
|
||||
/// Configuration for CalDAV server connection and authentication.
|
||||
///
|
||||
@@ -17,14 +17,16 @@ use base64::prelude::*;
|
||||
///
|
||||
/// ```rust
|
||||
/// # use calendar_backend::config::CalDAVConfig;
|
||||
/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// // Load configuration from environment variables
|
||||
/// let config = CalDAVConfig::from_env()?;
|
||||
/// let config = CalDAVConfig {
|
||||
/// server_url: "https://caldav.example.com".to_string(),
|
||||
/// username: "user@example.com".to_string(),
|
||||
/// password: "password".to_string(),
|
||||
/// calendar_path: None,
|
||||
/// tasks_path: None,
|
||||
/// };
|
||||
///
|
||||
/// // Use the configuration for HTTP requests
|
||||
/// let auth_header = format!("Basic {}", config.get_basic_auth());
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CalDAVConfig {
|
||||
@@ -41,74 +43,37 @@ pub struct CalDAVConfig {
|
||||
|
||||
/// Optional path to the calendar collection on the server
|
||||
///
|
||||
/// If not provided, the client will need to discover available calendars
|
||||
/// If not provided, the client will discover available calendars
|
||||
/// through CalDAV PROPFIND requests
|
||||
pub calendar_path: Option<String>,
|
||||
|
||||
/// Optional path to the tasks/todo collection on the server
|
||||
///
|
||||
/// Some CalDAV servers store tasks separately from calendar events
|
||||
pub tasks_path: Option<String>,
|
||||
}
|
||||
|
||||
impl CalDAVConfig {
|
||||
/// Creates a new CalDAVConfig by loading values from environment variables.
|
||||
/// Creates a new CalDAVConfig with the given credentials.
|
||||
///
|
||||
/// This method will attempt to load a `.env` file from the current directory
|
||||
/// and then read the following required environment variables:
|
||||
/// # Arguments
|
||||
///
|
||||
/// - `CALDAV_SERVER_URL`: The CalDAV server base URL
|
||||
/// - `CALDAV_USERNAME`: Username for authentication
|
||||
/// - `CALDAV_PASSWORD`: Password for authentication
|
||||
///
|
||||
/// Optional environment variables:
|
||||
///
|
||||
/// - `CALDAV_CALENDAR_PATH`: Path to calendar collection
|
||||
/// - `CALDAV_TASKS_PATH`: Path to tasks collection
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ConfigError::MissingVar` if any required environment variable
|
||||
/// is not set or cannot be read.
|
||||
/// * `server_url` - The base URL of the CalDAV server
|
||||
/// * `username` - Username for authentication
|
||||
/// * `password` - Password for authentication
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// # use calendar_backend::config::CalDAVConfig;
|
||||
///
|
||||
/// match CalDAVConfig::from_env() {
|
||||
/// Ok(config) => {
|
||||
/// println!("Loaded config for server: {}", config.server_url);
|
||||
/// }
|
||||
/// Err(e) => {
|
||||
/// eprintln!("Failed to load config: {}", e);
|
||||
/// }
|
||||
/// }
|
||||
/// let config = CalDAVConfig::new(
|
||||
/// "https://caldav.example.com".to_string(),
|
||||
/// "user@example.com".to_string(),
|
||||
/// "password".to_string()
|
||||
/// );
|
||||
/// ```
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
// Attempt to load .env file, but don't fail if it doesn't exist
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let server_url = env::var("CALDAV_SERVER_URL")
|
||||
.map_err(|_| ConfigError::MissingVar("CALDAV_SERVER_URL".to_string()))?;
|
||||
|
||||
let username = env::var("CALDAV_USERNAME")
|
||||
.map_err(|_| ConfigError::MissingVar("CALDAV_USERNAME".to_string()))?;
|
||||
|
||||
let password = env::var("CALDAV_PASSWORD")
|
||||
.map_err(|_| ConfigError::MissingVar("CALDAV_PASSWORD".to_string()))?;
|
||||
|
||||
// Optional paths - it's fine if these are not set
|
||||
let calendar_path = env::var("CALDAV_CALENDAR_PATH").ok();
|
||||
let tasks_path = env::var("CALDAV_TASKS_PATH").ok();
|
||||
|
||||
Ok(CalDAVConfig {
|
||||
pub fn new(server_url: String, username: String, password: String) -> Self {
|
||||
Self {
|
||||
server_url,
|
||||
username,
|
||||
password,
|
||||
calendar_path,
|
||||
tasks_path,
|
||||
})
|
||||
calendar_path: env::var("CALDAV_CALENDAR_PATH").ok(), // Optional override from env
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a Base64-encoded string for HTTP Basic Authentication.
|
||||
@@ -174,7 +139,6 @@ mod tests {
|
||||
username: "testuser".to_string(),
|
||||
password: "testpass".to_string(),
|
||||
calendar_path: None,
|
||||
tasks_path: None,
|
||||
};
|
||||
|
||||
let auth = config.get_basic_auth();
|
||||
@@ -192,9 +156,12 @@ mod tests {
|
||||
/// Run with: `cargo test test_baikal_auth`
|
||||
#[tokio::test]
|
||||
async fn test_baikal_auth() {
|
||||
// Load config from .env
|
||||
let config = CalDAVConfig::from_env()
|
||||
.expect("Failed to load CalDAV config from environment");
|
||||
// Use test config - update these values to test with real server
|
||||
let config = CalDAVConfig::new(
|
||||
"https://example.com".to_string(),
|
||||
"test_user".to_string(),
|
||||
"test_password".to_string(),
|
||||
);
|
||||
|
||||
println!("Testing authentication to: {}", config.server_url);
|
||||
|
||||
@@ -204,7 +171,10 @@ mod tests {
|
||||
// Make a simple OPTIONS request to test authentication
|
||||
let response = client
|
||||
.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")
|
||||
.send()
|
||||
.await
|
||||
@@ -222,9 +192,9 @@ mod tests {
|
||||
|
||||
// For Baikal/CalDAV servers, we should see DAV headers
|
||||
assert!(
|
||||
response.headers().contains_key("dav") ||
|
||||
response.headers().contains_key("DAV") ||
|
||||
response.status().is_success(),
|
||||
response.headers().contains_key("dav")
|
||||
|| response.headers().contains_key("DAV")
|
||||
|| response.status().is_success(),
|
||||
"Server doesn't appear to be a CalDAV server - missing DAV headers"
|
||||
);
|
||||
|
||||
@@ -238,8 +208,12 @@ mod tests {
|
||||
/// Run with: `cargo test test_propfind_calendars`
|
||||
#[tokio::test]
|
||||
async fn test_propfind_calendars() {
|
||||
let config = CalDAVConfig::from_env()
|
||||
.expect("Failed to load CalDAV config from environment");
|
||||
// Use test config - update these values to test with real server
|
||||
let config = CalDAVConfig::new(
|
||||
"https://example.com".to_string(),
|
||||
"test_user".to_string(),
|
||||
"test_password".to_string(),
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
@@ -255,8 +229,14 @@ mod tests {
|
||||
</d:propfind>"#;
|
||||
|
||||
let response = client
|
||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url)
|
||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
||||
.request(
|
||||
reqwest::Method::from_bytes(b"PROPFIND").unwrap(),
|
||||
&config.server_url,
|
||||
)
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Basic {}", config.get_basic_auth()),
|
||||
)
|
||||
.header("Content-Type", "application/xml")
|
||||
.header("Depth", "1")
|
||||
.header("User-Agent", "calendar-app/0.1.0")
|
||||
@@ -279,7 +259,10 @@ mod tests {
|
||||
);
|
||||
|
||||
// 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!");
|
||||
}
|
||||
|
||||
305
backend/src/db.rs
Normal file
305
backend/src/db.rs
Normal file
@@ -0,0 +1,305 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
|
||||
use sqlx::{FromRow, Result};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Database connection pool wrapper
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
pool: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Create a new database connection pool
|
||||
pub async fn new(database_url: &str) -> Result<Self> {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(database_url)
|
||||
.await?;
|
||||
|
||||
// Run migrations
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
Ok(Self {
|
||||
pool: Arc::new(pool),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a reference to the connection pool
|
||||
pub fn pool(&self) -> &SqlitePool {
|
||||
&self.pool
|
||||
}
|
||||
}
|
||||
|
||||
/// User model representing a CalDAV user
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct User {
|
||||
pub id: String, // UUID as string for SQLite
|
||||
pub username: String,
|
||||
pub server_url: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Create a new user with generated UUID
|
||||
pub fn new(username: String, server_url: String) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
username,
|
||||
server_url,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session model for user sessions
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Session {
|
||||
pub id: String, // UUID as string
|
||||
pub user_id: String, // Foreign key to User
|
||||
pub token: String, // Session token
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub last_accessed: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Create a new session for a user
|
||||
pub fn new(user_id: String, token: String, expires_in_hours: i64) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
user_id,
|
||||
token,
|
||||
created_at: now,
|
||||
expires_at: now + chrono::Duration::hours(expires_in_hours),
|
||||
last_accessed: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the session has expired
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Utc::now() > self.expires_at
|
||||
}
|
||||
}
|
||||
|
||||
/// User preferences model
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct UserPreferences {
|
||||
pub user_id: String,
|
||||
pub calendar_selected_date: Option<String>,
|
||||
pub calendar_time_increment: Option<i32>,
|
||||
pub calendar_view_mode: Option<String>,
|
||||
pub calendar_theme: Option<String>,
|
||||
pub calendar_colors: Option<String>, // JSON string
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl UserPreferences {
|
||||
/// Create default preferences for a new user
|
||||
pub fn default_for_user(user_id: String) -> Self {
|
||||
Self {
|
||||
user_id,
|
||||
calendar_selected_date: None,
|
||||
calendar_time_increment: Some(15),
|
||||
calendar_view_mode: Some("month".to_string()),
|
||||
calendar_theme: Some("light".to_string()),
|
||||
calendar_colors: None,
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Repository for User operations
|
||||
pub struct UserRepository<'a> {
|
||||
db: &'a Database,
|
||||
}
|
||||
|
||||
impl<'a> UserRepository<'a> {
|
||||
pub fn new(db: &'a Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// Find or create a user by username and server URL
|
||||
pub async fn find_or_create(
|
||||
&self,
|
||||
username: &str,
|
||||
server_url: &str,
|
||||
) -> Result<User> {
|
||||
// Try to find existing user
|
||||
let existing = sqlx::query_as::<_, User>(
|
||||
"SELECT * FROM users WHERE username = ? AND server_url = ?",
|
||||
)
|
||||
.bind(username)
|
||||
.bind(server_url)
|
||||
.fetch_optional(self.db.pool())
|
||||
.await?;
|
||||
|
||||
if let Some(user) = existing {
|
||||
Ok(user)
|
||||
} else {
|
||||
// Create new user
|
||||
let user = User::new(username.to_string(), server_url.to_string());
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, username, server_url, created_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&user.id)
|
||||
.bind(&user.username)
|
||||
.bind(&user.server_url)
|
||||
.bind(&user.created_at)
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a user by ID
|
||||
pub async fn find_by_id(&self, user_id: &str) -> Result<Option<User>> {
|
||||
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(self.db.pool())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Repository for Session operations
|
||||
pub struct SessionRepository<'a> {
|
||||
db: &'a Database,
|
||||
}
|
||||
|
||||
impl<'a> SessionRepository<'a> {
|
||||
pub fn new(db: &'a Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// Create a new session
|
||||
pub async fn create(&self, session: &Session) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO sessions (id, user_id, token, created_at, expires_at, last_accessed)
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&session.id)
|
||||
.bind(&session.user_id)
|
||||
.bind(&session.token)
|
||||
.bind(&session.created_at)
|
||||
.bind(&session.expires_at)
|
||||
.bind(&session.last_accessed)
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find a session by token and update last_accessed
|
||||
pub async fn find_by_token(&self, token: &str) -> Result<Option<Session>> {
|
||||
let session = sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token = ?")
|
||||
.bind(token)
|
||||
.fetch_optional(self.db.pool())
|
||||
.await?;
|
||||
|
||||
if let Some(ref s) = session {
|
||||
if !s.is_expired() {
|
||||
// Update last_accessed time
|
||||
sqlx::query("UPDATE sessions SET last_accessed = ? WHERE id = ?")
|
||||
.bind(Utc::now())
|
||||
.bind(&s.id)
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Delete a session (logout)
|
||||
pub async fn delete(&self, token: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM sessions WHERE token = ?")
|
||||
.bind(token)
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clean up expired sessions
|
||||
pub async fn cleanup_expired(&self) -> Result<u64> {
|
||||
let result = sqlx::query("DELETE FROM sessions WHERE expires_at < ?")
|
||||
.bind(Utc::now())
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
/// Repository for UserPreferences operations
|
||||
pub struct PreferencesRepository<'a> {
|
||||
db: &'a Database,
|
||||
}
|
||||
|
||||
impl<'a> PreferencesRepository<'a> {
|
||||
pub fn new(db: &'a Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// Get user preferences, creating defaults if not exist
|
||||
pub async fn get_or_create(&self, user_id: &str) -> Result<UserPreferences> {
|
||||
let existing = sqlx::query_as::<_, UserPreferences>(
|
||||
"SELECT * FROM user_preferences WHERE user_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(self.db.pool())
|
||||
.await?;
|
||||
|
||||
if let Some(prefs) = existing {
|
||||
Ok(prefs)
|
||||
} else {
|
||||
// Create default preferences
|
||||
let prefs = UserPreferences::default_for_user(user_id.to_string());
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO user_preferences
|
||||
(user_id, calendar_selected_date, calendar_time_increment,
|
||||
calendar_view_mode, calendar_theme, calendar_colors, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&prefs.user_id)
|
||||
.bind(&prefs.calendar_selected_date)
|
||||
.bind(&prefs.calendar_time_increment)
|
||||
.bind(&prefs.calendar_view_mode)
|
||||
.bind(&prefs.calendar_theme)
|
||||
.bind(&prefs.calendar_colors)
|
||||
.bind(&prefs.updated_at)
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(prefs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update user preferences
|
||||
pub async fn update(&self, prefs: &UserPreferences) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE user_preferences
|
||||
SET calendar_selected_date = ?, calendar_time_increment = ?,
|
||||
calendar_view_mode = ?, calendar_theme = ?,
|
||||
calendar_colors = ?, updated_at = ?
|
||||
WHERE user_id = ?",
|
||||
)
|
||||
.bind(&prefs.calendar_selected_date)
|
||||
.bind(&prefs.calendar_time_increment)
|
||||
.bind(&prefs.calendar_view_mode)
|
||||
.bind(&prefs.calendar_theme)
|
||||
.bind(&prefs.calendar_colors)
|
||||
.bind(Utc::now())
|
||||
.bind(&prefs.user_id)
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,12 @@ use crate::calendar::CalDAVClient;
|
||||
use crate::config::CalDAVConfig;
|
||||
|
||||
pub async fn debug_caldav_fetch() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = CalDAVConfig::from_env()?;
|
||||
// Use debug/test configuration
|
||||
let config = CalDAVConfig::new(
|
||||
"https://example.com".to_string(),
|
||||
"debug_user".to_string(),
|
||||
"debug_password".to_string()
|
||||
);
|
||||
let client = CalDAVClient::new(config);
|
||||
|
||||
println!("=== DEBUG: CalDAV Fetch ===");
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
mod auth;
|
||||
mod calendar;
|
||||
mod events;
|
||||
mod preferences;
|
||||
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 events::{get_calendar_events, refresh_event, create_event, update_event, delete_event};
|
||||
pub use series::{create_event_series, update_event_series, delete_event_series};
|
||||
pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event};
|
||||
pub use preferences::{get_preferences, logout, update_preferences};
|
||||
pub use series::{create_event_series, delete_event_series, update_event_series};
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::Json,
|
||||
};
|
||||
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo}};
|
||||
use crate::calendar::CalDAVClient;
|
||||
use crate::config::CalDAVConfig;
|
||||
use crate::{
|
||||
models::{ApiError, AuthResponse, CalDAVLoginRequest, CalendarInfo, UserInfo},
|
||||
AppState,
|
||||
};
|
||||
|
||||
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()))?;
|
||||
|
||||
let auth_str = auth_header.to_str()
|
||||
let auth_str = auth_header
|
||||
.to_str()
|
||||
.map_err(|_| ApiError::BadRequest("Invalid Authorization header".to_string()))?;
|
||||
|
||||
if let Some(token) = auth_str.strip_prefix("Bearer ") {
|
||||
Ok(token.to_string())
|
||||
} 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> {
|
||||
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()))?;
|
||||
|
||||
password_header.to_str()
|
||||
password_header
|
||||
.to_str()
|
||||
.map(|s| s.to_string())
|
||||
.map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string()))
|
||||
}
|
||||
@@ -41,38 +46,12 @@ pub async fn login(
|
||||
println!(" Username: {}", request.username);
|
||||
println!(" Password length: {}", request.password.len());
|
||||
|
||||
// Basic validation
|
||||
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()));
|
||||
}
|
||||
// Use the auth service login method which now handles database, sessions, and preferences
|
||||
let response = state.auth_service.login(request).await?;
|
||||
|
||||
println!("✅ Input validation passed");
|
||||
println!("✅ Login successful with session management");
|
||||
|
||||
// Create a token using the auth service
|
||||
println!("📝 Created CalDAV config");
|
||||
|
||||
// First verify the credentials are valid by attempting to discover calendars
|
||||
let config = CalDAVConfig {
|
||||
server_url: request.server_url.clone(),
|
||||
username: request.username.clone(),
|
||||
password: request.password.clone(),
|
||||
calendar_path: None,
|
||||
tasks_path: None,
|
||||
};
|
||||
let client = CalDAVClient::new(config);
|
||||
client.discover_calendars()
|
||||
.await
|
||||
.map_err(|e| ApiError::Unauthorized(format!("Authentication failed: {}", e)))?;
|
||||
|
||||
let token = state.auth_service.generate_token(&request.username, &request.server_url)?;
|
||||
|
||||
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
token,
|
||||
username: request.username,
|
||||
server_url: request.server_url,
|
||||
}))
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
pub async fn verify_token(
|
||||
@@ -93,23 +72,30 @@ pub async fn get_user_info(
|
||||
let password = extract_password_header(&headers)?;
|
||||
|
||||
// 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());
|
||||
|
||||
// Discover calendars
|
||||
let calendar_paths = client.discover_calendars()
|
||||
let calendar_paths = client
|
||||
.discover_calendars()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||
|
||||
println!("✅ Authentication successful! Found {} calendars", calendar_paths.len());
|
||||
println!(
|
||||
"✅ Authentication successful! Found {} calendars",
|
||||
calendar_paths.len()
|
||||
);
|
||||
|
||||
let calendars: Vec<CalendarInfo> = calendar_paths.iter().map(|path| {
|
||||
CalendarInfo {
|
||||
let calendars: Vec<CalendarInfo> = calendar_paths
|
||||
.iter()
|
||||
.map(|path| CalendarInfo {
|
||||
path: path.clone(),
|
||||
display_name: extract_calendar_name(path),
|
||||
color: generate_calendar_color(path),
|
||||
}
|
||||
}).collect();
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(UserInfo {
|
||||
username: config.username,
|
||||
@@ -128,10 +114,9 @@ fn generate_calendar_color(path: &str) -> String {
|
||||
|
||||
// Define a set of pleasant colors
|
||||
let colors = [
|
||||
"#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6",
|
||||
"#06B6D4", "#84CC16", "#F97316", "#EC4899", "#6366F1",
|
||||
"#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626",
|
||||
"#7C3AED", "#059669", "#D97706", "#BE185D", "#4F46E5"
|
||||
"#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#06B6D4", "#84CC16", "#F97316",
|
||||
"#EC4899", "#6366F1", "#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626", "#7C3AED",
|
||||
"#059669", "#D97706", "#BE185D", "#4F46E5",
|
||||
];
|
||||
|
||||
colors[(hash as usize) % colors.len()].to_string()
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::Json,
|
||||
};
|
||||
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{AppState, models::{ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse}};
|
||||
use crate::calendar::CalDAVClient;
|
||||
use crate::{
|
||||
models::{
|
||||
ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest,
|
||||
DeleteCalendarResponse,
|
||||
},
|
||||
AppState,
|
||||
};
|
||||
|
||||
use super::auth::{extract_bearer_token, extract_password_header};
|
||||
|
||||
@@ -20,22 +22,36 @@ pub async fn create_calendar(
|
||||
|
||||
// Validate request
|
||||
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
|
||||
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);
|
||||
|
||||
// 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 {
|
||||
success: true,
|
||||
message: "Calendar created successfully".to_string(),
|
||||
})),
|
||||
Err(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
|
||||
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
|
||||
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);
|
||||
|
||||
// Delete calendar on CalDAV server
|
||||
@@ -65,7 +85,10 @@ pub async fn delete_calendar(
|
||||
})),
|
||||
Err(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::{
|
||||
extract::{State, Query, Path},
|
||||
extract::{Path, Query, State},
|
||||
http::HeaderMap,
|
||||
response::Json,
|
||||
};
|
||||
use chrono::Datelike;
|
||||
use serde::Deserialize;
|
||||
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::{
|
||||
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};
|
||||
|
||||
@@ -30,11 +38,14 @@ pub async fn get_calendar_events(
|
||||
println!("🔑 API call with password length: {}", password.len());
|
||||
|
||||
// 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);
|
||||
|
||||
// Discover calendars if needed
|
||||
let calendar_paths = client.discover_calendars()
|
||||
let calendar_paths = client
|
||||
.discover_calendars()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||
|
||||
@@ -54,7 +65,10 @@ pub async fn get_calendar_events(
|
||||
all_events.extend(events);
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -82,11 +96,14 @@ pub async fn refresh_event(
|
||||
let password = extract_password_header(&headers)?;
|
||||
|
||||
// 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);
|
||||
|
||||
// Discover calendars
|
||||
let calendar_paths = client.discover_calendars()
|
||||
let calendar_paths = client
|
||||
.discover_calendars()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||
|
||||
@@ -101,13 +118,20 @@ pub async fn refresh_event(
|
||||
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
|
||||
// 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?;
|
||||
|
||||
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
|
||||
for event in &events {
|
||||
@@ -123,7 +147,10 @@ async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_h
|
||||
let filename = event_href.split('/').last().unwrap_or(event_href);
|
||||
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 {
|
||||
if event.uid == uid_from_href {
|
||||
@@ -146,23 +173,31 @@ pub async fn delete_event(
|
||||
let password = extract_password_header(&headers)?;
|
||||
|
||||
// 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);
|
||||
|
||||
// Handle different delete actions for recurring events
|
||||
match request.delete_action.as_str() {
|
||||
"delete_this" => {
|
||||
if let Some(event) = 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(event) =
|
||||
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
|
||||
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
|
||||
// Recurring event - add EXDATE for this occurrence
|
||||
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)
|
||||
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)
|
||||
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
||||
} else {
|
||||
@@ -172,12 +207,26 @@ pub async fn delete_event(
|
||||
let mut updated_event = event;
|
||||
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
|
||||
client.update_event(&request.calendar_path, &updated_event, &request.event_href)
|
||||
client
|
||||
.update_event(
|
||||
&request.calendar_path,
|
||||
&updated_event,
|
||||
&request.event_href,
|
||||
)
|
||||
.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");
|
||||
|
||||
@@ -192,9 +241,12 @@ pub async fn delete_event(
|
||||
// Non-recurring event - delete the entire event
|
||||
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
|
||||
.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");
|
||||
|
||||
@@ -206,51 +258,77 @@ pub async fn delete_event(
|
||||
} else {
|
||||
Err(ApiError::NotFound("Event not found".to_string()))
|
||||
}
|
||||
},
|
||||
}
|
||||
"delete_following" => {
|
||||
// For "this and following" deletion, we need to:
|
||||
// 1. Fetch the recurring event
|
||||
// 2. Modify the RRULE to end before this occurrence
|
||||
// 3. Update the event
|
||||
|
||||
if let Some(mut event) = 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(mut event) =
|
||||
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 {
|
||||
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)
|
||||
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)
|
||||
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
||||
} 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
|
||||
if let Some(rrule) = &event.rrule {
|
||||
// Remove existing UNTIL if present and add new one
|
||||
let parts: Vec<&str> = rrule.split(';').filter(|part| {
|
||||
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
||||
}).collect();
|
||||
let parts: Vec<&str> = rrule
|
||||
.split(';')
|
||||
.filter(|part| {
|
||||
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
||||
})
|
||||
.collect();
|
||||
|
||||
let new_rrule = format!("{};UNTIL={}", parts.join(";"), until_date.format("%Y%m%dT%H%M%SZ"));
|
||||
let new_rrule = format!(
|
||||
"{};UNTIL={}",
|
||||
parts.join(";"),
|
||||
until_date.format("%Y%m%dT%H%M%SZ")
|
||||
);
|
||||
event.rrule = Some(new_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
|
||||
.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 {
|
||||
success: true,
|
||||
message: "This and following occurrences deleted successfully".to_string(),
|
||||
message: "This and following occurrences deleted successfully"
|
||||
.to_string(),
|
||||
}))
|
||||
} else {
|
||||
// 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
|
||||
.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 {
|
||||
success: true,
|
||||
@@ -258,15 +336,18 @@ pub async fn delete_event(
|
||||
}))
|
||||
}
|
||||
} 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 {
|
||||
Err(ApiError::NotFound("Event not found".to_string()))
|
||||
}
|
||||
},
|
||||
}
|
||||
"delete_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
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
||||
|
||||
@@ -283,8 +364,10 @@ pub async fn create_event(
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<CreateEventRequest>,
|
||||
) -> Result<Json<CreateEventResponse>, ApiError> {
|
||||
println!("📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",
|
||||
request.title, request.all_day, request.calendar_path);
|
||||
println!(
|
||||
"📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",
|
||||
request.title, request.all_day, request.calendar_path
|
||||
);
|
||||
|
||||
// Extract and verify token
|
||||
let token = extract_bearer_token(&headers)?;
|
||||
@@ -296,11 +379,15 @@ pub async fn create_event(
|
||||
}
|
||||
|
||||
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
|
||||
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);
|
||||
|
||||
// Determine which calendar to use
|
||||
@@ -308,31 +395,41 @@ pub async fn create_event(
|
||||
path
|
||||
} else {
|
||||
// Use the first available calendar
|
||||
let calendar_paths = client.discover_calendars()
|
||||
let calendar_paths = client
|
||||
.discover_calendars()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||
|
||||
if calendar_paths.is_empty() {
|
||||
return Err(ApiError::BadRequest("No calendars available for event creation".to_string()));
|
||||
return Err(ApiError::BadRequest(
|
||||
"No calendars available for event creation".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
calendar_paths[0].clone()
|
||||
};
|
||||
|
||||
// Parse dates and times
|
||||
let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
||||
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
||||
let start_datetime =
|
||||
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)
|
||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||
|
||||
// Validate that end is after start
|
||||
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
|
||||
let uid = format!("{}-{}", uuid::Uuid::new_v4(), chrono::Utc::now().timestamp());
|
||||
let uid = format!(
|
||||
"{}-{}",
|
||||
uuid::Uuid::new_v4(),
|
||||
chrono::Utc::now().timestamp()
|
||||
);
|
||||
|
||||
// Parse status
|
||||
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() {
|
||||
Vec::new()
|
||||
} else {
|
||||
request.attendees
|
||||
request
|
||||
.attendees
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -363,7 +461,8 @@ pub async fn create_event(
|
||||
let categories: Vec<String> = if request.categories.trim().is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
request.categories
|
||||
request
|
||||
.categories
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -402,7 +501,8 @@ pub async fn create_event(
|
||||
|
||||
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
|
||||
if request.recurrence_days.len() == 7 {
|
||||
let selected_days: Vec<&str> = request.recurrence_days
|
||||
let selected_days: Vec<&str> = request
|
||||
.recurrence_days
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &selected)| {
|
||||
@@ -416,20 +516,20 @@ pub async fn create_event(
|
||||
5 => "FR", // Friday
|
||||
6 => "SA", // Saturday
|
||||
_ => return None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !selected_days.is_empty() {
|
||||
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
|
||||
if !selected_days.is_empty() {
|
||||
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(rrule)
|
||||
},
|
||||
}
|
||||
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
|
||||
"YEARLY" => Some("FREQ=YEARLY".to_string()),
|
||||
_ => None,
|
||||
@@ -439,9 +539,21 @@ pub async fn create_event(
|
||||
// Create the VEvent struct (RFC 5545 compliant)
|
||||
let mut event = VEvent::new(uid, start_datetime);
|
||||
event.dtend = Some(end_datetime);
|
||||
event.summary = if request.title.trim().is_empty() { None } 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.summary = if request.title.trim().is_empty() {
|
||||
None
|
||||
} 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.class = Some(class);
|
||||
event.priority = request.priority;
|
||||
@@ -456,41 +568,53 @@ pub async fn create_event(
|
||||
language: None,
|
||||
})
|
||||
};
|
||||
event.attendees = attendees.into_iter().map(|email| Attendee {
|
||||
cal_address: email,
|
||||
common_name: None,
|
||||
role: None,
|
||||
part_stat: None,
|
||||
rsvp: None,
|
||||
cu_type: None,
|
||||
member: Vec::new(),
|
||||
delegated_to: Vec::new(),
|
||||
delegated_from: Vec::new(),
|
||||
sent_by: None,
|
||||
dir_entry_ref: None,
|
||||
language: None,
|
||||
}).collect();
|
||||
event.attendees = attendees
|
||||
.into_iter()
|
||||
.map(|email| Attendee {
|
||||
cal_address: email,
|
||||
common_name: None,
|
||||
role: None,
|
||||
part_stat: None,
|
||||
rsvp: None,
|
||||
cu_type: None,
|
||||
member: Vec::new(),
|
||||
delegated_to: Vec::new(),
|
||||
delegated_from: Vec::new(),
|
||||
sent_by: None,
|
||||
dir_entry_ref: None,
|
||||
language: None,
|
||||
})
|
||||
.collect();
|
||||
event.categories = categories;
|
||||
event.rrule = rrule;
|
||||
event.all_day = request.all_day;
|
||||
event.alarms = alarms.into_iter().map(|reminder| VAlarm {
|
||||
action: AlarmAction::Display,
|
||||
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(-reminder.minutes_before as i64)),
|
||||
duration: None,
|
||||
repeat: None,
|
||||
description: reminder.description,
|
||||
summary: None,
|
||||
attendees: Vec::new(),
|
||||
attach: Vec::new(),
|
||||
}).collect();
|
||||
event.alarms = alarms
|
||||
.into_iter()
|
||||
.map(|reminder| VAlarm {
|
||||
action: AlarmAction::Display,
|
||||
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(
|
||||
-reminder.minutes_before as i64,
|
||||
)),
|
||||
duration: None,
|
||||
repeat: None,
|
||||
description: reminder.description,
|
||||
summary: None,
|
||||
attendees: Vec::new(),
|
||||
attach: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
event.calendar_path = Some(calendar_path.clone());
|
||||
|
||||
// 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
|
||||
.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 {
|
||||
success: true,
|
||||
@@ -520,18 +644,23 @@ pub async fn update_event(
|
||||
}
|
||||
|
||||
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
|
||||
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);
|
||||
|
||||
// Find the event across all calendars (or in the specified calendar)
|
||||
let calendar_paths = if let Some(path) = &request.calendar_path {
|
||||
vec![path.clone()]
|
||||
} else {
|
||||
client.discover_calendars()
|
||||
client
|
||||
.discover_calendars()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
||||
};
|
||||
@@ -544,7 +673,10 @@ pub async fn update_event(
|
||||
for event in events {
|
||||
if event.uid == request.uid {
|
||||
// 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);
|
||||
found_event = Some((event, calendar_path.clone(), event_href));
|
||||
break;
|
||||
@@ -553,9 +685,12 @@ pub async fn update_event(
|
||||
if found_event.is_some() {
|
||||
break;
|
||||
}
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e);
|
||||
eprintln!(
|
||||
"Failed to fetch events from calendar {}: {}",
|
||||
calendar_path, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -565,23 +700,38 @@ pub async fn update_event(
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
|
||||
|
||||
// Parse dates and times
|
||||
let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
||||
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
||||
let start_datetime =
|
||||
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)
|
||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||
|
||||
// Validate that end is after start
|
||||
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
|
||||
event.dtstart = start_datetime;
|
||||
event.dtend = Some(end_datetime);
|
||||
event.summary = if request.title.trim().is_empty() { None } 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.summary = if request.title.trim().is_empty() {
|
||||
None
|
||||
} 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;
|
||||
|
||||
// Parse and update status
|
||||
@@ -601,8 +751,12 @@ pub async fn update_event(
|
||||
event.priority = request.priority;
|
||||
|
||||
// Update the event on the CalDAV server
|
||||
println!("📝 Updating event {} at calendar_path: {}, event_href: {}", event.uid, calendar_path, event_href);
|
||||
client.update_event(&calendar_path, &event, &event_href)
|
||||
println!(
|
||||
"📝 Updating event {} at calendar_path: {}, event_href: {}",
|
||||
event.uid, calendar_path, event_href
|
||||
);
|
||||
client
|
||||
.update_event(&calendar_path, &event, &event_href)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?;
|
||||
|
||||
@@ -614,8 +768,12 @@ pub async fn update_event(
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result<chrono::DateTime<chrono::Utc>, String> {
|
||||
use chrono::{NaiveDate, NaiveTime, NaiveDateTime, Utc, TimeZone};
|
||||
fn parse_event_datetime(
|
||||
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
|
||||
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||
@@ -623,7 +781,8 @@ fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result
|
||||
|
||||
if all_day {
|
||||
// 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(Utc.from_utc_datetime(&datetime))
|
||||
} else {
|
||||
|
||||
123
backend/src/handlers/preferences.rs
Normal file
123
backend/src/handlers/preferences.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
db::PreferencesRepository,
|
||||
models::{ApiError, UpdatePreferencesRequest, UserPreferencesResponse},
|
||||
AppState,
|
||||
};
|
||||
|
||||
/// Get user preferences
|
||||
pub async fn get_preferences(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
// Extract session token from headers
|
||||
let session_token = headers
|
||||
.get("X-Session-Token")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
|
||||
|
||||
// Validate session and get user ID
|
||||
let user_id = state.auth_service.validate_session(session_token).await?;
|
||||
|
||||
// Get preferences from database
|
||||
let prefs_repo = PreferencesRepository::new(&state.db);
|
||||
let preferences = prefs_repo
|
||||
.get_or_create(&user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
|
||||
|
||||
Ok(Json(UserPreferencesResponse {
|
||||
calendar_selected_date: preferences.calendar_selected_date,
|
||||
calendar_time_increment: preferences.calendar_time_increment,
|
||||
calendar_view_mode: preferences.calendar_view_mode,
|
||||
calendar_theme: preferences.calendar_theme,
|
||||
calendar_colors: preferences.calendar_colors,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Update user preferences
|
||||
pub async fn update_preferences(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<UpdatePreferencesRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
// Extract session token from headers
|
||||
let session_token = headers
|
||||
.get("X-Session-Token")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
|
||||
|
||||
// Validate session and get user ID
|
||||
let user_id = state.auth_service.validate_session(session_token).await?;
|
||||
|
||||
// Update preferences in database
|
||||
let prefs_repo = PreferencesRepository::new(&state.db);
|
||||
|
||||
let mut preferences = prefs_repo
|
||||
.get_or_create(&user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
|
||||
|
||||
// Update only provided fields
|
||||
if request.calendar_selected_date.is_some() {
|
||||
preferences.calendar_selected_date = request.calendar_selected_date;
|
||||
}
|
||||
if request.calendar_time_increment.is_some() {
|
||||
preferences.calendar_time_increment = request.calendar_time_increment;
|
||||
}
|
||||
if request.calendar_view_mode.is_some() {
|
||||
preferences.calendar_view_mode = request.calendar_view_mode;
|
||||
}
|
||||
if request.calendar_theme.is_some() {
|
||||
preferences.calendar_theme = request.calendar_theme;
|
||||
}
|
||||
if request.calendar_colors.is_some() {
|
||||
preferences.calendar_colors = request.calendar_colors;
|
||||
}
|
||||
|
||||
prefs_repo
|
||||
.update(&preferences)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to update preferences: {}", e)))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(UserPreferencesResponse {
|
||||
calendar_selected_date: preferences.calendar_selected_date,
|
||||
calendar_time_increment: preferences.calendar_time_increment,
|
||||
calendar_view_mode: preferences.calendar_view_mode,
|
||||
calendar_theme: preferences.calendar_theme,
|
||||
calendar_colors: preferences.calendar_colors,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Logout user
|
||||
pub async fn logout(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
// Extract session token from headers
|
||||
let session_token = headers
|
||||
.get("X-Session-Token")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
|
||||
|
||||
// Delete session
|
||||
state.auth_service.logout(session_token).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"message": "Logged out successfully"
|
||||
})),
|
||||
))
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::Json,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||
use chrono::TimeZone;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{AppState, models::{ApiError, CreateEventSeriesRequest, CreateEventSeriesResponse, UpdateEventSeriesRequest, UpdateEventSeriesResponse, DeleteEventSeriesRequest, DeleteEventSeriesResponse}};
|
||||
use crate::calendar::CalDAVClient;
|
||||
use calendar_models::{VEvent, EventStatus, EventClass};
|
||||
use crate::{
|
||||
models::{
|
||||
ApiError, CreateEventSeriesRequest, CreateEventSeriesResponse, DeleteEventSeriesRequest,
|
||||
DeleteEventSeriesResponse, UpdateEventSeriesRequest, UpdateEventSeriesResponse,
|
||||
},
|
||||
AppState,
|
||||
};
|
||||
use calendar_models::{EventClass, EventStatus, VEvent};
|
||||
|
||||
use super::auth::{extract_bearer_token, extract_password_header};
|
||||
|
||||
@@ -18,8 +20,10 @@ pub async fn create_event_series(
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<CreateEventSeriesRequest>,
|
||||
) -> Result<Json<CreateEventSeriesResponse>, ApiError> {
|
||||
println!("📝 Create event series request received: title='{}', recurrence='{}', all_day={}",
|
||||
request.title, request.recurrence, request.all_day);
|
||||
println!(
|
||||
"📝 Create event series request received: title='{}', recurrence='{}', all_day={}",
|
||||
request.title, request.recurrence, request.all_day
|
||||
);
|
||||
|
||||
// Extract and verify token
|
||||
let token = extract_bearer_token(&headers)?;
|
||||
@@ -31,11 +35,15 @@ pub async fn create_event_series(
|
||||
}
|
||||
|
||||
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(),
|
||||
));
|
||||
}
|
||||
|
||||
if request.recurrence == "none" {
|
||||
return Err(ApiError::BadRequest("Use regular create endpoint for non-recurring events".to_string()));
|
||||
return Err(ApiError::BadRequest(
|
||||
"Use regular create endpoint for non-recurring events".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate recurrence type - handle both simple strings and RRULE strings
|
||||
@@ -50,7 +58,9 @@ pub async fn create_event_series(
|
||||
} else if request.recurrence.contains("FREQ=YEARLY") {
|
||||
"yearly"
|
||||
} else {
|
||||
return Err(ApiError::BadRequest("Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string()));
|
||||
return Err(ApiError::BadRequest(
|
||||
"Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// Handle simple strings
|
||||
@@ -60,12 +70,19 @@ pub async fn create_event_series(
|
||||
"weekly" => "weekly",
|
||||
"monthly" => "monthly",
|
||||
"yearly" => "yearly",
|
||||
_ => return Err(ApiError::BadRequest("Invalid recurrence type. Must be daily, weekly, monthly, or yearly".to_string())),
|
||||
_ => {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Invalid recurrence type. Must be daily, weekly, monthly, or yearly"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
// Determine which calendar to use
|
||||
@@ -73,12 +90,15 @@ pub async fn create_event_series(
|
||||
path.clone()
|
||||
} else {
|
||||
// Use the first available calendar
|
||||
let calendar_paths = client.discover_calendars()
|
||||
let calendar_paths = client
|
||||
.discover_calendars()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||
|
||||
if calendar_paths.is_empty() {
|
||||
return Err(ApiError::BadRequest("No calendars available for event creation".to_string()));
|
||||
return Err(ApiError::BadRequest(
|
||||
"No calendars available for event creation".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
calendar_paths[0].clone()
|
||||
@@ -87,37 +107,47 @@ pub async fn create_event_series(
|
||||
println!("📅 Using calendar path: {}", calendar_path);
|
||||
|
||||
// Parse datetime components
|
||||
let start_date = chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string()))?;
|
||||
let start_date =
|
||||
chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d").map_err(|_| {
|
||||
ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string())
|
||||
})?;
|
||||
|
||||
let (start_datetime, end_datetime) = if request.all_day {
|
||||
// For all-day events, use the dates as-is
|
||||
let start_dt = start_date.and_hms_opt(0, 0, 0)
|
||||
let start_dt = start_date
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
|
||||
|
||||
let end_date = if !request.end_date.is_empty() {
|
||||
chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string()))?
|
||||
chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d").map_err(|_| {
|
||||
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string())
|
||||
})?
|
||||
} else {
|
||||
start_date
|
||||
};
|
||||
|
||||
let end_dt = end_date.and_hms_opt(23, 59, 59)
|
||||
let end_dt = end_date
|
||||
.and_hms_opt(23, 59, 59)
|
||||
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
||||
|
||||
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
|
||||
(
|
||||
chrono::Utc.from_utc_datetime(&start_dt),
|
||||
chrono::Utc.from_utc_datetime(&end_dt),
|
||||
)
|
||||
} else {
|
||||
// Parse times for timed events
|
||||
let start_time = if !request.start_time.is_empty() {
|
||||
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string()))?
|
||||
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
|
||||
ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string())
|
||||
})?
|
||||
} else {
|
||||
chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap() // Default to 9 AM
|
||||
};
|
||||
|
||||
let end_time = if !request.end_time.is_empty() {
|
||||
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string()))?
|
||||
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M").map_err(|_| {
|
||||
ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string())
|
||||
})?
|
||||
} else {
|
||||
chrono::NaiveTime::from_hms_opt(10, 0, 0).unwrap() // Default to 1 hour duration
|
||||
};
|
||||
@@ -125,13 +155,18 @@ pub async fn create_event_series(
|
||||
let start_dt = start_date.and_time(start_time);
|
||||
let end_dt = if !request.end_date.is_empty() {
|
||||
let end_date = chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string()))?;
|
||||
.map_err(|_| {
|
||||
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string())
|
||||
})?;
|
||||
end_date.and_time(end_time)
|
||||
} else {
|
||||
start_date.and_time(end_time)
|
||||
};
|
||||
|
||||
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
|
||||
(
|
||||
chrono::Utc.from_utc_datetime(&start_dt),
|
||||
chrono::Utc.from_utc_datetime(&end_dt),
|
||||
)
|
||||
};
|
||||
|
||||
// Generate a unique UID for the series
|
||||
@@ -140,9 +175,21 @@ pub async fn create_event_series(
|
||||
// Create the VEvent for the series
|
||||
let mut event = VEvent::new(uid.clone(), start_datetime);
|
||||
event.dtend = Some(end_datetime);
|
||||
event.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) };
|
||||
event.description = if request.description.trim().is_empty() { None } else { Some(request.description.clone()) };
|
||||
event.location = if request.location.trim().is_empty() { None } else { Some(request.location.clone()) };
|
||||
event.summary = if request.title.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(request.title.clone())
|
||||
};
|
||||
event.description = if request.description.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(request.description.clone())
|
||||
};
|
||||
event.location = if request.location.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(request.location.clone())
|
||||
};
|
||||
|
||||
// Set event status
|
||||
event.status = Some(match request.status.to_lowercase().as_str() {
|
||||
@@ -171,13 +218,16 @@ pub async fn create_event_series(
|
||||
};
|
||||
event.rrule = Some(rrule);
|
||||
|
||||
|
||||
// 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
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to create event series: {}", e)))?;
|
||||
|
||||
println!("✅ Event series created successfully with UID: {}, href: {}", uid, event_href);
|
||||
println!(
|
||||
"✅ Event series created successfully with UID: {}, href: {}",
|
||||
uid, event_href
|
||||
);
|
||||
|
||||
Ok(Json(CreateEventSeriesResponse {
|
||||
success: true,
|
||||
@@ -194,8 +244,10 @@ pub async fn update_event_series(
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<UpdateEventSeriesRequest>,
|
||||
) -> Result<Json<UpdateEventSeriesResponse>, ApiError> {
|
||||
println!("🔄 Update event series request received: series_uid='{}', update_scope='{}'",
|
||||
request.series_uid, request.update_scope);
|
||||
println!(
|
||||
"🔄 Update event series request received: series_uid='{}', update_scope='{}'",
|
||||
request.series_uid, request.update_scope
|
||||
);
|
||||
|
||||
// Extract and verify token
|
||||
let token = extract_bearer_token(&headers)?;
|
||||
@@ -211,13 +263,20 @@ pub async fn update_event_series(
|
||||
}
|
||||
|
||||
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(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate update scope
|
||||
match request.update_scope.as_str() {
|
||||
"this_only" | "this_and_future" | "all_in_series" => {},
|
||||
_ => return Err(ApiError::BadRequest("Invalid update_scope. Must be: this_only, this_and_future, or all_in_series".to_string())),
|
||||
"this_only" | "this_and_future" | "all_in_series" => {}
|
||||
_ => {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Invalid update_scope. Must be: this_only, this_and_future, or all_in_series"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Validate recurrence type - handle both simple strings and RRULE strings
|
||||
@@ -232,7 +291,9 @@ pub async fn update_event_series(
|
||||
} else if request.recurrence.contains("FREQ=YEARLY") {
|
||||
"yearly"
|
||||
} else {
|
||||
return Err(ApiError::BadRequest("Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string()));
|
||||
return Err(ApiError::BadRequest(
|
||||
"Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// Handle simple strings
|
||||
@@ -242,12 +303,19 @@ pub async fn update_event_series(
|
||||
"weekly" => "weekly",
|
||||
"monthly" => "monthly",
|
||||
"yearly" => "yearly",
|
||||
_ => return Err(ApiError::BadRequest("Invalid recurrence type. Must be daily, weekly, monthly, or yearly".to_string())),
|
||||
_ => {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Invalid recurrence type. Must be daily, weekly, monthly, or yearly"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
// Use the parsed frequency for further processing (avoiding unused variable warning)
|
||||
@@ -257,13 +325,16 @@ pub async fn update_event_series(
|
||||
let calendar_paths = if let Some(ref path) = request.calendar_path {
|
||||
vec![path.clone()]
|
||||
} else {
|
||||
client.discover_calendars()
|
||||
client
|
||||
.discover_calendars()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
||||
};
|
||||
|
||||
if calendar_paths.is_empty() {
|
||||
return Err(ApiError::BadRequest("No calendars available for event update".to_string()));
|
||||
return Err(ApiError::BadRequest(
|
||||
"No calendars available for event update".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Find the series event across all specified calendars
|
||||
@@ -278,34 +349,46 @@ pub async fn update_event_series(
|
||||
}
|
||||
}
|
||||
|
||||
let mut existing_event = existing_event
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Event series with UID '{}' not found", request.series_uid)))?;
|
||||
let mut existing_event = existing_event.ok_or_else(|| {
|
||||
ApiError::NotFound(format!(
|
||||
"Event series with UID '{}' not found",
|
||||
request.series_uid
|
||||
))
|
||||
})?;
|
||||
|
||||
println!("📅 Found series event in calendar: {}", calendar_path);
|
||||
println!("📅 Event details: UID={}, summary={:?}, dtstart={}",
|
||||
existing_event.uid, existing_event.summary, existing_event.dtstart);
|
||||
println!(
|
||||
"📅 Event details: UID={}, summary={:?}, dtstart={}",
|
||||
existing_event.uid, existing_event.summary, existing_event.dtstart
|
||||
);
|
||||
|
||||
// Parse datetime components for the update
|
||||
let original_start_date = existing_event.dtstart.date_naive();
|
||||
|
||||
// For "this_and_future" and "this_only" updates, use the occurrence date for the modified event
|
||||
// For "all_in_series" updates, preserve the original series start date
|
||||
let start_date = if (request.update_scope == "this_and_future" || request.update_scope == "this_only") && request.occurrence_date.is_some() {
|
||||
let start_date = if (request.update_scope == "this_and_future"
|
||||
|| request.update_scope == "this_only")
|
||||
&& request.occurrence_date.is_some()
|
||||
{
|
||||
let occurrence_date_str = request.occurrence_date.as_ref().unwrap();
|
||||
chrono::NaiveDate::parse_from_str(occurrence_date_str, "%Y-%m-%d")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid occurrence_date format. Expected YYYY-MM-DD".to_string()))?
|
||||
chrono::NaiveDate::parse_from_str(occurrence_date_str, "%Y-%m-%d").map_err(|_| {
|
||||
ApiError::BadRequest("Invalid occurrence_date format. Expected YYYY-MM-DD".to_string())
|
||||
})?
|
||||
} else {
|
||||
original_start_date
|
||||
};
|
||||
|
||||
let (start_datetime, end_datetime) = if request.all_day {
|
||||
let start_dt = start_date.and_hms_opt(0, 0, 0)
|
||||
let start_dt = start_date
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
|
||||
|
||||
// For all-day events, also preserve the original date pattern
|
||||
let end_date = if !request.end_date.is_empty() {
|
||||
// Calculate the duration from the original event
|
||||
let original_duration_days = existing_event.dtend
|
||||
let original_duration_days = existing_event
|
||||
.dtend
|
||||
.map(|end| (end.date_naive() - existing_event.dtstart.date_naive()).num_days())
|
||||
.unwrap_or(0);
|
||||
start_date + chrono::Duration::days(original_duration_days)
|
||||
@@ -313,25 +396,32 @@ pub async fn update_event_series(
|
||||
start_date
|
||||
};
|
||||
|
||||
let end_dt = end_date.and_hms_opt(23, 59, 59)
|
||||
let end_dt = end_date
|
||||
.and_hms_opt(23, 59, 59)
|
||||
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
||||
|
||||
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
|
||||
(
|
||||
chrono::Utc.from_utc_datetime(&start_dt),
|
||||
chrono::Utc.from_utc_datetime(&end_dt),
|
||||
)
|
||||
} else {
|
||||
let start_time = if !request.start_time.is_empty() {
|
||||
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string()))?
|
||||
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
|
||||
ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string())
|
||||
})?
|
||||
} else {
|
||||
existing_event.dtstart.time()
|
||||
};
|
||||
|
||||
let end_time = if !request.end_time.is_empty() {
|
||||
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string()))?
|
||||
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M").map_err(|_| {
|
||||
ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string())
|
||||
})?
|
||||
} else {
|
||||
existing_event.dtend.map(|dt| dt.time()).unwrap_or_else(|| {
|
||||
existing_event.dtstart.time() + chrono::Duration::hours(1)
|
||||
})
|
||||
existing_event
|
||||
.dtend
|
||||
.map(|dt| dt.time())
|
||||
.unwrap_or_else(|| existing_event.dtstart.time() + chrono::Duration::hours(1))
|
||||
};
|
||||
|
||||
let start_dt = start_date.and_time(start_time);
|
||||
@@ -340,13 +430,17 @@ pub async fn update_event_series(
|
||||
start_date.and_time(end_time)
|
||||
} else {
|
||||
// Calculate end time based on original duration
|
||||
let original_duration = existing_event.dtend
|
||||
let original_duration = existing_event
|
||||
.dtend
|
||||
.map(|end| end - existing_event.dtstart)
|
||||
.unwrap_or_else(|| chrono::Duration::hours(1));
|
||||
(chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc()
|
||||
};
|
||||
|
||||
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
|
||||
(
|
||||
chrono::Utc.from_utc_datetime(&start_dt),
|
||||
chrono::Utc.from_utc_datetime(&end_dt),
|
||||
)
|
||||
};
|
||||
|
||||
// Handle different update scopes
|
||||
@@ -354,39 +448,73 @@ pub async fn update_event_series(
|
||||
"all_in_series" => {
|
||||
// Update the entire series - modify the master event
|
||||
update_entire_series(&mut existing_event, &request, start_datetime, end_datetime)?
|
||||
},
|
||||
}
|
||||
"this_and_future" => {
|
||||
// Split the series: keep past occurrences, create new series from occurrence date
|
||||
update_this_and_future(&mut existing_event, &request, start_datetime, end_datetime, &client, &calendar_path).await?
|
||||
},
|
||||
update_this_and_future(
|
||||
&mut existing_event,
|
||||
&request,
|
||||
start_datetime,
|
||||
end_datetime,
|
||||
&client,
|
||||
&calendar_path,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
"this_only" => {
|
||||
// Create exception for single occurrence, keep original series
|
||||
let event_href = existing_event.href.as_ref()
|
||||
.ok_or_else(|| ApiError::Internal("Event missing href for single occurrence update".to_string()))?
|
||||
let event_href = existing_event
|
||||
.href
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
ApiError::Internal(
|
||||
"Event missing href for single occurrence update".to_string(),
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
update_single_occurrence(&mut existing_event, &request, start_datetime, end_datetime, &client, &calendar_path, &event_href).await?
|
||||
},
|
||||
update_single_occurrence(
|
||||
&mut existing_event,
|
||||
&request,
|
||||
start_datetime,
|
||||
end_datetime,
|
||||
&client,
|
||||
&calendar_path,
|
||||
&event_href,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
_ => unreachable!(), // Already validated above
|
||||
};
|
||||
|
||||
// Update the event on the CalDAV server using the original event's href
|
||||
println!("📤 Updating event on CalDAV server...");
|
||||
let event_href = existing_event.href.as_ref()
|
||||
let event_href = existing_event
|
||||
.href
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?;
|
||||
println!("📤 Using event href: {}", event_href);
|
||||
println!("📤 Calendar path: {}", calendar_path);
|
||||
|
||||
match client.update_event(&calendar_path, &updated_event, event_href).await {
|
||||
match client
|
||||
.update_event(&calendar_path, &updated_event, event_href)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
println!("✅ CalDAV update completed successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ CalDAV update failed: {}", e);
|
||||
return Err(ApiError::Internal(format!("Failed to update event series: {}", e)));
|
||||
return Err(ApiError::Internal(format!(
|
||||
"Failed to update event series: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
println!("✅ Event series updated successfully with UID: {}", request.series_uid);
|
||||
println!(
|
||||
"✅ Event series updated successfully with UID: {}",
|
||||
request.series_uid
|
||||
);
|
||||
|
||||
Ok(Json(UpdateEventSeriesResponse {
|
||||
success: true,
|
||||
@@ -402,8 +530,10 @@ pub async fn delete_event_series(
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<DeleteEventSeriesRequest>,
|
||||
) -> Result<Json<DeleteEventSeriesResponse>, ApiError> {
|
||||
println!("🗑️ Delete event series request received: series_uid='{}', delete_scope='{}'",
|
||||
request.series_uid, request.delete_scope);
|
||||
println!(
|
||||
"🗑️ Delete event series request received: series_uid='{}', delete_scope='{}'",
|
||||
request.series_uid, request.delete_scope
|
||||
);
|
||||
|
||||
// Extract and verify token
|
||||
let token = extract_bearer_token(&headers)?;
|
||||
@@ -415,7 +545,9 @@ pub async fn delete_event_series(
|
||||
}
|
||||
|
||||
if request.calendar_path.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("Calendar path is required".to_string()));
|
||||
return Err(ApiError::BadRequest(
|
||||
"Calendar path is required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if request.event_href.trim().is_empty() {
|
||||
@@ -424,12 +556,19 @@ pub async fn delete_event_series(
|
||||
|
||||
// Validate delete scope
|
||||
match request.delete_scope.as_str() {
|
||||
"this_only" | "this_and_future" | "all_in_series" => {},
|
||||
_ => return Err(ApiError::BadRequest("Invalid delete_scope. Must be: this_only, this_and_future, or all_in_series".to_string())),
|
||||
"this_only" | "this_and_future" | "all_in_series" => {}
|
||||
_ => {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Invalid delete_scope. Must be: this_only, this_and_future, or all_in_series"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Handle different deletion scopes
|
||||
@@ -437,19 +576,22 @@ pub async fn delete_event_series(
|
||||
"all_in_series" => {
|
||||
// Delete the entire series - simply delete the event
|
||||
delete_entire_series(&client, &request).await?
|
||||
},
|
||||
}
|
||||
"this_and_future" => {
|
||||
// Modify RRULE to end before this occurrence
|
||||
delete_this_and_future(&client, &request).await?
|
||||
},
|
||||
}
|
||||
"this_only" => {
|
||||
// Add EXDATE for single occurrence
|
||||
delete_single_occurrence(&client, &request).await?
|
||||
},
|
||||
}
|
||||
_ => unreachable!(), // Already validated above
|
||||
};
|
||||
|
||||
println!("✅ Event series deletion completed with {} occurrences affected", occurrences_affected);
|
||||
println!(
|
||||
"✅ Event series deletion completed with {} occurrences affected",
|
||||
occurrences_affected
|
||||
);
|
||||
|
||||
Ok(Json(DeleteEventSeriesResponse {
|
||||
success: true,
|
||||
@@ -460,8 +602,10 @@ pub async fn delete_event_series(
|
||||
|
||||
// Helper functions
|
||||
|
||||
|
||||
fn build_series_rrule_with_freq(request: &CreateEventSeriesRequest, freq: &str) -> Result<String, ApiError> {
|
||||
fn build_series_rrule_with_freq(
|
||||
request: &CreateEventSeriesRequest,
|
||||
freq: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
let mut rrule_parts = Vec::new();
|
||||
|
||||
// Add frequency
|
||||
@@ -470,7 +614,11 @@ fn build_series_rrule_with_freq(request: &CreateEventSeriesRequest, freq: &str)
|
||||
"weekly" => rrule_parts.push("FREQ=WEEKLY".to_string()),
|
||||
"monthly" => rrule_parts.push("FREQ=MONTHLY".to_string()),
|
||||
"yearly" => rrule_parts.push("FREQ=YEARLY".to_string()),
|
||||
_ => return Err(ApiError::BadRequest("Invalid recurrence frequency".to_string())),
|
||||
_ => {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Invalid recurrence frequency".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Add interval if specified and greater than 1
|
||||
@@ -482,7 +630,8 @@ fn build_series_rrule_with_freq(request: &CreateEventSeriesRequest, freq: &str)
|
||||
|
||||
// Handle weekly recurrence with specific days (BYDAY)
|
||||
if freq == "weekly" && request.recurrence_days.len() == 7 {
|
||||
let selected_days: Vec<&str> = request.recurrence_days
|
||||
let selected_days: Vec<&str> = request
|
||||
.recurrence_days
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &selected)| {
|
||||
@@ -513,12 +662,17 @@ fn build_series_rrule_with_freq(request: &CreateEventSeriesRequest, freq: &str)
|
||||
// Parse the end date and convert to RRULE format (YYYYMMDDTHHMMSSZ)
|
||||
match chrono::NaiveDate::parse_from_str(end_date, "%Y-%m-%d") {
|
||||
Ok(date) => {
|
||||
let end_datetime = date.and_hms_opt(23, 59, 59)
|
||||
let end_datetime = date
|
||||
.and_hms_opt(23, 59, 59)
|
||||
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
||||
let utc_end = chrono::Utc.from_utc_datetime(&end_datetime);
|
||||
rrule_parts.push(format!("UNTIL={}", utc_end.format("%Y%m%dT%H%M%SZ")));
|
||||
},
|
||||
Err(_) => return Err(ApiError::BadRequest("Invalid recurrence_end_date format. Expected YYYY-MM-DD".to_string())),
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Invalid recurrence_end_date format. Expected YYYY-MM-DD".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
} else if let Some(count) = request.recurrence_count {
|
||||
if count > 0 {
|
||||
@@ -543,17 +697,17 @@ fn update_entire_series(
|
||||
updated_event.dtstart = start_datetime;
|
||||
updated_event.dtend = Some(end_datetime);
|
||||
updated_event.summary = if request.title.trim().is_empty() {
|
||||
existing_event.summary.clone() // Keep original if empty
|
||||
existing_event.summary.clone() // Keep original if empty
|
||||
} else {
|
||||
Some(request.title.clone())
|
||||
};
|
||||
updated_event.description = if request.description.trim().is_empty() {
|
||||
existing_event.description.clone() // Keep original if empty
|
||||
existing_event.description.clone() // Keep original if empty
|
||||
} else {
|
||||
Some(request.description.clone())
|
||||
};
|
||||
updated_event.location = if request.location.trim().is_empty() {
|
||||
existing_event.location.clone() // Keep original if empty
|
||||
existing_event.location.clone() // Keep original if empty
|
||||
} else {
|
||||
Some(request.location.clone())
|
||||
};
|
||||
@@ -641,30 +795,42 @@ async fn update_this_and_future(
|
||||
client: &CalDAVClient,
|
||||
calendar_path: &str,
|
||||
) -> Result<(VEvent, u32), ApiError> {
|
||||
|
||||
// Clone the existing event to create the new series before modifying the RRULE of the
|
||||
// original, because we'd like to preserve the original UNTIL logic
|
||||
let mut new_series = existing_event.clone();
|
||||
let occurrence_date = request.occurrence_date.as_ref()
|
||||
.ok_or_else(|| ApiError::BadRequest("occurrence_date is required for this_and_future updates".to_string()))?;
|
||||
let occurrence_date = request.occurrence_date.as_ref().ok_or_else(|| {
|
||||
ApiError::BadRequest("occurrence_date is required for this_and_future updates".to_string())
|
||||
})?;
|
||||
|
||||
// Parse occurrence date
|
||||
let occurrence_date_parsed = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid occurrence date format".to_string()))?;
|
||||
|
||||
// Step 1: Add UNTIL to the original series to stop before the occurrence date
|
||||
let until_datetime = occurrence_date_parsed.and_hms_opt(0, 0, 0)
|
||||
let until_datetime = occurrence_date_parsed
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| ApiError::BadRequest("Invalid occurrence date".to_string()))?;
|
||||
let utc_until = chrono::Utc.from_utc_datetime(&until_datetime);
|
||||
|
||||
// Create modified RRULE with UNTIL clause for the original series
|
||||
let original_rrule = existing_event.rrule.clone().unwrap_or_else(|| "FREQ=WEEKLY".to_string());
|
||||
let parts: Vec<&str> = original_rrule.split(';').filter(|part| {
|
||||
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
||||
}).collect();
|
||||
let original_rrule = existing_event
|
||||
.rrule
|
||||
.clone()
|
||||
.unwrap_or_else(|| "FREQ=WEEKLY".to_string());
|
||||
let parts: Vec<&str> = original_rrule
|
||||
.split(';')
|
||||
.filter(|part| !part.starts_with("UNTIL=") && !part.starts_with("COUNT="))
|
||||
.collect();
|
||||
|
||||
existing_event.rrule = Some(format!("{};UNTIL={}", parts.join(";"), utc_until.format("%Y%m%dT%H%M%SZ")));
|
||||
println!("🔄 this_and_future: Updated original series RRULE: {:?}", existing_event.rrule);
|
||||
existing_event.rrule = Some(format!(
|
||||
"{};UNTIL={}",
|
||||
parts.join(";"),
|
||||
utc_until.format("%Y%m%dT%H%M%SZ")
|
||||
));
|
||||
println!(
|
||||
"🔄 this_and_future: Updated original series RRULE: {:?}",
|
||||
existing_event.rrule
|
||||
);
|
||||
|
||||
// Step 2: Create a new series starting from the occurrence date with updated properties
|
||||
let new_series_uid = format!("series-{}", uuid::Uuid::new_v4());
|
||||
@@ -673,9 +839,21 @@ async fn update_this_and_future(
|
||||
new_series.uid = new_series_uid.clone();
|
||||
new_series.dtstart = start_datetime;
|
||||
new_series.dtend = Some(end_datetime);
|
||||
new_series.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) };
|
||||
new_series.description = if request.description.trim().is_empty() { None } else { Some(request.description.clone()) };
|
||||
new_series.location = if request.location.trim().is_empty() { None } else { Some(request.location.clone()) };
|
||||
new_series.summary = if request.title.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(request.title.clone())
|
||||
};
|
||||
new_series.description = if request.description.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(request.description.clone())
|
||||
};
|
||||
new_series.location = if request.location.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(request.location.clone())
|
||||
};
|
||||
|
||||
new_series.status = Some(match request.status.to_lowercase().as_str() {
|
||||
"tentative" => EventStatus::Tentative,
|
||||
@@ -698,11 +876,18 @@ async fn update_this_and_future(
|
||||
new_series.last_modified = Some(now);
|
||||
new_series.href = None; // Will be set when created
|
||||
|
||||
println!("🔄 this_and_future: Creating new series with UID: {}", new_series_uid);
|
||||
println!("🔄 this_and_future: New series RRULE: {:?}", new_series.rrule);
|
||||
println!(
|
||||
"🔄 this_and_future: Creating new series with UID: {}",
|
||||
new_series_uid
|
||||
);
|
||||
println!(
|
||||
"🔄 this_and_future: New series RRULE: {:?}",
|
||||
new_series.rrule
|
||||
);
|
||||
|
||||
// Create the new series on CalDAV server
|
||||
client.create_event(calendar_path, &new_series)
|
||||
client
|
||||
.create_event(calendar_path, &new_series)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to create new series: {}", e)))?;
|
||||
|
||||
@@ -727,12 +912,17 @@ async fn update_single_occurrence(
|
||||
// 2. Create a new exception event with RECURRENCE-ID pointing to the original occurrence
|
||||
|
||||
// First, add EXDATE to the original series
|
||||
let occurrence_date = request.occurrence_date.as_ref()
|
||||
.ok_or_else(|| ApiError::BadRequest("occurrence_date is required for single occurrence updates".to_string()))?;
|
||||
let occurrence_date = request.occurrence_date.as_ref().ok_or_else(|| {
|
||||
ApiError::BadRequest(
|
||||
"occurrence_date is required for single occurrence updates".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Parse the occurrence date
|
||||
let exception_date = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()))?;
|
||||
let exception_date =
|
||||
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d").map_err(|_| {
|
||||
ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string())
|
||||
})?;
|
||||
|
||||
// Create the EXDATE datetime using the original event's time
|
||||
let original_time = existing_event.dtstart.time();
|
||||
@@ -740,10 +930,19 @@ async fn update_single_occurrence(
|
||||
let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime);
|
||||
|
||||
// Add the exception date to the original series
|
||||
println!("📝 BEFORE adding EXDATE: existing_event.exdate = {:?}", existing_event.exdate);
|
||||
println!(
|
||||
"📝 BEFORE adding EXDATE: existing_event.exdate = {:?}",
|
||||
existing_event.exdate
|
||||
);
|
||||
existing_event.exdate.push(exception_utc);
|
||||
println!("📝 AFTER adding EXDATE: existing_event.exdate = {:?}", existing_event.exdate);
|
||||
println!("🚫 Added EXDATE for single occurrence modification: {}", exception_utc.format("%Y-%m-%d %H:%M:%S"));
|
||||
println!(
|
||||
"📝 AFTER adding EXDATE: existing_event.exdate = {:?}",
|
||||
existing_event.exdate
|
||||
);
|
||||
println!(
|
||||
"🚫 Added EXDATE for single occurrence modification: {}",
|
||||
exception_utc.format("%Y-%m-%d %H:%M:%S")
|
||||
);
|
||||
|
||||
// Create exception event by cloning the existing event to preserve all metadata
|
||||
let mut exception_event = existing_event.clone();
|
||||
@@ -755,17 +954,17 @@ async fn update_single_occurrence(
|
||||
exception_event.dtstart = start_datetime;
|
||||
exception_event.dtend = Some(end_datetime);
|
||||
exception_event.summary = if request.title.trim().is_empty() {
|
||||
existing_event.summary.clone() // Keep original if empty
|
||||
existing_event.summary.clone() // Keep original if empty
|
||||
} else {
|
||||
Some(request.title.clone())
|
||||
};
|
||||
exception_event.description = if request.description.trim().is_empty() {
|
||||
existing_event.description.clone() // Keep original if empty
|
||||
existing_event.description.clone() // Keep original if empty
|
||||
} else {
|
||||
Some(request.description.clone())
|
||||
};
|
||||
exception_event.location = if request.location.trim().is_empty() {
|
||||
existing_event.location.clone() // Keep original if empty
|
||||
existing_event.location.clone() // Keep original if empty
|
||||
} else {
|
||||
Some(request.location.clone())
|
||||
};
|
||||
@@ -801,10 +1000,14 @@ async fn update_single_occurrence(
|
||||
// Set calendar path for the exception event
|
||||
exception_event.calendar_path = Some(calendar_path.to_string());
|
||||
|
||||
println!("✨ Created exception event with RECURRENCE-ID: {}", exception_utc.format("%Y-%m-%d %H:%M:%S"));
|
||||
println!(
|
||||
"✨ Created exception event with RECURRENCE-ID: {}",
|
||||
exception_utc.format("%Y-%m-%d %H:%M:%S")
|
||||
);
|
||||
|
||||
// Create the exception event as a new event (original series will be updated by main handler)
|
||||
client.create_event(calendar_path, &exception_event)
|
||||
client
|
||||
.create_event(calendar_path, &exception_event)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to create exception event: {}", e)))?;
|
||||
|
||||
@@ -820,7 +1023,8 @@ async fn delete_entire_series(
|
||||
request: &DeleteEventSeriesRequest,
|
||||
) -> Result<u32, ApiError> {
|
||||
// Simply delete the entire event from the CalDAV server
|
||||
client.delete_event(&request.calendar_path, &request.event_href)
|
||||
client
|
||||
.delete_event(&request.calendar_path, &request.event_href)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event series: {}", e)))?;
|
||||
|
||||
@@ -835,10 +1039,13 @@ async fn delete_this_and_future(
|
||||
) -> Result<u32, ApiError> {
|
||||
// Fetch the existing event to modify its RRULE
|
||||
let event_uid = request.series_uid.clone();
|
||||
let existing_event = client.fetch_event_by_uid(&request.calendar_path, &event_uid)
|
||||
let existing_event = client
|
||||
.fetch_event_by_uid(&request.calendar_path, &event_uid)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch event for modification: {}", e)))?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid)))?;
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid))
|
||||
})?;
|
||||
|
||||
// If no occurrence_date is provided, delete the entire series
|
||||
let Some(occurrence_date) = &request.occurrence_date else {
|
||||
@@ -846,12 +1053,17 @@ async fn delete_this_and_future(
|
||||
};
|
||||
|
||||
// Parse occurrence date to set as UNTIL for the RRULE
|
||||
let until_date = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()))?;
|
||||
let until_date =
|
||||
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d").map_err(|_| {
|
||||
ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string())
|
||||
})?;
|
||||
|
||||
// Set UNTIL to the day before the occurrence to exclude it and all future occurrences
|
||||
let until_datetime = until_date.pred_opt()
|
||||
.ok_or_else(|| ApiError::BadRequest("Cannot delete from the first possible date".to_string()))?
|
||||
let until_datetime = until_date
|
||||
.pred_opt()
|
||||
.ok_or_else(|| {
|
||||
ApiError::BadRequest("Cannot delete from the first possible date".to_string())
|
||||
})?
|
||||
.and_hms_opt(23, 59, 59)
|
||||
.ok_or_else(|| ApiError::BadRequest("Invalid date calculation".to_string()))?;
|
||||
let utc_until = chrono::Utc.from_utc_datetime(&until_datetime);
|
||||
@@ -860,19 +1072,30 @@ async fn delete_this_and_future(
|
||||
let mut updated_event = existing_event;
|
||||
if let Some(rrule) = &updated_event.rrule {
|
||||
// Remove existing UNTIL or COUNT if present and add new UNTIL
|
||||
let parts: Vec<&str> = rrule.split(';').filter(|part| {
|
||||
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
||||
}).collect();
|
||||
let parts: Vec<&str> = rrule
|
||||
.split(';')
|
||||
.filter(|part| !part.starts_with("UNTIL=") && !part.starts_with("COUNT="))
|
||||
.collect();
|
||||
|
||||
updated_event.rrule = Some(format!("{};UNTIL={}", parts.join(";"), utc_until.format("%Y%m%dT%H%M%SZ")));
|
||||
updated_event.rrule = Some(format!(
|
||||
"{};UNTIL={}",
|
||||
parts.join(";"),
|
||||
utc_until.format("%Y%m%dT%H%M%SZ")
|
||||
));
|
||||
}
|
||||
|
||||
// Update the event on the CalDAV server
|
||||
client.update_event(&request.calendar_path, &updated_event, &request.event_href)
|
||||
client
|
||||
.update_event(&request.calendar_path, &updated_event, &request.event_href)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to update event series for deletion: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
ApiError::Internal(format!("Failed to update event series for deletion: {}", e))
|
||||
})?;
|
||||
|
||||
println!("🗑️ Series modified with UNTIL for this_and_future deletion: {}", utc_until.format("%Y-%m-%d"));
|
||||
println!(
|
||||
"🗑️ Series modified with UNTIL for this_and_future deletion: {}",
|
||||
utc_until.format("%Y-%m-%d")
|
||||
);
|
||||
Ok(1) // 1 series modified
|
||||
}
|
||||
|
||||
@@ -883,19 +1106,26 @@ async fn delete_single_occurrence(
|
||||
) -> Result<u32, ApiError> {
|
||||
// Fetch the existing event to add EXDATE
|
||||
let event_uid = request.series_uid.clone();
|
||||
let existing_event = client.fetch_event_by_uid(&request.calendar_path, &event_uid)
|
||||
let existing_event = client
|
||||
.fetch_event_by_uid(&request.calendar_path, &event_uid)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch event for modification: {}", e)))?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid)))?;
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!("Event series with UID '{}' not found", event_uid))
|
||||
})?;
|
||||
|
||||
// If no occurrence_date is provided, cannot delete single occurrence
|
||||
let Some(occurrence_date) = &request.occurrence_date else {
|
||||
return Err(ApiError::BadRequest("occurrence_date is required for single occurrence deletion".to_string()));
|
||||
return Err(ApiError::BadRequest(
|
||||
"occurrence_date is required for single occurrence deletion".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
// Parse occurrence date
|
||||
let exception_date = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||
.map_err(|_| ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string()))?;
|
||||
let exception_date =
|
||||
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d").map_err(|_| {
|
||||
ApiError::BadRequest("Invalid occurrence date format. Expected YYYY-MM-DD".to_string())
|
||||
})?;
|
||||
|
||||
// Create the EXDATE datetime (use the same time as the original event)
|
||||
let original_time = existing_event.dtstart.time();
|
||||
@@ -906,12 +1136,21 @@ async fn delete_single_occurrence(
|
||||
let mut updated_event = existing_event;
|
||||
updated_event.exdate.push(exception_utc);
|
||||
|
||||
println!("🗑️ Added EXDATE for single occurrence deletion: {}", exception_utc.format("%Y%m%dT%H%M%SZ"));
|
||||
println!(
|
||||
"🗑️ Added EXDATE for single occurrence deletion: {}",
|
||||
exception_utc.format("%Y%m%dT%H%M%SZ")
|
||||
);
|
||||
|
||||
// Update the event on the CalDAV server
|
||||
client.update_event(&request.calendar_path, &updated_event, &request.event_href)
|
||||
client
|
||||
.update_event(&request.calendar_path, &updated_event, &request.event_href)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to update event series for single deletion: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
ApiError::Internal(format!(
|
||||
"Failed to update event series for single deletion: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(1) // 1 occurrence excluded
|
||||
}
|
||||
|
||||
@@ -3,33 +3,43 @@ use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use tower_http::cors::{CorsLayer, Any};
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
pub mod auth;
|
||||
pub mod models;
|
||||
pub mod handlers;
|
||||
pub mod calendar;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod handlers;
|
||||
pub mod models;
|
||||
|
||||
use auth::AuthService;
|
||||
use db::Database;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub auth_service: AuthService,
|
||||
pub db: Database,
|
||||
}
|
||||
|
||||
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialize logging
|
||||
println!("🚀 Starting Calendar Backend Server");
|
||||
|
||||
// Initialize database
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "sqlite:calendar.db".to_string());
|
||||
|
||||
let db = Database::new(&database_url).await?;
|
||||
println!("✅ Database initialized");
|
||||
|
||||
// Create auth service
|
||||
let jwt_secret = std::env::var("JWT_SECRET")
|
||||
.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, db.clone());
|
||||
|
||||
let app_state = AppState { auth_service };
|
||||
let app_state = AppState { auth_service, db };
|
||||
|
||||
// Build our application with routes
|
||||
let app = Router::new()
|
||||
@@ -46,9 +56,22 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.route("/api/calendar/events/delete", post(handlers::delete_event))
|
||||
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
|
||||
// Event series-specific endpoints
|
||||
.route("/api/calendar/events/series/create", 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))
|
||||
.route(
|
||||
"/api/calendar/events/series/create",
|
||||
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),
|
||||
)
|
||||
// User preferences endpoints
|
||||
.route("/api/preferences", get(handlers::get_preferences))
|
||||
.route("/api/preferences", post(handlers::update_preferences))
|
||||
.route("/api/auth/logout", post(handlers::logout))
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
|
||||
@@ -16,8 +16,28 @@ pub struct CalDAVLoginRequest {
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthResponse {
|
||||
pub token: String,
|
||||
pub session_token: String,
|
||||
pub username: String,
|
||||
pub server_url: String,
|
||||
pub preferences: UserPreferencesResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UserPreferencesResponse {
|
||||
pub calendar_selected_date: Option<String>,
|
||||
pub calendar_time_increment: Option<i32>,
|
||||
pub calendar_view_mode: Option<String>,
|
||||
pub calendar_theme: Option<String>,
|
||||
pub calendar_colors: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdatePreferencesRequest {
|
||||
pub calendar_selected_date: Option<String>,
|
||||
pub calendar_time_increment: Option<i32>,
|
||||
pub calendar_view_mode: Option<String>,
|
||||
pub calendar_theme: Option<String>,
|
||||
pub calendar_colors: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -76,21 +96,21 @@ pub struct DeleteEventResponse {
|
||||
pub struct CreateEventRequest {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub start_date: String, // YYYY-MM-DD format
|
||||
pub start_time: String, // HH:MM format
|
||||
pub end_date: String, // YYYY-MM-DD format
|
||||
pub end_time: String, // HH:MM format
|
||||
pub start_date: String, // YYYY-MM-DD format
|
||||
pub start_time: String, // HH:MM format
|
||||
pub end_date: String, // YYYY-MM-DD format
|
||||
pub end_time: String, // HH:MM format
|
||||
pub location: String,
|
||||
pub all_day: bool,
|
||||
pub status: String, // confirmed, tentative, cancelled
|
||||
pub class: String, // public, private, confidential
|
||||
pub priority: Option<u8>, // 0-9 priority level
|
||||
pub organizer: String, // organizer email
|
||||
pub attendees: String, // comma-separated attendee emails
|
||||
pub categories: String, // comma-separated categories
|
||||
pub reminder: String, // reminder type
|
||||
pub recurrence: String, // recurrence type
|
||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||
pub status: String, // confirmed, tentative, cancelled
|
||||
pub class: String, // public, private, confidential
|
||||
pub priority: Option<u8>, // 0-9 priority level
|
||||
pub organizer: String, // organizer email
|
||||
pub attendees: String, // comma-separated attendee emails
|
||||
pub categories: String, // comma-separated categories
|
||||
pub reminder: String, // reminder type
|
||||
pub recurrence: String, // recurrence type
|
||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
||||
}
|
||||
|
||||
@@ -103,24 +123,24 @@ pub struct CreateEventResponse {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
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 description: String,
|
||||
pub start_date: String, // YYYY-MM-DD format
|
||||
pub start_time: String, // HH:MM format
|
||||
pub end_date: String, // YYYY-MM-DD format
|
||||
pub end_time: String, // HH:MM format
|
||||
pub start_date: String, // YYYY-MM-DD format
|
||||
pub start_time: String, // HH:MM format
|
||||
pub end_date: String, // YYYY-MM-DD format
|
||||
pub end_time: String, // HH:MM format
|
||||
pub location: String,
|
||||
pub all_day: bool,
|
||||
pub status: String, // confirmed, tentative, cancelled
|
||||
pub class: String, // public, private, confidential
|
||||
pub priority: Option<u8>, // 0-9 priority level
|
||||
pub organizer: String, // organizer email
|
||||
pub attendees: String, // comma-separated attendee emails
|
||||
pub categories: String, // comma-separated categories
|
||||
pub reminder: String, // reminder type
|
||||
pub recurrence: String, // recurrence type
|
||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||
pub status: String, // confirmed, tentative, cancelled
|
||||
pub class: String, // public, private, confidential
|
||||
pub priority: Option<u8>, // 0-9 priority level
|
||||
pub organizer: String, // organizer email
|
||||
pub attendees: String, // comma-separated attendee emails
|
||||
pub categories: String, // comma-separated categories
|
||||
pub reminder: String, // reminder type
|
||||
pub recurrence: String, // recurrence type
|
||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||
pub update_action: Option<String>, // "update_series" for recurring events
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -139,22 +159,22 @@ pub struct UpdateEventResponse {
|
||||
pub struct CreateEventSeriesRequest {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub start_date: String, // YYYY-MM-DD format
|
||||
pub start_time: String, // HH:MM format
|
||||
pub end_date: String, // YYYY-MM-DD format
|
||||
pub end_time: String, // HH:MM format
|
||||
pub start_date: String, // YYYY-MM-DD format
|
||||
pub start_time: String, // HH:MM format
|
||||
pub end_date: String, // YYYY-MM-DD format
|
||||
pub end_time: String, // HH:MM format
|
||||
pub location: String,
|
||||
pub all_day: bool,
|
||||
pub status: String, // confirmed, tentative, cancelled
|
||||
pub class: String, // public, private, confidential
|
||||
pub priority: Option<u8>, // 0-9 priority level
|
||||
pub organizer: String, // organizer email
|
||||
pub attendees: String, // comma-separated attendee emails
|
||||
pub categories: String, // comma-separated categories
|
||||
pub reminder: String, // reminder type
|
||||
pub status: String, // confirmed, tentative, cancelled
|
||||
pub class: String, // public, private, confidential
|
||||
pub priority: Option<u8>, // 0-9 priority level
|
||||
pub organizer: String, // organizer email
|
||||
pub attendees: String, // comma-separated attendee emails
|
||||
pub categories: String, // comma-separated categories
|
||||
pub reminder: String, // reminder type
|
||||
|
||||
// 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_interval: Option<u32>, // Every N days/weeks/months/years
|
||||
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||
@@ -173,25 +193,25 @@ pub struct CreateEventSeriesResponse {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
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 description: String,
|
||||
pub start_date: String, // YYYY-MM-DD format
|
||||
pub start_time: String, // HH:MM format
|
||||
pub end_date: String, // YYYY-MM-DD format
|
||||
pub end_time: String, // HH:MM format
|
||||
pub start_date: String, // YYYY-MM-DD format
|
||||
pub start_time: String, // HH:MM format
|
||||
pub end_date: String, // YYYY-MM-DD format
|
||||
pub end_time: String, // HH:MM format
|
||||
pub location: String,
|
||||
pub all_day: bool,
|
||||
pub status: String, // confirmed, tentative, cancelled
|
||||
pub class: String, // public, private, confidential
|
||||
pub priority: Option<u8>, // 0-9 priority level
|
||||
pub organizer: String, // organizer email
|
||||
pub attendees: String, // comma-separated attendee emails
|
||||
pub categories: String, // comma-separated categories
|
||||
pub reminder: String, // reminder type
|
||||
pub status: String, // confirmed, tentative, cancelled
|
||||
pub class: String, // public, private, confidential
|
||||
pub priority: Option<u8>, // 0-9 priority level
|
||||
pub organizer: String, // organizer email
|
||||
pub attendees: String, // comma-separated attendee emails
|
||||
pub categories: String, // comma-separated categories
|
||||
pub reminder: String, // reminder type
|
||||
|
||||
// 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_interval: Option<u32>, // Every N days/weeks/months/years
|
||||
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||
@@ -199,7 +219,7 @@ pub struct UpdateEventSeriesRequest {
|
||||
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||
|
||||
// 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 changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
|
||||
}
|
||||
@@ -214,12 +234,12 @@ pub struct UpdateEventSeriesResponse {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
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 event_href: String,
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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::{
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
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::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
/// Test utilities for integration testing
|
||||
mod test_utils {
|
||||
@@ -33,19 +33,55 @@ mod test_utils {
|
||||
.route("/", get(root))
|
||||
.route("/api/health", get(health_check))
|
||||
.route("/api/auth/login", post(calendar_backend::handlers::login))
|
||||
.route("/api/auth/verify", get(calendar_backend::handlers::verify_token))
|
||||
.route("/api/user/info", get(calendar_backend::handlers::get_user_info))
|
||||
.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))
|
||||
.route(
|
||||
"/api/auth/verify",
|
||||
get(calendar_backend::handlers::verify_token),
|
||||
)
|
||||
.route(
|
||||
"/api/user/info",
|
||||
get(calendar_backend::handlers::get_user_info),
|
||||
)
|
||||
.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
|
||||
.route("/api/calendar/events/series/create", 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))
|
||||
.route(
|
||||
"/api/calendar/events/series/create",
|
||||
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(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
@@ -72,22 +108,30 @@ mod test_utils {
|
||||
|
||||
pub async fn login(&self) -> String {
|
||||
let login_payload = json!({
|
||||
"username": std::env::var("CALDAV_USERNAME").unwrap_or("test".to_string()),
|
||||
"password": std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()),
|
||||
"server_url": std::env::var("CALDAV_SERVER_URL").unwrap_or("https://example.com".to_string())
|
||||
"username": "test".to_string(),
|
||||
"password": "test".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))
|
||||
.json(&login_payload)
|
||||
.send()
|
||||
.await
|
||||
.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();
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,15 +150,16 @@ mod test_utils {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::test_utils::*;
|
||||
use super::*;
|
||||
|
||||
/// Test the health endpoint
|
||||
#[tokio::test]
|
||||
async fn test_health_endpoint() {
|
||||
let server = TestServer::start().await;
|
||||
|
||||
let response = server.client
|
||||
let response = server
|
||||
.client
|
||||
.get(&format!("{}/api/health", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
@@ -134,12 +179,10 @@ mod tests {
|
||||
async fn test_auth_login() {
|
||||
let server = TestServer::start().await;
|
||||
|
||||
// Load credentials from .env
|
||||
dotenvy::dotenv().ok();
|
||||
let username = std::env::var("CALDAV_USERNAME").unwrap_or("test".to_string());
|
||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
||||
|
||||
let server_url = std::env::var("CALDAV_SERVER_URL").unwrap_or("https://example.com".to_string());
|
||||
// Use test credentials
|
||||
let username = "test".to_string();
|
||||
let password = "test".to_string();
|
||||
let server_url = "https://example.com".to_string();
|
||||
|
||||
let login_payload = json!({
|
||||
"username": username,
|
||||
@@ -147,18 +190,29 @@ mod tests {
|
||||
"server_url": server_url
|
||||
});
|
||||
|
||||
let response = server.client
|
||||
let response = server
|
||||
.client
|
||||
.post(&format!("{}/api/auth/login", server.base_url))
|
||||
.json(&login_payload)
|
||||
.send()
|
||||
.await
|
||||
.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();
|
||||
assert!(login_response["token"].is_string(), "Login response should contain a token");
|
||||
assert!(login_response["username"].is_string(), "Login response should contain username");
|
||||
assert!(
|
||||
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");
|
||||
}
|
||||
@@ -171,7 +225,8 @@ mod tests {
|
||||
// First login to get a token
|
||||
let token = server.login().await;
|
||||
|
||||
let response = server.client
|
||||
let response = server
|
||||
.client
|
||||
.get(&format!("{}/api/auth/verify", server.base_url))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
@@ -196,9 +251,10 @@ mod tests {
|
||||
|
||||
// Load password from env for CalDAV requests
|
||||
dotenvy::dotenv().ok();
|
||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
||||
let password = "test".to_string();
|
||||
|
||||
let response = server.client
|
||||
let response = server
|
||||
.client
|
||||
.get(&format!("{}/api/user/info", server.base_url))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("X-CalDAV-Password", password)
|
||||
@@ -212,7 +268,10 @@ mod tests {
|
||||
assert!(user_info["username"].is_string());
|
||||
println!("✓ User info test passed");
|
||||
} else {
|
||||
println!("⚠ User info test skipped (CalDAV server issues): {}", response.status());
|
||||
println!(
|
||||
"⚠ User info test skipped (CalDAV server issues): {}",
|
||||
response.status()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,22 +285,33 @@ mod tests {
|
||||
|
||||
// Load password from env for CalDAV requests
|
||||
dotenvy::dotenv().ok();
|
||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
||||
let password = "test".to_string();
|
||||
|
||||
let response = server.client
|
||||
.get(&format!("{}/api/calendar/events?year=2024&month=12", server.base_url))
|
||||
let response = server
|
||||
.client
|
||||
.get(&format!(
|
||||
"{}/api/calendar/events?year=2024&month=12",
|
||||
server.base_url
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("X-CalDAV-Password", password)
|
||||
.send()
|
||||
.await
|
||||
.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();
|
||||
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
|
||||
@@ -254,7 +324,7 @@ mod tests {
|
||||
|
||||
// Load password from env for CalDAV requests
|
||||
dotenvy::dotenv().ok();
|
||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
||||
let password = "test".to_string();
|
||||
|
||||
let create_payload = json!({
|
||||
"title": "Integration Test Event",
|
||||
@@ -276,7 +346,8 @@ mod tests {
|
||||
"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))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("X-CalDAV-Password", password)
|
||||
@@ -308,13 +379,17 @@ mod tests {
|
||||
|
||||
// Load password from env for CalDAV requests
|
||||
dotenvy::dotenv().ok();
|
||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("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
|
||||
let test_uid = "test-event-uid";
|
||||
|
||||
let response = server.client
|
||||
.get(&format!("{}/api/calendar/events/{}", server.base_url, test_uid))
|
||||
let response = server
|
||||
.client
|
||||
.get(&format!(
|
||||
"{}/api/calendar/events/{}",
|
||||
server.base_url, test_uid
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("X-CalDAV-Password", password)
|
||||
.send()
|
||||
@@ -322,8 +397,11 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
// We expect either 200 (if event exists) or 404 (if not found) - both are valid responses
|
||||
assert!(response.status() == 200 || response.status() == 404,
|
||||
"Refresh event failed with unexpected status: {}", response.status());
|
||||
assert!(
|
||||
response.status() == 200 || response.status() == 404,
|
||||
"Refresh event failed with unexpected status: {}",
|
||||
response.status()
|
||||
);
|
||||
|
||||
println!("✓ Refresh event endpoint test passed");
|
||||
}
|
||||
@@ -333,7 +411,8 @@ mod tests {
|
||||
async fn test_invalid_auth() {
|
||||
let server = TestServer::start().await;
|
||||
|
||||
let response = server.client
|
||||
let response = server
|
||||
.client
|
||||
.get(&format!("{}/api/user/info", server.base_url))
|
||||
.header("Authorization", "Bearer invalid-token")
|
||||
.send()
|
||||
@@ -341,8 +420,11 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
// Accept both 400 and 401 as valid responses for invalid tokens
|
||||
assert!(response.status() == 401 || response.status() == 400,
|
||||
"Expected 401 or 400 for invalid token, got {}", response.status());
|
||||
assert!(
|
||||
response.status() == 401 || response.status() == 400,
|
||||
"Expected 401 or 400 for invalid token, got {}",
|
||||
response.status()
|
||||
);
|
||||
println!("✓ Invalid authentication test passed");
|
||||
}
|
||||
|
||||
@@ -351,7 +433,8 @@ mod tests {
|
||||
async fn test_missing_auth() {
|
||||
let server = TestServer::start().await;
|
||||
|
||||
let response = server.client
|
||||
let response = server
|
||||
.client
|
||||
.get(&format!("{}/api/user/info", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
@@ -373,7 +456,7 @@ mod tests {
|
||||
|
||||
// Load password from env for CalDAV requests
|
||||
dotenvy::dotenv().ok();
|
||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
||||
let password = "test".to_string();
|
||||
|
||||
let create_payload = json!({
|
||||
"title": "Integration Test Series",
|
||||
@@ -398,8 +481,12 @@ mod tests {
|
||||
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
||||
});
|
||||
|
||||
let response = server.client
|
||||
.post(&format!("{}/api/calendar/events/series/create", server.base_url))
|
||||
let response = server
|
||||
.client
|
||||
.post(&format!(
|
||||
"{}/api/calendar/events/series/create",
|
||||
server.base_url
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("X-CalDAV-Password", password)
|
||||
.json(&create_payload)
|
||||
@@ -431,7 +518,7 @@ mod tests {
|
||||
|
||||
// Load password from env for CalDAV requests
|
||||
dotenvy::dotenv().ok();
|
||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
||||
let password = "test".to_string();
|
||||
|
||||
let update_payload = json!({
|
||||
"series_uid": "test-series-uid",
|
||||
@@ -458,8 +545,12 @@ mod tests {
|
||||
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
||||
});
|
||||
|
||||
let response = server.client
|
||||
.post(&format!("{}/api/calendar/events/series/update", server.base_url))
|
||||
let response = server
|
||||
.client
|
||||
.post(&format!(
|
||||
"{}/api/calendar/events/series/update",
|
||||
server.base_url
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("X-CalDAV-Password", password)
|
||||
.json(&update_payload)
|
||||
@@ -474,10 +565,15 @@ mod tests {
|
||||
if status.is_success() {
|
||||
let update_response: serde_json::Value = response.json().await.unwrap();
|
||||
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");
|
||||
} 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 {
|
||||
println!("⚠ Update event series test skipped (CalDAV server not accessible)");
|
||||
}
|
||||
@@ -493,7 +589,7 @@ mod tests {
|
||||
|
||||
// Load password from env for CalDAV requests
|
||||
dotenvy::dotenv().ok();
|
||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
||||
let password = "test".to_string();
|
||||
|
||||
let delete_payload = json!({
|
||||
"series_uid": "test-series-to-delete",
|
||||
@@ -502,8 +598,12 @@ mod tests {
|
||||
"delete_scope": "all_in_series"
|
||||
});
|
||||
|
||||
let response = server.client
|
||||
.post(&format!("{}/api/calendar/events/series/delete", server.base_url))
|
||||
let response = server
|
||||
.client
|
||||
.post(&format!(
|
||||
"{}/api/calendar/events/series/delete",
|
||||
server.base_url
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("X-CalDAV-Password", password)
|
||||
.json(&delete_payload)
|
||||
@@ -520,7 +620,9 @@ mod tests {
|
||||
assert!(delete_response["success"].as_bool().unwrap_or(false));
|
||||
println!("✓ Delete event series test passed");
|
||||
} 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 {
|
||||
println!("⚠ Delete event series test skipped (CalDAV server not accessible)");
|
||||
}
|
||||
@@ -555,15 +657,23 @@ mod tests {
|
||||
"update_scope": "invalid_scope" // This should cause a 400 error
|
||||
});
|
||||
|
||||
let response = server.client
|
||||
.post(&format!("{}/api/calendar/events/series/update", server.base_url))
|
||||
let response = server
|
||||
.client
|
||||
.post(&format!(
|
||||
"{}/api/calendar/events/series/update",
|
||||
server.base_url
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&invalid_payload)
|
||||
.send()
|
||||
.await
|
||||
.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");
|
||||
}
|
||||
|
||||
@@ -594,15 +704,23 @@ mod tests {
|
||||
"recurrence_days": [false, false, false, false, false, false, false]
|
||||
});
|
||||
|
||||
let response = server.client
|
||||
.post(&format!("{}/api/calendar/events/series/create", server.base_url))
|
||||
let response = server
|
||||
.client
|
||||
.post(&format!(
|
||||
"{}/api/calendar/events/series/create",
|
||||
server.base_url
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&non_recurring_payload)
|
||||
.send()
|
||||
.await
|
||||
.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");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Common types and enums used across calendar components
|
||||
|
||||
use chrono::{DateTime, Utc, Duration};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ==================== ENUMS AND COMMON TYPES ====================
|
||||
@@ -64,11 +64,11 @@ pub enum AlarmAction {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CalendarUser {
|
||||
pub cal_address: String, // Calendar user address (usually email)
|
||||
pub common_name: Option<String>, // CN parameter - display name
|
||||
pub dir_entry_ref: Option<String>, // DIR parameter - directory entry
|
||||
pub sent_by: Option<String>, // SENT-BY parameter
|
||||
pub language: Option<String>, // LANGUAGE parameter
|
||||
pub cal_address: String, // Calendar user address (usually email)
|
||||
pub common_name: Option<String>, // CN parameter - display name
|
||||
pub dir_entry_ref: Option<String>, // DIR parameter - directory entry
|
||||
pub sent_by: Option<String>, // SENT-BY parameter
|
||||
pub language: Option<String>, // LANGUAGE parameter
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -78,130 +78,130 @@ pub struct Attendee {
|
||||
pub role: Option<AttendeeRole>, // ROLE parameter
|
||||
pub part_stat: Option<ParticipationStatus>, // PARTSTAT parameter
|
||||
pub rsvp: Option<bool>, // RSVP parameter
|
||||
pub cu_type: Option<String>, // CUTYPE parameter (INDIVIDUAL, GROUP, RESOURCE, ROOM, UNKNOWN)
|
||||
pub member: Vec<String>, // MEMBER parameter
|
||||
pub delegated_to: Vec<String>, // DELEGATED-TO parameter
|
||||
pub delegated_from: Vec<String>, // DELEGATED-FROM parameter
|
||||
pub sent_by: Option<String>, // SENT-BY parameter
|
||||
pub dir_entry_ref: Option<String>, // DIR parameter
|
||||
pub language: Option<String>, // LANGUAGE parameter
|
||||
pub cu_type: Option<String>, // CUTYPE parameter (INDIVIDUAL, GROUP, RESOURCE, ROOM, UNKNOWN)
|
||||
pub member: Vec<String>, // MEMBER parameter
|
||||
pub delegated_to: Vec<String>, // DELEGATED-TO parameter
|
||||
pub delegated_from: Vec<String>, // DELEGATED-FROM parameter
|
||||
pub sent_by: Option<String>, // SENT-BY parameter
|
||||
pub dir_entry_ref: Option<String>, // DIR parameter
|
||||
pub language: Option<String>, // LANGUAGE parameter
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VAlarm {
|
||||
pub action: AlarmAction, // Action (ACTION) - REQUIRED
|
||||
pub trigger: AlarmTrigger, // Trigger (TRIGGER) - REQUIRED
|
||||
pub duration: Option<Duration>, // Duration (DURATION)
|
||||
pub repeat: Option<u32>, // Repeat count (REPEAT)
|
||||
pub description: Option<String>, // Description for DISPLAY/EMAIL
|
||||
pub summary: Option<String>, // Summary for EMAIL
|
||||
pub attendees: Vec<Attendee>, // Attendees for EMAIL
|
||||
pub attach: Vec<Attachment>, // Attachments for AUDIO/EMAIL
|
||||
pub action: AlarmAction, // Action (ACTION) - REQUIRED
|
||||
pub trigger: AlarmTrigger, // Trigger (TRIGGER) - REQUIRED
|
||||
pub duration: Option<Duration>, // Duration (DURATION)
|
||||
pub repeat: Option<u32>, // Repeat count (REPEAT)
|
||||
pub description: Option<String>, // Description for DISPLAY/EMAIL
|
||||
pub summary: Option<String>, // Summary for EMAIL
|
||||
pub attendees: Vec<Attendee>, // Attendees for EMAIL
|
||||
pub attach: Vec<Attachment>, // Attachments for AUDIO/EMAIL
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AlarmTrigger {
|
||||
DateTime(DateTime<Utc>), // Absolute trigger time
|
||||
Duration(Duration), // Duration relative to start/end
|
||||
DateTime(DateTime<Utc>), // Absolute trigger time
|
||||
Duration(Duration), // Duration relative to start/end
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Attachment {
|
||||
pub format_type: Option<String>, // FMTTYPE parameter (MIME type)
|
||||
pub encoding: Option<String>, // ENCODING parameter
|
||||
pub value: Option<String>, // VALUE parameter (BINARY or URI)
|
||||
pub uri: Option<String>, // URI reference
|
||||
pub binary_data: Option<Vec<u8>>, // Binary data (when ENCODING=BASE64)
|
||||
pub format_type: Option<String>, // FMTTYPE parameter (MIME type)
|
||||
pub encoding: Option<String>, // ENCODING parameter
|
||||
pub value: Option<String>, // VALUE parameter (BINARY or URI)
|
||||
pub uri: Option<String>, // URI reference
|
||||
pub binary_data: Option<Vec<u8>>, // Binary data (when ENCODING=BASE64)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct GeographicPosition {
|
||||
pub latitude: f64, // Latitude in decimal degrees
|
||||
pub longitude: f64, // Longitude in decimal degrees
|
||||
pub latitude: f64, // Latitude in decimal degrees
|
||||
pub longitude: f64, // Longitude in decimal degrees
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VTimeZone {
|
||||
pub tzid: String, // Time zone ID (TZID) - REQUIRED
|
||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||
pub tzurl: Option<String>, // Time zone URL (TZURL)
|
||||
pub tzid: String, // Time zone ID (TZID) - REQUIRED
|
||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||
pub tzurl: Option<String>, // Time zone URL (TZURL)
|
||||
pub standard_components: Vec<TimeZoneComponent>, // STANDARD components
|
||||
pub daylight_components: Vec<TimeZoneComponent>, // DAYLIGHT components
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TimeZoneComponent {
|
||||
pub dtstart: DateTime<Utc>, // Start of this time zone definition
|
||||
pub tzoffset_to: String, // UTC offset for this component
|
||||
pub tzoffset_from: String, // UTC offset before this component
|
||||
pub rrule: Option<String>, // Recurrence rule
|
||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates
|
||||
pub tzname: Vec<String>, // Time zone names
|
||||
pub comment: Vec<String>, // Comments
|
||||
pub dtstart: DateTime<Utc>, // Start of this time zone definition
|
||||
pub tzoffset_to: String, // UTC offset for this component
|
||||
pub tzoffset_from: String, // UTC offset before this component
|
||||
pub rrule: Option<String>, // Recurrence rule
|
||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates
|
||||
pub tzname: Vec<String>, // Time zone names
|
||||
pub comment: Vec<String>, // Comments
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VJournal {
|
||||
// Required properties
|
||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||
|
||||
// Optional properties
|
||||
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||
pub description: Option<String>, // Description (DESCRIPTION)
|
||||
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||
pub description: Option<String>, // Description (DESCRIPTION)
|
||||
|
||||
// Classification and status
|
||||
pub class: Option<EventClass>, // Classification (CLASS)
|
||||
pub status: Option<String>, // Status (STATUS)
|
||||
pub class: Option<EventClass>, // Classification (CLASS)
|
||||
pub status: Option<String>, // Status (STATUS)
|
||||
|
||||
// People and organization
|
||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||
|
||||
// Categorization
|
||||
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||
|
||||
// Versioning and modification
|
||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||
|
||||
// Recurrence
|
||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||
|
||||
// Attachments
|
||||
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VFreeBusy {
|
||||
// Required properties
|
||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||
|
||||
// Optional date-time properties
|
||||
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
||||
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
||||
|
||||
// People
|
||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||
|
||||
// Free/busy time
|
||||
pub freebusy: Vec<FreeBusyTime>, // Free/busy time periods
|
||||
pub url: Option<String>, // URL (URL)
|
||||
pub comment: Vec<String>, // Comments (COMMENT)
|
||||
pub contact: Option<String>, // Contact information (CONTACT)
|
||||
pub freebusy: Vec<FreeBusyTime>, // Free/busy time periods
|
||||
pub url: Option<String>, // URL (URL)
|
||||
pub comment: Vec<String>, // Comments (COMMENT)
|
||||
pub contact: Option<String>, // Contact information (CONTACT)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct FreeBusyTime {
|
||||
pub fb_type: FreeBusyType, // Free/busy type
|
||||
pub periods: Vec<Period>, // Time periods
|
||||
pub fb_type: FreeBusyType, // Free/busy type
|
||||
pub periods: Vec<Period>, // Time periods
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -214,7 +214,7 @@ pub enum FreeBusyType {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Period {
|
||||
pub start: DateTime<Utc>, // Period start
|
||||
pub end: Option<DateTime<Utc>>, // Period end
|
||||
pub duration: Option<Duration>, // Period duration (alternative to end)
|
||||
pub start: DateTime<Utc>, // Period start
|
||||
pub end: Option<DateTime<Utc>>, // Period end
|
||||
pub duration: Option<Duration>, // Period duration (alternative to end)
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
//! This crate provides shared data structures for calendar applications
|
||||
//! that comply with RFC 5545 (iCalendar) specification.
|
||||
|
||||
pub mod vevent;
|
||||
pub mod common;
|
||||
pub mod vevent;
|
||||
|
||||
pub use vevent::*;
|
||||
pub use common::*;
|
||||
pub use vevent::*;
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
//! VEvent - RFC 5545 compliant calendar event structure
|
||||
|
||||
use chrono::{DateTime, Utc, Duration};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::common::*;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ==================== VEVENT COMPONENT ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VEvent {
|
||||
// Required properties
|
||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED
|
||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED
|
||||
|
||||
// Optional properties (commonly used)
|
||||
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
||||
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||
pub description: Option<String>, // Description (DESCRIPTION)
|
||||
pub location: Option<String>, // Location (LOCATION)
|
||||
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
||||
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||
pub description: Option<String>, // Description (DESCRIPTION)
|
||||
pub location: Option<String>, // Location (LOCATION)
|
||||
|
||||
// Classification and status
|
||||
pub class: Option<EventClass>, // Classification (CLASS)
|
||||
pub status: Option<EventStatus>, // Status (STATUS)
|
||||
pub transp: Option<TimeTransparency>, // Time transparency (TRANSP)
|
||||
pub priority: Option<u8>, // Priority 0-9 (PRIORITY)
|
||||
pub class: Option<EventClass>, // Classification (CLASS)
|
||||
pub status: Option<EventStatus>, // Status (STATUS)
|
||||
pub transp: Option<TimeTransparency>, // Time transparency (TRANSP)
|
||||
pub priority: Option<u8>, // Priority 0-9 (PRIORITY)
|
||||
|
||||
// People and organization
|
||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||
pub contact: Option<String>, // Contact information (CONTACT)
|
||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||
pub contact: Option<String>, // Contact information (CONTACT)
|
||||
|
||||
// Categorization and relationships
|
||||
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||
pub comment: Option<String>, // Comment (COMMENT)
|
||||
pub resources: Vec<String>, // Resources (RESOURCES)
|
||||
pub related_to: Option<String>, // Related component (RELATED-TO)
|
||||
pub url: Option<String>, // URL (URL)
|
||||
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||
pub comment: Option<String>, // Comment (COMMENT)
|
||||
pub resources: Vec<String>, // Resources (RESOURCES)
|
||||
pub related_to: Option<String>, // Related component (RELATED-TO)
|
||||
pub url: Option<String>, // URL (URL)
|
||||
|
||||
// Geographical
|
||||
pub geo: Option<GeographicPosition>, // Geographic position (GEO)
|
||||
pub geo: Option<GeographicPosition>, // Geographic position (GEO)
|
||||
|
||||
// Versioning and modification
|
||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||
|
||||
// Recurrence
|
||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||
|
||||
// Alarms and attachments
|
||||
pub alarms: Vec<VAlarm>, // VALARM components
|
||||
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||
pub alarms: Vec<VAlarm>, // VALARM components
|
||||
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||
|
||||
// CalDAV specific (for implementation)
|
||||
pub etag: Option<String>, // ETag for CalDAV
|
||||
pub href: Option<String>, // Href for CalDAV
|
||||
pub calendar_path: Option<String>, // Calendar path
|
||||
pub all_day: bool, // All-day event flag
|
||||
pub etag: Option<String>, // ETag for CalDAV
|
||||
pub href: Option<String>, // Href for CalDAV
|
||||
pub calendar_path: Option<String>, // Calendar path
|
||||
pub all_day: bool, // All-day event flag
|
||||
}
|
||||
|
||||
impl VEvent {
|
||||
@@ -129,7 +129,9 @@ impl VEvent {
|
||||
|
||||
/// Helper method to get display title (summary or "Untitled Event")
|
||||
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
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use crate::components::{
|
||||
CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction,
|
||||
EditAction, EventClass, EventContextMenu, EventCreationData, EventStatus, RecurrenceType,
|
||||
ReminderType, RouteHandler, Sidebar, Theme, ViewMode,
|
||||
};
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::{calendar_service::UserInfo, CalendarService};
|
||||
use chrono::NaiveDate;
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use web_sys::MouseEvent;
|
||||
use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction, EditAction};
|
||||
use crate::services::{CalendarService, calendar_service::UserInfo};
|
||||
use crate::models::ical::VEvent;
|
||||
use chrono::NaiveDate;
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
|
||||
fn get_theme_event_colors() -> Vec<String> {
|
||||
if let Some(window) = web_sys::window() {
|
||||
@@ -27,18 +31,28 @@ fn get_theme_event_colors() -> Vec<String> {
|
||||
}
|
||||
|
||||
vec![
|
||||
"#3B82F6".to_string(), "#10B981".to_string(), "#F59E0B".to_string(), "#EF4444".to_string(),
|
||||
"#8B5CF6".to_string(), "#06B6D4".to_string(), "#84CC16".to_string(), "#F97316".to_string(),
|
||||
"#EC4899".to_string(), "#6366F1".to_string(), "#14B8A6".to_string(), "#F3B806".to_string(),
|
||||
"#8B5A2B".to_string(), "#6B7280".to_string(), "#DC2626".to_string(), "#7C3AED".to_string()
|
||||
"#3B82F6".to_string(),
|
||||
"#10B981".to_string(),
|
||||
"#F59E0B".to_string(),
|
||||
"#EF4444".to_string(),
|
||||
"#8B5CF6".to_string(),
|
||||
"#06B6D4".to_string(),
|
||||
"#84CC16".to_string(),
|
||||
"#F97316".to_string(),
|
||||
"#EC4899".to_string(),
|
||||
"#6366F1".to_string(),
|
||||
"#14B8A6".to_string(),
|
||||
"#F3B806".to_string(),
|
||||
"#8B5A2B".to_string(),
|
||||
"#6B7280".to_string(),
|
||||
"#DC2626".to_string(),
|
||||
"#7C3AED".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn App() -> Html {
|
||||
let auth_token = use_state(|| -> Option<String> {
|
||||
LocalStorage::get("auth_token").ok()
|
||||
});
|
||||
let auth_token = use_state(|| -> Option<String> { LocalStorage::get("auth_token").ok() });
|
||||
|
||||
let user_info = use_state(|| -> Option<UserInfo> { None });
|
||||
let color_picker_open = use_state(|| -> Option<String> { None });
|
||||
@@ -164,8 +178,12 @@ pub fn App() -> Html {
|
||||
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) {
|
||||
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()
|
||||
@@ -177,8 +195,12 @@ pub fn App() -> Html {
|
||||
if !password.is_empty() {
|
||||
match calendar_service.fetch_user_info(&token, &password).await {
|
||||
Ok(mut info) => {
|
||||
if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") {
|
||||
if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) {
|
||||
if let Ok(saved_colors_json) =
|
||||
LocalStorage::get::<String>("calendar_colors")
|
||||
{
|
||||
if let Ok(saved_info) =
|
||||
serde_json::from_str::<UserInfo>(&saved_colors_json)
|
||||
{
|
||||
for saved_cal in &saved_info.calendars {
|
||||
for cal in &mut info.calendars {
|
||||
if cal.path == saved_cal.path {
|
||||
@@ -191,7 +213,9 @@ pub fn App() -> Html {
|
||||
user_info.set(Some(info));
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::log_1(&format!("Failed to fetch user info: {}", err).into());
|
||||
web_sys::console::log_1(
|
||||
&format!("Failed to fetch user info: {}", err).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,10 +235,10 @@ pub fn App() -> Html {
|
||||
let calendar_context_menu_open = calendar_context_menu_open.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
// Check if any context menu or color picker is open
|
||||
let any_menu_open = color_picker_open.is_some() ||
|
||||
*context_menu_open ||
|
||||
*event_context_menu_open ||
|
||||
*calendar_context_menu_open;
|
||||
let any_menu_open = color_picker_open.is_some()
|
||||
|| *context_menu_open
|
||||
|| *event_context_menu_open
|
||||
|| *calendar_context_menu_open;
|
||||
|
||||
if any_menu_open {
|
||||
// Prevent the default action and stop event propagation
|
||||
@@ -231,10 +255,10 @@ pub fn App() -> Html {
|
||||
};
|
||||
|
||||
// Compute if any context menu is open
|
||||
let any_context_menu_open = color_picker_open.is_some() ||
|
||||
*context_menu_open ||
|
||||
*event_context_menu_open ||
|
||||
*calendar_context_menu_open;
|
||||
let any_context_menu_open = color_picker_open.is_some()
|
||||
|| *context_menu_open
|
||||
|| *event_context_menu_open
|
||||
|| *calendar_context_menu_open;
|
||||
|
||||
let on_color_change = {
|
||||
let user_info = user_info.clone();
|
||||
@@ -323,8 +347,12 @@ pub fn App() -> Html {
|
||||
let _calendar_service = CalendarService::new();
|
||||
|
||||
// Get CalDAV password from storage
|
||||
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) {
|
||||
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()
|
||||
@@ -334,28 +362,28 @@ pub fn App() -> Html {
|
||||
};
|
||||
|
||||
let params = event_data.to_create_event_params();
|
||||
let create_result = _calendar_service.create_event(
|
||||
&_token,
|
||||
&_password,
|
||||
params.0, // title
|
||||
params.1, // description
|
||||
params.2, // start_date
|
||||
params.3, // start_time
|
||||
params.4, // end_date
|
||||
params.5, // end_time
|
||||
params.6, // location
|
||||
params.7, // all_day
|
||||
params.8, // status
|
||||
params.9, // class
|
||||
params.10, // priority
|
||||
params.11, // organizer
|
||||
params.12, // attendees
|
||||
params.13, // categories
|
||||
params.14, // reminder
|
||||
params.15, // recurrence
|
||||
params.16, // recurrence_days
|
||||
params.17 // calendar_path
|
||||
).await;
|
||||
let create_result = _calendar_service
|
||||
.create_event(
|
||||
&_token, &_password, params.0, // title
|
||||
params.1, // description
|
||||
params.2, // start_date
|
||||
params.3, // start_time
|
||||
params.4, // end_date
|
||||
params.5, // end_time
|
||||
params.6, // location
|
||||
params.7, // all_day
|
||||
params.8, // status
|
||||
params.9, // class
|
||||
params.10, // priority
|
||||
params.11, // organizer
|
||||
params.12, // attendees
|
||||
params.13, // categories
|
||||
params.14, // reminder
|
||||
params.15, // recurrence
|
||||
params.16, // recurrence_days
|
||||
params.17, // calendar_path
|
||||
)
|
||||
.await;
|
||||
match create_result {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Event created successfully".into());
|
||||
@@ -364,8 +392,13 @@ pub fn App() -> Html {
|
||||
web_sys::window().unwrap().location().reload().unwrap();
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(&format!("Failed to create event: {}", err).into());
|
||||
web_sys::window().unwrap().alert_with_message(&format!("Failed to create event: {}", err)).unwrap();
|
||||
web_sys::console::error_1(
|
||||
&format!("Failed to create event: {}", err).into(),
|
||||
);
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.alert_with_message(&format!("Failed to create event: {}", err))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -375,161 +408,232 @@ pub fn App() -> Html {
|
||||
|
||||
let on_event_update = {
|
||||
let auth_token = auth_token.clone();
|
||||
Callback::from(move |(original_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>)| {
|
||||
web_sys::console::log_1(&format!("Updating event: {} to new times: {} - {}",
|
||||
original_event.uid,
|
||||
new_start.format("%Y-%m-%d %H:%M"),
|
||||
new_end.format("%Y-%m-%d %H:%M")).into());
|
||||
Callback::from(
|
||||
move |(
|
||||
original_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>,
|
||||
)| {
|
||||
web_sys::console::log_1(
|
||||
&format!(
|
||||
"Updating event: {} to new times: {} - {}",
|
||||
original_event.uid,
|
||||
new_start.format("%Y-%m-%d %H:%M"),
|
||||
new_end.format("%Y-%m-%d %H:%M")
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
|
||||
// Use the original UID for all updates
|
||||
let backend_uid = original_event.uid.clone();
|
||||
// Use the original UID for all updates
|
||||
let backend_uid = original_event.uid.clone();
|
||||
|
||||
if let Some(token) = (*auth_token).clone() {
|
||||
let original_event = original_event.clone();
|
||||
let backend_uid = backend_uid.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
if let Some(token) = (*auth_token).clone() {
|
||||
let original_event = original_event.clone();
|
||||
let backend_uid = backend_uid.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
|
||||
// Get CalDAV password from storage
|
||||
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()
|
||||
// Get CalDAV password from storage
|
||||
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()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
};
|
||||
|
||||
// Convert local times to UTC for backend storage
|
||||
let start_utc = new_start.and_local_timezone(chrono::Local).unwrap().to_utc();
|
||||
let end_utc = new_end.and_local_timezone(chrono::Local).unwrap().to_utc();
|
||||
// Convert local times to UTC for backend storage
|
||||
let start_utc = new_start
|
||||
.and_local_timezone(chrono::Local)
|
||||
.unwrap()
|
||||
.to_utc();
|
||||
let end_utc = new_end.and_local_timezone(chrono::Local).unwrap().to_utc();
|
||||
|
||||
// Format UTC date and time strings for backend
|
||||
let start_date = start_utc.format("%Y-%m-%d").to_string();
|
||||
let start_time = start_utc.format("%H:%M").to_string();
|
||||
let end_date = end_utc.format("%Y-%m-%d").to_string();
|
||||
let end_time = end_utc.format("%H:%M").to_string();
|
||||
// Format UTC date and time strings for backend
|
||||
let start_date = start_utc.format("%Y-%m-%d").to_string();
|
||||
let start_time = start_utc.format("%H:%M").to_string();
|
||||
let end_date = end_utc.format("%Y-%m-%d").to_string();
|
||||
let end_time = end_utc.format("%H:%M").to_string();
|
||||
|
||||
// Convert existing event data to string formats for the API
|
||||
let status_str = match original_event.status {
|
||||
Some(crate::models::ical::EventStatus::Tentative) => "TENTATIVE".to_string(),
|
||||
Some(crate::models::ical::EventStatus::Confirmed) => "CONFIRMED".to_string(),
|
||||
Some(crate::models::ical::EventStatus::Cancelled) => "CANCELLED".to_string(),
|
||||
None => "CONFIRMED".to_string(), // Default status
|
||||
};
|
||||
// Convert existing event data to string formats for the API
|
||||
let status_str = match original_event.status {
|
||||
Some(crate::models::ical::EventStatus::Tentative) => {
|
||||
"TENTATIVE".to_string()
|
||||
}
|
||||
Some(crate::models::ical::EventStatus::Confirmed) => {
|
||||
"CONFIRMED".to_string()
|
||||
}
|
||||
Some(crate::models::ical::EventStatus::Cancelled) => {
|
||||
"CANCELLED".to_string()
|
||||
}
|
||||
None => "CONFIRMED".to_string(), // Default status
|
||||
};
|
||||
|
||||
let class_str = match original_event.class {
|
||||
Some(crate::models::ical::EventClass::Public) => "PUBLIC".to_string(),
|
||||
Some(crate::models::ical::EventClass::Private) => "PRIVATE".to_string(),
|
||||
Some(crate::models::ical::EventClass::Confidential) => "CONFIDENTIAL".to_string(),
|
||||
None => "PUBLIC".to_string(), // Default class
|
||||
};
|
||||
let class_str = match original_event.class {
|
||||
Some(crate::models::ical::EventClass::Public) => "PUBLIC".to_string(),
|
||||
Some(crate::models::ical::EventClass::Private) => "PRIVATE".to_string(),
|
||||
Some(crate::models::ical::EventClass::Confidential) => {
|
||||
"CONFIDENTIAL".to_string()
|
||||
}
|
||||
None => "PUBLIC".to_string(), // Default class
|
||||
};
|
||||
|
||||
// Convert reminders to string format
|
||||
let reminder_str = if !original_event.alarms.is_empty() {
|
||||
// Convert from VAlarm to minutes before
|
||||
"15".to_string() // TODO: Convert VAlarm trigger to minutes
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
// Handle recurrence (keep existing)
|
||||
let recurrence_str = original_event.rrule.unwrap_or_default();
|
||||
let recurrence_days = vec![false; 7]; // Default - could be enhanced to parse existing recurrence
|
||||
|
||||
// Determine if this is a recurring event that needs series endpoint
|
||||
let has_recurrence = !recurrence_str.is_empty() && recurrence_str.to_uppercase() != "NONE";
|
||||
|
||||
let result = if let Some(scope) = update_scope.as_ref() {
|
||||
// Use series endpoint for recurring event operations
|
||||
if !has_recurrence {
|
||||
web_sys::console::log_1(&"⚠️ Warning: update_scope provided for non-recurring event, using regular endpoint instead".into());
|
||||
// Fall through to regular endpoint
|
||||
None
|
||||
// Convert reminders to string format
|
||||
let reminder_str = if !original_event.alarms.is_empty() {
|
||||
// Convert from VAlarm to minutes before
|
||||
"15".to_string() // TODO: Convert VAlarm trigger to minutes
|
||||
} else {
|
||||
Some(calendar_service.update_series(
|
||||
&token,
|
||||
&password,
|
||||
backend_uid.clone(),
|
||||
original_event.summary.clone().unwrap_or_default(),
|
||||
original_event.description.clone().unwrap_or_default(),
|
||||
start_date.clone(),
|
||||
start_time.clone(),
|
||||
end_date.clone(),
|
||||
end_time.clone(),
|
||||
original_event.location.clone().unwrap_or_default(),
|
||||
original_event.all_day,
|
||||
status_str.clone(),
|
||||
class_str.clone(),
|
||||
original_event.priority,
|
||||
original_event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
|
||||
original_event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(","),
|
||||
original_event.categories.join(","),
|
||||
reminder_str.clone(),
|
||||
recurrence_str.clone(),
|
||||
original_event.calendar_path.clone(),
|
||||
scope.clone(),
|
||||
occurrence_date,
|
||||
).await)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
let result = if let Some(series_result) = result {
|
||||
series_result
|
||||
} else {
|
||||
// Use regular endpoint
|
||||
calendar_service.update_event(
|
||||
&token,
|
||||
&password,
|
||||
backend_uid,
|
||||
original_event.summary.unwrap_or_default(),
|
||||
original_event.description.unwrap_or_default(),
|
||||
start_date,
|
||||
start_time,
|
||||
end_date,
|
||||
end_time,
|
||||
original_event.location.unwrap_or_default(),
|
||||
original_event.all_day,
|
||||
status_str,
|
||||
class_str,
|
||||
original_event.priority,
|
||||
original_event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
|
||||
original_event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(","),
|
||||
original_event.categories.join(","),
|
||||
reminder_str,
|
||||
recurrence_str,
|
||||
recurrence_days,
|
||||
original_event.calendar_path,
|
||||
original_event.exdate.clone(),
|
||||
if preserve_rrule {
|
||||
Some("update_series".to_string())
|
||||
// Handle recurrence (keep existing)
|
||||
let recurrence_str = original_event.rrule.unwrap_or_default();
|
||||
let recurrence_days = vec![false; 7]; // Default - could be enhanced to parse existing recurrence
|
||||
|
||||
// Determine if this is a recurring event that needs series endpoint
|
||||
let has_recurrence =
|
||||
!recurrence_str.is_empty() && recurrence_str.to_uppercase() != "NONE";
|
||||
|
||||
let result = if let Some(scope) = update_scope.as_ref() {
|
||||
// Use series endpoint for recurring event operations
|
||||
if !has_recurrence {
|
||||
web_sys::console::log_1(&"⚠️ Warning: update_scope provided for non-recurring event, using regular endpoint instead".into());
|
||||
// Fall through to regular endpoint
|
||||
None
|
||||
} else {
|
||||
Some("this_and_future".to_string())
|
||||
},
|
||||
until_date
|
||||
).await
|
||||
};
|
||||
Some(
|
||||
calendar_service
|
||||
.update_series(
|
||||
&token,
|
||||
&password,
|
||||
backend_uid.clone(),
|
||||
original_event.summary.clone().unwrap_or_default(),
|
||||
original_event.description.clone().unwrap_or_default(),
|
||||
start_date.clone(),
|
||||
start_time.clone(),
|
||||
end_date.clone(),
|
||||
end_time.clone(),
|
||||
original_event.location.clone().unwrap_or_default(),
|
||||
original_event.all_day,
|
||||
status_str.clone(),
|
||||
class_str.clone(),
|
||||
original_event.priority,
|
||||
original_event
|
||||
.organizer
|
||||
.as_ref()
|
||||
.map(|o| o.cal_address.clone())
|
||||
.unwrap_or_default(),
|
||||
original_event
|
||||
.attendees
|
||||
.iter()
|
||||
.map(|a| a.cal_address.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
original_event.categories.join(","),
|
||||
reminder_str.clone(),
|
||||
recurrence_str.clone(),
|
||||
original_event.calendar_path.clone(),
|
||||
scope.clone(),
|
||||
occurrence_date,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Event updated successfully".into());
|
||||
// Add small delay before reload to let any pending requests complete
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
gloo_timers::future::sleep(std::time::Duration::from_millis(100)).await;
|
||||
web_sys::window().unwrap().location().reload().unwrap();
|
||||
});
|
||||
let result = if let Some(series_result) = result {
|
||||
series_result
|
||||
} else {
|
||||
// Use regular endpoint
|
||||
calendar_service
|
||||
.update_event(
|
||||
&token,
|
||||
&password,
|
||||
backend_uid,
|
||||
original_event.summary.unwrap_or_default(),
|
||||
original_event.description.unwrap_or_default(),
|
||||
start_date,
|
||||
start_time,
|
||||
end_date,
|
||||
end_time,
|
||||
original_event.location.unwrap_or_default(),
|
||||
original_event.all_day,
|
||||
status_str,
|
||||
class_str,
|
||||
original_event.priority,
|
||||
original_event
|
||||
.organizer
|
||||
.as_ref()
|
||||
.map(|o| o.cal_address.clone())
|
||||
.unwrap_or_default(),
|
||||
original_event
|
||||
.attendees
|
||||
.iter()
|
||||
.map(|a| a.cal_address.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
original_event.categories.join(","),
|
||||
reminder_str,
|
||||
recurrence_str,
|
||||
recurrence_days,
|
||||
original_event.calendar_path,
|
||||
original_event.exdate.clone(),
|
||||
if preserve_rrule {
|
||||
Some("update_series".to_string())
|
||||
} else {
|
||||
Some("this_and_future".to_string())
|
||||
},
|
||||
until_date,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Event updated successfully".into());
|
||||
// Add small delay before reload to let any pending requests complete
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
gloo_timers::future::sleep(std::time::Duration::from_millis(
|
||||
100,
|
||||
))
|
||||
.await;
|
||||
web_sys::window().unwrap().location().reload().unwrap();
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(
|
||||
&format!("Failed to update event: {}", err).into(),
|
||||
);
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.alert_with_message(&format!("Failed to update event: {}", err))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(&format!("Failed to update event: {}", err).into());
|
||||
web_sys::window().unwrap().alert_with_message(&format!("Failed to update event: {}", err)).unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
let refresh_calendars = {
|
||||
@@ -542,8 +646,12 @@ pub fn App() -> Html {
|
||||
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) {
|
||||
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()
|
||||
@@ -554,8 +662,12 @@ pub fn App() -> Html {
|
||||
|
||||
match calendar_service.fetch_user_info(&token, &password).await {
|
||||
Ok(mut info) => {
|
||||
if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") {
|
||||
if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) {
|
||||
if let Ok(saved_colors_json) =
|
||||
LocalStorage::get::<String>("calendar_colors")
|
||||
{
|
||||
if let Ok(saved_info) =
|
||||
serde_json::from_str::<UserInfo>(&saved_colors_json)
|
||||
{
|
||||
for saved_cal in &saved_info.calendars {
|
||||
for cal in &mut info.calendars {
|
||||
if cal.path == saved_cal.path {
|
||||
@@ -568,7 +680,9 @@ pub fn App() -> Html {
|
||||
user_info.set(Some(info));
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::log_1(&format!("Failed to refresh calendars: {}", err).into());
|
||||
web_sys::console::log_1(
|
||||
&format!("Failed to refresh calendars: {}", err).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -577,7 +691,9 @@ pub fn App() -> Html {
|
||||
};
|
||||
|
||||
// Debug logging
|
||||
web_sys::console::log_1(&format!("App rendering: auth_token = {:?}", auth_token.is_some()).into());
|
||||
web_sys::console::log_1(
|
||||
&format!("App rendering: auth_token = {:?}", auth_token.is_some()).into(),
|
||||
);
|
||||
|
||||
html! {
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -11,11 +11,22 @@ pub struct CalDAVLoginRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UserPreferencesResponse {
|
||||
pub calendar_selected_date: Option<String>,
|
||||
pub calendar_time_increment: Option<i32>,
|
||||
pub calendar_view_mode: Option<String>,
|
||||
pub calendar_theme: Option<String>,
|
||||
pub calendar_colors: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AuthResponse {
|
||||
pub token: String,
|
||||
pub session_token: String,
|
||||
pub username: String,
|
||||
pub server_url: String,
|
||||
pub preferences: UserPreferencesResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -50,8 +61,8 @@ impl AuthService {
|
||||
) -> Result<R, String> {
|
||||
let window = web_sys::window().ok_or("No global window exists")?;
|
||||
|
||||
let json_body = serde_json::to_string(body)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
let json_body =
|
||||
serde_json::to_string(body).map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
|
||||
let opts = RequestInit::new();
|
||||
opts.set_method("POST");
|
||||
@@ -62,23 +73,27 @@ impl AuthService {
|
||||
let request = Request::new_with_str_and_init(&url, &opts)
|
||||
.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))?;
|
||||
|
||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||
.await
|
||||
.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))?;
|
||||
|
||||
let text = JsFuture::from(resp.text()
|
||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
||||
.await
|
||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||
let text = JsFuture::from(
|
||||
resp.text()
|
||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||
|
||||
let text_string = text.as_string()
|
||||
.ok_or("Response text is not a string")?;
|
||||
let text_string = text.as_string().ok_or("Response text is not a string")?;
|
||||
|
||||
if resp.ok() {
|
||||
serde_json::from_str::<R>(&text_string)
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::{Datelike, Local, NaiveDate, Duration};
|
||||
use crate::components::{
|
||||
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 web_sys::MouseEvent;
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::components::{EventModal, ViewMode, CalendarHeader, MonthView, WeekView, CreateEventModal, EventCreationData};
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
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]
|
||||
pub user_info: Option<UserInfo>,
|
||||
#[prop_or_default]
|
||||
@@ -25,7 +22,17 @@ pub struct CalendarProps {
|
||||
#[prop_or_default]
|
||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||
#[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]
|
||||
pub context_menus_open: bool,
|
||||
}
|
||||
@@ -33,6 +40,12 @@ pub struct CalendarProps {
|
||||
#[function_component]
|
||||
pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
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)
|
||||
let selected_date = use_state(|| {
|
||||
// Try to load saved selected date from localStorage
|
||||
@@ -57,17 +70,16 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
});
|
||||
|
||||
// Track the display date (what to show in the view)
|
||||
let current_date = use_state(|| {
|
||||
match props.view {
|
||||
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
|
||||
ViewMode::Week => *selected_date,
|
||||
}
|
||||
let current_date = use_state(|| match props.view {
|
||||
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
|
||||
ViewMode::Week => *selected_date,
|
||||
});
|
||||
let selected_event = use_state(|| None::<VEvent>);
|
||||
|
||||
// State for create event modal
|
||||
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)
|
||||
let time_increment = use_state(|| {
|
||||
@@ -83,6 +95,154 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
{
|
||||
let current_date = current_date.clone();
|
||||
@@ -110,16 +270,19 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
let prev_month = *current_date - Duration::days(1);
|
||||
let first_of_prev = prev_month.with_day(1).unwrap();
|
||||
(first_of_prev, first_of_prev)
|
||||
},
|
||||
}
|
||||
ViewMode::Week => {
|
||||
// Go to previous week
|
||||
let prev_week = *selected_date - Duration::weeks(1);
|
||||
(prev_week, prev_week)
|
||||
},
|
||||
}
|
||||
};
|
||||
selected_date.set(new_selected);
|
||||
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(),
|
||||
);
|
||||
})
|
||||
};
|
||||
|
||||
@@ -134,19 +297,23 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
let next_month = if current_date.month() == 12 {
|
||||
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
|
||||
} 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)
|
||||
},
|
||||
}
|
||||
ViewMode::Week => {
|
||||
// Go to next week
|
||||
let next_week = *selected_date + Duration::weeks(1);
|
||||
(next_week, next_week)
|
||||
},
|
||||
}
|
||||
};
|
||||
selected_date.set(new_selected);
|
||||
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,12 +327,15 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
ViewMode::Month => {
|
||||
let first_of_today = today.with_day(1).unwrap();
|
||||
(today, first_of_today) // Select today, but display the month
|
||||
},
|
||||
}
|
||||
ViewMode::Week => (today, today), // Select and display today
|
||||
};
|
||||
selected_date.set(new_selected);
|
||||
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(),
|
||||
);
|
||||
})
|
||||
};
|
||||
|
||||
@@ -184,22 +354,58 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
let on_create_event = {
|
||||
let show_create_modal = show_create_modal.clone();
|
||||
let create_event_data = create_event_data.clone();
|
||||
Callback::from(move |(_date, start_datetime, end_datetime): (NaiveDate, chrono::NaiveDateTime, 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);
|
||||
})
|
||||
Callback::from(
|
||||
move |(_date, start_datetime, end_datetime): (
|
||||
NaiveDate,
|
||||
chrono::NaiveDateTime,
|
||||
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
|
||||
let on_event_update = {
|
||||
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>)| {
|
||||
if let Some(callback) = &on_event_update_request {
|
||||
callback.emit((event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date));
|
||||
}
|
||||
})
|
||||
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>,
|
||||
)| {
|
||||
if let Some(callback) = &on_event_update_request {
|
||||
callback.emit((
|
||||
event,
|
||||
new_start,
|
||||
new_end,
|
||||
preserve_rrule,
|
||||
until_date,
|
||||
update_scope,
|
||||
occurrence_date,
|
||||
));
|
||||
}
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
html! {
|
||||
@@ -215,7 +421,20 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
/>
|
||||
|
||||
{
|
||||
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 => {
|
||||
let on_day_select = {
|
||||
let selected_date = selected_date.clone();
|
||||
@@ -229,9 +448,9 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
<MonthView
|
||||
current_month={*current_date}
|
||||
today={today}
|
||||
events={props.events.clone()}
|
||||
on_event_click={props.on_event_click.clone()}
|
||||
refreshing_event_uid={props.refreshing_event_uid.clone()}
|
||||
events={(*events).clone()}
|
||||
on_event_click={on_event_click.clone()}
|
||||
refreshing_event_uid={(*refreshing_event_uid).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()}
|
||||
@@ -244,9 +463,9 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
<WeekView
|
||||
current_date={*current_date}
|
||||
today={today}
|
||||
events={props.events.clone()}
|
||||
on_event_click={props.on_event_click.clone()}
|
||||
refreshing_event_uid={props.refreshing_event_uid.clone()}
|
||||
events={(*events).clone()}
|
||||
on_event_click={on_event_click.clone()}
|
||||
refreshing_event_uid={(*refreshing_event_uid).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()}
|
||||
@@ -257,6 +476,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
time_increment={*time_increment}
|
||||
/>
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::MouseEvent;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarContextMenuProps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::{NaiveDate, Datelike};
|
||||
use crate::components::ViewMode;
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use web_sys::MouseEvent;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarHeaderProps {
|
||||
@@ -18,7 +18,11 @@ pub struct CalendarHeaderProps {
|
||||
|
||||
#[function_component(CalendarHeader)]
|
||||
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! {
|
||||
<div class="calendar-header">
|
||||
@@ -59,6 +63,6 @@ fn get_month_name(month: u32) -> &'static str {
|
||||
10 => "October",
|
||||
11 => "November",
|
||||
12 => "December",
|
||||
_ => "Invalid"
|
||||
_ => "Invalid",
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::MouseEvent;
|
||||
use crate::services::calendar_service::CalendarInfo;
|
||||
use web_sys::MouseEvent;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarListItemProps {
|
||||
pub calendar: CalendarInfo,
|
||||
pub color_picker_open: bool,
|
||||
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 on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::MouseEvent;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ContextMenuProps {
|
||||
|
||||
@@ -50,7 +50,9 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::{HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement};
|
||||
use wasm_bindgen::JsCast;
|
||||
use chrono::{NaiveDate, NaiveTime, Local, TimeZone, Utc, Datelike};
|
||||
use crate::services::calendar_service::CalendarInfo;
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::components::EditAction;
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::calendar_service::CalendarInfo;
|
||||
use chrono::{Datelike, Local, NaiveDate, NaiveTime, TimeZone, Utc};
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::{HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CreateEventModalProps {
|
||||
@@ -36,7 +36,6 @@ impl Default for EventStatus {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum EventClass {
|
||||
Public,
|
||||
@@ -50,7 +49,6 @@ impl Default for EventClass {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum ReminderType {
|
||||
None,
|
||||
@@ -84,7 +82,6 @@ impl Default for RecurrenceType {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Parse RRULE string into recurrence components
|
||||
/// Example RRULE: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;UNTIL=20231215T000000Z"
|
||||
#[derive(Debug, Default, Clone)]
|
||||
@@ -145,9 +142,7 @@ fn parse_rrule(rrule: Option<&str>) -> ParsedRrule {
|
||||
}
|
||||
"BYDAY" => {
|
||||
// Parse BYDAY: "MO,WE,FR" or "1MO,-1SU" (with position)
|
||||
parsed.byday = value.split(',')
|
||||
.map(|s| s.trim().to_uppercase())
|
||||
.collect();
|
||||
parsed.byday = value.split(',').map(|s| s.trim().to_uppercase()).collect();
|
||||
}
|
||||
"BYMONTHDAY" => {
|
||||
// Parse BYMONTHDAY: "15" or "1,15,31"
|
||||
@@ -161,7 +156,8 @@ fn parse_rrule(rrule: Option<&str>) -> ParsedRrule {
|
||||
}
|
||||
"BYMONTH" => {
|
||||
// Parse BYMONTH: "1,3,5" (January, March, May)
|
||||
parsed.bymonth = value.split(',')
|
||||
parsed.bymonth = value
|
||||
.split(',')
|
||||
.filter_map(|m| m.trim().parse::<u8>().ok())
|
||||
.filter(|&m| m >= 1 && m <= 12)
|
||||
.collect();
|
||||
@@ -182,7 +178,7 @@ fn byday_to_weekday_array(byday: &[String]) -> Vec<bool> {
|
||||
// Handle both simple days (MO, TU) and positioned days (1MO, -1SU)
|
||||
let day_code = if day_spec.len() > 2 {
|
||||
// Extract last 2 characters for positioned days like "1MO" -> "MO"
|
||||
&day_spec[day_spec.len()-2..]
|
||||
&day_spec[day_spec.len() - 2..]
|
||||
} else {
|
||||
day_spec
|
||||
};
|
||||
@@ -221,7 +217,8 @@ fn bymonth_to_monthly_array(bymonth: &[u8]) -> Vec<bool> {
|
||||
/// Extract positioned weekday from BYDAY for monthly recurrence
|
||||
/// Examples: "1MO" -> Some("1MO"), "2TU" -> Some("2TU"), "-1SU" -> Some("-1SU")
|
||||
fn extract_monthly_byday(byday: &[String]) -> Option<String> {
|
||||
byday.iter()
|
||||
byday
|
||||
.iter()
|
||||
.find(|day| day.len() > 2) // Positioned days have length > 2
|
||||
.cloned()
|
||||
}
|
||||
@@ -240,7 +237,9 @@ mod rrule_tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_complex_monthly() {
|
||||
let parsed = parse_rrule(Some("FREQ=MONTHLY;INTERVAL=2;BYDAY=1MO;UNTIL=20241231T000000Z"));
|
||||
let parsed = parse_rrule(Some(
|
||||
"FREQ=MONTHLY;INTERVAL=2;BYDAY=1MO;UNTIL=20241231T000000Z",
|
||||
));
|
||||
assert_eq!(parsed.freq, RecurrenceType::Monthly);
|
||||
assert_eq!(parsed.interval, 2);
|
||||
assert_eq!(parsed.byday, vec!["1MO"]);
|
||||
@@ -249,7 +248,8 @@ mod rrule_tests {
|
||||
|
||||
#[test]
|
||||
fn test_byday_to_weekday_array() {
|
||||
let weekdays = byday_to_weekday_array(&["MO".to_string(), "WE".to_string(), "FR".to_string()]);
|
||||
let weekdays =
|
||||
byday_to_weekday_array(&["MO".to_string(), "WE".to_string(), "FR".to_string()]);
|
||||
// [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
|
||||
assert_eq!(weekdays, vec![false, true, false, true, false, true, false]);
|
||||
}
|
||||
@@ -295,14 +295,15 @@ mod rrule_tests {
|
||||
fn test_build_rrule_yearly() {
|
||||
let mut data = EventCreationData::default();
|
||||
data.recurrence = RecurrenceType::Yearly;
|
||||
data.yearly_by_month = vec![false, false, true, false, true, false, false, false, false, false, false, false]; // March, May
|
||||
data.yearly_by_month = vec![
|
||||
false, false, true, false, true, false, false, false, false, false, false, false,
|
||||
]; // March, May
|
||||
|
||||
let rrule = data.build_rrule();
|
||||
println!("YEARLY RRULE: {}", rrule);
|
||||
assert!(rrule.contains("FREQ=YEARLY"));
|
||||
assert!(rrule.contains("BYMONTH=3,5"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
@@ -319,7 +320,7 @@ pub struct EventCreationData {
|
||||
pub class: EventClass,
|
||||
pub priority: Option<u8>,
|
||||
pub organizer: String,
|
||||
pub attendees: String, // Comma-separated list
|
||||
pub attendees: String, // Comma-separated list
|
||||
pub categories: String, // Comma-separated list
|
||||
pub reminder: ReminderType,
|
||||
pub recurrence: RecurrenceType,
|
||||
@@ -332,7 +333,7 @@ pub struct EventCreationData {
|
||||
pub recurrence_count: Option<u32>, // COUNT - number of occurrences
|
||||
pub monthly_by_day: Option<String>, // For monthly: "1MO" = first Monday, "2TU" = second Tuesday, etc.
|
||||
pub monthly_by_monthday: Option<u8>, // For monthly: day of month (1-31)
|
||||
pub yearly_by_month: Vec<bool>, // For yearly: [Jan, Feb, Mar, ..., Dec]
|
||||
pub yearly_by_month: Vec<bool>, // For yearly: [Jan, Feb, Mar, ..., Dec]
|
||||
|
||||
// Edit scope and tracking fields
|
||||
pub edit_scope: Option<EditAction>,
|
||||
@@ -407,21 +408,25 @@ impl EventCreationData {
|
||||
match self.recurrence {
|
||||
RecurrenceType::Weekly => {
|
||||
// Add BYDAY for weekly recurrence
|
||||
let selected_days: Vec<&str> = self.recurrence_days.iter()
|
||||
let selected_days: Vec<&str> = self
|
||||
.recurrence_days
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &selected)| if selected {
|
||||
Some(match i {
|
||||
0 => "SU", // Sunday
|
||||
1 => "MO", // Monday
|
||||
2 => "TU", // Tuesday
|
||||
3 => "WE", // Wednesday
|
||||
4 => "TH", // Thursday
|
||||
5 => "FR", // Friday
|
||||
6 => "SA", // Saturday
|
||||
_ => "",
|
||||
})
|
||||
} else {
|
||||
None
|
||||
.filter_map(|(i, &selected)| {
|
||||
if selected {
|
||||
Some(match i {
|
||||
0 => "SU", // Sunday
|
||||
1 => "MO", // Monday
|
||||
2 => "TU", // Tuesday
|
||||
3 => "WE", // Wednesday
|
||||
4 => "TH", // Thursday
|
||||
5 => "FR", // Friday
|
||||
6 => "SA", // Saturday
|
||||
_ => "",
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
@@ -429,7 +434,7 @@ impl EventCreationData {
|
||||
if !selected_days.is_empty() {
|
||||
parts.push(format!("BYDAY={}", selected_days.join(",")));
|
||||
}
|
||||
},
|
||||
}
|
||||
RecurrenceType::Monthly => {
|
||||
// Add BYDAY or BYMONTHDAY for monthly recurrence
|
||||
if let Some(ref by_day) = self.monthly_by_day {
|
||||
@@ -437,22 +442,26 @@ impl EventCreationData {
|
||||
} else if let Some(by_monthday) = self.monthly_by_monthday {
|
||||
parts.push(format!("BYMONTHDAY={}", by_monthday));
|
||||
}
|
||||
},
|
||||
}
|
||||
RecurrenceType::Yearly => {
|
||||
// Add BYMONTH for yearly recurrence
|
||||
let selected_months: Vec<String> = self.yearly_by_month.iter()
|
||||
let selected_months: Vec<String> = self
|
||||
.yearly_by_month
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &selected)| if selected {
|
||||
Some((i + 1).to_string()) // Convert 0-based index to 1-based month
|
||||
} else {
|
||||
None
|
||||
.filter_map(|(i, &selected)| {
|
||||
if selected {
|
||||
Some((i + 1).to_string()) // Convert 0-based index to 1-based month
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !selected_months.is_empty() {
|
||||
parts.push(format!("BYMONTH={}", selected_months.join(",")));
|
||||
}
|
||||
},
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -467,11 +476,36 @@ impl EventCreationData {
|
||||
parts.join(";")
|
||||
}
|
||||
|
||||
pub fn to_create_event_params(&self) -> (String, String, String, String, String, String, String, bool, String, String, Option<u8>, String, String, String, String, String, Vec<bool>, Option<String>) {
|
||||
pub fn to_create_event_params(
|
||||
&self,
|
||||
) -> (
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
bool,
|
||||
String,
|
||||
String,
|
||||
Option<u8>,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Vec<bool>,
|
||||
Option<String>,
|
||||
) {
|
||||
// Convert local date/time to UTC
|
||||
let start_local = Local.from_local_datetime(&self.start_date.and_time(self.start_time)).single()
|
||||
let start_local = Local
|
||||
.from_local_datetime(&self.start_date.and_time(self.start_time))
|
||||
.single()
|
||||
.unwrap_or_else(|| Local::now());
|
||||
let end_local = Local.from_local_datetime(&self.end_date.and_time(self.end_time)).single()
|
||||
let end_local = Local
|
||||
.from_local_datetime(&self.end_date.and_time(self.end_time))
|
||||
.single()
|
||||
.unwrap_or_else(|| Local::now());
|
||||
|
||||
let start_utc = start_local.with_timezone(&Utc);
|
||||
@@ -512,7 +546,7 @@ impl EventCreationData {
|
||||
},
|
||||
self.build_rrule(), // Use the comprehensive RRULE builder
|
||||
self.recurrence_days.clone(),
|
||||
self.selected_calendar.clone()
|
||||
self.selected_calendar.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -531,23 +565,48 @@ impl EventCreationData {
|
||||
description: event.description.clone().unwrap_or_default(),
|
||||
start_date: event.dtstart.with_timezone(&chrono::Local).date_naive(),
|
||||
start_time: event.dtstart.with_timezone(&chrono::Local).time(),
|
||||
end_date: event.dtend.as_ref().map(|e| e.with_timezone(&chrono::Local).date_naive()).unwrap_or(event.dtstart.with_timezone(&chrono::Local).date_naive()),
|
||||
end_time: event.dtend.as_ref().map(|e| e.with_timezone(&chrono::Local).time()).unwrap_or(event.dtstart.with_timezone(&chrono::Local).time()),
|
||||
end_date: event
|
||||
.dtend
|
||||
.as_ref()
|
||||
.map(|e| e.with_timezone(&chrono::Local).date_naive())
|
||||
.unwrap_or(event.dtstart.with_timezone(&chrono::Local).date_naive()),
|
||||
end_time: event
|
||||
.dtend
|
||||
.as_ref()
|
||||
.map(|e| e.with_timezone(&chrono::Local).time())
|
||||
.unwrap_or(event.dtstart.with_timezone(&chrono::Local).time()),
|
||||
location: event.location.clone().unwrap_or_default(),
|
||||
all_day: event.all_day,
|
||||
status: event.status.as_ref().map(|s| match s {
|
||||
crate::models::ical::EventStatus::Tentative => EventStatus::Tentative,
|
||||
crate::models::ical::EventStatus::Confirmed => EventStatus::Confirmed,
|
||||
crate::models::ical::EventStatus::Cancelled => EventStatus::Cancelled,
|
||||
}).unwrap_or(EventStatus::Confirmed),
|
||||
class: event.class.as_ref().map(|c| match c {
|
||||
crate::models::ical::EventClass::Public => EventClass::Public,
|
||||
crate::models::ical::EventClass::Private => EventClass::Private,
|
||||
crate::models::ical::EventClass::Confidential => EventClass::Confidential,
|
||||
}).unwrap_or(EventClass::Public),
|
||||
status: event
|
||||
.status
|
||||
.as_ref()
|
||||
.map(|s| match s {
|
||||
crate::models::ical::EventStatus::Tentative => EventStatus::Tentative,
|
||||
crate::models::ical::EventStatus::Confirmed => EventStatus::Confirmed,
|
||||
crate::models::ical::EventStatus::Cancelled => EventStatus::Cancelled,
|
||||
})
|
||||
.unwrap_or(EventStatus::Confirmed),
|
||||
class: event
|
||||
.class
|
||||
.as_ref()
|
||||
.map(|c| match c {
|
||||
crate::models::ical::EventClass::Public => EventClass::Public,
|
||||
crate::models::ical::EventClass::Private => EventClass::Private,
|
||||
crate::models::ical::EventClass::Confidential => EventClass::Confidential,
|
||||
})
|
||||
.unwrap_or(EventClass::Public),
|
||||
priority: event.priority,
|
||||
organizer: event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
|
||||
attendees: event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(", "),
|
||||
organizer: event
|
||||
.organizer
|
||||
.as_ref()
|
||||
.map(|o| o.cal_address.clone())
|
||||
.unwrap_or_default(),
|
||||
attendees: event
|
||||
.attendees
|
||||
.iter()
|
||||
.map(|a| a.cal_address.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
categories: event.categories.join(", "),
|
||||
reminder: ReminderType::default(), // TODO: Convert from event reminders
|
||||
recurrence: parsed_rrule.freq.clone(),
|
||||
@@ -583,7 +642,6 @@ impl EventCreationData {
|
||||
changed_fields: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
@@ -608,48 +666,67 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
|
||||
let active_tab = use_state(|| ModalTab::default());
|
||||
|
||||
// Initialize with selected date or event data if provided
|
||||
use_effect_with((props.selected_date, props.event_to_edit.clone(), props.is_open, props.available_calendars.clone(), props.initial_start_time, props.initial_end_time, props.edit_scope.clone()), {
|
||||
let event_data = event_data.clone();
|
||||
move |(selected_date, event_to_edit, is_open, available_calendars, initial_start_time, initial_end_time, edit_scope)| {
|
||||
if *is_open {
|
||||
let mut data = if let Some(event) = event_to_edit {
|
||||
// Pre-populate with event data for editing
|
||||
EventCreationData::from_calendar_event(event)
|
||||
} else if let Some(date) = selected_date {
|
||||
// Initialize with selected date for new event
|
||||
let mut data = EventCreationData::default();
|
||||
data.start_date = *date;
|
||||
data.end_date = *date;
|
||||
use_effect_with(
|
||||
(
|
||||
props.selected_date,
|
||||
props.event_to_edit.clone(),
|
||||
props.is_open,
|
||||
props.available_calendars.clone(),
|
||||
props.initial_start_time,
|
||||
props.initial_end_time,
|
||||
props.edit_scope.clone(),
|
||||
),
|
||||
{
|
||||
let event_data = event_data.clone();
|
||||
move |(
|
||||
selected_date,
|
||||
event_to_edit,
|
||||
is_open,
|
||||
available_calendars,
|
||||
initial_start_time,
|
||||
initial_end_time,
|
||||
edit_scope,
|
||||
)| {
|
||||
if *is_open {
|
||||
let mut data = if let Some(event) = event_to_edit {
|
||||
// Pre-populate with event data for editing
|
||||
EventCreationData::from_calendar_event(event)
|
||||
} else if let Some(date) = selected_date {
|
||||
// Initialize with selected date for new event
|
||||
let mut data = EventCreationData::default();
|
||||
data.start_date = *date;
|
||||
data.end_date = *date;
|
||||
|
||||
// Use initial times if provided (from drag-to-create)
|
||||
if let Some(start_time) = initial_start_time {
|
||||
data.start_time = *start_time;
|
||||
}
|
||||
if let Some(end_time) = initial_end_time {
|
||||
data.end_time = *end_time;
|
||||
// Use initial times if provided (from drag-to-create)
|
||||
if let Some(start_time) = initial_start_time {
|
||||
data.start_time = *start_time;
|
||||
}
|
||||
if let Some(end_time) = initial_end_time {
|
||||
data.end_time = *end_time;
|
||||
}
|
||||
|
||||
data
|
||||
} else {
|
||||
// Default initialization
|
||||
EventCreationData::default()
|
||||
};
|
||||
|
||||
// Set default calendar to the first available one if none selected
|
||||
if data.selected_calendar.is_none() && !available_calendars.is_empty() {
|
||||
data.selected_calendar = Some(available_calendars[0].path.clone());
|
||||
}
|
||||
|
||||
data
|
||||
} else {
|
||||
// Default initialization
|
||||
EventCreationData::default()
|
||||
};
|
||||
// Set edit scope if provided
|
||||
if let Some(scope) = edit_scope {
|
||||
data.edit_scope = Some(scope.clone());
|
||||
}
|
||||
|
||||
// Set default calendar to the first available one if none selected
|
||||
if data.selected_calendar.is_none() && !available_calendars.is_empty() {
|
||||
data.selected_calendar = Some(available_calendars[0].path.clone());
|
||||
event_data.set(data);
|
||||
}
|
||||
|
||||
// Set edit scope if provided
|
||||
if let Some(scope) = edit_scope {
|
||||
data.edit_scope = Some(scope.clone());
|
||||
}
|
||||
|
||||
event_data.set(data);
|
||||
|| ()
|
||||
}
|
||||
|| ()
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if !props.is_open {
|
||||
return html! {};
|
||||
@@ -697,7 +774,10 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
|
||||
let new_calendar = if value.is_empty() { None } else { Some(value) };
|
||||
if data.selected_calendar != new_calendar {
|
||||
data.selected_calendar = new_calendar;
|
||||
if !data.changed_fields.contains(&"selected_calendar".to_string()) {
|
||||
if !data
|
||||
.changed_fields
|
||||
.contains(&"selected_calendar".to_string())
|
||||
{
|
||||
data.changed_fields.push("selected_calendar".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::MouseEvent;
|
||||
use crate::models::ical::VEvent;
|
||||
use web_sys::MouseEvent;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum DeleteAction {
|
||||
@@ -41,7 +41,9 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
||||
);
|
||||
|
||||
// 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())
|
||||
.unwrap_or(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::{DateTime, Utc};
|
||||
use crate::models::ical::VEvent;
|
||||
use chrono::{DateTime, Utc};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct EventModalProps {
|
||||
@@ -236,4 +236,3 @@ fn format_recurrence_rule(rrule: &str) -> String {
|
||||
format!("Custom ({})", rrule)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use yew::prelude::*;
|
||||
use web_sys::HtmlInputElement;
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use web_sys::HtmlInputElement;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct LoginProps {
|
||||
@@ -9,12 +9,21 @@ pub struct LoginProps {
|
||||
|
||||
#[function_component]
|
||||
pub fn Login(props: &LoginProps) -> Html {
|
||||
let server_url = use_state(String::new);
|
||||
let username = use_state(String::new);
|
||||
// Load remembered values from LocalStorage on mount
|
||||
let server_url = use_state(|| {
|
||||
LocalStorage::get::<String>("remembered_server_url").unwrap_or_default()
|
||||
});
|
||||
let username = use_state(|| {
|
||||
LocalStorage::get::<String>("remembered_username").unwrap_or_default()
|
||||
});
|
||||
let password = use_state(String::new);
|
||||
let error_message = use_state(|| Option::<String>::None);
|
||||
let is_loading = use_state(|| false);
|
||||
|
||||
// Remember checkboxes state - default to checked
|
||||
let remember_server = use_state(|| true);
|
||||
let remember_username = use_state(|| true);
|
||||
|
||||
let server_url_ref = use_node_ref();
|
||||
let username_ref = use_node_ref();
|
||||
let password_ref = use_node_ref();
|
||||
@@ -43,6 +52,38 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
let on_remember_server_change = {
|
||||
let remember_server = remember_server.clone();
|
||||
let server_url = server_url.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||
let checked = target.checked();
|
||||
remember_server.set(checked);
|
||||
|
||||
if checked {
|
||||
let _ = LocalStorage::set("remembered_server_url", (*server_url).clone());
|
||||
} else {
|
||||
let _ = LocalStorage::delete("remembered_server_url");
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_remember_username_change = {
|
||||
let remember_username = remember_username.clone();
|
||||
let username = username.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||
let checked = target.checked();
|
||||
remember_username.set(checked);
|
||||
|
||||
if checked {
|
||||
let _ = LocalStorage::set("remembered_username", (*username).clone());
|
||||
} else {
|
||||
let _ = LocalStorage::delete("remembered_username");
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_submit = {
|
||||
let server_url = server_url.clone();
|
||||
let username = username.clone();
|
||||
@@ -73,11 +114,18 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
web_sys::console::log_1(&"🚀 Starting login process...".into());
|
||||
match perform_login(server_url.clone(), username.clone(), password.clone()).await {
|
||||
Ok((token, credentials)) => {
|
||||
Ok((token, session_token, credentials, preferences)) => {
|
||||
web_sys::console::log_1(&"✅ Login successful!".into());
|
||||
// Store token and credentials in local storage
|
||||
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);
|
||||
return;
|
||||
}
|
||||
if let Err(_) = LocalStorage::set("session_token", &session_token) {
|
||||
error_message
|
||||
.set(Some("Failed to store session token".to_string()));
|
||||
is_loading.set(false);
|
||||
return;
|
||||
}
|
||||
@@ -87,6 +135,11 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store preferences from database
|
||||
if let Ok(prefs_json) = serde_json::to_string(&preferences) {
|
||||
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
||||
}
|
||||
|
||||
is_loading.set(false);
|
||||
on_login.emit(token);
|
||||
}
|
||||
@@ -116,6 +169,15 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
onchange={on_server_url_change}
|
||||
disabled={*is_loading}
|
||||
/>
|
||||
<div class="remember-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember_server"
|
||||
checked={*remember_server}
|
||||
onchange={on_remember_server_change}
|
||||
/>
|
||||
<label for="remember_server">{"Remember server"}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -129,6 +191,15 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
onchange={on_username_change}
|
||||
disabled={*is_loading}
|
||||
/>
|
||||
<div class="remember-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember_username"
|
||||
checked={*remember_username}
|
||||
onchange={on_remember_username_change}
|
||||
/>
|
||||
<label for="remember_username">{"Remember username"}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -172,7 +243,11 @@ pub fn Login(props: &LoginProps) -> Html {
|
||||
}
|
||||
|
||||
/// 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, serde_json::Value), String> {
|
||||
use crate::auth::{AuthService, CalDAVLoginRequest};
|
||||
use serde_json;
|
||||
|
||||
@@ -182,7 +257,7 @@ async fn perform_login(server_url: String, username: String, password: String) -
|
||||
let request = CalDAVLoginRequest {
|
||||
server_url: server_url.clone(),
|
||||
username: username.clone(),
|
||||
password: password.clone()
|
||||
password: password.clone(),
|
||||
};
|
||||
|
||||
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
|
||||
@@ -196,11 +271,21 @@ async fn perform_login(server_url: String, username: String, password: String) -
|
||||
"username": username,
|
||||
"password": password
|
||||
});
|
||||
Ok((response.token, credentials.to_string()))
|
||||
},
|
||||
|
||||
// Extract preferences as JSON
|
||||
let preferences = serde_json::json!({
|
||||
"calendar_selected_date": response.preferences.calendar_selected_date,
|
||||
"calendar_time_increment": response.preferences.calendar_time_increment,
|
||||
"calendar_view_mode": response.preferences.calendar_view_mode,
|
||||
"calendar_theme": response.preferences.calendar_theme,
|
||||
"calendar_colors": response.preferences.calendar_colors,
|
||||
});
|
||||
|
||||
Ok((response.token, response.session_token, credentials.to_string(), preferences))
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
|
||||
Err(err)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,33 @@
|
||||
pub mod login;
|
||||
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 create_event_modal;
|
||||
pub mod sidebar;
|
||||
pub mod calendar_header;
|
||||
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 route_handler;
|
||||
pub mod sidebar;
|
||||
pub mod week_view;
|
||||
|
||||
pub use login::Login;
|
||||
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 create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
|
||||
pub use sidebar::{Sidebar, ViewMode, Theme};
|
||||
pub use calendar_header::CalendarHeader;
|
||||
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 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 std::collections::HashMap;
|
||||
use web_sys::window;
|
||||
use wasm_bindgen::{prelude::*, JsCast};
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use crate::models::ical::VEvent;
|
||||
use web_sys::window;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct MonthViewProps {
|
||||
@@ -72,7 +72,10 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
||||
}) as Box<dyn Fn()>);
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -84,8 +87,11 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
||||
let get_event_color = |event: &VEvent| -> String {
|
||||
if let Some(user_info) = &props.user_info {
|
||||
if let Some(calendar_path) = &event.calendar_path {
|
||||
if let Some(calendar) = user_info.calendars.iter()
|
||||
.find(|cal| &cal.path == calendar_path) {
|
||||
if let Some(calendar) = user_info
|
||||
.calendars
|
||||
.iter()
|
||||
.find(|cal| &cal.path == calendar_path)
|
||||
{
|
||||
return calendar.color.clone();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
let total_slots = 42; // 6 rows x 7 days
|
||||
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
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
(1..=remaining_slots).map(|day| {
|
||||
html! {
|
||||
<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 {
|
||||
NaiveDate::from_ymd_opt(
|
||||
if date.month() == 12 { date.year() + 1 } else { date.year() },
|
||||
if date.month() == 12 { 1 } else { date.month() + 1 },
|
||||
1
|
||||
if date.month() == 12 {
|
||||
date.year() + 1
|
||||
} else {
|
||||
date.year()
|
||||
},
|
||||
if date.month() == 12 {
|
||||
1
|
||||
} else {
|
||||
date.month() + 1
|
||||
},
|
||||
1,
|
||||
)
|
||||
.unwrap()
|
||||
.pred_opt()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::NaiveDateTime;
|
||||
use crate::models::ical::VEvent;
|
||||
use chrono::NaiveDateTime;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum RecurringEditAction {
|
||||
@@ -25,7 +25,12 @@ pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> 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_choice = props.on_choice.clone();
|
||||
|
||||
@@ -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_router::prelude::*;
|
||||
use crate::components::{Login, ViewMode};
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use crate::models::ical::VEvent;
|
||||
|
||||
#[derive(Clone, Routable, PartialEq)]
|
||||
pub enum Route {
|
||||
@@ -28,7 +28,17 @@ pub struct RouteHandlerProps {
|
||||
#[prop_or_default]
|
||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||
#[prop_or_default]
|
||||
pub on_event_update_request: Option<Callback<(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]
|
||||
pub context_menus_open: bool,
|
||||
}
|
||||
@@ -106,192 +116,36 @@ pub struct CalendarViewProps {
|
||||
#[prop_or_default]
|
||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||
#[prop_or_default]
|
||||
pub on_event_update_request: Option<Callback<(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]
|
||||
pub context_menus_open: bool,
|
||||
}
|
||||
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use crate::services::CalendarService;
|
||||
use crate::components::Calendar;
|
||||
use std::collections::HashMap;
|
||||
use chrono::{Local, NaiveDate, Datelike};
|
||||
|
||||
#[function_component(CalendarView)]
|
||||
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! {
|
||||
<div class="calendar-view">
|
||||
{
|
||||
if *loading {
|
||||
html! {
|
||||
<div class="calendar-loading">
|
||||
<p>{"Loading calendar events..."}</p>
|
||||
</div>
|
||||
}
|
||||
} else if let Some(err) = (*error).clone() {
|
||||
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}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
<Calendar
|
||||
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>
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::components::CalendarListItem;
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use web_sys::HtmlSelectElement;
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use web_sys::HtmlSelectElement;
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use crate::components::CalendarListItem;
|
||||
|
||||
#[derive(Clone, Routable, PartialEq)]
|
||||
pub enum Route {
|
||||
@@ -33,7 +33,6 @@ pub enum Theme {
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
|
||||
pub fn value(&self) -> &'static str {
|
||||
match self {
|
||||
Theme::Default => "default",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::{Datelike, NaiveDate, Duration, Weekday, Local, Timelike, NaiveDateTime, NaiveTime};
|
||||
use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal};
|
||||
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 web_sys::MouseEvent;
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::components::{RecurringEditModal, RecurringEditAction, EventCreationData};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct WeekViewProps {
|
||||
@@ -25,7 +25,17 @@ pub struct WeekViewProps {
|
||||
#[prop_or_default]
|
||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||
#[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]
|
||||
pub context_menus_open: bool,
|
||||
#[prop_or_default]
|
||||
@@ -47,16 +57,14 @@ struct DragState {
|
||||
start_date: NaiveDate,
|
||||
start_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
|
||||
}
|
||||
|
||||
#[function_component(WeekView)]
|
||||
pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
let start_of_week = get_start_of_week(props.current_date);
|
||||
let week_days: Vec<NaiveDate> = (0..7)
|
||||
.map(|i| start_of_week + Duration::days(i))
|
||||
.collect();
|
||||
let week_days: Vec<NaiveDate> = (0..7).map(|i| start_of_week + Duration::days(i)).collect();
|
||||
|
||||
// Drag state for event creation
|
||||
let drag_state = use_state(|| None::<DragState>);
|
||||
@@ -75,8 +83,11 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
let get_event_color = |event: &VEvent| -> String {
|
||||
if let Some(user_info) = &props.user_info {
|
||||
if let Some(calendar_path) = &event.calendar_path {
|
||||
if let Some(calendar) = user_info.calendars.iter()
|
||||
.find(|cal| &cal.path == calendar_path) {
|
||||
if let Some(calendar) = user_info
|
||||
.calendars
|
||||
.iter()
|
||||
.find(|cal| &cal.path == calendar_path)
|
||||
{
|
||||
return calendar.color.clone();
|
||||
}
|
||||
}
|
||||
@@ -85,22 +96,23 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
};
|
||||
|
||||
// Generate time labels - 24 hours plus the final midnight boundary
|
||||
let mut time_labels: Vec<String> = (0..24).map(|hour| {
|
||||
if hour == 0 {
|
||||
"12 AM".to_string()
|
||||
} else if hour < 12 {
|
||||
format!("{} AM", hour)
|
||||
} else if hour == 12 {
|
||||
"12 PM".to_string()
|
||||
} else {
|
||||
format!("{} PM", hour - 12)
|
||||
}
|
||||
}).collect();
|
||||
let mut time_labels: Vec<String> = (0..24)
|
||||
.map(|hour| {
|
||||
if hour == 0 {
|
||||
"12 AM".to_string()
|
||||
} else if hour < 12 {
|
||||
format!("{} AM", hour)
|
||||
} else if hour == 12 {
|
||||
"12 PM".to_string()
|
||||
} else {
|
||||
format!("{} PM", hour - 12)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Add the final midnight boundary to show where the day ends
|
||||
time_labels.push("12 AM".to_string());
|
||||
|
||||
|
||||
// Handlers for recurring event modification modal
|
||||
let on_recurring_choice = {
|
||||
let pending_recurring_edit = pending_recurring_edit.clone();
|
||||
@@ -141,16 +153,16 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
// 1. Add EXDATE to original series (excludes this occurrence)
|
||||
// 2. Create exception event with RECURRENCE-ID and user's modifications
|
||||
update_callback.emit((
|
||||
edit.event.clone(), // Original event (series to modify)
|
||||
edit.new_start, // Dragged start time for exception
|
||||
edit.new_end, // Dragged end time for exception
|
||||
true, // preserve_rrule = true
|
||||
None, // No until_date for this_only
|
||||
edit.event.clone(), // Original event (series to modify)
|
||||
edit.new_start, // Dragged start time for exception
|
||||
edit.new_end, // Dragged end time for exception
|
||||
true, // preserve_rrule = true
|
||||
None, // No until_date for this_only
|
||||
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 => {
|
||||
// RFC 5545 Compliant Series Splitting: "This and Future Events"
|
||||
//
|
||||
@@ -177,7 +189,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
if let Some(update_callback) = &on_event_update {
|
||||
// Find the original series event (not the occurrence)
|
||||
// UIDs like "uuid-timestamp" need to split on the last hyphen, not the first
|
||||
let base_uid = if let Some(last_hyphen_pos) = edit.event.uid.rfind('-') {
|
||||
let base_uid = if let Some(last_hyphen_pos) = edit.event.uid.rfind('-')
|
||||
{
|
||||
let suffix = &edit.event.uid[last_hyphen_pos + 1..];
|
||||
// Check if suffix is numeric (timestamp), if so remove it
|
||||
if suffix.chars().all(|c| c.is_numeric()) {
|
||||
@@ -189,7 +202,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
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
|
||||
let mut original_series = None;
|
||||
@@ -207,9 +226,12 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
|
||||
let original_series = match original_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
|
||||
},
|
||||
}
|
||||
None => {
|
||||
web_sys::console::log_1(&format!("⚠️ Could not find original series '{}', using occurrence but fixing UID", base_uid).into());
|
||||
let mut fallback_event = edit.event.clone();
|
||||
@@ -220,9 +242,15 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
};
|
||||
|
||||
// Calculate the day before this occurrence for UNTIL clause
|
||||
let until_date = edit.event.dtstart.date_naive() - chrono::Duration::days(1);
|
||||
let until_datetime = until_date.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
|
||||
let until_utc = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(until_datetime, chrono::Utc);
|
||||
let until_date =
|
||||
edit.event.dtstart.date_naive() - chrono::Duration::days(1);
|
||||
let until_datetime = until_date
|
||||
.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
|
||||
let until_utc =
|
||||
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
|
||||
until_datetime,
|
||||
chrono::Utc,
|
||||
);
|
||||
|
||||
web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",
|
||||
until_utc.format("%Y-%m-%d %H:%M:%S UTC"),
|
||||
@@ -243,24 +271,32 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
// 1. Add UNTIL clause to original series (stops before occurrence_date)
|
||||
// 2. Create new series starting from occurrence_date with dragged times
|
||||
update_callback.emit((
|
||||
original_series, // Original event to terminate
|
||||
new_start, // Dragged start time for new series
|
||||
new_end, // Dragged end time for new series
|
||||
true, // preserve_rrule = true
|
||||
Some(until_utc), // UNTIL date for original series
|
||||
original_series, // Original event to terminate
|
||||
new_start, // Dragged start time for new series
|
||||
new_end, // Dragged end time for new series
|
||||
true, // preserve_rrule = true
|
||||
Some(until_utc), // UNTIL date for original series
|
||||
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 => {
|
||||
// Modify the entire series
|
||||
let series_event = edit.event.clone();
|
||||
|
||||
if let Some(callback) = &on_event_update {
|
||||
callback.emit((series_event, edit.new_start, edit.new_end, true, None, 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);
|
||||
@@ -988,7 +1024,6 @@ fn pixels_to_time(pixels: f64) -> NaiveTime {
|
||||
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) {
|
||||
// Convert UTC times to local time for display
|
||||
let local_start = event.dtstart.with_timezone(&Local);
|
||||
@@ -1009,7 +1044,6 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool)
|
||||
let start_minute = local_start.minute() as f32;
|
||||
let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour
|
||||
|
||||
|
||||
// Calculate duration and height
|
||||
let duration_pixels = if let Some(end) = event.dtend {
|
||||
let local_end = end.with_timezone(&Local);
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use base64::prelude::*;
|
||||
|
||||
/// Configuration for CalDAV server connection and authentication.
|
||||
///
|
||||
/// This struct holds all the necessary information to connect to a CalDAV server,
|
||||
/// including server URL, credentials, and optional collection paths.
|
||||
///
|
||||
/// # Security Note
|
||||
///
|
||||
/// The password field contains sensitive information and should be handled carefully.
|
||||
/// This struct implements `Debug` but in production, consider implementing a custom
|
||||
/// `Debug` that masks the password field.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use crate::config::CalDAVConfig;
|
||||
///
|
||||
/// // Load configuration from environment variables
|
||||
/// let config = CalDAVConfig::from_env()?;
|
||||
///
|
||||
/// // Use the configuration for HTTP requests
|
||||
/// let auth_header = format!("Basic {}", config.get_basic_auth());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CalDAVConfig {
|
||||
/// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/")
|
||||
pub server_url: String,
|
||||
|
||||
/// Username for authentication with the CalDAV server
|
||||
pub username: String,
|
||||
|
||||
/// Password for authentication with the CalDAV server
|
||||
///
|
||||
/// **Security Note**: This contains sensitive information
|
||||
pub password: String,
|
||||
|
||||
/// Optional path to the calendar collection on the server
|
||||
///
|
||||
/// If not provided, the client will need to discover available calendars
|
||||
/// through CalDAV PROPFIND requests
|
||||
pub calendar_path: Option<String>,
|
||||
|
||||
/// Optional path to the tasks/todo collection on the server
|
||||
///
|
||||
/// Some CalDAV servers store tasks separately from calendar events
|
||||
pub tasks_path: Option<String>,
|
||||
}
|
||||
|
||||
impl CalDAVConfig {
|
||||
/// Creates a new CalDAVConfig by loading values from environment variables.
|
||||
///
|
||||
/// This method will attempt to load a `.env` file from the current directory
|
||||
/// and then read the following required environment variables:
|
||||
///
|
||||
/// - `CALDAV_SERVER_URL`: The CalDAV server base URL
|
||||
/// - `CALDAV_USERNAME`: Username for authentication
|
||||
/// - `CALDAV_PASSWORD`: Password for authentication
|
||||
///
|
||||
/// Optional environment variables:
|
||||
///
|
||||
/// - `CALDAV_CALENDAR_PATH`: Path to calendar collection
|
||||
/// - `CALDAV_TASKS_PATH`: Path to tasks collection
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ConfigError::MissingVar` if any required environment variable
|
||||
/// is not set or cannot be read.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use crate::config::CalDAVConfig;
|
||||
///
|
||||
/// match CalDAVConfig::from_env() {
|
||||
/// Ok(config) => {
|
||||
/// println!("Loaded config for server: {}", config.server_url);
|
||||
/// }
|
||||
/// Err(e) => {
|
||||
/// eprintln!("Failed to load config: {}", e);
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
// Attempt to load .env file, but don't fail if it doesn't exist
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let server_url = env::var("CALDAV_SERVER_URL")
|
||||
.map_err(|_| ConfigError::MissingVar("CALDAV_SERVER_URL".to_string()))?;
|
||||
|
||||
let username = env::var("CALDAV_USERNAME")
|
||||
.map_err(|_| ConfigError::MissingVar("CALDAV_USERNAME".to_string()))?;
|
||||
|
||||
let password = env::var("CALDAV_PASSWORD")
|
||||
.map_err(|_| ConfigError::MissingVar("CALDAV_PASSWORD".to_string()))?;
|
||||
|
||||
// Optional paths - it's fine if these are not set
|
||||
let calendar_path = env::var("CALDAV_CALENDAR_PATH").ok();
|
||||
let tasks_path = env::var("CALDAV_TASKS_PATH").ok();
|
||||
|
||||
Ok(CalDAVConfig {
|
||||
server_url,
|
||||
username,
|
||||
password,
|
||||
calendar_path,
|
||||
tasks_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Generates a Base64-encoded string for HTTP Basic Authentication.
|
||||
///
|
||||
/// This method combines the username and password in the format
|
||||
/// `username:password` and encodes it using Base64, which is the
|
||||
/// standard format for the `Authorization: Basic` HTTP header.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A Base64-encoded string that can be used directly in the
|
||||
/// `Authorization` header: `Authorization: Basic <returned_value>`
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use crate::config::CalDAVConfig;
|
||||
///
|
||||
/// let config = CalDAVConfig {
|
||||
/// server_url: "https://example.com".to_string(),
|
||||
/// username: "user".to_string(),
|
||||
/// password: "pass".to_string(),
|
||||
/// calendar_path: None,
|
||||
/// tasks_path: None,
|
||||
/// };
|
||||
///
|
||||
/// let auth_value = config.get_basic_auth();
|
||||
/// let auth_header = format!("Basic {}", auth_value);
|
||||
/// ```
|
||||
pub fn get_basic_auth(&self) -> String {
|
||||
let credentials = format!("{}:{}", self.username, self.password);
|
||||
BASE64_STANDARD.encode(&credentials)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur when loading or using CalDAV configuration.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
/// A required environment variable is missing or cannot be read.
|
||||
///
|
||||
/// This error occurs when calling `CalDAVConfig::from_env()` and one of the
|
||||
/// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`,
|
||||
/// or `CALDAV_PASSWORD`) is not set.
|
||||
#[error("Missing environment variable: {0}")]
|
||||
MissingVar(String),
|
||||
|
||||
/// The configuration contains invalid or malformed values.
|
||||
///
|
||||
/// This could include malformed URLs, invalid authentication credentials,
|
||||
/// or other configuration issues that prevent proper CalDAV operation.
|
||||
#[error("Invalid configuration: {0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_basic_auth_encoding() {
|
||||
let config = CalDAVConfig {
|
||||
server_url: "https://example.com".to_string(),
|
||||
username: "testuser".to_string(),
|
||||
password: "testpass".to_string(),
|
||||
calendar_path: None,
|
||||
tasks_path: None,
|
||||
};
|
||||
|
||||
let auth = config.get_basic_auth();
|
||||
let expected = BASE64_STANDARD.encode("testuser:testpass");
|
||||
assert_eq!(auth, expected);
|
||||
}
|
||||
|
||||
/// Integration test that authenticates with the actual Baikal CalDAV server
|
||||
///
|
||||
/// This test requires a valid .env file with:
|
||||
/// - CALDAV_SERVER_URL
|
||||
/// - CALDAV_USERNAME
|
||||
/// - CALDAV_PASSWORD
|
||||
///
|
||||
/// Run with: `cargo test test_baikal_auth`
|
||||
#[tokio::test]
|
||||
async fn test_baikal_auth() {
|
||||
// Load config from .env
|
||||
let config = CalDAVConfig::from_env()
|
||||
.expect("Failed to load CalDAV config from environment");
|
||||
|
||||
println!("Testing authentication to: {}", config.server_url);
|
||||
|
||||
// Create HTTP client
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Make a simple OPTIONS request to test authentication
|
||||
let response = client
|
||||
.request(reqwest::Method::OPTIONS, &config.server_url)
|
||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
||||
.header("User-Agent", "calendar-app/0.1.0")
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send request to CalDAV server");
|
||||
|
||||
println!("Response status: {}", response.status());
|
||||
println!("Response headers: {:#?}", response.headers());
|
||||
|
||||
// Check if we got a successful response or at least not a 401 Unauthorized
|
||||
assert!(
|
||||
response.status().is_success() || response.status() != 401,
|
||||
"Authentication failed with status: {}. Check your credentials in .env",
|
||||
response.status()
|
||||
);
|
||||
|
||||
// For Baikal/CalDAV servers, we should see DAV headers
|
||||
assert!(
|
||||
response.headers().contains_key("dav") ||
|
||||
response.headers().contains_key("DAV") ||
|
||||
response.status().is_success(),
|
||||
"Server doesn't appear to be a CalDAV server - missing DAV headers"
|
||||
);
|
||||
|
||||
println!("✓ Authentication test passed!");
|
||||
}
|
||||
|
||||
/// Test making a PROPFIND request to discover calendars
|
||||
///
|
||||
/// This test requires a valid .env file and makes an actual CalDAV PROPFIND request
|
||||
///
|
||||
/// Run with: `cargo test test_propfind_calendars`
|
||||
#[tokio::test]
|
||||
async fn test_propfind_calendars() {
|
||||
let config = CalDAVConfig::from_env()
|
||||
.expect("Failed to load CalDAV config from environment");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// CalDAV PROPFIND request to discover calendars
|
||||
let propfind_body = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<d:resourcetype />
|
||||
<d:displayname />
|
||||
<c:calendar-description />
|
||||
<c:supported-calendar-component-set />
|
||||
</d:prop>
|
||||
</d:propfind>"#;
|
||||
|
||||
let response = client
|
||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url)
|
||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
||||
.header("Content-Type", "application/xml")
|
||||
.header("Depth", "1")
|
||||
.header("User-Agent", "calendar-app/0.1.0")
|
||||
.body(propfind_body)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to send PROPFIND request");
|
||||
|
||||
let status = response.status();
|
||||
println!("PROPFIND Response status: {}", status);
|
||||
|
||||
let body = response.text().await.expect("Failed to read response body");
|
||||
println!("PROPFIND Response body: {}", body);
|
||||
|
||||
// We should get a 207 Multi-Status for PROPFIND
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::from_u16(207).unwrap(),
|
||||
"PROPFIND should return 207 Multi-Status"
|
||||
);
|
||||
|
||||
// The response should contain XML with calendar information
|
||||
assert!(body.contains("calendar"), "Response should contain calendar information");
|
||||
|
||||
println!("✓ PROPFIND calendars test passed!");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
mod app;
|
||||
mod auth;
|
||||
mod components;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
pub mod calendar_service;
|
||||
pub mod preferences;
|
||||
|
||||
pub use calendar_service::CalendarService;
|
||||
pub use preferences::PreferencesService;
|
||||
|
||||
177
frontend/src/services/preferences.rs
Normal file
177
frontend/src/services/preferences.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{Request, RequestInit, RequestMode, Response};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UserPreferences {
|
||||
pub calendar_selected_date: Option<String>,
|
||||
pub calendar_time_increment: Option<i32>,
|
||||
pub calendar_view_mode: Option<String>,
|
||||
pub calendar_theme: Option<String>,
|
||||
pub calendar_colors: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UpdatePreferencesRequest {
|
||||
pub calendar_selected_date: Option<String>,
|
||||
pub calendar_time_increment: Option<i32>,
|
||||
pub calendar_view_mode: Option<String>,
|
||||
pub calendar_theme: Option<String>,
|
||||
pub calendar_colors: Option<String>,
|
||||
}
|
||||
|
||||
pub struct PreferencesService {
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl PreferencesService {
|
||||
pub fn new() -> Self {
|
||||
let base_url = option_env!("BACKEND_API_URL")
|
||||
.unwrap_or("http://localhost:3000/api")
|
||||
.to_string();
|
||||
|
||||
Self { base_url }
|
||||
}
|
||||
|
||||
/// Load preferences from LocalStorage (cached from login)
|
||||
pub fn load_cached() -> Option<UserPreferences> {
|
||||
if let Ok(prefs_json) = LocalStorage::get::<String>("user_preferences") {
|
||||
serde_json::from_str(&prefs_json).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a single preference field and sync with backend
|
||||
pub async fn update_preference(&self, field: &str, value: serde_json::Value) -> Result<(), String> {
|
||||
// Get session token
|
||||
let session_token = LocalStorage::get::<String>("session_token")
|
||||
.map_err(|_| "No session token found".to_string())?;
|
||||
|
||||
// Load current preferences
|
||||
let mut preferences = Self::load_cached().unwrap_or(UserPreferences {
|
||||
calendar_selected_date: None,
|
||||
calendar_time_increment: None,
|
||||
calendar_view_mode: None,
|
||||
calendar_theme: None,
|
||||
calendar_colors: None,
|
||||
});
|
||||
|
||||
// Update the specific field
|
||||
match field {
|
||||
"calendar_selected_date" => {
|
||||
preferences.calendar_selected_date = value.as_str().map(|s| s.to_string());
|
||||
}
|
||||
"calendar_time_increment" => {
|
||||
preferences.calendar_time_increment = value.as_i64().map(|i| i as i32);
|
||||
}
|
||||
"calendar_view_mode" => {
|
||||
preferences.calendar_view_mode = value.as_str().map(|s| s.to_string());
|
||||
}
|
||||
"calendar_theme" => {
|
||||
preferences.calendar_theme = value.as_str().map(|s| s.to_string());
|
||||
}
|
||||
"calendar_colors" => {
|
||||
preferences.calendar_colors = value.as_str().map(|s| s.to_string());
|
||||
}
|
||||
_ => return Err(format!("Unknown preference field: {}", field)),
|
||||
}
|
||||
|
||||
// Save to LocalStorage cache
|
||||
if let Ok(prefs_json) = serde_json::to_string(&preferences) {
|
||||
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
||||
}
|
||||
|
||||
// Sync with backend
|
||||
let request = UpdatePreferencesRequest {
|
||||
calendar_selected_date: preferences.calendar_selected_date.clone(),
|
||||
calendar_time_increment: preferences.calendar_time_increment,
|
||||
calendar_view_mode: preferences.calendar_view_mode.clone(),
|
||||
calendar_theme: preferences.calendar_theme.clone(),
|
||||
calendar_colors: preferences.calendar_colors.clone(),
|
||||
};
|
||||
|
||||
self.sync_preferences(&session_token, &request).await
|
||||
}
|
||||
|
||||
/// Sync all preferences with backend
|
||||
async fn sync_preferences(
|
||||
&self,
|
||||
session_token: &str,
|
||||
request: &UpdatePreferencesRequest,
|
||||
) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("No global window exists")?;
|
||||
|
||||
let json_body = serde_json::to_string(request)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
|
||||
let opts = RequestInit::new();
|
||||
opts.set_method("POST");
|
||||
opts.set_mode(RequestMode::Cors);
|
||||
opts.set_body(&wasm_bindgen::JsValue::from_str(&json_body));
|
||||
|
||||
let url = format!("{}/preferences", self.base_url);
|
||||
let request = Request::new_with_str_and_init(&url, &opts)
|
||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||
|
||||
request
|
||||
.headers()
|
||||
.set("Content-Type", "application/json")
|
||||
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||
|
||||
request
|
||||
.headers()
|
||||
.set("X-Session-Token", session_token)
|
||||
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||
|
||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||
.await
|
||||
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||
|
||||
let resp: Response = resp_value
|
||||
.dyn_into()
|
||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||
|
||||
if resp.ok() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Failed to update preferences: {}", resp.status()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate preferences from LocalStorage to backend (on first login after update)
|
||||
pub async fn migrate_from_local_storage(&self) -> Result<(), String> {
|
||||
let session_token = LocalStorage::get::<String>("session_token")
|
||||
.map_err(|_| "No session token found".to_string())?;
|
||||
|
||||
let mut request = UpdatePreferencesRequest {
|
||||
calendar_selected_date: LocalStorage::get::<String>("calendar_selected_date").ok(),
|
||||
calendar_time_increment: LocalStorage::get::<u32>("calendar_time_increment").ok().map(|i| i as i32),
|
||||
calendar_view_mode: LocalStorage::get::<String>("calendar_view_mode").ok(),
|
||||
calendar_theme: LocalStorage::get::<String>("calendar_theme").ok(),
|
||||
calendar_colors: LocalStorage::get::<String>("calendar_colors").ok(),
|
||||
};
|
||||
|
||||
// Only migrate if we have some preferences to migrate
|
||||
if request.calendar_selected_date.is_some()
|
||||
|| request.calendar_time_increment.is_some()
|
||||
|| request.calendar_view_mode.is_some()
|
||||
|| request.calendar_theme.is_some()
|
||||
|| request.calendar_colors.is_some()
|
||||
{
|
||||
self.sync_preferences(&session_token, &request).await?;
|
||||
|
||||
// Clear old LocalStorage entries after successful migration
|
||||
let _ = LocalStorage::delete("calendar_selected_date");
|
||||
let _ = LocalStorage::delete("calendar_time_increment");
|
||||
let _ = LocalStorage::delete("calendar_view_mode");
|
||||
let _ = LocalStorage::delete("calendar_theme");
|
||||
let _ = LocalStorage::delete("calendar_colors");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -289,6 +289,30 @@ body {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.remember-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.375rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.remember-checkbox input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
.remember-checkbox label {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.login-button, .register-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
|
||||
Reference in New Issue
Block a user