Compare commits
101 Commits
01411f76c4
...
bugfix/wee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
970b0a07da | ||
|
|
e2e5813b54 | ||
|
|
73567c185c | ||
| 0587762bbb | |||
|
|
cd6e9c3619 | ||
|
|
d8c3997f24 | ||
|
|
e44d49e190 | ||
| 4d2aad404b | |||
|
|
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 | ||
|
|
d36609d8c2 | ||
|
|
e23278d71e | ||
|
|
edd209238f | ||
|
|
4fbef8a5dc | ||
|
|
edb216347d | ||
|
|
508c4f129f | ||
|
|
1c0140292f | ||
|
|
53815c4814 | ||
|
|
df714a43a2 | ||
|
|
a8bb2c8164 | ||
|
|
5d0628878b | ||
|
|
dacc18fe5d | ||
|
|
9ab6377d16 | ||
|
|
197157cecb | ||
|
|
c273a8625a | ||
|
|
2a2666e75f | ||
|
|
1b57adab98 | ||
|
|
e1578ed11c | ||
|
|
7a53228ec8 | ||
|
|
811cceae52 | ||
|
|
34461640af | ||
|
|
749ffaff58 | ||
|
|
3440403bed | ||
|
|
5c966b2571 | ||
|
|
7e62e3b7e3 | ||
|
|
b444ae710d | ||
|
|
c454104c69 | ||
|
|
f9c87369e5 | ||
|
|
f94d057f81 | ||
|
|
5d519fd875 | ||
|
|
7c83a4522c | ||
|
|
8a0d2286dc | ||
|
|
d85898cae7 | ||
|
|
0741afd0b2 | ||
|
|
d945c46e5a | ||
|
|
1c4857ccad | ||
|
|
01a21cb869 | ||
|
|
b1b8e1e580 | ||
|
|
a3d1612dac | ||
|
|
5b0e84121b | ||
|
|
f6fa745775 | ||
|
|
b7b351416d | ||
|
|
42091492d5 | ||
|
|
25bf194d19 | ||
|
|
08c333dcba | ||
|
|
181e0c58c1 | ||
|
|
ad176dd423 | ||
|
|
786f078e45 | ||
|
|
103c380098 |
@@ -1,6 +1,9 @@
|
||||
# Build artifacts
|
||||
target/
|
||||
dist/
|
||||
frontend/dist/
|
||||
backend/target/
|
||||
# Allow backend binary for multi-stage builds
|
||||
!backend/target/release/backend
|
||||
|
||||
# Git
|
||||
.git/
|
||||
@@ -21,8 +24,18 @@ Thumbs.db
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
# Development files
|
||||
CLAUDE.md
|
||||
*.txt
|
||||
test_*.js
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
calendar.db
|
||||
|
||||
# Test files
|
||||
**/tests/
|
||||
|
||||
# Migrations (not needed for builds)
|
||||
migrations/
|
||||
|
||||
@@ -9,5 +9,9 @@ CALDAV_CALENDAR_PATH=/calendars/your-username/personal/
|
||||
# Optional: Task/Todo collection path
|
||||
CALDAV_TASKS_PATH=/calendars/your-username/tasks/
|
||||
|
||||
# Backend API Configuration
|
||||
# Set this to point to your backend API server
|
||||
BACKEND_API_URL=http://localhost:3000/api
|
||||
|
||||
# Development settings
|
||||
RUST_LOG=info
|
||||
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)
|
||||
.env
|
||||
.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
|
||||
}
|
||||
45
Cargo.toml
45
Cargo.toml
@@ -1,37 +1,14 @@
|
||||
[package]
|
||||
name = "yew-app"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
[workspace]
|
||||
members = [
|
||||
"frontend",
|
||||
"backend",
|
||||
"calendar-models"
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[dependencies]
|
||||
yew = { version = "0.21", features = ["csr"] }
|
||||
web-sys = "0.3"
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
# HTTP client for CalDAV requests
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
|
||||
# Calendar and iCal parsing
|
||||
ical = "0.7"
|
||||
[workspace.dependencies]
|
||||
calendar-models = { path = "calendar-models" }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
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"] }
|
||||
|
||||
# Environment variable handling
|
||||
dotenvy = "0.15"
|
||||
base64 = "0.21"
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
136
Dockerfile
136
Dockerfile
@@ -1,67 +1,109 @@
|
||||
# Build stage
|
||||
# ---------------------------------------
|
||||
# -----------------------------------------------------------
|
||||
FROM rust:alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache \
|
||||
musl-dev \
|
||||
pkgconfig \
|
||||
openssl-dev \
|
||||
nodejs \
|
||||
npm
|
||||
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static nodejs npm
|
||||
|
||||
# Install trunk for building Yew apps
|
||||
RUN cargo install trunk wasm-pack
|
||||
# Install trunk ahead of the compilation. This may break and then you'll have to update the version.
|
||||
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
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependency files
|
||||
COPY Cargo.toml ./
|
||||
COPY src ./src
|
||||
# Copy workspace files to maintain workspace structure
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
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
|
||||
COPY index.html ./
|
||||
COPY Trunk.toml ./
|
||||
# Create empty backend directory to satisfy workspace
|
||||
RUN mkdir -p backend/src && \
|
||||
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
|
||||
# ---------------------------------------
|
||||
FROM docker.io/nginx:alpine
|
||||
# -----------------------------------------------------------
|
||||
FROM alpine:latest
|
||||
|
||||
# Remove default nginx content
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/dist/* /usr/share/nginx/html/
|
||||
# Copy frontend files to temporary location
|
||||
COPY --from=builder /app/frontend/dist /app/frontend-dist
|
||||
|
||||
# Add nginx configuration for SPA
|
||||
RUN echo 'server { \
|
||||
listen 80; \
|
||||
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
|
||||
# Copy backend binary and sqlx-cli
|
||||
COPY --from=backend-builder /app/target/release/backend /usr/local/bin/backend
|
||||
COPY --from=backend-builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
# Copy migrations for database setup
|
||||
COPY --from=backend-builder /app/backend/migrations /migrations
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
|
||||
# Create startup script to copy frontend files, run migrations, and start backend
|
||||
RUN mkdir -p /srv/www /db
|
||||
RUN echo '#!/bin/sh' > /usr/local/bin/start.sh && \
|
||||
echo 'echo "Copying frontend files..."' >> /usr/local/bin/start.sh && \
|
||||
echo 'cp -r /app/frontend-dist/* /srv/www/' >> /usr/local/bin/start.sh && \
|
||||
echo 'echo "Ensuring database directory exists..."' >> /usr/local/bin/start.sh && \
|
||||
echo 'mkdir -p /db && chmod 755 /db' >> /usr/local/bin/start.sh && \
|
||||
echo 'echo "Running database migrations..."' >> /usr/local/bin/start.sh && \
|
||||
echo 'sqlx migrate run --database-url "sqlite:///db/calendar.db" --source /migrations || echo "Migration failed but continuing..."' >> /usr/local/bin/start.sh && \
|
||||
echo 'echo "Starting backend server..."' >> /usr/local/bin/start.sh && \
|
||||
echo 'export DATABASE_URL="sqlite:///db/calendar.db"' >> /usr/local/bin/start.sh && \
|
||||
echo '/usr/local/bin/backend' >> /usr/local/bin/start.sh && \
|
||||
chmod +x /usr/local/bin/start.sh
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
# Start with script that copies frontend files then starts backend
|
||||
CMD ["/usr/local/bin/start.sh"]
|
||||
|
||||
170
README.md
Normal file
170
README.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Modern CalDAV Web Client
|
||||
|
||||
>[!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
|
||||
|
||||
### Calendar Management
|
||||
- **Interactive Calendar Views**: Month and week views with intuitive navigation
|
||||
- **Event Creation & Editing**: Comprehensive event forms with all standard iCalendar properties
|
||||
- **Drag & Drop**: Move events between dates and times with automatic timezone conversion
|
||||
- **CalDAV Integration**: Full bidirectional sync with any RFC-compliant CalDAV server
|
||||
|
||||
### 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
|
||||
|
||||
### Frontend (Yew WebAssembly)
|
||||
- **Framework**: Yew for component-based UI development
|
||||
- **Performance**: Rust WebAssembly for near-native browser performance
|
||||
- **Models**: RFC 5545-compliant VEvent structures throughout
|
||||
- **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
|
||||
|
||||
### Docker Deployment (Recommended)
|
||||
|
||||
The easiest way to run the calendar is using Docker Compose:
|
||||
|
||||
1. **Clone the repository**:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd calendar
|
||||
```
|
||||
|
||||
2. **Start the application**:
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
3. **Access the application** at `http://localhost`
|
||||
|
||||
The Docker setup includes:
|
||||
- **Automatic database migrations** on startup
|
||||
- **Persistent data storage** in `./data/db/` volume
|
||||
- **Frontend served via Caddy** on port 80
|
||||
- **Backend API** accessible on port 3000
|
||||
|
||||
### Development Setup
|
||||
|
||||
#### Prerequisites
|
||||
- Rust (latest stable version)
|
||||
- Trunk (`cargo install trunk`)
|
||||
|
||||
#### Local Development
|
||||
|
||||
1. **Start the backend server** (serves API at http://localhost:3000):
|
||||
```bash
|
||||
cargo run --manifest-path=backend/Cargo.toml
|
||||
```
|
||||
|
||||
2. **Start the frontend development server** (serves at http://localhost:8080):
|
||||
```bash
|
||||
trunk serve
|
||||
```
|
||||
|
||||
3. **Access the application** at `http://localhost:8080`
|
||||
|
||||
#### Database Setup
|
||||
|
||||
For local development, run the database migrations:
|
||||
```bash
|
||||
# Install sqlx-cli if not already installed
|
||||
cargo install sqlx-cli --features sqlite
|
||||
|
||||
# Run migrations
|
||||
sqlx migrate run --database-url "sqlite:calendar.db" --source backend/migrations
|
||||
```
|
||||
|
||||
### Building for Production
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```
|
||||
calendar/
|
||||
├── frontend/ # Yew WebAssembly frontend
|
||||
│ ├── 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.*
|
||||
11
Trunk.toml
11
Trunk.toml
@@ -1,11 +0,0 @@
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "dist"
|
||||
|
||||
[watch]
|
||||
watch = ["src", "Cargo.toml"]
|
||||
|
||||
[serve]
|
||||
address = "127.0.0.1"
|
||||
port = 8080
|
||||
open = false
|
||||
45
backend/Cargo.toml
Normal file
45
backend/Cargo.toml
Normal file
@@ -0,0 +1,45 @@
|
||||
[package]
|
||||
name = "calendar-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "backend"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
calendar-models = { workspace = true }
|
||||
|
||||
# Backend authentication dependencies
|
||||
jsonwebtoken = "9.0"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
axum = { version = "0.7", features = ["json"] }
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
hyper = { version = "1.0", features = ["full"] }
|
||||
|
||||
# Shared dependencies
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
anyhow = "1.0"
|
||||
|
||||
# CalDAV dependencies
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
ical = "0.7"
|
||||
regex = "1.0"
|
||||
dotenvy = "0.15"
|
||||
base64 = "0.21"
|
||||
thiserror = "1.0"
|
||||
lazy_static = "1.4"
|
||||
|
||||
# Database dependencies
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "uuid", "chrono", "json"] }
|
||||
tokio-rusqlite = "0.5"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.0", features = ["macros", "rt"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
tower = { version = "0.4", features = ["util"] }
|
||||
hyper = "1.0"
|
||||
BIN
backend/calendar.db
Normal file
BIN
backend/calendar.db
Normal file
Binary file not shown.
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
|
||||
);
|
||||
2
backend/migrations/004_add_style_preference.sql
Normal file
2
backend/migrations/004_add_style_preference.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add calendar style preference to user preferences
|
||||
ALTER TABLE user_preferences ADD COLUMN calendar_style TEXT DEFAULT 'default';
|
||||
213
backend/src/auth.rs
Normal file
213
backend/src/auth.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::calendar::CalDAVClient;
|
||||
use crate::config::CalDAVConfig;
|
||||
use crate::db::{Database, PreferencesRepository, Session, SessionRepository, UserRepository};
|
||||
use crate::models::{ApiError, AuthResponse, CalDAVLoginRequest, UserPreferencesResponse};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub username: String,
|
||||
pub server_url: String,
|
||||
pub exp: i64, // Expiration time
|
||||
pub iat: i64, // Issued at
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthService {
|
||||
jwt_secret: String,
|
||||
db: Database,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub fn new(jwt_secret: String, db: Database) -> Self {
|
||||
Self { jwt_secret, db }
|
||||
}
|
||||
|
||||
/// Authenticate user directly against CalDAV server
|
||||
pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, ApiError> {
|
||||
// Validate input
|
||||
self.validate_login(&request)?;
|
||||
println!("✅ Input validation passed");
|
||||
|
||||
// Create CalDAV config with provided credentials
|
||||
let caldav_config = CalDAVConfig::new(
|
||||
request.server_url.clone(),
|
||||
request.username.clone(),
|
||||
request.password.clone(),
|
||||
);
|
||||
println!("📝 Created CalDAV config");
|
||||
|
||||
// Test authentication against CalDAV server
|
||||
let caldav_client = CalDAVClient::new(caldav_config.clone());
|
||||
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
||||
|
||||
// Try to discover calendars as an authentication test
|
||||
match caldav_client.discover_calendars().await {
|
||||
Ok(calendars) => {
|
||||
println!(
|
||||
"✅ Authentication successful! Found {} calendars",
|
||||
calendars.len()
|
||||
);
|
||||
|
||||
// Find or create user in database
|
||||
let user_repo = UserRepository::new(&self.db);
|
||||
let user = user_repo
|
||||
.find_or_create(&request.username, &request.server_url)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to create user: {}", e)))?;
|
||||
|
||||
// Generate JWT token
|
||||
let jwt_token = self.generate_token(&request.username, &request.server_url)?;
|
||||
|
||||
// Generate session token
|
||||
let session_token = format!("sess_{}", Uuid::new_v4());
|
||||
|
||||
// Create session in database
|
||||
let session = Session::new(user.id.clone(), session_token.clone(), 24);
|
||||
let session_repo = SessionRepository::new(&self.db);
|
||||
session_repo
|
||||
.create(&session)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to create session: {}", e)))?;
|
||||
|
||||
// Get or create user preferences
|
||||
let prefs_repo = PreferencesRepository::new(&self.db);
|
||||
let preferences = prefs_repo
|
||||
.get_or_create(&user.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
|
||||
|
||||
Ok(AuthResponse {
|
||||
token: jwt_token,
|
||||
session_token,
|
||||
username: request.username,
|
||||
server_url: request.server_url,
|
||||
preferences: UserPreferencesResponse {
|
||||
calendar_selected_date: preferences.calendar_selected_date,
|
||||
calendar_time_increment: preferences.calendar_time_increment,
|
||||
calendar_view_mode: preferences.calendar_view_mode,
|
||||
calendar_theme: preferences.calendar_theme,
|
||||
calendar_style: preferences.calendar_style,
|
||||
calendar_colors: preferences.calendar_colors,
|
||||
},
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
println!("❌ Authentication failed: {:?}", err);
|
||||
// Authentication failed
|
||||
Err(ApiError::Unauthorized(
|
||||
"Invalid CalDAV credentials or server unavailable".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify JWT token and extract CalDAV credentials info
|
||||
pub fn verify_token(&self, token: &str) -> Result<Claims, ApiError> {
|
||||
self.decode_token(token)
|
||||
}
|
||||
|
||||
/// Create CalDAV config from token
|
||||
pub fn caldav_config_from_token(
|
||||
&self,
|
||||
token: &str,
|
||||
password: &str,
|
||||
) -> Result<CalDAVConfig, ApiError> {
|
||||
let claims = self.verify_token(token)?;
|
||||
|
||||
Ok(CalDAVConfig::new(
|
||||
claims.server_url,
|
||||
claims.username,
|
||||
password.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> {
|
||||
if request.username.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("Username is required".to_string()));
|
||||
}
|
||||
|
||||
if request.password.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("Password is required".to_string()));
|
||||
}
|
||||
|
||||
if request.server_url.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("Server URL is required".to_string()));
|
||||
}
|
||||
|
||||
// Basic URL validation
|
||||
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://")
|
||||
{
|
||||
return Err(ApiError::BadRequest(
|
||||
"Server URL must start with http:// or https://".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn generate_token(&self, username: &str, server_url: &str) -> Result<String, ApiError> {
|
||||
let now = Utc::now();
|
||||
let expires_at = now + Duration::hours(24); // Token valid for 24 hours
|
||||
|
||||
let claims = Claims {
|
||||
username: username.to_string(),
|
||||
server_url: server_url.to_string(),
|
||||
exp: expires_at.timestamp(),
|
||||
iat: now.timestamp(),
|
||||
};
|
||||
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(self.jwt_secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| ApiError::Internal(format!("Token generation failed: {}", e)))?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn decode_token(&self, token: &str) -> Result<Claims, ApiError> {
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(self.jwt_secret.as_bytes()),
|
||||
&Validation::new(Algorithm::HS256),
|
||||
)
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid token".to_string()))?;
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
1446
backend/src/calendar.rs
Normal file
1446
backend/src/calendar.rs
Normal file
File diff suppressed because it is too large
Load Diff
269
backend/src/config.rs
Normal file
269
backend/src/config.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
use base64::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
|
||||
/// 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 calendar_backend::config::CalDAVConfig;
|
||||
/// let config = CalDAVConfig {
|
||||
/// server_url: "https://caldav.example.com".to_string(),
|
||||
/// username: "user@example.com".to_string(),
|
||||
/// password: "password".to_string(),
|
||||
/// calendar_path: None,
|
||||
/// tasks_path: None,
|
||||
/// };
|
||||
///
|
||||
/// // Use the configuration for HTTP requests
|
||||
/// let auth_header = format!("Basic {}", config.get_basic_auth());
|
||||
/// ```
|
||||
#[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 discover available calendars
|
||||
/// through CalDAV PROPFIND requests
|
||||
pub calendar_path: Option<String>,
|
||||
}
|
||||
|
||||
impl CalDAVConfig {
|
||||
/// Creates a new CalDAVConfig with the given credentials.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `server_url` - The base URL of the CalDAV server
|
||||
/// * `username` - Username for authentication
|
||||
/// * `password` - Password for authentication
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// # use calendar_backend::config::CalDAVConfig;
|
||||
/// let config = CalDAVConfig::new(
|
||||
/// "https://caldav.example.com".to_string(),
|
||||
/// "user@example.com".to_string(),
|
||||
/// "password".to_string()
|
||||
/// );
|
||||
/// ```
|
||||
pub fn new(server_url: String, username: String, password: String) -> Self {
|
||||
Self {
|
||||
server_url,
|
||||
username,
|
||||
password,
|
||||
calendar_path: env::var("CALDAV_CALENDAR_PATH").ok(), // Optional override from env
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 calendar_backend::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,
|
||||
};
|
||||
|
||||
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() {
|
||||
// Use test config - update these values to test with real server
|
||||
let config = CalDAVConfig::new(
|
||||
"https://example.com".to_string(),
|
||||
"test_user".to_string(),
|
||||
"test_password".to_string(),
|
||||
);
|
||||
|
||||
println!("Testing authentication to: {}", config.server_url);
|
||||
|
||||
// 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() {
|
||||
// Use test config - update these values to test with real server
|
||||
let config = CalDAVConfig::new(
|
||||
"https://example.com".to_string(),
|
||||
"test_user".to_string(),
|
||||
"test_password".to_string(),
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// 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!");
|
||||
}
|
||||
}
|
||||
309
backend/src/db.rs
Normal file
309
backend/src/db.rs
Normal file
@@ -0,0 +1,309 @@
|
||||
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_style: 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_style: Some("default".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_style, 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_style)
|
||||
.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_style = ?,
|
||||
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_style)
|
||||
.bind(&prefs.calendar_colors)
|
||||
.bind(Utc::now())
|
||||
.bind(&prefs.user_id)
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
66
backend/src/debug_caldav.rs
Normal file
66
backend/src/debug_caldav.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use crate::calendar::CalDAVClient;
|
||||
use crate::config::CalDAVConfig;
|
||||
|
||||
pub async fn debug_caldav_fetch() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Use debug/test configuration
|
||||
let config = CalDAVConfig::new(
|
||||
"https://example.com".to_string(),
|
||||
"debug_user".to_string(),
|
||||
"debug_password".to_string()
|
||||
);
|
||||
let client = CalDAVClient::new(config);
|
||||
|
||||
println!("=== DEBUG: CalDAV Fetch ===");
|
||||
|
||||
// Discover calendars
|
||||
let calendars = client.discover_calendars().await?;
|
||||
println!("Found {} calendars: {:?}", calendars.len(), calendars);
|
||||
|
||||
if let Some(calendar_path) = calendars.first() {
|
||||
println!("Fetching events from: {}", calendar_path);
|
||||
|
||||
// Make the raw REPORT request
|
||||
let report_body = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<c:calendar-data/>
|
||||
</d:prop>
|
||||
<c:filter>
|
||||
<c:comp-filter name="VCALENDAR">
|
||||
<c:comp-filter name="VEVENT"/>
|
||||
</c:comp-filter>
|
||||
</c:filter>
|
||||
</c:calendar-query>"#;
|
||||
|
||||
let url = format!("{}{}", client.config.server_url.trim_end_matches('/'), calendar_path);
|
||||
println!("Request URL: {}", url);
|
||||
|
||||
let response = client.http_client
|
||||
.request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url)
|
||||
.header("Authorization", format!("Basic {}", client.config.get_basic_auth()))
|
||||
.header("Content-Type", "application/xml")
|
||||
.header("Depth", "1")
|
||||
.header("User-Agent", "calendar-app/0.1.0")
|
||||
.body(report_body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
println!("Response status: {}", response.status());
|
||||
let body = response.text().await?;
|
||||
println!("Response body length: {}", body.len());
|
||||
println!("First 500 chars of response: {}", &body[..std::cmp::min(500, body.len())]);
|
||||
|
||||
// Try to parse it
|
||||
let events = client.parse_calendar_response(&body)?;
|
||||
println!("Parsed {} events", events.len());
|
||||
|
||||
for (i, event) in events.iter().enumerate() {
|
||||
println!("Event {}: {}", i+1, event.summary.as_deref().unwrap_or("No title"));
|
||||
println!(" Start: {}", event.start);
|
||||
println!(" UID: {}", event.uid);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
12
backend/src/handlers.rs
Normal file
12
backend/src/handlers.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
// Re-export all handlers from the modular structure
|
||||
mod auth;
|
||||
mod calendar;
|
||||
mod events;
|
||||
mod preferences;
|
||||
mod series;
|
||||
|
||||
pub use auth::{get_user_info, login, verify_token};
|
||||
pub use calendar::{create_calendar, delete_calendar};
|
||||
pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event};
|
||||
pub use preferences::{get_preferences, logout, update_preferences};
|
||||
pub use series::{create_event_series, delete_event_series, update_event_series};
|
||||
143
backend/src/handlers/auth.rs
Normal file
143
backend/src/handlers/auth.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::calendar::CalDAVClient;
|
||||
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))
|
||||
}
|
||||
}
|
||||
128
backend/src/handlers/preferences.rs
Normal file
128
backend/src/handlers/preferences.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
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_style: preferences.calendar_style,
|
||||
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_style.is_some() {
|
||||
preferences.calendar_style = request.calendar_style;
|
||||
}
|
||||
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_style: preferences.calendar_style,
|
||||
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
102
backend/src/lib.rs
Normal file
102
backend/src/lib.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use axum::{
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
pub mod auth;
|
||||
pub mod calendar;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod handlers;
|
||||
pub mod models;
|
||||
|
||||
use auth::AuthService;
|
||||
use db::Database;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub auth_service: AuthService,
|
||||
pub db: Database,
|
||||
}
|
||||
|
||||
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialize logging
|
||||
println!("🚀 Starting Calendar Backend Server");
|
||||
|
||||
// Initialize database
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "sqlite:calendar.db".to_string());
|
||||
|
||||
let db = Database::new(&database_url).await?;
|
||||
println!("✅ Database initialized");
|
||||
|
||||
// Create auth service
|
||||
let jwt_secret = std::env::var("JWT_SECRET")
|
||||
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
|
||||
|
||||
let auth_service = AuthService::new(jwt_secret, db.clone());
|
||||
|
||||
let app_state = AppState { auth_service, db };
|
||||
|
||||
// Build our application with routes
|
||||
let app = Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/api/health", get(health_check))
|
||||
.route("/api/auth/login", post(handlers::login))
|
||||
.route("/api/auth/verify", get(handlers::verify_token))
|
||||
.route("/api/user/info", get(handlers::get_user_info))
|
||||
.route("/api/calendar/create", post(handlers::create_calendar))
|
||||
.route("/api/calendar/delete", post(handlers::delete_calendar))
|
||||
.route("/api/calendar/events", get(handlers::get_calendar_events))
|
||||
.route("/api/calendar/events/create", post(handlers::create_event))
|
||||
.route("/api/calendar/events/update", post(handlers::update_event))
|
||||
.route("/api/calendar/events/delete", post(handlers::delete_event))
|
||||
.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(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any),
|
||||
)
|
||||
.with_state(Arc::new(app_state));
|
||||
|
||||
// Start server
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
||||
println!("📡 Server listening on http://0.0.0.0:3000");
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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"
|
||||
}))
|
||||
}
|
||||
7
backend/src/main.rs
Normal file
7
backend/src/main.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// Backend main entry point
|
||||
use calendar_backend::*;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
run_server().await
|
||||
}
|
||||
299
backend/src/models.rs
Normal file
299
backend/src/models.rs
Normal file
@@ -0,0 +1,299 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// API request/response types
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CalDAVLoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub server_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthResponse {
|
||||
pub token: String,
|
||||
pub session_token: String,
|
||||
pub username: String,
|
||||
pub server_url: String,
|
||||
pub preferences: UserPreferencesResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UserPreferencesResponse {
|
||||
pub calendar_selected_date: Option<String>,
|
||||
pub calendar_time_increment: Option<i32>,
|
||||
pub calendar_view_mode: Option<String>,
|
||||
pub calendar_theme: Option<String>,
|
||||
pub calendar_style: 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_style: Option<String>,
|
||||
pub calendar_colors: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserInfo {
|
||||
pub username: String,
|
||||
pub server_url: String,
|
||||
pub calendars: Vec<CalendarInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CalendarInfo {
|
||||
pub path: String,
|
||||
pub display_name: String,
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateCalendarRequest {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateCalendarResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeleteCalendarRequest {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeleteCalendarResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeleteEventRequest {
|
||||
pub calendar_path: String,
|
||||
pub event_href: String,
|
||||
pub delete_action: String, // "delete_this", "delete_following", or "delete_series"
|
||||
pub occurrence_date: Option<String>, // ISO date string for the specific occurrence
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeleteEventResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateEventRequest {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub start_date: String, // YYYY-MM-DD format
|
||||
pub start_time: String, // HH:MM format
|
||||
pub end_date: String, // YYYY-MM-DD format
|
||||
pub end_time: String, // HH:MM format
|
||||
pub location: String,
|
||||
pub all_day: bool,
|
||||
pub status: String, // confirmed, tentative, cancelled
|
||||
pub class: String, // public, private, confidential
|
||||
pub priority: Option<u8>, // 0-9 priority level
|
||||
pub organizer: String, // organizer email
|
||||
pub attendees: String, // comma-separated attendee emails
|
||||
pub categories: String, // comma-separated categories
|
||||
pub reminder: String, // reminder type
|
||||
pub recurrence: String, // recurrence type
|
||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateEventResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub event_href: Option<String>, // The created event's href/filename
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateEventRequest {
|
||||
pub uid: String, // Event UID to identify which event to update
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub start_date: String, // YYYY-MM-DD format
|
||||
pub start_time: String, // HH:MM format
|
||||
pub end_date: String, // YYYY-MM-DD format
|
||||
pub end_time: String, // HH:MM format
|
||||
pub location: String,
|
||||
pub all_day: bool,
|
||||
pub status: String, // confirmed, tentative, cancelled
|
||||
pub class: String, // public, private, confidential
|
||||
pub priority: Option<u8>, // 0-9 priority level
|
||||
pub organizer: String, // organizer email
|
||||
pub attendees: String, // comma-separated attendee emails
|
||||
pub categories: String, // comma-separated categories
|
||||
pub reminder: String, // reminder type
|
||||
pub recurrence: String, // recurrence type
|
||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||
pub 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)]
|
||||
pub struct UpdateEventResponse {
|
||||
pub success: bool,
|
||||
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
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
Database(String),
|
||||
NotFound(String),
|
||||
Unauthorized(String),
|
||||
BadRequest(String),
|
||||
Conflict(String),
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, error_message) = match self {
|
||||
ApiError::Database(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
|
||||
ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
||||
ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
|
||||
ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||
ApiError::Conflict(msg) => (StatusCode::CONFLICT, msg),
|
||||
ApiError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
|
||||
};
|
||||
|
||||
let body = Json(serde_json::json!({
|
||||
"error": error_message,
|
||||
"status": status.as_u16()
|
||||
}));
|
||||
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApiError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ApiError::Database(msg) => write!(f, "Database error: {}", msg),
|
||||
ApiError::NotFound(msg) => write!(f, "Not found: {}", msg),
|
||||
ApiError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
|
||||
ApiError::BadRequest(msg) => write!(f, "Bad request: {}", msg),
|
||||
ApiError::Conflict(msg) => write!(f, "Conflict: {}", msg),
|
||||
ApiError::Internal(msg) => write!(f, "Internal error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
calendar.db
Normal file
BIN
calendar.db
Normal file
Binary file not shown.
21
compose.yml
Normal file
21
compose.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
calendar-backend:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data/site_dist:/srv/www
|
||||
- ./data/db:/db
|
||||
|
||||
calendar-frontend:
|
||||
image: caddy
|
||||
environment:
|
||||
- BACKEND_API_URL=http://localhost:3000/api
|
||||
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
|
||||
69
frontend/Cargo.toml
Normal file
69
frontend/Cargo.toml
Normal file
@@ -0,0 +1,69 @@
|
||||
[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",
|
||||
"HtmlLinkElement",
|
||||
"HtmlHeadElement",
|
||||
"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
|
||||
|
||||
19
frontend/index.html
Normal file
19
frontend/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!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">
|
||||
<link data-trunk rel="copy-file" href="styles/google.css">
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
console.log("HTML loaded, waiting for WASM...");
|
||||
window.addEventListener('TrunkApplicationStarted', () => {
|
||||
console.log("Trunk application started successfully!");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1292
frontend/src/app.rs
Normal file
1292
frontend/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
110
frontend/src/auth.rs
Normal file
110
frontend/src/auth.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
// Frontend authentication module - connects to backend API
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{Request, RequestInit, RequestMode, Response};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CalDAVLoginRequest {
|
||||
pub server_url: String,
|
||||
pub username: 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)]
|
||||
pub struct AuthResponse {
|
||||
pub token: String,
|
||||
pub session_token: String,
|
||||
pub username: String,
|
||||
pub server_url: String,
|
||||
pub preferences: UserPreferencesResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ApiErrorResponse {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
// Frontend auth service - connects to backend API
|
||||
pub struct AuthService {
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub fn new() -> Self {
|
||||
// Get backend URL from environment variable at compile time, fallback to localhost
|
||||
let base_url = option_env!("BACKEND_API_URL")
|
||||
.unwrap_or("http://localhost:3000/api")
|
||||
.to_string();
|
||||
|
||||
Self { base_url }
|
||||
}
|
||||
|
||||
pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, String> {
|
||||
self.post_json("/auth/login", &request).await
|
||||
}
|
||||
|
||||
// Helper method for POST requests with JSON body
|
||||
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
body: &T,
|
||||
) -> Result<R, String> {
|
||||
let window = web_sys::window().ok_or("No global window exists")?;
|
||||
|
||||
let json_body =
|
||||
serde_json::to_string(body).map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
|
||||
let 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!("{}{}", self.base_url, endpoint);
|
||||
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))?;
|
||||
|
||||
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() {
|
||||
serde_json::from_str::<R>(&text_string)
|
||||
.map_err(|e| format!("JSON parsing failed: {}", e))
|
||||
} else {
|
||||
// Try to parse error response
|
||||
if let Ok(error_response) = serde_json::from_str::<ApiErrorResponse>(&text_string) {
|
||||
Err(error_response.error)
|
||||
} else {
|
||||
Err(format!("Request failed with status {}", resp.status()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
639
frontend/src/calendar.rs
Normal file
639
frontend/src/calendar.rs
Normal file
@@ -0,0 +1,639 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Represents a calendar event with all its properties
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CalendarEvent {
|
||||
/// Unique identifier for the event (UID field in iCal)
|
||||
pub uid: String,
|
||||
|
||||
/// Summary/title of the event
|
||||
pub summary: Option<String>,
|
||||
|
||||
/// Detailed description of the event
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Start date and time of the event
|
||||
pub start: DateTime<Utc>,
|
||||
|
||||
/// End date and time of the event
|
||||
pub end: Option<DateTime<Utc>>,
|
||||
|
||||
/// Location where the event takes place
|
||||
pub location: Option<String>,
|
||||
|
||||
/// Event status (TENTATIVE, CONFIRMED, CANCELLED)
|
||||
pub status: EventStatus,
|
||||
|
||||
/// Event classification (PUBLIC, PRIVATE, CONFIDENTIAL)
|
||||
pub class: EventClass,
|
||||
|
||||
/// Event priority (0-9, where 0 is undefined, 1 is highest, 9 is lowest)
|
||||
pub priority: Option<u8>,
|
||||
|
||||
/// Organizer of the event
|
||||
pub organizer: Option<String>,
|
||||
|
||||
/// List of attendees
|
||||
pub attendees: Vec<String>,
|
||||
|
||||
/// Categories/tags for the event
|
||||
pub categories: Vec<String>,
|
||||
|
||||
/// Date and time when the event was created
|
||||
pub created: Option<DateTime<Utc>>,
|
||||
|
||||
/// Date and time when the event was last modified
|
||||
pub last_modified: Option<DateTime<Utc>>,
|
||||
|
||||
/// Recurrence rule (RRULE)
|
||||
pub recurrence_rule: Option<String>,
|
||||
|
||||
/// All-day event flag
|
||||
pub all_day: bool,
|
||||
|
||||
/// ETag from CalDAV server for conflict detection
|
||||
pub etag: Option<String>,
|
||||
|
||||
/// URL/href of this event on the CalDAV server
|
||||
pub href: Option<String>,
|
||||
}
|
||||
|
||||
/// Event status enumeration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum EventStatus {
|
||||
Tentative,
|
||||
Confirmed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl Default for EventStatus {
|
||||
fn default() -> Self {
|
||||
EventStatus::Confirmed
|
||||
}
|
||||
}
|
||||
|
||||
/// Event classification enumeration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum EventClass {
|
||||
Public,
|
||||
Private,
|
||||
Confidential,
|
||||
}
|
||||
|
||||
impl Default for EventClass {
|
||||
fn default() -> Self {
|
||||
EventClass::Public
|
||||
}
|
||||
}
|
||||
|
||||
/// CalDAV client for fetching and parsing calendar events
|
||||
pub struct CalDAVClient {
|
||||
config: crate::config::CalDAVConfig,
|
||||
http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl CalDAVClient {
|
||||
/// Create a new CalDAV client with the given configuration
|
||||
pub fn new(config: crate::config::CalDAVConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
http_client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch calendar events from the CalDAV server
|
||||
///
|
||||
/// This method performs a REPORT request to get calendar data and parses
|
||||
/// the returned iCalendar format into CalendarEvent structs.
|
||||
pub async fn fetch_events(&self, calendar_path: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
||||
// CalDAV REPORT request to get calendar events
|
||||
let report_body = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<c:calendar-data/>
|
||||
</d:prop>
|
||||
<c:filter>
|
||||
<c:comp-filter name="VCALENDAR">
|
||||
<c:comp-filter name="VEVENT"/>
|
||||
</c:comp-filter>
|
||||
</c:filter>
|
||||
</c:calendar-query>"#;
|
||||
|
||||
let url = if calendar_path.starts_with("http") {
|
||||
calendar_path.to_string()
|
||||
} else {
|
||||
format!("{}{}", self.config.server_url.trim_end_matches('/'), calendar_path)
|
||||
};
|
||||
|
||||
let response = self.http_client
|
||||
.request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url)
|
||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
||||
.header("Content-Type", "application/xml")
|
||||
.header("Depth", "1")
|
||||
.header("User-Agent", "calendar-app/0.1.0")
|
||||
.body(report_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(CalDAVError::RequestError)?;
|
||||
|
||||
if !response.status().is_success() && response.status().as_u16() != 207 {
|
||||
return Err(CalDAVError::ServerError(response.status().as_u16()));
|
||||
}
|
||||
|
||||
let body = response.text().await.map_err(CalDAVError::RequestError)?;
|
||||
self.parse_calendar_response(&body)
|
||||
}
|
||||
|
||||
/// Parse CalDAV XML response containing calendar data
|
||||
fn parse_calendar_response(&self, xml_response: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
// Extract calendar data from XML response
|
||||
// This is a simplified parser - in production, you'd want a proper XML parser
|
||||
let calendar_data_sections = self.extract_calendar_data(xml_response);
|
||||
|
||||
for calendar_data in calendar_data_sections {
|
||||
if let Ok(parsed_events) = self.parse_ical_data(&calendar_data.data) {
|
||||
for mut event in parsed_events {
|
||||
event.etag = calendar_data.etag.clone();
|
||||
event.href = calendar_data.href.clone();
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Extract calendar data sections from CalDAV XML response
|
||||
fn extract_calendar_data(&self, xml_response: &str) -> Vec<CalendarDataSection> {
|
||||
let mut sections = Vec::new();
|
||||
|
||||
// Simple regex-based extraction (in production, use a proper XML parser)
|
||||
// Look for <d:response> blocks containing calendar data
|
||||
for response_block in xml_response.split("<d:response>").skip(1) {
|
||||
if let Some(end_pos) = response_block.find("</d:response>") {
|
||||
let response_content = &response_block[..end_pos];
|
||||
|
||||
let href = self.extract_xml_content(response_content, "href").unwrap_or_default();
|
||||
let etag = self.extract_xml_content(response_content, "getetag").unwrap_or_default();
|
||||
|
||||
if let Some(calendar_data) = self.extract_xml_content(response_content, "calendar-data") {
|
||||
sections.push(CalendarDataSection {
|
||||
href: if href.is_empty() { None } else { Some(href) },
|
||||
etag: if etag.is_empty() { None } else { Some(etag) },
|
||||
data: calendar_data,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sections
|
||||
}
|
||||
|
||||
/// Extract content from XML tags (simple implementation)
|
||||
fn extract_xml_content(&self, xml: &str, tag: &str) -> Option<String> {
|
||||
// Handle both with and without namespace prefixes
|
||||
let patterns = [
|
||||
format!("<{}>(.*?)</{}>", tag, tag),
|
||||
format!("<{}>(.*?)</.*:{}>", tag, tag),
|
||||
format!("<.*:{}>(.*?)</{}>", tag, tag),
|
||||
format!("<.*:{}>(.*?)</.*:{}>", tag, tag),
|
||||
];
|
||||
|
||||
for pattern in &patterns {
|
||||
if let Ok(re) = regex::Regex::new(pattern) {
|
||||
if let Some(captures) = re.captures(xml) {
|
||||
if let Some(content) = captures.get(1) {
|
||||
return Some(content.as_str().trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse iCalendar data into CalendarEvent structs
|
||||
fn parse_ical_data(&self, ical_data: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
// Parse the iCal data using the ical crate
|
||||
let reader = ical::IcalParser::new(ical_data.as_bytes());
|
||||
|
||||
for calendar in reader {
|
||||
let calendar = calendar.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
|
||||
|
||||
for event in calendar.events {
|
||||
if let Ok(calendar_event) = self.parse_ical_event(event) {
|
||||
events.push(calendar_event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Parse a single iCal event into a CalendarEvent struct
|
||||
fn parse_ical_event(&self, event: ical::parser::ical::component::IcalEvent) -> Result<CalendarEvent, CalDAVError> {
|
||||
let mut properties: HashMap<String, String> = HashMap::new();
|
||||
|
||||
// Extract all properties from the event
|
||||
for property in event.properties {
|
||||
properties.insert(property.name.to_uppercase(), property.value.unwrap_or_default());
|
||||
}
|
||||
|
||||
// Required UID field
|
||||
let uid = properties.get("UID")
|
||||
.ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))?
|
||||
.clone();
|
||||
|
||||
// Parse start time (required)
|
||||
let start = properties.get("DTSTART")
|
||||
.ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?;
|
||||
let start = self.parse_datetime(start, properties.get("DTSTART"))?;
|
||||
|
||||
// Parse end time (optional - use start time if not present)
|
||||
let end = if let Some(dtend) = properties.get("DTEND") {
|
||||
Some(self.parse_datetime(dtend, properties.get("DTEND"))?)
|
||||
} else if let Some(duration) = properties.get("DURATION") {
|
||||
// TODO: Parse duration and add to start time
|
||||
Some(start)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Determine if it's an all-day event
|
||||
let all_day = properties.get("DTSTART")
|
||||
.map(|s| !s.contains("T"))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Parse status
|
||||
let status = properties.get("STATUS")
|
||||
.map(|s| match s.to_uppercase().as_str() {
|
||||
"TENTATIVE" => EventStatus::Tentative,
|
||||
"CANCELLED" => EventStatus::Cancelled,
|
||||
_ => EventStatus::Confirmed,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Parse classification
|
||||
let class = properties.get("CLASS")
|
||||
.map(|s| match s.to_uppercase().as_str() {
|
||||
"PRIVATE" => EventClass::Private,
|
||||
"CONFIDENTIAL" => EventClass::Confidential,
|
||||
_ => EventClass::Public,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Parse priority
|
||||
let priority = properties.get("PRIORITY")
|
||||
.and_then(|s| s.parse::<u8>().ok())
|
||||
.filter(|&p| p <= 9);
|
||||
|
||||
// Parse categories
|
||||
let categories = properties.get("CATEGORIES")
|
||||
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Parse dates
|
||||
let created = properties.get("CREATED")
|
||||
.and_then(|s| self.parse_datetime(s, None).ok());
|
||||
|
||||
let last_modified = properties.get("LAST-MODIFIED")
|
||||
.and_then(|s| self.parse_datetime(s, None).ok());
|
||||
|
||||
Ok(CalendarEvent {
|
||||
uid,
|
||||
summary: properties.get("SUMMARY").cloned(),
|
||||
description: properties.get("DESCRIPTION").cloned(),
|
||||
start,
|
||||
end,
|
||||
location: properties.get("LOCATION").cloned(),
|
||||
status,
|
||||
class,
|
||||
priority,
|
||||
organizer: properties.get("ORGANIZER").cloned(),
|
||||
attendees: Vec::new(), // TODO: Parse attendees
|
||||
categories,
|
||||
created,
|
||||
last_modified,
|
||||
recurrence_rule: properties.get("RRULE").cloned(),
|
||||
all_day,
|
||||
etag: None, // Set by caller
|
||||
href: None, // Set by caller
|
||||
})
|
||||
}
|
||||
|
||||
/// Discover available calendar collections on the server
|
||||
pub async fn discover_calendars(&self) -> Result<Vec<String>, CalDAVError> {
|
||||
// First, try to discover user calendars if we have a calendar path in config
|
||||
if let Some(calendar_path) = &self.config.calendar_path {
|
||||
println!("Using configured calendar path: {}", calendar_path);
|
||||
return Ok(vec![calendar_path.clone()]);
|
||||
}
|
||||
|
||||
println!("No calendar path configured, discovering calendars...");
|
||||
|
||||
// Try different common CalDAV discovery paths
|
||||
let user_calendar_path = format!("/calendars/{}/", self.config.username);
|
||||
let user_dav_calendar_path = format!("/dav.php/calendars/{}/", self.config.username);
|
||||
|
||||
let discovery_paths = vec![
|
||||
"/calendars/",
|
||||
user_calendar_path.as_str(),
|
||||
user_dav_calendar_path.as_str(),
|
||||
"/dav.php/calendars/",
|
||||
];
|
||||
|
||||
let mut all_calendars = Vec::new();
|
||||
|
||||
for path in discovery_paths {
|
||||
println!("Trying discovery path: {}", path);
|
||||
if let Ok(calendars) = self.discover_calendars_at_path(&path).await {
|
||||
println!("Found {} calendar(s) at {}", calendars.len(), path);
|
||||
all_calendars.extend(calendars);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
all_calendars.sort();
|
||||
all_calendars.dedup();
|
||||
|
||||
Ok(all_calendars)
|
||||
}
|
||||
|
||||
/// Discover calendars at a specific path
|
||||
async fn discover_calendars_at_path(&self, path: &str) -> Result<Vec<String>, CalDAVError> {
|
||||
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:supported-calendar-component-set />
|
||||
</d:prop>
|
||||
</d:propfind>"#;
|
||||
|
||||
let url = format!("{}{}", self.config.server_url.trim_end_matches('/'), path);
|
||||
|
||||
let response = self.http_client
|
||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url)
|
||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
||||
.header("Content-Type", "application/xml")
|
||||
.header("Depth", "2") // Deeper search to find actual calendars
|
||||
.header("User-Agent", "calendar-app/0.1.0")
|
||||
.body(propfind_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(CalDAVError::RequestError)?;
|
||||
|
||||
if response.status().as_u16() != 207 {
|
||||
return Err(CalDAVError::ServerError(response.status().as_u16()));
|
||||
}
|
||||
|
||||
let body = response.text().await.map_err(CalDAVError::RequestError)?;
|
||||
println!("Discovery response for {}: {}", path, body);
|
||||
|
||||
let mut calendar_paths = Vec::new();
|
||||
|
||||
// Extract calendar collection URLs from the response
|
||||
for response_block in body.split("<d:response>").skip(1) {
|
||||
if let Some(end_pos) = response_block.find("</d:response>") {
|
||||
let response_content = &response_block[..end_pos];
|
||||
|
||||
// Look for actual calendar collections (not just containers)
|
||||
if response_content.contains("<c:supported-calendar-component-set") ||
|
||||
(response_content.contains("<d:collection/>") &&
|
||||
response_content.contains("calendar")) {
|
||||
if let Some(href) = self.extract_xml_content(response_content, "href") {
|
||||
// Only include actual calendar paths, not container directories
|
||||
if href.ends_with('/') && href.contains("calendar") && !href.ends_with("/calendars/") {
|
||||
calendar_paths.push(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(calendar_paths)
|
||||
}
|
||||
|
||||
/// Parse iCal datetime format
|
||||
fn parse_datetime(&self, datetime_str: &str, _original_property: Option<&String>) -> Result<DateTime<Utc>, CalDAVError> {
|
||||
use chrono::TimeZone;
|
||||
|
||||
// Handle different iCal datetime formats
|
||||
let cleaned = datetime_str.replace("TZID=", "").trim().to_string();
|
||||
|
||||
// Try different parsing formats
|
||||
let formats = [
|
||||
"%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z
|
||||
"%Y%m%dT%H%M%S", // Local format: 20231225T120000
|
||||
"%Y%m%d", // Date only: 20231225
|
||||
];
|
||||
|
||||
for format in &formats {
|
||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&cleaned, format) {
|
||||
return Ok(Utc.from_utc_datetime(&dt));
|
||||
}
|
||||
if let Ok(date) = chrono::NaiveDate::parse_from_str(&cleaned, format) {
|
||||
return Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
Err(CalDAVError::ParseError(format!("Unable to parse datetime: {}", datetime_str)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper struct for extracting calendar data from XML responses
|
||||
#[derive(Debug)]
|
||||
struct CalendarDataSection {
|
||||
pub href: Option<String>,
|
||||
pub etag: Option<String>,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// CalDAV-specific error types
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CalDAVError {
|
||||
#[error("HTTP request failed: {0}")]
|
||||
RequestError(#[from] reqwest::Error),
|
||||
|
||||
#[error("CalDAV server returned error: {0}")]
|
||||
ServerError(u16),
|
||||
|
||||
#[error("Failed to parse calendar data: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
ConfigError(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::CalDAVConfig;
|
||||
|
||||
/// Integration test that fetches real calendar events from the Baikal server
|
||||
///
|
||||
/// This test requires a valid .env file and a calendar with some events
|
||||
#[tokio::test]
|
||||
async fn test_fetch_calendar_events() {
|
||||
let config = CalDAVConfig::from_env()
|
||||
.expect("Failed to load CalDAV config from environment");
|
||||
|
||||
let client = CalDAVClient::new(config);
|
||||
|
||||
// First discover available calendars using PROPFIND
|
||||
println!("Discovering calendars...");
|
||||
let discovery_result = client.discover_calendars().await;
|
||||
|
||||
match discovery_result {
|
||||
Ok(calendar_paths) => {
|
||||
println!("Found {} calendar collection(s)", calendar_paths.len());
|
||||
|
||||
if calendar_paths.is_empty() {
|
||||
println!("No calendars found - this might be normal for a new server");
|
||||
return;
|
||||
}
|
||||
|
||||
// Try the first available calendar
|
||||
let calendar_path = &calendar_paths[0];
|
||||
println!("Trying to fetch events from: {}", calendar_path);
|
||||
|
||||
match client.fetch_events(calendar_path).await {
|
||||
Ok(events) => {
|
||||
println!("Successfully fetched {} calendar events", events.len());
|
||||
|
||||
for (i, event) in events.iter().take(3).enumerate() {
|
||||
println!("\n--- Event {} ---", i + 1);
|
||||
println!("UID: {}", event.uid);
|
||||
println!("Summary: {:?}", event.summary);
|
||||
println!("Start: {}", event.start);
|
||||
println!("End: {:?}", event.end);
|
||||
println!("All Day: {}", event.all_day);
|
||||
println!("Status: {:?}", event.status);
|
||||
println!("Location: {:?}", event.location);
|
||||
println!("Description: {:?}", event.description);
|
||||
println!("ETag: {:?}", event.etag);
|
||||
println!("HREF: {:?}", event.href);
|
||||
}
|
||||
|
||||
// Validate that events have required fields
|
||||
for event in &events {
|
||||
assert!(!event.uid.is_empty(), "Event UID should not be empty");
|
||||
// All events should have a start time
|
||||
assert!(event.start > DateTime::from_timestamp(0, 0).unwrap(), "Event should have valid start time");
|
||||
}
|
||||
|
||||
println!("\n✓ Calendar event fetching test passed!");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error fetching events from {}: {:?}", calendar_path, e);
|
||||
println!("This might be normal if the calendar is empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error discovering calendars: {:?}", e);
|
||||
println!("This might be normal if no calendars are set up yet");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test parsing a sample iCal event
|
||||
#[test]
|
||||
fn test_parse_ical_event() {
|
||||
let sample_ical = r#"BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:test-event-123@example.com
|
||||
DTSTART:20231225T120000Z
|
||||
DTEND:20231225T130000Z
|
||||
SUMMARY:Test Event
|
||||
DESCRIPTION:This is a test event
|
||||
LOCATION:Test Location
|
||||
STATUS:CONFIRMED
|
||||
CLASS:PUBLIC
|
||||
PRIORITY:5
|
||||
CREATED:20231220T100000Z
|
||||
LAST-MODIFIED:20231221T150000Z
|
||||
CATEGORIES:work,important
|
||||
END:VEVENT
|
||||
END:VCALENDAR"#;
|
||||
|
||||
let config = CalDAVConfig {
|
||||
server_url: "https://example.com".to_string(),
|
||||
username: "test".to_string(),
|
||||
password: "test".to_string(),
|
||||
calendar_path: None,
|
||||
tasks_path: None,
|
||||
};
|
||||
|
||||
let client = CalDAVClient::new(config);
|
||||
let events = client.parse_ical_data(sample_ical)
|
||||
.expect("Should be able to parse sample iCal data");
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
let event = &events[0];
|
||||
assert_eq!(event.uid, "test-event-123@example.com");
|
||||
assert_eq!(event.summary, Some("Test Event".to_string()));
|
||||
assert_eq!(event.description, Some("This is a test event".to_string()));
|
||||
assert_eq!(event.location, Some("Test Location".to_string()));
|
||||
assert_eq!(event.status, EventStatus::Confirmed);
|
||||
assert_eq!(event.class, EventClass::Public);
|
||||
assert_eq!(event.priority, Some(5));
|
||||
assert_eq!(event.categories, vec!["work", "important"]);
|
||||
assert!(!event.all_day);
|
||||
|
||||
println!("✓ iCal parsing test passed!");
|
||||
}
|
||||
|
||||
/// Test datetime parsing
|
||||
#[test]
|
||||
fn test_datetime_parsing() {
|
||||
let config = CalDAVConfig {
|
||||
server_url: "https://example.com".to_string(),
|
||||
username: "test".to_string(),
|
||||
password: "test".to_string(),
|
||||
calendar_path: None,
|
||||
tasks_path: None,
|
||||
};
|
||||
|
||||
let client = CalDAVClient::new(config);
|
||||
|
||||
// Test UTC format
|
||||
let dt1 = client.parse_datetime("20231225T120000Z", None)
|
||||
.expect("Should parse UTC datetime");
|
||||
println!("Parsed UTC datetime: {}", dt1);
|
||||
|
||||
// Test date-only format (should be treated as all-day)
|
||||
let dt2 = client.parse_datetime("20231225", None)
|
||||
.expect("Should parse date-only");
|
||||
println!("Parsed date-only: {}", dt2);
|
||||
|
||||
// Test local format
|
||||
let dt3 = client.parse_datetime("20231225T120000", None)
|
||||
.expect("Should parse local datetime");
|
||||
println!("Parsed local datetime: {}", dt3);
|
||||
|
||||
println!("✓ Datetime parsing test passed!");
|
||||
}
|
||||
|
||||
/// Test event status parsing
|
||||
#[test]
|
||||
fn test_event_enums() {
|
||||
// Test status parsing
|
||||
assert_eq!(EventStatus::default(), EventStatus::Confirmed);
|
||||
|
||||
// Test class parsing
|
||||
assert_eq!(EventClass::default(), EventClass::Public);
|
||||
|
||||
println!("✓ Event enum tests passed!");
|
||||
}
|
||||
}
|
||||
536
frontend/src/components/calendar.rs
Normal file
536
frontend/src/components/calendar.rs
Normal file
@@ -0,0 +1,536 @@
|
||||
use crate::components::{
|
||||
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
|
||||
};
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::{calendar_service::UserInfo, CalendarService};
|
||||
use chrono::{Datelike, Duration, Local, NaiveDate};
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use std::collections::HashMap;
|
||||
use web_sys::MouseEvent;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarProps {
|
||||
#[prop_or_default]
|
||||
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, NaiveDate)>>,
|
||||
#[prop_or_default]
|
||||
pub view: ViewMode,
|
||||
#[prop_or_default]
|
||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||
#[prop_or_default]
|
||||
pub on_event_update_request: Option<
|
||||
Callback<(
|
||||
VEvent,
|
||||
chrono::NaiveDateTime,
|
||||
chrono::NaiveDateTime,
|
||||
bool,
|
||||
Option<chrono::DateTime<chrono::Utc>>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)>,
|
||||
>,
|
||||
#[prop_or_default]
|
||||
pub context_menus_open: bool,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
let today = Local::now().date_naive();
|
||||
|
||||
// Event management state
|
||||
let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new());
|
||||
let loading = use_state(|| true);
|
||||
let error = use_state(|| None::<String>);
|
||||
let refreshing_event_uid = use_state(|| None::<String>);
|
||||
// Track the currently selected date (the actual day the user has selected)
|
||||
let selected_date = use_state(|| {
|
||||
// Try to load saved selected date from localStorage
|
||||
if let Ok(saved_date_str) = LocalStorage::get::<String>("calendar_selected_date") {
|
||||
if let Ok(saved_date) = NaiveDate::parse_from_str(&saved_date_str, "%Y-%m-%d") {
|
||||
saved_date
|
||||
} else {
|
||||
today
|
||||
}
|
||||
} else {
|
||||
// Check for old key for backward compatibility
|
||||
if let Ok(saved_date_str) = LocalStorage::get::<String>("calendar_current_month") {
|
||||
if let Ok(saved_date) = NaiveDate::parse_from_str(&saved_date_str, "%Y-%m-%d") {
|
||||
saved_date
|
||||
} else {
|
||||
today
|
||||
}
|
||||
} else {
|
||||
today
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Track the display date (what to show in the view)
|
||||
let current_date = use_state(|| match props.view {
|
||||
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
|
||||
ViewMode::Week => *selected_date,
|
||||
});
|
||||
let selected_event = use_state(|| None::<VEvent>);
|
||||
|
||||
// State for create event modal
|
||||
let show_create_modal = use_state(|| false);
|
||||
let create_event_data =
|
||||
use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>);
|
||||
|
||||
// State for time increment snapping (15 or 30 minutes)
|
||||
let time_increment = use_state(|| {
|
||||
// Try to load saved time increment from localStorage
|
||||
if let Ok(saved_increment) = LocalStorage::get::<u32>("calendar_time_increment") {
|
||||
if saved_increment == 15 || saved_increment == 30 {
|
||||
saved_increment
|
||||
} else {
|
||||
15
|
||||
}
|
||||
} else {
|
||||
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
|
||||
{
|
||||
let current_date = current_date.clone();
|
||||
let selected_date = selected_date.clone();
|
||||
let view = props.view.clone();
|
||||
use_effect_with(view, move |view_mode| {
|
||||
let selected = *selected_date;
|
||||
let new_display_date = match view_mode {
|
||||
ViewMode::Month => selected.with_day(1).unwrap_or(selected),
|
||||
ViewMode::Week => selected, // Show the week containing the selected date
|
||||
};
|
||||
current_date.set(new_display_date);
|
||||
|| {}
|
||||
});
|
||||
}
|
||||
|
||||
let on_prev = {
|
||||
let current_date = current_date.clone();
|
||||
let selected_date = selected_date.clone();
|
||||
let view = props.view.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
let (new_selected, new_display) = match view {
|
||||
ViewMode::Month => {
|
||||
// Go to previous month, select the 1st day
|
||||
let prev_month = *current_date - Duration::days(1);
|
||||
let first_of_prev = prev_month.with_day(1).unwrap();
|
||||
(first_of_prev, first_of_prev)
|
||||
}
|
||||
ViewMode::Week => {
|
||||
// Go to previous week
|
||||
let prev_week = *selected_date - Duration::weeks(1);
|
||||
(prev_week, prev_week)
|
||||
}
|
||||
};
|
||||
selected_date.set(new_selected);
|
||||
current_date.set(new_display);
|
||||
let _ = LocalStorage::set(
|
||||
"calendar_selected_date",
|
||||
new_selected.format("%Y-%m-%d").to_string(),
|
||||
);
|
||||
})
|
||||
};
|
||||
|
||||
let on_next = {
|
||||
let current_date = current_date.clone();
|
||||
let selected_date = selected_date.clone();
|
||||
let view = props.view.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
let (new_selected, new_display) = match view {
|
||||
ViewMode::Month => {
|
||||
// Go to next month, select the 1st day
|
||||
let next_month = if current_date.month() == 12 {
|
||||
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
|
||||
} else {
|
||||
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1)
|
||||
.unwrap()
|
||||
};
|
||||
(next_month, next_month)
|
||||
}
|
||||
ViewMode::Week => {
|
||||
// Go to next week
|
||||
let next_week = *selected_date + Duration::weeks(1);
|
||||
(next_week, next_week)
|
||||
}
|
||||
};
|
||||
selected_date.set(new_selected);
|
||||
current_date.set(new_display);
|
||||
let _ = LocalStorage::set(
|
||||
"calendar_selected_date",
|
||||
new_selected.format("%Y-%m-%d").to_string(),
|
||||
);
|
||||
})
|
||||
};
|
||||
|
||||
let on_today = {
|
||||
let current_date = current_date.clone();
|
||||
let selected_date = selected_date.clone();
|
||||
let view = props.view.clone();
|
||||
Callback::from(move |_| {
|
||||
let today = Local::now().date_naive();
|
||||
let (new_selected, new_display) = match view {
|
||||
ViewMode::Month => {
|
||||
let first_of_today = today.with_day(1).unwrap();
|
||||
(today, first_of_today) // Select today, but display the month
|
||||
}
|
||||
ViewMode::Week => (today, today), // Select and display today
|
||||
};
|
||||
selected_date.set(new_selected);
|
||||
current_date.set(new_display);
|
||||
let _ = LocalStorage::set(
|
||||
"calendar_selected_date",
|
||||
new_selected.format("%Y-%m-%d").to_string(),
|
||||
);
|
||||
})
|
||||
};
|
||||
|
||||
// Handle time increment toggle
|
||||
let on_time_increment_toggle = {
|
||||
let time_increment = time_increment.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
let current = *time_increment;
|
||||
let next = if current == 15 { 30 } else { 15 };
|
||||
time_increment.set(next);
|
||||
let _ = LocalStorage::set("calendar_time_increment", next);
|
||||
})
|
||||
};
|
||||
|
||||
// Handle drag-to-create event
|
||||
let on_create_event = {
|
||||
let show_create_modal = show_create_modal.clone();
|
||||
let create_event_data = create_event_data.clone();
|
||||
Callback::from(
|
||||
move |(_date, start_datetime, end_datetime): (
|
||||
NaiveDate,
|
||||
chrono::NaiveDateTime,
|
||||
chrono::NaiveDateTime,
|
||||
)| {
|
||||
// For drag-to-create, we don't need the temporary event approach
|
||||
// Instead, just pass the local times directly via initial_time props
|
||||
create_event_data.set(Some((
|
||||
start_datetime.date(),
|
||||
start_datetime.time(),
|
||||
end_datetime.time(),
|
||||
)));
|
||||
show_create_modal.set(true);
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
// Handle drag-to-move event
|
||||
let on_event_update = {
|
||||
let on_event_update_request = props.on_event_update_request.clone();
|
||||
Callback::from(
|
||||
move |(
|
||||
event,
|
||||
new_start,
|
||||
new_end,
|
||||
preserve_rrule,
|
||||
until_date,
|
||||
update_scope,
|
||||
occurrence_date,
|
||||
): (
|
||||
VEvent,
|
||||
chrono::NaiveDateTime,
|
||||
chrono::NaiveDateTime,
|
||||
bool,
|
||||
Option<chrono::DateTime<chrono::Utc>>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)| {
|
||||
if let Some(callback) = &on_event_update_request {
|
||||
callback.emit((
|
||||
event,
|
||||
new_start,
|
||||
new_end,
|
||||
preserve_rrule,
|
||||
until_date,
|
||||
update_scope,
|
||||
occurrence_date,
|
||||
));
|
||||
}
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class={classes!("calendar", match props.view { ViewMode::Week => Some("week-view"), _ => None })}>
|
||||
<CalendarHeader
|
||||
current_date={*current_date}
|
||||
view_mode={props.view.clone()}
|
||||
on_prev={on_prev}
|
||||
on_next={on_next}
|
||||
on_today={on_today}
|
||||
time_increment={Some(*time_increment)}
|
||||
on_time_increment_toggle={Some(on_time_increment_toggle)}
|
||||
/>
|
||||
|
||||
{
|
||||
if *loading {
|
||||
html! {
|
||||
<div class="calendar-loading">
|
||||
<p>{"Loading calendar events..."}</p>
|
||||
</div>
|
||||
}
|
||||
} else if let Some(err) = (*error).clone() {
|
||||
html! {
|
||||
<div class="calendar-error">
|
||||
<p>{format!("Error: {}", err)}</p>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
match props.view {
|
||||
ViewMode::Month => {
|
||||
let on_day_select = {
|
||||
let selected_date = selected_date.clone();
|
||||
Callback::from(move |date: NaiveDate| {
|
||||
selected_date.set(date);
|
||||
let _ = LocalStorage::set("calendar_selected_date", date.format("%Y-%m-%d").to_string());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<MonthView
|
||||
current_month={*current_date}
|
||||
today={today}
|
||||
events={(*events).clone()}
|
||||
on_event_click={on_event_click.clone()}
|
||||
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
||||
user_info={props.user_info.clone()}
|
||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||
selected_date={Some(*selected_date)}
|
||||
on_day_select={Some(on_day_select)}
|
||||
/>
|
||||
}
|
||||
},
|
||||
ViewMode::Week => html! {
|
||||
<WeekView
|
||||
current_date={*current_date}
|
||||
today={today}
|
||||
events={(*events).clone()}
|
||||
on_event_click={on_event_click.clone()}
|
||||
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
||||
user_info={props.user_info.clone()}
|
||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||
on_create_event={Some(on_create_event)}
|
||||
on_create_event_request={props.on_create_event_request.clone()}
|
||||
on_event_update={Some(on_event_update)}
|
||||
context_menus_open={props.context_menus_open}
|
||||
time_increment={*time_increment}
|
||||
/>
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Event details modal
|
||||
<EventModal
|
||||
event={(*selected_event).clone()}
|
||||
on_close={{
|
||||
let selected_event_clone = selected_event.clone();
|
||||
Callback::from(move |_| {
|
||||
selected_event_clone.set(None);
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
// Create event modal
|
||||
<CreateEventModal
|
||||
is_open={*show_create_modal}
|
||||
selected_date={create_event_data.as_ref().map(|(date, _, _)| *date)}
|
||||
event_to_edit={None}
|
||||
available_calendars={props.user_info.as_ref().map(|info| info.calendars.clone()).unwrap_or_default()}
|
||||
initial_start_time={create_event_data.as_ref().map(|(_, start_time, _)| *start_time)}
|
||||
initial_end_time={create_event_data.as_ref().map(|(_, _, end_time)| *end_time)}
|
||||
on_close={{
|
||||
let show_create_modal = show_create_modal.clone();
|
||||
let create_event_data = create_event_data.clone();
|
||||
Callback::from(move |_| {
|
||||
show_create_modal.set(false);
|
||||
create_event_data.set(None);
|
||||
})
|
||||
}}
|
||||
on_create={{
|
||||
let show_create_modal = show_create_modal.clone();
|
||||
let create_event_data = create_event_data.clone();
|
||||
let on_create_event_request = props.on_create_event_request.clone();
|
||||
Callback::from(move |event_data: EventCreationData| {
|
||||
show_create_modal.set(false);
|
||||
create_event_data.set(None);
|
||||
|
||||
// Emit the create event request to parent
|
||||
if let Some(callback) = &on_create_event_request {
|
||||
callback.emit(event_data);
|
||||
}
|
||||
})
|
||||
}}
|
||||
on_update={{
|
||||
let show_create_modal = show_create_modal.clone();
|
||||
let create_event_data = create_event_data.clone();
|
||||
Callback::from(move |(_original_event, _updated_data): (VEvent, EventCreationData)| {
|
||||
show_create_modal.set(false);
|
||||
create_event_data.set(None);
|
||||
// TODO: Handle actual event update
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
47
frontend/src/components/calendar_context_menu.rs
Normal file
47
frontend/src/components/calendar_context_menu.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use web_sys::MouseEvent;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarContextMenuProps {
|
||||
pub is_open: bool,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub on_close: Callback<()>,
|
||||
pub on_create_event: Callback<MouseEvent>,
|
||||
}
|
||||
|
||||
#[function_component(CalendarContextMenu)]
|
||||
pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
||||
let menu_ref = use_node_ref();
|
||||
|
||||
if !props.is_open {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
let style = format!(
|
||||
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
||||
props.x, props.y
|
||||
);
|
||||
|
||||
let on_create_event_click = {
|
||||
let on_create_event = props.on_create_event.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
on_create_event.emit(e);
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div
|
||||
ref={menu_ref}
|
||||
class="context-menu"
|
||||
style={style}
|
||||
>
|
||||
<div class="context-menu-item context-menu-create" onclick={on_create_event_click}>
|
||||
<span class="context-menu-icon">{"+"}</span>
|
||||
{"Create Event"}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
68
frontend/src/components/calendar_header.rs
Normal file
68
frontend/src/components/calendar_header.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use crate::components::ViewMode;
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use web_sys::MouseEvent;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarHeaderProps {
|
||||
pub current_date: NaiveDate,
|
||||
pub view_mode: ViewMode,
|
||||
pub on_prev: Callback<MouseEvent>,
|
||||
pub on_next: Callback<MouseEvent>,
|
||||
pub on_today: Callback<MouseEvent>,
|
||||
#[prop_or_default]
|
||||
pub time_increment: Option<u32>,
|
||||
#[prop_or_default]
|
||||
pub on_time_increment_toggle: Option<Callback<MouseEvent>>,
|
||||
}
|
||||
|
||||
#[function_component(CalendarHeader)]
|
||||
pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
||||
let title = format!(
|
||||
"{} {}",
|
||||
get_month_name(props.current_date.month()),
|
||||
props.current_date.year()
|
||||
);
|
||||
|
||||
html! {
|
||||
<div class="calendar-header">
|
||||
<div class="header-left">
|
||||
<button class="nav-button" onclick={props.on_prev.clone()}>{"‹"}</button>
|
||||
{
|
||||
if let (Some(increment), Some(callback)) = (props.time_increment, &props.on_time_increment_toggle) {
|
||||
html! {
|
||||
<button class="time-increment-button" onclick={callback.clone()}>
|
||||
{format!("{}", increment)}
|
||||
</button>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<h2 class="month-year">{title}</h2>
|
||||
<div class="header-right">
|
||||
<button class="today-button" onclick={props.on_today.clone()}>{"Today"}</button>
|
||||
<button class="nav-button" onclick={props.on_next.clone()}>{"›"}</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn get_month_name(month: u32) -> &'static str {
|
||||
match month {
|
||||
1 => "January",
|
||||
2 => "February",
|
||||
3 => "March",
|
||||
4 => "April",
|
||||
5 => "May",
|
||||
6 => "June",
|
||||
7 => "July",
|
||||
8 => "August",
|
||||
9 => "September",
|
||||
10 => "October",
|
||||
11 => "November",
|
||||
12 => "December",
|
||||
_ => "Invalid",
|
||||
}
|
||||
}
|
||||
75
frontend/src/components/calendar_list_item.rs
Normal file
75
frontend/src/components/calendar_list_item.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use crate::services::calendar_service::CalendarInfo;
|
||||
use web_sys::MouseEvent;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CalendarListItemProps {
|
||||
pub calendar: CalendarInfo,
|
||||
pub color_picker_open: bool,
|
||||
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
|
||||
pub on_color_picker_toggle: Callback<String>, // calendar_path
|
||||
pub available_colors: Vec<String>,
|
||||
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
||||
}
|
||||
|
||||
#[function_component(CalendarListItem)]
|
||||
pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
||||
let on_color_click = {
|
||||
let cal_path = props.calendar.path.clone();
|
||||
let on_color_picker_toggle = props.on_color_picker_toggle.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.stop_propagation();
|
||||
on_color_picker_toggle.emit(cal_path.clone());
|
||||
})
|
||||
};
|
||||
|
||||
let on_context_menu = {
|
||||
let cal_path = props.calendar.path.clone();
|
||||
let on_context_menu = props.on_context_menu.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
on_context_menu.emit((e, cal_path.clone()));
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}>
|
||||
<span class="calendar-color"
|
||||
style={format!("background-color: {}", props.calendar.color)}
|
||||
onclick={on_color_click}>
|
||||
{
|
||||
if props.color_picker_open {
|
||||
html! {
|
||||
<div class="color-picker">
|
||||
{
|
||||
props.available_colors.iter().map(|color| {
|
||||
let color_str = color.clone();
|
||||
let cal_path = props.calendar.path.clone();
|
||||
let on_color_change = props.on_color_change.clone();
|
||||
|
||||
let on_color_select = Callback::from(move |_: MouseEvent| {
|
||||
on_color_change.emit((cal_path.clone(), color_str.clone()));
|
||||
});
|
||||
|
||||
let is_selected = props.calendar.color == *color;
|
||||
let class_name = if is_selected { "color-option selected" } else { "color-option" };
|
||||
|
||||
html! {
|
||||
<div class={class_name}
|
||||
style={format!("background-color: {}", color)}
|
||||
onclick={on_color_select}>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</span>
|
||||
<span class="calendar-name">{&props.calendar.display_name}</span>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
48
frontend/src/components/context_menu.rs
Normal file
48
frontend/src/components/context_menu.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use web_sys::MouseEvent;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ContextMenuProps {
|
||||
pub is_open: bool,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub on_delete: Callback<MouseEvent>,
|
||||
pub on_close: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(ContextMenu)]
|
||||
pub fn context_menu(props: &ContextMenuProps) -> Html {
|
||||
let menu_ref = use_node_ref();
|
||||
|
||||
// Close menu when clicking outside (handled by parent component)
|
||||
|
||||
if !props.is_open {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
let style = format!(
|
||||
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
||||
props.x, props.y
|
||||
);
|
||||
|
||||
let on_delete_click = {
|
||||
let on_delete = props.on_delete.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
on_delete.emit(e);
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div
|
||||
ref={menu_ref}
|
||||
class="context-menu"
|
||||
style={style}
|
||||
>
|
||||
<div class="context-menu-item context-menu-delete" onclick={on_delete_click}>
|
||||
{"Delete Calendar"}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
198
frontend/src/components/create_calendar_modal.rs
Normal file
198
frontend/src/components/create_calendar_modal.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CreateCalendarModalProps {
|
||||
pub is_open: bool,
|
||||
pub on_close: Callback<()>,
|
||||
pub on_create: Callback<(String, Option<String>, Option<String>)>, // name, description, color
|
||||
pub available_colors: Vec<String>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
||||
let calendar_name = use_state(|| String::new());
|
||||
let description = use_state(|| String::new());
|
||||
let selected_color = use_state(|| None::<String>);
|
||||
let error_message = use_state(|| None::<String>);
|
||||
let is_creating = use_state(|| false);
|
||||
|
||||
let on_name_change = {
|
||||
let calendar_name = calendar_name.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
calendar_name.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_description_change = {
|
||||
let description = description.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: web_sys::HtmlTextAreaElement = e.target_unchecked_into();
|
||||
description.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_submit = {
|
||||
let calendar_name = calendar_name.clone();
|
||||
let description = description.clone();
|
||||
let selected_color = selected_color.clone();
|
||||
let error_message = error_message.clone();
|
||||
let is_creating = is_creating.clone();
|
||||
let on_create = props.on_create.clone();
|
||||
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
let name = (*calendar_name).trim();
|
||||
if name.is_empty() {
|
||||
error_message.set(Some("Calendar name is required".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
if name.len() > 100 {
|
||||
error_message.set(Some(
|
||||
"Calendar name too long (max 100 characters)".to_string(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
error_message.set(None);
|
||||
is_creating.set(true);
|
||||
|
||||
let desc = if (*description).trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((*description).clone())
|
||||
};
|
||||
|
||||
on_create.emit((name.to_string(), desc, (*selected_color).clone()));
|
||||
})
|
||||
};
|
||||
|
||||
let on_backdrop_click = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
// Only close if clicking the backdrop, not the modal content
|
||||
if e.target() == e.current_target() {
|
||||
on_close.emit(());
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if !props.is_open {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
html! {
|
||||
<div class="modal-backdrop" onclick={on_backdrop_click}>
|
||||
<div class="create-calendar-modal">
|
||||
<div class="modal-header">
|
||||
<h2>{"Create New Calendar"}</h2>
|
||||
<button class="close-button" onclick={props.on_close.reform(|_| ())}>
|
||||
{"×"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="modal-body" onsubmit={on_submit}>
|
||||
{
|
||||
if let Some(ref error) = *error_message {
|
||||
html! {
|
||||
<div class="error-message">
|
||||
{error}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="calendar-name">{"Calendar Name *"}</label>
|
||||
<input
|
||||
id="calendar-name"
|
||||
type="text"
|
||||
value={(*calendar_name).clone()}
|
||||
oninput={on_name_change}
|
||||
placeholder="Enter calendar name"
|
||||
maxlength="100"
|
||||
disabled={*is_creating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="calendar-description">{"Description"}</label>
|
||||
<textarea
|
||||
id="calendar-description"
|
||||
value={(*description).clone()}
|
||||
oninput={on_description_change}
|
||||
placeholder="Optional calendar description"
|
||||
rows="3"
|
||||
disabled={*is_creating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{"Calendar Color"}</label>
|
||||
<div class="color-grid">
|
||||
{
|
||||
props.available_colors.iter().enumerate().map(|(index, color)| {
|
||||
let color = color.clone();
|
||||
let selected_color = selected_color.clone();
|
||||
let is_selected = selected_color.as_ref() == Some(&color);
|
||||
let on_color_select = {
|
||||
let color = color.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
selected_color.set(Some(color.clone()));
|
||||
})
|
||||
};
|
||||
|
||||
let class_name = if is_selected {
|
||||
"color-option selected"
|
||||
} else {
|
||||
"color-option"
|
||||
};
|
||||
|
||||
html! {
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
class={class_name}
|
||||
style={format!("background-color: {}", color)}
|
||||
onclick={on_color_select}
|
||||
disabled={*is_creating}
|
||||
/>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</div>
|
||||
<p class="color-help-text">{"Optional: Choose a color for your calendar"}</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cancel-button"
|
||||
onclick={props.on_close.reform(|_| ())}
|
||||
disabled={*is_creating}
|
||||
>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="create-button"
|
||||
disabled={*is_creating}
|
||||
>
|
||||
{
|
||||
if *is_creating {
|
||||
"Creating..."
|
||||
} else {
|
||||
"Create Calendar"
|
||||
}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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
122
frontend/src/components/event_context_menu.rs
Normal file
122
frontend/src/components/event_context_menu.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use crate::models::ical::VEvent;
|
||||
use web_sys::MouseEvent;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum DeleteAction {
|
||||
DeleteThis,
|
||||
DeleteFollowing,
|
||||
DeleteSeries,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum EditAction {
|
||||
EditThis,
|
||||
EditFuture,
|
||||
EditAll,
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct EventContextMenuProps {
|
||||
pub is_open: bool,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub event: Option<VEvent>,
|
||||
pub on_edit: Callback<EditAction>,
|
||||
pub on_delete: Callback<DeleteAction>,
|
||||
pub on_close: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(EventContextMenu)]
|
||||
pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
||||
let menu_ref = use_node_ref();
|
||||
|
||||
if !props.is_open {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
let style = format!(
|
||||
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
||||
props.x, props.y
|
||||
);
|
||||
|
||||
// Check if the event is recurring
|
||||
let is_recurring = props
|
||||
.event
|
||||
.as_ref()
|
||||
.map(|event| event.rrule.is_some())
|
||||
.unwrap_or(false);
|
||||
|
||||
let create_edit_callback = |action: EditAction| {
|
||||
let on_edit = props.on_edit.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_edit.emit(action.clone());
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
let create_delete_callback = |action: DeleteAction| {
|
||||
let on_delete = props.on_delete.clone();
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_delete.emit(action.clone());
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div
|
||||
ref={menu_ref}
|
||||
class="context-menu"
|
||||
style={style}
|
||||
>
|
||||
{
|
||||
if is_recurring {
|
||||
html! {
|
||||
<>
|
||||
<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 {
|
||||
html! {
|
||||
<>
|
||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
||||
{"Delete This Event"}
|
||||
</div>
|
||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteFollowing)}>
|
||||
{"Delete Following Events"}
|
||||
</div>
|
||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteSeries)}>
|
||||
{"Delete Entire Series"}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
||||
{"Delete Event"}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
238
frontend/src/components/event_modal.rs
Normal file
238
frontend/src/components/event_modal.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
use crate::models::ical::VEvent;
|
||||
use chrono::{DateTime, Utc};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct EventModalProps {
|
||||
pub event: Option<VEvent>,
|
||||
pub on_close: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn EventModal(props: &EventModalProps) -> Html {
|
||||
let close_modal = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |_| {
|
||||
on_close.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
let backdrop_click = {
|
||||
let on_close = props.on_close.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
if e.target() == e.current_target() {
|
||||
on_close.emit(());
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if let Some(ref event) = props.event {
|
||||
html! {
|
||||
<div class="modal-backdrop" onclick={backdrop_click}>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>{"Event Details"}</h3>
|
||||
<button class="modal-close" onclick={close_modal}>{"×"}</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="event-detail">
|
||||
<strong>{"Title:"}</strong>
|
||||
<span>{event.get_title()}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
if let Some(ref description) = event.description {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Description:"}</strong>
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="event-detail">
|
||||
<strong>{"Start:"}</strong>
|
||||
<span>{format_datetime(&event.dtstart, event.all_day)}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
if let Some(ref end) = event.dtend {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"End:"}</strong>
|
||||
<span>{format_datetime(end, event.all_day)}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="event-detail">
|
||||
<strong>{"All Day:"}</strong>
|
||||
<span>{if event.all_day { "Yes" } else { "No" }}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
if let Some(ref location) = event.location {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Location:"}</strong>
|
||||
<span>{location}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="event-detail">
|
||||
<strong>{"Status:"}</strong>
|
||||
<span>{event.get_status_display()}</span>
|
||||
</div>
|
||||
|
||||
<div class="event-detail">
|
||||
<strong>{"Privacy:"}</strong>
|
||||
<span>{event.get_class_display()}</span>
|
||||
</div>
|
||||
|
||||
<div class="event-detail">
|
||||
<strong>{"Priority:"}</strong>
|
||||
<span>{event.get_priority_display()}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
if let Some(ref organizer) = event.organizer {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Organizer:"}</strong>
|
||||
<span>{organizer.cal_address.clone()}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if !event.attendees.is_empty() {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Attendees:"}</strong>
|
||||
<span>{event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(", ")}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if !event.categories.is_empty() {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Categories:"}</strong>
|
||||
<span>{event.categories.join(", ")}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(ref recurrence) = event.rrule {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Repeats:"}</strong>
|
||||
<span>{format_recurrence_rule(recurrence)}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Repeats:"}</strong>
|
||||
<span>{"No"}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if !event.alarms.is_empty() {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Reminders:"}</strong>
|
||||
<span>{"Alarms configured"}</span> /* TODO: Convert VAlarm to displayable format */
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Reminders:"}</strong>
|
||||
<span>{"None"}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(ref created) = event.created {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Created:"}</strong>
|
||||
<span>{format_datetime(created, false)}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(ref modified) = event.last_modified {
|
||||
html! {
|
||||
<div class="event-detail">
|
||||
<strong>{"Last Modified:"}</strong>
|
||||
<span>{format_datetime(modified, false)}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
|
||||
if all_day {
|
||||
dt.format("%B %d, %Y").to_string()
|
||||
} else {
|
||||
dt.format("%B %d, %Y at %I:%M %p").to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_recurrence_rule(rrule: &str) -> String {
|
||||
// Basic parsing of RRULE to display user-friendly text
|
||||
if rrule.contains("FREQ=DAILY") {
|
||||
"Daily".to_string()
|
||||
} else if rrule.contains("FREQ=WEEKLY") {
|
||||
"Weekly".to_string()
|
||||
} else if rrule.contains("FREQ=MONTHLY") {
|
||||
"Monthly".to_string()
|
||||
} else if rrule.contains("FREQ=YEARLY") {
|
||||
"Yearly".to_string()
|
||||
} else {
|
||||
// Show the raw rule if we can't parse it
|
||||
format!("Custom ({})", rrule)
|
||||
}
|
||||
}
|
||||
291
frontend/src/components/login.rs
Normal file
291
frontend/src/components/login.rs
Normal file
@@ -0,0 +1,291 @@
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use web_sys::HtmlInputElement;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct LoginProps {
|
||||
pub on_login: Callback<String>, // Callback with JWT token
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn Login(props: &LoginProps) -> Html {
|
||||
// Load remembered values from LocalStorage on mount
|
||||
let server_url = use_state(|| {
|
||||
LocalStorage::get::<String>("remembered_server_url").unwrap_or_default()
|
||||
});
|
||||
let username = use_state(|| {
|
||||
LocalStorage::get::<String>("remembered_username").unwrap_or_default()
|
||||
});
|
||||
let password = use_state(String::new);
|
||||
let error_message = use_state(|| Option::<String>::None);
|
||||
let is_loading = use_state(|| false);
|
||||
|
||||
// Remember checkboxes state - default to checked
|
||||
let remember_server = use_state(|| true);
|
||||
let remember_username = use_state(|| true);
|
||||
|
||||
let server_url_ref = use_node_ref();
|
||||
let username_ref = use_node_ref();
|
||||
let password_ref = use_node_ref();
|
||||
|
||||
let on_server_url_change = {
|
||||
let server_url = server_url.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||
server_url.set(target.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_username_change = {
|
||||
let username = username.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||
username.set(target.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_password_change = {
|
||||
let password = password.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||
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 server_url = server_url.clone();
|
||||
let username = username.clone();
|
||||
let password = password.clone();
|
||||
let error_message = error_message.clone();
|
||||
let is_loading = is_loading.clone();
|
||||
let on_login = props.on_login.clone();
|
||||
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
let server_url = (*server_url).clone();
|
||||
let username = (*username).clone();
|
||||
let password = (*password).clone();
|
||||
let error_message = error_message.clone();
|
||||
let is_loading = is_loading.clone();
|
||||
let on_login = on_login.clone();
|
||||
|
||||
// Basic client-side validation
|
||||
if server_url.trim().is_empty() || username.trim().is_empty() || password.is_empty() {
|
||||
error_message.set(Some("Please fill in all fields".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
is_loading.set(true);
|
||||
error_message.set(None);
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
web_sys::console::log_1(&"🚀 Starting login process...".into());
|
||||
match perform_login(server_url.clone(), username.clone(), password.clone()).await {
|
||||
Ok((token, session_token, credentials, preferences)) => {
|
||||
web_sys::console::log_1(&"✅ Login successful!".into());
|
||||
// Store token and credentials in local storage
|
||||
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
||||
error_message
|
||||
.set(Some("Failed to store authentication token".to_string()));
|
||||
is_loading.set(false);
|
||||
return;
|
||||
}
|
||||
if let Err(_) = LocalStorage::set("session_token", &session_token) {
|
||||
error_message
|
||||
.set(Some("Failed to store session token".to_string()));
|
||||
is_loading.set(false);
|
||||
return;
|
||||
}
|
||||
if let Err(_) = LocalStorage::set("caldav_credentials", &credentials) {
|
||||
error_message.set(Some("Failed to store credentials".to_string()));
|
||||
is_loading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store preferences from database
|
||||
if let Ok(prefs_json) = serde_json::to_string(&preferences) {
|
||||
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
||||
}
|
||||
|
||||
is_loading.set(false);
|
||||
on_login.emit(token);
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::log_1(&format!("❌ Login failed: {}", err).into());
|
||||
error_message.set(Some(err));
|
||||
is_loading.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="login-container">
|
||||
<div class="login-form">
|
||||
<h2>{"Sign In to CalDAV"}</h2>
|
||||
<form onsubmit={on_submit}>
|
||||
<div class="form-group">
|
||||
<label for="server_url">{"CalDAV Server URL"}</label>
|
||||
<input
|
||||
ref={server_url_ref}
|
||||
type="text"
|
||||
id="server_url"
|
||||
placeholder="https://your-caldav-server.com/dav/"
|
||||
value={(*server_url).clone()}
|
||||
onchange={on_server_url_change}
|
||||
disabled={*is_loading}
|
||||
/>
|
||||
<div class="remember-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember_server"
|
||||
checked={*remember_server}
|
||||
onchange={on_remember_server_change}
|
||||
/>
|
||||
<label for="remember_server">{"Remember server"}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">{"Username"}</label>
|
||||
<input
|
||||
ref={username_ref}
|
||||
type="text"
|
||||
id="username"
|
||||
placeholder="Enter your username"
|
||||
value={(*username).clone()}
|
||||
onchange={on_username_change}
|
||||
disabled={*is_loading}
|
||||
/>
|
||||
<div class="remember-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember_username"
|
||||
checked={*remember_username}
|
||||
onchange={on_remember_username_change}
|
||||
/>
|
||||
<label for="remember_username">{"Remember username"}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">{"Password"}</label>
|
||||
<input
|
||||
ref={password_ref}
|
||||
type="password"
|
||||
id="password"
|
||||
placeholder="Enter your password"
|
||||
value={(*password).clone()}
|
||||
onchange={on_password_change}
|
||||
disabled={*is_loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
if let Some(error) = (*error_message).clone() {
|
||||
html! { <div class="error-message">{error}</div> }
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
<button type="submit" disabled={*is_loading} class="login-button">
|
||||
{
|
||||
if *is_loading {
|
||||
"Signing in..."
|
||||
} else {
|
||||
"Sign In"
|
||||
}
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-links">
|
||||
<p>{"Enter your CalDAV server credentials to connect to your calendar"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform login using the CalDAV auth service
|
||||
async fn perform_login(
|
||||
server_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<(String, String, String, serde_json::Value), String> {
|
||||
use crate::auth::{AuthService, CalDAVLoginRequest};
|
||||
use serde_json;
|
||||
|
||||
web_sys::console::log_1(&format!("📡 Creating auth service and request...").into());
|
||||
|
||||
let auth_service = AuthService::new();
|
||||
let request = CalDAVLoginRequest {
|
||||
server_url: server_url.clone(),
|
||||
username: username.clone(),
|
||||
password: password.clone(),
|
||||
};
|
||||
|
||||
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
|
||||
|
||||
match auth_service.login(request).await {
|
||||
Ok(response) => {
|
||||
web_sys::console::log_1(&format!("✅ Backend responded successfully").into());
|
||||
// Create credentials object to store
|
||||
let credentials = serde_json::json!({
|
||||
"server_url": server_url,
|
||||
"username": username,
|
||||
"password": password
|
||||
});
|
||||
|
||||
// Extract preferences as JSON
|
||||
let preferences = serde_json::json!({
|
||||
"calendar_selected_date": response.preferences.calendar_selected_date,
|
||||
"calendar_time_increment": response.preferences.calendar_time_increment,
|
||||
"calendar_view_mode": response.preferences.calendar_view_mode,
|
||||
"calendar_theme": response.preferences.calendar_theme,
|
||||
"calendar_colors": response.preferences.calendar_colors,
|
||||
});
|
||||
|
||||
Ok((response.token, response.session_token, credentials.to_string(), preferences))
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
33
frontend/src/components/mod.rs
Normal file
33
frontend/src/components/mod.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
pub mod calendar;
|
||||
pub mod calendar_context_menu;
|
||||
pub mod calendar_header;
|
||||
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 sidebar;
|
||||
pub mod week_view;
|
||||
|
||||
pub use calendar::Calendar;
|
||||
pub use calendar_context_menu::CalendarContextMenu;
|
||||
pub use calendar_header::CalendarHeader;
|
||||
pub use calendar_list_item::CalendarListItem;
|
||||
pub use context_menu::ContextMenu;
|
||||
pub use create_calendar_modal::CreateCalendarModal;
|
||||
pub use create_event_modal::{
|
||||
CreateEventModal, EventClass, EventCreationData, EventStatus, RecurrenceType, ReminderType,
|
||||
};
|
||||
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
|
||||
pub use event_modal::EventModal;
|
||||
pub use login::Login;
|
||||
pub use month_view::MonthView;
|
||||
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
|
||||
pub use route_handler::RouteHandler;
|
||||
pub use sidebar::{Sidebar, Theme, ViewMode};
|
||||
pub use week_view::WeekView;
|
||||
288
frontend/src/components/month_view.rs
Normal file
288
frontend/src/components/month_view.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
use crate::models::ical::VEvent;
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use chrono::{Datelike, NaiveDate, Weekday};
|
||||
use std::collections::HashMap;
|
||||
use wasm_bindgen::{prelude::*, JsCast};
|
||||
use web_sys::window;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct MonthViewProps {
|
||||
pub current_month: NaiveDate,
|
||||
pub today: NaiveDate,
|
||||
pub events: HashMap<NaiveDate, Vec<VEvent>>,
|
||||
pub on_event_click: Callback<VEvent>,
|
||||
#[prop_or_default]
|
||||
pub refreshing_event_uid: Option<String>,
|
||||
#[prop_or_default]
|
||||
pub user_info: Option<UserInfo>,
|
||||
#[prop_or_default]
|
||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||
#[prop_or_default]
|
||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
||||
#[prop_or_default]
|
||||
pub selected_date: Option<NaiveDate>,
|
||||
#[prop_or_default]
|
||||
pub on_day_select: Option<Callback<NaiveDate>>,
|
||||
}
|
||||
|
||||
#[function_component(MonthView)]
|
||||
pub fn month_view(props: &MonthViewProps) -> Html {
|
||||
let max_events_per_day = use_state(|| 4); // Default to 4 events max
|
||||
let first_day_of_month = props.current_month.with_day(1).unwrap();
|
||||
let days_in_month = get_days_in_month(props.current_month);
|
||||
let first_weekday = first_day_of_month.weekday();
|
||||
let days_from_prev_month = get_days_from_previous_month(props.current_month, first_weekday);
|
||||
|
||||
// Calculate maximum events that can fit based on available height
|
||||
let calculate_max_events = {
|
||||
let max_events_per_day = max_events_per_day.clone();
|
||||
move || {
|
||||
// Since we're using CSS Grid with equal row heights,
|
||||
// we can estimate based on typical calendar dimensions
|
||||
// Typical calendar height is around 600-800px for 6 rows
|
||||
// Each row gets ~100-133px, minus day number and padding leaves ~70-100px
|
||||
// Each event is ~18px, so we can fit ~3-4 events + "+n more" indicator
|
||||
max_events_per_day.set(3);
|
||||
}
|
||||
};
|
||||
|
||||
// Setup resize handler and initial calculation
|
||||
{
|
||||
let calculate_max_events = calculate_max_events.clone();
|
||||
use_effect_with((), move |_| {
|
||||
let calculate_max_events_clone = calculate_max_events.clone();
|
||||
|
||||
// Initial calculation with a slight delay to ensure DOM is ready
|
||||
if let Some(window) = window() {
|
||||
let timeout_closure = Closure::wrap(Box::new(move || {
|
||||
calculate_max_events_clone();
|
||||
}) as Box<dyn FnMut()>);
|
||||
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
timeout_closure.as_ref().unchecked_ref(),
|
||||
100,
|
||||
);
|
||||
timeout_closure.forget();
|
||||
}
|
||||
|
||||
// Setup resize listener
|
||||
let resize_closure = Closure::wrap(Box::new(move || {
|
||||
calculate_max_events();
|
||||
}) as Box<dyn Fn()>);
|
||||
|
||||
if let Some(window) = window() {
|
||||
let _ = window.add_event_listener_with_callback(
|
||||
"resize",
|
||||
resize_closure.as_ref().unchecked_ref(),
|
||||
);
|
||||
resize_closure.forget(); // Keep the closure alive
|
||||
}
|
||||
|
||||
|| {}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to get calendar color for an event
|
||||
let get_event_color = |event: &VEvent| -> String {
|
||||
if let Some(user_info) = &props.user_info {
|
||||
if let Some(calendar_path) = &event.calendar_path {
|
||||
if let Some(calendar) = user_info
|
||||
.calendars
|
||||
.iter()
|
||||
.find(|cal| &cal.path == calendar_path)
|
||||
{
|
||||
return calendar.color.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
"#3B82F6".to_string()
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="calendar-grid">
|
||||
// Weekday headers
|
||||
<div class="weekday-header">{"Sun"}</div>
|
||||
<div class="weekday-header">{"Mon"}</div>
|
||||
<div class="weekday-header">{"Tue"}</div>
|
||||
<div class="weekday-header">{"Wed"}</div>
|
||||
<div class="weekday-header">{"Thu"}</div>
|
||||
<div class="weekday-header">{"Fri"}</div>
|
||||
<div class="weekday-header">{"Sat"}</div>
|
||||
|
||||
// Days from previous month (grayed out)
|
||||
{
|
||||
days_from_prev_month.iter().map(|day| {
|
||||
html! {
|
||||
<div class="calendar-day prev-month">{*day}</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
|
||||
// Days of the current month
|
||||
{
|
||||
(1..=days_in_month).map(|day| {
|
||||
let date = props.current_month.with_day(day).unwrap();
|
||||
let is_today = date == props.today;
|
||||
let is_selected = props.selected_date == Some(date);
|
||||
let day_events = props.events.get(&date).cloned().unwrap_or_default();
|
||||
|
||||
// Calculate visible events and overflow
|
||||
let max_events = *max_events_per_day as usize;
|
||||
let visible_events: Vec<_> = day_events.iter().take(max_events).collect();
|
||||
let hidden_count = day_events.len().saturating_sub(max_events);
|
||||
|
||||
html! {
|
||||
<div
|
||||
class={classes!(
|
||||
"calendar-day",
|
||||
if is_today { Some("today") } else { None },
|
||||
if is_selected { Some("selected") } else { None }
|
||||
)}
|
||||
onclick={
|
||||
if let Some(callback) = &props.on_day_select {
|
||||
let callback = callback.clone();
|
||||
Some(Callback::from(move |e: web_sys::MouseEvent| {
|
||||
e.stop_propagation(); // Prevent other handlers
|
||||
callback.emit(date);
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
oncontextmenu={
|
||||
if let Some(callback) = &props.on_calendar_context_menu {
|
||||
let callback = callback.clone();
|
||||
Some(Callback::from(move |e: web_sys::MouseEvent| {
|
||||
e.prevent_default();
|
||||
callback.emit((e, date));
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
>
|
||||
<div class="day-number">{day}</div>
|
||||
<div class="day-events">
|
||||
{
|
||||
visible_events.iter().map(|event| {
|
||||
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 |_: web_sys::MouseEvent| {
|
||||
on_event_click.emit(event.clone());
|
||||
})
|
||||
};
|
||||
|
||||
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();
|
||||
callback.emit((e, event.clone()));
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<div
|
||||
class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })}
|
||||
style={format!("background-color: {}", event_color)}
|
||||
{onclick}
|
||||
{oncontextmenu}
|
||||
>
|
||||
{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
{
|
||||
if hidden_count > 0 {
|
||||
html! {
|
||||
<div class="more-events-indicator">
|
||||
{format!("+{} more", hidden_count)}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
|
||||
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
|
||||
let total_slots = 42; // 6 rows x 7 days
|
||||
let used_slots = prev_days_count + current_days_count as usize;
|
||||
let remaining_slots = if used_slots < total_slots {
|
||||
total_slots - used_slots
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
(1..=remaining_slots)
|
||||
.map(|day| {
|
||||
html! {
|
||||
<div class="calendar-day next-month">{day}</div>
|
||||
}
|
||||
})
|
||||
.collect::<Html>()
|
||||
}
|
||||
|
||||
fn get_days_in_month(date: NaiveDate) -> u32 {
|
||||
NaiveDate::from_ymd_opt(
|
||||
if date.month() == 12 {
|
||||
date.year() + 1
|
||||
} else {
|
||||
date.year()
|
||||
},
|
||||
if date.month() == 12 {
|
||||
1
|
||||
} else {
|
||||
date.month() + 1
|
||||
},
|
||||
1,
|
||||
)
|
||||
.unwrap()
|
||||
.pred_opt()
|
||||
.unwrap()
|
||||
.day()
|
||||
}
|
||||
|
||||
fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday) -> Vec<u32> {
|
||||
let days_before = match first_weekday {
|
||||
Weekday::Sun => 0,
|
||||
Weekday::Mon => 1,
|
||||
Weekday::Tue => 2,
|
||||
Weekday::Wed => 3,
|
||||
Weekday::Thu => 4,
|
||||
Weekday::Fri => 5,
|
||||
Weekday::Sat => 6,
|
||||
};
|
||||
|
||||
if days_before == 0 {
|
||||
vec![]
|
||||
} else {
|
||||
let prev_month = if current_month.month() == 1 {
|
||||
NaiveDate::from_ymd_opt(current_month.year() - 1, 12, 1).unwrap()
|
||||
} else {
|
||||
NaiveDate::from_ymd_opt(current_month.year(), current_month.month() - 1, 1).unwrap()
|
||||
};
|
||||
|
||||
let prev_month_days = get_days_in_month(prev_month);
|
||||
((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>
|
||||
}
|
||||
}
|
||||
247
frontend/src/components/sidebar.rs
Normal file
247
frontend/src/components/sidebar.rs
Normal file
@@ -0,0 +1,247 @@
|
||||
use crate::components::CalendarListItem;
|
||||
use crate::services::calendar_service::UserInfo;
|
||||
use web_sys::HtmlSelectElement;
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
|
||||
#[derive(Clone, Routable, PartialEq)]
|
||||
pub enum Route {
|
||||
#[at("/")]
|
||||
Home,
|
||||
#[at("/login")]
|
||||
Login,
|
||||
#[at("/calendar")]
|
||||
Calendar,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum ViewMode {
|
||||
Month,
|
||||
Week,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum Theme {
|
||||
Default,
|
||||
Ocean,
|
||||
Forest,
|
||||
Sunset,
|
||||
Purple,
|
||||
Dark,
|
||||
Rose,
|
||||
Mint,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum Style {
|
||||
Default,
|
||||
Google,
|
||||
}
|
||||
|
||||
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 Style {
|
||||
pub fn value(&self) -> &'static str {
|
||||
match self {
|
||||
Style::Default => "default",
|
||||
Style::Google => "google",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_value(value: &str) -> Self {
|
||||
match value {
|
||||
"google" => Style::Google,
|
||||
_ => Style::Default,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn stylesheet_path(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Style::Default => None, // No additional stylesheet needed - uses base styles.css
|
||||
Style::Google => Some("google.css"), // Trunk copies to root level
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ViewMode {
|
||||
fn default() -> Self {
|
||||
ViewMode::Month
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct SidebarProps {
|
||||
pub user_info: Option<UserInfo>,
|
||||
pub on_logout: Callback<()>,
|
||||
pub on_create_calendar: Callback<()>,
|
||||
pub color_picker_open: Option<String>,
|
||||
pub on_color_change: Callback<(String, String)>,
|
||||
pub on_color_picker_toggle: Callback<String>,
|
||||
pub available_colors: Vec<String>,
|
||||
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
||||
pub current_view: ViewMode,
|
||||
pub on_view_change: Callback<ViewMode>,
|
||||
pub current_theme: Theme,
|
||||
pub on_theme_change: Callback<Theme>,
|
||||
pub current_style: Style,
|
||||
pub on_style_change: Callback<Style>,
|
||||
}
|
||||
|
||||
#[function_component(Sidebar)]
|
||||
pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
let on_view_change = {
|
||||
let on_view_change = props.on_view_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_view = match value.as_str() {
|
||||
"week" => ViewMode::Week,
|
||||
_ => ViewMode::Month,
|
||||
};
|
||||
on_view_change.emit(new_view);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_style_change = {
|
||||
let on_style_change = props.on_style_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_style = Style::from_value(&value);
|
||||
on_style_change.emit(new_style);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<aside class="app-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>{"Calendar App"}</h1>
|
||||
{
|
||||
if let Some(ref info) = props.user_info {
|
||||
html! {
|
||||
<div class="user-info">
|
||||
<div class="username">{&info.username}</div>
|
||||
<div class="server-url">{&info.server_url}</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! { <div class="user-info loading">{"Loading..."}</div> }
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<Link<Route> to={Route::Calendar} classes="nav-link">{"Calendar"}</Link<Route>>
|
||||
</nav>
|
||||
{
|
||||
if let Some(ref info) = props.user_info {
|
||||
if !info.calendars.is_empty() {
|
||||
html! {
|
||||
<div class="calendar-list">
|
||||
<h3>{"My Calendars"}</h3>
|
||||
<ul>
|
||||
{
|
||||
info.calendars.iter().map(|cal| {
|
||||
html! {
|
||||
<CalendarListItem
|
||||
calendar={cal.clone()}
|
||||
color_picker_open={props.color_picker_open.as_ref() == Some(&cal.path)}
|
||||
on_color_change={props.on_color_change.clone()}
|
||||
on_color_picker_toggle={props.on_color_picker_toggle.clone()}
|
||||
available_colors={props.available_colors.clone()}
|
||||
on_context_menu={props.on_calendar_context_menu.clone()}
|
||||
/>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! { <div class="no-calendars">{"No calendars found"}</div> }
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
<div class="sidebar-footer">
|
||||
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
|
||||
{"+ Create Calendar"}
|
||||
</button>
|
||||
|
||||
<div class="view-selector">
|
||||
<select class="view-selector-dropdown" onchange={on_view_change}>
|
||||
<option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option>
|
||||
<option value="week" selected={matches!(props.current_view, ViewMode::Week)}>{"Week"}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="theme-selector">
|
||||
<label>{"Theme:"}</label>
|
||||
<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>
|
||||
|
||||
<div class="style-selector">
|
||||
<label>{"Style:"}</label>
|
||||
<select class="style-selector-dropdown" onchange={on_style_change}>
|
||||
<option value="default" selected={matches!(props.current_style, Style::Default)}>{"Default"}</option>
|
||||
<option value="google" selected={matches!(props.current_style, Style::Google)}>{"Google Calendar"}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
|
||||
</div>
|
||||
</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,10 +1,11 @@
|
||||
use yew::prelude::*;
|
||||
|
||||
mod app;
|
||||
mod config;
|
||||
mod auth;
|
||||
mod components;
|
||||
mod models;
|
||||
mod services;
|
||||
|
||||
use app::App;
|
||||
|
||||
fn main() {
|
||||
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
4
frontend/src/services/mod.rs
Normal file
4
frontend/src/services/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod calendar_service;
|
||||
pub mod preferences;
|
||||
|
||||
pub use calendar_service::CalendarService;
|
||||
180
frontend/src/services/preferences.rs
Normal file
180
frontend/src/services/preferences.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
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)]
|
||||
#[allow(dead_code)]
|
||||
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>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct PreferencesService {
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
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 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(())
|
||||
}
|
||||
}
|
||||
3659
frontend/styles.css
Normal file
3659
frontend/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
3501
frontend/styles.css.backup
Normal file
3501
frontend/styles.css.backup
Normal file
File diff suppressed because it is too large
Load Diff
51
frontend/styles/base.css
Normal file
51
frontend/styles/base.css
Normal file
@@ -0,0 +1,51 @@
|
||||
/* Base Styles - Always Loaded */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.login-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Base Layout */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 280px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Basic Form Elements */
|
||||
input, select, textarea, button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.loading {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #28a745;
|
||||
}
|
||||
3501
frontend/styles/default.css
Normal file
3501
frontend/styles/default.css
Normal file
File diff suppressed because it is too large
Load Diff
645
frontend/styles/google.css
Normal file
645
frontend/styles/google.css
Normal file
@@ -0,0 +1,645 @@
|
||||
/* Google Calendar-inspired styles */
|
||||
|
||||
/* Override CSS Variables for Google Calendar Style */
|
||||
:root {
|
||||
/* Google-style spacing */
|
||||
--spacing-xs: 2px;
|
||||
--spacing-sm: 4px;
|
||||
--spacing-md: 8px;
|
||||
--spacing-lg: 12px;
|
||||
--spacing-xl: 16px;
|
||||
|
||||
/* Google-style borders and radius */
|
||||
--border-radius-small: 2px;
|
||||
--border-radius-medium: 4px;
|
||||
--border-radius-large: 8px;
|
||||
--border-light: 1px solid #dadce0;
|
||||
--border-medium: 1px solid #dadce0;
|
||||
|
||||
/* Google-style shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15);
|
||||
--shadow-md: 0 1px 3px 0 rgba(60,64,67,.3), 0 4px 8px 3px rgba(60,64,67,.15);
|
||||
--shadow-lg: 0 4px 6px 0 rgba(60,64,67,.3), 0 8px 25px 5px rgba(60,64,67,.15);
|
||||
}
|
||||
|
||||
/* Google-style sidebar - override all theme variants */
|
||||
body .app-sidebar,
|
||||
[data-theme] .app-sidebar,
|
||||
[data-theme="default"] .app-sidebar,
|
||||
[data-theme="ocean"] .app-sidebar,
|
||||
[data-theme="forest"] .app-sidebar,
|
||||
[data-theme="sunset"] .app-sidebar,
|
||||
[data-theme="purple"] .app-sidebar,
|
||||
[data-theme="dark"] .app-sidebar,
|
||||
[data-theme="rose"] .app-sidebar,
|
||||
[data-theme="mint"] .app-sidebar {
|
||||
background: #ffffff !important;
|
||||
border-right: 1px solid #dadce0 !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
box-shadow: 2px 0 8px rgba(60,64,67,.1) !important;
|
||||
}
|
||||
|
||||
body .sidebar-header,
|
||||
[data-theme] .sidebar-header {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid #dadce0 !important;
|
||||
}
|
||||
|
||||
body .sidebar-header h1,
|
||||
[data-theme] .sidebar-header h1 {
|
||||
font-size: 20px !important;
|
||||
font-weight: 500 !important;
|
||||
color: #3c4043 !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
body .user-info,
|
||||
[data-theme] .user-info {
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
body .user-info .username,
|
||||
[data-theme] .user-info .username {
|
||||
font-weight: 500 !important;
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
body .user-info .server-url,
|
||||
[data-theme] .user-info .server-url {
|
||||
color: #5f6368 !important;
|
||||
}
|
||||
|
||||
/* Google-style buttons */
|
||||
.create-calendar-button {
|
||||
background: #1a73e8 !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 8px 16px !important;
|
||||
font-weight: 500 !important;
|
||||
font-size: 14px !important;
|
||||
cursor: pointer !important;
|
||||
box-shadow: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15) !important;
|
||||
transition: box-shadow 0.2s ease !important;
|
||||
}
|
||||
|
||||
.create-calendar-button:hover {
|
||||
box-shadow: 0 1px 3px 0 rgba(60,64,67,.3), 0 4px 8px 3px rgba(60,64,67,.15) !important;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background: transparent !important;
|
||||
color: #1a73e8 !important;
|
||||
border: 1px solid #dadce0 !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 8px 16px !important;
|
||||
font-weight: 500 !important;
|
||||
font-size: 14px !important;
|
||||
cursor: pointer !important;
|
||||
transition: background-color 0.2s ease !important;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background: #f8f9fa !important;
|
||||
}
|
||||
|
||||
/* Google-style navigation and sidebar text */
|
||||
body .sidebar-nav .nav-link,
|
||||
[data-theme] .sidebar-nav .nav-link {
|
||||
color: #3c4043 !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
body .sidebar-nav .nav-link:hover,
|
||||
[data-theme] .sidebar-nav .nav-link:hover {
|
||||
color: #1a73e8 !important;
|
||||
background: #f1f3f4 !important;
|
||||
}
|
||||
|
||||
/* Calendar list styling */
|
||||
body .calendar-list h3,
|
||||
[data-theme] .calendar-list h3 {
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
body .calendar-list .calendar-name,
|
||||
[data-theme] .calendar-list .calendar-name {
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
body .no-calendars,
|
||||
[data-theme] .no-calendars {
|
||||
color: #5f6368 !important;
|
||||
}
|
||||
|
||||
/* Form labels and text */
|
||||
body .sidebar-footer label,
|
||||
[data-theme] .sidebar-footer label,
|
||||
body .view-selector label,
|
||||
[data-theme] .view-selector label,
|
||||
body .theme-selector label,
|
||||
[data-theme] .theme-selector label,
|
||||
body .style-selector label,
|
||||
[data-theme] .style-selector label {
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
/* Google-style selectors */
|
||||
body .view-selector-dropdown,
|
||||
body .theme-selector-dropdown,
|
||||
body .style-selector-dropdown,
|
||||
[data-theme] .view-selector-dropdown,
|
||||
[data-theme] .theme-selector-dropdown,
|
||||
[data-theme] .style-selector-dropdown {
|
||||
border: 1px solid #dadce0 !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 8px !important;
|
||||
font-size: 14px !important;
|
||||
color: #3c4043 !important;
|
||||
background: white !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
.view-selector-dropdown:focus,
|
||||
.theme-selector-dropdown:focus,
|
||||
.style-selector-dropdown:focus {
|
||||
outline: none;
|
||||
border-color: #1a73e8;
|
||||
box-shadow: 0 0 0 2px rgba(26,115,232,.2);
|
||||
}
|
||||
|
||||
/* Google-style calendar list items */
|
||||
.calendar-list h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #3c4043;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.calendar-list ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.calendar-list .calendar-item {
|
||||
padding: 4px 0;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.calendar-list .calendar-item:hover {
|
||||
background-color: #f1f3f4;
|
||||
}
|
||||
|
||||
.calendar-list .calendar-name {
|
||||
color: #3c4043;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Google-style main content area */
|
||||
body .app-main,
|
||||
[data-theme] .app-main {
|
||||
background: #ffffff !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
/* Calendar header elements */
|
||||
body .calendar-header,
|
||||
[data-theme] .calendar-header {
|
||||
background: #f8f9fa !important;
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
body .calendar-header h2,
|
||||
body .calendar-header h3,
|
||||
body .month-header,
|
||||
body .week-header,
|
||||
[data-theme] .calendar-header h2,
|
||||
[data-theme] .calendar-header h3,
|
||||
[data-theme] .month-header,
|
||||
[data-theme] .week-header {
|
||||
color: #3c4043 !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Month name and title - aggressive override */
|
||||
body h1,
|
||||
body h2,
|
||||
body h3,
|
||||
body .month-title,
|
||||
body .calendar-title,
|
||||
body .current-month,
|
||||
body .month-year,
|
||||
body .header-title,
|
||||
[data-theme] h1,
|
||||
[data-theme] h2,
|
||||
[data-theme] h3,
|
||||
[data-theme] .month-title,
|
||||
[data-theme] .calendar-title,
|
||||
[data-theme] .current-month,
|
||||
[data-theme] .month-year,
|
||||
[data-theme] .header-title {
|
||||
color: #3c4043 !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Navigation arrows and buttons - aggressive override */
|
||||
body button,
|
||||
body .nav-button,
|
||||
body .calendar-nav-button,
|
||||
body .prev-button,
|
||||
body .next-button,
|
||||
body .arrow-button,
|
||||
body .navigation-arrow,
|
||||
body [class*="arrow"],
|
||||
body [class*="nav"],
|
||||
body [class*="button"],
|
||||
[data-theme] button,
|
||||
[data-theme] .nav-button,
|
||||
[data-theme] .calendar-nav-button,
|
||||
[data-theme] .prev-button,
|
||||
[data-theme] .next-button,
|
||||
[data-theme] .arrow-button,
|
||||
[data-theme] .navigation-arrow,
|
||||
[data-theme] [class*="arrow"],
|
||||
[data-theme] [class*="nav"],
|
||||
[data-theme] [class*="button"] {
|
||||
color: #3c4043 !important;
|
||||
background: #f8f9fa !important;
|
||||
border: 1px solid #dadce0 !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
body button:hover,
|
||||
body .nav-button:hover,
|
||||
body .calendar-nav-button:hover,
|
||||
body .prev-button:hover,
|
||||
body .next-button:hover,
|
||||
body .arrow-button:hover,
|
||||
[data-theme] button:hover,
|
||||
[data-theme] .nav-button:hover,
|
||||
[data-theme] .calendar-nav-button:hover,
|
||||
[data-theme] .prev-button:hover,
|
||||
[data-theme] .next-button:hover,
|
||||
[data-theme] .arrow-button:hover {
|
||||
background: #e8f0fe !important;
|
||||
color: #1a73e8 !important;
|
||||
border-color: #1a73e8 !important;
|
||||
}
|
||||
|
||||
/* Calendar controls and date display */
|
||||
body .calendar-controls,
|
||||
body .current-date,
|
||||
body .date-display,
|
||||
[data-theme] .calendar-controls,
|
||||
[data-theme] .current-date,
|
||||
[data-theme] .date-display {
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
/* Ultimate nuclear approach - override EVERYTHING */
|
||||
html body .app-main,
|
||||
html body .app-main *,
|
||||
html body .main-content,
|
||||
html body .main-content *,
|
||||
html body .calendar-container,
|
||||
html body .calendar-container *,
|
||||
html [data-theme] .app-main,
|
||||
html [data-theme] .app-main *,
|
||||
html [data-theme] .main-content,
|
||||
html [data-theme] .main-content *,
|
||||
html [data-theme] .calendar-container,
|
||||
html [data-theme] .calendar-container *,
|
||||
html [data-theme="default"] .app-main *,
|
||||
html [data-theme="ocean"] .app-main *,
|
||||
html [data-theme="forest"] .app-main *,
|
||||
html [data-theme="sunset"] .app-main *,
|
||||
html [data-theme="purple"] .app-main *,
|
||||
html [data-theme="dark"] .app-main *,
|
||||
html [data-theme="rose"] .app-main *,
|
||||
html [data-theme="mint"] .app-main * {
|
||||
color: #3c4043 !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Force all text elements */
|
||||
html body .app-main h1,
|
||||
html body .app-main h2,
|
||||
html body .app-main h3,
|
||||
html body .app-main h4,
|
||||
html body .app-main h5,
|
||||
html body .app-main h6,
|
||||
html body .app-main p,
|
||||
html body .app-main span,
|
||||
html body .app-main div,
|
||||
html body .app-main button,
|
||||
html [data-theme] .app-main h1,
|
||||
html [data-theme] .app-main h2,
|
||||
html [data-theme] .app-main h3,
|
||||
html [data-theme] .app-main h4,
|
||||
html [data-theme] .app-main h5,
|
||||
html [data-theme] .app-main h6,
|
||||
html [data-theme] .app-main p,
|
||||
html [data-theme] .app-main span,
|
||||
html [data-theme] .app-main div,
|
||||
html [data-theme] .app-main button {
|
||||
color: #3c4043 !important;
|
||||
}
|
||||
|
||||
/* Exception for buttons - make them stand out */
|
||||
body .app-main button,
|
||||
body .main-content button,
|
||||
[data-theme] .app-main button,
|
||||
[data-theme] .main-content button {
|
||||
color: #3c4043 !important;
|
||||
background: #f8f9fa !important;
|
||||
border: 1px solid #dadce0 !important;
|
||||
}
|
||||
|
||||
/* Google-style calendar grid - more aggressive styling */
|
||||
html body .calendar-grid,
|
||||
html [data-theme] .calendar-grid,
|
||||
body .calendar-container,
|
||||
[data-theme] .calendar-container {
|
||||
border: 1px solid #dadce0 !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden !important;
|
||||
background: white !important;
|
||||
box-shadow: 0 1px 3px 0 rgba(60,64,67,.3), 0 4px 8px 3px rgba(60,64,67,.15) !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
html body .calendar-header,
|
||||
html [data-theme] .calendar-header {
|
||||
background: #f8f9fa !important;
|
||||
border-bottom: 1px solid #dadce0 !important;
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
html body .month-header,
|
||||
html body .week-header,
|
||||
html [data-theme] .month-header,
|
||||
html [data-theme] .week-header {
|
||||
font-size: 22px !important;
|
||||
font-weight: 400 !important;
|
||||
color: #3c4043 !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Google-style calendar cells - complete overhaul */
|
||||
html body .calendar-day,
|
||||
html [data-theme] .calendar-day,
|
||||
body .day-cell,
|
||||
[data-theme] .day-cell {
|
||||
border: 1px solid #e8eaed !important;
|
||||
background: white !important;
|
||||
transition: background-color 0.15s ease !important;
|
||||
padding: 8px !important;
|
||||
min-height: 120px !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
html body .calendar-day:hover,
|
||||
html [data-theme] .calendar-day:hover,
|
||||
body .day-cell:hover,
|
||||
[data-theme] .day-cell:hover {
|
||||
background: #f8f9fa !important;
|
||||
box-shadow: inset 0 0 0 1px #dadce0 !important;
|
||||
}
|
||||
|
||||
html body .calendar-day.today,
|
||||
html [data-theme] .calendar-day.today,
|
||||
body .day-cell.today,
|
||||
[data-theme] .day-cell.today {
|
||||
background: #e8f0fe !important;
|
||||
border-color: #1a73e8 !important;
|
||||
}
|
||||
|
||||
html body .calendar-day.other-month,
|
||||
html [data-theme] .calendar-day.other-month,
|
||||
body .day-cell.other-month,
|
||||
[data-theme] .day-cell.other-month {
|
||||
background: #fafafa !important;
|
||||
color: #9aa0a6 !important;
|
||||
}
|
||||
|
||||
html body .day-number,
|
||||
html [data-theme] .day-number,
|
||||
body .date-number,
|
||||
[data-theme] .date-number {
|
||||
font-size: 14px !important;
|
||||
font-weight: 500 !important;
|
||||
color: #3c4043 !important;
|
||||
margin-bottom: 4px !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Day headers (Mon, Tue, Wed, etc.) */
|
||||
html body .day-header,
|
||||
html [data-theme] .day-header,
|
||||
body .weekday-header,
|
||||
[data-theme] .weekday-header {
|
||||
background: #f8f9fa !important;
|
||||
color: #5f6368 !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.8px !important;
|
||||
padding: 8px !important;
|
||||
border-bottom: 1px solid #dadce0 !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
/* Google Calendar-style events - complete redesign */
|
||||
html body .app-main .event,
|
||||
html [data-theme] .app-main .event,
|
||||
html body .calendar-container .event,
|
||||
html [data-theme] .calendar-container .event,
|
||||
body .event,
|
||||
[data-theme] .event {
|
||||
border-radius: 4px !important;
|
||||
padding: 2px 8px !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 400 !important;
|
||||
margin: 1px 0 2px 0 !important;
|
||||
cursor: pointer !important;
|
||||
border: none !important;
|
||||
color: white !important;
|
||||
font-family: 'Google Sans', 'Roboto', sans-serif !important;
|
||||
box-shadow: 0 1px 3px rgba(60,64,67,.3) !important;
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease !important;
|
||||
display: block !important;
|
||||
text-overflow: ellipsis !important;
|
||||
overflow: hidden !important;
|
||||
white-space: nowrap !important;
|
||||
line-height: 1.3 !important;
|
||||
}
|
||||
|
||||
html body .app-main .event *,
|
||||
html [data-theme] .app-main .event *,
|
||||
html body .calendar-container .event *,
|
||||
html [data-theme] .calendar-container .event *,
|
||||
body .event *,
|
||||
[data-theme] .event * {
|
||||
color: white !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
html body .app-main .event:hover,
|
||||
html [data-theme] .app-main .event:hover,
|
||||
body .event:hover,
|
||||
[data-theme] .event:hover {
|
||||
transform: translateY(-1px) !important;
|
||||
box-shadow: 0 2px 8px rgba(60,64,67,.4) !important;
|
||||
}
|
||||
|
||||
/* All-day events styling */
|
||||
html body .event.all-day,
|
||||
html [data-theme] .event.all-day {
|
||||
border-radius: 12px !important;
|
||||
padding: 4px 12px !important;
|
||||
font-weight: 500 !important;
|
||||
margin: 2px 0 !important;
|
||||
}
|
||||
|
||||
/* Event time display */
|
||||
html body .event-time,
|
||||
html [data-theme] .event-time {
|
||||
opacity: 0.9 !important;
|
||||
font-size: 10px !important;
|
||||
margin-right: 4px !important;
|
||||
}
|
||||
|
||||
/* Week view events */
|
||||
html body .week-view .event,
|
||||
html [data-theme] .week-view .event {
|
||||
border-left: 3px solid rgba(255,255,255,0.8) !important;
|
||||
border-radius: 0 4px 4px 0 !important;
|
||||
padding-left: 6px !important;
|
||||
}
|
||||
|
||||
/* Calendar table structure */
|
||||
html body .calendar-table,
|
||||
html [data-theme] .calendar-table,
|
||||
body table,
|
||||
[data-theme] table {
|
||||
border-collapse: separate !important;
|
||||
border-spacing: 0 !important;
|
||||
width: 100% !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
html body .calendar-table td,
|
||||
html [data-theme] .calendar-table td,
|
||||
body table td,
|
||||
[data-theme] table td {
|
||||
vertical-align: top !important;
|
||||
border: 1px solid #e8eaed !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
/* Month/Week view toggle */
|
||||
html body .view-toggle,
|
||||
html [data-theme] .view-toggle {
|
||||
display: flex !important;
|
||||
gap: 4px !important;
|
||||
background: #f1f3f4 !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 2px !important;
|
||||
}
|
||||
|
||||
html body .view-toggle button,
|
||||
html [data-theme] .view-toggle button {
|
||||
padding: 6px 12px !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
color: #5f6368 !important;
|
||||
border-radius: 4px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.15s ease !important;
|
||||
}
|
||||
|
||||
html body .view-toggle button.active,
|
||||
html [data-theme] .view-toggle button.active {
|
||||
background: white !important;
|
||||
color: #1a73e8 !important;
|
||||
box-shadow: 0 1px 3px rgba(60,64,67,.3) !important;
|
||||
}
|
||||
|
||||
/* Today button */
|
||||
html body .today-button,
|
||||
html [data-theme] .today-button {
|
||||
background: white !important;
|
||||
border: 1px solid #dadce0 !important;
|
||||
color: #1a73e8 !important;
|
||||
padding: 8px 16px !important;
|
||||
border-radius: 4px !important;
|
||||
font-weight: 500 !important;
|
||||
font-size: 14px !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.15s ease !important;
|
||||
}
|
||||
|
||||
html body .today-button:hover,
|
||||
html [data-theme] .today-button:hover {
|
||||
background: #f8f9fa !important;
|
||||
border-color: #1a73e8 !important;
|
||||
}
|
||||
|
||||
/* Google-style modals */
|
||||
.modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #3c4043;
|
||||
font-family: 'Google Sans', sans-serif;
|
||||
}
|
||||
|
||||
/* Google-style form inputs */
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="url"],
|
||||
input[type="date"],
|
||||
input[type="time"],
|
||||
textarea,
|
||||
select {
|
||||
border: 1px solid #dadce0;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: #3c4043;
|
||||
background: white;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: #1a73e8;
|
||||
box-shadow: 0 0 0 2px rgba(26,115,232,.2);
|
||||
}
|
||||
|
||||
/* Google-style labels */
|
||||
label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #3c4043;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
}
|
||||
40
index.html
40
index.html
@@ -1,40 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Yew App</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
div {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
}
|
||||
button {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
14
migrations/001_create_users_table.sql
Normal file
14
migrations/001_create_users_table.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Create users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
|
||||
24
src/app.rs
24
src/app.rs
@@ -1,24 +0,0 @@
|
||||
use yew::prelude::*;
|
||||
|
||||
#[function_component]
|
||||
pub fn App() -> Html {
|
||||
let counter = use_state(|| 0);
|
||||
let onclick = {
|
||||
let counter = counter.clone();
|
||||
move |_| {
|
||||
let value = *counter + 1;
|
||||
counter.set(value);
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<div>
|
||||
<h1>{ "Hello Yew!" }</h1>
|
||||
<p>{ "This is a basic Yew application template." }</p>
|
||||
<div>
|
||||
<button {onclick}>{ "Click me!" }</button>
|
||||
<p>{ format!("Counter: {}", *counter) }</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
182
src/config.rs
182
src/config.rs
@@ -1,182 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
|
||||
/// 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::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::*;
|
||||
use std::env;
|
||||
|
||||
#[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::encode("testuser:testpass");
|
||||
assert_eq!(auth, expected);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user