Compare commits
47 Commits
d36609d8c2
...
bugfix/sql
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd6e9c3619 | ||
|
|
d8c3997f24 | ||
|
|
e44d49e190 | ||
|
|
0453763c98 | ||
|
|
03c0011445 | ||
|
|
79f287ed61 | ||
|
|
e55e6bf4dd | ||
| 1fa3bf44b6 | |||
|
|
51d5552156 | ||
|
|
5a12c0e0d0 | ||
|
|
ee181cf6cb | ||
|
|
74d636117d | ||
|
|
ed458e6c3a | ||
|
|
9b9378477a | ||
|
|
1b4a26e31a | ||
|
|
45c32a6d1e | ||
|
|
63968280b8 | ||
|
|
3ccf31f479 | ||
|
|
c599598390 | ||
|
|
d0aa6fda08 | ||
|
|
62c39b8aa5 | ||
|
|
75eddcf85d | ||
|
|
0babfc90f4 | ||
|
|
7538054b20 | ||
|
|
117dd2cc75 | ||
|
|
b9e8778f8f | ||
|
|
9536158f58 | ||
|
|
783e13eb10 | ||
|
|
1794cf9a59 | ||
|
|
ee1c6ee299 | ||
|
|
a6aac42c78 | ||
|
|
071fc3099f | ||
|
|
78f1db7203 | ||
|
|
e21430f6ff | ||
|
|
b195208ddc | ||
|
|
5cb77235da | ||
|
|
a6d72ce37f | ||
|
|
663b322d97 | ||
|
|
15f2d0c6d9 | ||
|
|
6887e0b389 | ||
|
|
f266d3f304 | ||
|
|
4af4aafd98 | ||
|
|
81805289e4 | ||
|
|
9f2f58e23e | ||
|
|
53ea5e3fc1 | ||
|
|
d35fc11267 | ||
|
|
697eb64dd4 |
@@ -1,6 +1,9 @@
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
target/
|
target/
|
||||||
dist/
|
frontend/dist/
|
||||||
|
backend/target/
|
||||||
|
# Allow backend binary for multi-stage builds
|
||||||
|
!backend/target/release/backend
|
||||||
|
|
||||||
# Git
|
# Git
|
||||||
.git/
|
.git/
|
||||||
@@ -21,8 +24,18 @@ Thumbs.db
|
|||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
README.md
|
README.md
|
||||||
*.md
|
|
||||||
|
|
||||||
# Docker
|
# Development files
|
||||||
Dockerfile
|
CLAUDE.md
|
||||||
.dockerignore
|
*.txt
|
||||||
|
test_*.js
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
calendar.db
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
**/tests/
|
||||||
|
|
||||||
|
# Migrations (not needed for builds)
|
||||||
|
migrations/
|
||||||
34
.gitea/workflows/docker.yml
Normal file
34
.gitea/workflows/docker.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Docker Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ secrets.DOCKER_REGISTRY }}
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ secrets.DOCKER_REGISTRY }}/calendar:latest
|
||||||
|
${{ secrets.DOCKER_REGISTRY }}/calendar:${{ github.sha }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -16,4 +16,15 @@ dist/
|
|||||||
# Environment variables (secrets)
|
# Environment variables (secrets)
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# Development notes (keep local)
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
data/
|
||||||
|
|
||||||
|
# SQLite database
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
calendar.db
|
||||||
|
|||||||
10
Caddyfile
Normal file
10
Caddyfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
default_sni rcjohnstone.com
|
||||||
|
key_type rsa4096
|
||||||
|
email c@rcjohnstone.com
|
||||||
|
}
|
||||||
|
|
||||||
|
:80, :443 {
|
||||||
|
root * /srv/www
|
||||||
|
file_server
|
||||||
|
}
|
||||||
73
Cargo.toml
73
Cargo.toml
@@ -1,65 +1,14 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "calendar-app"
|
members = [
|
||||||
version = "0.1.0"
|
"frontend",
|
||||||
edition = "2021"
|
"backend",
|
||||||
|
"calendar-models"
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
# Frontend binary only
|
[workspace.dependencies]
|
||||||
|
calendar-models = { path = "calendar-models" }
|
||||||
[dependencies]
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
yew = { version = "0.21", features = ["csr"] }
|
|
||||||
web-sys = { version = "0.3", features = [
|
|
||||||
"console",
|
|
||||||
"HtmlSelectElement",
|
|
||||||
"HtmlInputElement",
|
|
||||||
"HtmlTextAreaElement",
|
|
||||||
"Event",
|
|
||||||
"MouseEvent",
|
|
||||||
"InputEvent",
|
|
||||||
"Element",
|
|
||||||
"Document",
|
|
||||||
"Window",
|
|
||||||
"Location",
|
|
||||||
"Headers",
|
|
||||||
"Request",
|
|
||||||
"RequestInit",
|
|
||||||
"RequestMode",
|
|
||||||
"Response",
|
|
||||||
] }
|
|
||||||
wasm-bindgen = "0.2"
|
|
||||||
|
|
||||||
# HTTP client for CalDAV requests
|
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
|
||||||
# Calendar and iCal parsing
|
|
||||||
ical = "0.7"
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
# Date and time handling
|
|
||||||
chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] }
|
|
||||||
chrono-tz = "0.8"
|
|
||||||
|
|
||||||
# Error handling
|
|
||||||
anyhow = "1.0"
|
|
||||||
thiserror = "1.0"
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
log = "0.4"
|
|
||||||
console_log = "1.0"
|
|
||||||
|
|
||||||
# UUID generation for calendar events
|
|
||||||
uuid = { version = "1.0", features = ["v4", "wasm-bindgen", "serde"] }
|
|
||||||
getrandom = { version = "0.2", features = ["js"] }
|
|
||||||
|
|
||||||
# Environment variable handling
|
|
||||||
dotenvy = "0.15"
|
|
||||||
base64 = "0.21"
|
|
||||||
|
|
||||||
# XML/Regex parsing
|
|
||||||
regex = "1.0"
|
|
||||||
|
|
||||||
# Yew routing and local storage (WASM only)
|
|
||||||
yew-router = "0.18"
|
|
||||||
gloo-storage = "0.3"
|
|
||||||
gloo-timers = "0.3"
|
|
||||||
wasm-bindgen-futures = "0.4"
|
|
||||||
|
|
||||||
136
Dockerfile
136
Dockerfile
@@ -1,67 +1,109 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
# ---------------------------------------
|
# -----------------------------------------------------------
|
||||||
FROM rust:alpine AS builder
|
FROM rust:alpine AS builder
|
||||||
|
|
||||||
# Install build dependencies
|
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static nodejs npm
|
||||||
RUN apk add --no-cache \
|
|
||||||
musl-dev \
|
|
||||||
pkgconfig \
|
|
||||||
openssl-dev \
|
|
||||||
nodejs \
|
|
||||||
npm
|
|
||||||
|
|
||||||
# Install trunk for building Yew apps
|
# Install trunk ahead of the compilation. This may break and then you'll have to update the version.
|
||||||
RUN cargo install trunk wasm-pack
|
RUN cargo install trunk@0.21.14 wasm-pack@0.13.1 wasm-bindgen-cli@0.2.100
|
||||||
|
|
||||||
# Add wasm32 target
|
|
||||||
RUN rustup target add wasm32-unknown-unknown
|
RUN rustup target add wasm32-unknown-unknown
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy dependency files
|
# Copy workspace files to maintain workspace structure
|
||||||
COPY Cargo.toml ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY src ./src
|
COPY calendar-models ./calendar-models
|
||||||
|
COPY frontend/Cargo.toml ./frontend/
|
||||||
|
COPY frontend/Trunk.toml ./frontend/
|
||||||
|
COPY frontend/index.html ./frontend/
|
||||||
|
COPY frontend/styles.css ./frontend/
|
||||||
|
|
||||||
# Copy web assets
|
# Create empty backend directory to satisfy workspace
|
||||||
COPY index.html ./
|
RUN mkdir -p backend/src && \
|
||||||
COPY Trunk.toml ./
|
printf '[package]\nname = "calendar-backend"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' > backend/Cargo.toml && \
|
||||||
|
echo 'fn main() {}' > backend/src/main.rs
|
||||||
|
|
||||||
|
# Create dummy source files to build dependencies first
|
||||||
|
RUN mkdir -p frontend/src && \
|
||||||
|
echo "use web_sys::*; fn main() {}" > frontend/src/main.rs && \
|
||||||
|
echo "pub fn add(a: usize, b: usize) -> usize { a + b }" > calendar-models/src/lib.rs
|
||||||
|
|
||||||
|
# Build dependencies (this layer will be cached unless dependencies change)
|
||||||
|
RUN cargo build --release --target wasm32-unknown-unknown --bin calendar-app
|
||||||
|
|
||||||
|
# Copy actual source code and build the frontend application
|
||||||
|
RUN rm -rf frontend
|
||||||
|
COPY frontend ./frontend
|
||||||
|
RUN trunk build --release --config ./frontend/Trunk.toml
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Backend build stage
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
FROM rust:alpine AS backend-builder
|
||||||
|
|
||||||
|
# Install build dependencies for backend
|
||||||
|
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
|
||||||
|
|
||||||
|
# Create empty frontend directory to satisfy workspace
|
||||||
|
RUN mkdir -p frontend/src && \
|
||||||
|
printf '[package]\nname = "calendar-app"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' > frontend/Cargo.toml && \
|
||||||
|
echo 'fn main() {}' > frontend/src/main.rs
|
||||||
|
|
||||||
|
# Create dummy backend source to build dependencies first
|
||||||
|
RUN mkdir -p backend/src && \
|
||||||
|
echo "fn main() {}" > backend/src/main.rs
|
||||||
|
|
||||||
|
# Build dependencies (this layer will be cached unless dependencies change)
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY backend/Cargo.toml ./backend/
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
# Build the backend
|
||||||
|
COPY backend ./backend
|
||||||
|
RUN cargo build --release --bin backend
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN trunk build --release
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
# ---------------------------------------
|
# -----------------------------------------------------------
|
||||||
FROM docker.io/nginx:alpine
|
FROM alpine:latest
|
||||||
|
|
||||||
# Remove default nginx content
|
# Install runtime dependencies
|
||||||
RUN rm -rf /usr/share/nginx/html/*
|
RUN apk add --no-cache ca-certificates tzdata sqlite
|
||||||
|
|
||||||
# Copy built application from builder stage
|
# Copy frontend files to temporary location
|
||||||
COPY --from=builder /app/dist/* /usr/share/nginx/html/
|
COPY --from=builder /app/frontend/dist /app/frontend-dist
|
||||||
|
|
||||||
# Add nginx configuration for SPA
|
# Copy backend binary and sqlx-cli
|
||||||
RUN echo 'server { \
|
COPY --from=backend-builder /app/target/release/backend /usr/local/bin/backend
|
||||||
listen 80; \
|
COPY --from=backend-builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx
|
||||||
server_name localhost; \
|
|
||||||
root /usr/share/nginx/html; \
|
|
||||||
index index.html; \
|
|
||||||
location / { \
|
|
||||||
try_files $uri $uri/ /index.html; \
|
|
||||||
} \
|
|
||||||
# Enable gzip compression \
|
|
||||||
gzip on; \
|
|
||||||
gzip_types text/css application/javascript application/wasm; \
|
|
||||||
}' > /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
# Expose port
|
# Copy migrations for database setup
|
||||||
EXPOSE 80
|
COPY --from=backend-builder /app/backend/migrations /migrations
|
||||||
|
|
||||||
# Health check
|
# Create startup script to copy frontend files, run migrations, and start backend
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
RUN mkdir -p /srv/www /db
|
||||||
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
|
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
|
||||||
|
|
||||||
# Start nginx
|
# Start with script that copies frontend files then starts backend
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["/usr/local/bin/start.sh"]
|
||||||
|
|||||||
164
README.md
164
README.md
@@ -1,46 +1,119 @@
|
|||||||
# Calendar App
|
# Modern CalDAV Web Client
|
||||||
|
|
||||||
A full-stack calendar application built with Rust, featuring a Yew frontend and Axum backend with CalDAV integration.
|
>[!WARNING]
|
||||||
|
>This project was entirely vibe coded. It's my first attempt vibe coding anything, but I just sat down one day and realized this was something I've wanted to build for many years, but would just take way too long. With AI, I've been able to lay out the majority of the app in one long weekend. So proceed at your own risk, but I actually think the app is pretty solid.
|
||||||
|
|
||||||
|
A full-stack calendar application built with Rust, featuring a modern web interface for CalDAV calendar management.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
While there are many excellent self-hosted CalDAV server implementations (Nextcloud, Radicale, Baikal, etc.), the web client ecosystem remains limited. Existing solutions like [AgenDAV](https://github.com/agendav/agendav) often suffer from outdated interfaces, bugs, and poor user experience. This project aims to provide a modern, fast, and reliable web interface for CalDAV servers.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Interactive calendar interface
|
### Calendar Management
|
||||||
- Event creation and management
|
- **Interactive Calendar Views**: Month and week views with intuitive navigation
|
||||||
- CalDAV server integration
|
- **Event Creation & Editing**: Comprehensive event forms with all standard iCalendar properties
|
||||||
- User authentication with JWT
|
- **Drag & Drop**: Move events between dates and times with automatic timezone conversion
|
||||||
- iCal format support
|
- **CalDAV Integration**: Full bidirectional sync with any RFC-compliant CalDAV server
|
||||||
- Weekly recurrence patterns
|
|
||||||
- Responsive web design
|
### Recurring Events
|
||||||
|
- **RFC 5545 Compliance**: Complete RRULE support with proper parsing and generation
|
||||||
|
- **Flexible Patterns**: Daily, weekly, monthly, and yearly recurrence with custom intervals
|
||||||
|
- **Advanced Options**: BYDAY rules, COUNT limits, UNTIL dates, and exception handling
|
||||||
|
- **Series Management**: Edit entire series or "this and future" events with proper UNTIL handling
|
||||||
|
|
||||||
|
### Modern Web Experience
|
||||||
|
- **Fast & Responsive**: Rust WebAssembly frontend for native-like performance
|
||||||
|
- **Clean Interface**: Modern, intuitive design built with web standards
|
||||||
|
- **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
|
## Architecture
|
||||||
|
|
||||||
- **Frontend**: Yew (Rust WebAssembly)
|
### Frontend (Yew WebAssembly)
|
||||||
- **Backend**: Axum (Rust async web framework)
|
- **Framework**: Yew for component-based UI development
|
||||||
- **Protocol**: CalDAV for calendar synchronization
|
- **Performance**: Rust WebAssembly for near-native browser performance
|
||||||
- **Database**: SQLite (via migrations)
|
- **Models**: RFC 5545-compliant VEvent structures throughout
|
||||||
- **Build Tool**: Trunk for frontend bundling
|
- **Services**: HTTP client for backend API communication
|
||||||
|
- **Views**: Responsive month/week calendar views with drag-and-drop
|
||||||
|
|
||||||
|
### Backend (Axum)
|
||||||
|
- **Framework**: Axum async web framework with CORS support
|
||||||
|
- **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
|
||||||
|
|
||||||
|
### Key Technical Features
|
||||||
|
- **RFC 5545 Compliance**: Complete iCalendar standard implementation
|
||||||
|
- **RRULE Processing**: Advanced recurrence rule parsing and generation
|
||||||
|
- **Timezone Handling**: Local time in UI, UTC for storage and CalDAV sync
|
||||||
|
- **Event Series**: Proper handling of recurring event modifications and exceptions
|
||||||
|
- **Build System**: Trunk for frontend bundling, Cargo workspaces for organization
|
||||||
|
|
||||||
## Getting Started
|
## 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)
|
- Rust (latest stable version)
|
||||||
- Trunk (`cargo install trunk`)
|
- Trunk (`cargo install trunk`)
|
||||||
|
|
||||||
### Development
|
#### Local Development
|
||||||
|
|
||||||
1. Start the backend server:
|
1. **Start the backend server** (serves API at http://localhost:3000):
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cargo run --manifest-path=backend/Cargo.toml
|
||||||
cargo run
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Start the frontend development server:
|
2. **Start the frontend development server** (serves at http://localhost:8080):
|
||||||
```bash
|
```bash
|
||||||
trunk serve
|
trunk serve
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Open your browser to `http://localhost:8080`
|
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
|
### Building for Production
|
||||||
|
|
||||||
@@ -48,9 +121,50 @@ A full-stack calendar application built with Rust, featuring a Yew frontend and
|
|||||||
trunk build --release
|
trunk build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Development Commands
|
||||||
|
|
||||||
|
- `cargo check` - Check frontend compilation
|
||||||
|
- `cargo check --manifest-path=backend/Cargo.toml` - Check backend compilation
|
||||||
|
- `trunk serve` - Start frontend development server with hot reload
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
- `src/` - Frontend Yew application
|
```
|
||||||
- `backend/` - Axum backend server
|
calendar/
|
||||||
- `migrations/` - Database schema migrations
|
├── frontend/ # Yew WebAssembly frontend
|
||||||
- `dist/` - Built frontend assets
|
│ ├── src/
|
||||||
|
│ │ ├── app.rs # Main app component with routing
|
||||||
|
│ │ ├── components/ # UI components
|
||||||
|
│ │ │ ├── calendar.rs # Main calendar container
|
||||||
|
│ │ │ ├── month_view.rs # Month calendar view
|
||||||
|
│ │ │ ├── week_view.rs # Week calendar view
|
||||||
|
│ │ │ ├── create_event_modal.rs # Event creation form
|
||||||
|
│ │ │ └── ...
|
||||||
|
│ │ ├── models/
|
||||||
|
│ │ │ └── ical.rs # RFC 5545 VEvent structures
|
||||||
|
│ │ └── services/
|
||||||
|
│ │ └── calendar_service.rs # HTTP client & RRULE logic
|
||||||
|
│ ├── index.html # HTML template
|
||||||
|
│ └── Trunk.toml # Frontend build config
|
||||||
|
├── backend/ # Axum REST API server
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.rs # Server entry point
|
||||||
|
│ ├── handlers/ # API endpoint handlers
|
||||||
|
│ │ ├── events.rs # Event CRUD operations
|
||||||
|
│ │ └── series.rs # Recurring event operations
|
||||||
|
│ ├── auth.rs # JWT authentication
|
||||||
|
│ └── calendar.rs # CalDAV client implementation
|
||||||
|
└── CLAUDE.md # Development instructions
|
||||||
|
```
|
||||||
|
|
||||||
|
## CalDAV Compatibility
|
||||||
|
|
||||||
|
This client is designed to work with any RFC-compliant CalDAV server:
|
||||||
|
|
||||||
|
- **Baikal** - ✅ Fully tested with complete event and recurrence support
|
||||||
|
- **Nextcloud** - 🚧 Planned compatibility with calendar app
|
||||||
|
- **Radicale** - 🚧 Planned lightweight CalDAV server support
|
||||||
|
- **Apple Calendar Server** - 🚧 Planned standards-compliant operation
|
||||||
|
- **Google Calendar** - 🚧 Planned CalDAV API compatibility
|
||||||
|
|
||||||
|
*Note: While the client follows RFC standards and should work with any compliant CalDAV server, we have currently only tested extensively with Baikal. Testing with other servers is planned.*
|
||||||
|
|||||||
16
Trunk.toml
16
Trunk.toml
@@ -1,16 +0,0 @@
|
|||||||
[build]
|
|
||||||
target = "index.html"
|
|
||||||
dist = "dist"
|
|
||||||
|
|
||||||
[env]
|
|
||||||
BACKEND_API_URL = "http://localhost:3000/api"
|
|
||||||
|
|
||||||
[watch]
|
|
||||||
watch = ["src", "Cargo.toml", "styles.css", "index.html"]
|
|
||||||
ignore = ["backend/"]
|
|
||||||
|
|
||||||
[serve]
|
|
||||||
address = "127.0.0.1"
|
|
||||||
port = 8080
|
|
||||||
open = false
|
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ name = "backend"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
calendar-models = { workspace = true }
|
||||||
|
|
||||||
# Backend authentication dependencies
|
# Backend authentication dependencies
|
||||||
jsonwebtoken = "9.0"
|
jsonwebtoken = "9.0"
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
@@ -30,6 +32,14 @@ regex = "1.0"
|
|||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
base64 = "0.21"
|
base64 = "0.21"
|
||||||
thiserror = "1.0"
|
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]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.0", features = ["macros", "rt"] }
|
tokio = { version = "1.0", features = ["macros", "rt"] }
|
||||||
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
hyper = "1.0"
|
||||||
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 chrono::{Duration, Utc};
|
||||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{CalDAVLoginRequest, AuthResponse, ApiError};
|
|
||||||
use crate::config::CalDAVConfig;
|
|
||||||
use crate::calendar::CalDAVClient;
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
pub exp: i64, // Expiration time
|
pub exp: i64, // Expiration time
|
||||||
pub iat: i64, // Issued at
|
pub iat: i64, // Issued at
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AuthService {
|
pub struct AuthService {
|
||||||
jwt_secret: String,
|
jwt_secret: String,
|
||||||
|
db: Database,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthService {
|
impl AuthService {
|
||||||
pub fn new(jwt_secret: String) -> Self {
|
pub fn new(jwt_secret: String, db: Database) -> Self {
|
||||||
Self { jwt_secret }
|
Self { jwt_secret, db }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authenticate user directly against CalDAV server
|
/// Authenticate user directly against CalDAV server
|
||||||
@@ -31,36 +34,73 @@ impl AuthService {
|
|||||||
println!("✅ Input validation passed");
|
println!("✅ Input validation passed");
|
||||||
|
|
||||||
// Create CalDAV config with provided credentials
|
// Create CalDAV config with provided credentials
|
||||||
let caldav_config = CalDAVConfig {
|
let caldav_config = CalDAVConfig::new(
|
||||||
server_url: request.server_url.clone(),
|
request.server_url.clone(),
|
||||||
username: request.username.clone(),
|
request.username.clone(),
|
||||||
password: request.password.clone(),
|
request.password.clone(),
|
||||||
calendar_path: None,
|
);
|
||||||
tasks_path: None,
|
|
||||||
};
|
|
||||||
println!("📝 Created CalDAV config");
|
println!("📝 Created CalDAV config");
|
||||||
|
|
||||||
// Test authentication against CalDAV server
|
// Test authentication against CalDAV server
|
||||||
let caldav_client = CalDAVClient::new(caldav_config.clone());
|
let caldav_client = CalDAVClient::new(caldav_config.clone());
|
||||||
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
||||||
|
|
||||||
// Try to discover calendars as an authentication test
|
// Try to discover calendars as an authentication test
|
||||||
match caldav_client.discover_calendars().await {
|
match caldav_client.discover_calendars().await {
|
||||||
Ok(calendars) => {
|
Ok(calendars) => {
|
||||||
println!("✅ Authentication successful! Found {} calendars", calendars.len());
|
println!(
|
||||||
// Authentication successful, generate JWT token
|
"✅ Authentication successful! Found {} calendars",
|
||||||
let token = self.generate_token(&request.username, &request.server_url)?;
|
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 {
|
Ok(AuthResponse {
|
||||||
token,
|
token: jwt_token,
|
||||||
|
session_token,
|
||||||
username: request.username,
|
username: request.username,
|
||||||
server_url: request.server_url,
|
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) => {
|
Err(err) => {
|
||||||
println!("❌ Authentication failed: {:?}", err);
|
println!("❌ Authentication failed: {:?}", err);
|
||||||
// Authentication failed
|
// Authentication failed
|
||||||
Err(ApiError::Unauthorized("Invalid CalDAV credentials or server unavailable".to_string()))
|
Err(ApiError::Unauthorized(
|
||||||
|
"Invalid CalDAV credentials or server unavailable".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,16 +111,18 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create CalDAV config from token
|
/// Create CalDAV config from token
|
||||||
pub fn caldav_config_from_token(&self, token: &str, password: &str) -> Result<CalDAVConfig, ApiError> {
|
pub fn caldav_config_from_token(
|
||||||
|
&self,
|
||||||
|
token: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<CalDAVConfig, ApiError> {
|
||||||
let claims = self.verify_token(token)?;
|
let claims = self.verify_token(token)?;
|
||||||
|
|
||||||
Ok(CalDAVConfig {
|
Ok(CalDAVConfig::new(
|
||||||
server_url: claims.server_url,
|
claims.server_url,
|
||||||
username: claims.username,
|
claims.username,
|
||||||
password: password.to_string(),
|
password.to_string(),
|
||||||
calendar_path: None,
|
))
|
||||||
tasks_path: None,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> {
|
fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> {
|
||||||
@@ -97,14 +139,17 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Basic URL validation
|
// Basic URL validation
|
||||||
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://") {
|
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://")
|
||||||
return Err(ApiError::BadRequest("Server URL must start with http:// or https://".to_string()));
|
{
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"Server URL must start with http:// or https://".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_token(&self, username: &str, server_url: &str) -> Result<String, ApiError> {
|
pub fn generate_token(&self, username: &str, server_url: &str) -> Result<String, ApiError> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let expires_at = now + Duration::hours(24); // Token valid for 24 hours
|
let expires_at = now + Duration::hours(24); // Token valid for 24 hours
|
||||||
|
|
||||||
@@ -135,4 +180,33 @@ impl AuthService {
|
|||||||
|
|
||||||
Ok(token_data.claims)
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,30 @@
|
|||||||
|
use base64::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::env;
|
use std::env;
|
||||||
use base64::prelude::*;
|
|
||||||
|
|
||||||
/// Configuration for CalDAV server connection and authentication.
|
/// Configuration for CalDAV server connection and authentication.
|
||||||
///
|
///
|
||||||
/// This struct holds all the necessary information to connect to a CalDAV server,
|
/// This struct holds all the necessary information to connect to a CalDAV server,
|
||||||
/// including server URL, credentials, and optional collection paths.
|
/// including server URL, credentials, and optional collection paths.
|
||||||
///
|
///
|
||||||
/// # Security Note
|
/// # Security Note
|
||||||
///
|
///
|
||||||
/// The password field contains sensitive information and should be handled carefully.
|
/// The password field contains sensitive information and should be handled carefully.
|
||||||
/// This struct implements `Debug` but in production, consider implementing a custom
|
/// This struct implements `Debug` but in production, consider implementing a custom
|
||||||
/// `Debug` that masks the password field.
|
/// `Debug` that masks the password field.
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// use crate::config::CalDAVConfig;
|
/// # use calendar_backend::config::CalDAVConfig;
|
||||||
///
|
/// let config = CalDAVConfig {
|
||||||
/// // Load configuration from environment variables
|
/// server_url: "https://caldav.example.com".to_string(),
|
||||||
/// let config = CalDAVConfig::from_env()?;
|
/// username: "user@example.com".to_string(),
|
||||||
///
|
/// password: "password".to_string(),
|
||||||
|
/// calendar_path: None,
|
||||||
|
/// tasks_path: None,
|
||||||
|
/// };
|
||||||
|
///
|
||||||
/// // Use the configuration for HTTP requests
|
/// // Use the configuration for HTTP requests
|
||||||
/// let auth_header = format!("Basic {}", config.get_basic_auth());
|
/// let auth_header = format!("Basic {}", config.get_basic_auth());
|
||||||
/// ```
|
/// ```
|
||||||
@@ -28,103 +32,66 @@ use base64::prelude::*;
|
|||||||
pub struct CalDAVConfig {
|
pub struct CalDAVConfig {
|
||||||
/// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/")
|
/// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/")
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
|
|
||||||
/// Username for authentication with the CalDAV server
|
/// Username for authentication with the CalDAV server
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
|
||||||
/// Password for authentication with the CalDAV server
|
/// Password for authentication with the CalDAV server
|
||||||
///
|
///
|
||||||
/// **Security Note**: This contains sensitive information
|
/// **Security Note**: This contains sensitive information
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
|
||||||
/// Optional path to the calendar collection on the server
|
/// Optional path to the calendar collection on the server
|
||||||
///
|
///
|
||||||
/// If not provided, the client will need to discover available calendars
|
/// If not provided, the client will discover available calendars
|
||||||
/// through CalDAV PROPFIND requests
|
/// through CalDAV PROPFIND requests
|
||||||
pub calendar_path: Option<String>,
|
pub calendar_path: Option<String>,
|
||||||
|
|
||||||
/// 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 {
|
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
|
/// # Arguments
|
||||||
/// and then read the following required environment variables:
|
///
|
||||||
///
|
/// * `server_url` - The base URL of the CalDAV server
|
||||||
/// - `CALDAV_SERVER_URL`: The CalDAV server base URL
|
/// * `username` - Username for authentication
|
||||||
/// - `CALDAV_USERNAME`: Username for authentication
|
/// * `password` - Password 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
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// use crate::config::CalDAVConfig;
|
/// # use calendar_backend::config::CalDAVConfig;
|
||||||
///
|
/// let config = CalDAVConfig::new(
|
||||||
/// match CalDAVConfig::from_env() {
|
/// "https://caldav.example.com".to_string(),
|
||||||
/// Ok(config) => {
|
/// "user@example.com".to_string(),
|
||||||
/// println!("Loaded config for server: {}", config.server_url);
|
/// "password".to_string()
|
||||||
/// }
|
/// );
|
||||||
/// Err(e) => {
|
|
||||||
/// eprintln!("Failed to load config: {}", e);
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
/// ```
|
||||||
pub fn from_env() -> Result<Self, ConfigError> {
|
pub fn new(server_url: String, username: String, password: String) -> Self {
|
||||||
// Attempt to load .env file, but don't fail if it doesn't exist
|
Self {
|
||||||
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,
|
server_url,
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
calendar_path,
|
calendar_path: env::var("CALDAV_CALENDAR_PATH").ok(), // Optional override from env
|
||||||
tasks_path,
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a Base64-encoded string for HTTP Basic Authentication.
|
/// Generates a Base64-encoded string for HTTP Basic Authentication.
|
||||||
///
|
///
|
||||||
/// This method combines the username and password in the format
|
/// This method combines the username and password in the format
|
||||||
/// `username:password` and encodes it using Base64, which is the
|
/// `username:password` and encodes it using Base64, which is the
|
||||||
/// standard format for the `Authorization: Basic` HTTP header.
|
/// standard format for the `Authorization: Basic` HTTP header.
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// A Base64-encoded string that can be used directly in the
|
/// A Base64-encoded string that can be used directly in the
|
||||||
/// `Authorization` header: `Authorization: Basic <returned_value>`
|
/// `Authorization` header: `Authorization: Basic <returned_value>`
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// use crate::config::CalDAVConfig;
|
/// # use calendar_backend::config::CalDAVConfig;
|
||||||
///
|
///
|
||||||
/// let config = CalDAVConfig {
|
/// let config = CalDAVConfig {
|
||||||
/// server_url: "https://example.com".to_string(),
|
/// server_url: "https://example.com".to_string(),
|
||||||
/// username: "user".to_string(),
|
/// username: "user".to_string(),
|
||||||
@@ -132,7 +99,7 @@ impl CalDAVConfig {
|
|||||||
/// calendar_path: None,
|
/// calendar_path: None,
|
||||||
/// tasks_path: None,
|
/// tasks_path: None,
|
||||||
/// };
|
/// };
|
||||||
///
|
///
|
||||||
/// let auth_value = config.get_basic_auth();
|
/// let auth_value = config.get_basic_auth();
|
||||||
/// let auth_header = format!("Basic {}", auth_value);
|
/// let auth_header = format!("Basic {}", auth_value);
|
||||||
/// ```
|
/// ```
|
||||||
@@ -146,15 +113,15 @@ impl CalDAVConfig {
|
|||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ConfigError {
|
pub enum ConfigError {
|
||||||
/// A required environment variable is missing or cannot be read.
|
/// A required environment variable is missing or cannot be read.
|
||||||
///
|
///
|
||||||
/// This error occurs when calling `CalDAVConfig::from_env()` and one of the
|
/// This error occurs when calling `CalDAVConfig::from_env()` and one of the
|
||||||
/// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`,
|
/// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`,
|
||||||
/// or `CALDAV_PASSWORD`) is not set.
|
/// or `CALDAV_PASSWORD`) is not set.
|
||||||
#[error("Missing environment variable: {0}")]
|
#[error("Missing environment variable: {0}")]
|
||||||
MissingVar(String),
|
MissingVar(String),
|
||||||
|
|
||||||
/// The configuration contains invalid or malformed values.
|
/// The configuration contains invalid or malformed values.
|
||||||
///
|
///
|
||||||
/// This could include malformed URLs, invalid authentication credentials,
|
/// This could include malformed URLs, invalid authentication credentials,
|
||||||
/// or other configuration issues that prevent proper CalDAV operation.
|
/// or other configuration issues that prevent proper CalDAV operation.
|
||||||
#[error("Invalid configuration: {0}")]
|
#[error("Invalid configuration: {0}")]
|
||||||
@@ -172,7 +139,6 @@ mod tests {
|
|||||||
username: "testuser".to_string(),
|
username: "testuser".to_string(),
|
||||||
password: "testpass".to_string(),
|
password: "testpass".to_string(),
|
||||||
calendar_path: None,
|
calendar_path: None,
|
||||||
tasks_path: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth = config.get_basic_auth();
|
let auth = config.get_basic_auth();
|
||||||
@@ -181,18 +147,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Integration test that authenticates with the actual Baikal CalDAV server
|
/// Integration test that authenticates with the actual Baikal CalDAV server
|
||||||
///
|
///
|
||||||
/// This test requires a valid .env file with:
|
/// This test requires a valid .env file with:
|
||||||
/// - CALDAV_SERVER_URL
|
/// - CALDAV_SERVER_URL
|
||||||
/// - CALDAV_USERNAME
|
/// - CALDAV_USERNAME
|
||||||
/// - CALDAV_PASSWORD
|
/// - CALDAV_PASSWORD
|
||||||
///
|
///
|
||||||
/// Run with: `cargo test test_baikal_auth`
|
/// Run with: `cargo test test_baikal_auth`
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_baikal_auth() {
|
async fn test_baikal_auth() {
|
||||||
// Load config from .env
|
// Use test config - update these values to test with real server
|
||||||
let config = CalDAVConfig::from_env()
|
let config = CalDAVConfig::new(
|
||||||
.expect("Failed to load CalDAV config from environment");
|
"https://example.com".to_string(),
|
||||||
|
"test_user".to_string(),
|
||||||
|
"test_password".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
println!("Testing authentication to: {}", config.server_url);
|
println!("Testing authentication to: {}", config.server_url);
|
||||||
|
|
||||||
@@ -202,7 +171,10 @@ mod tests {
|
|||||||
// Make a simple OPTIONS request to test authentication
|
// Make a simple OPTIONS request to test authentication
|
||||||
let response = client
|
let response = client
|
||||||
.request(reqwest::Method::OPTIONS, &config.server_url)
|
.request(reqwest::Method::OPTIONS, &config.server_url)
|
||||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -220,9 +192,9 @@ mod tests {
|
|||||||
|
|
||||||
// For Baikal/CalDAV servers, we should see DAV headers
|
// For Baikal/CalDAV servers, we should see DAV headers
|
||||||
assert!(
|
assert!(
|
||||||
response.headers().contains_key("dav") ||
|
response.headers().contains_key("dav")
|
||||||
response.headers().contains_key("DAV") ||
|
|| response.headers().contains_key("DAV")
|
||||||
response.status().is_success(),
|
|| response.status().is_success(),
|
||||||
"Server doesn't appear to be a CalDAV server - missing DAV headers"
|
"Server doesn't appear to be a CalDAV server - missing DAV headers"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -230,14 +202,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Test making a PROPFIND request to discover calendars
|
/// Test making a PROPFIND request to discover calendars
|
||||||
///
|
///
|
||||||
/// This test requires a valid .env file and makes an actual CalDAV PROPFIND request
|
/// This test requires a valid .env file and makes an actual CalDAV PROPFIND request
|
||||||
///
|
///
|
||||||
/// Run with: `cargo test test_propfind_calendars`
|
/// Run with: `cargo test test_propfind_calendars`
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_propfind_calendars() {
|
async fn test_propfind_calendars() {
|
||||||
let config = CalDAVConfig::from_env()
|
// Use test config - update these values to test with real server
|
||||||
.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 = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
@@ -253,8 +229,14 @@ mod tests {
|
|||||||
</d:propfind>"#;
|
</d:propfind>"#;
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url)
|
.request(
|
||||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
reqwest::Method::from_bytes(b"PROPFIND").unwrap(),
|
||||||
|
&config.server_url,
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("Content-Type", "application/xml")
|
.header("Content-Type", "application/xml")
|
||||||
.header("Depth", "1")
|
.header("Depth", "1")
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
@@ -265,7 +247,7 @@ mod tests {
|
|||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
println!("PROPFIND Response status: {}", status);
|
println!("PROPFIND Response status: {}", status);
|
||||||
|
|
||||||
let body = response.text().await.expect("Failed to read response body");
|
let body = response.text().await.expect("Failed to read response body");
|
||||||
println!("PROPFIND Response body: {}", body);
|
println!("PROPFIND Response body: {}", body);
|
||||||
|
|
||||||
@@ -277,8 +259,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// The response should contain XML with calendar information
|
// The response should contain XML with calendar information
|
||||||
assert!(body.contains("calendar"), "Response should contain calendar information");
|
assert!(
|
||||||
|
body.contains("calendar"),
|
||||||
|
"Response should contain calendar information"
|
||||||
|
);
|
||||||
|
|
||||||
println!("✓ PROPFIND calendars test passed!");
|
println!("✓ PROPFIND calendars test passed!");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
use crate::config::CalDAVConfig;
|
||||||
|
|
||||||
pub async fn debug_caldav_fetch() -> Result<(), Box<dyn std::error::Error>> {
|
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);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
println!("=== DEBUG: CalDAV Fetch ===");
|
println!("=== DEBUG: CalDAV Fetch ===");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
144
backend/src/handlers/auth.rs
Normal file
144
backend/src/handlers/auth.rs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
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")
|
||||||
|
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
|
||||||
|
|
||||||
|
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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
|
||||||
|
let password_header = headers
|
||||||
|
.get("x-caldav-password")
|
||||||
|
.ok_or_else(|| ApiError::BadRequest("Missing X-CalDAV-Password header".to_string()))?;
|
||||||
|
|
||||||
|
password_header
|
||||||
|
.to_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(request): Json<CalDAVLoginRequest>,
|
||||||
|
) -> Result<Json<AuthResponse>, ApiError> {
|
||||||
|
println!("🔐 Login attempt:");
|
||||||
|
println!(" Server URL: {}", request.server_url);
|
||||||
|
println!(" Username: {}", request.username);
|
||||||
|
println!(" Password length: {}", request.password.len());
|
||||||
|
|
||||||
|
// Use the auth service login method which now handles database, sessions, and preferences
|
||||||
|
let response = state.auth_service.login(request).await?;
|
||||||
|
|
||||||
|
println!("✅ Login successful with session management");
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn verify_token(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let is_valid = state.auth_service.verify_token(&token).is_ok();
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "valid": is_valid })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_info(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<UserInfo>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
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 client = CalDAVClient::new(config.clone());
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
Ok(Json(UserInfo {
|
||||||
|
username: config.username,
|
||||||
|
server_url: config.server_url,
|
||||||
|
calendars,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_calendar_color(path: &str) -> String {
|
||||||
|
// Generate a consistent color based on the calendar path
|
||||||
|
// This is a simple hash-based approach
|
||||||
|
let mut hash: u32 = 0;
|
||||||
|
for byte in path.bytes() {
|
||||||
|
hash = hash.wrapping_mul(31).wrapping_add(byte as u32);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
];
|
||||||
|
|
||||||
|
colors[(hash as usize) % colors.len()].to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_calendar_name(path: &str) -> String {
|
||||||
|
// Extract calendar name from path
|
||||||
|
// E.g., "/calendars/user/calendar-name/" -> "Calendar Name"
|
||||||
|
path.split('/')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.last()
|
||||||
|
.unwrap_or("Calendar")
|
||||||
|
.replace('-', " ")
|
||||||
|
.replace('_', " ")
|
||||||
|
.split_whitespace()
|
||||||
|
.map(|word| {
|
||||||
|
let mut chars = word.chars();
|
||||||
|
match chars.next() {
|
||||||
|
None => String::new(),
|
||||||
|
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
94
backend/src/handlers/calendar.rs
Normal file
94
backend/src/handlers/calendar.rs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::calendar::CalDAVClient;
|
||||||
|
use crate::{
|
||||||
|
models::{
|
||||||
|
ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest,
|
||||||
|
DeleteCalendarResponse,
|
||||||
|
},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::auth::{extract_bearer_token, extract_password_header};
|
||||||
|
|
||||||
|
pub async fn create_calendar(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(request): Json<CreateCalendarRequest>,
|
||||||
|
) -> Result<Json<CreateCalendarResponse>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if request.name.trim().is_empty() {
|
||||||
|
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 client = CalDAVClient::new(config);
|
||||||
|
|
||||||
|
// Create calendar on CalDAV server
|
||||||
|
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
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_calendar(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(request): Json<DeleteCalendarRequest>,
|
||||||
|
) -> Result<Json<DeleteCalendarResponse>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if request.path.trim().is_empty() {
|
||||||
|
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 client = CalDAVClient::new(config);
|
||||||
|
|
||||||
|
// Delete calendar on CalDAV server
|
||||||
|
match client.delete_calendar(&request.path).await {
|
||||||
|
Ok(_) => Ok(Json(DeleteCalendarResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Calendar deleted successfully".to_string(),
|
||||||
|
})),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to delete calendar: {}", e);
|
||||||
|
Err(ApiError::Internal(format!(
|
||||||
|
"Failed to delete calendar: {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
799
backend/src/handlers/events.rs
Normal file
799
backend/src/handlers/events.rs
Normal file
@@ -0,0 +1,799 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
response::Json,
|
||||||
|
};
|
||||||
|
use chrono::Datelike;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
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};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CalendarQuery {
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub month: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_calendar_events(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Query(params): Query<CalendarQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Vec<CalendarEvent>>, ApiError> {
|
||||||
|
// Extract and verify token
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let password = extract_password_header(&headers)?;
|
||||||
|
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 client = CalDAVClient::new(config);
|
||||||
|
|
||||||
|
// Discover calendars if needed
|
||||||
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
|
if calendar_paths.is_empty() {
|
||||||
|
return Ok(Json(vec![])); // No calendars found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch events from all calendars
|
||||||
|
let mut all_events = Vec::new();
|
||||||
|
for calendar_path in &calendar_paths {
|
||||||
|
match client.fetch_events(calendar_path).await {
|
||||||
|
Ok(mut events) => {
|
||||||
|
// Set calendar_path for each event to identify which calendar it belongs to
|
||||||
|
for event in &mut events {
|
||||||
|
event.calendar_path = Some(calendar_path.clone());
|
||||||
|
}
|
||||||
|
all_events.extend(events);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Failed to fetch events from calendar {}: {}",
|
||||||
|
calendar_path, e
|
||||||
|
);
|
||||||
|
// Continue with other calendars instead of failing completely
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If year and month are specified, filter events
|
||||||
|
if let (Some(year), Some(month)) = (params.year, params.month) {
|
||||||
|
all_events.retain(|event| {
|
||||||
|
let event_year = event.dtstart.year();
|
||||||
|
let event_month = event.dtstart.month();
|
||||||
|
event_year == year && event_month == month
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("📅 Returning {} events", all_events.len());
|
||||||
|
Ok(Json(all_events))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh_event(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(uid): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
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 client = CalDAVClient::new(config);
|
||||||
|
|
||||||
|
// Discover calendars
|
||||||
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
|
// Search for the event by UID across all calendars
|
||||||
|
for calendar_path in &calendar_paths {
|
||||||
|
if let Ok(Some(mut event)) = client.fetch_event_by_uid(calendar_path, &uid).await {
|
||||||
|
event.calendar_path = Some(calendar_path.clone());
|
||||||
|
return Ok(Json(Some(event)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
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<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
// First try to match by exact href
|
||||||
|
for event in &events {
|
||||||
|
if let Some(stored_href) = &event.href {
|
||||||
|
if stored_href == event_href {
|
||||||
|
println!("✅ Found matching event by exact href: {}", event.uid);
|
||||||
|
return Ok(Some(event.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try to match by UID extracted from href filename
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
if event.uid == uid_from_href {
|
||||||
|
println!("✅ Found matching event by UID: {}", event.uid);
|
||||||
|
return Ok(Some(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("❌ No matching event found for href: {}", event_href);
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_event(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(request): Json<DeleteEventRequest>,
|
||||||
|
) -> Result<Json<DeleteEventResponse>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
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 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)))?
|
||||||
|
{
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
// 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")
|
||||||
|
{
|
||||||
|
// 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)));
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the event with the new EXDATE
|
||||||
|
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
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
println!("✅ Successfully updated recurring event with EXDATE");
|
||||||
|
|
||||||
|
Ok(Json(DeleteEventResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Single occurrence deleted successfully".to_string(),
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Err(ApiError::BadRequest("Occurrence date is required for single occurrence deletion of recurring events".to_string()))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-recurring event - delete the entire event
|
||||||
|
println!("🗑️ Deleting non-recurring event: {}", event.uid);
|
||||||
|
|
||||||
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to delete event: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
println!("✅ Successfully deleted non-recurring event");
|
||||||
|
|
||||||
|
Ok(Json(DeleteEventResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Event deleted successfully".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} 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(occurrence_date) = &request.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")
|
||||||
|
{
|
||||||
|
// 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
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 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)
|
||||||
|
.await
|
||||||
|
.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(),
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
// No RRULE, just delete the single event
|
||||||
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to delete event: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(DeleteEventResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Event deleted successfully".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(DeleteEventResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Event deleted successfully".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_event(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract and verify token
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if request.title.trim().is_empty() {
|
||||||
|
return Err(ApiError::BadRequest("Event title is required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.title.len() > 200 {
|
||||||
|
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 client = CalDAVClient::new(config);
|
||||||
|
|
||||||
|
// Determine which calendar to use
|
||||||
|
let calendar_path = if let Some(path) = request.calendar_path {
|
||||||
|
path
|
||||||
|
} else {
|
||||||
|
// Use the first available calendar
|
||||||
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
|
if calendar_paths.is_empty() {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"No calendars available for event creation".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
calendar_paths[0].clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique UID for the event
|
||||||
|
let uid = format!(
|
||||||
|
"{}-{}",
|
||||||
|
uuid::Uuid::new_v4(),
|
||||||
|
chrono::Utc::now().timestamp()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse status
|
||||||
|
let status = match request.status.to_lowercase().as_str() {
|
||||||
|
"tentative" => EventStatus::Tentative,
|
||||||
|
"cancelled" => EventStatus::Cancelled,
|
||||||
|
_ => EventStatus::Confirmed,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse class
|
||||||
|
let class = match request.class.to_lowercase().as_str() {
|
||||||
|
"private" => EventClass::Private,
|
||||||
|
"confidential" => EventClass::Confidential,
|
||||||
|
_ => EventClass::Public,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse attendees (comma-separated email list)
|
||||||
|
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
request
|
||||||
|
.attendees
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse categories (comma-separated list)
|
||||||
|
let categories: Vec<String> = if request.categories.trim().is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
request
|
||||||
|
.categories
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse alarms - convert from minutes string to EventReminder structs
|
||||||
|
let alarms: Vec<crate::calendar::EventReminder> = if request.reminder.trim().is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
match request.reminder.parse::<i32>() {
|
||||||
|
Ok(minutes) => vec![crate::calendar::EventReminder {
|
||||||
|
minutes_before: minutes,
|
||||||
|
action: crate::calendar::ReminderAction::Display,
|
||||||
|
description: None,
|
||||||
|
}],
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if recurrence is already a full RRULE or just a simple type
|
||||||
|
let rrule = if request.recurrence.starts_with("FREQ=") {
|
||||||
|
// Frontend sent a complete RRULE string, use it directly
|
||||||
|
if request.recurrence.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.recurrence.clone())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy path: Parse recurrence with BYDAY support for weekly recurrence
|
||||||
|
match request.recurrence.to_uppercase().as_str() {
|
||||||
|
"DAILY" => Some("FREQ=DAILY".to_string()),
|
||||||
|
"WEEKLY" => {
|
||||||
|
// Handle weekly recurrence with optional BYDAY parameter
|
||||||
|
let mut rrule = "FREQ=WEEKLY".to_string();
|
||||||
|
|
||||||
|
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
|
||||||
|
if request.recurrence_days.len() == 7 {
|
||||||
|
let selected_days: Vec<&str> = request
|
||||||
|
.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
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.status = Some(status);
|
||||||
|
event.class = Some(class);
|
||||||
|
event.priority = request.priority;
|
||||||
|
event.organizer = if request.organizer.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(CalendarUser {
|
||||||
|
cal_address: request.organizer,
|
||||||
|
common_name: None,
|
||||||
|
dir_entry_ref: None,
|
||||||
|
sent_by: None,
|
||||||
|
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.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.calendar_path = Some(calendar_path.clone());
|
||||||
|
|
||||||
|
// Create the event on the CalDAV server
|
||||||
|
let event_href = client
|
||||||
|
.create_event(&calendar_path, &event)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"✅ Event created successfully with UID: {} at href: {}",
|
||||||
|
event.uid, event_href
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(CreateEventResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Event created successfully".to_string(),
|
||||||
|
event_href: Some(event_href),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_event(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(request): Json<UpdateEventRequest>,
|
||||||
|
) -> Result<Json<UpdateEventResponse>, ApiError> {
|
||||||
|
// Handle update request
|
||||||
|
|
||||||
|
// Extract and verify token
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if request.uid.trim().is_empty() {
|
||||||
|
return Err(ApiError::BadRequest("Event UID is required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.title.trim().is_empty() {
|
||||||
|
return Err(ApiError::BadRequest("Event title is required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.title.len() > 200 {
|
||||||
|
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 client = CalDAVClient::new(config);
|
||||||
|
|
||||||
|
// Find the event across all calendars (or in the specified calendar)
|
||||||
|
let calendar_paths = if let Some(path) = &request.calendar_path {
|
||||||
|
vec![path.clone()]
|
||||||
|
} else {
|
||||||
|
client
|
||||||
|
.discover_calendars()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, event_href)
|
||||||
|
|
||||||
|
for calendar_path in &calendar_paths {
|
||||||
|
match client.fetch_events(calendar_path).await {
|
||||||
|
Ok(events) => {
|
||||||
|
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));
|
||||||
|
println!("🔍 Found event {} with href: {}", event.uid, event_href);
|
||||||
|
found_event = Some((event, calendar_path.clone(), event_href));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found_event.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Failed to fetch events from calendar {}: {}",
|
||||||
|
calendar_path, e
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (mut event, calendar_path, event_href) = found_event
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", 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 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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.all_day = request.all_day;
|
||||||
|
|
||||||
|
// Parse and update status
|
||||||
|
event.status = Some(match request.status.to_lowercase().as_str() {
|
||||||
|
"tentative" => EventStatus::Tentative,
|
||||||
|
"cancelled" => EventStatus::Cancelled,
|
||||||
|
_ => EventStatus::Confirmed,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse and update class
|
||||||
|
event.class = Some(match request.class.to_lowercase().as_str() {
|
||||||
|
"private" => EventClass::Private,
|
||||||
|
"confidential" => EventClass::Confidential,
|
||||||
|
_ => EventClass::Public,
|
||||||
|
});
|
||||||
|
|
||||||
|
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)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?;
|
||||||
|
|
||||||
|
println!("✅ Successfully updated event {}", event.uid);
|
||||||
|
|
||||||
|
Ok(Json(UpdateEventResponse {
|
||||||
|
success: true,
|
||||||
|
message: "Event updated successfully".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
|
||||||
|
|
||||||
|
if all_day {
|
||||||
|
// For all-day events, use midnight UTC
|
||||||
|
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 {
|
||||||
|
// Parse the time
|
||||||
|
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
|
||||||
|
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
|
||||||
|
|
||||||
|
// Combine date and time
|
||||||
|
let datetime = NaiveDateTime::new(date, time);
|
||||||
|
|
||||||
|
// Assume local time and convert to UTC (in a real app, you'd want timezone support)
|
||||||
|
Ok(Utc.from_utc_datetime(&datetime))
|
||||||
|
}
|
||||||
|
}
|
||||||
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"
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
1156
backend/src/handlers/series.rs
Normal file
1156
backend/src/handlers/series.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,33 +3,43 @@ use axum::{
|
|||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tower_http::cors::{CorsLayer, Any};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
mod auth;
|
pub mod auth;
|
||||||
mod models;
|
pub mod calendar;
|
||||||
mod handlers;
|
pub mod config;
|
||||||
mod calendar;
|
pub mod db;
|
||||||
mod config;
|
pub mod handlers;
|
||||||
|
pub mod models;
|
||||||
|
|
||||||
use auth::AuthService;
|
use auth::AuthService;
|
||||||
|
use db::Database;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub auth_service: AuthService,
|
pub auth_service: AuthService,
|
||||||
|
pub db: Database,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
println!("🚀 Starting Calendar Backend Server");
|
println!("🚀 Starting Calendar Backend Server");
|
||||||
|
|
||||||
|
// 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
|
// Create auth service
|
||||||
let jwt_secret = std::env::var("JWT_SECRET")
|
let jwt_secret = std::env::var("JWT_SECRET")
|
||||||
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
|
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
|
||||||
|
|
||||||
let auth_service = AuthService::new(jwt_secret);
|
let auth_service = AuthService::new(jwt_secret, db.clone());
|
||||||
|
|
||||||
let app_state = AppState { auth_service };
|
let app_state = AppState { auth_service, db };
|
||||||
|
|
||||||
// Build our application with routes
|
// Build our application with routes
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
@@ -45,6 +55,23 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.route("/api/calendar/events/update", post(handlers::update_event))
|
.route("/api/calendar/events/update", post(handlers::update_event))
|
||||||
.route("/api/calendar/events/delete", post(handlers::delete_event))
|
.route("/api/calendar/events/delete", post(handlers::delete_event))
|
||||||
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
|
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
|
||||||
|
// Event series-specific endpoints
|
||||||
|
.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(
|
.layer(
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
@@ -56,7 +83,7 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Start server
|
// Start server
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
||||||
println!("📡 Server listening on http://0.0.0.0:3000");
|
println!("📡 Server listening on http://0.0.0.0:3000");
|
||||||
|
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -72,4 +99,4 @@ async fn health_check() -> Json<serde_json::Value> {
|
|||||||
"service": "calendar-backend",
|
"service": "calendar-backend",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ use calendar_backend::*;
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
run_server().await
|
run_server().await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,28 @@ pub struct CalDAVLoginRequest {
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct AuthResponse {
|
pub struct AuthResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
pub session_token: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub server_url: 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)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -76,21 +96,21 @@ pub struct DeleteEventResponse {
|
|||||||
pub struct CreateEventRequest {
|
pub struct CreateEventRequest {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
pub recurrence: String, // recurrence type
|
pub recurrence: String, // recurrence type
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,25 +123,28 @@ pub struct CreateEventResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateEventRequest {
|
pub struct UpdateEventRequest {
|
||||||
pub uid: String, // Event UID to identify which event to update
|
pub uid: String, // Event UID to identify which event to update
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
pub recurrence: String, // recurrence type
|
pub recurrence: String, // recurrence type
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
|
pub update_action: Option<String>, // "update_series" for recurring events
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub until_date: Option<String>, // ISO datetime string for RRULE UNTIL clause
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -130,6 +153,103 @@ pub struct UpdateEventResponse {
|
|||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== EVENT SERIES MODELS ====================
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
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 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
|
||||||
|
|
||||||
|
// Series-specific fields
|
||||||
|
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)
|
||||||
|
pub recurrence_count: Option<u32>, // Number of occurrences
|
||||||
|
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CreateEventSeriesResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub series_uid: Option<String>, // The base UID for the series
|
||||||
|
pub occurrences_created: Option<u32>, // Number of individual events created
|
||||||
|
pub event_href: Option<String>, // The created series' href/filename
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateEventSeriesRequest {
|
||||||
|
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 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
|
||||||
|
|
||||||
|
// Series-specific fields
|
||||||
|
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)
|
||||||
|
pub recurrence_count: Option<u32>, // Number of occurrences
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct UpdateEventSeriesResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub series_uid: Option<String>,
|
||||||
|
pub occurrences_affected: Option<u32>, // Number of events updated
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DeleteEventSeriesRequest {
|
||||||
|
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 occurrence_date: Option<String>, // ISO date string for specific occurrence being deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DeleteEventSeriesResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub occurrences_affected: Option<u32>, // Number of events deleted
|
||||||
|
}
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ApiError {
|
pub enum ApiError {
|
||||||
@@ -174,4 +294,4 @@ impl std::fmt::Display for ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for ApiError {}
|
impl std::error::Error for ApiError {}
|
||||||
|
|||||||
726
backend/tests/integration_tests.rs
Normal file
726
backend/tests/integration_tests.rs
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
use axum::{
|
||||||
|
response::Json,
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
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 {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub struct TestServer {
|
||||||
|
pub base_url: String,
|
||||||
|
pub client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestServer {
|
||||||
|
pub async fn start() -> Self {
|
||||||
|
// Create auth service
|
||||||
|
let jwt_secret = "test-secret-key-for-integration-tests".to_string();
|
||||||
|
let auth_service = AuthService::new(jwt_secret);
|
||||||
|
let app_state = AppState { auth_service };
|
||||||
|
|
||||||
|
// Build application with routes
|
||||||
|
let app = Router::new()
|
||||||
|
.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),
|
||||||
|
)
|
||||||
|
// 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),
|
||||||
|
)
|
||||||
|
.layer(
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any),
|
||||||
|
)
|
||||||
|
.with_state(Arc::new(app_state));
|
||||||
|
|
||||||
|
// Start server on a random port
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let base_url = format!("http://127.0.0.1:{}", addr.port());
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for server to start
|
||||||
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
TestServer { base_url, client }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(&self) -> String {
|
||||||
|
let login_payload = json!({
|
||||||
|
"username": "test".to_string(),
|
||||||
|
"password": "test".to_string(),
|
||||||
|
"server_url": "https://example.com".to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
|
||||||
|
let login_response: serde_json::Value = response.json().await.unwrap();
|
||||||
|
login_response["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("Login response should contain token")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn root() -> &'static str {
|
||||||
|
"Calendar Backend API v0.1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_check() -> Json<serde_json::Value> {
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"status": "healthy",
|
||||||
|
"service": "calendar-backend",
|
||||||
|
"version": "0.1.0"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
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
|
||||||
|
.get(&format!("{}/api/health", server.base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
let health_response: serde_json::Value = response.json().await.unwrap();
|
||||||
|
assert_eq!(health_response["status"], "healthy");
|
||||||
|
assert_eq!(health_response["service"], "calendar-backend");
|
||||||
|
|
||||||
|
println!("✓ Health endpoint test passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test authentication login endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_auth_login() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
"password": password,
|
||||||
|
"server_url": server_url
|
||||||
|
});
|
||||||
|
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("✓ Authentication login test passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test authentication verify endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_auth_verify() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.get(&format!("{}/api/auth/verify", server.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
let verify_response: serde_json::Value = response.json().await.unwrap();
|
||||||
|
assert!(verify_response["valid"].as_bool().unwrap_or(false));
|
||||||
|
|
||||||
|
println!("✓ Authentication verify test passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test user info endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_user_info() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
// Load password from env for CalDAV requests
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let password = "test".to_string();
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.header("X-CalDAV-Password", password)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Note: This might fail if CalDAV server discovery fails, which can happen
|
||||||
|
if response.status().is_success() {
|
||||||
|
let user_info: serde_json::Value = response.json().await.unwrap();
|
||||||
|
assert!(user_info["username"].is_string());
|
||||||
|
println!("✓ User info test passed");
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"⚠ User info test skipped (CalDAV server issues): {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test calendar events listing endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_calendar_events() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
// Load password from env for CalDAV requests
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let password = "test".to_string();
|
||||||
|
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test event creation endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_event() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
// Load password from env for CalDAV requests
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let password = "test".to_string();
|
||||||
|
|
||||||
|
let create_payload = json!({
|
||||||
|
"title": "Integration Test Event",
|
||||||
|
"description": "Created by integration test",
|
||||||
|
"start_date": "2024-12-25",
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_date": "2024-12-25",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"location": "Test Location",
|
||||||
|
"all_day": false,
|
||||||
|
"status": "confirmed",
|
||||||
|
"class": "public",
|
||||||
|
"priority": 5,
|
||||||
|
"organizer": "test@example.com",
|
||||||
|
"attendees": "",
|
||||||
|
"categories": "test",
|
||||||
|
"reminder": "15min",
|
||||||
|
"recurrence": "none",
|
||||||
|
"recurrence_days": [false, false, false, false, false, false, false]
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.post(&format!("{}/api/calendar/events/create", server.base_url))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.header("X-CalDAV-Password", password)
|
||||||
|
.json(&create_payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
println!("Create event response status: {}", status);
|
||||||
|
|
||||||
|
// Note: This might fail if CalDAV server is not accessible, which is expected in CI
|
||||||
|
if status.is_success() {
|
||||||
|
let create_response: serde_json::Value = response.json().await.unwrap();
|
||||||
|
assert!(create_response["success"].as_bool().unwrap_or(false));
|
||||||
|
println!("✓ Create event test passed");
|
||||||
|
} else {
|
||||||
|
println!("⚠ Create event test skipped (CalDAV server not accessible)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test event refresh endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_refresh_event() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
// Load password from env for CalDAV requests
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
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
|
||||||
|
))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.header("X-CalDAV-Password", password)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.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()
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("✓ Refresh event endpoint test passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test invalid authentication
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_invalid_auth() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
|
.header("Authorization", "Bearer invalid-token")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.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()
|
||||||
|
);
|
||||||
|
println!("✓ Invalid authentication test passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test missing authentication
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_missing_auth() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 401);
|
||||||
|
println!("✓ Missing authentication test passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== EVENT SERIES TESTS ====================
|
||||||
|
|
||||||
|
/// Test event series creation endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_event_series() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
// Load password from env for CalDAV requests
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let password = "test".to_string();
|
||||||
|
|
||||||
|
let create_payload = json!({
|
||||||
|
"title": "Integration Test Series",
|
||||||
|
"description": "Created by integration test for series",
|
||||||
|
"start_date": "2024-12-25",
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_date": "2024-12-25",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"location": "Test Series Location",
|
||||||
|
"all_day": false,
|
||||||
|
"status": "confirmed",
|
||||||
|
"class": "public",
|
||||||
|
"priority": 5,
|
||||||
|
"organizer": "test@example.com",
|
||||||
|
"attendees": "",
|
||||||
|
"categories": "test-series",
|
||||||
|
"reminder": "15min",
|
||||||
|
"recurrence": "weekly",
|
||||||
|
"recurrence_days": [false, true, false, false, false, false, false], // Monday only
|
||||||
|
"recurrence_interval": 1,
|
||||||
|
"recurrence_count": 4,
|
||||||
|
"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
|
||||||
|
))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.header("X-CalDAV-Password", password)
|
||||||
|
.json(&create_payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
println!("Create series response status: {}", status);
|
||||||
|
|
||||||
|
// Note: This might fail if CalDAV server is not accessible, which is expected in CI
|
||||||
|
if status.is_success() {
|
||||||
|
let create_response: serde_json::Value = response.json().await.unwrap();
|
||||||
|
assert!(create_response["success"].as_bool().unwrap_or(false));
|
||||||
|
assert!(create_response["series_uid"].is_string());
|
||||||
|
println!("✓ Create event series test passed");
|
||||||
|
} else {
|
||||||
|
println!("⚠ Create event series test skipped (CalDAV server not accessible)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test event series update endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_update_event_series() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
// Load password from env for CalDAV requests
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let password = "test".to_string();
|
||||||
|
|
||||||
|
let update_payload = json!({
|
||||||
|
"series_uid": "test-series-uid",
|
||||||
|
"title": "Updated Series Title",
|
||||||
|
"description": "Updated by integration test",
|
||||||
|
"start_date": "2024-12-26",
|
||||||
|
"start_time": "14:00",
|
||||||
|
"end_date": "2024-12-26",
|
||||||
|
"end_time": "15:00",
|
||||||
|
"location": "Updated Location",
|
||||||
|
"all_day": false,
|
||||||
|
"status": "confirmed",
|
||||||
|
"class": "public",
|
||||||
|
"priority": 3,
|
||||||
|
"organizer": "test@example.com",
|
||||||
|
"attendees": "attendee@example.com",
|
||||||
|
"categories": "updated-series",
|
||||||
|
"reminder": "30min",
|
||||||
|
"recurrence": "daily",
|
||||||
|
"recurrence_days": [false, false, false, false, false, false, false],
|
||||||
|
"recurrence_interval": 2,
|
||||||
|
"recurrence_count": 10,
|
||||||
|
"update_scope": "all_in_series",
|
||||||
|
"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
|
||||||
|
))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.header("X-CalDAV-Password", password)
|
||||||
|
.json(&update_payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
println!("Update series response status: {}", status);
|
||||||
|
|
||||||
|
// Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
println!("✓ Update event series test passed");
|
||||||
|
} else if status == 404 {
|
||||||
|
println!(
|
||||||
|
"⚠ Update event series test skipped (event not found - expected for test data)"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("⚠ Update event series test skipped (CalDAV server not accessible)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test event series deletion endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_delete_event_series() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
// Load password from env for CalDAV requests
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let password = "test".to_string();
|
||||||
|
|
||||||
|
let delete_payload = json!({
|
||||||
|
"series_uid": "test-series-to-delete",
|
||||||
|
"calendar_path": "/calendars/test/default/",
|
||||||
|
"event_href": "test-series.ics",
|
||||||
|
"delete_scope": "all_in_series"
|
||||||
|
});
|
||||||
|
|
||||||
|
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)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
println!("Delete series response status: {}", status);
|
||||||
|
|
||||||
|
// Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI
|
||||||
|
if status.is_success() {
|
||||||
|
let delete_response: serde_json::Value = response.json().await.unwrap();
|
||||||
|
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)"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("⚠ Delete event series test skipped (CalDAV server not accessible)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test invalid update scope
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_invalid_update_scope() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
let invalid_payload = json!({
|
||||||
|
"series_uid": "test-series-uid",
|
||||||
|
"title": "Test Title",
|
||||||
|
"description": "Test",
|
||||||
|
"start_date": "2024-12-25",
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_date": "2024-12-25",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"location": "Test",
|
||||||
|
"all_day": false,
|
||||||
|
"status": "confirmed",
|
||||||
|
"class": "public",
|
||||||
|
"organizer": "test@example.com",
|
||||||
|
"attendees": "",
|
||||||
|
"categories": "",
|
||||||
|
"reminder": "none",
|
||||||
|
"recurrence": "none",
|
||||||
|
"recurrence_days": [false, false, false, false, false, false, false],
|
||||||
|
"update_scope": "invalid_scope" // This should cause a 400 error
|
||||||
|
});
|
||||||
|
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
println!("✓ Invalid update scope test passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test non-recurring event rejection in series endpoint
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_non_recurring_series_rejection() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
|
// First login to get a token
|
||||||
|
let token = server.login().await;
|
||||||
|
|
||||||
|
let non_recurring_payload = json!({
|
||||||
|
"title": "Non-recurring Event",
|
||||||
|
"description": "This should be rejected",
|
||||||
|
"start_date": "2024-12-25",
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_date": "2024-12-25",
|
||||||
|
"end_time": "11:00",
|
||||||
|
"location": "Test",
|
||||||
|
"all_day": false,
|
||||||
|
"status": "confirmed",
|
||||||
|
"class": "public",
|
||||||
|
"organizer": "test@example.com",
|
||||||
|
"attendees": "",
|
||||||
|
"categories": "",
|
||||||
|
"reminder": "none",
|
||||||
|
"recurrence": "none", // This should cause rejection
|
||||||
|
"recurrence_days": [false, false, false, false, false, false, false]
|
||||||
|
});
|
||||||
|
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
println!("✓ Non-recurring series rejection test passed");
|
||||||
|
}
|
||||||
|
}
|
||||||
13
calendar-models/Cargo.toml
Normal file
13
calendar-models/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "calendar-models"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
wasm = ["chrono/wasm-bindgen", "uuid/wasm-bindgen"]
|
||||||
220
calendar-models/src/common.rs
Normal file
220
calendar-models/src/common.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
//! Common types and enums used across calendar components
|
||||||
|
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ==================== ENUMS AND COMMON TYPES ====================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum EventStatus {
|
||||||
|
Tentative,
|
||||||
|
Confirmed,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum EventClass {
|
||||||
|
Public,
|
||||||
|
Private,
|
||||||
|
Confidential,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum TimeTransparency {
|
||||||
|
Opaque, // OPAQUE - time is not available
|
||||||
|
Transparent, // TRANSPARENT - time is available
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum TodoStatus {
|
||||||
|
NeedsAction,
|
||||||
|
Completed,
|
||||||
|
InProcess,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum AttendeeRole {
|
||||||
|
Chair,
|
||||||
|
ReqParticipant,
|
||||||
|
OptParticipant,
|
||||||
|
NonParticipant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum ParticipationStatus {
|
||||||
|
NeedsAction,
|
||||||
|
Accepted,
|
||||||
|
Declined,
|
||||||
|
Tentative,
|
||||||
|
Delegated,
|
||||||
|
Completed,
|
||||||
|
InProcess,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum AlarmAction {
|
||||||
|
Audio,
|
||||||
|
Display,
|
||||||
|
Email,
|
||||||
|
Procedure,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== STRUCTURED TYPES ====================
|
||||||
|
|
||||||
|
#[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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Attendee {
|
||||||
|
pub cal_address: String, // Calendar user address
|
||||||
|
pub common_name: Option<String>, // CN parameter
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum AlarmTrigger {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct GeographicPosition {
|
||||||
|
pub latitude: f64, // Latitude in decimal degrees
|
||||||
|
pub longitude: f64, // Longitude in decimal degrees
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct 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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct VJournal {
|
||||||
|
// Required properties
|
||||||
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||||
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
|
|
||||||
|
// Optional properties
|
||||||
|
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||||
|
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||||
|
pub description: Option<String>, // Description (DESCRIPTION)
|
||||||
|
|
||||||
|
// Classification and status
|
||||||
|
pub class: Option<EventClass>, // Classification (CLASS)
|
||||||
|
pub status: Option<String>, // Status (STATUS)
|
||||||
|
|
||||||
|
// People and organization
|
||||||
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
|
|
||||||
|
// Categorization
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Recurrence
|
||||||
|
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||||
|
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||||
|
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||||
|
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct VFreeBusy {
|
||||||
|
// Required properties
|
||||||
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||||
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
|
|
||||||
|
// Optional date-time properties
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct FreeBusyTime {
|
||||||
|
pub fb_type: FreeBusyType, // Free/busy type
|
||||||
|
pub periods: Vec<Period>, // Time periods
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum FreeBusyType {
|
||||||
|
Free,
|
||||||
|
Busy,
|
||||||
|
BusyUnavailable,
|
||||||
|
BusyTentative,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)
|
||||||
|
}
|
||||||
10
calendar-models/src/lib.rs
Normal file
10
calendar-models/src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
//! RFC 5545 Compliant Calendar Models
|
||||||
|
//!
|
||||||
|
//! This crate provides shared data structures for calendar applications
|
||||||
|
//! that comply with RFC 5545 (iCalendar) specification.
|
||||||
|
|
||||||
|
pub mod common;
|
||||||
|
pub mod vevent;
|
||||||
|
|
||||||
|
pub use common::*;
|
||||||
|
pub use vevent::*;
|
||||||
185
calendar-models/src/vevent.rs
Normal file
185
calendar-models/src/vevent.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
//! VEvent - RFC 5545 compliant calendar event structure
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Optional properties (commonly used)
|
||||||
|
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
||||||
|
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
||||||
|
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||||
|
pub description: Option<String>, // Description (DESCRIPTION)
|
||||||
|
pub location: Option<String>, // Location (LOCATION)
|
||||||
|
|
||||||
|
// Classification and status
|
||||||
|
pub class: Option<EventClass>, // Classification (CLASS)
|
||||||
|
pub status: Option<EventStatus>, // Status (STATUS)
|
||||||
|
pub transp: Option<TimeTransparency>, // Time transparency (TRANSP)
|
||||||
|
pub priority: Option<u8>, // Priority 0-9 (PRIORITY)
|
||||||
|
|
||||||
|
// People and organization
|
||||||
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
|
pub contact: Option<String>, // Contact information (CONTACT)
|
||||||
|
|
||||||
|
// Categorization and relationships
|
||||||
|
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||||
|
pub comment: Option<String>, // Comment (COMMENT)
|
||||||
|
pub resources: Vec<String>, // Resources (RESOURCES)
|
||||||
|
pub related_to: Option<String>, // Related component (RELATED-TO)
|
||||||
|
pub url: Option<String>, // URL (URL)
|
||||||
|
|
||||||
|
// Geographical
|
||||||
|
pub geo: Option<GeographicPosition>, // Geographic position (GEO)
|
||||||
|
|
||||||
|
// Versioning and modification
|
||||||
|
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||||
|
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||||
|
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||||
|
|
||||||
|
// Recurrence
|
||||||
|
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||||
|
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||||
|
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||||
|
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||||
|
|
||||||
|
// Alarms and attachments
|
||||||
|
pub alarms: Vec<VAlarm>, // VALARM components
|
||||||
|
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||||
|
|
||||||
|
// CalDAV specific (for implementation)
|
||||||
|
pub etag: Option<String>, // ETag for CalDAV
|
||||||
|
pub href: Option<String>, // Href for CalDAV
|
||||||
|
pub calendar_path: Option<String>, // Calendar path
|
||||||
|
pub all_day: bool, // All-day event flag
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VEvent {
|
||||||
|
/// Create a new VEvent with required fields
|
||||||
|
pub fn new(uid: String, dtstart: DateTime<Utc>) -> Self {
|
||||||
|
Self {
|
||||||
|
dtstamp: Utc::now(),
|
||||||
|
uid,
|
||||||
|
dtstart,
|
||||||
|
dtend: None,
|
||||||
|
duration: None,
|
||||||
|
summary: None,
|
||||||
|
description: None,
|
||||||
|
location: None,
|
||||||
|
class: None,
|
||||||
|
status: None,
|
||||||
|
transp: None,
|
||||||
|
priority: None,
|
||||||
|
organizer: None,
|
||||||
|
attendees: Vec::new(),
|
||||||
|
contact: None,
|
||||||
|
categories: Vec::new(),
|
||||||
|
comment: None,
|
||||||
|
resources: Vec::new(),
|
||||||
|
related_to: None,
|
||||||
|
url: None,
|
||||||
|
geo: None,
|
||||||
|
sequence: None,
|
||||||
|
created: Some(Utc::now()),
|
||||||
|
last_modified: Some(Utc::now()),
|
||||||
|
rrule: None,
|
||||||
|
rdate: Vec::new(),
|
||||||
|
exdate: Vec::new(),
|
||||||
|
recurrence_id: None,
|
||||||
|
alarms: Vec::new(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
etag: None,
|
||||||
|
href: None,
|
||||||
|
calendar_path: None,
|
||||||
|
all_day: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper method to get effective end time (dtend or dtstart + duration)
|
||||||
|
pub fn get_end_time(&self) -> DateTime<Utc> {
|
||||||
|
if let Some(dtend) = self.dtend {
|
||||||
|
dtend
|
||||||
|
} else if let Some(duration) = self.duration {
|
||||||
|
self.dtstart + duration
|
||||||
|
} else {
|
||||||
|
// Default to 1 hour if no end or duration specified
|
||||||
|
self.dtstart + Duration::hours(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper method to get event duration
|
||||||
|
pub fn get_duration(&self) -> Duration {
|
||||||
|
if let Some(duration) = self.duration {
|
||||||
|
duration
|
||||||
|
} else if let Some(dtend) = self.dtend {
|
||||||
|
dtend - self.dtstart
|
||||||
|
} else {
|
||||||
|
Duration::hours(1) // Default duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper method to get start date for UI compatibility
|
||||||
|
pub fn get_date(&self) -> chrono::NaiveDate {
|
||||||
|
self.dtstart.date_naive()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if event is recurring
|
||||||
|
pub fn is_recurring(&self) -> bool {
|
||||||
|
self.rrule.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this is an exception to a recurring series
|
||||||
|
pub fn is_exception(&self) -> bool {
|
||||||
|
self.recurrence_id.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get display string for status
|
||||||
|
pub fn get_status_display(&self) -> &'static str {
|
||||||
|
match &self.status {
|
||||||
|
Some(EventStatus::Tentative) => "Tentative",
|
||||||
|
Some(EventStatus::Confirmed) => "Confirmed",
|
||||||
|
Some(EventStatus::Cancelled) => "Cancelled",
|
||||||
|
None => "Confirmed", // Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get display string for class
|
||||||
|
pub fn get_class_display(&self) -> &'static str {
|
||||||
|
match &self.class {
|
||||||
|
Some(EventClass::Public) => "Public",
|
||||||
|
Some(EventClass::Private) => "Private",
|
||||||
|
Some(EventClass::Confidential) => "Confidential",
|
||||||
|
None => "Public", // Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get display string for priority
|
||||||
|
pub fn get_priority_display(&self) -> String {
|
||||||
|
match self.priority {
|
||||||
|
None => "Not set".to_string(),
|
||||||
|
Some(0) => "Undefined".to_string(),
|
||||||
|
Some(1) => "High".to_string(),
|
||||||
|
Some(p) if p <= 4 => "High".to_string(),
|
||||||
|
Some(5) => "Medium".to_string(),
|
||||||
|
Some(p) if p <= 8 => "Low".to_string(),
|
||||||
|
Some(9) => "Low".to_string(),
|
||||||
|
Some(p) => format!("Priority {}", p),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
compose.yml
Normal file
22
compose.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
calendar-backend:
|
||||||
|
build: .
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./data/site_dist:/srv/www
|
||||||
|
|
||||||
|
calendar-frontend:
|
||||||
|
image: caddy
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./data/site_dist:/srv/www:ro
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- ./data/caddy/data:/data
|
||||||
|
- ./data/caddy/config:/config
|
||||||
67
frontend/Cargo.toml
Normal file
67
frontend/Cargo.toml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
[package]
|
||||||
|
name = "calendar-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# Frontend binary only
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
calendar-models = { workspace = true, features = ["wasm"] }
|
||||||
|
yew = { version = "0.21", features = ["csr"] }
|
||||||
|
web-sys = { version = "0.3", features = [
|
||||||
|
"console",
|
||||||
|
"HtmlSelectElement",
|
||||||
|
"HtmlInputElement",
|
||||||
|
"HtmlTextAreaElement",
|
||||||
|
"Event",
|
||||||
|
"MouseEvent",
|
||||||
|
"InputEvent",
|
||||||
|
"Element",
|
||||||
|
"Document",
|
||||||
|
"Window",
|
||||||
|
"Location",
|
||||||
|
"Headers",
|
||||||
|
"Request",
|
||||||
|
"RequestInit",
|
||||||
|
"RequestMode",
|
||||||
|
"Response",
|
||||||
|
"CssStyleDeclaration",
|
||||||
|
] }
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
|
||||||
|
# HTTP client for CalDAV requests
|
||||||
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
# Calendar and iCal parsing
|
||||||
|
ical = "0.7"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
# Date and time handling
|
||||||
|
chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] }
|
||||||
|
chrono-tz = "0.8"
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
anyhow = "1.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log = "0.4"
|
||||||
|
console_log = "1.0"
|
||||||
|
|
||||||
|
# UUID generation for calendar events
|
||||||
|
uuid = { version = "1.0", features = ["v4", "wasm-bindgen", "serde"] }
|
||||||
|
getrandom = { version = "0.2", features = ["js"] }
|
||||||
|
|
||||||
|
# Environment variable handling
|
||||||
|
dotenvy = "0.15"
|
||||||
|
base64 = "0.21"
|
||||||
|
|
||||||
|
# XML/Regex parsing
|
||||||
|
regex = "1.0"
|
||||||
|
|
||||||
|
# Yew routing and local storage (WASM only)
|
||||||
|
yew-router = "0.18"
|
||||||
|
gloo-storage = "0.3"
|
||||||
|
gloo-timers = "0.3"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
|
||||||
16
frontend/Trunk.toml
Normal file
16
frontend/Trunk.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[build]
|
||||||
|
target = "index.html"
|
||||||
|
dist = "dist"
|
||||||
|
|
||||||
|
[env]
|
||||||
|
BACKEND_API_URL = "http://localhost:3000/api"
|
||||||
|
|
||||||
|
[watch]
|
||||||
|
watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "index.html"]
|
||||||
|
ignore = ["../backend/", "../target/"]
|
||||||
|
|
||||||
|
[serve]
|
||||||
|
addresses = ["127.0.0.1"]
|
||||||
|
port = 8080
|
||||||
|
open = false
|
||||||
|
|
||||||
18
frontend/index.html
Normal file
18
frontend/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Calendar App</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<base data-trunk-public-url />
|
||||||
|
<link data-trunk rel="css" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
console.log("HTML loaded, waiting for WASM...");
|
||||||
|
window.addEventListener('TrunkApplicationStarted', () => {
|
||||||
|
console.log("Trunk application started successfully!");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -11,11 +11,22 @@ pub struct CalDAVLoginRequest {
|
|||||||
pub password: String,
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct AuthResponse {
|
pub struct AuthResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
pub session_token: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
|
pub preferences: UserPreferencesResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -34,14 +45,14 @@ impl AuthService {
|
|||||||
let base_url = option_env!("BACKEND_API_URL")
|
let base_url = option_env!("BACKEND_API_URL")
|
||||||
.unwrap_or("http://localhost:3000/api")
|
.unwrap_or("http://localhost:3000/api")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
Self { base_url }
|
Self { base_url }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, String> {
|
pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, String> {
|
||||||
self.post_json("/auth/login", &request).await
|
self.post_json("/auth/login", &request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method for POST requests with JSON body
|
// Helper method for POST requests with JSON body
|
||||||
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
|
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
|
||||||
&self,
|
&self,
|
||||||
@@ -49,9 +60,9 @@ impl AuthService {
|
|||||||
body: &T,
|
body: &T,
|
||||||
) -> Result<R, String> {
|
) -> Result<R, String> {
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
let json_body = serde_json::to_string(body)
|
let json_body =
|
||||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
serde_json::to_string(body).map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||||
|
|
||||||
let opts = RequestInit::new();
|
let opts = RequestInit::new();
|
||||||
opts.set_method("POST");
|
opts.set_method("POST");
|
||||||
@@ -62,23 +73,27 @@ impl AuthService {
|
|||||||
let request = Request::new_with_str_and_init(&url, &opts)
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
request.headers().set("Content-Type", "application/json")
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||||
|
|
||||||
let resp: Response = resp_value.dyn_into()
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||||
|
|
||||||
let text = JsFuture::from(resp.text()
|
let text = JsFuture::from(
|
||||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
resp.text()
|
||||||
.await
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?,
|
||||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||||
|
|
||||||
let text_string = text.as_string()
|
let text_string = text.as_string().ok_or("Response text is not a string")?;
|
||||||
.ok_or("Response text is not a string")?;
|
|
||||||
|
|
||||||
if resp.ok() {
|
if resp.ok() {
|
||||||
serde_json::from_str::<R>(&text_string)
|
serde_json::from_str::<R>(&text_string)
|
||||||
@@ -92,4 +107,4 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
use yew::prelude::*;
|
use crate::components::{
|
||||||
use chrono::{Datelike, Local, NaiveDate, Duration};
|
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
|
||||||
|
};
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::{calendar_service::UserInfo, CalendarService};
|
||||||
|
use chrono::{Datelike, Duration, Local, NaiveDate};
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
use crate::services::calendar_service::{CalendarEvent, UserInfo};
|
use yew::prelude::*;
|
||||||
use crate::components::{EventModal, ViewMode, CalendarHeader, MonthView, WeekView, CreateEventModal, EventCreationData};
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarProps {
|
pub struct CalendarProps {
|
||||||
#[prop_or_default]
|
|
||||||
pub events: HashMap<NaiveDate, Vec<CalendarEvent>>,
|
|
||||||
pub on_event_click: Callback<CalendarEvent>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub refreshing_event_uid: Option<String>,
|
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
@@ -24,7 +22,17 @@ pub struct CalendarProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update_request: Option<Callback<(CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime)>>,
|
pub on_event_update_request: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
}
|
}
|
||||||
@@ -32,6 +40,12 @@ pub struct CalendarProps {
|
|||||||
#[function_component]
|
#[function_component]
|
||||||
pub fn Calendar(props: &CalendarProps) -> Html {
|
pub fn Calendar(props: &CalendarProps) -> Html {
|
||||||
let today = Local::now().date_naive();
|
let today = Local::now().date_naive();
|
||||||
|
|
||||||
|
// Event management state
|
||||||
|
let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new());
|
||||||
|
let loading = use_state(|| true);
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
let refreshing_event_uid = use_state(|| None::<String>);
|
||||||
// Track the currently selected date (the actual day the user has selected)
|
// Track the currently selected date (the actual day the user has selected)
|
||||||
let selected_date = use_state(|| {
|
let selected_date = use_state(|| {
|
||||||
// Try to load saved selected date from localStorage
|
// Try to load saved selected date from localStorage
|
||||||
@@ -54,20 +68,19 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track the display date (what to show in the view)
|
// Track the display date (what to show in the view)
|
||||||
let current_date = use_state(|| {
|
let current_date = use_state(|| match props.view {
|
||||||
match props.view {
|
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
|
||||||
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
|
ViewMode::Week => *selected_date,
|
||||||
ViewMode::Week => *selected_date,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
let selected_event = use_state(|| None::<CalendarEvent>);
|
let selected_event = use_state(|| None::<VEvent>);
|
||||||
|
|
||||||
// State for create event modal
|
// State for create event modal
|
||||||
let show_create_modal = use_state(|| false);
|
let show_create_modal = use_state(|| false);
|
||||||
let create_event_data = use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>);
|
let create_event_data =
|
||||||
|
use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>);
|
||||||
|
|
||||||
// State for time increment snapping (15 or 30 minutes)
|
// State for time increment snapping (15 or 30 minutes)
|
||||||
let time_increment = use_state(|| {
|
let time_increment = use_state(|| {
|
||||||
// Try to load saved time increment from localStorage
|
// Try to load saved time increment from localStorage
|
||||||
@@ -81,7 +94,155 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
15
|
15
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch events when current_date changes
|
||||||
|
{
|
||||||
|
let events = events.clone();
|
||||||
|
let loading = loading.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let current_date = current_date.clone();
|
||||||
|
|
||||||
|
use_effect_with((*current_date, props.view.clone()), move |(date, _view)| {
|
||||||
|
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||||
|
let date = *date; // Clone the date to avoid lifetime issues
|
||||||
|
|
||||||
|
if let Some(token) = auth_token {
|
||||||
|
let events = events.clone();
|
||||||
|
let loading = loading.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let calendar_service = CalendarService::new();
|
||||||
|
|
||||||
|
let password = if let Ok(credentials_str) =
|
||||||
|
LocalStorage::get::<String>("caldav_credentials")
|
||||||
|
{
|
||||||
|
if let Ok(credentials) =
|
||||||
|
serde_json::from_str::<serde_json::Value>(&credentials_str)
|
||||||
|
{
|
||||||
|
credentials["password"].as_str().unwrap_or("").to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_year = date.year();
|
||||||
|
let current_month = date.month();
|
||||||
|
|
||||||
|
match calendar_service
|
||||||
|
.fetch_events_for_month_vevent(
|
||||||
|
&token,
|
||||||
|
&password,
|
||||||
|
current_year,
|
||||||
|
current_month,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(vevents) => {
|
||||||
|
let grouped_events = CalendarService::group_events_by_date(vevents);
|
||||||
|
events.set(grouped_events);
|
||||||
|
loading.set(false);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error.set(Some(format!("Failed to load events: {}", err)));
|
||||||
|
loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
loading.set(false);
|
||||||
|
error.set(Some("No authentication token found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
|| ()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle event click to refresh individual events
|
||||||
|
let on_event_click = {
|
||||||
|
let events = events.clone();
|
||||||
|
let refreshing_event_uid = refreshing_event_uid.clone();
|
||||||
|
|
||||||
|
Callback::from(move |event: VEvent| {
|
||||||
|
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||||
|
|
||||||
|
if let Some(token) = auth_token {
|
||||||
|
let events = events.clone();
|
||||||
|
let refreshing_event_uid = refreshing_event_uid.clone();
|
||||||
|
let uid = event.uid.clone();
|
||||||
|
|
||||||
|
refreshing_event_uid.set(Some(uid.clone()));
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let calendar_service = CalendarService::new();
|
||||||
|
|
||||||
|
let password = if let Ok(credentials_str) =
|
||||||
|
LocalStorage::get::<String>("caldav_credentials")
|
||||||
|
{
|
||||||
|
if let Ok(credentials) =
|
||||||
|
serde_json::from_str::<serde_json::Value>(&credentials_str)
|
||||||
|
{
|
||||||
|
credentials["password"].as_str().unwrap_or("").to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
match calendar_service
|
||||||
|
.refresh_event(&token, &password, &uid)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(refreshed_event)) => {
|
||||||
|
let refreshed_vevent = refreshed_event;
|
||||||
|
let mut updated_events = (*events).clone();
|
||||||
|
|
||||||
|
for (_, day_events) in updated_events.iter_mut() {
|
||||||
|
day_events.retain(|e| e.uid != uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if refreshed_vevent.rrule.is_some() {
|
||||||
|
let new_occurrences =
|
||||||
|
CalendarService::expand_recurring_events(vec![
|
||||||
|
refreshed_vevent.clone(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for occurrence in new_occurrences {
|
||||||
|
let date = occurrence.get_date();
|
||||||
|
updated_events
|
||||||
|
.entry(date)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(occurrence);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let date = refreshed_vevent.get_date();
|
||||||
|
updated_events
|
||||||
|
.entry(date)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(refreshed_vevent);
|
||||||
|
}
|
||||||
|
|
||||||
|
events.set(updated_events);
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
let mut updated_events = (*events).clone();
|
||||||
|
for (_, day_events) in updated_events.iter_mut() {
|
||||||
|
day_events.retain(|e| e.uid != uid);
|
||||||
|
}
|
||||||
|
events.set(updated_events);
|
||||||
|
}
|
||||||
|
Err(_err) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshing_event_uid.set(None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
// Handle view mode changes - adjust current_date format when switching between month/week
|
// Handle view mode changes - adjust current_date format when switching between month/week
|
||||||
{
|
{
|
||||||
let current_date = current_date.clone();
|
let current_date = current_date.clone();
|
||||||
@@ -97,7 +258,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
|| {}
|
|| {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let on_prev = {
|
let on_prev = {
|
||||||
let current_date = current_date.clone();
|
let current_date = current_date.clone();
|
||||||
let selected_date = selected_date.clone();
|
let selected_date = selected_date.clone();
|
||||||
@@ -109,19 +270,22 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let prev_month = *current_date - Duration::days(1);
|
let prev_month = *current_date - Duration::days(1);
|
||||||
let first_of_prev = prev_month.with_day(1).unwrap();
|
let first_of_prev = prev_month.with_day(1).unwrap();
|
||||||
(first_of_prev, first_of_prev)
|
(first_of_prev, first_of_prev)
|
||||||
},
|
}
|
||||||
ViewMode::Week => {
|
ViewMode::Week => {
|
||||||
// Go to previous week
|
// Go to previous week
|
||||||
let prev_week = *selected_date - Duration::weeks(1);
|
let prev_week = *selected_date - Duration::weeks(1);
|
||||||
(prev_week, prev_week)
|
(prev_week, prev_week)
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
selected_date.set(new_selected);
|
selected_date.set(new_selected);
|
||||||
current_date.set(new_display);
|
current_date.set(new_display);
|
||||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_next = {
|
let on_next = {
|
||||||
let current_date = current_date.clone();
|
let current_date = current_date.clone();
|
||||||
let selected_date = selected_date.clone();
|
let selected_date = selected_date.clone();
|
||||||
@@ -133,19 +297,23 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let next_month = if current_date.month() == 12 {
|
let next_month = if current_date.month() == 12 {
|
||||||
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
|
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
|
||||||
} else {
|
} else {
|
||||||
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1).unwrap()
|
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1)
|
||||||
|
.unwrap()
|
||||||
};
|
};
|
||||||
(next_month, next_month)
|
(next_month, next_month)
|
||||||
},
|
}
|
||||||
ViewMode::Week => {
|
ViewMode::Week => {
|
||||||
// Go to next week
|
// Go to next week
|
||||||
let next_week = *selected_date + Duration::weeks(1);
|
let next_week = *selected_date + Duration::weeks(1);
|
||||||
(next_week, next_week)
|
(next_week, next_week)
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
selected_date.set(new_selected);
|
selected_date.set(new_selected);
|
||||||
current_date.set(new_display);
|
current_date.set(new_display);
|
||||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,15 +327,18 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
ViewMode::Month => {
|
ViewMode::Month => {
|
||||||
let first_of_today = today.with_day(1).unwrap();
|
let first_of_today = today.with_day(1).unwrap();
|
||||||
(today, first_of_today) // Select today, but display the month
|
(today, first_of_today) // Select today, but display the month
|
||||||
},
|
}
|
||||||
ViewMode::Week => (today, today), // Select and display today
|
ViewMode::Week => (today, today), // Select and display today
|
||||||
};
|
};
|
||||||
selected_date.set(new_selected);
|
selected_date.set(new_selected);
|
||||||
current_date.set(new_display);
|
current_date.set(new_display);
|
||||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle time increment toggle
|
// Handle time increment toggle
|
||||||
let on_time_increment_toggle = {
|
let on_time_increment_toggle = {
|
||||||
let time_increment = time_increment.clone();
|
let time_increment = time_increment.clone();
|
||||||
@@ -178,32 +349,68 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let _ = LocalStorage::set("calendar_time_increment", next);
|
let _ = LocalStorage::set("calendar_time_increment", next);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle drag-to-create event
|
// Handle drag-to-create event
|
||||||
let on_create_event = {
|
let on_create_event = {
|
||||||
let show_create_modal = show_create_modal.clone();
|
let show_create_modal = show_create_modal.clone();
|
||||||
let create_event_data = create_event_data.clone();
|
let create_event_data = create_event_data.clone();
|
||||||
Callback::from(move |(_date, start_datetime, end_datetime): (NaiveDate, chrono::NaiveDateTime, chrono::NaiveDateTime)| {
|
Callback::from(
|
||||||
// For drag-to-create, we don't need the temporary event approach
|
move |(_date, start_datetime, end_datetime): (
|
||||||
// Instead, just pass the local times directly via initial_time props
|
NaiveDate,
|
||||||
create_event_data.set(Some((start_datetime.date(), start_datetime.time(), end_datetime.time())));
|
chrono::NaiveDateTime,
|
||||||
show_create_modal.set(true);
|
chrono::NaiveDateTime,
|
||||||
})
|
)| {
|
||||||
|
// For drag-to-create, we don't need the temporary event approach
|
||||||
|
// Instead, just pass the local times directly via initial_time props
|
||||||
|
create_event_data.set(Some((
|
||||||
|
start_datetime.date(),
|
||||||
|
start_datetime.time(),
|
||||||
|
end_datetime.time(),
|
||||||
|
)));
|
||||||
|
show_create_modal.set(true);
|
||||||
|
},
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle drag-to-move event
|
// Handle drag-to-move event
|
||||||
let on_event_update = {
|
let on_event_update = {
|
||||||
let on_event_update_request = props.on_event_update_request.clone();
|
let on_event_update_request = props.on_event_update_request.clone();
|
||||||
Callback::from(move |(event, new_start, new_end): (CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime)| {
|
Callback::from(
|
||||||
if let Some(callback) = &on_event_update_request {
|
move |(
|
||||||
callback.emit((event, new_start, new_end));
|
event,
|
||||||
}
|
new_start,
|
||||||
})
|
new_end,
|
||||||
|
preserve_rrule,
|
||||||
|
until_date,
|
||||||
|
update_scope,
|
||||||
|
occurrence_date,
|
||||||
|
): (
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)| {
|
||||||
|
if let Some(callback) = &on_event_update_request {
|
||||||
|
callback.emit((
|
||||||
|
event,
|
||||||
|
new_start,
|
||||||
|
new_end,
|
||||||
|
preserve_rrule,
|
||||||
|
until_date,
|
||||||
|
update_scope,
|
||||||
|
occurrence_date,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class={classes!("calendar", match props.view { ViewMode::Week => Some("week-view"), _ => None })}>
|
<div class={classes!("calendar", match props.view { ViewMode::Week => Some("week-view"), _ => None })}>
|
||||||
<CalendarHeader
|
<CalendarHeader
|
||||||
current_date={*current_date}
|
current_date={*current_date}
|
||||||
view_mode={props.view.clone()}
|
view_mode={props.view.clone()}
|
||||||
on_prev={on_prev}
|
on_prev={on_prev}
|
||||||
@@ -212,9 +419,22 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
time_increment={Some(*time_increment)}
|
time_increment={Some(*time_increment)}
|
||||||
on_time_increment_toggle={Some(on_time_increment_toggle)}
|
on_time_increment_toggle={Some(on_time_increment_toggle)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{
|
{
|
||||||
match props.view {
|
if *loading {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-loading">
|
||||||
|
<p>{"Loading calendar events..."}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else if let Some(err) = (*error).clone() {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-error">
|
||||||
|
<p>{format!("Error: {}", err)}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match props.view {
|
||||||
ViewMode::Month => {
|
ViewMode::Month => {
|
||||||
let on_day_select = {
|
let on_day_select = {
|
||||||
let selected_date = selected_date.clone();
|
let selected_date = selected_date.clone();
|
||||||
@@ -223,14 +443,14 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let _ = LocalStorage::set("calendar_selected_date", date.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set("calendar_selected_date", date.format("%Y-%m-%d").to_string());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<MonthView
|
<MonthView
|
||||||
current_month={*current_date}
|
current_month={*current_date}
|
||||||
today={today}
|
today={today}
|
||||||
events={props.events.clone()}
|
events={(*events).clone()}
|
||||||
on_event_click={props.on_event_click.clone()}
|
on_event_click={on_event_click.clone()}
|
||||||
refreshing_event_uid={props.refreshing_event_uid.clone()}
|
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
||||||
user_info={props.user_info.clone()}
|
user_info={props.user_info.clone()}
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
@@ -243,23 +463,25 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
<WeekView
|
<WeekView
|
||||||
current_date={*current_date}
|
current_date={*current_date}
|
||||||
today={today}
|
today={today}
|
||||||
events={props.events.clone()}
|
events={(*events).clone()}
|
||||||
on_event_click={props.on_event_click.clone()}
|
on_event_click={on_event_click.clone()}
|
||||||
refreshing_event_uid={props.refreshing_event_uid.clone()}
|
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
||||||
user_info={props.user_info.clone()}
|
user_info={props.user_info.clone()}
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
on_create_event={Some(on_create_event)}
|
on_create_event={Some(on_create_event)}
|
||||||
|
on_create_event_request={props.on_create_event_request.clone()}
|
||||||
on_event_update={Some(on_event_update)}
|
on_event_update={Some(on_event_update)}
|
||||||
context_menus_open={props.context_menus_open}
|
context_menus_open={props.context_menus_open}
|
||||||
time_increment={*time_increment}
|
time_increment={*time_increment}
|
||||||
/>
|
/>
|
||||||
},
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event details modal
|
// Event details modal
|
||||||
<EventModal
|
<EventModal
|
||||||
event={(*selected_event).clone()}
|
event={(*selected_event).clone()}
|
||||||
on_close={{
|
on_close={{
|
||||||
let selected_event_clone = selected_event.clone();
|
let selected_event_clone = selected_event.clone();
|
||||||
@@ -268,7 +490,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
// Create event modal
|
// Create event modal
|
||||||
<CreateEventModal
|
<CreateEventModal
|
||||||
is_open={*show_create_modal}
|
is_open={*show_create_modal}
|
||||||
@@ -292,7 +514,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
Callback::from(move |event_data: EventCreationData| {
|
Callback::from(move |event_data: EventCreationData| {
|
||||||
show_create_modal.set(false);
|
show_create_modal.set(false);
|
||||||
create_event_data.set(None);
|
create_event_data.set(None);
|
||||||
|
|
||||||
// Emit the create event request to parent
|
// Emit the create event request to parent
|
||||||
if let Some(callback) = &on_create_event_request {
|
if let Some(callback) = &on_create_event_request {
|
||||||
callback.emit(event_data);
|
callback.emit(event_data);
|
||||||
@@ -302,7 +524,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
on_update={{
|
on_update={{
|
||||||
let show_create_modal = show_create_modal.clone();
|
let show_create_modal = show_create_modal.clone();
|
||||||
let create_event_data = create_event_data.clone();
|
let create_event_data = create_event_data.clone();
|
||||||
Callback::from(move |(_original_event, _updated_data): (CalendarEvent, EventCreationData)| {
|
Callback::from(move |(_original_event, _updated_data): (VEvent, EventCreationData)| {
|
||||||
show_create_modal.set(false);
|
show_create_modal.set(false);
|
||||||
create_event_data.set(None);
|
create_event_data.set(None);
|
||||||
// TODO: Handle actual event update
|
// TODO: Handle actual event update
|
||||||
@@ -311,4 +533,4 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarContextMenuProps {
|
pub struct CalendarContextMenuProps {
|
||||||
@@ -13,7 +13,7 @@ pub struct CalendarContextMenuProps {
|
|||||||
#[function_component(CalendarContextMenu)]
|
#[function_component(CalendarContextMenu)]
|
||||||
pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
||||||
let menu_ref = use_node_ref();
|
let menu_ref = use_node_ref();
|
||||||
|
|
||||||
if !props.is_open {
|
if !props.is_open {
|
||||||
return html! {};
|
return html! {};
|
||||||
}
|
}
|
||||||
@@ -33,9 +33,9 @@ pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
ref={menu_ref}
|
ref={menu_ref}
|
||||||
class="context-menu"
|
class="context-menu"
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<div class="context-menu-item context-menu-create" onclick={on_create_event_click}>
|
<div class="context-menu-item context-menu-create" onclick={on_create_event_click}>
|
||||||
@@ -44,4 +44,4 @@ pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use chrono::{NaiveDate, Datelike};
|
|
||||||
use crate::components::ViewMode;
|
use crate::components::ViewMode;
|
||||||
|
use chrono::{Datelike, NaiveDate};
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarHeaderProps {
|
pub struct CalendarHeaderProps {
|
||||||
@@ -18,7 +18,11 @@ pub struct CalendarHeaderProps {
|
|||||||
|
|
||||||
#[function_component(CalendarHeader)]
|
#[function_component(CalendarHeader)]
|
||||||
pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
||||||
let title = format!("{} {}", get_month_name(props.current_date.month()), props.current_date.year());
|
let title = format!(
|
||||||
|
"{} {}",
|
||||||
|
get_month_name(props.current_date.month()),
|
||||||
|
props.current_date.year()
|
||||||
|
);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="calendar-header">
|
<div class="calendar-header">
|
||||||
@@ -48,7 +52,7 @@ pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
|||||||
fn get_month_name(month: u32) -> &'static str {
|
fn get_month_name(month: u32) -> &'static str {
|
||||||
match month {
|
match month {
|
||||||
1 => "January",
|
1 => "January",
|
||||||
2 => "February",
|
2 => "February",
|
||||||
3 => "March",
|
3 => "March",
|
||||||
4 => "April",
|
4 => "April",
|
||||||
5 => "May",
|
5 => "May",
|
||||||
@@ -59,6 +63,6 @@ fn get_month_name(month: u32) -> &'static str {
|
|||||||
10 => "October",
|
10 => "October",
|
||||||
11 => "November",
|
11 => "November",
|
||||||
12 => "December",
|
12 => "December",
|
||||||
_ => "Invalid"
|
_ => "Invalid",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
|
||||||
use crate::services::calendar_service::CalendarInfo;
|
use crate::services::calendar_service::CalendarInfo;
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarListItemProps {
|
pub struct CalendarListItemProps {
|
||||||
pub calendar: CalendarInfo,
|
pub calendar: CalendarInfo,
|
||||||
pub color_picker_open: bool,
|
pub color_picker_open: bool,
|
||||||
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
|
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
|
||||||
pub on_color_picker_toggle: Callback<String>, // calendar_path
|
pub on_color_picker_toggle: Callback<String>, // calendar_path
|
||||||
pub available_colors: Vec<String>,
|
pub available_colors: Vec<String>,
|
||||||
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
|||||||
|
|
||||||
html! {
|
html! {
|
||||||
<li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}>
|
<li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}>
|
||||||
<span class="calendar-color"
|
<span class="calendar-color"
|
||||||
style={format!("background-color: {}", props.calendar.color)}
|
style={format!("background-color: {}", props.calendar.color)}
|
||||||
onclick={on_color_click}>
|
onclick={on_color_click}>
|
||||||
{
|
{
|
||||||
@@ -46,14 +46,14 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
|||||||
let color_str = color.clone();
|
let color_str = color.clone();
|
||||||
let cal_path = props.calendar.path.clone();
|
let cal_path = props.calendar.path.clone();
|
||||||
let on_color_change = props.on_color_change.clone();
|
let on_color_change = props.on_color_change.clone();
|
||||||
|
|
||||||
let on_color_select = Callback::from(move |_: MouseEvent| {
|
let on_color_select = Callback::from(move |_: MouseEvent| {
|
||||||
on_color_change.emit((cal_path.clone(), color_str.clone()));
|
on_color_change.emit((cal_path.clone(), color_str.clone()));
|
||||||
});
|
});
|
||||||
|
|
||||||
let is_selected = props.calendar.color == *color;
|
let is_selected = props.calendar.color == *color;
|
||||||
let class_name = if is_selected { "color-option selected" } else { "color-option" };
|
let class_name = if is_selected { "color-option selected" } else { "color-option" };
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class={class_name}
|
<div class={class_name}
|
||||||
style={format!("background-color: {}", color)}
|
style={format!("background-color: {}", color)}
|
||||||
@@ -72,4 +72,4 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
|||||||
<span class="calendar-name">{&props.calendar.display_name}</span>
|
<span class="calendar-name">{&props.calendar.display_name}</span>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct ContextMenuProps {
|
pub struct ContextMenuProps {
|
||||||
@@ -13,7 +13,7 @@ pub struct ContextMenuProps {
|
|||||||
#[function_component(ContextMenu)]
|
#[function_component(ContextMenu)]
|
||||||
pub fn context_menu(props: &ContextMenuProps) -> Html {
|
pub fn context_menu(props: &ContextMenuProps) -> Html {
|
||||||
let menu_ref = use_node_ref();
|
let menu_ref = use_node_ref();
|
||||||
|
|
||||||
// Close menu when clicking outside (handled by parent component)
|
// Close menu when clicking outside (handled by parent component)
|
||||||
|
|
||||||
if !props.is_open {
|
if !props.is_open {
|
||||||
@@ -35,15 +35,14 @@ pub fn context_menu(props: &ContextMenuProps) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
ref={menu_ref}
|
ref={menu_ref}
|
||||||
class="context-menu"
|
class="context-menu"
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<div class="context-menu-item context-menu-delete" onclick={on_delete_click}>
|
<div class="context-menu-item context-menu-delete" onclick={on_delete_click}>
|
||||||
<span class="context-menu-icon">{"🗑️"}</span>
|
|
||||||
{"Delete Calendar"}
|
{"Delete Calendar"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,30 +39,32 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
let error_message = error_message.clone();
|
let error_message = error_message.clone();
|
||||||
let is_creating = is_creating.clone();
|
let is_creating = is_creating.clone();
|
||||||
let on_create = props.on_create.clone();
|
let on_create = props.on_create.clone();
|
||||||
|
|
||||||
Callback::from(move |e: SubmitEvent| {
|
Callback::from(move |e: SubmitEvent| {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
|
|
||||||
let name = (*calendar_name).trim();
|
let name = (*calendar_name).trim();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
error_message.set(Some("Calendar name is required".to_string()));
|
error_message.set(Some("Calendar name is required".to_string()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if name.len() > 100 {
|
if name.len() > 100 {
|
||||||
error_message.set(Some("Calendar name too long (max 100 characters)".to_string()));
|
error_message.set(Some(
|
||||||
|
"Calendar name too long (max 100 characters)".to_string(),
|
||||||
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
error_message.set(None);
|
error_message.set(None);
|
||||||
is_creating.set(true);
|
is_creating.set(true);
|
||||||
|
|
||||||
let desc = if (*description).trim().is_empty() {
|
let desc = if (*description).trim().is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some((*description).clone())
|
Some((*description).clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
on_create.emit((name.to_string(), desc, (*selected_color).clone()));
|
on_create.emit((name.to_string(), desc, (*selected_color).clone()));
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -90,7 +92,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
{"×"}
|
{"×"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="modal-body" onsubmit={on_submit}>
|
<form class="modal-body" onsubmit={on_submit}>
|
||||||
{
|
{
|
||||||
if let Some(ref error) = *error_message {
|
if let Some(ref error) = *error_message {
|
||||||
@@ -103,10 +105,10 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="calendar-name">{"Calendar Name *"}</label>
|
<label for="calendar-name">{"Calendar Name *"}</label>
|
||||||
<input
|
<input
|
||||||
id="calendar-name"
|
id="calendar-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={(*calendar_name).clone()}
|
value={(*calendar_name).clone()}
|
||||||
@@ -116,7 +118,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
disabled={*is_creating}
|
disabled={*is_creating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="calendar-description">{"Description"}</label>
|
<label for="calendar-description">{"Description"}</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -128,7 +130,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
disabled={*is_creating}
|
disabled={*is_creating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{"Calendar Color"}</label>
|
<label>{"Calendar Color"}</label>
|
||||||
<div class="color-grid">
|
<div class="color-grid">
|
||||||
@@ -143,13 +145,13 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
selected_color.set(Some(color.clone()));
|
selected_color.set(Some(color.clone()));
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let class_name = if is_selected {
|
let class_name = if is_selected {
|
||||||
"color-option selected"
|
"color-option selected"
|
||||||
} else {
|
} else {
|
||||||
"color-option"
|
"color-option"
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
@@ -165,18 +167,18 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
<p class="color-help-text">{"Optional: Choose a color for your calendar"}</p>
|
<p class="color-help-text">{"Optional: Choose a color for your calendar"}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="cancel-button"
|
class="cancel-button"
|
||||||
onclick={props.on_close.reform(|_| ())}
|
onclick={props.on_close.reform(|_| ())}
|
||||||
disabled={*is_creating}
|
disabled={*is_creating}
|
||||||
>
|
>
|
||||||
{"Cancel"}
|
{"Cancel"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="create-button"
|
class="create-button"
|
||||||
disabled={*is_creating}
|
disabled={*is_creating}
|
||||||
>
|
>
|
||||||
@@ -193,4 +195,4 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2315
frontend/src/components/create_event_modal.rs
Normal file
2315
frontend/src/components/create_event_modal.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
use crate::models::ical::VEvent;
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
use crate::services::calendar_service::CalendarEvent;
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
pub enum DeleteAction {
|
pub enum DeleteAction {
|
||||||
@@ -9,13 +9,20 @@ pub enum DeleteAction {
|
|||||||
DeleteSeries,
|
DeleteSeries,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
|
pub enum EditAction {
|
||||||
|
EditThis,
|
||||||
|
EditFuture,
|
||||||
|
EditAll,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct EventContextMenuProps {
|
pub struct EventContextMenuProps {
|
||||||
pub is_open: bool,
|
pub is_open: bool,
|
||||||
pub x: i32,
|
pub x: i32,
|
||||||
pub y: i32,
|
pub y: i32,
|
||||||
pub event: Option<CalendarEvent>,
|
pub event: Option<VEvent>,
|
||||||
pub on_edit: Callback<()>,
|
pub on_edit: Callback<EditAction>,
|
||||||
pub on_delete: Callback<DeleteAction>,
|
pub on_delete: Callback<DeleteAction>,
|
||||||
pub on_close: Callback<()>,
|
pub on_close: Callback<()>,
|
||||||
}
|
}
|
||||||
@@ -23,7 +30,7 @@ pub struct EventContextMenuProps {
|
|||||||
#[function_component(EventContextMenu)]
|
#[function_component(EventContextMenu)]
|
||||||
pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
||||||
let menu_ref = use_node_ref();
|
let menu_ref = use_node_ref();
|
||||||
|
|
||||||
if !props.is_open {
|
if !props.is_open {
|
||||||
return html! {};
|
return html! {};
|
||||||
}
|
}
|
||||||
@@ -34,15 +41,17 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Check if the event is recurring
|
// Check if the event is recurring
|
||||||
let is_recurring = props.event.as_ref()
|
let is_recurring = props
|
||||||
.map(|event| event.recurrence_rule.is_some())
|
.event
|
||||||
|
.as_ref()
|
||||||
|
.map(|event| event.rrule.is_some())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
let on_edit_click = {
|
let create_edit_callback = |action: EditAction| {
|
||||||
let on_edit = props.on_edit.clone();
|
let on_edit = props.on_edit.clone();
|
||||||
let on_close = props.on_close.clone();
|
let on_close = props.on_close.clone();
|
||||||
Callback::from(move |_: MouseEvent| {
|
Callback::from(move |_: MouseEvent| {
|
||||||
on_edit.emit(());
|
on_edit.emit(action.clone());
|
||||||
on_close.emit(());
|
on_close.emit(());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -57,42 +66,57 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
ref={menu_ref}
|
ref={menu_ref}
|
||||||
class="context-menu"
|
class="context-menu"
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<div class="context-menu-item" onclick={on_edit_click}>
|
{
|
||||||
<span class="context-menu-icon">{"✏️"}</span>
|
if is_recurring {
|
||||||
{"Edit Event"}
|
html! {
|
||||||
</div>
|
<>
|
||||||
|
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
|
||||||
|
{"Edit This Event"}
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditFuture)}>
|
||||||
|
{"Edit This and Future Events"}
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditAll)}>
|
||||||
|
{"Edit All Events in Series"}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
|
||||||
|
{"Edit Event"}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
{
|
{
|
||||||
if is_recurring {
|
if is_recurring {
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
||||||
<span class="context-menu-icon">{"🗑️"}</span>
|
{"Delete This Event"}
|
||||||
{"Delete This Event"}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteFollowing)}>
|
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteFollowing)}>
|
||||||
<span class="context-menu-icon">{"🗑️"}</span>
|
{"Delete Following Events"}
|
||||||
{"Delete Following Events"}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteSeries)}>
|
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteSeries)}>
|
||||||
<span class="context-menu-icon">{"🗑️"}</span>
|
{"Delete Entire Series"}
|
||||||
{"Delete Entire Series"}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {
|
html! {
|
||||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
||||||
<span class="context-menu-icon">{"🗑️"}</span>
|
{"Delete Event"}
|
||||||
{"Delete Event"}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
use yew::prelude::*;
|
use crate::models::ical::VEvent;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use crate::services::{CalendarEvent, EventReminder, ReminderAction};
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct EventModalProps {
|
pub struct EventModalProps {
|
||||||
pub event: Option<CalendarEvent>,
|
pub event: Option<VEvent>,
|
||||||
pub on_close: Callback<()>,
|
pub on_close: Callback<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
on_close.emit(());
|
on_close.emit(());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let backdrop_click = {
|
let backdrop_click = {
|
||||||
let on_close = props.on_close.clone();
|
let on_close = props.on_close.clone();
|
||||||
Callback::from(move |e: MouseEvent| {
|
Callback::from(move |e: MouseEvent| {
|
||||||
@@ -39,7 +39,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
<strong>{"Title:"}</strong>
|
<strong>{"Title:"}</strong>
|
||||||
<span>{event.get_title()}</span>
|
<span>{event.get_title()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref description) = event.description {
|
if let Some(ref description) = event.description {
|
||||||
html! {
|
html! {
|
||||||
@@ -52,14 +52,14 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Start:"}</strong>
|
<strong>{"Start:"}</strong>
|
||||||
<span>{format_datetime(&event.start, event.all_day)}</span>
|
<span>{format_datetime(&event.dtstart, event.all_day)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref end) = event.end {
|
if let Some(ref end) = event.dtend {
|
||||||
html! {
|
html! {
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"End:"}</strong>
|
<strong>{"End:"}</strong>
|
||||||
@@ -70,12 +70,12 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"All Day:"}</strong>
|
<strong>{"All Day:"}</strong>
|
||||||
<span>{if event.all_day { "Yes" } else { "No" }}</span>
|
<span>{if event.all_day { "Yes" } else { "No" }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref location) = event.location {
|
if let Some(ref location) = event.location {
|
||||||
html! {
|
html! {
|
||||||
@@ -88,48 +88,48 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Status:"}</strong>
|
<strong>{"Status:"}</strong>
|
||||||
<span>{event.get_status_display()}</span>
|
<span>{event.get_status_display()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Privacy:"}</strong>
|
<strong>{"Privacy:"}</strong>
|
||||||
<span>{event.get_class_display()}</span>
|
<span>{event.get_class_display()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Priority:"}</strong>
|
<strong>{"Priority:"}</strong>
|
||||||
<span>{event.get_priority_display()}</span>
|
<span>{event.get_priority_display()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref organizer) = event.organizer {
|
if let Some(ref organizer) = event.organizer {
|
||||||
html! {
|
html! {
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Organizer:"}</strong>
|
<strong>{"Organizer:"}</strong>
|
||||||
<span>{organizer}</span>
|
<span>{organizer.cal_address.clone()}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if !event.attendees.is_empty() {
|
if !event.attendees.is_empty() {
|
||||||
html! {
|
html! {
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Attendees:"}</strong>
|
<strong>{"Attendees:"}</strong>
|
||||||
<span>{event.attendees.join(", ")}</span>
|
<span>{event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(", ")}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if !event.categories.is_empty() {
|
if !event.categories.is_empty() {
|
||||||
html! {
|
html! {
|
||||||
@@ -142,9 +142,9 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref recurrence) = event.recurrence_rule {
|
if let Some(ref recurrence) = event.rrule {
|
||||||
html! {
|
html! {
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Repeats:"}</strong>
|
<strong>{"Repeats:"}</strong>
|
||||||
@@ -160,13 +160,13 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if !event.reminders.is_empty() {
|
if !event.alarms.is_empty() {
|
||||||
html! {
|
html! {
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"Reminders:"}</strong>
|
<strong>{"Reminders:"}</strong>
|
||||||
<span>{format_reminders(&event.reminders)}</span>
|
<span>{"Alarms configured"}</span> /* TODO: Convert VAlarm to displayable format */
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -178,7 +178,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref created) = event.created {
|
if let Some(ref created) = event.created {
|
||||||
html! {
|
html! {
|
||||||
@@ -191,7 +191,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(ref modified) = event.last_modified {
|
if let Some(ref modified) = event.last_modified {
|
||||||
html! {
|
html! {
|
||||||
@@ -236,54 +236,3 @@ fn format_recurrence_rule(rrule: &str) -> String {
|
|||||||
format!("Custom ({})", rrule)
|
format!("Custom ({})", rrule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_reminders(reminders: &[EventReminder]) -> String {
|
|
||||||
if reminders.is_empty() {
|
|
||||||
return "None".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
let formatted_reminders: Vec<String> = reminders
|
|
||||||
.iter()
|
|
||||||
.map(|reminder| {
|
|
||||||
let time_text = if reminder.minutes_before == 0 {
|
|
||||||
"At event time".to_string()
|
|
||||||
} else if reminder.minutes_before < 60 {
|
|
||||||
format!("{} minutes before", reminder.minutes_before)
|
|
||||||
} else if reminder.minutes_before == 60 {
|
|
||||||
"1 hour before".to_string()
|
|
||||||
} else if reminder.minutes_before % 60 == 0 {
|
|
||||||
format!("{} hours before", reminder.minutes_before / 60)
|
|
||||||
} else if reminder.minutes_before < 1440 {
|
|
||||||
let hours = reminder.minutes_before / 60;
|
|
||||||
let minutes = reminder.minutes_before % 60;
|
|
||||||
format!("{}h {}m before", hours, minutes)
|
|
||||||
} else if reminder.minutes_before == 1440 {
|
|
||||||
"1 day before".to_string()
|
|
||||||
} else if reminder.minutes_before % 1440 == 0 {
|
|
||||||
format!("{} days before", reminder.minutes_before / 1440)
|
|
||||||
} else {
|
|
||||||
let days = reminder.minutes_before / 1440;
|
|
||||||
let remaining_minutes = reminder.minutes_before % 1440;
|
|
||||||
let hours = remaining_minutes / 60;
|
|
||||||
let minutes = remaining_minutes % 60;
|
|
||||||
if hours > 0 {
|
|
||||||
format!("{}d {}h before", days, hours)
|
|
||||||
} else if minutes > 0 {
|
|
||||||
format!("{}d {}m before", days, minutes)
|
|
||||||
} else {
|
|
||||||
format!("{} days before", days)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let action_text = match reminder.action {
|
|
||||||
ReminderAction::Display => "notification",
|
|
||||||
ReminderAction::Email => "email",
|
|
||||||
ReminderAction::Audio => "sound",
|
|
||||||
};
|
|
||||||
|
|
||||||
format!("{} ({})", time_text, action_text)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
formatted_reminders.join(", ")
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::HtmlInputElement;
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct LoginProps {
|
pub struct LoginProps {
|
||||||
@@ -9,11 +9,20 @@ pub struct LoginProps {
|
|||||||
|
|
||||||
#[function_component]
|
#[function_component]
|
||||||
pub fn Login(props: &LoginProps) -> Html {
|
pub fn Login(props: &LoginProps) -> Html {
|
||||||
let server_url = use_state(String::new);
|
// Load remembered values from LocalStorage on mount
|
||||||
let username = use_state(String::new);
|
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 password = use_state(String::new);
|
||||||
let error_message = use_state(|| Option::<String>::None);
|
let error_message = use_state(|| Option::<String>::None);
|
||||||
let is_loading = use_state(|| false);
|
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 server_url_ref = use_node_ref();
|
||||||
let username_ref = use_node_ref();
|
let username_ref = use_node_ref();
|
||||||
@@ -42,6 +51,38 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
password.set(target.value());
|
password.set(target.value());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 on_submit = {
|
||||||
let server_url = server_url.clone();
|
let server_url = server_url.clone();
|
||||||
@@ -53,7 +94,7 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
|
|
||||||
Callback::from(move |e: SubmitEvent| {
|
Callback::from(move |e: SubmitEvent| {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
|
|
||||||
let server_url = (*server_url).clone();
|
let server_url = (*server_url).clone();
|
||||||
let username = (*username).clone();
|
let username = (*username).clone();
|
||||||
let password = (*password).clone();
|
let password = (*password).clone();
|
||||||
@@ -73,11 +114,18 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
web_sys::console::log_1(&"🚀 Starting login process...".into());
|
web_sys::console::log_1(&"🚀 Starting login process...".into());
|
||||||
match perform_login(server_url.clone(), username.clone(), password.clone()).await {
|
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());
|
web_sys::console::log_1(&"✅ Login successful!".into());
|
||||||
// Store token and credentials in local storage
|
// Store token and credentials in local storage
|
||||||
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
||||||
error_message.set(Some("Failed to store authentication token".to_string()));
|
error_message
|
||||||
|
.set(Some("Failed to store authentication token".to_string()));
|
||||||
|
is_loading.set(false);
|
||||||
|
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);
|
is_loading.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -87,6 +135,11 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
return;
|
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);
|
is_loading.set(false);
|
||||||
on_login.emit(token);
|
on_login.emit(token);
|
||||||
}
|
}
|
||||||
@@ -116,6 +169,15 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
onchange={on_server_url_change}
|
onchange={on_server_url_change}
|
||||||
disabled={*is_loading}
|
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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -129,6 +191,15 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
onchange={on_username_change}
|
onchange={on_username_change}
|
||||||
disabled={*is_loading}
|
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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -172,21 +243,25 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Perform login using the CalDAV auth service
|
/// Perform login using the CalDAV auth service
|
||||||
async fn perform_login(server_url: String, username: String, password: String) -> Result<(String, String), String> {
|
async fn perform_login(
|
||||||
|
server_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<(String, String, String, serde_json::Value), String> {
|
||||||
use crate::auth::{AuthService, CalDAVLoginRequest};
|
use crate::auth::{AuthService, CalDAVLoginRequest};
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("📡 Creating auth service and request...").into());
|
web_sys::console::log_1(&format!("📡 Creating auth service and request...").into());
|
||||||
|
|
||||||
let auth_service = AuthService::new();
|
let auth_service = AuthService::new();
|
||||||
let request = CalDAVLoginRequest {
|
let request = CalDAVLoginRequest {
|
||||||
server_url: server_url.clone(),
|
server_url: server_url.clone(),
|
||||||
username: username.clone(),
|
username: username.clone(),
|
||||||
password: password.clone()
|
password: password.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
|
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
|
||||||
|
|
||||||
match auth_service.login(request).await {
|
match auth_service.login(request).await {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
web_sys::console::log_1(&format!("✅ Backend responded successfully").into());
|
web_sys::console::log_1(&format!("✅ Backend responded successfully").into());
|
||||||
@@ -196,11 +271,21 @@ async fn perform_login(server_url: String, username: String, password: String) -
|
|||||||
"username": username,
|
"username": username,
|
||||||
"password": password
|
"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) => {
|
Err(err) => {
|
||||||
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
|
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
|
||||||
Err(err)
|
Err(err)
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,29 +1,33 @@
|
|||||||
pub mod login;
|
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod calendar_header;
|
|
||||||
pub mod month_view;
|
|
||||||
pub mod week_view;
|
|
||||||
pub mod event_modal;
|
|
||||||
pub mod create_calendar_modal;
|
|
||||||
pub mod context_menu;
|
|
||||||
pub mod event_context_menu;
|
|
||||||
pub mod calendar_context_menu;
|
pub mod calendar_context_menu;
|
||||||
pub mod create_event_modal;
|
pub mod calendar_header;
|
||||||
pub mod sidebar;
|
|
||||||
pub mod calendar_list_item;
|
pub mod calendar_list_item;
|
||||||
|
pub mod 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 route_handler;
|
||||||
|
pub mod sidebar;
|
||||||
|
pub mod week_view;
|
||||||
|
|
||||||
pub use login::Login;
|
|
||||||
pub use calendar::Calendar;
|
pub use calendar::Calendar;
|
||||||
pub use calendar_header::CalendarHeader;
|
|
||||||
pub use month_view::MonthView;
|
|
||||||
pub use week_view::WeekView;
|
|
||||||
pub use event_modal::EventModal;
|
|
||||||
pub use create_calendar_modal::CreateCalendarModal;
|
|
||||||
pub use context_menu::ContextMenu;
|
|
||||||
pub use event_context_menu::{EventContextMenu, DeleteAction};
|
|
||||||
pub use calendar_context_menu::CalendarContextMenu;
|
pub use calendar_context_menu::CalendarContextMenu;
|
||||||
pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
|
pub use calendar_header::CalendarHeader;
|
||||||
pub use sidebar::{Sidebar, ViewMode};
|
|
||||||
pub use calendar_list_item::CalendarListItem;
|
pub use calendar_list_item::CalendarListItem;
|
||||||
pub use route_handler::RouteHandler;
|
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 sidebar::{Sidebar, Theme, ViewMode};
|
||||||
|
pub use week_view::WeekView;
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
use yew::prelude::*;
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
use chrono::{Datelike, NaiveDate, Weekday};
|
use chrono::{Datelike, NaiveDate, Weekday};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use web_sys::window;
|
|
||||||
use wasm_bindgen::{prelude::*, JsCast};
|
use wasm_bindgen::{prelude::*, JsCast};
|
||||||
use crate::services::calendar_service::{CalendarEvent, UserInfo};
|
use web_sys::window;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct MonthViewProps {
|
pub struct MonthViewProps {
|
||||||
pub current_month: NaiveDate,
|
pub current_month: NaiveDate,
|
||||||
pub today: NaiveDate,
|
pub today: NaiveDate,
|
||||||
pub events: HashMap<NaiveDate, Vec<CalendarEvent>>,
|
pub events: HashMap<NaiveDate, Vec<VEvent>>,
|
||||||
pub on_event_click: Callback<CalendarEvent>,
|
pub on_event_click: Callback<VEvent>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub refreshing_event_uid: Option<String>,
|
pub refreshing_event_uid: Option<String>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
@@ -51,40 +52,46 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
let calculate_max_events = calculate_max_events.clone();
|
let calculate_max_events = calculate_max_events.clone();
|
||||||
use_effect_with((), move |_| {
|
use_effect_with((), move |_| {
|
||||||
let calculate_max_events_clone = calculate_max_events.clone();
|
let calculate_max_events_clone = calculate_max_events.clone();
|
||||||
|
|
||||||
// Initial calculation with a slight delay to ensure DOM is ready
|
// Initial calculation with a slight delay to ensure DOM is ready
|
||||||
if let Some(window) = window() {
|
if let Some(window) = window() {
|
||||||
let timeout_closure = Closure::wrap(Box::new(move || {
|
let timeout_closure = Closure::wrap(Box::new(move || {
|
||||||
calculate_max_events_clone();
|
calculate_max_events_clone();
|
||||||
}) as Box<dyn FnMut()>);
|
}) as Box<dyn FnMut()>);
|
||||||
|
|
||||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||||
timeout_closure.as_ref().unchecked_ref(),
|
timeout_closure.as_ref().unchecked_ref(),
|
||||||
100,
|
100,
|
||||||
);
|
);
|
||||||
timeout_closure.forget();
|
timeout_closure.forget();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup resize listener
|
// Setup resize listener
|
||||||
let resize_closure = Closure::wrap(Box::new(move || {
|
let resize_closure = Closure::wrap(Box::new(move || {
|
||||||
calculate_max_events();
|
calculate_max_events();
|
||||||
}) as Box<dyn Fn()>);
|
}) as Box<dyn Fn()>);
|
||||||
|
|
||||||
if let Some(window) = window() {
|
if let Some(window) = window() {
|
||||||
let _ = window.add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref());
|
let _ = window.add_event_listener_with_callback(
|
||||||
|
"resize",
|
||||||
|
resize_closure.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
resize_closure.forget(); // Keep the closure alive
|
resize_closure.forget(); // Keep the closure alive
|
||||||
}
|
}
|
||||||
|
|
||||||
|| {}
|
|| {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get calendar color for an event
|
// Helper function to get calendar color for an event
|
||||||
let get_event_color = |event: &CalendarEvent| -> String {
|
let get_event_color = |event: &VEvent| -> String {
|
||||||
if let Some(user_info) = &props.user_info {
|
if let Some(user_info) = &props.user_info {
|
||||||
if let Some(calendar_path) = &event.calendar_path {
|
if let Some(calendar_path) = &event.calendar_path {
|
||||||
if let Some(calendar) = user_info.calendars.iter()
|
if let Some(calendar) = user_info
|
||||||
.find(|cal| &cal.path == calendar_path) {
|
.calendars
|
||||||
|
.iter()
|
||||||
|
.find(|cal| &cal.path == calendar_path)
|
||||||
|
{
|
||||||
return calendar.color.clone();
|
return calendar.color.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,7 +109,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
<div class="weekday-header">{"Thu"}</div>
|
<div class="weekday-header">{"Thu"}</div>
|
||||||
<div class="weekday-header">{"Fri"}</div>
|
<div class="weekday-header">{"Fri"}</div>
|
||||||
<div class="weekday-header">{"Sat"}</div>
|
<div class="weekday-header">{"Sat"}</div>
|
||||||
|
|
||||||
// Days from previous month (grayed out)
|
// Days from previous month (grayed out)
|
||||||
{
|
{
|
||||||
days_from_prev_month.iter().map(|day| {
|
days_from_prev_month.iter().map(|day| {
|
||||||
@@ -111,7 +118,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Days of the current month
|
// Days of the current month
|
||||||
{
|
{
|
||||||
(1..=days_in_month).map(|day| {
|
(1..=days_in_month).map(|day| {
|
||||||
@@ -119,16 +126,16 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
let is_today = date == props.today;
|
let is_today = date == props.today;
|
||||||
let is_selected = props.selected_date == Some(date);
|
let is_selected = props.selected_date == Some(date);
|
||||||
let day_events = props.events.get(&date).cloned().unwrap_or_default();
|
let day_events = props.events.get(&date).cloned().unwrap_or_default();
|
||||||
|
|
||||||
// Calculate visible events and overflow
|
// Calculate visible events and overflow
|
||||||
let max_events = *max_events_per_day as usize;
|
let max_events = *max_events_per_day as usize;
|
||||||
let visible_events: Vec<_> = day_events.iter().take(max_events).collect();
|
let visible_events: Vec<_> = day_events.iter().take(max_events).collect();
|
||||||
let hidden_count = day_events.len().saturating_sub(max_events);
|
let hidden_count = day_events.len().saturating_sub(max_events);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class={classes!(
|
class={classes!(
|
||||||
"calendar-day",
|
"calendar-day",
|
||||||
if is_today { Some("today") } else { None },
|
if is_today { Some("today") } else { None },
|
||||||
if is_selected { Some("selected") } else { None }
|
if is_selected { Some("selected") } else { None }
|
||||||
)}
|
)}
|
||||||
@@ -161,7 +168,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
visible_events.iter().map(|event| {
|
visible_events.iter().map(|event| {
|
||||||
let event_color = get_event_color(event);
|
let event_color = get_event_color(event);
|
||||||
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
||||||
|
|
||||||
let onclick = {
|
let onclick = {
|
||||||
let on_event_click = props.on_event_click.clone();
|
let on_event_click = props.on_event_click.clone();
|
||||||
let event = (*event).clone();
|
let event = (*event).clone();
|
||||||
@@ -169,7 +176,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
on_event_click.emit(event.clone());
|
on_event_click.emit(event.clone());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let oncontextmenu = {
|
let oncontextmenu = {
|
||||||
if let Some(callback) = &props.on_event_context_menu {
|
if let Some(callback) = &props.on_event_context_menu {
|
||||||
let callback = callback.clone();
|
let callback = callback.clone();
|
||||||
@@ -182,9 +189,9 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })}
|
class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })}
|
||||||
style={format!("background-color: {}", event_color)}
|
style={format!("background-color: {}", event_color)}
|
||||||
{onclick}
|
{onclick}
|
||||||
@@ -211,7 +218,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
}
|
}
|
||||||
|
|
||||||
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
|
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -220,20 +227,34 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
|
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
|
||||||
let total_slots = 42; // 6 rows x 7 days
|
let total_slots = 42; // 6 rows x 7 days
|
||||||
let used_slots = prev_days_count + current_days_count as usize;
|
let used_slots = prev_days_count + current_days_count as usize;
|
||||||
let remaining_slots = if used_slots < total_slots { total_slots - used_slots } else { 0 };
|
let remaining_slots = if used_slots < total_slots {
|
||||||
|
total_slots - used_slots
|
||||||
(1..=remaining_slots).map(|day| {
|
} else {
|
||||||
html! {
|
0
|
||||||
<div class="calendar-day next-month">{day}</div>
|
};
|
||||||
}
|
|
||||||
}).collect::<Html>()
|
(1..=remaining_slots)
|
||||||
|
.map(|day| {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-day next-month">{day}</div>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Html>()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_days_in_month(date: NaiveDate) -> u32 {
|
fn get_days_in_month(date: NaiveDate) -> u32 {
|
||||||
NaiveDate::from_ymd_opt(
|
NaiveDate::from_ymd_opt(
|
||||||
if date.month() == 12 { date.year() + 1 } else { date.year() },
|
if date.month() == 12 {
|
||||||
if date.month() == 12 { 1 } else { date.month() + 1 },
|
date.year() + 1
|
||||||
1
|
} else {
|
||||||
|
date.year()
|
||||||
|
},
|
||||||
|
if date.month() == 12 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
date.month() + 1
|
||||||
|
},
|
||||||
|
1,
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.pred_opt()
|
.pred_opt()
|
||||||
@@ -251,7 +272,7 @@ fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday
|
|||||||
Weekday::Fri => 5,
|
Weekday::Fri => 5,
|
||||||
Weekday::Sat => 6,
|
Weekday::Sat => 6,
|
||||||
};
|
};
|
||||||
|
|
||||||
if days_before == 0 {
|
if days_before == 0 {
|
||||||
vec![]
|
vec![]
|
||||||
} else {
|
} else {
|
||||||
@@ -260,8 +281,8 @@ fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday
|
|||||||
} else {
|
} else {
|
||||||
NaiveDate::from_ymd_opt(current_month.year(), current_month.month() - 1, 1).unwrap()
|
NaiveDate::from_ymd_opt(current_month.year(), current_month.month() - 1, 1).unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
let prev_month_days = get_days_in_month(prev_month);
|
let prev_month_days = get_days_in_month(prev_month);
|
||||||
((prev_month_days - days_before as u32 + 1)..=prev_month_days).collect()
|
((prev_month_days - days_before as u32 + 1)..=prev_month_days).collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
98
frontend/src/components/recurring_edit_modal.rs
Normal file
98
frontend/src/components/recurring_edit_modal.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum RecurringEditAction {
|
||||||
|
ThisEvent,
|
||||||
|
FutureEvents,
|
||||||
|
AllEvents,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct RecurringEditModalProps {
|
||||||
|
pub show: bool,
|
||||||
|
pub event: VEvent,
|
||||||
|
pub new_start: NaiveDateTime,
|
||||||
|
pub new_end: NaiveDateTime,
|
||||||
|
pub on_choice: Callback<RecurringEditAction>,
|
||||||
|
pub on_cancel: Callback<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(RecurringEditModal)]
|
||||||
|
pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
|
||||||
|
if !props.show {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let event_title = props
|
||||||
|
.event
|
||||||
|
.summary
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("Untitled Event");
|
||||||
|
|
||||||
|
let on_this_event = {
|
||||||
|
let on_choice = props.on_choice.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_choice.emit(RecurringEditAction::ThisEvent);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_future_events = {
|
||||||
|
let on_choice = props.on_choice.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_choice.emit(RecurringEditAction::FutureEvents);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_all_events = {
|
||||||
|
let on_choice = props.on_choice.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_choice.emit(RecurringEditAction::AllEvents);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_cancel = {
|
||||||
|
let on_cancel = props.on_cancel.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_cancel.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="modal-backdrop">
|
||||||
|
<div class="modal-content recurring-edit-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{"Edit Recurring Event"}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>{format!("You're modifying \"{}\" which is part of a recurring series.", event_title)}</p>
|
||||||
|
<p>{"How would you like to apply this change?"}</p>
|
||||||
|
|
||||||
|
<div class="recurring-edit-options">
|
||||||
|
<button class="btn btn-primary recurring-option" onclick={on_this_event}>
|
||||||
|
<div class="option-title">{"This event only"}</div>
|
||||||
|
<div class="option-description">{"Change only this occurrence"}</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-primary recurring-option" onclick={on_future_events}>
|
||||||
|
<div class="option-title">{"This and future events"}</div>
|
||||||
|
<div class="option-description">{"Change this occurrence and all future occurrences"}</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-primary recurring-option" onclick={on_all_events}>
|
||||||
|
<div class="option-title">{"All events in series"}</div>
|
||||||
|
<div class="option-description">{"Change all occurrences in the series"}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick={on_cancel}>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
151
frontend/src/components/route_handler.rs
Normal file
151
frontend/src/components/route_handler.rs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
use crate::components::{Login, ViewMode};
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
|
use yew::prelude::*;
|
||||||
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Routable, PartialEq)]
|
||||||
|
pub enum Route {
|
||||||
|
#[at("/")]
|
||||||
|
Home,
|
||||||
|
#[at("/login")]
|
||||||
|
Login,
|
||||||
|
#[at("/calendar")]
|
||||||
|
Calendar,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct RouteHandlerProps {
|
||||||
|
pub auth_token: Option<String>,
|
||||||
|
pub user_info: Option<UserInfo>,
|
||||||
|
pub on_login: Callback<String>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub view: ViewMode,
|
||||||
|
#[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>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub context_menus_open: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(RouteHandler)]
|
||||||
|
pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
||||||
|
let auth_token = props.auth_token.clone();
|
||||||
|
let user_info = props.user_info.clone();
|
||||||
|
let on_login = props.on_login.clone();
|
||||||
|
let on_event_context_menu = props.on_event_context_menu.clone();
|
||||||
|
let on_calendar_context_menu = props.on_calendar_context_menu.clone();
|
||||||
|
let view = props.view.clone();
|
||||||
|
let on_create_event_request = props.on_create_event_request.clone();
|
||||||
|
let on_event_update_request = props.on_event_update_request.clone();
|
||||||
|
let context_menus_open = props.context_menus_open;
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<Switch<Route> render={move |route| {
|
||||||
|
let auth_token = auth_token.clone();
|
||||||
|
let user_info = user_info.clone();
|
||||||
|
let on_login = on_login.clone();
|
||||||
|
let on_event_context_menu = on_event_context_menu.clone();
|
||||||
|
let on_calendar_context_menu = on_calendar_context_menu.clone();
|
||||||
|
let view = view.clone();
|
||||||
|
let on_create_event_request = on_create_event_request.clone();
|
||||||
|
let on_event_update_request = on_event_update_request.clone();
|
||||||
|
let context_menus_open = context_menus_open;
|
||||||
|
|
||||||
|
match route {
|
||||||
|
Route::Home => {
|
||||||
|
if auth_token.is_some() {
|
||||||
|
html! { <Redirect<Route> to={Route::Calendar}/> }
|
||||||
|
} else {
|
||||||
|
html! { <Redirect<Route> to={Route::Login}/> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Route::Login => {
|
||||||
|
if auth_token.is_some() {
|
||||||
|
html! { <Redirect<Route> to={Route::Calendar}/> }
|
||||||
|
} else {
|
||||||
|
html! { <Login {on_login} /> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Route::Calendar => {
|
||||||
|
if auth_token.is_some() {
|
||||||
|
html! {
|
||||||
|
<CalendarView
|
||||||
|
user_info={user_info}
|
||||||
|
on_event_context_menu={on_event_context_menu}
|
||||||
|
on_calendar_context_menu={on_calendar_context_menu}
|
||||||
|
view={view}
|
||||||
|
on_create_event_request={on_create_event_request}
|
||||||
|
on_event_update_request={on_event_update_request}
|
||||||
|
context_menus_open={context_menus_open}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! { <Redirect<Route> to={Route::Login}/> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct CalendarViewProps {
|
||||||
|
pub user_info: Option<UserInfo>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub view: ViewMode,
|
||||||
|
#[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>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub context_menus_open: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
use crate::components::Calendar;
|
||||||
|
|
||||||
|
#[function_component(CalendarView)]
|
||||||
|
pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-view">
|
||||||
|
<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::prelude::*;
|
||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
use web_sys::HtmlSelectElement;
|
|
||||||
use crate::services::calendar_service::UserInfo;
|
|
||||||
use crate::components::CalendarListItem;
|
|
||||||
|
|
||||||
#[derive(Clone, Routable, PartialEq)]
|
#[derive(Clone, Routable, PartialEq)]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
@@ -20,6 +20,46 @@ pub enum ViewMode {
|
|||||||
Week,
|
Week,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum Theme {
|
||||||
|
Default,
|
||||||
|
Ocean,
|
||||||
|
Forest,
|
||||||
|
Sunset,
|
||||||
|
Purple,
|
||||||
|
Dark,
|
||||||
|
Rose,
|
||||||
|
Mint,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
pub fn value(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Theme::Default => "default",
|
||||||
|
Theme::Ocean => "ocean",
|
||||||
|
Theme::Forest => "forest",
|
||||||
|
Theme::Sunset => "sunset",
|
||||||
|
Theme::Purple => "purple",
|
||||||
|
Theme::Dark => "dark",
|
||||||
|
Theme::Rose => "rose",
|
||||||
|
Theme::Mint => "mint",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_value(value: &str) -> Self {
|
||||||
|
match value {
|
||||||
|
"ocean" => Theme::Ocean,
|
||||||
|
"forest" => Theme::Forest,
|
||||||
|
"sunset" => Theme::Sunset,
|
||||||
|
"purple" => Theme::Purple,
|
||||||
|
"dark" => Theme::Dark,
|
||||||
|
"rose" => Theme::Rose,
|
||||||
|
"mint" => Theme::Mint,
|
||||||
|
_ => Theme::Default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for ViewMode {
|
impl Default for ViewMode {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
ViewMode::Month
|
ViewMode::Month
|
||||||
@@ -38,6 +78,8 @@ pub struct SidebarProps {
|
|||||||
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
||||||
pub current_view: ViewMode,
|
pub current_view: ViewMode,
|
||||||
pub on_view_change: Callback<ViewMode>,
|
pub on_view_change: Callback<ViewMode>,
|
||||||
|
pub current_theme: Theme,
|
||||||
|
pub on_theme_change: Callback<Theme>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component(Sidebar)]
|
#[function_component(Sidebar)]
|
||||||
@@ -57,6 +99,18 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let on_theme_change = {
|
||||||
|
let on_theme_change = props.on_theme_change.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_dyn_into::<HtmlSelectElement>();
|
||||||
|
if let Some(select) = target {
|
||||||
|
let value = select.value();
|
||||||
|
let new_theme = Theme::from_value(&value);
|
||||||
|
on_theme_change.emit(new_theme);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<aside class="app-sidebar">
|
<aside class="app-sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
@@ -112,16 +166,29 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
|
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
|
||||||
{"+ Create Calendar"}
|
{"+ Create Calendar"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="view-selector">
|
<div class="view-selector">
|
||||||
<select class="view-selector-dropdown" onchange={on_view_change}>
|
<select class="view-selector-dropdown" onchange={on_view_change}>
|
||||||
<option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option>
|
<option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option>
|
||||||
<option value="week" selected={matches!(props.current_view, ViewMode::Week)}>{"Week"}</option>
|
<option value="week" selected={matches!(props.current_view, ViewMode::Week)}>{"Week"}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-selector">
|
||||||
|
<select class="theme-selector-dropdown" onchange={on_theme_change}>
|
||||||
|
<option value="default" selected={matches!(props.current_theme, Theme::Default)}>{"Default"}</option>
|
||||||
|
<option value="ocean" selected={matches!(props.current_theme, Theme::Ocean)}>{"Ocean"}</option>
|
||||||
|
<option value="forest" selected={matches!(props.current_theme, Theme::Forest)}>{"Forest"}</option>
|
||||||
|
<option value="sunset" selected={matches!(props.current_theme, Theme::Sunset)}>{"Sunset"}</option>
|
||||||
|
<option value="purple" selected={matches!(props.current_theme, Theme::Purple)}>{"Purple"}</option>
|
||||||
|
<option value="dark" selected={matches!(props.current_theme, Theme::Dark)}>{"Dark"}</option>
|
||||||
|
<option value="rose" selected={matches!(props.current_theme, Theme::Rose)}>{"Rose"}</option>
|
||||||
|
<option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
|
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1067
frontend/src/components/week_view.rs
Normal file
1067
frontend/src/components/week_view.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
|||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod components;
|
mod components;
|
||||||
|
mod models;
|
||||||
mod services;
|
mod services;
|
||||||
|
|
||||||
use app::App;
|
use app::App;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
yew::Renderer::<App>::new().render();
|
yew::Renderer::<App>::new().render();
|
||||||
}
|
}
|
||||||
2
frontend/src/models/ical.rs
Normal file
2
frontend/src/models/ical.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Re-export from shared calendar-models library for backward compatibility
|
||||||
|
pub use calendar_models::*;
|
||||||
5
frontend/src/models/mod.rs
Normal file
5
frontend/src/models/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// RFC 5545 Compliant iCalendar Models
|
||||||
|
pub mod ical;
|
||||||
|
|
||||||
|
// Re-export commonly used types
|
||||||
|
// pub use ical::VEvent;
|
||||||
1844
frontend/src/services/calendar_service.rs
Normal file
1844
frontend/src/services/calendar_service.rs
Normal file
File diff suppressed because it is too large
Load Diff
5
frontend/src/services/mod.rs
Normal file
5
frontend/src/services/mod.rs
Normal file
@@ -0,0 +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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
3501
frontend/styles.css
Normal file
3501
frontend/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
10
index.html
10
index.html
@@ -1,10 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>Calendar App</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link data-trunk rel="css" href="styles.css">
|
|
||||||
</head>
|
|
||||||
<body></body>
|
|
||||||
</html>
|
|
||||||
@@ -1,808 +0,0 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::{HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement};
|
|
||||||
use chrono::{NaiveDate, NaiveTime};
|
|
||||||
use crate::services::calendar_service::{CalendarInfo, CalendarEvent};
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
|
||||||
pub struct CreateEventModalProps {
|
|
||||||
pub is_open: bool,
|
|
||||||
pub selected_date: Option<NaiveDate>,
|
|
||||||
pub event_to_edit: Option<CalendarEvent>,
|
|
||||||
pub on_close: Callback<()>,
|
|
||||||
pub on_create: Callback<EventCreationData>,
|
|
||||||
pub on_update: Callback<(CalendarEvent, EventCreationData)>, // (original_event, updated_data)
|
|
||||||
pub available_calendars: Vec<CalendarInfo>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub initial_start_time: Option<NaiveTime>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub initial_end_time: Option<NaiveTime>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
|
||||||
pub enum EventStatus {
|
|
||||||
Tentative,
|
|
||||||
Confirmed,
|
|
||||||
Cancelled,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for EventStatus {
|
|
||||||
fn default() -> Self {
|
|
||||||
EventStatus::Confirmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventStatus {
|
|
||||||
pub fn from_service_status(status: &crate::services::calendar_service::EventStatus) -> Self {
|
|
||||||
match status {
|
|
||||||
crate::services::calendar_service::EventStatus::Tentative => EventStatus::Tentative,
|
|
||||||
crate::services::calendar_service::EventStatus::Confirmed => EventStatus::Confirmed,
|
|
||||||
crate::services::calendar_service::EventStatus::Cancelled => EventStatus::Cancelled,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
|
||||||
pub enum EventClass {
|
|
||||||
Public,
|
|
||||||
Private,
|
|
||||||
Confidential,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for EventClass {
|
|
||||||
fn default() -> Self {
|
|
||||||
EventClass::Public
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventClass {
|
|
||||||
pub fn from_service_class(class: &crate::services::calendar_service::EventClass) -> Self {
|
|
||||||
match class {
|
|
||||||
crate::services::calendar_service::EventClass::Public => EventClass::Public,
|
|
||||||
crate::services::calendar_service::EventClass::Private => EventClass::Private,
|
|
||||||
crate::services::calendar_service::EventClass::Confidential => EventClass::Confidential,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
|
||||||
pub enum ReminderType {
|
|
||||||
None,
|
|
||||||
Minutes15,
|
|
||||||
Minutes30,
|
|
||||||
Hour1,
|
|
||||||
Hours2,
|
|
||||||
Day1,
|
|
||||||
Days2,
|
|
||||||
Week1,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ReminderType {
|
|
||||||
fn default() -> Self {
|
|
||||||
ReminderType::None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
|
||||||
pub enum RecurrenceType {
|
|
||||||
None,
|
|
||||||
Daily,
|
|
||||||
Weekly,
|
|
||||||
Monthly,
|
|
||||||
Yearly,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for RecurrenceType {
|
|
||||||
fn default() -> Self {
|
|
||||||
RecurrenceType::None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RecurrenceType {
|
|
||||||
pub fn from_rrule(rrule: Option<&str>) -> Self {
|
|
||||||
match rrule {
|
|
||||||
Some(rule) if rule.contains("FREQ=DAILY") => RecurrenceType::Daily,
|
|
||||||
Some(rule) if rule.contains("FREQ=WEEKLY") => RecurrenceType::Weekly,
|
|
||||||
Some(rule) if rule.contains("FREQ=MONTHLY") => RecurrenceType::Monthly,
|
|
||||||
Some(rule) if rule.contains("FREQ=YEARLY") => RecurrenceType::Yearly,
|
|
||||||
_ => RecurrenceType::None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
|
||||||
pub struct EventCreationData {
|
|
||||||
pub title: String,
|
|
||||||
pub description: String,
|
|
||||||
pub start_date: NaiveDate,
|
|
||||||
pub start_time: NaiveTime,
|
|
||||||
pub end_date: NaiveDate,
|
|
||||||
pub end_time: NaiveTime,
|
|
||||||
pub location: String,
|
|
||||||
pub all_day: bool,
|
|
||||||
pub status: EventStatus,
|
|
||||||
pub class: EventClass,
|
|
||||||
pub priority: Option<u8>,
|
|
||||||
pub organizer: String,
|
|
||||||
pub attendees: String, // Comma-separated list
|
|
||||||
pub categories: String, // Comma-separated list
|
|
||||||
pub reminder: ReminderType,
|
|
||||||
pub recurrence: RecurrenceType,
|
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
|
||||||
pub selected_calendar: Option<String>, // Calendar path
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for EventCreationData {
|
|
||||||
fn default() -> Self {
|
|
||||||
let now = chrono::Local::now().naive_local();
|
|
||||||
let start_time = NaiveTime::from_hms_opt(9, 0, 0).unwrap_or_default();
|
|
||||||
let end_time = NaiveTime::from_hms_opt(10, 0, 0).unwrap_or_default();
|
|
||||||
|
|
||||||
Self {
|
|
||||||
title: String::new(),
|
|
||||||
description: String::new(),
|
|
||||||
start_date: now.date(),
|
|
||||||
start_time,
|
|
||||||
end_date: now.date(),
|
|
||||||
end_time,
|
|
||||||
location: String::new(),
|
|
||||||
all_day: false,
|
|
||||||
status: EventStatus::default(),
|
|
||||||
class: EventClass::default(),
|
|
||||||
priority: None,
|
|
||||||
organizer: String::new(),
|
|
||||||
attendees: String::new(),
|
|
||||||
categories: String::new(),
|
|
||||||
reminder: ReminderType::default(),
|
|
||||||
recurrence: RecurrenceType::default(),
|
|
||||||
recurrence_days: vec![false; 7], // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] - all false by default
|
|
||||||
selected_calendar: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventCreationData {
|
|
||||||
pub fn from_calendar_event(event: &CalendarEvent) -> Self {
|
|
||||||
// Convert CalendarEvent to EventCreationData for editing
|
|
||||||
// All events (including temporary drag events) now have proper UTC times
|
|
||||||
// Convert to local time for display in the modal
|
|
||||||
|
|
||||||
Self {
|
|
||||||
title: event.summary.clone().unwrap_or_default(),
|
|
||||||
description: event.description.clone().unwrap_or_default(),
|
|
||||||
start_date: event.start.with_timezone(&chrono::Local).date_naive(),
|
|
||||||
start_time: event.start.with_timezone(&chrono::Local).time(),
|
|
||||||
end_date: event.end.as_ref().map(|e| e.with_timezone(&chrono::Local).date_naive()).unwrap_or(event.start.with_timezone(&chrono::Local).date_naive()),
|
|
||||||
end_time: event.end.as_ref().map(|e| e.with_timezone(&chrono::Local).time()).unwrap_or(event.start.with_timezone(&chrono::Local).time()),
|
|
||||||
location: event.location.clone().unwrap_or_default(),
|
|
||||||
all_day: event.all_day,
|
|
||||||
status: EventStatus::from_service_status(&event.status),
|
|
||||||
class: EventClass::from_service_class(&event.class),
|
|
||||||
priority: event.priority,
|
|
||||||
organizer: event.organizer.clone().unwrap_or_default(),
|
|
||||||
attendees: event.attendees.join(", "),
|
|
||||||
categories: event.categories.join(", "),
|
|
||||||
reminder: ReminderType::default(), // TODO: Convert from event reminders
|
|
||||||
recurrence: RecurrenceType::from_rrule(event.recurrence_rule.as_deref()),
|
|
||||||
recurrence_days: vec![false; 7], // TODO: Parse from RRULE
|
|
||||||
selected_calendar: event.calendar_path.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[function_component(CreateEventModal)]
|
|
||||||
pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
|
|
||||||
let event_data = use_state(|| EventCreationData::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), {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
move |(selected_date, event_to_edit, is_open, available_calendars, initial_start_time, initial_end_time)| {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
|| ()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if !props.is_open {
|
|
||||||
return html! {};
|
|
||||||
}
|
|
||||||
|
|
||||||
let on_backdrop_click = {
|
|
||||||
let on_close = props.on_close.clone();
|
|
||||||
Callback::from(move |e: MouseEvent| {
|
|
||||||
if e.target() == e.current_target() {
|
|
||||||
on_close.emit(());
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_title_input = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: InputEvent| {
|
|
||||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
data.title = input.value();
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_calendar_change = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: Event| {
|
|
||||||
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
let value = select.value();
|
|
||||||
data.selected_calendar = if value.is_empty() { None } else { Some(value) };
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_description_input = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: InputEvent| {
|
|
||||||
if let Some(textarea) = e.target_dyn_into::<HtmlTextAreaElement>() {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
data.description = textarea.value();
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_location_input = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: InputEvent| {
|
|
||||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
data.location = input.value();
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_organizer_input = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: InputEvent| {
|
|
||||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
data.organizer = input.value();
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_attendees_input = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: InputEvent| {
|
|
||||||
if let Some(textarea) = e.target_dyn_into::<HtmlTextAreaElement>() {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
data.attendees = textarea.value();
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_categories_input = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: InputEvent| {
|
|
||||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
data.categories = input.value();
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_status_change = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: Event| {
|
|
||||||
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
data.status = match select.value().as_str() {
|
|
||||||
"tentative" => EventStatus::Tentative,
|
|
||||||
"cancelled" => EventStatus::Cancelled,
|
|
||||||
_ => EventStatus::Confirmed,
|
|
||||||
};
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_class_change = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: Event| {
|
|
||||||
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
data.class = match select.value().as_str() {
|
|
||||||
"private" => EventClass::Private,
|
|
||||||
"confidential" => EventClass::Confidential,
|
|
||||||
_ => EventClass::Public,
|
|
||||||
};
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_priority_input = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: InputEvent| {
|
|
||||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
data.priority = input.value().parse::<u8>().ok().filter(|&p| p <= 9);
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_reminder_change = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: Event| {
|
|
||||||
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
data.reminder = match select.value().as_str() {
|
|
||||||
"15min" => ReminderType::Minutes15,
|
|
||||||
"30min" => ReminderType::Minutes30,
|
|
||||||
"1hour" => ReminderType::Hour1,
|
|
||||||
"2hours" => ReminderType::Hours2,
|
|
||||||
"1day" => ReminderType::Day1,
|
|
||||||
"2days" => ReminderType::Days2,
|
|
||||||
"1week" => ReminderType::Week1,
|
|
||||||
_ => ReminderType::None,
|
|
||||||
};
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_recurrence_change = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: Event| {
|
|
||||||
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
data.recurrence = match select.value().as_str() {
|
|
||||||
"daily" => RecurrenceType::Daily,
|
|
||||||
"weekly" => RecurrenceType::Weekly,
|
|
||||||
"monthly" => RecurrenceType::Monthly,
|
|
||||||
"yearly" => RecurrenceType::Yearly,
|
|
||||||
_ => RecurrenceType::None,
|
|
||||||
};
|
|
||||||
// Reset recurrence days when changing recurrence type
|
|
||||||
data.recurrence_days = vec![false; 7];
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_weekday_change = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
move |day_index: usize| {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: Event| {
|
|
||||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
if day_index < data.recurrence_days.len() {
|
|
||||||
data.recurrence_days[day_index] = input.checked();
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_start_date_change = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: Event| {
|
|
||||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
|
||||||
if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
data.start_date = date;
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_start_time_change = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: Event| {
|
|
||||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
|
||||||
if let Ok(time) = NaiveTime::parse_from_str(&input.value(), "%H:%M") {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
data.start_time = time;
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_end_date_change = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: Event| {
|
|
||||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
|
||||||
if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
data.end_date = date;
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_end_time_change = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: Event| {
|
|
||||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
|
||||||
if let Ok(time) = NaiveTime::parse_from_str(&input.value(), "%H:%M") {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
data.end_time = time;
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_all_day_change = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
Callback::from(move |e: Event| {
|
|
||||||
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
|
||||||
let mut data = (*event_data).clone();
|
|
||||||
data.all_day = input.checked();
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_submit_click = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
let on_create = props.on_create.clone();
|
|
||||||
let on_update = props.on_update.clone();
|
|
||||||
let event_to_edit = props.event_to_edit.clone();
|
|
||||||
Callback::from(move |_: MouseEvent| {
|
|
||||||
if let Some(original_event) = &event_to_edit {
|
|
||||||
// We're editing - call on_update with original event and new data
|
|
||||||
on_update.emit((original_event.clone(), (*event_data).clone()));
|
|
||||||
} else {
|
|
||||||
// We're creating - call on_create with new data
|
|
||||||
on_create.emit((*event_data).clone());
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_cancel_click = {
|
|
||||||
let on_close = props.on_close.clone();
|
|
||||||
Callback::from(move |_: MouseEvent| {
|
|
||||||
on_close.emit(());
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let data = &*event_data;
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<div class="modal-backdrop" onclick={on_backdrop_click}>
|
|
||||||
<div class="modal-content create-event-modal" onclick={Callback::from(|e: MouseEvent| e.stop_propagation())}>
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>{if props.event_to_edit.is_some() { "Edit Event" } else { "Create New Event" }}</h3>
|
|
||||||
<button type="button" class="modal-close" onclick={Callback::from({
|
|
||||||
let on_close = props.on_close.clone();
|
|
||||||
move |_: MouseEvent| on_close.emit(())
|
|
||||||
})}>{"×"}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="event-title">{"Title *"}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="event-title"
|
|
||||||
class="form-input"
|
|
||||||
value={data.title.clone()}
|
|
||||||
oninput={on_title_input}
|
|
||||||
placeholder="Enter event title"
|
|
||||||
required=true
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="event-calendar">{"Calendar"}</label>
|
|
||||||
<select
|
|
||||||
id="event-calendar"
|
|
||||||
class="form-input"
|
|
||||||
onchange={on_calendar_change}
|
|
||||||
>
|
|
||||||
<option value="" selected={data.selected_calendar.is_none()}>{"Select calendar..."}</option>
|
|
||||||
{
|
|
||||||
props.available_calendars.iter().map(|calendar| {
|
|
||||||
let is_selected = data.selected_calendar.as_ref() == Some(&calendar.path);
|
|
||||||
html! {
|
|
||||||
<option
|
|
||||||
key={calendar.path.clone()}
|
|
||||||
value={calendar.path.clone()}
|
|
||||||
selected={is_selected}
|
|
||||||
>
|
|
||||||
{&calendar.display_name}
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
}).collect::<Html>()
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="event-description">{"Description"}</label>
|
|
||||||
<textarea
|
|
||||||
id="event-description"
|
|
||||||
class="form-input"
|
|
||||||
value={data.description.clone()}
|
|
||||||
oninput={on_description_input}
|
|
||||||
placeholder="Enter event description"
|
|
||||||
rows="3"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={data.all_day}
|
|
||||||
onchange={on_all_day_change}
|
|
||||||
/>
|
|
||||||
{" All Day"}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="start-date">{"Start Date *"}</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="start-date"
|
|
||||||
class="form-input"
|
|
||||||
value={data.start_date.format("%Y-%m-%d").to_string()}
|
|
||||||
onchange={on_start_date_change}
|
|
||||||
required=true
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
if !data.all_day {
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="start-time">{"Start Time"}</label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
id="start-time"
|
|
||||||
class="form-input"
|
|
||||||
value={data.start_time.format("%H:%M").to_string()}
|
|
||||||
onchange={on_start_time_change}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="end-date">{"End Date *"}</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="end-date"
|
|
||||||
class="form-input"
|
|
||||||
value={data.end_date.format("%Y-%m-%d").to_string()}
|
|
||||||
onchange={on_end_date_change}
|
|
||||||
required=true
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
if !data.all_day {
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="end-time">{"End Time"}</label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
id="end-time"
|
|
||||||
class="form-input"
|
|
||||||
value={data.end_time.format("%H:%M").to_string()}
|
|
||||||
onchange={on_end_time_change}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="event-location">{"Location"}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="event-location"
|
|
||||||
class="form-input"
|
|
||||||
value={data.location.clone()}
|
|
||||||
oninput={on_location_input}
|
|
||||||
placeholder="Enter event location"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="event-status">{"Status"}</label>
|
|
||||||
<select
|
|
||||||
id="event-status"
|
|
||||||
class="form-input"
|
|
||||||
onchange={on_status_change}
|
|
||||||
>
|
|
||||||
<option value="confirmed" selected={matches!(data.status, EventStatus::Confirmed)}>{"Confirmed"}</option>
|
|
||||||
<option value="tentative" selected={matches!(data.status, EventStatus::Tentative)}>{"Tentative"}</option>
|
|
||||||
<option value="cancelled" selected={matches!(data.status, EventStatus::Cancelled)}>{"Cancelled"}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="event-class">{"Privacy"}</label>
|
|
||||||
<select
|
|
||||||
id="event-class"
|
|
||||||
class="form-input"
|
|
||||||
onchange={on_class_change}
|
|
||||||
>
|
|
||||||
<option value="public" selected={matches!(data.class, EventClass::Public)}>{"Public"}</option>
|
|
||||||
<option value="private" selected={matches!(data.class, EventClass::Private)}>{"Private"}</option>
|
|
||||||
<option value="confidential" selected={matches!(data.class, EventClass::Confidential)}>{"Confidential"}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="event-priority">{"Priority (0-9, optional)"}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="event-priority"
|
|
||||||
class="form-input"
|
|
||||||
value={data.priority.map(|p| p.to_string()).unwrap_or_default()}
|
|
||||||
oninput={on_priority_input}
|
|
||||||
placeholder="0-9 priority level"
|
|
||||||
min="0"
|
|
||||||
max="9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="event-organizer">{"Organizer Email"}</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="event-organizer"
|
|
||||||
class="form-input"
|
|
||||||
value={data.organizer.clone()}
|
|
||||||
oninput={on_organizer_input}
|
|
||||||
placeholder="organizer@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="event-attendees">{"Attendees (comma-separated emails)"}</label>
|
|
||||||
<textarea
|
|
||||||
id="event-attendees"
|
|
||||||
class="form-input"
|
|
||||||
value={data.attendees.clone()}
|
|
||||||
oninput={on_attendees_input}
|
|
||||||
placeholder="attendee1@example.com, attendee2@example.com"
|
|
||||||
rows="2"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="event-categories">{"Categories (comma-separated)"}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="event-categories"
|
|
||||||
class="form-input"
|
|
||||||
value={data.categories.clone()}
|
|
||||||
oninput={on_categories_input}
|
|
||||||
placeholder="work, meeting, personal"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="event-reminder">{"Reminder"}</label>
|
|
||||||
<select
|
|
||||||
id="event-reminder"
|
|
||||||
class="form-input"
|
|
||||||
onchange={on_reminder_change}
|
|
||||||
>
|
|
||||||
<option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"None"}</option>
|
|
||||||
<option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes"}</option>
|
|
||||||
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes"}</option>
|
|
||||||
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour"}</option>
|
|
||||||
<option value="2hours" selected={matches!(data.reminder, ReminderType::Hours2)}>{"2 hours"}</option>
|
|
||||||
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day"}</option>
|
|
||||||
<option value="2days" selected={matches!(data.reminder, ReminderType::Days2)}>{"2 days"}</option>
|
|
||||||
<option value="1week" selected={matches!(data.reminder, ReminderType::Week1)}>{"1 week"}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="event-recurrence">{"Recurrence"}</label>
|
|
||||||
<select
|
|
||||||
id="event-recurrence"
|
|
||||||
class="form-input"
|
|
||||||
onchange={on_recurrence_change}
|
|
||||||
>
|
|
||||||
<option value="none" selected={matches!(data.recurrence, RecurrenceType::None)}>{"None"}</option>
|
|
||||||
<option value="daily" selected={matches!(data.recurrence, RecurrenceType::Daily)}>{"Daily"}</option>
|
|
||||||
<option value="weekly" selected={matches!(data.recurrence, RecurrenceType::Weekly)}>{"Weekly"}</option>
|
|
||||||
<option value="monthly" selected={matches!(data.recurrence, RecurrenceType::Monthly)}>{"Monthly"}</option>
|
|
||||||
<option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Show weekday selection only when weekly recurrence is selected
|
|
||||||
if matches!(data.recurrence, RecurrenceType::Weekly) {
|
|
||||||
<div class="form-group">
|
|
||||||
<label>{"Repeat on"}</label>
|
|
||||||
<div class="weekday-selection">
|
|
||||||
{
|
|
||||||
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, day)| {
|
|
||||||
let day_checked = data.recurrence_days.get(i).cloned().unwrap_or(false);
|
|
||||||
let on_change = on_weekday_change(i);
|
|
||||||
html! {
|
|
||||||
<label key={i} class="weekday-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={day_checked}
|
|
||||||
onchange={on_change}
|
|
||||||
/>
|
|
||||||
<span class="weekday-label">{day}</span>
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Html>()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" onclick={on_cancel_click}>
|
|
||||||
{"Cancel"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary"
|
|
||||||
onclick={on_submit_click}
|
|
||||||
disabled={data.title.trim().is_empty()}
|
|
||||||
>
|
|
||||||
{if props.event_to_edit.is_some() { "Update Event" } else { "Create Event" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use yew_router::prelude::*;
|
|
||||||
use crate::components::{Login, ViewMode};
|
|
||||||
use crate::services::calendar_service::{UserInfo, CalendarEvent};
|
|
||||||
|
|
||||||
#[derive(Clone, Routable, PartialEq)]
|
|
||||||
pub enum Route {
|
|
||||||
#[at("/")]
|
|
||||||
Home,
|
|
||||||
#[at("/login")]
|
|
||||||
Login,
|
|
||||||
#[at("/calendar")]
|
|
||||||
Calendar,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
|
||||||
pub struct RouteHandlerProps {
|
|
||||||
pub auth_token: Option<String>,
|
|
||||||
pub user_info: Option<UserInfo>,
|
|
||||||
pub on_login: Callback<String>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub view: ViewMode,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub on_event_update_request: Option<Callback<(CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime)>>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub context_menus_open: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[function_component(RouteHandler)]
|
|
||||||
pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
|
||||||
let auth_token = props.auth_token.clone();
|
|
||||||
let user_info = props.user_info.clone();
|
|
||||||
let on_login = props.on_login.clone();
|
|
||||||
let on_event_context_menu = props.on_event_context_menu.clone();
|
|
||||||
let on_calendar_context_menu = props.on_calendar_context_menu.clone();
|
|
||||||
let view = props.view.clone();
|
|
||||||
let on_create_event_request = props.on_create_event_request.clone();
|
|
||||||
let on_event_update_request = props.on_event_update_request.clone();
|
|
||||||
let context_menus_open = props.context_menus_open;
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<Switch<Route> render={move |route| {
|
|
||||||
let auth_token = auth_token.clone();
|
|
||||||
let user_info = user_info.clone();
|
|
||||||
let on_login = on_login.clone();
|
|
||||||
let on_event_context_menu = on_event_context_menu.clone();
|
|
||||||
let on_calendar_context_menu = on_calendar_context_menu.clone();
|
|
||||||
let view = view.clone();
|
|
||||||
let on_create_event_request = on_create_event_request.clone();
|
|
||||||
let on_event_update_request = on_event_update_request.clone();
|
|
||||||
let context_menus_open = context_menus_open;
|
|
||||||
|
|
||||||
match route {
|
|
||||||
Route::Home => {
|
|
||||||
if auth_token.is_some() {
|
|
||||||
html! { <Redirect<Route> to={Route::Calendar}/> }
|
|
||||||
} else {
|
|
||||||
html! { <Redirect<Route> to={Route::Login}/> }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Route::Login => {
|
|
||||||
if auth_token.is_some() {
|
|
||||||
html! { <Redirect<Route> to={Route::Calendar}/> }
|
|
||||||
} else {
|
|
||||||
html! { <Login {on_login} /> }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Route::Calendar => {
|
|
||||||
if auth_token.is_some() {
|
|
||||||
html! {
|
|
||||||
<CalendarView
|
|
||||||
user_info={user_info}
|
|
||||||
on_event_context_menu={on_event_context_menu}
|
|
||||||
on_calendar_context_menu={on_calendar_context_menu}
|
|
||||||
view={view}
|
|
||||||
on_create_event_request={on_create_event_request}
|
|
||||||
on_event_update_request={on_event_update_request}
|
|
||||||
context_menus_open={context_menus_open}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! { <Redirect<Route> to={Route::Login}/> }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
|
||||||
pub struct CalendarViewProps {
|
|
||||||
pub user_info: Option<UserInfo>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub view: ViewMode,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub on_event_update_request: Option<Callback<(CalendarEvent, chrono::NaiveDateTime, chrono::NaiveDateTime)>>,
|
|
||||||
#[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<CalendarEvent>>::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: CalendarEvent| {
|
|
||||||
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 mut updated_events = (*events).clone();
|
|
||||||
|
|
||||||
for (_, day_events) in updated_events.iter_mut() {
|
|
||||||
day_events.retain(|e| e.uid != uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if refreshed_event.recurrence_rule.is_some() {
|
|
||||||
let new_occurrences = CalendarService::expand_recurring_events(vec![refreshed_event.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_event.get_date();
|
|
||||||
updated_events.entry(date)
|
|
||||||
.or_insert_with(Vec::new)
|
|
||||||
.push(refreshed_event);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(&token, &password, current_year, current_month).await {
|
|
||||||
Ok(calendar_events) => {
|
|
||||||
let grouped_events = CalendarService::group_events_by_date(calendar_events);
|
|
||||||
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(|_: CalendarEvent| {});
|
|
||||||
html! {
|
|
||||||
<div class="calendar-error">
|
|
||||||
<p>{format!("Error: {}", err)}</p>
|
|
||||||
<Calendar
|
|
||||||
events={HashMap::new()}
|
|
||||||
on_event_click={dummy_callback}
|
|
||||||
refreshing_event_uid={(*refreshing_event).clone()}
|
|
||||||
user_info={props.user_info.clone()}
|
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
|
||||||
view={props.view.clone()}
|
|
||||||
on_create_event_request={props.on_create_event_request.clone()}
|
|
||||||
on_event_update_request={props.on_event_update_request.clone()}
|
|
||||||
context_menus_open={props.context_menus_open}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
<Calendar
|
|
||||||
events={(*events).clone()}
|
|
||||||
on_event_click={on_event_click}
|
|
||||||
refreshing_event_uid={(*refreshing_event).clone()}
|
|
||||||
user_info={props.user_info.clone()}
|
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
|
||||||
view={props.view.clone()}
|
|
||||||
on_create_event_request={props.on_create_event_request.clone()}
|
|
||||||
on_event_update_request={props.on_event_update_request.clone()}
|
|
||||||
context_menus_open={props.context_menus_open}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,584 +0,0 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use chrono::{Datelike, NaiveDate, Duration, Weekday, Local, Timelike, NaiveDateTime, NaiveTime};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use web_sys::MouseEvent;
|
|
||||||
use crate::services::calendar_service::{CalendarEvent, UserInfo};
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
|
||||||
pub struct WeekViewProps {
|
|
||||||
pub current_date: NaiveDate,
|
|
||||||
pub today: NaiveDate,
|
|
||||||
pub events: HashMap<NaiveDate, Vec<CalendarEvent>>,
|
|
||||||
pub on_event_click: Callback<CalendarEvent>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub refreshing_event_uid: Option<String>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub user_info: Option<UserInfo>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub on_create_event: Option<Callback<(NaiveDate, NaiveDateTime, NaiveDateTime)>>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub on_event_update: Option<Callback<(CalendarEvent, NaiveDateTime, NaiveDateTime)>>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub context_menus_open: bool,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub time_increment: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
|
||||||
enum DragType {
|
|
||||||
CreateEvent,
|
|
||||||
MoveEvent(CalendarEvent),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
|
||||||
struct DragState {
|
|
||||||
is_dragging: bool,
|
|
||||||
drag_type: DragType,
|
|
||||||
start_date: NaiveDate,
|
|
||||||
start_y: f64,
|
|
||||||
current_y: f64,
|
|
||||||
offset_y: f64, // For event moves, this is the offset from the event's top
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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();
|
|
||||||
|
|
||||||
// Drag state for event creation
|
|
||||||
let drag_state = use_state(|| None::<DragState>);
|
|
||||||
|
|
||||||
// Helper function to get calendar color for an event
|
|
||||||
let get_event_color = |event: &CalendarEvent| -> 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) {
|
|
||||||
return calendar.color.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"#3B82F6".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
// Add the final midnight boundary to show where the day ends
|
|
||||||
time_labels.push("12 AM".to_string());
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<div class="week-view-container">
|
|
||||||
// Header with weekday names and dates
|
|
||||||
<div class="week-header">
|
|
||||||
<div class="time-gutter"></div>
|
|
||||||
{
|
|
||||||
week_days.iter().map(|date| {
|
|
||||||
let is_today = *date == props.today;
|
|
||||||
let weekday_name = get_weekday_name(date.weekday());
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
|
|
||||||
<div class="weekday-name">{weekday_name}</div>
|
|
||||||
<div class="day-number">{date.day()}</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}).collect::<Html>()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Scrollable content area with time grid
|
|
||||||
<div class="week-content">
|
|
||||||
<div class="time-grid">
|
|
||||||
// Time labels
|
|
||||||
<div class="time-labels">
|
|
||||||
{
|
|
||||||
time_labels.iter().enumerate().map(|(index, time)| {
|
|
||||||
let is_final = index == time_labels.len() - 1;
|
|
||||||
html! {
|
|
||||||
<div class={classes!("time-label", if is_final { Some("final-boundary") } else { None })}>
|
|
||||||
{time}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}).collect::<Html>()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Day columns
|
|
||||||
<div class="week-days-grid">
|
|
||||||
{
|
|
||||||
week_days.iter().enumerate().map(|(_column_index, date)| {
|
|
||||||
let is_today = *date == props.today;
|
|
||||||
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
|
||||||
|
|
||||||
// Drag event handlers
|
|
||||||
let drag_state_clone = drag_state.clone();
|
|
||||||
let date_for_drag = *date;
|
|
||||||
|
|
||||||
let onmousedown = {
|
|
||||||
let drag_state = drag_state_clone.clone();
|
|
||||||
let context_menus_open = props.context_menus_open;
|
|
||||||
let time_increment = props.time_increment;
|
|
||||||
Callback::from(move |e: MouseEvent| {
|
|
||||||
// Don't start drag if any context menu is open
|
|
||||||
if context_menus_open {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only handle left-click (button 0)
|
|
||||||
if e.button() != 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate Y position relative to day column container
|
|
||||||
// Use layer_y which gives coordinates relative to positioned ancestor
|
|
||||||
let relative_y = e.layer_y() as f64;
|
|
||||||
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
|
||||||
|
|
||||||
// Snap to increment
|
|
||||||
let snapped_y = snap_to_increment(relative_y, time_increment);
|
|
||||||
|
|
||||||
drag_state.set(Some(DragState {
|
|
||||||
is_dragging: true,
|
|
||||||
drag_type: DragType::CreateEvent,
|
|
||||||
start_date: date_for_drag,
|
|
||||||
start_y: snapped_y,
|
|
||||||
current_y: snapped_y,
|
|
||||||
offset_y: 0.0,
|
|
||||||
}));
|
|
||||||
e.prevent_default();
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let onmousemove = {
|
|
||||||
let drag_state = drag_state_clone.clone();
|
|
||||||
let time_increment = props.time_increment;
|
|
||||||
Callback::from(move |e: MouseEvent| {
|
|
||||||
if let Some(mut current_drag) = (*drag_state).clone() {
|
|
||||||
if current_drag.is_dragging {
|
|
||||||
// Use layer_y for consistent coordinate calculation
|
|
||||||
let relative_y = e.layer_y() as f64;
|
|
||||||
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
|
||||||
|
|
||||||
// Snap to increment
|
|
||||||
let snapped_y = snap_to_increment(relative_y, time_increment);
|
|
||||||
|
|
||||||
current_drag.current_y = snapped_y;
|
|
||||||
drag_state.set(Some(current_drag));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let onmouseup = {
|
|
||||||
let drag_state = drag_state_clone.clone();
|
|
||||||
let on_create_event = props.on_create_event.clone();
|
|
||||||
let on_event_update = props.on_event_update.clone();
|
|
||||||
Callback::from(move |_e: MouseEvent| {
|
|
||||||
if let Some(current_drag) = (*drag_state).clone() {
|
|
||||||
if current_drag.is_dragging {
|
|
||||||
match ¤t_drag.drag_type {
|
|
||||||
DragType::CreateEvent => {
|
|
||||||
// Calculate start and end times
|
|
||||||
let start_time = pixels_to_time(current_drag.start_y);
|
|
||||||
let end_time = pixels_to_time(current_drag.current_y);
|
|
||||||
|
|
||||||
// Ensure start is before end
|
|
||||||
let (actual_start, actual_end) = if start_time <= end_time {
|
|
||||||
(start_time, end_time)
|
|
||||||
} else {
|
|
||||||
(end_time, start_time)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ensure minimum duration (15 minutes)
|
|
||||||
let actual_end = if actual_end.signed_duration_since(actual_start).num_minutes() < 15 {
|
|
||||||
actual_start + chrono::Duration::minutes(15)
|
|
||||||
} else {
|
|
||||||
actual_end
|
|
||||||
};
|
|
||||||
|
|
||||||
let start_datetime = NaiveDateTime::new(current_drag.start_date, actual_start);
|
|
||||||
let end_datetime = NaiveDateTime::new(current_drag.start_date, actual_end);
|
|
||||||
|
|
||||||
if let Some(callback) = &on_create_event {
|
|
||||||
callback.emit((current_drag.start_date, start_datetime, end_datetime));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
DragType::MoveEvent(event) => {
|
|
||||||
// Calculate new start time based on drag position
|
|
||||||
let new_start_time = pixels_to_time(current_drag.current_y);
|
|
||||||
|
|
||||||
// Calculate duration from original event
|
|
||||||
let original_duration = if let Some(end) = event.end {
|
|
||||||
end.signed_duration_since(event.start)
|
|
||||||
} else {
|
|
||||||
chrono::Duration::hours(1) // Default 1 hour
|
|
||||||
};
|
|
||||||
|
|
||||||
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
|
||||||
let new_end_datetime = new_start_datetime + original_duration;
|
|
||||||
|
|
||||||
if let Some(callback) = &on_event_update {
|
|
||||||
callback.emit((event.clone(), new_start_datetime, new_end_datetime));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drag_state.set(None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<div
|
|
||||||
class={classes!("week-day-column", if is_today { Some("today") } else { None })}
|
|
||||||
{onmousedown}
|
|
||||||
{onmousemove}
|
|
||||||
{onmouseup}
|
|
||||||
>
|
|
||||||
// Time slot backgrounds - 24 hour slots to represent full day
|
|
||||||
{
|
|
||||||
(0..24).map(|_hour| {
|
|
||||||
html! {
|
|
||||||
<div class="time-slot">
|
|
||||||
<div class="time-slot-half"></div>
|
|
||||||
<div class="time-slot-half"></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}).collect::<Html>()
|
|
||||||
}
|
|
||||||
// Final boundary slot to complete the 24-hour visual grid - make it interactive like other slots
|
|
||||||
<div class="time-slot boundary-slot">
|
|
||||||
<div class="time-slot-half"></div>
|
|
||||||
<div class="time-slot-half"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Events positioned absolutely based on their actual times
|
|
||||||
<div class="events-container">
|
|
||||||
{
|
|
||||||
day_events.iter().filter_map(|event| {
|
|
||||||
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
|
|
||||||
|
|
||||||
// Skip events that don't belong on this date or have invalid positioning
|
|
||||||
if start_pixels == 0.0 && duration_pixels == 0.0 && !is_all_day {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let event_color = get_event_color(event);
|
|
||||||
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
|
||||||
|
|
||||||
let onclick = {
|
|
||||||
let on_event_click = props.on_event_click.clone();
|
|
||||||
let event = event.clone();
|
|
||||||
Callback::from(move |e: MouseEvent| {
|
|
||||||
e.stop_propagation(); // Prevent calendar click events from also triggering
|
|
||||||
on_event_click.emit(event.clone());
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let onmousedown_event = {
|
|
||||||
let drag_state = drag_state.clone();
|
|
||||||
let event_for_drag = event.clone();
|
|
||||||
let date_for_drag = *date;
|
|
||||||
let time_increment = props.time_increment;
|
|
||||||
Callback::from(move |e: MouseEvent| {
|
|
||||||
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
|
|
||||||
|
|
||||||
// Only handle left-click (button 0)
|
|
||||||
if e.button() != 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate Y position relative to the day column
|
|
||||||
let relative_y = e.layer_y() as f64;
|
|
||||||
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
|
||||||
|
|
||||||
// Get event's current position
|
|
||||||
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag);
|
|
||||||
let event_start_pixels = event_start_pixels as f64;
|
|
||||||
|
|
||||||
// Calculate offset from the top of the event
|
|
||||||
let offset_y = relative_y - event_start_pixels;
|
|
||||||
|
|
||||||
// Snap to increment
|
|
||||||
let snapped_y = snap_to_increment(relative_y, time_increment);
|
|
||||||
|
|
||||||
drag_state.set(Some(DragState {
|
|
||||||
is_dragging: true,
|
|
||||||
drag_type: DragType::MoveEvent(event_for_drag.clone()),
|
|
||||||
start_date: date_for_drag,
|
|
||||||
start_y: snapped_y,
|
|
||||||
current_y: snapped_y,
|
|
||||||
offset_y,
|
|
||||||
}));
|
|
||||||
e.prevent_default();
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let oncontextmenu = {
|
|
||||||
if let Some(callback) = &props.on_event_context_menu {
|
|
||||||
let callback = callback.clone();
|
|
||||||
let event = event.clone();
|
|
||||||
Some(Callback::from(move |e: web_sys::MouseEvent| {
|
|
||||||
e.prevent_default();
|
|
||||||
e.stop_propagation(); // Prevent calendar context menu from also triggering
|
|
||||||
callback.emit((e, event.clone()));
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format time display for the event
|
|
||||||
let time_display = if event.all_day {
|
|
||||||
"All Day".to_string()
|
|
||||||
} else {
|
|
||||||
let local_start = event.start.with_timezone(&Local);
|
|
||||||
if let Some(end) = event.end {
|
|
||||||
let local_end = end.with_timezone(&Local);
|
|
||||||
|
|
||||||
// Check if both times are in same AM/PM period to avoid redundancy
|
|
||||||
let start_is_am = local_start.hour() < 12;
|
|
||||||
let end_is_am = local_end.hour() < 12;
|
|
||||||
|
|
||||||
if start_is_am == end_is_am {
|
|
||||||
// Same AM/PM period - show "9:00 - 10:30 AM"
|
|
||||||
format!("{} - {}",
|
|
||||||
local_start.format("%I:%M").to_string().trim_start_matches('0'),
|
|
||||||
local_end.format("%I:%M %p")
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Different AM/PM periods - show "9:00 AM - 2:30 PM"
|
|
||||||
format!("{} - {}",
|
|
||||||
local_start.format("%I:%M %p"),
|
|
||||||
local_end.format("%I:%M %p")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No end time, just show start time
|
|
||||||
format!("{}", local_start.format("%I:%M %p"))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if this event is currently being dragged
|
|
||||||
let is_being_dragged = if let Some(drag) = (*drag_state).clone() {
|
|
||||||
if let DragType::MoveEvent(dragged_event) = &drag.drag_type {
|
|
||||||
dragged_event.uid == event.uid && drag.is_dragging
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
|
|
||||||
if is_being_dragged {
|
|
||||||
// Hide the original event while being dragged
|
|
||||||
Some(html! {})
|
|
||||||
} else {
|
|
||||||
Some(html! {
|
|
||||||
<div
|
|
||||||
class={classes!(
|
|
||||||
"week-event",
|
|
||||||
if is_refreshing { Some("refreshing") } else { None },
|
|
||||||
if is_all_day { Some("all-day") } else { None }
|
|
||||||
)}
|
|
||||||
style={format!(
|
|
||||||
"background-color: {}; top: {}px; height: {}px;",
|
|
||||||
event_color,
|
|
||||||
start_pixels,
|
|
||||||
duration_pixels
|
|
||||||
)}
|
|
||||||
{onclick}
|
|
||||||
{oncontextmenu}
|
|
||||||
onmousedown={onmousedown_event}
|
|
||||||
>
|
|
||||||
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
|
||||||
{if !is_all_day {
|
|
||||||
html! { <div class="event-time">{time_display}</div> }
|
|
||||||
} else {
|
|
||||||
html! {}
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}).collect::<Html>()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Temporary event box during drag
|
|
||||||
{
|
|
||||||
if let Some(drag) = (*drag_state).clone() {
|
|
||||||
if drag.is_dragging && drag.start_date == *date {
|
|
||||||
match &drag.drag_type {
|
|
||||||
DragType::CreateEvent => {
|
|
||||||
let start_y = drag.start_y.min(drag.current_y);
|
|
||||||
let end_y = drag.start_y.max(drag.current_y);
|
|
||||||
let height = (drag.current_y - drag.start_y).abs().max(20.0);
|
|
||||||
|
|
||||||
// Convert pixels to times for display
|
|
||||||
let start_time = pixels_to_time(start_y);
|
|
||||||
let end_time = pixels_to_time(end_y);
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<div
|
|
||||||
class="temp-event-box"
|
|
||||||
style={format!("top: {}px; height: {}px;", start_y, height)}
|
|
||||||
>
|
|
||||||
{format!("{} - {}", start_time.format("%I:%M %p"), end_time.format("%I:%M %p"))}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
},
|
|
||||||
DragType::MoveEvent(event) => {
|
|
||||||
// Show the event being moved at its new position
|
|
||||||
let new_start_time = pixels_to_time(drag.current_y);
|
|
||||||
let original_duration = if let Some(end) = event.end {
|
|
||||||
end.signed_duration_since(event.start)
|
|
||||||
} else {
|
|
||||||
chrono::Duration::hours(1)
|
|
||||||
};
|
|
||||||
let duration_pixels = (original_duration.num_minutes() as f64).max(20.0);
|
|
||||||
let new_end_time = new_start_time + original_duration;
|
|
||||||
|
|
||||||
let event_color = get_event_color(event);
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<div
|
|
||||||
class="temp-event-box moving-event"
|
|
||||||
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", drag.current_y, duration_pixels, event_color)}
|
|
||||||
>
|
|
||||||
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
|
||||||
<div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}).collect::<Html>()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_start_of_week(date: NaiveDate) -> NaiveDate {
|
|
||||||
let weekday = date.weekday();
|
|
||||||
let days_from_sunday = match weekday {
|
|
||||||
Weekday::Sun => 0,
|
|
||||||
Weekday::Mon => 1,
|
|
||||||
Weekday::Tue => 2,
|
|
||||||
Weekday::Wed => 3,
|
|
||||||
Weekday::Thu => 4,
|
|
||||||
Weekday::Fri => 5,
|
|
||||||
Weekday::Sat => 6,
|
|
||||||
};
|
|
||||||
date - Duration::days(days_from_sunday)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_weekday_name(weekday: Weekday) -> &'static str {
|
|
||||||
match weekday {
|
|
||||||
Weekday::Sun => "Sun",
|
|
||||||
Weekday::Mon => "Mon",
|
|
||||||
Weekday::Tue => "Tue",
|
|
||||||
Weekday::Wed => "Wed",
|
|
||||||
Weekday::Thu => "Thu",
|
|
||||||
Weekday::Fri => "Fri",
|
|
||||||
Weekday::Sat => "Sat",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the pixel position of an event based on its time
|
|
||||||
// Each hour is 60px, so we convert time to pixels
|
|
||||||
// Snap pixel position to 15-minute increments (15px = 15 minutes since 60px = 60 minutes)
|
|
||||||
fn snap_to_increment(pixels: f64, increment: u32) -> f64 {
|
|
||||||
let increment_px = increment as f64; // Convert to pixels (1px = 1 minute)
|
|
||||||
(pixels / increment_px).round() * increment_px
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert pixel position to time (inverse of time to pixels)
|
|
||||||
fn pixels_to_time(pixels: f64) -> NaiveTime {
|
|
||||||
// Since 60px = 1 hour, pixels directly represent minutes
|
|
||||||
let total_minutes = pixels; // 1px = 1 minute
|
|
||||||
let hours = (total_minutes / 60.0) as u32;
|
|
||||||
let minutes = (total_minutes % 60.0) as u32;
|
|
||||||
|
|
||||||
// Handle midnight boundary - if we're at exactly 1440 pixels (24:00), return midnight
|
|
||||||
if total_minutes >= 1440.0 {
|
|
||||||
return NaiveTime::from_hms_opt(0, 0, 0).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp to valid time range for within-day times
|
|
||||||
let hours = hours.min(23);
|
|
||||||
let minutes = minutes.min(59);
|
|
||||||
|
|
||||||
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fn calculate_event_position(event: &CalendarEvent, date: NaiveDate) -> (f32, f32, bool) {
|
|
||||||
// Convert UTC times to local time for display
|
|
||||||
let local_start = event.start.with_timezone(&Local);
|
|
||||||
let event_date = local_start.date_naive();
|
|
||||||
|
|
||||||
// Only position events that are on this specific date
|
|
||||||
if event_date != date {
|
|
||||||
return (0.0, 0.0, false); // Event not on this date
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle all-day events - they appear at the top
|
|
||||||
if event.all_day {
|
|
||||||
return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate start position in pixels from midnight
|
|
||||||
let start_hour = local_start.hour() as f32;
|
|
||||||
let start_minute = local_start.minute() as f32;
|
|
||||||
let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour
|
|
||||||
|
|
||||||
// Calculate duration and height
|
|
||||||
let duration_pixels = if let Some(end) = event.end {
|
|
||||||
let local_end = end.with_timezone(&Local);
|
|
||||||
let end_date = local_end.date_naive();
|
|
||||||
|
|
||||||
// Handle events that span multiple days by capping at midnight
|
|
||||||
if end_date > date {
|
|
||||||
// Event continues past midnight, cap at 24:00 (1440px)
|
|
||||||
1440.0 - start_pixels
|
|
||||||
} else {
|
|
||||||
let end_hour = local_end.hour() as f32;
|
|
||||||
let end_minute = local_end.minute() as f32;
|
|
||||||
let end_pixels = (end_hour + end_minute / 60.0) * 60.0;
|
|
||||||
(end_pixels - start_pixels).max(20.0) // Minimum 20px height
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
60.0 // Default 1 hour if no end time
|
|
||||||
};
|
|
||||||
|
|
||||||
(start_pixels, duration_pixels, false) // is_all_day = false
|
|
||||||
}
|
|
||||||
284
src/config.rs
284
src/config.rs
@@ -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,947 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc, NaiveDate, Datelike, Weekday, Duration, TimeZone};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
use wasm_bindgen_futures::JsFuture;
|
|
||||||
use web_sys::{Request, RequestInit, RequestMode, Response};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct EventReminder {
|
|
||||||
pub minutes_before: i32,
|
|
||||||
pub action: ReminderAction,
|
|
||||||
pub description: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub enum ReminderAction {
|
|
||||||
Display,
|
|
||||||
Email,
|
|
||||||
Audio,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ReminderAction {
|
|
||||||
fn default() -> Self {
|
|
||||||
ReminderAction::Display
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct UserInfo {
|
|
||||||
pub username: String,
|
|
||||||
pub server_url: String,
|
|
||||||
pub calendars: Vec<CalendarInfo>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct CalendarInfo {
|
|
||||||
pub path: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub color: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct CalendarEvent {
|
|
||||||
pub uid: String,
|
|
||||||
pub summary: Option<String>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub start: DateTime<Utc>,
|
|
||||||
pub end: Option<DateTime<Utc>>,
|
|
||||||
pub location: Option<String>,
|
|
||||||
pub status: EventStatus,
|
|
||||||
pub class: EventClass,
|
|
||||||
pub priority: Option<u8>,
|
|
||||||
pub organizer: Option<String>,
|
|
||||||
pub attendees: Vec<String>,
|
|
||||||
pub categories: Vec<String>,
|
|
||||||
pub created: Option<DateTime<Utc>>,
|
|
||||||
pub last_modified: Option<DateTime<Utc>>,
|
|
||||||
pub recurrence_rule: Option<String>,
|
|
||||||
pub exception_dates: Vec<DateTime<Utc>>,
|
|
||||||
pub all_day: bool,
|
|
||||||
pub reminders: Vec<EventReminder>,
|
|
||||||
pub etag: Option<String>,
|
|
||||||
pub href: Option<String>,
|
|
||||||
pub calendar_path: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub enum EventStatus {
|
|
||||||
Tentative,
|
|
||||||
Confirmed,
|
|
||||||
Cancelled,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for EventStatus {
|
|
||||||
fn default() -> Self {
|
|
||||||
EventStatus::Confirmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub enum EventClass {
|
|
||||||
Public,
|
|
||||||
Private,
|
|
||||||
Confidential,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for EventClass {
|
|
||||||
fn default() -> Self {
|
|
||||||
EventClass::Public
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CalendarEvent {
|
|
||||||
/// Get the date for this event (for calendar display)
|
|
||||||
pub fn get_date(&self) -> NaiveDate {
|
|
||||||
if self.all_day {
|
|
||||||
self.start.date_naive()
|
|
||||||
} else {
|
|
||||||
self.start.date_naive()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get display title for the event
|
|
||||||
pub fn get_title(&self) -> String {
|
|
||||||
self.summary.clone().unwrap_or_else(|| "Untitled Event".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get display string for status
|
|
||||||
pub fn get_status_display(&self) -> &'static str {
|
|
||||||
match self.status {
|
|
||||||
EventStatus::Tentative => "Tentative",
|
|
||||||
EventStatus::Confirmed => "Confirmed",
|
|
||||||
EventStatus::Cancelled => "Cancelled",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get display string for class
|
|
||||||
pub fn get_class_display(&self) -> &'static str {
|
|
||||||
match self.class {
|
|
||||||
EventClass::Public => "Public",
|
|
||||||
EventClass::Private => "Private",
|
|
||||||
EventClass::Confidential => "Confidential",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get display string for priority
|
|
||||||
pub fn get_priority_display(&self) -> String {
|
|
||||||
match self.priority {
|
|
||||||
None => "Not set".to_string(),
|
|
||||||
Some(0) => "Undefined".to_string(),
|
|
||||||
Some(1) => "High".to_string(),
|
|
||||||
Some(p) if p <= 4 => "High".to_string(),
|
|
||||||
Some(5) => "Medium".to_string(),
|
|
||||||
Some(p) if p <= 8 => "Low".to_string(),
|
|
||||||
Some(9) => "Low".to_string(),
|
|
||||||
Some(p) => format!("Priority {}", p),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CalendarService {
|
|
||||||
base_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CalendarService {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let base_url = option_env!("BACKEND_API_URL")
|
|
||||||
.unwrap_or("http://localhost:3000/api")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Self { base_url }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch user info including available calendars
|
|
||||||
pub async fn fetch_user_info(&self, token: &str, password: &str) -> Result<UserInfo, String> {
|
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
|
||||||
|
|
||||||
let opts = RequestInit::new();
|
|
||||||
opts.set_method("GET");
|
|
||||||
opts.set_mode(RequestMode::Cors);
|
|
||||||
|
|
||||||
let url = format!("{}/user/info", self.base_url);
|
|
||||||
let request = Request::new_with_str_and_init(&url, &opts)
|
|
||||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("Authorization", &format!("Bearer {}", token))
|
|
||||||
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("X-CalDAV-Password", password)
|
|
||||||
.map_err(|e| format!("Password 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))?;
|
|
||||||
|
|
||||||
let text = JsFuture::from(resp.text()
|
|
||||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let text_string = text.as_string()
|
|
||||||
.ok_or("Response text is not a string")?;
|
|
||||||
|
|
||||||
if resp.ok() {
|
|
||||||
let user_info: UserInfo = serde_json::from_str(&text_string)
|
|
||||||
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
|
||||||
Ok(user_info)
|
|
||||||
} else {
|
|
||||||
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch calendar events for a specific month
|
|
||||||
pub async fn fetch_events_for_month(
|
|
||||||
&self,
|
|
||||||
token: &str,
|
|
||||||
password: &str,
|
|
||||||
year: i32,
|
|
||||||
month: u32
|
|
||||||
) -> Result<Vec<CalendarEvent>, String> {
|
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
|
||||||
|
|
||||||
let opts = RequestInit::new();
|
|
||||||
opts.set_method("GET");
|
|
||||||
opts.set_mode(RequestMode::Cors);
|
|
||||||
|
|
||||||
let url = format!("{}/calendar/events?year={}&month={}", self.base_url, year, month);
|
|
||||||
let request = Request::new_with_str_and_init(&url, &opts)
|
|
||||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("Authorization", &format!("Bearer {}", token))
|
|
||||||
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("X-CalDAV-Password", password)
|
|
||||||
.map_err(|e| format!("Password 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))?;
|
|
||||||
|
|
||||||
let text = JsFuture::from(resp.text()
|
|
||||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let text_string = text.as_string()
|
|
||||||
.ok_or("Response text is not a string")?;
|
|
||||||
|
|
||||||
if resp.ok() {
|
|
||||||
let events: Vec<CalendarEvent> = serde_json::from_str(&text_string)
|
|
||||||
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
|
||||||
Ok(events)
|
|
||||||
} else {
|
|
||||||
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert events to a HashMap grouped by date for calendar display
|
|
||||||
pub fn group_events_by_date(events: Vec<CalendarEvent>) -> HashMap<NaiveDate, Vec<CalendarEvent>> {
|
|
||||||
let mut grouped = HashMap::new();
|
|
||||||
|
|
||||||
// Expand recurring events first
|
|
||||||
let expanded_events = Self::expand_recurring_events(events);
|
|
||||||
|
|
||||||
for event in expanded_events {
|
|
||||||
let date = event.get_date();
|
|
||||||
|
|
||||||
grouped.entry(date)
|
|
||||||
.or_insert_with(Vec::new)
|
|
||||||
.push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
grouped
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Expand recurring events to show on all occurrence dates within a reasonable range
|
|
||||||
pub fn expand_recurring_events(events: Vec<CalendarEvent>) -> Vec<CalendarEvent> {
|
|
||||||
let mut expanded_events = Vec::new();
|
|
||||||
let today = chrono::Utc::now().date_naive();
|
|
||||||
let start_range = today - Duration::days(30); // Show past 30 days
|
|
||||||
let end_range = today + Duration::days(365); // Show next 365 days
|
|
||||||
|
|
||||||
for event in events {
|
|
||||||
if let Some(ref rrule) = event.recurrence_rule {
|
|
||||||
web_sys::console::log_1(&format!("📅 Processing recurring event '{}' with RRULE: {}",
|
|
||||||
event.summary.as_deref().unwrap_or("Untitled"),
|
|
||||||
rrule
|
|
||||||
).into());
|
|
||||||
|
|
||||||
// Log if event has exception dates
|
|
||||||
if !event.exception_dates.is_empty() {
|
|
||||||
web_sys::console::log_1(&format!("📅 Event '{}' has {} exception dates: {:?}",
|
|
||||||
event.summary.as_deref().unwrap_or("Untitled"),
|
|
||||||
event.exception_dates.len(),
|
|
||||||
event.exception_dates
|
|
||||||
).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate occurrences for recurring events
|
|
||||||
let occurrences = Self::generate_occurrences(&event, rrule, start_range, end_range);
|
|
||||||
web_sys::console::log_1(&format!("📅 Generated {} occurrences for event '{}'",
|
|
||||||
occurrences.len(),
|
|
||||||
event.summary.as_deref().unwrap_or("Untitled")
|
|
||||||
).into());
|
|
||||||
expanded_events.extend(occurrences);
|
|
||||||
} else {
|
|
||||||
// Non-recurring event - add as-is
|
|
||||||
expanded_events.push(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expanded_events
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate occurrence dates for a recurring event based on RRULE
|
|
||||||
fn generate_occurrences(
|
|
||||||
base_event: &CalendarEvent,
|
|
||||||
rrule: &str,
|
|
||||||
start_range: NaiveDate,
|
|
||||||
end_range: NaiveDate,
|
|
||||||
) -> Vec<CalendarEvent> {
|
|
||||||
let mut occurrences = Vec::new();
|
|
||||||
|
|
||||||
// Parse RRULE components
|
|
||||||
let rrule_upper = rrule.to_uppercase();
|
|
||||||
web_sys::console::log_1(&format!("🔄 Parsing RRULE: {}", rrule_upper).into());
|
|
||||||
|
|
||||||
let components: HashMap<String, String> = rrule_upper
|
|
||||||
.split(';')
|
|
||||||
.filter_map(|part| {
|
|
||||||
let mut split = part.split('=');
|
|
||||||
if let (Some(key), Some(value)) = (split.next(), split.next()) {
|
|
||||||
Some((key.to_string(), value.to_string()))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Get frequency
|
|
||||||
let freq = components.get("FREQ").map(|s| s.as_str()).unwrap_or("DAILY");
|
|
||||||
|
|
||||||
// Get interval (default 1)
|
|
||||||
let interval: i32 = components.get("INTERVAL")
|
|
||||||
.and_then(|s| s.parse().ok())
|
|
||||||
.unwrap_or(1);
|
|
||||||
|
|
||||||
// Get count limit (default 100 to prevent infinite loops)
|
|
||||||
let count: usize = components.get("COUNT")
|
|
||||||
.and_then(|s| s.parse().ok())
|
|
||||||
.unwrap_or(100)
|
|
||||||
.min(365); // Cap at 365 occurrences for performance
|
|
||||||
|
|
||||||
// Get UNTIL date if specified
|
|
||||||
let until_date = components.get("UNTIL")
|
|
||||||
.and_then(|until_str| {
|
|
||||||
// Parse UNTIL date in YYYYMMDDTHHMMSSZ format
|
|
||||||
// Try different parsing approaches for UTC dates
|
|
||||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(until_str.trim_end_matches('Z'), "%Y%m%dT%H%M%S") {
|
|
||||||
Some(chrono::Utc.from_utc_datetime(&dt))
|
|
||||||
} else if let Ok(dt) = chrono::DateTime::parse_from_str(until_str, "%Y%m%dT%H%M%SZ") {
|
|
||||||
Some(dt.with_timezone(&chrono::Utc))
|
|
||||||
} else if let Ok(date) = chrono::NaiveDate::parse_from_str(until_str, "%Y%m%d") {
|
|
||||||
// Handle date-only UNTIL
|
|
||||||
Some(chrono::Utc.from_utc_datetime(&date.and_hms_opt(23, 59, 59).unwrap()))
|
|
||||||
} else {
|
|
||||||
web_sys::console::log_1(&format!("⚠️ Failed to parse UNTIL date: {}", until_str).into());
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(until) = until_date {
|
|
||||||
web_sys::console::log_1(&format!("📅 RRULE has UNTIL: {}", until).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let start_date = base_event.start.date_naive();
|
|
||||||
let mut current_date = start_date;
|
|
||||||
let mut occurrence_count = 0;
|
|
||||||
|
|
||||||
// Generate occurrences based on frequency
|
|
||||||
while current_date <= end_range && occurrence_count < count {
|
|
||||||
// Check UNTIL constraint - stop if current occurrence is after UNTIL date
|
|
||||||
if let Some(until) = until_date {
|
|
||||||
let current_datetime = base_event.start + Duration::days(current_date.signed_duration_since(start_date).num_days());
|
|
||||||
if current_datetime > until {
|
|
||||||
web_sys::console::log_1(&format!("🛑 Stopping at {} due to UNTIL {}", current_datetime, until).into());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if current_date >= start_range {
|
|
||||||
// Calculate the occurrence datetime
|
|
||||||
let days_diff = current_date.signed_duration_since(start_date).num_days();
|
|
||||||
let occurrence_datetime = base_event.start + Duration::days(days_diff);
|
|
||||||
|
|
||||||
// Check if this occurrence is in the exception dates (EXDATE)
|
|
||||||
let is_exception = base_event.exception_dates.iter().any(|exception_date| {
|
|
||||||
// Compare dates ignoring sub-second precision
|
|
||||||
let exception_naive = exception_date.naive_utc();
|
|
||||||
let occurrence_naive = occurrence_datetime.naive_utc();
|
|
||||||
|
|
||||||
// Check if dates match (within a minute to handle minor time differences)
|
|
||||||
let diff = occurrence_naive - exception_naive;
|
|
||||||
let matches = diff.num_seconds().abs() < 60;
|
|
||||||
|
|
||||||
if matches {
|
|
||||||
web_sys::console::log_1(&format!("🚫 Excluding occurrence {} due to EXDATE {}", occurrence_naive, exception_naive).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
matches
|
|
||||||
});
|
|
||||||
|
|
||||||
if !is_exception {
|
|
||||||
// Create occurrence event
|
|
||||||
let mut occurrence_event = base_event.clone();
|
|
||||||
occurrence_event.start = occurrence_datetime;
|
|
||||||
|
|
||||||
if let Some(end) = base_event.end {
|
|
||||||
occurrence_event.end = Some(end + Duration::days(days_diff));
|
|
||||||
}
|
|
||||||
|
|
||||||
occurrences.push(occurrence_event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate next occurrence date
|
|
||||||
match freq {
|
|
||||||
"DAILY" => {
|
|
||||||
current_date = current_date + Duration::days(interval as i64);
|
|
||||||
}
|
|
||||||
"WEEKLY" => {
|
|
||||||
if let Some(byday) = components.get("BYDAY") {
|
|
||||||
// Handle specific days of week
|
|
||||||
current_date = Self::next_weekday_occurrence(current_date, byday, interval);
|
|
||||||
} else {
|
|
||||||
current_date = current_date + Duration::weeks(interval as i64);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"MONTHLY" => {
|
|
||||||
// Simple monthly increment (same day of month)
|
|
||||||
if let Some(next_month) = Self::add_months(current_date, interval) {
|
|
||||||
current_date = next_month;
|
|
||||||
} else {
|
|
||||||
break; // Invalid date
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"YEARLY" => {
|
|
||||||
if let Some(next_year) = Self::add_years(current_date, interval) {
|
|
||||||
current_date = next_year;
|
|
||||||
} else {
|
|
||||||
break; // Invalid date
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => break, // Unsupported frequency
|
|
||||||
}
|
|
||||||
|
|
||||||
occurrence_count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
occurrences
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate next weekday occurrence for WEEKLY frequency with BYDAY
|
|
||||||
fn next_weekday_occurrence(current_date: NaiveDate, byday: &str, interval: i32) -> NaiveDate {
|
|
||||||
let weekdays = Self::parse_byday(byday);
|
|
||||||
if weekdays.is_empty() {
|
|
||||||
return current_date + Duration::weeks(interval as i64);
|
|
||||||
}
|
|
||||||
|
|
||||||
let current_weekday = current_date.weekday();
|
|
||||||
|
|
||||||
// Find next occurrence within current week
|
|
||||||
for &target_weekday in &weekdays {
|
|
||||||
let days_until = Self::days_until_weekday(current_weekday, target_weekday);
|
|
||||||
if days_until > 0 {
|
|
||||||
return current_date + Duration::days(days_until as i64);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No more occurrences this week, move to next interval
|
|
||||||
let next_week_start = current_date + Duration::weeks(interval as i64) - Duration::days(current_weekday.num_days_from_monday() as i64);
|
|
||||||
next_week_start + Duration::days(weekdays[0].num_days_from_monday() as i64)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse BYDAY parameter (e.g., "MO,WE,FR" -> [Monday, Wednesday, Friday])
|
|
||||||
fn parse_byday(byday: &str) -> Vec<Weekday> {
|
|
||||||
byday
|
|
||||||
.split(',')
|
|
||||||
.filter_map(|day| match day {
|
|
||||||
"MO" => Some(Weekday::Mon),
|
|
||||||
"TU" => Some(Weekday::Tue),
|
|
||||||
"WE" => Some(Weekday::Wed),
|
|
||||||
"TH" => Some(Weekday::Thu),
|
|
||||||
"FR" => Some(Weekday::Fri),
|
|
||||||
"SA" => Some(Weekday::Sat),
|
|
||||||
"SU" => Some(Weekday::Sun),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate days until target weekday
|
|
||||||
fn days_until_weekday(from: Weekday, to: Weekday) -> i32 {
|
|
||||||
let from_num = from.num_days_from_monday();
|
|
||||||
let to_num = to.num_days_from_monday();
|
|
||||||
|
|
||||||
if to_num > from_num {
|
|
||||||
(to_num - from_num) as i32
|
|
||||||
} else if to_num < from_num {
|
|
||||||
(7 + to_num - from_num) as i32
|
|
||||||
} else {
|
|
||||||
0 // Same day
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add months to a date (handling month boundary issues)
|
|
||||||
fn add_months(date: NaiveDate, months: i32) -> Option<NaiveDate> {
|
|
||||||
let mut year = date.year();
|
|
||||||
let mut month = date.month() as i32 + months;
|
|
||||||
|
|
||||||
while month > 12 {
|
|
||||||
year += 1;
|
|
||||||
month -= 12;
|
|
||||||
}
|
|
||||||
while month < 1 {
|
|
||||||
year -= 1;
|
|
||||||
month += 12;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle day overflow (e.g., Jan 31 -> Feb 28)
|
|
||||||
let day = date.day().min(Self::days_in_month(year, month as u32));
|
|
||||||
|
|
||||||
NaiveDate::from_ymd_opt(year, month as u32, day)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add years to a date
|
|
||||||
fn add_years(date: NaiveDate, years: i32) -> Option<NaiveDate> {
|
|
||||||
NaiveDate::from_ymd_opt(date.year() + years, date.month(), date.day())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get number of days in a month
|
|
||||||
fn days_in_month(year: i32, month: u32) -> u32 {
|
|
||||||
match month {
|
|
||||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
|
||||||
4 | 6 | 9 | 11 => 30,
|
|
||||||
2 => if Self::is_leap_year(year) { 29 } else { 28 },
|
|
||||||
_ => 30,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if year is leap year
|
|
||||||
fn is_leap_year(year: i32) -> bool {
|
|
||||||
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new calendar on the CalDAV server
|
|
||||||
pub async fn create_calendar(
|
|
||||||
&self,
|
|
||||||
token: &str,
|
|
||||||
password: &str,
|
|
||||||
name: String,
|
|
||||||
description: Option<String>,
|
|
||||||
color: Option<String>
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
|
||||||
|
|
||||||
let opts = RequestInit::new();
|
|
||||||
opts.set_method("POST");
|
|
||||||
opts.set_mode(RequestMode::Cors);
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"name": name,
|
|
||||||
"description": description,
|
|
||||||
"color": color
|
|
||||||
});
|
|
||||||
|
|
||||||
let body_string = serde_json::to_string(&body)
|
|
||||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
|
||||||
|
|
||||||
opts.set_body(&body_string.into());
|
|
||||||
|
|
||||||
let url = format!("{}/calendar/create", self.base_url);
|
|
||||||
let request = Request::new_with_str_and_init(&url, &opts)
|
|
||||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("Authorization", &format!("Bearer {}", token))
|
|
||||||
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("X-CalDAV-Password", password)
|
|
||||||
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("Content-Type", "application/json")
|
|
||||||
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let resp: Response = resp_value.dyn_into()
|
|
||||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let text = JsFuture::from(resp.text()
|
|
||||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let text_string = text.as_string()
|
|
||||||
.ok_or("Response text is not a string")?;
|
|
||||||
|
|
||||||
if resp.ok() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete an event from the CalDAV server
|
|
||||||
pub async fn delete_event(
|
|
||||||
&self,
|
|
||||||
token: &str,
|
|
||||||
password: &str,
|
|
||||||
calendar_path: String,
|
|
||||||
event_href: String,
|
|
||||||
delete_action: String,
|
|
||||||
occurrence_date: Option<String>
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
|
||||||
|
|
||||||
let opts = RequestInit::new();
|
|
||||||
opts.set_method("POST");
|
|
||||||
opts.set_mode(RequestMode::Cors);
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"calendar_path": calendar_path,
|
|
||||||
"event_href": event_href,
|
|
||||||
"delete_action": delete_action,
|
|
||||||
"occurrence_date": occurrence_date
|
|
||||||
});
|
|
||||||
|
|
||||||
let body_string = serde_json::to_string(&body)
|
|
||||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
|
||||||
|
|
||||||
let url = format!("{}/calendar/events/delete", self.base_url);
|
|
||||||
opts.set_body(&body_string.into());
|
|
||||||
let request = Request::new_with_str_and_init(&url, &opts)
|
|
||||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("Authorization", &format!("Bearer {}", token))
|
|
||||||
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("X-CalDAV-Password", password)
|
|
||||||
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("Content-Type", "application/json")
|
|
||||||
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let resp: Response = resp_value.dyn_into()
|
|
||||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let text = JsFuture::from(resp.text()
|
|
||||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let text_string = text.as_string()
|
|
||||||
.ok_or("Response text is not a string")?;
|
|
||||||
|
|
||||||
if resp.ok() {
|
|
||||||
// Parse the response to get the message
|
|
||||||
let response: serde_json::Value = serde_json::from_str(&text_string)
|
|
||||||
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
|
|
||||||
let message = response["message"].as_str().unwrap_or("Event deleted successfully").to_string();
|
|
||||||
Ok(message)
|
|
||||||
} else {
|
|
||||||
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new event on the CalDAV server
|
|
||||||
pub async fn create_event(
|
|
||||||
&self,
|
|
||||||
token: &str,
|
|
||||||
password: &str,
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
start_date: String,
|
|
||||||
start_time: String,
|
|
||||||
end_date: String,
|
|
||||||
end_time: String,
|
|
||||||
location: String,
|
|
||||||
all_day: bool,
|
|
||||||
status: String,
|
|
||||||
class: String,
|
|
||||||
priority: Option<u8>,
|
|
||||||
organizer: String,
|
|
||||||
attendees: String,
|
|
||||||
categories: String,
|
|
||||||
reminder: String,
|
|
||||||
recurrence: String,
|
|
||||||
recurrence_days: Vec<bool>,
|
|
||||||
calendar_path: Option<String>
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
|
||||||
|
|
||||||
let opts = RequestInit::new();
|
|
||||||
opts.set_method("POST");
|
|
||||||
opts.set_mode(RequestMode::Cors);
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"title": title,
|
|
||||||
"description": description,
|
|
||||||
"start_date": start_date,
|
|
||||||
"start_time": start_time,
|
|
||||||
"end_date": end_date,
|
|
||||||
"end_time": end_time,
|
|
||||||
"location": location,
|
|
||||||
"all_day": all_day,
|
|
||||||
"status": status,
|
|
||||||
"class": class,
|
|
||||||
"priority": priority,
|
|
||||||
"organizer": organizer,
|
|
||||||
"attendees": attendees,
|
|
||||||
"categories": categories,
|
|
||||||
"reminder": reminder,
|
|
||||||
"recurrence": recurrence,
|
|
||||||
"recurrence_days": recurrence_days,
|
|
||||||
"calendar_path": calendar_path
|
|
||||||
});
|
|
||||||
|
|
||||||
let body_string = serde_json::to_string(&body)
|
|
||||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
|
||||||
|
|
||||||
let url = format!("{}/calendar/events/create", self.base_url);
|
|
||||||
opts.set_body(&body_string.into());
|
|
||||||
let request = Request::new_with_str_and_init(&url, &opts)
|
|
||||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("Authorization", &format!("Bearer {}", token))
|
|
||||||
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("X-CalDAV-Password", password)
|
|
||||||
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("Content-Type", "application/json")
|
|
||||||
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let resp: Response = resp_value.dyn_into()
|
|
||||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let text = JsFuture::from(resp.text()
|
|
||||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let text_string = text.as_string()
|
|
||||||
.ok_or("Response text is not a string")?;
|
|
||||||
|
|
||||||
if resp.ok() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_event(
|
|
||||||
&self,
|
|
||||||
token: &str,
|
|
||||||
password: &str,
|
|
||||||
event_uid: String,
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
start_date: String,
|
|
||||||
start_time: String,
|
|
||||||
end_date: String,
|
|
||||||
end_time: String,
|
|
||||||
location: String,
|
|
||||||
all_day: bool,
|
|
||||||
status: String,
|
|
||||||
class: String,
|
|
||||||
priority: Option<u8>,
|
|
||||||
organizer: String,
|
|
||||||
attendees: String,
|
|
||||||
categories: String,
|
|
||||||
reminder: String,
|
|
||||||
recurrence: String,
|
|
||||||
recurrence_days: Vec<bool>,
|
|
||||||
calendar_path: Option<String>
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
|
||||||
|
|
||||||
let opts = RequestInit::new();
|
|
||||||
opts.set_method("POST");
|
|
||||||
opts.set_mode(RequestMode::Cors);
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"uid": event_uid,
|
|
||||||
"title": title,
|
|
||||||
"description": description,
|
|
||||||
"start_date": start_date,
|
|
||||||
"start_time": start_time,
|
|
||||||
"end_date": end_date,
|
|
||||||
"end_time": end_time,
|
|
||||||
"location": location,
|
|
||||||
"all_day": all_day,
|
|
||||||
"status": status,
|
|
||||||
"class": class,
|
|
||||||
"priority": priority,
|
|
||||||
"organizer": organizer,
|
|
||||||
"attendees": attendees,
|
|
||||||
"categories": categories,
|
|
||||||
"reminder": reminder,
|
|
||||||
"recurrence": recurrence,
|
|
||||||
"recurrence_days": recurrence_days,
|
|
||||||
"calendar_path": calendar_path
|
|
||||||
});
|
|
||||||
|
|
||||||
let body_string = serde_json::to_string(&body)
|
|
||||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
|
||||||
|
|
||||||
let url = format!("{}/calendar/events/update", self.base_url);
|
|
||||||
opts.set_body(&body_string.into());
|
|
||||||
let request = Request::new_with_str_and_init(&url, &opts)
|
|
||||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("Authorization", &format!("Bearer {}", token))
|
|
||||||
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("X-CalDAV-Password", password)
|
|
||||||
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("Content-Type", "application/json")
|
|
||||||
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let resp: Response = resp_value.dyn_into()
|
|
||||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let text = JsFuture::from(resp.text()
|
|
||||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let text_string = text.as_string()
|
|
||||||
.ok_or("Response text is not a string")?;
|
|
||||||
|
|
||||||
if resp.ok() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a calendar from the CalDAV server
|
|
||||||
pub async fn delete_calendar(
|
|
||||||
&self,
|
|
||||||
token: &str,
|
|
||||||
password: &str,
|
|
||||||
path: String
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
|
||||||
|
|
||||||
let opts = RequestInit::new();
|
|
||||||
opts.set_method("POST");
|
|
||||||
opts.set_mode(RequestMode::Cors);
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"path": path
|
|
||||||
});
|
|
||||||
|
|
||||||
let body_string = serde_json::to_string(&body)
|
|
||||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
|
||||||
|
|
||||||
opts.set_body(&body_string.into());
|
|
||||||
|
|
||||||
let url = format!("{}/calendar/delete", self.base_url);
|
|
||||||
let request = Request::new_with_str_and_init(&url, &opts)
|
|
||||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("Authorization", &format!("Bearer {}", token))
|
|
||||||
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("X-CalDAV-Password", password)
|
|
||||||
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("Content-Type", "application/json")
|
|
||||||
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let resp: Response = resp_value.dyn_into()
|
|
||||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let text = JsFuture::from(resp.text()
|
|
||||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let text_string = text.as_string()
|
|
||||||
.ok_or("Response text is not a string")?;
|
|
||||||
|
|
||||||
if resp.ok() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refresh a single event by UID from the CalDAV server
|
|
||||||
pub async fn refresh_event(&self, token: &str, password: &str, uid: &str) -> Result<Option<CalendarEvent>, String> {
|
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
|
||||||
|
|
||||||
let opts = RequestInit::new();
|
|
||||||
opts.set_method("GET");
|
|
||||||
opts.set_mode(RequestMode::Cors);
|
|
||||||
|
|
||||||
let url = format!("{}/calendar/events/{}", self.base_url, uid);
|
|
||||||
let request = Request::new_with_str_and_init(&url, &opts)
|
|
||||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("Authorization", &format!("Bearer {}", token))
|
|
||||||
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
|
||||||
|
|
||||||
request.headers().set("X-CalDAV-Password", password)
|
|
||||||
.map_err(|e| format!("Password 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))?;
|
|
||||||
|
|
||||||
let text = JsFuture::from(resp.text()
|
|
||||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
|
||||||
|
|
||||||
let text_string = text.as_string()
|
|
||||||
.ok_or("Response text is not a string")?;
|
|
||||||
|
|
||||||
if resp.ok() {
|
|
||||||
let event: Option<CalendarEvent> = serde_json::from_str(&text_string)
|
|
||||||
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
|
||||||
Ok(event)
|
|
||||||
} else {
|
|
||||||
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
pub mod calendar_service;
|
|
||||||
|
|
||||||
pub use calendar_service::{CalendarService, CalendarEvent, EventReminder, ReminderAction};
|
|
||||||
1749
styles.css
1749
styles.css
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
console.log("Backend URL test");
|
|
||||||
Reference in New Issue
Block a user