Compare commits
53 Commits
feature/mo
...
289284a532
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
289284a532 | ||
|
|
089f4ce105 | ||
|
|
235dcf8e1d | ||
|
|
8dd60a8ec1 | ||
|
|
20679b6b53 | ||
|
|
53c4a99697 | ||
|
|
5ea33b7d0a | ||
|
|
13a752a69c | ||
|
|
0609a99839 | ||
|
|
dce82d5f7d | ||
|
|
1e8a8ce5f2 | ||
|
|
c0bdd3d8c2 | ||
|
|
2b98c4d229 | ||
|
|
ceae654a39 | ||
|
|
fb28fa95c9 | ||
|
|
419cb3d790 | ||
|
|
53a62fb05e | ||
|
|
322c88612a | ||
|
|
4aa53d79e7 | ||
|
|
3464754489 | ||
|
|
e56253b9c2 | ||
|
|
cb8cc7258c | ||
|
|
b576cd8c4a | ||
|
|
a773159016 | ||
|
|
a9521ad536 | ||
|
|
5456d7140c | ||
|
|
62cc910e1a | ||
|
|
6ec7bb5422 | ||
|
|
ce74750d85 | ||
|
|
d089f1545b | ||
|
|
7b06fef6c3 | ||
|
|
7be9f5a869 | ||
|
|
a7ebbe0635 | ||
|
|
3662f117f5 | ||
|
|
0899a84b42 | ||
|
|
85d23b0347 | ||
|
|
13db4abc0f | ||
|
|
57e434e4ff | ||
|
|
7c2901f453 | ||
| 6c67444b19 | |||
|
|
970b0a07da | ||
|
|
e2e5813b54 | ||
|
|
73567c185c | ||
| 0587762bbb | |||
|
|
cd6e9c3619 | ||
|
|
d8c3997f24 | ||
|
|
e44d49e190 | ||
| 4d2aad404b | |||
|
|
0453763c98 | ||
|
|
03c0011445 | ||
|
|
79f287ed61 | ||
|
|
e55e6bf4dd | ||
| 1fa3bf44b6 |
@@ -18,17 +18,18 @@ jobs:
|
|||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ secrets.DOCKER_REGISTRY }}
|
registry: ${{ vars.REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ vars.USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
file: ./backend/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ secrets.DOCKER_REGISTRY }}/calendar:latest
|
${{ vars.REGISTRY }}/connor/calendar:latest
|
||||||
${{ secrets.DOCKER_REGISTRY }}/calendar:${{ github.sha }}
|
${{ vars.REGISTRY }}/connor/calendar:${{ github.sha }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -22,3 +22,9 @@ dist/
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# SQLite database
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
calendar.db
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:80, :443 {
|
:80, :443 {
|
||||||
|
@backend {
|
||||||
|
path /api /api/*
|
||||||
|
}
|
||||||
|
reverse_proxy @backend calendar-backend:3000
|
||||||
|
try_files {path} /index.html
|
||||||
root * /srv/www
|
root * /srv/www
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
|
|||||||
96
Dockerfile
96
Dockerfile
@@ -1,96 +0,0 @@
|
|||||||
# Build stage
|
|
||||||
# -----------------------------------------------------------
|
|
||||||
FROM rust:alpine AS builder
|
|
||||||
|
|
||||||
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static nodejs npm
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
RUN rustup target add wasm32-unknown-unknown
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 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/
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Runtime stage
|
|
||||||
# -----------------------------------------------------------
|
|
||||||
FROM alpine:latest
|
|
||||||
|
|
||||||
# Install runtime dependencies
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
|
||||||
|
|
||||||
# Copy frontend files to temporary location
|
|
||||||
COPY --from=builder /app/frontend/dist /app/frontend-dist
|
|
||||||
|
|
||||||
# Copy backend binary (built in workspace root)
|
|
||||||
COPY --from=backend-builder /app/target/release/backend /usr/local/bin/backend
|
|
||||||
|
|
||||||
# Create startup script to copy frontend files to shared volume
|
|
||||||
RUN mkdir -p /srv/www
|
|
||||||
RUN echo '#!/bin/sh' > /usr/local/bin/start.sh && \
|
|
||||||
echo 'cp -r /app/frontend-dist/* /srv/www/' >> /usr/local/bin/start.sh && \
|
|
||||||
echo 'echo "Starting backend server..."' >> /usr/local/bin/start.sh && \
|
|
||||||
echo '/usr/local/bin/backend' >> /usr/local/bin/start.sh && \
|
|
||||||
chmod +x /usr/local/bin/start.sh
|
|
||||||
|
|
||||||
# Start with script that copies frontend files then starts backend
|
|
||||||
CMD ["/usr/local/bin/start.sh"]
|
|
||||||
67
README.md
67
README.md
@@ -1,13 +1,22 @@
|
|||||||
# Modern CalDAV Web Client
|
# Runway
|
||||||
|
## _Passive infrastructure for life's coordination_
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
>[!WARNING]
|
>[!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.
|
>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.
|
A modern CalDAV web client built with Rust WebAssembly.
|
||||||
|
|
||||||
## Motivation
|
## The Name
|
||||||
|
|
||||||
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.
|
Runway embodies the concept of **passive infrastructure** — unobtrusive systems that enable better coordination without getting in the way. Planes can fly and do lots of cool things, but without runways, they can't take off or land. Similarly, calendars and scheduling tools are essential for organizing our lives, but they should not dominate our attention.
|
||||||
|
|
||||||
|
The best infrastructure is invisible when working, essential when needed, and enables rather than constrains.
|
||||||
|
|
||||||
|
## Why Runway?
|
||||||
|
|
||||||
|
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. Runway provides a modern, fast, and reliable web interface for CalDAV servers — infrastructure that just works.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -29,6 +38,12 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
|||||||
- **Real-time Updates**: Seamless synchronization with CalDAV servers
|
- **Real-time Updates**: Seamless synchronization with CalDAV servers
|
||||||
- **Timezone Aware**: Proper local time display with UTC storage
|
- **Timezone Aware**: Proper local time display with UTC storage
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- **Persistent Preferences**: Settings sync across devices and sessions
|
||||||
|
- **Remember Me**: Optional server/username remembering for convenience
|
||||||
|
- **Session Management**: Secure session tokens with automatic expiry
|
||||||
|
- **Cross-Device Sync**: User preferences stored in database, not just browser
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Frontend (Yew WebAssembly)
|
### Frontend (Yew WebAssembly)
|
||||||
@@ -40,7 +55,8 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
|||||||
|
|
||||||
### Backend (Axum)
|
### Backend (Axum)
|
||||||
- **Framework**: Axum async web framework with CORS support
|
- **Framework**: Axum async web framework with CORS support
|
||||||
- **Authentication**: JWT token management and validation
|
- **Authentication**: SQLite-backed session management with JWT tokens
|
||||||
|
- **Database**: SQLite for user preferences and session storage
|
||||||
- **CalDAV Client**: Full CalDAV protocol implementation for server sync
|
- **CalDAV Client**: Full CalDAV protocol implementation for server sync
|
||||||
- **API Design**: RESTful endpoints following calendar operation patterns
|
- **API Design**: RESTful endpoints following calendar operation patterns
|
||||||
- **Data Flow**: Proxy between frontend and CalDAV servers with proper authentication
|
- **Data Flow**: Proxy between frontend and CalDAV servers with proper authentication
|
||||||
@@ -54,12 +70,36 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
|||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Docker Deployment (Recommended)
|
||||||
|
|
||||||
|
The easiest way to run Runway is using Docker Compose:
|
||||||
|
|
||||||
|
1. **Clone the repository**:
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd calendar
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Start the application**:
|
||||||
|
```bash
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Access the application** at `http://localhost`
|
||||||
|
|
||||||
|
The Docker setup includes:
|
||||||
|
- **Automatic database migrations** on startup
|
||||||
|
- **Persistent data storage** in `./data/db/` volume
|
||||||
|
- **Frontend served via Caddy** on port 80
|
||||||
|
- **Backend API** accessible on port 3000
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
- Rust (latest stable version)
|
- Rust (latest stable version)
|
||||||
- Trunk (`cargo install trunk`)
|
- Trunk (`cargo install trunk`)
|
||||||
|
|
||||||
### Development Setup
|
#### Local Development
|
||||||
|
|
||||||
1. **Start the backend server** (serves API at http://localhost:3000):
|
1. **Start the backend server** (serves API at http://localhost:3000):
|
||||||
```bash
|
```bash
|
||||||
@@ -73,6 +113,17 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
|
|||||||
|
|
||||||
3. **Access the application** at `http://localhost:8080`
|
3. **Access the application** at `http://localhost:8080`
|
||||||
|
|
||||||
|
#### Database Setup
|
||||||
|
|
||||||
|
For local development, run the database migrations:
|
||||||
|
```bash
|
||||||
|
# Install sqlx-cli if not already installed
|
||||||
|
cargo install sqlx-cli --features sqlite
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
sqlx migrate run --database-url "sqlite:calendar.db" --source backend/migrations
|
||||||
|
```
|
||||||
|
|
||||||
### Building for Production
|
### Building for Production
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -120,7 +171,7 @@ calendar/
|
|||||||
This client is designed to work with any RFC-compliant CalDAV server:
|
This client is designed to work with any RFC-compliant CalDAV server:
|
||||||
|
|
||||||
- **Baikal** - ✅ Fully tested with complete event and recurrence support
|
- **Baikal** - ✅ Fully tested with complete event and recurrence support
|
||||||
- **Nextcloud** - 🚧 Planned compatibility with calendar app
|
- **Nextcloud** - 🚧 Planned compatibility with Nextcloud calendar
|
||||||
- **Radicale** - 🚧 Planned lightweight CalDAV server support
|
- **Radicale** - 🚧 Planned lightweight CalDAV server support
|
||||||
- **Apple Calendar Server** - 🚧 Planned standards-compliant operation
|
- **Apple Calendar Server** - 🚧 Planned standards-compliant operation
|
||||||
- **Google Calendar** - 🚧 Planned CalDAV API compatibility
|
- **Google Calendar** - 🚧 Planned CalDAV API compatibility
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ base64 = "0.21"
|
|||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
lazy_static = "1.4"
|
lazy_static = "1.4"
|
||||||
|
|
||||||
|
# Database dependencies
|
||||||
|
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "uuid", "chrono", "json"] }
|
||||||
|
tokio-rusqlite = "0.5"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.0", features = ["macros", "rt"] }
|
tokio = { version = "1.0", features = ["macros", "rt"] }
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
|||||||
64
backend/Dockerfile
Normal file
64
backend/Dockerfile
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Build stage
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
FROM rust:alpine AS 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 workspace files to maintain workspace structure
|
||||||
|
COPY ./Cargo.toml ./
|
||||||
|
COPY ./calendar-models ./calendar-models
|
||||||
|
|
||||||
|
# Create empty frontend directory to satisfy workspace
|
||||||
|
RUN mkdir -p frontend/src && \
|
||||||
|
printf '[package]\nname = "runway"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' > frontend/Cargo.toml && \
|
||||||
|
echo 'fn main() {}' > frontend/src/main.rs
|
||||||
|
|
||||||
|
# Copy backend files
|
||||||
|
COPY backend/Cargo.toml ./backend/
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
# Copy actual backend source and build
|
||||||
|
COPY backend/src ./backend/src
|
||||||
|
COPY backend/migrations ./backend/migrations
|
||||||
|
RUN cargo build --release --bin backend
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata sqlite
|
||||||
|
|
||||||
|
# Copy backend binary and sqlx-cli
|
||||||
|
COPY --from=builder /app/target/release/backend /usr/local/bin/backend
|
||||||
|
COPY --from=builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx
|
||||||
|
|
||||||
|
# Copy migrations for database setup
|
||||||
|
COPY backend/migrations /migrations
|
||||||
|
|
||||||
|
# Create startup script to run migrations and start backend
|
||||||
|
RUN mkdir -p /db
|
||||||
|
RUN echo '#!/bin/sh' > /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 'touch /db/calendar.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 with script that runs migrations then starts backend
|
||||||
|
CMD ["/usr/local/bin/start.sh"]
|
||||||
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';
|
||||||
2
backend/migrations/005_add_last_used_calendar.sql
Normal file
2
backend/migrations/005_add_last_used_calendar.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add last used calendar preference to user preferences
|
||||||
|
ALTER TABLE user_preferences ADD COLUMN last_used_calendar TEXT;
|
||||||
@@ -1,27 +1,30 @@
|
|||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{CalDAVLoginRequest, AuthResponse, ApiError};
|
|
||||||
use crate::config::CalDAVConfig;
|
|
||||||
use crate::calendar::CalDAVClient;
|
use crate::calendar::CalDAVClient;
|
||||||
|
use crate::config::CalDAVConfig;
|
||||||
|
use crate::db::{Database, PreferencesRepository, Session, SessionRepository, UserRepository};
|
||||||
|
use crate::models::{ApiError, AuthResponse, CalDAVLoginRequest, UserPreferencesResponse};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
pub exp: i64, // Expiration time
|
pub exp: i64, // Expiration time
|
||||||
pub iat: i64, // Issued at
|
pub iat: i64, // Issued at
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AuthService {
|
pub struct AuthService {
|
||||||
jwt_secret: String,
|
jwt_secret: String,
|
||||||
|
db: Database,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthService {
|
impl AuthService {
|
||||||
pub fn new(jwt_secret: String) -> Self {
|
pub fn new(jwt_secret: String, db: Database) -> Self {
|
||||||
Self { jwt_secret }
|
Self { jwt_secret, db }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authenticate user directly against CalDAV server
|
/// Authenticate user directly against CalDAV server
|
||||||
@@ -31,13 +34,11 @@ impl AuthService {
|
|||||||
println!("✅ Input validation passed");
|
println!("✅ Input validation passed");
|
||||||
|
|
||||||
// Create CalDAV config with provided credentials
|
// Create CalDAV config with provided credentials
|
||||||
let caldav_config = CalDAVConfig {
|
let caldav_config = CalDAVConfig::new(
|
||||||
server_url: request.server_url.clone(),
|
request.server_url.clone(),
|
||||||
username: request.username.clone(),
|
request.username.clone(),
|
||||||
password: request.password.clone(),
|
request.password.clone(),
|
||||||
calendar_path: None,
|
);
|
||||||
tasks_path: None,
|
|
||||||
};
|
|
||||||
println!("📝 Created CalDAV config");
|
println!("📝 Created CalDAV config");
|
||||||
|
|
||||||
// Test authentication against CalDAV server
|
// Test authentication against CalDAV server
|
||||||
@@ -47,20 +48,61 @@ impl AuthService {
|
|||||||
// Try to discover calendars as an authentication test
|
// Try to discover calendars as an authentication test
|
||||||
match caldav_client.discover_calendars().await {
|
match caldav_client.discover_calendars().await {
|
||||||
Ok(calendars) => {
|
Ok(calendars) => {
|
||||||
println!("✅ Authentication successful! Found {} calendars", calendars.len());
|
println!(
|
||||||
// Authentication successful, generate JWT token
|
"✅ Authentication successful! Found {} calendars",
|
||||||
let token = self.generate_token(&request.username, &request.server_url)?;
|
calendars.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find or create user in database
|
||||||
|
let user_repo = UserRepository::new(&self.db);
|
||||||
|
let user = user_repo
|
||||||
|
.find_or_create(&request.username, &request.server_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to create user: {}", e)))?;
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
let jwt_token = self.generate_token(&request.username, &request.server_url)?;
|
||||||
|
|
||||||
|
// Generate session token
|
||||||
|
let session_token = format!("sess_{}", Uuid::new_v4());
|
||||||
|
|
||||||
|
// Create session in database
|
||||||
|
let session = Session::new(user.id.clone(), session_token.clone(), 24);
|
||||||
|
let session_repo = SessionRepository::new(&self.db);
|
||||||
|
session_repo
|
||||||
|
.create(&session)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to create session: {}", e)))?;
|
||||||
|
|
||||||
|
// Get or create user preferences
|
||||||
|
let prefs_repo = PreferencesRepository::new(&self.db);
|
||||||
|
let preferences = prefs_repo
|
||||||
|
.get_or_create(&user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
|
||||||
|
|
||||||
Ok(AuthResponse {
|
Ok(AuthResponse {
|
||||||
token,
|
token: jwt_token,
|
||||||
|
session_token,
|
||||||
username: request.username,
|
username: request.username,
|
||||||
server_url: request.server_url,
|
server_url: request.server_url,
|
||||||
|
preferences: UserPreferencesResponse {
|
||||||
|
calendar_selected_date: preferences.calendar_selected_date,
|
||||||
|
calendar_time_increment: preferences.calendar_time_increment,
|
||||||
|
calendar_view_mode: preferences.calendar_view_mode,
|
||||||
|
calendar_theme: preferences.calendar_theme,
|
||||||
|
calendar_style: preferences.calendar_style,
|
||||||
|
calendar_colors: preferences.calendar_colors,
|
||||||
|
last_used_calendar: preferences.last_used_calendar,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
println!("❌ Authentication failed: {:?}", err);
|
println!("❌ Authentication failed: {:?}", err);
|
||||||
// Authentication failed
|
// Authentication failed
|
||||||
Err(ApiError::Unauthorized("Invalid CalDAV credentials or server unavailable".to_string()))
|
Err(ApiError::Unauthorized(
|
||||||
|
"Invalid CalDAV credentials or server unavailable".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,16 +113,18 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create CalDAV config from token
|
/// Create CalDAV config from token
|
||||||
pub fn caldav_config_from_token(&self, token: &str, password: &str) -> Result<CalDAVConfig, ApiError> {
|
pub fn caldav_config_from_token(
|
||||||
|
&self,
|
||||||
|
token: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<CalDAVConfig, ApiError> {
|
||||||
let claims = self.verify_token(token)?;
|
let claims = self.verify_token(token)?;
|
||||||
|
|
||||||
Ok(CalDAVConfig {
|
Ok(CalDAVConfig::new(
|
||||||
server_url: claims.server_url,
|
claims.server_url,
|
||||||
username: claims.username,
|
claims.username,
|
||||||
password: password.to_string(),
|
password.to_string(),
|
||||||
calendar_path: None,
|
))
|
||||||
tasks_path: None,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> {
|
fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> {
|
||||||
@@ -97,8 +141,11 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Basic URL validation
|
// Basic URL validation
|
||||||
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://") {
|
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://")
|
||||||
return Err(ApiError::BadRequest("Server URL must start with http:// or https://".to_string()));
|
{
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"Server URL must start with http:// or https://".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -135,4 +182,33 @@ impl AuthService {
|
|||||||
|
|
||||||
Ok(token_data.claims)
|
Ok(token_data.claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate session token
|
||||||
|
pub async fn validate_session(&self, session_token: &str) -> Result<String, ApiError> {
|
||||||
|
let session_repo = SessionRepository::new(&self.db);
|
||||||
|
|
||||||
|
let session = session_repo
|
||||||
|
.find_by_token(session_token)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to find session: {}", e)))?
|
||||||
|
.ok_or_else(|| ApiError::Unauthorized("Invalid session token".to_string()))?;
|
||||||
|
|
||||||
|
if session.is_expired() {
|
||||||
|
return Err(ApiError::Unauthorized("Session expired".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(session.user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout user by deleting session
|
||||||
|
pub async fn logout(&self, session_token: &str) -> Result<(), ApiError> {
|
||||||
|
let session_repo = SessionRepository::new(&self.db);
|
||||||
|
|
||||||
|
session_repo
|
||||||
|
.delete(session_token)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to delete session: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
|
use calendar_models::{CalendarUser, EventClass, EventStatus, VAlarm, VEvent};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use calendar_models::{VEvent, EventStatus, EventClass, CalendarUser, VAlarm};
|
|
||||||
|
|
||||||
// Global mutex to serialize CalDAV HTTP requests to prevent race conditions
|
// Global mutex to serialize CalDAV HTTP requests to prevent race conditions
|
||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
@@ -128,7 +128,10 @@ impl CalDAVClient {
|
|||||||
///
|
///
|
||||||
/// This method performs a REPORT request to get calendar data and parses
|
/// This method performs a REPORT request to get calendar data and parses
|
||||||
/// the returned iCalendar format into CalendarEvent structs.
|
/// the returned iCalendar format into CalendarEvent structs.
|
||||||
pub async fn fetch_events(&self, calendar_path: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
pub async fn fetch_events(
|
||||||
|
&self,
|
||||||
|
calendar_path: &str,
|
||||||
|
) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
||||||
// CalDAV REPORT request to get calendar events
|
// CalDAV REPORT request to get calendar events
|
||||||
let report_body = r#"<?xml version="1.0" encoding="utf-8" ?>
|
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">
|
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
@@ -149,7 +152,11 @@ impl CalDAVClient {
|
|||||||
// Extract the base URL (scheme + host + port) from server_url
|
// Extract the base URL (scheme + host + port) from server_url
|
||||||
let server_url = &self.config.server_url;
|
let server_url = &self.config.server_url;
|
||||||
// Find the first '/' after "https://" or "http://"
|
// Find the first '/' after "https://" or "http://"
|
||||||
let scheme_end = if server_url.starts_with("https://") { 8 } else { 7 };
|
let scheme_end = if server_url.starts_with("https://") {
|
||||||
|
8
|
||||||
|
} else {
|
||||||
|
7
|
||||||
|
};
|
||||||
if let Some(path_start) = server_url[scheme_end..].find('/') {
|
if let Some(path_start) = server_url[scheme_end..].find('/') {
|
||||||
let base_url = &server_url[..scheme_end + path_start];
|
let base_url = &server_url[..scheme_end + path_start];
|
||||||
format!("{}{}", base_url, calendar_path)
|
format!("{}{}", base_url, calendar_path)
|
||||||
@@ -163,7 +170,8 @@ impl CalDAVClient {
|
|||||||
println!("🔑 REPORT Basic Auth: Basic {}", basic_auth);
|
println!("🔑 REPORT Basic Auth: Basic {}", basic_auth);
|
||||||
println!("🌐 REPORT URL: {}", url);
|
println!("🌐 REPORT URL: {}", url);
|
||||||
|
|
||||||
let response = self.http_client
|
let response = self
|
||||||
|
.http_client
|
||||||
.request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url)
|
.request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url)
|
||||||
.header("Authorization", format!("Basic {}", basic_auth))
|
.header("Authorization", format!("Basic {}", basic_auth))
|
||||||
.header("Content-Type", "application/xml")
|
.header("Content-Type", "application/xml")
|
||||||
@@ -183,7 +191,11 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse CalDAV XML response containing calendar data
|
/// Parse CalDAV XML response containing calendar data
|
||||||
fn parse_calendar_response(&self, xml_response: &str, calendar_path: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
fn parse_calendar_response(
|
||||||
|
&self,
|
||||||
|
xml_response: &str,
|
||||||
|
calendar_path: &str,
|
||||||
|
) -> Result<Vec<CalendarEvent>, CalDAVError> {
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
|
||||||
// Extract calendar data from XML response
|
// Extract calendar data from XML response
|
||||||
@@ -205,7 +217,11 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch a single calendar event by UID from the CalDAV server
|
/// Fetch a single calendar event by UID from the CalDAV server
|
||||||
pub async fn fetch_event_by_uid(&self, calendar_path: &str, uid: &str) -> Result<Option<CalendarEvent>, CalDAVError> {
|
pub async fn fetch_event_by_uid(
|
||||||
|
&self,
|
||||||
|
calendar_path: &str,
|
||||||
|
uid: &str,
|
||||||
|
) -> Result<Option<CalendarEvent>, CalDAVError> {
|
||||||
// First fetch all events and find the one with matching UID
|
// First fetch all events and find the one with matching UID
|
||||||
let events = self.fetch_events(calendar_path).await?;
|
let events = self.fetch_events(calendar_path).await?;
|
||||||
|
|
||||||
@@ -225,10 +241,16 @@ impl CalDAVClient {
|
|||||||
if let Some(end_pos) = response_block.find("</d:response>") {
|
if let Some(end_pos) = response_block.find("</d:response>") {
|
||||||
let response_content = &response_block[..end_pos];
|
let response_content = &response_block[..end_pos];
|
||||||
|
|
||||||
let href = self.extract_xml_content(response_content, "href").unwrap_or_default();
|
let href = self
|
||||||
let etag = self.extract_xml_content(response_content, "getetag").unwrap_or_default();
|
.extract_xml_content(response_content, "href")
|
||||||
|
.unwrap_or_default();
|
||||||
|
let etag = self
|
||||||
|
.extract_xml_content(response_content, "getetag")
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
if let Some(calendar_data) = self.extract_xml_content(response_content, "cal:calendar-data") {
|
if let Some(calendar_data) =
|
||||||
|
self.extract_xml_content(response_content, "cal:calendar-data")
|
||||||
|
{
|
||||||
sections.push(CalendarDataSection {
|
sections.push(CalendarDataSection {
|
||||||
href: if href.is_empty() { None } else { Some(href) },
|
href: if href.is_empty() { None } else { Some(href) },
|
||||||
etag: if etag.is_empty() { None } else { Some(etag) },
|
etag: if etag.is_empty() { None } else { Some(etag) },
|
||||||
@@ -245,12 +267,28 @@ impl CalDAVClient {
|
|||||||
fn extract_xml_content(&self, xml: &str, tag: &str) -> Option<String> {
|
fn extract_xml_content(&self, xml: &str, tag: &str) -> Option<String> {
|
||||||
// Handle both with and without namespace prefixes
|
// Handle both with and without namespace prefixes
|
||||||
let patterns = [
|
let patterns = [
|
||||||
format!("(?s)<{}>(.*?)</{}>", tag, tag), // <tag>content</tag>
|
format!("(?s)<{}>(.*?)</{}>", tag, tag), // <tag>content</tag>
|
||||||
format!("(?s)<{}>(.*?)</.*:{}>", tag, tag.split(':').last().unwrap_or(tag)), // <tag>content</ns:tag>
|
format!(
|
||||||
format!("(?s)<.*:{}>(.*?)</{}>", tag.split(':').last().unwrap_or(tag), tag), // <ns:tag>content</tag>
|
"(?s)<{}>(.*?)</.*:{}>",
|
||||||
format!("(?s)<.*:{}>(.*?)</.*:{}>", tag.split(':').last().unwrap_or(tag), tag.split(':').last().unwrap_or(tag)), // <ns:tag>content</ns:tag>
|
tag,
|
||||||
format!("(?s)<{}[^>]*>(.*?)</{}>", tag, tag), // <tag attr>content</tag>
|
tag.split(':').last().unwrap_or(tag)
|
||||||
format!("(?s)<{}[^>]*>(.*?)</.*:{}>", tag, tag.split(':').last().unwrap_or(tag)),
|
), // <tag>content</ns:tag>
|
||||||
|
format!(
|
||||||
|
"(?s)<.*:{}>(.*?)</{}>",
|
||||||
|
tag.split(':').last().unwrap_or(tag),
|
||||||
|
tag
|
||||||
|
), // <ns:tag>content</tag>
|
||||||
|
format!(
|
||||||
|
"(?s)<.*:{}>(.*?)</.*:{}>",
|
||||||
|
tag.split(':').last().unwrap_or(tag),
|
||||||
|
tag.split(':').last().unwrap_or(tag)
|
||||||
|
), // <ns:tag>content</ns:tag>
|
||||||
|
format!("(?s)<{}[^>]*>(.*?)</{}>", tag, tag), // <tag attr>content</tag>
|
||||||
|
format!(
|
||||||
|
"(?s)<{}[^>]*>(.*?)</.*:{}>",
|
||||||
|
tag,
|
||||||
|
tag.split(':').last().unwrap_or(tag)
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
for pattern in &patterns {
|
for pattern in &patterns {
|
||||||
@@ -287,21 +325,29 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a single iCal event into a CalendarEvent struct
|
/// Parse a single iCal event into a CalendarEvent struct
|
||||||
fn parse_ical_event(&self, event: ical::parser::ical::component::IcalEvent) -> Result<CalendarEvent, CalDAVError> {
|
fn parse_ical_event(
|
||||||
|
&self,
|
||||||
|
event: ical::parser::ical::component::IcalEvent,
|
||||||
|
) -> Result<CalendarEvent, CalDAVError> {
|
||||||
let mut properties: HashMap<String, String> = HashMap::new();
|
let mut properties: HashMap<String, String> = HashMap::new();
|
||||||
|
|
||||||
// Extract all properties from the event
|
// Extract all properties from the event
|
||||||
for property in &event.properties {
|
for property in &event.properties {
|
||||||
properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default());
|
properties.insert(
|
||||||
|
property.name.to_uppercase(),
|
||||||
|
property.value.clone().unwrap_or_default(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Required UID field
|
// Required UID field
|
||||||
let uid = properties.get("UID")
|
let uid = properties
|
||||||
|
.get("UID")
|
||||||
.ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))?
|
.ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))?
|
||||||
.clone();
|
.clone();
|
||||||
|
|
||||||
// Parse start time (required)
|
// Parse start time (required)
|
||||||
let start = properties.get("DTSTART")
|
let start = properties
|
||||||
|
.get("DTSTART")
|
||||||
.ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?;
|
.ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?;
|
||||||
let start = self.parse_datetime(start, properties.get("DTSTART"))?;
|
let start = self.parse_datetime(start, properties.get("DTSTART"))?;
|
||||||
|
|
||||||
@@ -315,13 +361,14 @@ impl CalDAVClient {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine if it's an all-day event
|
// Determine if it's an all-day event by checking for VALUE=DATE parameter
|
||||||
let all_day = properties.get("DTSTART")
|
let empty_string = String::new();
|
||||||
.map(|s| !s.contains("T"))
|
let dtstart_raw = properties.get("DTSTART").unwrap_or(&empty_string);
|
||||||
.unwrap_or(false);
|
let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_raw.contains("T") && dtstart_raw.len() == 8);
|
||||||
|
|
||||||
// Parse status
|
// Parse status
|
||||||
let status = properties.get("STATUS")
|
let status = properties
|
||||||
|
.get("STATUS")
|
||||||
.map(|s| match s.to_uppercase().as_str() {
|
.map(|s| match s.to_uppercase().as_str() {
|
||||||
"TENTATIVE" => EventStatus::Tentative,
|
"TENTATIVE" => EventStatus::Tentative,
|
||||||
"CANCELLED" => EventStatus::Cancelled,
|
"CANCELLED" => EventStatus::Cancelled,
|
||||||
@@ -330,7 +377,8 @@ impl CalDAVClient {
|
|||||||
.unwrap_or(EventStatus::Confirmed);
|
.unwrap_or(EventStatus::Confirmed);
|
||||||
|
|
||||||
// Parse classification
|
// Parse classification
|
||||||
let class = properties.get("CLASS")
|
let class = properties
|
||||||
|
.get("CLASS")
|
||||||
.map(|s| match s.to_uppercase().as_str() {
|
.map(|s| match s.to_uppercase().as_str() {
|
||||||
"PRIVATE" => EventClass::Private,
|
"PRIVATE" => EventClass::Private,
|
||||||
"CONFIDENTIAL" => EventClass::Confidential,
|
"CONFIDENTIAL" => EventClass::Confidential,
|
||||||
@@ -339,20 +387,24 @@ impl CalDAVClient {
|
|||||||
.unwrap_or(EventClass::Public);
|
.unwrap_or(EventClass::Public);
|
||||||
|
|
||||||
// Parse priority
|
// Parse priority
|
||||||
let priority = properties.get("PRIORITY")
|
let priority = properties
|
||||||
|
.get("PRIORITY")
|
||||||
.and_then(|s| s.parse::<u8>().ok())
|
.and_then(|s| s.parse::<u8>().ok())
|
||||||
.filter(|&p| p <= 9);
|
.filter(|&p| p <= 9);
|
||||||
|
|
||||||
// Parse categories
|
// Parse categories
|
||||||
let categories = properties.get("CATEGORIES")
|
let categories = properties
|
||||||
|
.get("CATEGORIES")
|
||||||
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect())
|
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Parse dates
|
// Parse dates
|
||||||
let created = properties.get("CREATED")
|
let created = properties
|
||||||
|
.get("CREATED")
|
||||||
.and_then(|s| self.parse_datetime(s, None).ok());
|
.and_then(|s| self.parse_datetime(s, None).ok());
|
||||||
|
|
||||||
let last_modified = properties.get("LAST-MODIFIED")
|
let last_modified = properties
|
||||||
|
.get("LAST-MODIFIED")
|
||||||
.and_then(|s| self.parse_datetime(s, None).ok());
|
.and_then(|s| self.parse_datetime(s, None).ok());
|
||||||
|
|
||||||
// Parse exception dates (EXDATE)
|
// Parse exception dates (EXDATE)
|
||||||
@@ -403,7 +455,10 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse VALARM components from an iCal event
|
/// Parse VALARM components from an iCal event
|
||||||
fn parse_valarms(&self, event: &ical::parser::ical::component::IcalEvent) -> Result<Vec<VAlarm>, CalDAVError> {
|
fn parse_valarms(
|
||||||
|
&self,
|
||||||
|
event: &ical::parser::ical::component::IcalEvent,
|
||||||
|
) -> Result<Vec<VAlarm>, CalDAVError> {
|
||||||
let mut alarms = Vec::new();
|
let mut alarms = Vec::new();
|
||||||
|
|
||||||
for alarm in &event.alarms {
|
for alarm in &event.alarms {
|
||||||
@@ -416,20 +471,30 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a single VALARM component into a VAlarm
|
/// Parse a single VALARM component into a VAlarm
|
||||||
fn parse_single_valarm(&self, alarm: &ical::parser::ical::component::IcalAlarm) -> Result<VAlarm, CalDAVError> {
|
fn parse_single_valarm(
|
||||||
|
&self,
|
||||||
|
alarm: &ical::parser::ical::component::IcalAlarm,
|
||||||
|
) -> Result<VAlarm, CalDAVError> {
|
||||||
let mut properties: HashMap<String, String> = HashMap::new();
|
let mut properties: HashMap<String, String> = HashMap::new();
|
||||||
|
|
||||||
// Extract all properties from the alarm
|
// Extract all properties from the alarm
|
||||||
for property in &alarm.properties {
|
for property in &alarm.properties {
|
||||||
properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default());
|
properties.insert(
|
||||||
|
property.name.to_uppercase(),
|
||||||
|
property.value.clone().unwrap_or_default(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse ACTION (required)
|
// Parse ACTION (required)
|
||||||
let action = match properties.get("ACTION").map(|s| s.to_uppercase()) {
|
let action = match properties.get("ACTION").map(|s| s.to_uppercase()) {
|
||||||
Some(ref action_str) if action_str == "DISPLAY" => calendar_models::AlarmAction::Display,
|
Some(ref action_str) if action_str == "DISPLAY" => {
|
||||||
|
calendar_models::AlarmAction::Display
|
||||||
|
}
|
||||||
Some(ref action_str) if action_str == "EMAIL" => calendar_models::AlarmAction::Email,
|
Some(ref action_str) if action_str == "EMAIL" => calendar_models::AlarmAction::Email,
|
||||||
Some(ref action_str) if action_str == "AUDIO" => calendar_models::AlarmAction::Audio,
|
Some(ref action_str) if action_str == "AUDIO" => calendar_models::AlarmAction::Audio,
|
||||||
Some(ref action_str) if action_str == "PROCEDURE" => calendar_models::AlarmAction::Procedure,
|
Some(ref action_str) if action_str == "PROCEDURE" => {
|
||||||
|
calendar_models::AlarmAction::Procedure
|
||||||
|
}
|
||||||
_ => calendar_models::AlarmAction::Display, // Default
|
_ => calendar_models::AlarmAction::Display, // Default
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -468,15 +533,15 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
if trigger.starts_with("-PT") && trigger.ends_with("M") {
|
if trigger.starts_with("-PT") && trigger.ends_with("M") {
|
||||||
// Parse "-PT15M" format (minutes)
|
// Parse "-PT15M" format (minutes)
|
||||||
let minutes_str = &trigger[3..trigger.len()-1];
|
let minutes_str = &trigger[3..trigger.len() - 1];
|
||||||
minutes_str.parse::<i32>().ok()
|
minutes_str.parse::<i32>().ok()
|
||||||
} else if trigger.starts_with("-PT") && trigger.ends_with("H") {
|
} else if trigger.starts_with("-PT") && trigger.ends_with("H") {
|
||||||
// Parse "-PT1H" format (hours)
|
// Parse "-PT1H" format (hours)
|
||||||
let hours_str = &trigger[3..trigger.len()-1];
|
let hours_str = &trigger[3..trigger.len() - 1];
|
||||||
hours_str.parse::<i32>().ok().map(|h| h * 60)
|
hours_str.parse::<i32>().ok().map(|h| h * 60)
|
||||||
} else if trigger.starts_with("-P") && trigger.ends_with("D") {
|
} else if trigger.starts_with("-P") && trigger.ends_with("D") {
|
||||||
// Parse "-P1D" format (days)
|
// Parse "-P1D" format (days)
|
||||||
let days_str = &trigger[2..trigger.len()-1];
|
let days_str = &trigger[2..trigger.len() - 1];
|
||||||
days_str.parse::<i32>().ok().map(|d| d * 24 * 60)
|
days_str.parse::<i32>().ok().map(|d| d * 24 * 60)
|
||||||
} else {
|
} else {
|
||||||
// Try to parse as raw minutes
|
// Try to parse as raw minutes
|
||||||
@@ -498,10 +563,7 @@ impl CalDAVClient {
|
|||||||
// Note: paths should be relative to the server URL base
|
// Note: paths should be relative to the server URL base
|
||||||
let user_calendar_path = format!("/calendars/{}/", self.config.username);
|
let user_calendar_path = format!("/calendars/{}/", self.config.username);
|
||||||
|
|
||||||
let discovery_paths = vec![
|
let discovery_paths = vec!["/calendars/", user_calendar_path.as_str()];
|
||||||
"/calendars/",
|
|
||||||
user_calendar_path.as_str(),
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut all_calendars = Vec::new();
|
let mut all_calendars = Vec::new();
|
||||||
|
|
||||||
@@ -533,9 +595,13 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
let url = format!("{}{}", self.config.server_url.trim_end_matches('/'), path);
|
let url = format!("{}{}", self.config.server_url.trim_end_matches('/'), path);
|
||||||
|
|
||||||
let response = self.http_client
|
let response = self
|
||||||
|
.http_client
|
||||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url)
|
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url)
|
||||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", self.config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("Content-Type", "application/xml")
|
.header("Content-Type", "application/xml")
|
||||||
.header("Depth", "2") // Deeper search to find actual calendars
|
.header("Depth", "2") // Deeper search to find actual calendars
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
@@ -545,7 +611,11 @@ impl CalDAVClient {
|
|||||||
.map_err(CalDAVError::RequestError)?;
|
.map_err(CalDAVError::RequestError)?;
|
||||||
|
|
||||||
if response.status().as_u16() != 207 {
|
if response.status().as_u16() != 207 {
|
||||||
println!("❌ Discovery PROPFIND failed for {}: HTTP {}", path, response.status().as_u16());
|
println!(
|
||||||
|
"❌ Discovery PROPFIND failed for {}: HTTP {}",
|
||||||
|
path,
|
||||||
|
response.status().as_u16()
|
||||||
|
);
|
||||||
return Err(CalDAVError::ServerError(response.status().as_u16()));
|
return Err(CalDAVError::ServerError(response.status().as_u16()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,19 +635,26 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
// Check if this is a calendar collection by looking for supported-calendar-component-set
|
// Check if this is a calendar collection by looking for supported-calendar-component-set
|
||||||
// This indicates it's an actual calendar that can contain events
|
// This indicates it's an actual calendar that can contain events
|
||||||
let has_supported_components = response_content.contains("supported-calendar-component-set") &&
|
let has_supported_components = response_content
|
||||||
(response_content.contains("VEVENT") || response_content.contains("VTODO"));
|
.contains("supported-calendar-component-set")
|
||||||
let has_calendar_resourcetype = response_content.contains("<cal:calendar") || response_content.contains("<c:calendar");
|
&& (response_content.contains("VEVENT")
|
||||||
|
|| response_content.contains("VTODO"));
|
||||||
|
let has_calendar_resourcetype = response_content.contains("<cal:calendar")
|
||||||
|
|| response_content.contains("<c:calendar");
|
||||||
|
|
||||||
let is_calendar = has_supported_components || has_calendar_resourcetype;
|
let is_calendar = has_supported_components || has_calendar_resourcetype;
|
||||||
|
|
||||||
// Also check resourcetype for collection
|
// Also check resourcetype for collection
|
||||||
let has_collection = response_content.contains("<d:collection") || response_content.contains("<collection");
|
let has_collection = response_content.contains("<d:collection")
|
||||||
|
|| response_content.contains("<collection");
|
||||||
|
|
||||||
if is_calendar && has_collection {
|
if is_calendar && has_collection {
|
||||||
// Exclude system directories like inbox, outbox, and root calendar directories
|
// Exclude system directories like inbox, outbox, and root calendar directories
|
||||||
if !href.contains("/inbox/") && !href.contains("/outbox/") &&
|
if !href.contains("/inbox/")
|
||||||
!href.ends_with("/calendars/") && href.ends_with('/') {
|
&& !href.contains("/outbox/")
|
||||||
|
&& !href.ends_with("/calendars/")
|
||||||
|
&& href.ends_with('/')
|
||||||
|
{
|
||||||
println!("📅 Found calendar collection: {}", href);
|
println!("📅 Found calendar collection: {}", href);
|
||||||
calendar_paths.push(href);
|
calendar_paths.push(href);
|
||||||
} else {
|
} else {
|
||||||
@@ -595,7 +672,11 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse iCal datetime format
|
/// Parse iCal datetime format
|
||||||
fn parse_datetime(&self, datetime_str: &str, _original_property: Option<&String>) -> Result<DateTime<Utc>, CalDAVError> {
|
fn parse_datetime(
|
||||||
|
&self,
|
||||||
|
datetime_str: &str,
|
||||||
|
_original_property: Option<&String>,
|
||||||
|
) -> Result<DateTime<Utc>, CalDAVError> {
|
||||||
use chrono::TimeZone;
|
use chrono::TimeZone;
|
||||||
|
|
||||||
// Handle different iCal datetime formats
|
// Handle different iCal datetime formats
|
||||||
@@ -603,9 +684,9 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
// Try different parsing formats
|
// Try different parsing formats
|
||||||
let formats = [
|
let formats = [
|
||||||
"%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z
|
"%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z
|
||||||
"%Y%m%dT%H%M%S", // Local format: 20231225T120000
|
"%Y%m%dT%H%M%S", // Local format: 20231225T120000
|
||||||
"%Y%m%d", // Date only: 20231225
|
"%Y%m%d", // Date only: 20231225
|
||||||
];
|
];
|
||||||
|
|
||||||
for format in &formats {
|
for format in &formats {
|
||||||
@@ -617,7 +698,10 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(CalDAVError::ParseError(format!("Unable to parse datetime: {}", datetime_str)))
|
Err(CalDAVError::ParseError(format!(
|
||||||
|
"Unable to parse datetime: {}",
|
||||||
|
datetime_str
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse EXDATE properties from an iCal event
|
/// Parse EXDATE properties from an iCal event
|
||||||
@@ -643,7 +727,12 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new calendar on the CalDAV server using MKCALENDAR
|
/// Create a new calendar on the CalDAV server using MKCALENDAR
|
||||||
pub async fn create_calendar(&self, name: &str, description: Option<&str>, color: Option<&str>) -> Result<(), CalDAVError> {
|
pub async fn create_calendar(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
color: Option<&str>,
|
||||||
|
) -> Result<(), CalDAVError> {
|
||||||
// Sanitize calendar name for URL path
|
// Sanitize calendar name for URL path
|
||||||
let calendar_id = name
|
let calendar_id = name
|
||||||
.chars()
|
.chars()
|
||||||
@@ -652,17 +741,27 @@ impl CalDAVClient {
|
|||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
|
|
||||||
let calendar_path = format!("/calendars/{}/{}/", self.config.username, calendar_id);
|
let calendar_path = format!("/calendars/{}/{}/", self.config.username, calendar_id);
|
||||||
let full_url = format!("{}{}", self.config.server_url.trim_end_matches('/'), calendar_path);
|
let full_url = format!(
|
||||||
|
"{}{}",
|
||||||
|
self.config.server_url.trim_end_matches('/'),
|
||||||
|
calendar_path
|
||||||
|
);
|
||||||
|
|
||||||
// Build color property if provided
|
// Build color property if provided
|
||||||
let color_property = if let Some(color) = color {
|
let color_property = if let Some(color) = color {
|
||||||
format!(r#"<ic:calendar-color xmlns:ic="http://apple.com/ns/ical/">{}</ic:calendar-color>"#, color)
|
format!(
|
||||||
|
r#"<ic:calendar-color xmlns:ic="http://apple.com/ns/ical/">{}</ic:calendar-color>"#,
|
||||||
|
color
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
let description_property = if let Some(desc) = description {
|
let description_property = if let Some(desc) = description {
|
||||||
format!(r#"<c:calendar-description xmlns:c="urn:ietf:params:xml:ns:caldav">{}</c:calendar-description>"#, desc)
|
format!(
|
||||||
|
r#"<c:calendar-description xmlns:c="urn:ietf:params:xml:ns:caldav">{}</c:calendar-description>"#,
|
||||||
|
desc
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
@@ -688,10 +787,17 @@ impl CalDAVClient {
|
|||||||
println!("Creating calendar at: {}", full_url);
|
println!("Creating calendar at: {}", full_url);
|
||||||
println!("MKCALENDAR body: {}", mkcalendar_body);
|
println!("MKCALENDAR body: {}", mkcalendar_body);
|
||||||
|
|
||||||
let response = self.http_client
|
let response = self
|
||||||
.request(reqwest::Method::from_bytes(b"MKCALENDAR").unwrap(), &full_url)
|
.http_client
|
||||||
|
.request(
|
||||||
|
reqwest::Method::from_bytes(b"MKCALENDAR").unwrap(),
|
||||||
|
&full_url,
|
||||||
|
)
|
||||||
.header("Content-Type", "application/xml; charset=utf-8")
|
.header("Content-Type", "application/xml; charset=utf-8")
|
||||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", self.config.get_basic_auth()),
|
||||||
|
)
|
||||||
.body(mkcalendar_body)
|
.body(mkcalendar_body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -721,14 +827,22 @@ impl CalDAVClient {
|
|||||||
} else {
|
} else {
|
||||||
calendar_path
|
calendar_path
|
||||||
};
|
};
|
||||||
format!("{}{}", self.config.server_url.trim_end_matches('/'), clean_path)
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
self.config.server_url.trim_end_matches('/'),
|
||||||
|
clean_path
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("Deleting calendar at: {}", full_url);
|
println!("Deleting calendar at: {}", full_url);
|
||||||
|
|
||||||
let response = self.http_client
|
let response = self
|
||||||
|
.http_client
|
||||||
.delete(&full_url)
|
.delete(&full_url)
|
||||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", self.config.get_basic_auth()),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
|
.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
|
||||||
@@ -747,7 +861,11 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new event in a CalDAV calendar
|
/// Create a new event in a CalDAV calendar
|
||||||
pub async fn create_event(&self, calendar_path: &str, event: &CalendarEvent) -> Result<String, CalDAVError> {
|
pub async fn create_event(
|
||||||
|
&self,
|
||||||
|
calendar_path: &str,
|
||||||
|
event: &CalendarEvent,
|
||||||
|
) -> Result<String, CalDAVError> {
|
||||||
// Generate a unique filename for the event (using UID + .ics extension)
|
// Generate a unique filename for the event (using UID + .ics extension)
|
||||||
let event_filename = format!("{}.ics", event.uid);
|
let event_filename = format!("{}.ics", event.uid);
|
||||||
|
|
||||||
@@ -790,9 +908,13 @@ impl CalDAVClient {
|
|||||||
let _lock = CALDAV_HTTP_MUTEX.lock().await;
|
let _lock = CALDAV_HTTP_MUTEX.lock().await;
|
||||||
println!("📡 Lock acquired, sending CREATE request to CalDAV server...");
|
println!("📡 Lock acquired, sending CREATE request to CalDAV server...");
|
||||||
|
|
||||||
let response = self.http_client
|
let response = self
|
||||||
|
.http_client
|
||||||
.put(&full_url)
|
.put(&full_url)
|
||||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", self.config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("Content-Type", "text/calendar; charset=utf-8")
|
.header("Content-Type", "text/calendar; charset=utf-8")
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
.body(ical_data)
|
.body(ical_data)
|
||||||
@@ -814,13 +936,22 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update an existing event on the CalDAV server
|
/// Update an existing event on the CalDAV server
|
||||||
pub async fn update_event(&self, calendar_path: &str, event: &CalendarEvent, event_href: &str) -> Result<(), CalDAVError> {
|
pub async fn update_event(
|
||||||
|
&self,
|
||||||
|
calendar_path: &str,
|
||||||
|
event: &CalendarEvent,
|
||||||
|
event_href: &str,
|
||||||
|
) -> Result<(), CalDAVError> {
|
||||||
// Construct the full URL for the event
|
// Construct the full URL for the event
|
||||||
let full_url = if event_href.starts_with("http") {
|
let full_url = if event_href.starts_with("http") {
|
||||||
event_href.to_string()
|
event_href.to_string()
|
||||||
} else if event_href.starts_with("/dav.php") {
|
} else if event_href.starts_with("/dav.php") {
|
||||||
// Event href is already a full path, combine with base server URL (without /dav.php)
|
// Event href is already a full path, combine with base server URL (without /dav.php)
|
||||||
let base_url = self.config.server_url.trim_end_matches('/').trim_end_matches("/dav.php");
|
let base_url = self
|
||||||
|
.config
|
||||||
|
.server_url
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.trim_end_matches("/dav.php");
|
||||||
format!("{}{}", base_url, event_href)
|
format!("{}{}", base_url, event_href)
|
||||||
} else {
|
} else {
|
||||||
// Event href is just a filename, combine with calendar path
|
// Event href is just a filename, combine with calendar path
|
||||||
@@ -829,7 +960,12 @@ impl CalDAVClient {
|
|||||||
} else {
|
} else {
|
||||||
calendar_path
|
calendar_path
|
||||||
};
|
};
|
||||||
format!("{}/dav.php{}/{}", self.config.server_url.trim_end_matches('/'), clean_path, event_href)
|
format!(
|
||||||
|
"{}/dav.php{}/{}",
|
||||||
|
self.config.server_url.trim_end_matches('/'),
|
||||||
|
clean_path,
|
||||||
|
event_href
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("📝 Updating event at: {}", full_url);
|
println!("📝 Updating event at: {}", full_url);
|
||||||
@@ -846,9 +982,13 @@ impl CalDAVClient {
|
|||||||
println!("🔗 PUT URL: {}", full_url);
|
println!("🔗 PUT URL: {}", full_url);
|
||||||
println!("🔍 Request headers: Authorization: Basic [HIDDEN], Content-Type: text/calendar; charset=utf-8");
|
println!("🔍 Request headers: Authorization: Basic [HIDDEN], Content-Type: text/calendar; charset=utf-8");
|
||||||
|
|
||||||
let response = self.http_client
|
let response = self
|
||||||
|
.http_client
|
||||||
.put(&full_url)
|
.put(&full_url)
|
||||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", self.config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("Content-Type", "text/calendar; charset=utf-8")
|
.header("Content-Type", "text/calendar; charset=utf-8")
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
@@ -862,7 +1002,10 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
println!("Event update response status: {}", response.status());
|
println!("Event update response status: {}", response.status());
|
||||||
|
|
||||||
if response.status().is_success() || response.status().as_u16() == 201 || response.status().as_u16() == 204 {
|
if response.status().is_success()
|
||||||
|
|| response.status().as_u16() == 201
|
||||||
|
|| response.status().as_u16() == 204
|
||||||
|
{
|
||||||
println!("✅ Event updated successfully");
|
println!("✅ Event updated successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -878,13 +1021,10 @@ impl CalDAVClient {
|
|||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
|
|
||||||
// Format datetime for iCal (YYYYMMDDTHHMMSSZ format)
|
// Format datetime for iCal (YYYYMMDDTHHMMSSZ format)
|
||||||
let format_datetime = |dt: &DateTime<Utc>| -> String {
|
let format_datetime =
|
||||||
dt.format("%Y%m%dT%H%M%SZ").to_string()
|
|dt: &DateTime<Utc>| -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() };
|
||||||
};
|
|
||||||
|
|
||||||
let format_date = |dt: &DateTime<Utc>| -> String {
|
let format_date = |dt: &DateTime<Utc>| -> String { dt.format("%Y%m%d").to_string() };
|
||||||
dt.format("%Y%m%d").to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start building the iCal event
|
// Start building the iCal event
|
||||||
let mut ical = String::new();
|
let mut ical = String::new();
|
||||||
@@ -899,7 +1039,10 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
// Start and end times
|
// Start and end times
|
||||||
if event.all_day {
|
if event.all_day {
|
||||||
ical.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", format_date(&event.dtstart)));
|
ical.push_str(&format!(
|
||||||
|
"DTSTART;VALUE=DATE:{}\r\n",
|
||||||
|
format_date(&event.dtstart)
|
||||||
|
));
|
||||||
if let Some(end) = &event.dtend {
|
if let Some(end) = &event.dtend {
|
||||||
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end)));
|
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end)));
|
||||||
}
|
}
|
||||||
@@ -916,7 +1059,10 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(description) = &event.description {
|
if let Some(description) = &event.description {
|
||||||
ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(description)));
|
ical.push_str(&format!(
|
||||||
|
"DESCRIPTION:{}\r\n",
|
||||||
|
self.escape_ical_text(description)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(location) = &event.location {
|
if let Some(location) = &event.location {
|
||||||
@@ -951,7 +1097,10 @@ impl CalDAVClient {
|
|||||||
// Categories
|
// Categories
|
||||||
if !event.categories.is_empty() {
|
if !event.categories.is_empty() {
|
||||||
let categories = event.categories.join(",");
|
let categories = event.categories.join(",");
|
||||||
ical.push_str(&format!("CATEGORIES:{}\r\n", self.escape_ical_text(&categories)));
|
ical.push_str(&format!(
|
||||||
|
"CATEGORIES:{}\r\n",
|
||||||
|
self.escape_ical_text(&categories)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creation and modification times
|
// Creation and modification times
|
||||||
@@ -989,9 +1138,15 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(description) = &alarm.description {
|
if let Some(description) = &alarm.description {
|
||||||
ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(description)));
|
ical.push_str(&format!(
|
||||||
|
"DESCRIPTION:{}\r\n",
|
||||||
|
self.escape_ical_text(description)
|
||||||
|
));
|
||||||
} else if let Some(summary) = &event.summary {
|
} else if let Some(summary) = &event.summary {
|
||||||
ical.push_str(&format!("DESCRIPTION:{}\r\n", self.escape_ical_text(summary)));
|
ical.push_str(&format!(
|
||||||
|
"DESCRIPTION:{}\r\n",
|
||||||
|
self.escape_ical_text(summary)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
ical.push_str("END:VALARM\r\n");
|
ical.push_str("END:VALARM\r\n");
|
||||||
@@ -1005,7 +1160,10 @@ impl CalDAVClient {
|
|||||||
// Exception dates (EXDATE)
|
// Exception dates (EXDATE)
|
||||||
for exception_date in &event.exdate {
|
for exception_date in &event.exdate {
|
||||||
if event.all_day {
|
if event.all_day {
|
||||||
ical.push_str(&format!("EXDATE;VALUE=DATE:{}\r\n", format_date(exception_date)));
|
ical.push_str(&format!(
|
||||||
|
"EXDATE;VALUE=DATE:{}\r\n",
|
||||||
|
format_date(exception_date)
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
ical.push_str(&format!("EXDATE:{}\r\n", format_datetime(exception_date)));
|
ical.push_str(&format!("EXDATE:{}\r\n", format_datetime(exception_date)));
|
||||||
}
|
}
|
||||||
@@ -1027,13 +1185,21 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete an event from a CalDAV calendar
|
/// Delete an event from a CalDAV calendar
|
||||||
pub async fn delete_event(&self, calendar_path: &str, event_href: &str) -> Result<(), CalDAVError> {
|
pub async fn delete_event(
|
||||||
|
&self,
|
||||||
|
calendar_path: &str,
|
||||||
|
event_href: &str,
|
||||||
|
) -> Result<(), CalDAVError> {
|
||||||
// Construct the full URL for the event
|
// Construct the full URL for the event
|
||||||
let full_url = if event_href.starts_with("http") {
|
let full_url = if event_href.starts_with("http") {
|
||||||
event_href.to_string()
|
event_href.to_string()
|
||||||
} else if event_href.starts_with("/dav.php") {
|
} else if event_href.starts_with("/dav.php") {
|
||||||
// Event href is already a full path, combine with base server URL (without /dav.php)
|
// Event href is already a full path, combine with base server URL (without /dav.php)
|
||||||
let base_url = self.config.server_url.trim_end_matches('/').trim_end_matches("/dav.php");
|
let base_url = self
|
||||||
|
.config
|
||||||
|
.server_url
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.trim_end_matches("/dav.php");
|
||||||
format!("{}{}", base_url, event_href)
|
format!("{}{}", base_url, event_href)
|
||||||
} else {
|
} else {
|
||||||
// Event href is just a filename, combine with calendar path
|
// Event href is just a filename, combine with calendar path
|
||||||
@@ -1042,7 +1208,12 @@ impl CalDAVClient {
|
|||||||
} else {
|
} else {
|
||||||
calendar_path
|
calendar_path
|
||||||
};
|
};
|
||||||
format!("{}/dav.php{}/{}", self.config.server_url.trim_end_matches('/'), clean_path, event_href)
|
format!(
|
||||||
|
"{}/dav.php{}/{}",
|
||||||
|
self.config.server_url.trim_end_matches('/'),
|
||||||
|
clean_path,
|
||||||
|
event_href
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("Deleting event at: {}", full_url);
|
println!("Deleting event at: {}", full_url);
|
||||||
@@ -1051,9 +1222,13 @@ impl CalDAVClient {
|
|||||||
let _lock = CALDAV_HTTP_MUTEX.lock().await;
|
let _lock = CALDAV_HTTP_MUTEX.lock().await;
|
||||||
println!("📡 Lock acquired, sending DELETE request to CalDAV server...");
|
println!("📡 Lock acquired, sending DELETE request to CalDAV server...");
|
||||||
|
|
||||||
let response = self.http_client
|
let response = self
|
||||||
|
.http_client
|
||||||
.delete(&full_url)
|
.delete(&full_url)
|
||||||
.header("Authorization", format!("Basic {}", self.config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", self.config.get_basic_auth()),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
|
.map_err(|e| CalDAVError::ParseError(e.to_string()))?;
|
||||||
@@ -1103,8 +1278,11 @@ mod tests {
|
|||||||
/// This test requires a valid .env file and a calendar with some events
|
/// This test requires a valid .env file and a calendar with some events
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_fetch_calendar_events() {
|
async fn test_fetch_calendar_events() {
|
||||||
let config = CalDAVConfig::from_env()
|
let config = CalDAVConfig::new(
|
||||||
.expect("Failed to load CalDAV config from environment");
|
"https://example.com".to_string(),
|
||||||
|
"test_user".to_string(),
|
||||||
|
"test_password".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
@@ -1147,7 +1325,10 @@ mod tests {
|
|||||||
for event in &events {
|
for event in &events {
|
||||||
assert!(!event.uid.is_empty(), "Event UID should not be empty");
|
assert!(!event.uid.is_empty(), "Event UID should not be empty");
|
||||||
// All events should have a start time
|
// All events should have a start time
|
||||||
assert!(event.dtstart > DateTime::from_timestamp(0, 0).unwrap(), "Event should have valid start time");
|
assert!(
|
||||||
|
event.dtstart > DateTime::from_timestamp(0, 0).unwrap(),
|
||||||
|
"Event should have valid start time"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("\n✓ Calendar event fetching test passed!");
|
println!("\n✓ Calendar event fetching test passed!");
|
||||||
@@ -1192,11 +1373,11 @@ END:VCALENDAR"#;
|
|||||||
username: "test".to_string(),
|
username: "test".to_string(),
|
||||||
password: "test".to_string(),
|
password: "test".to_string(),
|
||||||
calendar_path: None,
|
calendar_path: None,
|
||||||
tasks_path: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
let events = client.parse_ical_data(sample_ical)
|
let events = client
|
||||||
|
.parse_ical_data(sample_ical)
|
||||||
.expect("Should be able to parse sample iCal data");
|
.expect("Should be able to parse sample iCal data");
|
||||||
|
|
||||||
assert_eq!(events.len(), 1);
|
assert_eq!(events.len(), 1);
|
||||||
@@ -1223,23 +1404,25 @@ END:VCALENDAR"#;
|
|||||||
username: "test".to_string(),
|
username: "test".to_string(),
|
||||||
password: "test".to_string(),
|
password: "test".to_string(),
|
||||||
calendar_path: None,
|
calendar_path: None,
|
||||||
tasks_path: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Test UTC format
|
// Test UTC format
|
||||||
let dt1 = client.parse_datetime("20231225T120000Z", None)
|
let dt1 = client
|
||||||
|
.parse_datetime("20231225T120000Z", None)
|
||||||
.expect("Should parse UTC datetime");
|
.expect("Should parse UTC datetime");
|
||||||
println!("Parsed UTC datetime: {}", dt1);
|
println!("Parsed UTC datetime: {}", dt1);
|
||||||
|
|
||||||
// Test date-only format (should be treated as all-day)
|
// Test date-only format (should be treated as all-day)
|
||||||
let dt2 = client.parse_datetime("20231225", None)
|
let dt2 = client
|
||||||
|
.parse_datetime("20231225", None)
|
||||||
.expect("Should parse date-only");
|
.expect("Should parse date-only");
|
||||||
println!("Parsed date-only: {}", dt2);
|
println!("Parsed date-only: {}", dt2);
|
||||||
|
|
||||||
// Test local format
|
// Test local format
|
||||||
let dt3 = client.parse_datetime("20231225T120000", None)
|
let dt3 = client
|
||||||
|
.parse_datetime("20231225T120000", None)
|
||||||
.expect("Should parse local datetime");
|
.expect("Should parse local datetime");
|
||||||
println!("Parsed local datetime: {}", dt3);
|
println!("Parsed local datetime: {}", dt3);
|
||||||
|
|
||||||
@@ -1259,5 +1442,4 @@ END:VCALENDAR"#;
|
|||||||
|
|
||||||
println!("✓ Event enum tests passed!");
|
println!("✓ Event enum tests passed!");
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
use base64::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::env;
|
use std::env;
|
||||||
use base64::prelude::*;
|
|
||||||
|
|
||||||
/// Configuration for CalDAV server connection and authentication.
|
/// Configuration for CalDAV server connection and authentication.
|
||||||
///
|
///
|
||||||
@@ -17,14 +17,16 @@ use base64::prelude::*;
|
|||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// # use calendar_backend::config::CalDAVConfig;
|
/// # use calendar_backend::config::CalDAVConfig;
|
||||||
/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
|
/// let config = CalDAVConfig {
|
||||||
/// // Load configuration from environment variables
|
/// server_url: "https://caldav.example.com".to_string(),
|
||||||
/// let config = CalDAVConfig::from_env()?;
|
/// username: "user@example.com".to_string(),
|
||||||
|
/// password: "password".to_string(),
|
||||||
|
/// calendar_path: None,
|
||||||
|
/// tasks_path: None,
|
||||||
|
/// };
|
||||||
///
|
///
|
||||||
/// // Use the configuration for HTTP requests
|
/// // Use the configuration for HTTP requests
|
||||||
/// let auth_header = format!("Basic {}", config.get_basic_auth());
|
/// let auth_header = format!("Basic {}", config.get_basic_auth());
|
||||||
/// # Ok(())
|
|
||||||
/// # }
|
|
||||||
/// ```
|
/// ```
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CalDAVConfig {
|
pub struct CalDAVConfig {
|
||||||
@@ -41,74 +43,37 @@ pub struct CalDAVConfig {
|
|||||||
|
|
||||||
/// Optional path to the calendar collection on the server
|
/// Optional path to the calendar collection on the server
|
||||||
///
|
///
|
||||||
/// If not provided, the client will need to discover available calendars
|
/// If not provided, the client will discover available calendars
|
||||||
/// through CalDAV PROPFIND requests
|
/// through CalDAV PROPFIND requests
|
||||||
pub calendar_path: Option<String>,
|
pub calendar_path: Option<String>,
|
||||||
|
|
||||||
/// Optional path to the tasks/todo collection on the server
|
|
||||||
///
|
|
||||||
/// Some CalDAV servers store tasks separately from calendar events
|
|
||||||
pub tasks_path: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CalDAVConfig {
|
impl CalDAVConfig {
|
||||||
/// Creates a new CalDAVConfig by loading values from environment variables.
|
/// Creates a new CalDAVConfig with the given credentials.
|
||||||
///
|
///
|
||||||
/// This method will attempt to load a `.env` file from the current directory
|
/// # Arguments
|
||||||
/// and then read the following required environment variables:
|
|
||||||
///
|
///
|
||||||
/// - `CALDAV_SERVER_URL`: The CalDAV server base URL
|
/// * `server_url` - The base URL of the CalDAV server
|
||||||
/// - `CALDAV_USERNAME`: Username for authentication
|
/// * `username` - Username for authentication
|
||||||
/// - `CALDAV_PASSWORD`: Password for authentication
|
/// * `password` - Password for authentication
|
||||||
///
|
|
||||||
/// Optional environment variables:
|
|
||||||
///
|
|
||||||
/// - `CALDAV_CALENDAR_PATH`: Path to calendar collection
|
|
||||||
/// - `CALDAV_TASKS_PATH`: Path to tasks collection
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns `ConfigError::MissingVar` if any required environment variable
|
|
||||||
/// is not set or cannot be read.
|
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// # use calendar_backend::config::CalDAVConfig;
|
/// # use calendar_backend::config::CalDAVConfig;
|
||||||
///
|
/// let config = CalDAVConfig::new(
|
||||||
/// match CalDAVConfig::from_env() {
|
/// "https://caldav.example.com".to_string(),
|
||||||
/// Ok(config) => {
|
/// "user@example.com".to_string(),
|
||||||
/// println!("Loaded config for server: {}", config.server_url);
|
/// "password".to_string()
|
||||||
/// }
|
/// );
|
||||||
/// Err(e) => {
|
|
||||||
/// eprintln!("Failed to load config: {}", e);
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
/// ```
|
||||||
pub fn from_env() -> Result<Self, ConfigError> {
|
pub fn new(server_url: String, username: String, password: String) -> Self {
|
||||||
// Attempt to load .env file, but don't fail if it doesn't exist
|
Self {
|
||||||
dotenvy::dotenv().ok();
|
|
||||||
|
|
||||||
let server_url = env::var("CALDAV_SERVER_URL")
|
|
||||||
.map_err(|_| ConfigError::MissingVar("CALDAV_SERVER_URL".to_string()))?;
|
|
||||||
|
|
||||||
let username = env::var("CALDAV_USERNAME")
|
|
||||||
.map_err(|_| ConfigError::MissingVar("CALDAV_USERNAME".to_string()))?;
|
|
||||||
|
|
||||||
let password = env::var("CALDAV_PASSWORD")
|
|
||||||
.map_err(|_| ConfigError::MissingVar("CALDAV_PASSWORD".to_string()))?;
|
|
||||||
|
|
||||||
// Optional paths - it's fine if these are not set
|
|
||||||
let calendar_path = env::var("CALDAV_CALENDAR_PATH").ok();
|
|
||||||
let tasks_path = env::var("CALDAV_TASKS_PATH").ok();
|
|
||||||
|
|
||||||
Ok(CalDAVConfig {
|
|
||||||
server_url,
|
server_url,
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
calendar_path,
|
calendar_path: env::var("CALDAV_CALENDAR_PATH").ok(), // Optional override from env
|
||||||
tasks_path,
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a Base64-encoded string for HTTP Basic Authentication.
|
/// Generates a Base64-encoded string for HTTP Basic Authentication.
|
||||||
@@ -174,7 +139,6 @@ mod tests {
|
|||||||
username: "testuser".to_string(),
|
username: "testuser".to_string(),
|
||||||
password: "testpass".to_string(),
|
password: "testpass".to_string(),
|
||||||
calendar_path: None,
|
calendar_path: None,
|
||||||
tasks_path: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let auth = config.get_basic_auth();
|
let auth = config.get_basic_auth();
|
||||||
@@ -192,9 +156,12 @@ mod tests {
|
|||||||
/// Run with: `cargo test test_baikal_auth`
|
/// Run with: `cargo test test_baikal_auth`
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_baikal_auth() {
|
async fn test_baikal_auth() {
|
||||||
// Load config from .env
|
// Use test config - update these values to test with real server
|
||||||
let config = CalDAVConfig::from_env()
|
let config = CalDAVConfig::new(
|
||||||
.expect("Failed to load CalDAV config from environment");
|
"https://example.com".to_string(),
|
||||||
|
"test_user".to_string(),
|
||||||
|
"test_password".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
println!("Testing authentication to: {}", config.server_url);
|
println!("Testing authentication to: {}", config.server_url);
|
||||||
|
|
||||||
@@ -204,7 +171,10 @@ mod tests {
|
|||||||
// Make a simple OPTIONS request to test authentication
|
// Make a simple OPTIONS request to test authentication
|
||||||
let response = client
|
let response = client
|
||||||
.request(reqwest::Method::OPTIONS, &config.server_url)
|
.request(reqwest::Method::OPTIONS, &config.server_url)
|
||||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -222,9 +192,9 @@ mod tests {
|
|||||||
|
|
||||||
// For Baikal/CalDAV servers, we should see DAV headers
|
// For Baikal/CalDAV servers, we should see DAV headers
|
||||||
assert!(
|
assert!(
|
||||||
response.headers().contains_key("dav") ||
|
response.headers().contains_key("dav")
|
||||||
response.headers().contains_key("DAV") ||
|
|| response.headers().contains_key("DAV")
|
||||||
response.status().is_success(),
|
|| response.status().is_success(),
|
||||||
"Server doesn't appear to be a CalDAV server - missing DAV headers"
|
"Server doesn't appear to be a CalDAV server - missing DAV headers"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -238,8 +208,12 @@ mod tests {
|
|||||||
/// Run with: `cargo test test_propfind_calendars`
|
/// Run with: `cargo test test_propfind_calendars`
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_propfind_calendars() {
|
async fn test_propfind_calendars() {
|
||||||
let config = CalDAVConfig::from_env()
|
// Use test config - update these values to test with real server
|
||||||
.expect("Failed to load CalDAV config from environment");
|
let config = CalDAVConfig::new(
|
||||||
|
"https://example.com".to_string(),
|
||||||
|
"test_user".to_string(),
|
||||||
|
"test_password".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
@@ -255,8 +229,14 @@ mod tests {
|
|||||||
</d:propfind>"#;
|
</d:propfind>"#;
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url)
|
.request(
|
||||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
reqwest::Method::from_bytes(b"PROPFIND").unwrap(),
|
||||||
|
&config.server_url,
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("Basic {}", config.get_basic_auth()),
|
||||||
|
)
|
||||||
.header("Content-Type", "application/xml")
|
.header("Content-Type", "application/xml")
|
||||||
.header("Depth", "1")
|
.header("Depth", "1")
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
.header("User-Agent", "calendar-app/0.1.0")
|
||||||
@@ -279,7 +259,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// The response should contain XML with calendar information
|
// The response should contain XML with calendar information
|
||||||
assert!(body.contains("calendar"), "Response should contain calendar information");
|
assert!(
|
||||||
|
body.contains("calendar"),
|
||||||
|
"Response should contain calendar information"
|
||||||
|
);
|
||||||
|
|
||||||
println!("✓ PROPFIND calendars test passed!");
|
println!("✓ PROPFIND calendars test passed!");
|
||||||
}
|
}
|
||||||
|
|||||||
313
backend/src/db.rs
Normal file
313
backend/src/db.rs
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
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 last_used_calendar: Option<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,
|
||||||
|
last_used_calendar: 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, last_used_calendar, 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.last_used_calendar)
|
||||||
|
.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 = ?, last_used_calendar = ?, 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(&prefs.last_used_calendar)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.bind(&prefs.user_id)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,12 @@ use crate::calendar::CalDAVClient;
|
|||||||
use crate::config::CalDAVConfig;
|
use crate::config::CalDAVConfig;
|
||||||
|
|
||||||
pub async fn debug_caldav_fetch() -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn debug_caldav_fetch() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let config = CalDAVConfig::from_env()?;
|
// Use debug/test configuration
|
||||||
|
let config = CalDAVConfig::new(
|
||||||
|
"https://example.com".to_string(),
|
||||||
|
"debug_user".to_string(),
|
||||||
|
"debug_password".to_string()
|
||||||
|
);
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
println!("=== DEBUG: CalDAV Fetch ===");
|
println!("=== DEBUG: CalDAV Fetch ===");
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
mod auth;
|
mod auth;
|
||||||
mod calendar;
|
mod calendar;
|
||||||
mod events;
|
mod events;
|
||||||
|
mod preferences;
|
||||||
mod series;
|
mod series;
|
||||||
|
|
||||||
pub use auth::{login, verify_token, get_user_info};
|
pub use auth::{get_user_info, login, verify_token};
|
||||||
pub use calendar::{create_calendar, delete_calendar};
|
pub use calendar::{create_calendar, delete_calendar};
|
||||||
pub use events::{get_calendar_events, refresh_event, create_event, update_event, delete_event};
|
pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event};
|
||||||
pub use series::{create_event_series, update_event_series, delete_event_series};
|
pub use preferences::{get_preferences, logout, update_preferences};
|
||||||
|
pub use series::{create_event_series, delete_event_series, update_event_series};
|
||||||
|
|||||||
@@ -1,33 +1,37 @@
|
|||||||
use axum::{
|
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||||
extract::State,
|
|
||||||
http::HeaderMap,
|
|
||||||
response::Json,
|
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo}};
|
|
||||||
use crate::calendar::CalDAVClient;
|
use crate::calendar::CalDAVClient;
|
||||||
use crate::config::CalDAVConfig;
|
use crate::{
|
||||||
|
models::{ApiError, AuthResponse, CalDAVLoginRequest, CalendarInfo, UserInfo},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
|
pub fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
|
||||||
let auth_header = headers.get("authorization")
|
let auth_header = headers
|
||||||
|
.get("authorization")
|
||||||
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
|
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
|
||||||
|
|
||||||
let auth_str = auth_header.to_str()
|
let auth_str = auth_header
|
||||||
|
.to_str()
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid Authorization header".to_string()))?;
|
.map_err(|_| ApiError::BadRequest("Invalid Authorization header".to_string()))?;
|
||||||
|
|
||||||
if let Some(token) = auth_str.strip_prefix("Bearer ") {
|
if let Some(token) = auth_str.strip_prefix("Bearer ") {
|
||||||
Ok(token.to_string())
|
Ok(token.to_string())
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::BadRequest("Authorization header must be Bearer token".to_string()))
|
Err(ApiError::BadRequest(
|
||||||
|
"Authorization header must be Bearer token".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
|
pub fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
|
||||||
let password_header = headers.get("x-caldav-password")
|
let password_header = headers
|
||||||
|
.get("x-caldav-password")
|
||||||
.ok_or_else(|| ApiError::BadRequest("Missing X-CalDAV-Password header".to_string()))?;
|
.ok_or_else(|| ApiError::BadRequest("Missing X-CalDAV-Password header".to_string()))?;
|
||||||
|
|
||||||
password_header.to_str()
|
password_header
|
||||||
|
.to_str()
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string()))
|
.map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string()))
|
||||||
}
|
}
|
||||||
@@ -41,38 +45,12 @@ pub async fn login(
|
|||||||
println!(" Username: {}", request.username);
|
println!(" Username: {}", request.username);
|
||||||
println!(" Password length: {}", request.password.len());
|
println!(" Password length: {}", request.password.len());
|
||||||
|
|
||||||
// Basic validation
|
// Use the auth service login method which now handles database, sessions, and preferences
|
||||||
if request.username.is_empty() || request.password.is_empty() || request.server_url.is_empty() {
|
let response = state.auth_service.login(request).await?;
|
||||||
return Err(ApiError::BadRequest("Username, password, and server URL are required".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("✅ Input validation passed");
|
println!("✅ Login successful with session management");
|
||||||
|
|
||||||
// Create a token using the auth service
|
Ok(Json(response))
|
||||||
println!("📝 Created CalDAV config");
|
|
||||||
|
|
||||||
// First verify the credentials are valid by attempting to discover calendars
|
|
||||||
let config = CalDAVConfig {
|
|
||||||
server_url: request.server_url.clone(),
|
|
||||||
username: request.username.clone(),
|
|
||||||
password: request.password.clone(),
|
|
||||||
calendar_path: None,
|
|
||||||
tasks_path: None,
|
|
||||||
};
|
|
||||||
let client = CalDAVClient::new(config);
|
|
||||||
client.discover_calendars()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError::Unauthorized(format!("Authentication failed: {}", e)))?;
|
|
||||||
|
|
||||||
let token = state.auth_service.generate_token(&request.username, &request.server_url)?;
|
|
||||||
|
|
||||||
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
|
||||||
|
|
||||||
Ok(Json(AuthResponse {
|
|
||||||
token,
|
|
||||||
username: request.username,
|
|
||||||
server_url: request.server_url,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn verify_token(
|
pub async fn verify_token(
|
||||||
@@ -93,23 +71,30 @@ pub async fn get_user_info(
|
|||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config.clone());
|
let client = CalDAVClient::new(config.clone());
|
||||||
|
|
||||||
// Discover calendars
|
// Discover calendars
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
println!("✅ Authentication successful! Found {} calendars", calendar_paths.len());
|
println!(
|
||||||
|
"✅ Authentication successful! Found {} calendars",
|
||||||
|
calendar_paths.len()
|
||||||
|
);
|
||||||
|
|
||||||
let calendars: Vec<CalendarInfo> = calendar_paths.iter().map(|path| {
|
let calendars: Vec<CalendarInfo> = calendar_paths
|
||||||
CalendarInfo {
|
.iter()
|
||||||
|
.map(|path| CalendarInfo {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
display_name: extract_calendar_name(path),
|
display_name: extract_calendar_name(path),
|
||||||
color: generate_calendar_color(path),
|
color: generate_calendar_color(path),
|
||||||
}
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(UserInfo {
|
Ok(Json(UserInfo {
|
||||||
username: config.username,
|
username: config.username,
|
||||||
@@ -128,10 +113,9 @@ fn generate_calendar_color(path: &str) -> String {
|
|||||||
|
|
||||||
// Define a set of pleasant colors
|
// Define a set of pleasant colors
|
||||||
let colors = [
|
let colors = [
|
||||||
"#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6",
|
"#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#06B6D4", "#84CC16", "#F97316",
|
||||||
"#06B6D4", "#84CC16", "#F97316", "#EC4899", "#6366F1",
|
"#EC4899", "#6366F1", "#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626", "#7C3AED",
|
||||||
"#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626",
|
"#059669", "#D97706", "#BE185D", "#4F46E5",
|
||||||
"#7C3AED", "#059669", "#D97706", "#BE185D", "#4F46E5"
|
|
||||||
];
|
];
|
||||||
|
|
||||||
colors[(hash as usize) % colors.len()].to_string()
|
colors[(hash as usize) % colors.len()].to_string()
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
use axum::{
|
use axum::{extract::State, http::HeaderMap, response::Json};
|
||||||
extract::State,
|
|
||||||
http::HeaderMap,
|
|
||||||
response::Json,
|
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{AppState, models::{ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse}};
|
|
||||||
use crate::calendar::CalDAVClient;
|
use crate::calendar::CalDAVClient;
|
||||||
|
use crate::{
|
||||||
|
models::{
|
||||||
|
ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest,
|
||||||
|
DeleteCalendarResponse,
|
||||||
|
},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
use super::auth::{extract_bearer_token, extract_password_header};
|
use super::auth::{extract_bearer_token, extract_password_header};
|
||||||
|
|
||||||
@@ -20,22 +22,36 @@ pub async fn create_calendar(
|
|||||||
|
|
||||||
// Validate request
|
// Validate request
|
||||||
if request.name.trim().is_empty() {
|
if request.name.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest("Calendar name is required".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Calendar name is required".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Create calendar on CalDAV server
|
// Create calendar on CalDAV server
|
||||||
match client.create_calendar(&request.name, request.description.as_deref(), request.color.as_deref()).await {
|
match client
|
||||||
|
.create_calendar(
|
||||||
|
&request.name,
|
||||||
|
request.description.as_deref(),
|
||||||
|
request.color.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(_) => Ok(Json(CreateCalendarResponse {
|
Ok(_) => Ok(Json(CreateCalendarResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Calendar created successfully".to_string(),
|
message: "Calendar created successfully".to_string(),
|
||||||
})),
|
})),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to create calendar: {}", e);
|
eprintln!("Failed to create calendar: {}", e);
|
||||||
Err(ApiError::Internal(format!("Failed to create calendar: {}", e)))
|
Err(ApiError::Internal(format!(
|
||||||
|
"Failed to create calendar: {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,11 +66,15 @@ pub async fn delete_calendar(
|
|||||||
|
|
||||||
// Validate request
|
// Validate request
|
||||||
if request.path.trim().is_empty() {
|
if request.path.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest("Calendar path is required".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Calendar path is required".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Delete calendar on CalDAV server
|
// Delete calendar on CalDAV server
|
||||||
@@ -65,7 +85,10 @@ pub async fn delete_calendar(
|
|||||||
})),
|
})),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to delete calendar: {}", e);
|
eprintln!("Failed to delete calendar: {}", e);
|
||||||
Err(ApiError::Internal(format!("Failed to delete calendar: {}", e)))
|
Err(ApiError::Internal(format!(
|
||||||
|
"Failed to delete calendar: {}",
|
||||||
|
e
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,23 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{State, Query, Path},
|
extract::{Path, Query, State},
|
||||||
http::HeaderMap,
|
http::HeaderMap,
|
||||||
response::Json,
|
response::Json,
|
||||||
};
|
};
|
||||||
|
use chrono::Datelike;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use chrono::Datelike;
|
|
||||||
|
|
||||||
use calendar_models::{VEvent, EventStatus, EventClass, CalendarUser, Attendee, VAlarm, AlarmAction, AlarmTrigger};
|
|
||||||
use crate::{AppState, models::{ApiError, DeleteEventRequest, DeleteEventResponse, CreateEventRequest, CreateEventResponse, UpdateEventRequest, UpdateEventResponse}};
|
|
||||||
use crate::calendar::{CalDAVClient, CalendarEvent};
|
use crate::calendar::{CalDAVClient, CalendarEvent};
|
||||||
|
use crate::{
|
||||||
|
models::{
|
||||||
|
ApiError, CreateEventRequest, CreateEventResponse, DeleteEventRequest, DeleteEventResponse,
|
||||||
|
UpdateEventRequest, UpdateEventResponse,
|
||||||
|
},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
use calendar_models::{
|
||||||
|
AlarmAction, AlarmTrigger, Attendee, CalendarUser, EventClass, EventStatus, VAlarm, VEvent,
|
||||||
|
};
|
||||||
|
|
||||||
use super::auth::{extract_bearer_token, extract_password_header};
|
use super::auth::{extract_bearer_token, extract_password_header};
|
||||||
|
|
||||||
@@ -30,11 +38,14 @@ pub async fn get_calendar_events(
|
|||||||
println!("🔑 API call with password length: {}", password.len());
|
println!("🔑 API call with password length: {}", password.len());
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Discover calendars if needed
|
// Discover calendars if needed
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
@@ -54,7 +65,10 @@ pub async fn get_calendar_events(
|
|||||||
all_events.extend(events);
|
all_events.extend(events);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e);
|
eprintln!(
|
||||||
|
"Failed to fetch events from calendar {}: {}",
|
||||||
|
calendar_path, e
|
||||||
|
);
|
||||||
// Continue with other calendars instead of failing completely
|
// Continue with other calendars instead of failing completely
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,10 +76,54 @@ pub async fn get_calendar_events(
|
|||||||
|
|
||||||
// If year and month are specified, filter events
|
// If year and month are specified, filter events
|
||||||
if let (Some(year), Some(month)) = (params.year, params.month) {
|
if let (Some(year), Some(month)) = (params.year, params.month) {
|
||||||
|
let target_date = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
|
||||||
|
let month_start = target_date;
|
||||||
|
let month_end = if month == 12 {
|
||||||
|
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
|
||||||
|
} else {
|
||||||
|
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
|
||||||
|
} - chrono::Duration::days(1);
|
||||||
|
|
||||||
all_events.retain(|event| {
|
all_events.retain(|event| {
|
||||||
let event_year = event.dtstart.year();
|
let event_date = event.dtstart.date_naive();
|
||||||
let event_month = event.dtstart.month();
|
|
||||||
event_year == year && event_month == month
|
// For non-recurring events, check if the event date is within the month
|
||||||
|
if event.rrule.is_none() || event.rrule.as_ref().unwrap().is_empty() {
|
||||||
|
let event_year = event.dtstart.year();
|
||||||
|
let event_month = event.dtstart.month();
|
||||||
|
return event_year == year && event_month == month;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For recurring events, check if they could have instances in this month
|
||||||
|
// Include if:
|
||||||
|
// 1. The event starts before or during the requested month
|
||||||
|
// 2. The event doesn't have an UNTIL date, OR the UNTIL date is after the month start
|
||||||
|
if event_date > month_end {
|
||||||
|
// Event starts after the requested month
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check UNTIL date in RRULE if present
|
||||||
|
if let Some(ref rrule) = event.rrule {
|
||||||
|
if let Some(until_pos) = rrule.find("UNTIL=") {
|
||||||
|
let until_part = &rrule[until_pos + 6..];
|
||||||
|
let until_end = until_part.find(';').unwrap_or(until_part.len());
|
||||||
|
let until_str = &until_part[..until_end];
|
||||||
|
|
||||||
|
// Try to parse UNTIL date (format: YYYYMMDDTHHMMSSZ or YYYYMMDD)
|
||||||
|
if until_str.len() >= 8 {
|
||||||
|
if let Ok(until_date) = chrono::NaiveDate::parse_from_str(&until_str[..8], "%Y%m%d") {
|
||||||
|
if until_date < month_start {
|
||||||
|
// Recurring event ended before the requested month
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include the recurring event - the frontend will do proper expansion
|
||||||
|
true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,11 +140,14 @@ pub async fn refresh_event(
|
|||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Discover calendars
|
// Discover calendars
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
@@ -101,13 +162,20 @@ pub async fn refresh_event(
|
|||||||
Ok(Json(None))
|
Ok(Json(None))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_href: &str) -> Result<Option<CalendarEvent>, crate::calendar::CalDAVError> {
|
async fn fetch_event_by_href(
|
||||||
|
client: &CalDAVClient,
|
||||||
|
calendar_path: &str,
|
||||||
|
event_href: &str,
|
||||||
|
) -> Result<Option<CalendarEvent>, crate::calendar::CalDAVError> {
|
||||||
// This is a simplified implementation - in a real scenario, you'd want to fetch the specific event by href
|
// This is a simplified implementation - in a real scenario, you'd want to fetch the specific event by href
|
||||||
// For now, we'll fetch all events and find the matching one by href (inefficient but functional)
|
// For now, we'll fetch all events and find the matching one by href (inefficient but functional)
|
||||||
let events = client.fetch_events(calendar_path).await?;
|
let events = client.fetch_events(calendar_path).await?;
|
||||||
|
|
||||||
println!("🔍 fetch_event_by_href: looking for href='{}'", event_href);
|
println!("🔍 fetch_event_by_href: looking for href='{}'", event_href);
|
||||||
println!("🔍 Available events with hrefs: {:?}", events.iter().map(|e| (&e.uid, &e.href)).collect::<Vec<_>>());
|
println!(
|
||||||
|
"🔍 Available events with hrefs: {:?}",
|
||||||
|
events.iter().map(|e| (&e.uid, &e.href)).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
// First try to match by exact href
|
// First try to match by exact href
|
||||||
for event in &events {
|
for event in &events {
|
||||||
@@ -123,7 +191,10 @@ async fn fetch_event_by_href(client: &CalDAVClient, calendar_path: &str, event_h
|
|||||||
let filename = event_href.split('/').last().unwrap_or(event_href);
|
let filename = event_href.split('/').last().unwrap_or(event_href);
|
||||||
let uid_from_href = filename.trim_end_matches(".ics");
|
let uid_from_href = filename.trim_end_matches(".ics");
|
||||||
|
|
||||||
println!("🔍 Fallback: trying UID match. filename='{}', uid='{}'", filename, uid_from_href);
|
println!(
|
||||||
|
"🔍 Fallback: trying UID match. filename='{}', uid='{}'",
|
||||||
|
filename, uid_from_href
|
||||||
|
);
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
if event.uid == uid_from_href {
|
if event.uid == uid_from_href {
|
||||||
@@ -146,23 +217,31 @@ pub async fn delete_event(
|
|||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Handle different delete actions for recurring events
|
// Handle different delete actions for recurring events
|
||||||
match request.delete_action.as_str() {
|
match request.delete_action.as_str() {
|
||||||
"delete_this" => {
|
"delete_this" => {
|
||||||
if let Some(event) = fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await
|
if let Some(event) =
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? {
|
fetch_event_by_href(&client, &request.calendar_path, &request.event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?
|
||||||
|
{
|
||||||
// Check if this is a recurring event
|
// Check if this is a recurring event
|
||||||
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
|
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
|
||||||
// Recurring event - add EXDATE for this occurrence
|
// Recurring event - add EXDATE for this occurrence
|
||||||
if let Some(occurrence_date) = &request.occurrence_date {
|
if let Some(occurrence_date) = &request.occurrence_date {
|
||||||
let exception_utc = if let Ok(date) = chrono::DateTime::parse_from_rfc3339(occurrence_date) {
|
let exception_utc = if let Ok(date) =
|
||||||
|
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
||||||
|
{
|
||||||
// RFC3339 format (with time and timezone)
|
// RFC3339 format (with time and timezone)
|
||||||
date.with_timezone(&chrono::Utc)
|
date.with_timezone(&chrono::Utc)
|
||||||
} else if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") {
|
} else if let Ok(naive_date) =
|
||||||
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||||
|
{
|
||||||
// Simple date format (YYYY-MM-DD)
|
// Simple date format (YYYY-MM-DD)
|
||||||
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
||||||
} else {
|
} else {
|
||||||
@@ -172,12 +251,26 @@ pub async fn delete_event(
|
|||||||
let mut updated_event = event;
|
let mut updated_event = event;
|
||||||
updated_event.exdate.push(exception_utc);
|
updated_event.exdate.push(exception_utc);
|
||||||
|
|
||||||
println!("🔄 Adding EXDATE {} to recurring event {}", exception_utc.format("%Y%m%dT%H%M%SZ"), updated_event.uid);
|
println!(
|
||||||
|
"🔄 Adding EXDATE {} to recurring event {}",
|
||||||
|
exception_utc.format("%Y%m%dT%H%M%SZ"),
|
||||||
|
updated_event.uid
|
||||||
|
);
|
||||||
|
|
||||||
// Update the event with the new EXDATE
|
// Update the event with the new EXDATE
|
||||||
client.update_event(&request.calendar_path, &updated_event, &request.event_href)
|
client
|
||||||
|
.update_event(
|
||||||
|
&request.calendar_path,
|
||||||
|
&updated_event,
|
||||||
|
&request.event_href,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to update event with EXDATE: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!(
|
||||||
|
"Failed to update event with EXDATE: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
println!("✅ Successfully updated recurring event with EXDATE");
|
println!("✅ Successfully updated recurring event with EXDATE");
|
||||||
|
|
||||||
@@ -192,9 +285,12 @@ pub async fn delete_event(
|
|||||||
// Non-recurring event - delete the entire event
|
// Non-recurring event - delete the entire event
|
||||||
println!("🗑️ Deleting non-recurring event: {}", event.uid);
|
println!("🗑️ Deleting non-recurring event: {}", event.uid);
|
||||||
|
|
||||||
client.delete_event(&request.calendar_path, &request.event_href)
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to delete event: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
println!("✅ Successfully deleted non-recurring event");
|
println!("✅ Successfully deleted non-recurring event");
|
||||||
|
|
||||||
@@ -206,51 +302,77 @@ pub async fn delete_event(
|
|||||||
} else {
|
} else {
|
||||||
Err(ApiError::NotFound("Event not found".to_string()))
|
Err(ApiError::NotFound("Event not found".to_string()))
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"delete_following" => {
|
"delete_following" => {
|
||||||
// For "this and following" deletion, we need to:
|
// For "this and following" deletion, we need to:
|
||||||
// 1. Fetch the recurring event
|
// 1. Fetch the recurring event
|
||||||
// 2. Modify the RRULE to end before this occurrence
|
// 2. Modify the RRULE to end before this occurrence
|
||||||
// 3. Update the event
|
// 3. Update the event
|
||||||
|
|
||||||
if let Some(mut event) = fetch_event_by_href(&client, &request.calendar_path, &request.event_href).await
|
if let Some(mut event) =
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))? {
|
fetch_event_by_href(&client, &request.calendar_path, &request.event_href)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?
|
||||||
|
{
|
||||||
if let Some(occurrence_date) = &request.occurrence_date {
|
if let Some(occurrence_date) = &request.occurrence_date {
|
||||||
let until_date = if let Ok(date) = chrono::DateTime::parse_from_rfc3339(occurrence_date) {
|
let until_date = if let Ok(date) =
|
||||||
|
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
||||||
|
{
|
||||||
// RFC3339 format (with time and timezone)
|
// RFC3339 format (with time and timezone)
|
||||||
date.with_timezone(&chrono::Utc)
|
date.with_timezone(&chrono::Utc)
|
||||||
} else if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") {
|
} else if let Ok(naive_date) =
|
||||||
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||||
|
{
|
||||||
// Simple date format (YYYY-MM-DD)
|
// Simple date format (YYYY-MM-DD)
|
||||||
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
||||||
} else {
|
} else {
|
||||||
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
|
return Err(ApiError::BadRequest(format!(
|
||||||
|
"Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD",
|
||||||
|
occurrence_date
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Modify the RRULE to add an UNTIL clause
|
// Modify the RRULE to add an UNTIL clause
|
||||||
if let Some(rrule) = &event.rrule {
|
if let Some(rrule) = &event.rrule {
|
||||||
// Remove existing UNTIL if present and add new one
|
// Remove existing UNTIL if present and add new one
|
||||||
let parts: Vec<&str> = rrule.split(';').filter(|part| {
|
let parts: Vec<&str> = rrule
|
||||||
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
.split(';')
|
||||||
}).collect();
|
.filter(|part| {
|
||||||
|
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let new_rrule = format!("{};UNTIL={}", parts.join(";"), until_date.format("%Y%m%dT%H%M%SZ"));
|
let new_rrule = format!(
|
||||||
|
"{};UNTIL={}",
|
||||||
|
parts.join(";"),
|
||||||
|
until_date.format("%Y%m%dT%H%M%SZ")
|
||||||
|
);
|
||||||
event.rrule = Some(new_rrule);
|
event.rrule = Some(new_rrule);
|
||||||
|
|
||||||
// Update the event with the modified RRULE
|
// Update the event with the modified RRULE
|
||||||
client.update_event(&request.calendar_path, &event, &request.event_href)
|
client
|
||||||
|
.update_event(&request.calendar_path, &event, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to update event with modified RRULE: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!(
|
||||||
|
"Failed to update event with modified RRULE: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(DeleteEventResponse {
|
Ok(Json(DeleteEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "This and following occurrences deleted successfully".to_string(),
|
message: "This and following occurrences deleted successfully"
|
||||||
|
.to_string(),
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
// No RRULE, just delete the single event
|
// No RRULE, just delete the single event
|
||||||
client.delete_event(&request.calendar_path, &request.event_href)
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ApiError::Internal(format!("Failed to delete event: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(DeleteEventResponse {
|
Ok(Json(DeleteEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -258,15 +380,18 @@ pub async fn delete_event(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::BadRequest("Occurrence date is required for following deletion".to_string()))
|
Err(ApiError::BadRequest(
|
||||||
|
"Occurrence date is required for following deletion".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::NotFound("Event not found".to_string()))
|
Err(ApiError::NotFound("Event not found".to_string()))
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"delete_series" | _ => {
|
"delete_series" | _ => {
|
||||||
// Delete the entire event/series
|
// Delete the entire event/series
|
||||||
client.delete_event(&request.calendar_path, &request.event_href)
|
client
|
||||||
|
.delete_event(&request.calendar_path, &request.event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
|
||||||
|
|
||||||
@@ -283,8 +408,10 @@ pub async fn create_event(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(request): Json<CreateEventRequest>,
|
Json(request): Json<CreateEventRequest>,
|
||||||
) -> Result<Json<CreateEventResponse>, ApiError> {
|
) -> Result<Json<CreateEventResponse>, ApiError> {
|
||||||
println!("📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",
|
println!(
|
||||||
request.title, request.all_day, request.calendar_path);
|
"📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",
|
||||||
|
request.title, request.all_day, request.calendar_path
|
||||||
|
);
|
||||||
|
|
||||||
// Extract and verify token
|
// Extract and verify token
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
@@ -296,11 +423,15 @@ pub async fn create_event(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if request.title.len() > 200 {
|
if request.title.len() > 200 {
|
||||||
return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Event title too long (max 200 characters)".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Determine which calendar to use
|
// Determine which calendar to use
|
||||||
@@ -308,31 +439,55 @@ pub async fn create_event(
|
|||||||
path
|
path
|
||||||
} else {
|
} else {
|
||||||
// Use the first available calendar
|
// Use the first available calendar
|
||||||
let calendar_paths = client.discover_calendars()
|
let calendar_paths = client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
if calendar_paths.is_empty() {
|
if calendar_paths.is_empty() {
|
||||||
return Err(ApiError::BadRequest("No calendars available for event creation".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"No calendars available for event creation".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
calendar_paths[0].clone()
|
calendar_paths[0].clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse dates and times
|
// Parse dates and times
|
||||||
let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
let start_datetime =
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
||||||
|
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
||||||
|
|
||||||
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
let mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
|
|
||||||
// Validate that end is after start
|
// For all-day events, add one day to end date for RFC-5545 compliance
|
||||||
if end_datetime <= start_datetime {
|
// RFC-5545 uses exclusive end dates for all-day events
|
||||||
return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string()));
|
if request.all_day {
|
||||||
|
end_datetime = end_datetime + chrono::Duration::days(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that end is after start (allow equal times for all-day events)
|
||||||
|
if request.all_day {
|
||||||
|
if end_datetime < start_datetime {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"End date must be on or after start date for all-day events".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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
|
// Generate a unique UID for the event
|
||||||
let uid = format!("{}-{}", uuid::Uuid::new_v4(), chrono::Utc::now().timestamp());
|
let uid = format!(
|
||||||
|
"{}-{}",
|
||||||
|
uuid::Uuid::new_v4(),
|
||||||
|
chrono::Utc::now().timestamp()
|
||||||
|
);
|
||||||
|
|
||||||
// Parse status
|
// Parse status
|
||||||
let status = match request.status.to_lowercase().as_str() {
|
let status = match request.status.to_lowercase().as_str() {
|
||||||
@@ -352,7 +507,8 @@ pub async fn create_event(
|
|||||||
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
|
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
request.attendees
|
request
|
||||||
|
.attendees
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
@@ -363,7 +519,8 @@ pub async fn create_event(
|
|||||||
let categories: Vec<String> = if request.categories.trim().is_empty() {
|
let categories: Vec<String> = if request.categories.trim().is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
request.categories
|
request
|
||||||
|
.categories
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
@@ -402,7 +559,8 @@ pub async fn create_event(
|
|||||||
|
|
||||||
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
|
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
|
||||||
if request.recurrence_days.len() == 7 {
|
if request.recurrence_days.len() == 7 {
|
||||||
let selected_days: Vec<&str> = request.recurrence_days
|
let selected_days: Vec<&str> = request
|
||||||
|
.recurrence_days
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(i, &selected)| {
|
.filter_map(|(i, &selected)| {
|
||||||
@@ -416,20 +574,20 @@ pub async fn create_event(
|
|||||||
5 => "FR", // Friday
|
5 => "FR", // Friday
|
||||||
6 => "SA", // Saturday
|
6 => "SA", // Saturday
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !selected_days.is_empty() {
|
if !selected_days.is_empty() {
|
||||||
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
|
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Some(rrule)
|
Some(rrule)
|
||||||
},
|
}
|
||||||
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
|
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
|
||||||
"YEARLY" => Some("FREQ=YEARLY".to_string()),
|
"YEARLY" => Some("FREQ=YEARLY".to_string()),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -439,9 +597,21 @@ pub async fn create_event(
|
|||||||
// Create the VEvent struct (RFC 5545 compliant)
|
// Create the VEvent struct (RFC 5545 compliant)
|
||||||
let mut event = VEvent::new(uid, start_datetime);
|
let mut event = VEvent::new(uid, start_datetime);
|
||||||
event.dtend = Some(end_datetime);
|
event.dtend = Some(end_datetime);
|
||||||
event.summary = if request.title.trim().is_empty() { None } else { Some(request.title.clone()) };
|
event.summary = if request.title.trim().is_empty() {
|
||||||
event.description = if request.description.trim().is_empty() { None } else { Some(request.description) };
|
None
|
||||||
event.location = if request.location.trim().is_empty() { None } else { Some(request.location) };
|
} else {
|
||||||
|
Some(request.title.clone())
|
||||||
|
};
|
||||||
|
event.description = if request.description.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.description)
|
||||||
|
};
|
||||||
|
event.location = if request.location.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.location)
|
||||||
|
};
|
||||||
event.status = Some(status);
|
event.status = Some(status);
|
||||||
event.class = Some(class);
|
event.class = Some(class);
|
||||||
event.priority = request.priority;
|
event.priority = request.priority;
|
||||||
@@ -456,41 +626,53 @@ pub async fn create_event(
|
|||||||
language: None,
|
language: None,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
event.attendees = attendees.into_iter().map(|email| Attendee {
|
event.attendees = attendees
|
||||||
cal_address: email,
|
.into_iter()
|
||||||
common_name: None,
|
.map(|email| Attendee {
|
||||||
role: None,
|
cal_address: email,
|
||||||
part_stat: None,
|
common_name: None,
|
||||||
rsvp: None,
|
role: None,
|
||||||
cu_type: None,
|
part_stat: None,
|
||||||
member: Vec::new(),
|
rsvp: None,
|
||||||
delegated_to: Vec::new(),
|
cu_type: None,
|
||||||
delegated_from: Vec::new(),
|
member: Vec::new(),
|
||||||
sent_by: None,
|
delegated_to: Vec::new(),
|
||||||
dir_entry_ref: None,
|
delegated_from: Vec::new(),
|
||||||
language: None,
|
sent_by: None,
|
||||||
}).collect();
|
dir_entry_ref: None,
|
||||||
|
language: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
event.categories = categories;
|
event.categories = categories;
|
||||||
event.rrule = rrule;
|
event.rrule = rrule;
|
||||||
event.all_day = request.all_day;
|
event.all_day = request.all_day;
|
||||||
event.alarms = alarms.into_iter().map(|reminder| VAlarm {
|
event.alarms = alarms
|
||||||
action: AlarmAction::Display,
|
.into_iter()
|
||||||
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(-reminder.minutes_before as i64)),
|
.map(|reminder| VAlarm {
|
||||||
duration: None,
|
action: AlarmAction::Display,
|
||||||
repeat: None,
|
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(
|
||||||
description: reminder.description,
|
-reminder.minutes_before as i64,
|
||||||
summary: None,
|
)),
|
||||||
attendees: Vec::new(),
|
duration: None,
|
||||||
attach: Vec::new(),
|
repeat: None,
|
||||||
}).collect();
|
description: reminder.description,
|
||||||
|
summary: None,
|
||||||
|
attendees: Vec::new(),
|
||||||
|
attach: Vec::new(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
event.calendar_path = Some(calendar_path.clone());
|
event.calendar_path = Some(calendar_path.clone());
|
||||||
|
|
||||||
// Create the event on the CalDAV server
|
// Create the event on the CalDAV server
|
||||||
let event_href = client.create_event(&calendar_path, &event)
|
let event_href = client
|
||||||
|
.create_event(&calendar_path, &event)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
|
||||||
|
|
||||||
println!("✅ Event created successfully with UID: {} at href: {}", event.uid, event_href);
|
println!(
|
||||||
|
"✅ Event created successfully with UID: {} at href: {}",
|
||||||
|
event.uid, event_href
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Json(CreateEventResponse {
|
Ok(Json(CreateEventResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -520,18 +702,23 @@ pub async fn update_event(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if request.title.len() > 200 {
|
if request.title.len() > 200 {
|
||||||
return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string()));
|
return Err(ApiError::BadRequest(
|
||||||
|
"Event title too long (max 200 characters)".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
|
let config = state
|
||||||
|
.auth_service
|
||||||
|
.caldav_config_from_token(&token, &password)?;
|
||||||
let client = CalDAVClient::new(config);
|
let client = CalDAVClient::new(config);
|
||||||
|
|
||||||
// Find the event across all calendars (or in the specified calendar)
|
// Find the event across all calendars (or in the specified calendar)
|
||||||
let calendar_paths = if let Some(path) = &request.calendar_path {
|
let calendar_paths = if let Some(path) = &request.calendar_path {
|
||||||
vec![path.clone()]
|
vec![path.clone()]
|
||||||
} else {
|
} else {
|
||||||
client.discover_calendars()
|
client
|
||||||
|
.discover_calendars()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
|
||||||
};
|
};
|
||||||
@@ -544,7 +731,10 @@ pub async fn update_event(
|
|||||||
for event in events {
|
for event in events {
|
||||||
if event.uid == request.uid {
|
if event.uid == request.uid {
|
||||||
// Use the actual href from the event, or generate one if missing
|
// Use the actual href from the event, or generate one if missing
|
||||||
let event_href = event.href.clone().unwrap_or_else(|| format!("{}.ics", event.uid));
|
let event_href = event
|
||||||
|
.href
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{}.ics", event.uid));
|
||||||
println!("🔍 Found event {} with href: {}", event.uid, event_href);
|
println!("🔍 Found event {} with href: {}", event.uid, event_href);
|
||||||
found_event = Some((event, calendar_path.clone(), event_href));
|
found_event = Some((event, calendar_path.clone(), event_href));
|
||||||
break;
|
break;
|
||||||
@@ -553,9 +743,12 @@ pub async fn update_event(
|
|||||||
if found_event.is_some() {
|
if found_event.is_some() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Failed to fetch events from calendar {}: {}", calendar_path, e);
|
eprintln!(
|
||||||
|
"Failed to fetch events from calendar {}: {}",
|
||||||
|
calendar_path, e
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,23 +758,52 @@ pub async fn update_event(
|
|||||||
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
|
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
|
||||||
|
|
||||||
// Parse dates and times
|
// Parse dates and times
|
||||||
let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
let start_datetime =
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
||||||
|
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
||||||
|
|
||||||
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
let mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
|
|
||||||
// Validate that end is after start
|
// For all-day events, add one day to end date for RFC-5545 compliance
|
||||||
if end_datetime <= start_datetime {
|
// RFC-5545 uses exclusive end dates for all-day events
|
||||||
return Err(ApiError::BadRequest("End date/time must be after start date/time".to_string()));
|
if request.all_day {
|
||||||
|
end_datetime = end_datetime + chrono::Duration::days(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that end is after start (allow equal times for all-day events)
|
||||||
|
if request.all_day {
|
||||||
|
if end_datetime < start_datetime {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"End date must be on or after start date for all-day events".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if end_datetime <= start_datetime {
|
||||||
|
return Err(ApiError::BadRequest(
|
||||||
|
"End date/time must be after start date/time".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update event properties
|
// Update event properties
|
||||||
event.dtstart = start_datetime;
|
event.dtstart = start_datetime;
|
||||||
event.dtend = Some(end_datetime);
|
event.dtend = Some(end_datetime);
|
||||||
event.summary = if request.title.trim().is_empty() { None } else { Some(request.title) };
|
event.summary = if request.title.trim().is_empty() {
|
||||||
event.description = if request.description.trim().is_empty() { None } else { Some(request.description) };
|
None
|
||||||
event.location = if request.location.trim().is_empty() { None } else { Some(request.location) };
|
} else {
|
||||||
|
Some(request.title)
|
||||||
|
};
|
||||||
|
event.description = if request.description.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.description)
|
||||||
|
};
|
||||||
|
event.location = if request.location.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.location)
|
||||||
|
};
|
||||||
event.all_day = request.all_day;
|
event.all_day = request.all_day;
|
||||||
|
|
||||||
// Parse and update status
|
// Parse and update status
|
||||||
@@ -601,8 +823,12 @@ pub async fn update_event(
|
|||||||
event.priority = request.priority;
|
event.priority = request.priority;
|
||||||
|
|
||||||
// Update the event on the CalDAV server
|
// Update the event on the CalDAV server
|
||||||
println!("📝 Updating event {} at calendar_path: {}, event_href: {}", event.uid, calendar_path, event_href);
|
println!(
|
||||||
client.update_event(&calendar_path, &event, &event_href)
|
"📝 Updating event {} at calendar_path: {}, event_href: {}",
|
||||||
|
event.uid, calendar_path, event_href
|
||||||
|
);
|
||||||
|
client
|
||||||
|
.update_event(&calendar_path, &event, &event_href)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?;
|
||||||
|
|
||||||
@@ -614,17 +840,23 @@ pub async fn update_event(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result<chrono::DateTime<chrono::Utc>, String> {
|
fn parse_event_datetime(
|
||||||
use chrono::{NaiveDate, NaiveTime, NaiveDateTime, Utc, TimeZone};
|
date_str: &str,
|
||||||
|
time_str: &str,
|
||||||
|
all_day: bool,
|
||||||
|
) -> Result<chrono::DateTime<chrono::Utc>, String> {
|
||||||
|
use chrono::{Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
|
||||||
|
|
||||||
// Parse the date
|
// Parse the date
|
||||||
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||||
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
|
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
|
||||||
|
|
||||||
if all_day {
|
if all_day {
|
||||||
// For all-day events, use midnight UTC
|
// For all-day events, use noon UTC to avoid timezone boundary issues
|
||||||
let datetime = date.and_hms_opt(0, 0, 0)
|
// This ensures the date remains correct when converted to any local timezone
|
||||||
.ok_or_else(|| "Failed to create midnight datetime".to_string())?;
|
let datetime = date
|
||||||
|
.and_hms_opt(12, 0, 0)
|
||||||
|
.ok_or_else(|| "Failed to create noon datetime".to_string())?;
|
||||||
Ok(Utc.from_utc_datetime(&datetime))
|
Ok(Utc.from_utc_datetime(&datetime))
|
||||||
} else {
|
} else {
|
||||||
// Parse the time
|
// Parse the time
|
||||||
@@ -634,7 +866,11 @@ fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result
|
|||||||
// Combine date and time
|
// Combine date and time
|
||||||
let datetime = NaiveDateTime::new(date, time);
|
let datetime = NaiveDateTime::new(date, time);
|
||||||
|
|
||||||
// Assume local time and convert to UTC (in a real app, you'd want timezone support)
|
// Treat the datetime as local time and convert to UTC
|
||||||
Ok(Utc.from_utc_datetime(&datetime))
|
let local_datetime = Local.from_local_datetime(&datetime)
|
||||||
|
.single()
|
||||||
|
.ok_or_else(|| "Ambiguous local datetime".to_string())?;
|
||||||
|
|
||||||
|
Ok(local_datetime.with_timezone(&Utc))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
133
backend/src/handlers/preferences.rs
Normal file
133
backend/src/handlers/preferences.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
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,
|
||||||
|
last_used_calendar: preferences.last_used_calendar,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
if request.last_used_calendar.is_some() {
|
||||||
|
preferences.last_used_calendar = request.last_used_calendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
last_used_calendar: preferences.last_used_calendar,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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"
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,33 +3,43 @@ use axum::{
|
|||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tower_http::cors::{CorsLayer, Any};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod models;
|
|
||||||
pub mod handlers;
|
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod db;
|
||||||
|
pub mod handlers;
|
||||||
|
pub mod models;
|
||||||
|
|
||||||
use auth::AuthService;
|
use auth::AuthService;
|
||||||
|
use db::Database;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub auth_service: AuthService,
|
pub auth_service: AuthService,
|
||||||
|
pub db: Database,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
println!("🚀 Starting Calendar Backend Server");
|
println!("🚀 Starting Calendar Backend Server");
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
let database_url = std::env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "sqlite:calendar.db".to_string());
|
||||||
|
|
||||||
|
let db = Database::new(&database_url).await?;
|
||||||
|
println!("✅ Database initialized");
|
||||||
|
|
||||||
// Create auth service
|
// Create auth service
|
||||||
let jwt_secret = std::env::var("JWT_SECRET")
|
let jwt_secret = std::env::var("JWT_SECRET")
|
||||||
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
|
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
|
||||||
|
|
||||||
let auth_service = AuthService::new(jwt_secret);
|
let auth_service = AuthService::new(jwt_secret, db.clone());
|
||||||
|
|
||||||
let app_state = AppState { auth_service };
|
let app_state = AppState { auth_service, db };
|
||||||
|
|
||||||
// Build our application with routes
|
// Build our application with routes
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
@@ -46,9 +56,22 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.route("/api/calendar/events/delete", post(handlers::delete_event))
|
.route("/api/calendar/events/delete", post(handlers::delete_event))
|
||||||
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
|
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
|
||||||
// Event series-specific endpoints
|
// Event series-specific endpoints
|
||||||
.route("/api/calendar/events/series/create", post(handlers::create_event_series))
|
.route(
|
||||||
.route("/api/calendar/events/series/update", post(handlers::update_event_series))
|
"/api/calendar/events/series/create",
|
||||||
.route("/api/calendar/events/series/delete", post(handlers::delete_event_series))
|
post(handlers::create_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/update",
|
||||||
|
post(handlers::update_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/delete",
|
||||||
|
post(handlers::delete_event_series),
|
||||||
|
)
|
||||||
|
// User preferences endpoints
|
||||||
|
.route("/api/preferences", get(handlers::get_preferences))
|
||||||
|
.route("/api/preferences", post(handlers::update_preferences))
|
||||||
|
.route("/api/auth/logout", post(handlers::logout))
|
||||||
.layer(
|
.layer(
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
|
|||||||
@@ -16,8 +16,32 @@ pub struct CalDAVLoginRequest {
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct AuthResponse {
|
pub struct AuthResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
pub session_token: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
|
pub preferences: UserPreferencesResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct UserPreferencesResponse {
|
||||||
|
pub calendar_selected_date: Option<String>,
|
||||||
|
pub calendar_time_increment: Option<i32>,
|
||||||
|
pub calendar_view_mode: Option<String>,
|
||||||
|
pub calendar_theme: Option<String>,
|
||||||
|
pub calendar_style: Option<String>,
|
||||||
|
pub calendar_colors: Option<String>,
|
||||||
|
pub last_used_calendar: 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>,
|
||||||
|
pub last_used_calendar: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -76,21 +100,21 @@ pub struct DeleteEventResponse {
|
|||||||
pub struct CreateEventRequest {
|
pub struct CreateEventRequest {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
pub recurrence: String, // recurrence type
|
pub recurrence: String, // recurrence type
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,24 +127,24 @@ pub struct CreateEventResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateEventRequest {
|
pub struct UpdateEventRequest {
|
||||||
pub uid: String, // Event UID to identify which event to update
|
pub uid: String, // Event UID to identify which event to update
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
pub recurrence: String, // recurrence type
|
pub recurrence: String, // recurrence type
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
pub update_action: Option<String>, // "update_series" for recurring events
|
pub update_action: Option<String>, // "update_series" for recurring events
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -139,22 +163,22 @@ pub struct UpdateEventResponse {
|
|||||||
pub struct CreateEventSeriesRequest {
|
pub struct CreateEventSeriesRequest {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
|
|
||||||
// Series-specific fields
|
// Series-specific fields
|
||||||
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
||||||
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||||
@@ -173,25 +197,25 @@ pub struct CreateEventSeriesResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateEventSeriesRequest {
|
pub struct UpdateEventSeriesRequest {
|
||||||
pub series_uid: String, // Series UID to identify which series to update
|
pub series_uid: String, // Series UID to identify which series to update
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub start_date: String, // YYYY-MM-DD format
|
pub start_date: String, // YYYY-MM-DD format
|
||||||
pub start_time: String, // HH:MM format
|
pub start_time: String, // HH:MM format
|
||||||
pub end_date: String, // YYYY-MM-DD format
|
pub end_date: String, // YYYY-MM-DD format
|
||||||
pub end_time: String, // HH:MM format
|
pub end_time: String, // HH:MM format
|
||||||
pub location: String,
|
pub location: String,
|
||||||
pub all_day: bool,
|
pub all_day: bool,
|
||||||
pub status: String, // confirmed, tentative, cancelled
|
pub status: String, // confirmed, tentative, cancelled
|
||||||
pub class: String, // public, private, confidential
|
pub class: String, // public, private, confidential
|
||||||
pub priority: Option<u8>, // 0-9 priority level
|
pub priority: Option<u8>, // 0-9 priority level
|
||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub reminder: String, // reminder type
|
||||||
|
|
||||||
// Series-specific fields
|
// Series-specific fields
|
||||||
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
||||||
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||||
@@ -199,7 +223,7 @@ pub struct UpdateEventSeriesRequest {
|
|||||||
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
|
|
||||||
// Update scope control
|
// Update scope control
|
||||||
pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
|
pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
|
||||||
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
|
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
|
||||||
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
|
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
|
||||||
}
|
}
|
||||||
@@ -214,12 +238,12 @@ pub struct UpdateEventSeriesResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct DeleteEventSeriesRequest {
|
pub struct DeleteEventSeriesRequest {
|
||||||
pub series_uid: String, // Series UID to identify which series to delete
|
pub series_uid: String, // Series UID to identify which series to delete
|
||||||
pub calendar_path: String,
|
pub calendar_path: String,
|
||||||
pub event_href: String,
|
pub event_href: String,
|
||||||
|
|
||||||
// Delete scope control
|
// Delete scope control
|
||||||
pub delete_scope: String, // "this_only", "this_and_future", "all_in_series"
|
pub delete_scope: String, // "this_only", "this_and_future", "all_in_series"
|
||||||
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being deleted
|
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being deleted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
use calendar_backend::AppState;
|
|
||||||
use calendar_backend::auth::AuthService;
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::time::sleep;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
response::Json,
|
response::Json,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tower_http::cors::{CorsLayer, Any};
|
use calendar_backend::auth::AuthService;
|
||||||
|
use calendar_backend::AppState;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde_json::json;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
/// Test utilities for integration testing
|
/// Test utilities for integration testing
|
||||||
mod test_utils {
|
mod test_utils {
|
||||||
@@ -33,19 +33,55 @@ mod test_utils {
|
|||||||
.route("/", get(root))
|
.route("/", get(root))
|
||||||
.route("/api/health", get(health_check))
|
.route("/api/health", get(health_check))
|
||||||
.route("/api/auth/login", post(calendar_backend::handlers::login))
|
.route("/api/auth/login", post(calendar_backend::handlers::login))
|
||||||
.route("/api/auth/verify", get(calendar_backend::handlers::verify_token))
|
.route(
|
||||||
.route("/api/user/info", get(calendar_backend::handlers::get_user_info))
|
"/api/auth/verify",
|
||||||
.route("/api/calendar/create", post(calendar_backend::handlers::create_calendar))
|
get(calendar_backend::handlers::verify_token),
|
||||||
.route("/api/calendar/delete", post(calendar_backend::handlers::delete_calendar))
|
)
|
||||||
.route("/api/calendar/events", get(calendar_backend::handlers::get_calendar_events))
|
.route(
|
||||||
.route("/api/calendar/events/create", post(calendar_backend::handlers::create_event))
|
"/api/user/info",
|
||||||
.route("/api/calendar/events/update", post(calendar_backend::handlers::update_event))
|
get(calendar_backend::handlers::get_user_info),
|
||||||
.route("/api/calendar/events/delete", post(calendar_backend::handlers::delete_event))
|
)
|
||||||
.route("/api/calendar/events/:uid", get(calendar_backend::handlers::refresh_event))
|
.route(
|
||||||
|
"/api/calendar/create",
|
||||||
|
post(calendar_backend::handlers::create_calendar),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/delete",
|
||||||
|
post(calendar_backend::handlers::delete_calendar),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events",
|
||||||
|
get(calendar_backend::handlers::get_calendar_events),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/create",
|
||||||
|
post(calendar_backend::handlers::create_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/update",
|
||||||
|
post(calendar_backend::handlers::update_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/delete",
|
||||||
|
post(calendar_backend::handlers::delete_event),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/:uid",
|
||||||
|
get(calendar_backend::handlers::refresh_event),
|
||||||
|
)
|
||||||
// Event series-specific endpoints
|
// Event series-specific endpoints
|
||||||
.route("/api/calendar/events/series/create", post(calendar_backend::handlers::create_event_series))
|
.route(
|
||||||
.route("/api/calendar/events/series/update", post(calendar_backend::handlers::update_event_series))
|
"/api/calendar/events/series/create",
|
||||||
.route("/api/calendar/events/series/delete", post(calendar_backend::handlers::delete_event_series))
|
post(calendar_backend::handlers::create_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/update",
|
||||||
|
post(calendar_backend::handlers::update_event_series),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/calendar/events/series/delete",
|
||||||
|
post(calendar_backend::handlers::delete_event_series),
|
||||||
|
)
|
||||||
.layer(
|
.layer(
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
@@ -72,22 +108,30 @@ mod test_utils {
|
|||||||
|
|
||||||
pub async fn login(&self) -> String {
|
pub async fn login(&self) -> String {
|
||||||
let login_payload = json!({
|
let login_payload = json!({
|
||||||
"username": std::env::var("CALDAV_USERNAME").unwrap_or("test".to_string()),
|
"username": "test".to_string(),
|
||||||
"password": std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string()),
|
"password": "test".to_string(),
|
||||||
"server_url": std::env::var("CALDAV_SERVER_URL").unwrap_or("https://example.com".to_string())
|
"server_url": "https://example.com".to_string()
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = self.client
|
let response = self
|
||||||
|
.client
|
||||||
.post(&format!("{}/api/auth/login", self.base_url))
|
.post(&format!("{}/api/auth/login", self.base_url))
|
||||||
.json(&login_payload)
|
.json(&login_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("Failed to send login request");
|
.expect("Failed to send login request");
|
||||||
|
|
||||||
assert!(response.status().is_success(), "Login failed with status: {}", response.status());
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"Login failed with status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
let login_response: serde_json::Value = response.json().await.unwrap();
|
let login_response: serde_json::Value = response.json().await.unwrap();
|
||||||
login_response["token"].as_str().expect("Login response should contain token").to_string()
|
login_response["token"]
|
||||||
|
.as_str()
|
||||||
|
.expect("Login response should contain token")
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,15 +150,16 @@ mod test_utils {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
|
||||||
use super::test_utils::*;
|
use super::test_utils::*;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
/// Test the health endpoint
|
/// Test the health endpoint
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_health_endpoint() {
|
async fn test_health_endpoint() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/health", server.base_url))
|
.get(&format!("{}/api/health", server.base_url))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -134,12 +179,10 @@ mod tests {
|
|||||||
async fn test_auth_login() {
|
async fn test_auth_login() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
// Load credentials from .env
|
// Use test credentials
|
||||||
dotenvy::dotenv().ok();
|
let username = "test".to_string();
|
||||||
let username = std::env::var("CALDAV_USERNAME").unwrap_or("test".to_string());
|
let password = "test".to_string();
|
||||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
let server_url = "https://example.com".to_string();
|
||||||
|
|
||||||
let server_url = std::env::var("CALDAV_SERVER_URL").unwrap_or("https://example.com".to_string());
|
|
||||||
|
|
||||||
let login_payload = json!({
|
let login_payload = json!({
|
||||||
"username": username,
|
"username": username,
|
||||||
@@ -147,18 +190,29 @@ mod tests {
|
|||||||
"server_url": server_url
|
"server_url": server_url
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.post(&format!("{}/api/auth/login", server.base_url))
|
.post(&format!("{}/api/auth/login", server.base_url))
|
||||||
.json(&login_payload)
|
.json(&login_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(response.status().is_success(), "Login failed with status: {}", response.status());
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"Login failed with status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
let login_response: serde_json::Value = response.json().await.unwrap();
|
let login_response: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(login_response["token"].is_string(), "Login response should contain a token");
|
assert!(
|
||||||
assert!(login_response["username"].is_string(), "Login response should contain username");
|
login_response["token"].is_string(),
|
||||||
|
"Login response should contain a token"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
login_response["username"].is_string(),
|
||||||
|
"Login response should contain username"
|
||||||
|
);
|
||||||
|
|
||||||
println!("✓ Authentication login test passed");
|
println!("✓ Authentication login test passed");
|
||||||
}
|
}
|
||||||
@@ -171,7 +225,8 @@ mod tests {
|
|||||||
// First login to get a token
|
// First login to get a token
|
||||||
let token = server.login().await;
|
let token = server.login().await;
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/auth/verify", server.base_url))
|
.get(&format!("{}/api/auth/verify", server.base_url))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.send()
|
.send()
|
||||||
@@ -196,9 +251,10 @@ mod tests {
|
|||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
let password = "test".to_string();
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/user/info", server.base_url))
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
@@ -212,7 +268,10 @@ mod tests {
|
|||||||
assert!(user_info["username"].is_string());
|
assert!(user_info["username"].is_string());
|
||||||
println!("✓ User info test passed");
|
println!("✓ User info test passed");
|
||||||
} else {
|
} else {
|
||||||
println!("⚠ User info test skipped (CalDAV server issues): {}", response.status());
|
println!(
|
||||||
|
"⚠ User info test skipped (CalDAV server issues): {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,22 +285,33 @@ mod tests {
|
|||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
let password = "test".to_string();
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.get(&format!("{}/api/calendar/events?year=2024&month=12", server.base_url))
|
.client
|
||||||
|
.get(&format!(
|
||||||
|
"{}/api/calendar/events?year=2024&month=12",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(response.status().is_success(), "Get events failed with status: {}", response.status());
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"Get events failed with status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
let events: serde_json::Value = response.json().await.unwrap();
|
let events: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(events.is_array());
|
assert!(events.is_array());
|
||||||
|
|
||||||
println!("✓ Get calendar events test passed (found {} events)", events.as_array().unwrap().len());
|
println!(
|
||||||
|
"✓ Get calendar events test passed (found {} events)",
|
||||||
|
events.as_array().unwrap().len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test event creation endpoint
|
/// Test event creation endpoint
|
||||||
@@ -254,7 +324,7 @@ mod tests {
|
|||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
let password = "test".to_string();
|
||||||
|
|
||||||
let create_payload = json!({
|
let create_payload = json!({
|
||||||
"title": "Integration Test Event",
|
"title": "Integration Test Event",
|
||||||
@@ -276,7 +346,8 @@ mod tests {
|
|||||||
"recurrence_days": [false, false, false, false, false, false, false]
|
"recurrence_days": [false, false, false, false, false, false, false]
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.post(&format!("{}/api/calendar/events/create", server.base_url))
|
.post(&format!("{}/api/calendar/events/create", server.base_url))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
@@ -308,13 +379,17 @@ mod tests {
|
|||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
let password = "test".to_string();
|
||||||
|
|
||||||
// Use a dummy UID for testing - this will likely return 404 but we're testing the endpoint structure
|
// Use a dummy UID for testing - this will likely return 404 but we're testing the endpoint structure
|
||||||
let test_uid = "test-event-uid";
|
let test_uid = "test-event-uid";
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.get(&format!("{}/api/calendar/events/{}", server.base_url, test_uid))
|
.client
|
||||||
|
.get(&format!(
|
||||||
|
"{}/api/calendar/events/{}",
|
||||||
|
server.base_url, test_uid
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.send()
|
.send()
|
||||||
@@ -322,8 +397,11 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// We expect either 200 (if event exists) or 404 (if not found) - both are valid responses
|
// We expect either 200 (if event exists) or 404 (if not found) - both are valid responses
|
||||||
assert!(response.status() == 200 || response.status() == 404,
|
assert!(
|
||||||
"Refresh event failed with unexpected status: {}", response.status());
|
response.status() == 200 || response.status() == 404,
|
||||||
|
"Refresh event failed with unexpected status: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
println!("✓ Refresh event endpoint test passed");
|
println!("✓ Refresh event endpoint test passed");
|
||||||
}
|
}
|
||||||
@@ -333,7 +411,8 @@ mod tests {
|
|||||||
async fn test_invalid_auth() {
|
async fn test_invalid_auth() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/user/info", server.base_url))
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
.header("Authorization", "Bearer invalid-token")
|
.header("Authorization", "Bearer invalid-token")
|
||||||
.send()
|
.send()
|
||||||
@@ -341,8 +420,11 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Accept both 400 and 401 as valid responses for invalid tokens
|
// Accept both 400 and 401 as valid responses for invalid tokens
|
||||||
assert!(response.status() == 401 || response.status() == 400,
|
assert!(
|
||||||
"Expected 401 or 400 for invalid token, got {}", response.status());
|
response.status() == 401 || response.status() == 400,
|
||||||
|
"Expected 401 or 400 for invalid token, got {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
println!("✓ Invalid authentication test passed");
|
println!("✓ Invalid authentication test passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +433,8 @@ mod tests {
|
|||||||
async fn test_missing_auth() {
|
async fn test_missing_auth() {
|
||||||
let server = TestServer::start().await;
|
let server = TestServer::start().await;
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
|
.client
|
||||||
.get(&format!("{}/api/user/info", server.base_url))
|
.get(&format!("{}/api/user/info", server.base_url))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -373,7 +456,7 @@ mod tests {
|
|||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
let password = "test".to_string();
|
||||||
|
|
||||||
let create_payload = json!({
|
let create_payload = json!({
|
||||||
"title": "Integration Test Series",
|
"title": "Integration Test Series",
|
||||||
@@ -398,8 +481,12 @@ mod tests {
|
|||||||
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/create", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/create",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.json(&create_payload)
|
.json(&create_payload)
|
||||||
@@ -431,7 +518,7 @@ mod tests {
|
|||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
let password = "test".to_string();
|
||||||
|
|
||||||
let update_payload = json!({
|
let update_payload = json!({
|
||||||
"series_uid": "test-series-uid",
|
"series_uid": "test-series-uid",
|
||||||
@@ -458,8 +545,12 @@ mod tests {
|
|||||||
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/update", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/update",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.json(&update_payload)
|
.json(&update_payload)
|
||||||
@@ -474,10 +565,15 @@ mod tests {
|
|||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
let update_response: serde_json::Value = response.json().await.unwrap();
|
let update_response: serde_json::Value = response.json().await.unwrap();
|
||||||
assert!(update_response["success"].as_bool().unwrap_or(false));
|
assert!(update_response["success"].as_bool().unwrap_or(false));
|
||||||
assert_eq!(update_response["series_uid"].as_str().unwrap(), "test-series-uid");
|
assert_eq!(
|
||||||
|
update_response["series_uid"].as_str().unwrap(),
|
||||||
|
"test-series-uid"
|
||||||
|
);
|
||||||
println!("✓ Update event series test passed");
|
println!("✓ Update event series test passed");
|
||||||
} else if status == 404 {
|
} else if status == 404 {
|
||||||
println!("⚠ Update event series test skipped (event not found - expected for test data)");
|
println!(
|
||||||
|
"⚠ Update event series test skipped (event not found - expected for test data)"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!("⚠ Update event series test skipped (CalDAV server not accessible)");
|
println!("⚠ Update event series test skipped (CalDAV server not accessible)");
|
||||||
}
|
}
|
||||||
@@ -493,7 +589,7 @@ mod tests {
|
|||||||
|
|
||||||
// Load password from env for CalDAV requests
|
// Load password from env for CalDAV requests
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
let password = std::env::var("CALDAV_PASSWORD").unwrap_or("test".to_string());
|
let password = "test".to_string();
|
||||||
|
|
||||||
let delete_payload = json!({
|
let delete_payload = json!({
|
||||||
"series_uid": "test-series-to-delete",
|
"series_uid": "test-series-to-delete",
|
||||||
@@ -502,8 +598,12 @@ mod tests {
|
|||||||
"delete_scope": "all_in_series"
|
"delete_scope": "all_in_series"
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/delete", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/delete",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.header("X-CalDAV-Password", password)
|
.header("X-CalDAV-Password", password)
|
||||||
.json(&delete_payload)
|
.json(&delete_payload)
|
||||||
@@ -520,7 +620,9 @@ mod tests {
|
|||||||
assert!(delete_response["success"].as_bool().unwrap_or(false));
|
assert!(delete_response["success"].as_bool().unwrap_or(false));
|
||||||
println!("✓ Delete event series test passed");
|
println!("✓ Delete event series test passed");
|
||||||
} else if status == 404 {
|
} else if status == 404 {
|
||||||
println!("⚠ Delete event series test skipped (event not found - expected for test data)");
|
println!(
|
||||||
|
"⚠ Delete event series test skipped (event not found - expected for test data)"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!("⚠ Delete event series test skipped (CalDAV server not accessible)");
|
println!("⚠ Delete event series test skipped (CalDAV server not accessible)");
|
||||||
}
|
}
|
||||||
@@ -555,15 +657,23 @@ mod tests {
|
|||||||
"update_scope": "invalid_scope" // This should cause a 400 error
|
"update_scope": "invalid_scope" // This should cause a 400 error
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/update", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/update",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.json(&invalid_payload)
|
.json(&invalid_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), 400, "Expected 400 for invalid update scope");
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
400,
|
||||||
|
"Expected 400 for invalid update scope"
|
||||||
|
);
|
||||||
println!("✓ Invalid update scope test passed");
|
println!("✓ Invalid update scope test passed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,15 +704,23 @@ mod tests {
|
|||||||
"recurrence_days": [false, false, false, false, false, false, false]
|
"recurrence_days": [false, false, false, false, false, false, false]
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = server.client
|
let response = server
|
||||||
.post(&format!("{}/api/calendar/events/series/create", server.base_url))
|
.client
|
||||||
|
.post(&format!(
|
||||||
|
"{}/api/calendar/events/series/create",
|
||||||
|
server.base_url
|
||||||
|
))
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.json(&non_recurring_payload)
|
.json(&non_recurring_payload)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.status(), 400, "Expected 400 for non-recurring event in series endpoint");
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
400,
|
||||||
|
"Expected 400 for non-recurring event in series endpoint"
|
||||||
|
);
|
||||||
println!("✓ Non-recurring series rejection test passed");
|
println!("✓ Non-recurring series rejection test passed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Common types and enums used across calendar components
|
//! Common types and enums used across calendar components
|
||||||
|
|
||||||
use chrono::{DateTime, Utc, Duration};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ==================== ENUMS AND COMMON TYPES ====================
|
// ==================== ENUMS AND COMMON TYPES ====================
|
||||||
@@ -64,11 +64,11 @@ pub enum AlarmAction {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct CalendarUser {
|
pub struct CalendarUser {
|
||||||
pub cal_address: String, // Calendar user address (usually email)
|
pub cal_address: String, // Calendar user address (usually email)
|
||||||
pub common_name: Option<String>, // CN parameter - display name
|
pub common_name: Option<String>, // CN parameter - display name
|
||||||
pub dir_entry_ref: Option<String>, // DIR parameter - directory entry
|
pub dir_entry_ref: Option<String>, // DIR parameter - directory entry
|
||||||
pub sent_by: Option<String>, // SENT-BY parameter
|
pub sent_by: Option<String>, // SENT-BY parameter
|
||||||
pub language: Option<String>, // LANGUAGE parameter
|
pub language: Option<String>, // LANGUAGE parameter
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -78,130 +78,130 @@ pub struct Attendee {
|
|||||||
pub role: Option<AttendeeRole>, // ROLE parameter
|
pub role: Option<AttendeeRole>, // ROLE parameter
|
||||||
pub part_stat: Option<ParticipationStatus>, // PARTSTAT parameter
|
pub part_stat: Option<ParticipationStatus>, // PARTSTAT parameter
|
||||||
pub rsvp: Option<bool>, // RSVP parameter
|
pub rsvp: Option<bool>, // RSVP parameter
|
||||||
pub cu_type: Option<String>, // CUTYPE parameter (INDIVIDUAL, GROUP, RESOURCE, ROOM, UNKNOWN)
|
pub cu_type: Option<String>, // CUTYPE parameter (INDIVIDUAL, GROUP, RESOURCE, ROOM, UNKNOWN)
|
||||||
pub member: Vec<String>, // MEMBER parameter
|
pub member: Vec<String>, // MEMBER parameter
|
||||||
pub delegated_to: Vec<String>, // DELEGATED-TO parameter
|
pub delegated_to: Vec<String>, // DELEGATED-TO parameter
|
||||||
pub delegated_from: Vec<String>, // DELEGATED-FROM parameter
|
pub delegated_from: Vec<String>, // DELEGATED-FROM parameter
|
||||||
pub sent_by: Option<String>, // SENT-BY parameter
|
pub sent_by: Option<String>, // SENT-BY parameter
|
||||||
pub dir_entry_ref: Option<String>, // DIR parameter
|
pub dir_entry_ref: Option<String>, // DIR parameter
|
||||||
pub language: Option<String>, // LANGUAGE parameter
|
pub language: Option<String>, // LANGUAGE parameter
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VAlarm {
|
pub struct VAlarm {
|
||||||
pub action: AlarmAction, // Action (ACTION) - REQUIRED
|
pub action: AlarmAction, // Action (ACTION) - REQUIRED
|
||||||
pub trigger: AlarmTrigger, // Trigger (TRIGGER) - REQUIRED
|
pub trigger: AlarmTrigger, // Trigger (TRIGGER) - REQUIRED
|
||||||
pub duration: Option<Duration>, // Duration (DURATION)
|
pub duration: Option<Duration>, // Duration (DURATION)
|
||||||
pub repeat: Option<u32>, // Repeat count (REPEAT)
|
pub repeat: Option<u32>, // Repeat count (REPEAT)
|
||||||
pub description: Option<String>, // Description for DISPLAY/EMAIL
|
pub description: Option<String>, // Description for DISPLAY/EMAIL
|
||||||
pub summary: Option<String>, // Summary for EMAIL
|
pub summary: Option<String>, // Summary for EMAIL
|
||||||
pub attendees: Vec<Attendee>, // Attendees for EMAIL
|
pub attendees: Vec<Attendee>, // Attendees for EMAIL
|
||||||
pub attach: Vec<Attachment>, // Attachments for AUDIO/EMAIL
|
pub attach: Vec<Attachment>, // Attachments for AUDIO/EMAIL
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub enum AlarmTrigger {
|
pub enum AlarmTrigger {
|
||||||
DateTime(DateTime<Utc>), // Absolute trigger time
|
DateTime(DateTime<Utc>), // Absolute trigger time
|
||||||
Duration(Duration), // Duration relative to start/end
|
Duration(Duration), // Duration relative to start/end
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct Attachment {
|
pub struct Attachment {
|
||||||
pub format_type: Option<String>, // FMTTYPE parameter (MIME type)
|
pub format_type: Option<String>, // FMTTYPE parameter (MIME type)
|
||||||
pub encoding: Option<String>, // ENCODING parameter
|
pub encoding: Option<String>, // ENCODING parameter
|
||||||
pub value: Option<String>, // VALUE parameter (BINARY or URI)
|
pub value: Option<String>, // VALUE parameter (BINARY or URI)
|
||||||
pub uri: Option<String>, // URI reference
|
pub uri: Option<String>, // URI reference
|
||||||
pub binary_data: Option<Vec<u8>>, // Binary data (when ENCODING=BASE64)
|
pub binary_data: Option<Vec<u8>>, // Binary data (when ENCODING=BASE64)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct GeographicPosition {
|
pub struct GeographicPosition {
|
||||||
pub latitude: f64, // Latitude in decimal degrees
|
pub latitude: f64, // Latitude in decimal degrees
|
||||||
pub longitude: f64, // Longitude in decimal degrees
|
pub longitude: f64, // Longitude in decimal degrees
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VTimeZone {
|
pub struct VTimeZone {
|
||||||
pub tzid: String, // Time zone ID (TZID) - REQUIRED
|
pub tzid: String, // Time zone ID (TZID) - REQUIRED
|
||||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||||
pub tzurl: Option<String>, // Time zone URL (TZURL)
|
pub tzurl: Option<String>, // Time zone URL (TZURL)
|
||||||
pub standard_components: Vec<TimeZoneComponent>, // STANDARD components
|
pub standard_components: Vec<TimeZoneComponent>, // STANDARD components
|
||||||
pub daylight_components: Vec<TimeZoneComponent>, // DAYLIGHT components
|
pub daylight_components: Vec<TimeZoneComponent>, // DAYLIGHT components
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct TimeZoneComponent {
|
pub struct TimeZoneComponent {
|
||||||
pub dtstart: DateTime<Utc>, // Start of this time zone definition
|
pub dtstart: DateTime<Utc>, // Start of this time zone definition
|
||||||
pub tzoffset_to: String, // UTC offset for this component
|
pub tzoffset_to: String, // UTC offset for this component
|
||||||
pub tzoffset_from: String, // UTC offset before this component
|
pub tzoffset_from: String, // UTC offset before this component
|
||||||
pub rrule: Option<String>, // Recurrence rule
|
pub rrule: Option<String>, // Recurrence rule
|
||||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates
|
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates
|
||||||
pub tzname: Vec<String>, // Time zone names
|
pub tzname: Vec<String>, // Time zone names
|
||||||
pub comment: Vec<String>, // Comments
|
pub comment: Vec<String>, // Comments
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VJournal {
|
pub struct VJournal {
|
||||||
// Required properties
|
// Required properties
|
||||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
|
|
||||||
// Optional properties
|
// Optional properties
|
||||||
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||||
pub description: Option<String>, // Description (DESCRIPTION)
|
pub description: Option<String>, // Description (DESCRIPTION)
|
||||||
|
|
||||||
// Classification and status
|
// Classification and status
|
||||||
pub class: Option<EventClass>, // Classification (CLASS)
|
pub class: Option<EventClass>, // Classification (CLASS)
|
||||||
pub status: Option<String>, // Status (STATUS)
|
pub status: Option<String>, // Status (STATUS)
|
||||||
|
|
||||||
// People and organization
|
// People and organization
|
||||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
|
|
||||||
// Categorization
|
// Categorization
|
||||||
pub categories: Vec<String>, // Categories (CATEGORIES)
|
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||||
|
|
||||||
// Versioning and modification
|
// Versioning and modification
|
||||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||||
|
|
||||||
// Recurrence
|
// Recurrence
|
||||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||||
|
|
||||||
// Attachments
|
// Attachments
|
||||||
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VFreeBusy {
|
pub struct VFreeBusy {
|
||||||
// Required properties
|
// Required properties
|
||||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
|
|
||||||
// Optional date-time properties
|
// Optional date-time properties
|
||||||
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
|
||||||
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
||||||
|
|
||||||
// People
|
// People
|
||||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
|
|
||||||
// Free/busy time
|
// Free/busy time
|
||||||
pub freebusy: Vec<FreeBusyTime>, // Free/busy time periods
|
pub freebusy: Vec<FreeBusyTime>, // Free/busy time periods
|
||||||
pub url: Option<String>, // URL (URL)
|
pub url: Option<String>, // URL (URL)
|
||||||
pub comment: Vec<String>, // Comments (COMMENT)
|
pub comment: Vec<String>, // Comments (COMMENT)
|
||||||
pub contact: Option<String>, // Contact information (CONTACT)
|
pub contact: Option<String>, // Contact information (CONTACT)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct FreeBusyTime {
|
pub struct FreeBusyTime {
|
||||||
pub fb_type: FreeBusyType, // Free/busy type
|
pub fb_type: FreeBusyType, // Free/busy type
|
||||||
pub periods: Vec<Period>, // Time periods
|
pub periods: Vec<Period>, // Time periods
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -214,7 +214,7 @@ pub enum FreeBusyType {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct Period {
|
pub struct Period {
|
||||||
pub start: DateTime<Utc>, // Period start
|
pub start: DateTime<Utc>, // Period start
|
||||||
pub end: Option<DateTime<Utc>>, // Period end
|
pub end: Option<DateTime<Utc>>, // Period end
|
||||||
pub duration: Option<Duration>, // Period duration (alternative to end)
|
pub duration: Option<Duration>, // Period duration (alternative to end)
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
//! This crate provides shared data structures for calendar applications
|
//! This crate provides shared data structures for calendar applications
|
||||||
//! that comply with RFC 5545 (iCalendar) specification.
|
//! that comply with RFC 5545 (iCalendar) specification.
|
||||||
|
|
||||||
pub mod vevent;
|
|
||||||
pub mod common;
|
pub mod common;
|
||||||
|
pub mod vevent;
|
||||||
|
|
||||||
pub use vevent::*;
|
|
||||||
pub use common::*;
|
pub use common::*;
|
||||||
|
pub use vevent::*;
|
||||||
|
|||||||
@@ -1,66 +1,66 @@
|
|||||||
//! VEvent - RFC 5545 compliant calendar event structure
|
//! VEvent - RFC 5545 compliant calendar event structure
|
||||||
|
|
||||||
use chrono::{DateTime, Utc, Duration};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use crate::common::*;
|
use crate::common::*;
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ==================== VEVENT COMPONENT ====================
|
// ==================== VEVENT COMPONENT ====================
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VEvent {
|
pub struct VEvent {
|
||||||
// Required properties
|
// Required properties
|
||||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
||||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED
|
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED
|
||||||
|
|
||||||
// Optional properties (commonly used)
|
// Optional properties (commonly used)
|
||||||
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
||||||
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
||||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||||
pub description: Option<String>, // Description (DESCRIPTION)
|
pub description: Option<String>, // Description (DESCRIPTION)
|
||||||
pub location: Option<String>, // Location (LOCATION)
|
pub location: Option<String>, // Location (LOCATION)
|
||||||
|
|
||||||
// Classification and status
|
// Classification and status
|
||||||
pub class: Option<EventClass>, // Classification (CLASS)
|
pub class: Option<EventClass>, // Classification (CLASS)
|
||||||
pub status: Option<EventStatus>, // Status (STATUS)
|
pub status: Option<EventStatus>, // Status (STATUS)
|
||||||
pub transp: Option<TimeTransparency>, // Time transparency (TRANSP)
|
pub transp: Option<TimeTransparency>, // Time transparency (TRANSP)
|
||||||
pub priority: Option<u8>, // Priority 0-9 (PRIORITY)
|
pub priority: Option<u8>, // Priority 0-9 (PRIORITY)
|
||||||
|
|
||||||
// People and organization
|
// People and organization
|
||||||
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
|
||||||
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
|
||||||
pub contact: Option<String>, // Contact information (CONTACT)
|
pub contact: Option<String>, // Contact information (CONTACT)
|
||||||
|
|
||||||
// Categorization and relationships
|
// Categorization and relationships
|
||||||
pub categories: Vec<String>, // Categories (CATEGORIES)
|
pub categories: Vec<String>, // Categories (CATEGORIES)
|
||||||
pub comment: Option<String>, // Comment (COMMENT)
|
pub comment: Option<String>, // Comment (COMMENT)
|
||||||
pub resources: Vec<String>, // Resources (RESOURCES)
|
pub resources: Vec<String>, // Resources (RESOURCES)
|
||||||
pub related_to: Option<String>, // Related component (RELATED-TO)
|
pub related_to: Option<String>, // Related component (RELATED-TO)
|
||||||
pub url: Option<String>, // URL (URL)
|
pub url: Option<String>, // URL (URL)
|
||||||
|
|
||||||
// Geographical
|
// Geographical
|
||||||
pub geo: Option<GeographicPosition>, // Geographic position (GEO)
|
pub geo: Option<GeographicPosition>, // Geographic position (GEO)
|
||||||
|
|
||||||
// Versioning and modification
|
// Versioning and modification
|
||||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
||||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
||||||
|
|
||||||
// Recurrence
|
// Recurrence
|
||||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
||||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
||||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
||||||
|
|
||||||
// Alarms and attachments
|
// Alarms and attachments
|
||||||
pub alarms: Vec<VAlarm>, // VALARM components
|
pub alarms: Vec<VAlarm>, // VALARM components
|
||||||
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
|
||||||
|
|
||||||
// CalDAV specific (for implementation)
|
// CalDAV specific (for implementation)
|
||||||
pub etag: Option<String>, // ETag for CalDAV
|
pub etag: Option<String>, // ETag for CalDAV
|
||||||
pub href: Option<String>, // Href for CalDAV
|
pub href: Option<String>, // Href for CalDAV
|
||||||
pub calendar_path: Option<String>, // Calendar path
|
pub calendar_path: Option<String>, // Calendar path
|
||||||
pub all_day: bool, // All-day event flag
|
pub all_day: bool, // All-day event flag
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VEvent {
|
impl VEvent {
|
||||||
@@ -129,7 +129,9 @@ impl VEvent {
|
|||||||
|
|
||||||
/// Helper method to get display title (summary or "Untitled Event")
|
/// Helper method to get display title (summary or "Untitled Event")
|
||||||
pub fn get_title(&self) -> String {
|
pub fn get_title(&self) -> String {
|
||||||
self.summary.clone().unwrap_or_else(|| "Untitled Event".to_string())
|
self.summary
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Untitled Event".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper method to get start date for UI compatibility
|
/// Helper method to get start date for UI compatibility
|
||||||
|
|||||||
BIN
calendar.db
BIN
calendar.db
Binary file not shown.
14
compose.yml
14
compose.yml
@@ -1,22 +1,22 @@
|
|||||||
services:
|
services:
|
||||||
calendar-backend:
|
calendar-backend:
|
||||||
build: .
|
build:
|
||||||
env_file:
|
context: .
|
||||||
- .env
|
dockerfile: ./backend/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/site_dist:/srv/www
|
- ./data/db:/db
|
||||||
|
|
||||||
calendar-frontend:
|
calendar-frontend:
|
||||||
image: caddy
|
image: caddy
|
||||||
env_file:
|
environment:
|
||||||
- .env
|
- BACKEND_API_URL=http://localhost:3000/api
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/site_dist:/srv/www:ro
|
- ./frontend/dist:/srv/www:ro
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
- ./data/caddy/data:/data
|
- ./data/caddy/data:/data
|
||||||
- ./data/caddy/config:/config
|
- ./data/caddy/config:/config
|
||||||
|
|||||||
4
deploy_frontend.sh
Executable file
4
deploy_frontend.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
trunk build --release --config /home/connor/docs/projects/calendar/frontend/Trunk.toml
|
||||||
|
sudo rsync -azX --delete --info=progress2 -e 'ssh -T -q' --rsync-path='sudo rsync' /home/connor/docs/projects/calendar/frontend/dist connor@server.rcjohnstone.com:/home/connor/data/runway/
|
||||||
BIN
favicon_big.png
Normal file
BIN
favicon_big.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 952 KiB |
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "calendar-app"
|
name = "runway"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ web-sys = { version = "0.3", features = [
|
|||||||
"HtmlSelectElement",
|
"HtmlSelectElement",
|
||||||
"HtmlInputElement",
|
"HtmlInputElement",
|
||||||
"HtmlTextAreaElement",
|
"HtmlTextAreaElement",
|
||||||
|
"HtmlLinkElement",
|
||||||
|
"HtmlHeadElement",
|
||||||
"Event",
|
"Event",
|
||||||
"MouseEvent",
|
"MouseEvent",
|
||||||
"InputEvent",
|
"InputEvent",
|
||||||
|
|||||||
BIN
frontend/favicon.ico
Normal file
BIN
frontend/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -2,14 +2,16 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Calendar App</title>
|
<title>Runway</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<base data-trunk-public-url />
|
<base data-trunk-public-url />
|
||||||
<link data-trunk rel="css" href="styles.css">
|
<link data-trunk rel="css" href="styles.css">
|
||||||
|
<link data-trunk rel="copy-file" href="styles/google.css">
|
||||||
|
<link data-trunk rel="icon" href="favicon.ico">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>
|
<script>
|
||||||
console.log("HTML loaded, waiting for WASM...");
|
console.log("HTML fully loaded, waiting for WASM...");
|
||||||
window.addEventListener('TrunkApplicationStarted', () => {
|
window.addEventListener('TrunkApplicationStarted', () => {
|
||||||
console.log("Trunk application started successfully!");
|
console.log("Trunk application started successfully!");
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,11 +11,22 @@ pub struct CalDAVLoginRequest {
|
|||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct UserPreferencesResponse {
|
||||||
|
pub calendar_selected_date: Option<String>,
|
||||||
|
pub calendar_time_increment: Option<i32>,
|
||||||
|
pub calendar_view_mode: Option<String>,
|
||||||
|
pub calendar_theme: Option<String>,
|
||||||
|
pub calendar_colors: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct AuthResponse {
|
pub struct AuthResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
pub session_token: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
|
pub preferences: UserPreferencesResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -50,8 +61,8 @@ impl AuthService {
|
|||||||
) -> Result<R, String> {
|
) -> Result<R, String> {
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
let json_body = serde_json::to_string(body)
|
let json_body =
|
||||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
serde_json::to_string(body).map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||||
|
|
||||||
let opts = RequestInit::new();
|
let opts = RequestInit::new();
|
||||||
opts.set_method("POST");
|
opts.set_method("POST");
|
||||||
@@ -62,23 +73,27 @@ impl AuthService {
|
|||||||
let request = Request::new_with_str_and_init(&url, &opts)
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
request.headers().set("Content-Type", "application/json")
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||||
|
|
||||||
let resp: Response = resp_value.dyn_into()
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||||
|
|
||||||
let text = JsFuture::from(resp.text()
|
let text = JsFuture::from(
|
||||||
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
|
resp.text()
|
||||||
.await
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?,
|
||||||
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||||
|
|
||||||
let text_string = text.as_string()
|
let text_string = text.as_string().ok_or("Response text is not a string")?;
|
||||||
.ok_or("Response text is not a string")?;
|
|
||||||
|
|
||||||
if resp.ok() {
|
if resp.ok() {
|
||||||
serde_json::from_str::<R>(&text_string)
|
serde_json::from_str::<R>(&text_string)
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
use yew::prelude::*;
|
use crate::components::{
|
||||||
use chrono::{Datelike, Local, NaiveDate, Duration};
|
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
|
||||||
|
};
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::{calendar_service::UserInfo, CalendarService};
|
||||||
|
use chrono::{Datelike, Duration, Local, NaiveDate};
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
use crate::services::calendar_service::UserInfo;
|
use yew::prelude::*;
|
||||||
use crate::models::ical::VEvent;
|
|
||||||
use crate::components::{EventModal, ViewMode, CalendarHeader, MonthView, WeekView, CreateEventModal, EventCreationData};
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarProps {
|
pub struct CalendarProps {
|
||||||
#[prop_or_default]
|
|
||||||
pub events: HashMap<NaiveDate, Vec<VEvent>>,
|
|
||||||
pub on_event_click: Callback<VEvent>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub refreshing_event_uid: Option<String>,
|
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
@@ -25,7 +22,17 @@ pub struct CalendarProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
|
pub on_event_update_request: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
}
|
}
|
||||||
@@ -33,6 +40,12 @@ pub struct CalendarProps {
|
|||||||
#[function_component]
|
#[function_component]
|
||||||
pub fn Calendar(props: &CalendarProps) -> Html {
|
pub fn Calendar(props: &CalendarProps) -> Html {
|
||||||
let today = Local::now().date_naive();
|
let today = Local::now().date_naive();
|
||||||
|
|
||||||
|
// Event management state
|
||||||
|
let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new());
|
||||||
|
let loading = use_state(|| true);
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
let refreshing_event_uid = use_state(|| None::<String>);
|
||||||
// Track the currently selected date (the actual day the user has selected)
|
// Track the currently selected date (the actual day the user has selected)
|
||||||
let selected_date = use_state(|| {
|
let selected_date = use_state(|| {
|
||||||
// Try to load saved selected date from localStorage
|
// Try to load saved selected date from localStorage
|
||||||
@@ -57,17 +70,16 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Track the display date (what to show in the view)
|
// Track the display date (what to show in the view)
|
||||||
let current_date = use_state(|| {
|
let current_date = use_state(|| match props.view {
|
||||||
match props.view {
|
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
|
||||||
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
|
ViewMode::Week => *selected_date,
|
||||||
ViewMode::Week => *selected_date,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
let selected_event = use_state(|| None::<VEvent>);
|
let selected_event = use_state(|| None::<VEvent>);
|
||||||
|
|
||||||
// State for create event modal
|
// State for create event modal
|
||||||
let show_create_modal = use_state(|| false);
|
let show_create_modal = use_state(|| false);
|
||||||
let create_event_data = use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>);
|
let create_event_data =
|
||||||
|
use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>);
|
||||||
|
|
||||||
// State for time increment snapping (15 or 30 minutes)
|
// State for time increment snapping (15 or 30 minutes)
|
||||||
let time_increment = use_state(|| {
|
let time_increment = use_state(|| {
|
||||||
@@ -83,6 +95,154 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch events when current_date changes
|
||||||
|
{
|
||||||
|
let events = events.clone();
|
||||||
|
let loading = loading.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
let current_date = current_date.clone();
|
||||||
|
|
||||||
|
use_effect_with((*current_date, props.view.clone()), move |(date, _view)| {
|
||||||
|
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||||
|
let date = *date; // Clone the date to avoid lifetime issues
|
||||||
|
|
||||||
|
if let Some(token) = auth_token {
|
||||||
|
let events = events.clone();
|
||||||
|
let loading = loading.clone();
|
||||||
|
let error = error.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let calendar_service = CalendarService::new();
|
||||||
|
|
||||||
|
let password = if let Ok(credentials_str) =
|
||||||
|
LocalStorage::get::<String>("caldav_credentials")
|
||||||
|
{
|
||||||
|
if let Ok(credentials) =
|
||||||
|
serde_json::from_str::<serde_json::Value>(&credentials_str)
|
||||||
|
{
|
||||||
|
credentials["password"].as_str().unwrap_or("").to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_year = date.year();
|
||||||
|
let current_month = date.month();
|
||||||
|
|
||||||
|
match calendar_service
|
||||||
|
.fetch_events_for_month_vevent(
|
||||||
|
&token,
|
||||||
|
&password,
|
||||||
|
current_year,
|
||||||
|
current_month,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(vevents) => {
|
||||||
|
let grouped_events = CalendarService::group_events_by_date(vevents);
|
||||||
|
events.set(grouped_events);
|
||||||
|
loading.set(false);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error.set(Some(format!("Failed to load events: {}", err)));
|
||||||
|
loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
loading.set(false);
|
||||||
|
error.set(Some("No authentication token found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
|| ()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle event click to refresh individual events
|
||||||
|
let on_event_click = {
|
||||||
|
let events = events.clone();
|
||||||
|
let refreshing_event_uid = refreshing_event_uid.clone();
|
||||||
|
|
||||||
|
Callback::from(move |event: VEvent| {
|
||||||
|
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||||
|
|
||||||
|
if let Some(token) = auth_token {
|
||||||
|
let events = events.clone();
|
||||||
|
let refreshing_event_uid = refreshing_event_uid.clone();
|
||||||
|
let uid = event.uid.clone();
|
||||||
|
|
||||||
|
refreshing_event_uid.set(Some(uid.clone()));
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let calendar_service = CalendarService::new();
|
||||||
|
|
||||||
|
let password = if let Ok(credentials_str) =
|
||||||
|
LocalStorage::get::<String>("caldav_credentials")
|
||||||
|
{
|
||||||
|
if let Ok(credentials) =
|
||||||
|
serde_json::from_str::<serde_json::Value>(&credentials_str)
|
||||||
|
{
|
||||||
|
credentials["password"].as_str().unwrap_or("").to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
match calendar_service
|
||||||
|
.refresh_event(&token, &password, &uid)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(refreshed_event)) => {
|
||||||
|
let refreshed_vevent = refreshed_event;
|
||||||
|
let mut updated_events = (*events).clone();
|
||||||
|
|
||||||
|
for (_, day_events) in updated_events.iter_mut() {
|
||||||
|
day_events.retain(|e| e.uid != uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if refreshed_vevent.rrule.is_some() {
|
||||||
|
let new_occurrences =
|
||||||
|
CalendarService::expand_recurring_events(vec![
|
||||||
|
refreshed_vevent.clone(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for occurrence in new_occurrences {
|
||||||
|
let date = occurrence.get_date();
|
||||||
|
updated_events
|
||||||
|
.entry(date)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(occurrence);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let date = refreshed_vevent.get_date();
|
||||||
|
updated_events
|
||||||
|
.entry(date)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(refreshed_vevent);
|
||||||
|
}
|
||||||
|
|
||||||
|
events.set(updated_events);
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
let mut updated_events = (*events).clone();
|
||||||
|
for (_, day_events) in updated_events.iter_mut() {
|
||||||
|
day_events.retain(|e| e.uid != uid);
|
||||||
|
}
|
||||||
|
events.set(updated_events);
|
||||||
|
}
|
||||||
|
Err(_err) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshing_event_uid.set(None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
// Handle view mode changes - adjust current_date format when switching between month/week
|
// Handle view mode changes - adjust current_date format when switching between month/week
|
||||||
{
|
{
|
||||||
let current_date = current_date.clone();
|
let current_date = current_date.clone();
|
||||||
@@ -110,16 +270,19 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let prev_month = *current_date - Duration::days(1);
|
let prev_month = *current_date - Duration::days(1);
|
||||||
let first_of_prev = prev_month.with_day(1).unwrap();
|
let first_of_prev = prev_month.with_day(1).unwrap();
|
||||||
(first_of_prev, first_of_prev)
|
(first_of_prev, first_of_prev)
|
||||||
},
|
}
|
||||||
ViewMode::Week => {
|
ViewMode::Week => {
|
||||||
// Go to previous week
|
// Go to previous week
|
||||||
let prev_week = *selected_date - Duration::weeks(1);
|
let prev_week = *selected_date - Duration::weeks(1);
|
||||||
(prev_week, prev_week)
|
(prev_week, prev_week)
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
selected_date.set(new_selected);
|
selected_date.set(new_selected);
|
||||||
current_date.set(new_display);
|
current_date.set(new_display);
|
||||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,19 +297,23 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let next_month = if current_date.month() == 12 {
|
let next_month = if current_date.month() == 12 {
|
||||||
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
|
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
|
||||||
} else {
|
} else {
|
||||||
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1).unwrap()
|
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1)
|
||||||
|
.unwrap()
|
||||||
};
|
};
|
||||||
(next_month, next_month)
|
(next_month, next_month)
|
||||||
},
|
}
|
||||||
ViewMode::Week => {
|
ViewMode::Week => {
|
||||||
// Go to next week
|
// Go to next week
|
||||||
let next_week = *selected_date + Duration::weeks(1);
|
let next_week = *selected_date + Duration::weeks(1);
|
||||||
(next_week, next_week)
|
(next_week, next_week)
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
selected_date.set(new_selected);
|
selected_date.set(new_selected);
|
||||||
current_date.set(new_display);
|
current_date.set(new_display);
|
||||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,12 +327,15 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
ViewMode::Month => {
|
ViewMode::Month => {
|
||||||
let first_of_today = today.with_day(1).unwrap();
|
let first_of_today = today.with_day(1).unwrap();
|
||||||
(today, first_of_today) // Select today, but display the month
|
(today, first_of_today) // Select today, but display the month
|
||||||
},
|
}
|
||||||
ViewMode::Week => (today, today), // Select and display today
|
ViewMode::Week => (today, today), // Select and display today
|
||||||
};
|
};
|
||||||
selected_date.set(new_selected);
|
selected_date.set(new_selected);
|
||||||
current_date.set(new_display);
|
current_date.set(new_display);
|
||||||
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
|
let _ = LocalStorage::set(
|
||||||
|
"calendar_selected_date",
|
||||||
|
new_selected.format("%Y-%m-%d").to_string(),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,22 +354,58 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let on_create_event = {
|
let on_create_event = {
|
||||||
let show_create_modal = show_create_modal.clone();
|
let show_create_modal = show_create_modal.clone();
|
||||||
let create_event_data = create_event_data.clone();
|
let create_event_data = create_event_data.clone();
|
||||||
Callback::from(move |(_date, start_datetime, end_datetime): (NaiveDate, chrono::NaiveDateTime, chrono::NaiveDateTime)| {
|
Callback::from(
|
||||||
// For drag-to-create, we don't need the temporary event approach
|
move |(_date, start_datetime, end_datetime): (
|
||||||
// Instead, just pass the local times directly via initial_time props
|
NaiveDate,
|
||||||
create_event_data.set(Some((start_datetime.date(), start_datetime.time(), end_datetime.time())));
|
chrono::NaiveDateTime,
|
||||||
show_create_modal.set(true);
|
chrono::NaiveDateTime,
|
||||||
})
|
)| {
|
||||||
|
// For drag-to-create, we don't need the temporary event approach
|
||||||
|
// Instead, just pass the local times directly via initial_time props
|
||||||
|
create_event_data.set(Some((
|
||||||
|
start_datetime.date(),
|
||||||
|
start_datetime.time(),
|
||||||
|
end_datetime.time(),
|
||||||
|
)));
|
||||||
|
show_create_modal.set(true);
|
||||||
|
},
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle drag-to-move event
|
// Handle drag-to-move event
|
||||||
let on_event_update = {
|
let on_event_update = {
|
||||||
let on_event_update_request = props.on_event_update_request.clone();
|
let on_event_update_request = props.on_event_update_request.clone();
|
||||||
Callback::from(move |(event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)| {
|
Callback::from(
|
||||||
if let Some(callback) = &on_event_update_request {
|
move |(
|
||||||
callback.emit((event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date));
|
event,
|
||||||
}
|
new_start,
|
||||||
})
|
new_end,
|
||||||
|
preserve_rrule,
|
||||||
|
until_date,
|
||||||
|
update_scope,
|
||||||
|
occurrence_date,
|
||||||
|
): (
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)| {
|
||||||
|
if let Some(callback) = &on_event_update_request {
|
||||||
|
callback.emit((
|
||||||
|
event,
|
||||||
|
new_start,
|
||||||
|
new_end,
|
||||||
|
preserve_rrule,
|
||||||
|
until_date,
|
||||||
|
update_scope,
|
||||||
|
occurrence_date,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
@@ -215,7 +421,20 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{
|
{
|
||||||
match props.view {
|
if *loading {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-loading">
|
||||||
|
<p>{"Loading calendar events..."}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else if let Some(err) = (*error).clone() {
|
||||||
|
html! {
|
||||||
|
<div class="calendar-error">
|
||||||
|
<p>{format!("Error: {}", err)}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match props.view {
|
||||||
ViewMode::Month => {
|
ViewMode::Month => {
|
||||||
let on_day_select = {
|
let on_day_select = {
|
||||||
let selected_date = selected_date.clone();
|
let selected_date = selected_date.clone();
|
||||||
@@ -229,9 +448,9 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
<MonthView
|
<MonthView
|
||||||
current_month={*current_date}
|
current_month={*current_date}
|
||||||
today={today}
|
today={today}
|
||||||
events={props.events.clone()}
|
events={(*events).clone()}
|
||||||
on_event_click={props.on_event_click.clone()}
|
on_event_click={on_event_click.clone()}
|
||||||
refreshing_event_uid={props.refreshing_event_uid.clone()}
|
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
||||||
user_info={props.user_info.clone()}
|
user_info={props.user_info.clone()}
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
@@ -244,9 +463,9 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
<WeekView
|
<WeekView
|
||||||
current_date={*current_date}
|
current_date={*current_date}
|
||||||
today={today}
|
today={today}
|
||||||
events={props.events.clone()}
|
events={(*events).clone()}
|
||||||
on_event_click={props.on_event_click.clone()}
|
on_event_click={on_event_click.clone()}
|
||||||
refreshing_event_uid={props.refreshing_event_uid.clone()}
|
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
||||||
user_info={props.user_info.clone()}
|
user_info={props.user_info.clone()}
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
@@ -257,6 +476,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
time_increment={*time_increment}
|
time_increment={*time_increment}
|
||||||
/>
|
/>
|
||||||
},
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,15 +521,6 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
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>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarContextMenuProps {
|
pub struct CalendarContextMenuProps {
|
||||||
@@ -18,9 +18,45 @@ pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
|
|||||||
return html! {};
|
return html! {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Smart positioning to keep menu within viewport
|
||||||
|
let (x, y) = {
|
||||||
|
let mut x = props.x;
|
||||||
|
let mut y = props.y;
|
||||||
|
|
||||||
|
// Try to get actual viewport dimensions
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let (Ok(width), Ok(height)) = (window.inner_width(), window.inner_height()) {
|
||||||
|
if let (Some(w), Some(h)) = (width.as_f64(), height.as_f64()) {
|
||||||
|
let viewport_width = w as i32;
|
||||||
|
let viewport_height = h as i32;
|
||||||
|
|
||||||
|
// Calendar context menu: "Create Event" with icon
|
||||||
|
let menu_width = 180; // "Create Event" text + icon + padding
|
||||||
|
let menu_height = 60; // Single item + padding + margins
|
||||||
|
|
||||||
|
// Adjust horizontally if too close to right edge
|
||||||
|
if x + menu_width > viewport_width - 10 {
|
||||||
|
x = x.saturating_sub(menu_width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust vertically if too close to bottom edge
|
||||||
|
if y + menu_height > viewport_height - 10 {
|
||||||
|
y = y.saturating_sub(menu_height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure minimum margins from edges
|
||||||
|
x = x.max(5);
|
||||||
|
y = y.max(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(x, y)
|
||||||
|
};
|
||||||
|
|
||||||
let style = format!(
|
let style = format!(
|
||||||
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
||||||
props.x, props.y
|
x, y
|
||||||
);
|
);
|
||||||
|
|
||||||
let on_create_event_click = {
|
let on_create_event_click = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use chrono::{NaiveDate, Datelike};
|
|
||||||
use crate::components::ViewMode;
|
use crate::components::ViewMode;
|
||||||
|
use chrono::{Datelike, NaiveDate};
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarHeaderProps {
|
pub struct CalendarHeaderProps {
|
||||||
@@ -18,7 +18,11 @@ pub struct CalendarHeaderProps {
|
|||||||
|
|
||||||
#[function_component(CalendarHeader)]
|
#[function_component(CalendarHeader)]
|
||||||
pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
||||||
let title = format!("{} {}", get_month_name(props.current_date.month()), props.current_date.year());
|
let title = format!(
|
||||||
|
"{} {}",
|
||||||
|
get_month_name(props.current_date.month()),
|
||||||
|
props.current_date.year()
|
||||||
|
);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="calendar-header">
|
<div class="calendar-header">
|
||||||
@@ -59,6 +63,6 @@ fn get_month_name(month: u32) -> &'static str {
|
|||||||
10 => "October",
|
10 => "October",
|
||||||
11 => "November",
|
11 => "November",
|
||||||
12 => "December",
|
12 => "December",
|
||||||
_ => "Invalid"
|
_ => "Invalid",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
|
||||||
use crate::services::calendar_service::CalendarInfo;
|
use crate::services::calendar_service::CalendarInfo;
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct CalendarListItemProps {
|
pub struct CalendarListItemProps {
|
||||||
pub calendar: CalendarInfo,
|
pub calendar: CalendarInfo,
|
||||||
pub color_picker_open: bool,
|
pub color_picker_open: bool,
|
||||||
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
|
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
|
||||||
pub on_color_picker_toggle: Callback<String>, // calendar_path
|
pub on_color_picker_toggle: Callback<String>, // calendar_path
|
||||||
pub available_colors: Vec<String>,
|
pub available_colors: Vec<String>,
|
||||||
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct ContextMenuProps {
|
pub struct ContextMenuProps {
|
||||||
@@ -20,9 +20,45 @@ pub fn context_menu(props: &ContextMenuProps) -> Html {
|
|||||||
return html! {};
|
return html! {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Smart positioning to keep menu within viewport
|
||||||
|
let (x, y) = {
|
||||||
|
let mut x = props.x;
|
||||||
|
let mut y = props.y;
|
||||||
|
|
||||||
|
// Try to get actual viewport dimensions
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let (Ok(width), Ok(height)) = (window.inner_width(), window.inner_height()) {
|
||||||
|
if let (Some(w), Some(h)) = (width.as_f64(), height.as_f64()) {
|
||||||
|
let viewport_width = w as i32;
|
||||||
|
let viewport_height = h as i32;
|
||||||
|
|
||||||
|
// Generic context menu: "Delete Calendar"
|
||||||
|
let menu_width = 180; // "Delete Calendar" text + padding
|
||||||
|
let menu_height = 60; // Single item + padding + margins
|
||||||
|
|
||||||
|
// Adjust horizontally if too close to right edge
|
||||||
|
if x + menu_width > viewport_width - 10 {
|
||||||
|
x = x.saturating_sub(menu_width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust vertically if too close to bottom edge
|
||||||
|
if y + menu_height > viewport_height - 10 {
|
||||||
|
y = y.saturating_sub(menu_height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure minimum margins from edges
|
||||||
|
x = x.max(5);
|
||||||
|
y = y.max(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(x, y)
|
||||||
|
};
|
||||||
|
|
||||||
let style = format!(
|
let style = format!(
|
||||||
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
||||||
props.x, props.y
|
x, y
|
||||||
);
|
);
|
||||||
|
|
||||||
let on_delete_click = {
|
let on_delete_click = {
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if name.len() > 100 {
|
if name.len() > 100 {
|
||||||
error_message.set(Some("Calendar name too long (max 100 characters)".to_string()));
|
error_message.set(Some(
|
||||||
|
"Calendar name too long (max 100 characters)".to_string(),
|
||||||
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::MouseEvent;
|
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
pub enum DeleteAction {
|
pub enum DeleteAction {
|
||||||
@@ -35,13 +35,59 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
return html! {};
|
return html! {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Smart positioning to keep menu within viewport
|
||||||
|
let (x, y) = {
|
||||||
|
let mut x = props.x;
|
||||||
|
let mut y = props.y;
|
||||||
|
|
||||||
|
// Try to get actual viewport dimensions
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let (Ok(width), Ok(height)) = (window.inner_width(), window.inner_height()) {
|
||||||
|
if let (Some(w), Some(h)) = (width.as_f64(), height.as_f64()) {
|
||||||
|
let viewport_width = w as i32;
|
||||||
|
let viewport_height = h as i32;
|
||||||
|
|
||||||
|
// More accurate menu dimensions based on actual CSS and content
|
||||||
|
let menu_width = if props.event.as_ref().map_or(false, |e| e.rrule.is_some()) {
|
||||||
|
280 // Recurring: "Edit This and Future Events" is long text + padding
|
||||||
|
} else {
|
||||||
|
180 // Non-recurring: "Edit Event" + "Delete Event" + padding
|
||||||
|
};
|
||||||
|
let menu_height = if props.event.as_ref().map_or(false, |e| e.rrule.is_some()) {
|
||||||
|
200 // 6 items × ~32px per item (12px padding top/bottom + text height + borders)
|
||||||
|
} else {
|
||||||
|
100 // 2 items × ~32px per item + some extra margin
|
||||||
|
};
|
||||||
|
|
||||||
|
// Adjust horizontally if too close to right edge
|
||||||
|
if x + menu_width > viewport_width - 10 {
|
||||||
|
x = x.saturating_sub(menu_width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust vertically if too close to bottom edge
|
||||||
|
if y + menu_height > viewport_height - 10 {
|
||||||
|
y = y.saturating_sub(menu_height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure minimum margins from edges
|
||||||
|
x = x.max(5);
|
||||||
|
y = y.max(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(x, y)
|
||||||
|
};
|
||||||
|
|
||||||
let style = format!(
|
let style = format!(
|
||||||
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
|
||||||
props.x, props.y
|
x, y
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if the event is recurring
|
// Check if the event is recurring
|
||||||
let is_recurring = props.event.as_ref()
|
let is_recurring = props
|
||||||
|
.event
|
||||||
|
.as_ref()
|
||||||
.map(|event| event.rrule.is_some())
|
.map(|event| event.rrule.is_some())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
|||||||
109
frontend/src/components/event_form/advanced.rs
Normal file
109
frontend/src/components/event_form/advanced.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use super::types::*;
|
||||||
|
// Types are already imported from super::types::*
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::HtmlSelectElement;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[function_component(AdvancedTab)]
|
||||||
|
pub fn advanced_tab(props: &TabProps) -> Html {
|
||||||
|
let data = &props.data;
|
||||||
|
|
||||||
|
let on_status_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.status = match select.value().as_str() {
|
||||||
|
"tentative" => EventStatus::Tentative,
|
||||||
|
"cancelled" => EventStatus::Cancelled,
|
||||||
|
_ => EventStatus::Confirmed,
|
||||||
|
};
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_class_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.class = match select.value().as_str() {
|
||||||
|
"private" => EventClass::Private,
|
||||||
|
"confidential" => EventClass::Confidential,
|
||||||
|
_ => EventClass::Public,
|
||||||
|
};
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_priority_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
let value = select.value();
|
||||||
|
event_data.priority = if value.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
value.parse::<u8>().ok().filter(|&p| p <= 9)
|
||||||
|
};
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="tab-panel">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="event-status">{"Status"}</label>
|
||||||
|
<select
|
||||||
|
id="event-status"
|
||||||
|
class="form-input"
|
||||||
|
onchange={on_status_change}
|
||||||
|
>
|
||||||
|
<option value="confirmed" selected={matches!(data.status, EventStatus::Confirmed)}>{"Confirmed"}</option>
|
||||||
|
<option value="tentative" selected={matches!(data.status, EventStatus::Tentative)}>{"Tentative"}</option>
|
||||||
|
<option value="cancelled" selected={matches!(data.status, EventStatus::Cancelled)}>{"Cancelled"}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="event-class">{"Privacy"}</label>
|
||||||
|
<select
|
||||||
|
id="event-class"
|
||||||
|
class="form-input"
|
||||||
|
onchange={on_class_change}
|
||||||
|
>
|
||||||
|
<option value="public" selected={matches!(data.class, EventClass::Public)}>{"Public"}</option>
|
||||||
|
<option value="private" selected={matches!(data.class, EventClass::Private)}>{"Private"}</option>
|
||||||
|
<option value="confidential" selected={matches!(data.class, EventClass::Confidential)}>{"Confidential"}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="event-priority">{"Priority"}</label>
|
||||||
|
<select
|
||||||
|
id="event-priority"
|
||||||
|
class="form-input"
|
||||||
|
onchange={on_priority_change}
|
||||||
|
>
|
||||||
|
<option value="" selected={data.priority.is_none()}>{"Not set"}</option>
|
||||||
|
<option value="1" selected={data.priority == Some(1)}>{"High"}</option>
|
||||||
|
<option value="5" selected={data.priority == Some(5)}>{"Medium"}</option>
|
||||||
|
<option value="9" selected={data.priority == Some(9)}>{"Low"}</option>
|
||||||
|
</select>
|
||||||
|
<p class="form-help-text">{"Set the importance level for this event."}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
730
frontend/src/components/event_form/basic_details.rs
Normal file
730
frontend/src/components/event_form/basic_details.rs
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
use super::types::*;
|
||||||
|
// Types are already imported from super::types::*
|
||||||
|
use chrono::{Datelike, NaiveDate};
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::{HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement};
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[function_component(BasicDetailsTab)]
|
||||||
|
pub fn basic_details_tab(props: &TabProps) -> Html {
|
||||||
|
let data = &props.data;
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
let on_title_input = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.title = input.value();
|
||||||
|
if !event_data.changed_fields.contains(&"title".to_string()) {
|
||||||
|
event_data.changed_fields.push("title".to_string());
|
||||||
|
}
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_description_input = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Ok(textarea) = target.dyn_into::<HtmlTextAreaElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.description = textarea.value();
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_calendar_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
let value = select.value();
|
||||||
|
let new_calendar = if value.is_empty() { None } else { Some(value) };
|
||||||
|
if event_data.selected_calendar != new_calendar {
|
||||||
|
event_data.selected_calendar = new_calendar;
|
||||||
|
if !event_data.changed_fields.contains(&"selected_calendar".to_string()) {
|
||||||
|
event_data.changed_fields.push("selected_calendar".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_all_day_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.all_day = input.checked();
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_recurrence_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.recurrence = match select.value().as_str() {
|
||||||
|
"daily" => RecurrenceType::Daily,
|
||||||
|
"weekly" => RecurrenceType::Weekly,
|
||||||
|
"monthly" => RecurrenceType::Monthly,
|
||||||
|
"yearly" => RecurrenceType::Yearly,
|
||||||
|
_ => RecurrenceType::None,
|
||||||
|
};
|
||||||
|
// Reset recurrence-related fields when changing type
|
||||||
|
event_data.recurrence_days = vec![false; 7];
|
||||||
|
event_data.recurrence_interval = 1;
|
||||||
|
event_data.recurrence_until = None;
|
||||||
|
event_data.recurrence_count = None;
|
||||||
|
event_data.monthly_by_day = None;
|
||||||
|
event_data.monthly_by_monthday = None;
|
||||||
|
event_data.yearly_by_month = vec![false; 12];
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_reminder_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.reminder = match select.value().as_str() {
|
||||||
|
"15min" => ReminderType::Minutes15,
|
||||||
|
"30min" => ReminderType::Minutes30,
|
||||||
|
"1hour" => ReminderType::Hour1,
|
||||||
|
"1day" => ReminderType::Day1,
|
||||||
|
"2days" => ReminderType::Days2,
|
||||||
|
"1week" => ReminderType::Week1,
|
||||||
|
_ => ReminderType::None,
|
||||||
|
};
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_recurrence_interval_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
if let Ok(interval) = input.value().parse::<u32>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.recurrence_interval = interval.max(1);
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_recurrence_until_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
if input.value().is_empty() {
|
||||||
|
event_data.recurrence_until = None;
|
||||||
|
} else if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
|
||||||
|
event_data.recurrence_until = Some(date);
|
||||||
|
}
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_recurrence_count_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
if input.value().is_empty() {
|
||||||
|
event_data.recurrence_count = None;
|
||||||
|
} else if let Ok(count) = input.value().parse::<u32>() {
|
||||||
|
event_data.recurrence_count = Some(count.max(1));
|
||||||
|
}
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_monthly_by_monthday_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
if input.value().is_empty() {
|
||||||
|
event_data.monthly_by_monthday = None;
|
||||||
|
} else if let Ok(day) = input.value().parse::<u8>() {
|
||||||
|
if day >= 1 && day <= 31 {
|
||||||
|
event_data.monthly_by_monthday = Some(day);
|
||||||
|
event_data.monthly_by_day = None; // Clear the other option
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_monthly_by_day_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
if select.value().is_empty() || select.value() == "none" {
|
||||||
|
event_data.monthly_by_day = None;
|
||||||
|
} else {
|
||||||
|
event_data.monthly_by_day = Some(select.value());
|
||||||
|
event_data.monthly_by_monthday = None; // Clear the other option
|
||||||
|
}
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_weekday_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
move |day_index: usize| {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
if day_index < event_data.recurrence_days.len() {
|
||||||
|
event_data.recurrence_days[day_index] = input.checked();
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_yearly_month_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
move |month_index: usize| {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
if month_index < event_data.yearly_by_month.len() {
|
||||||
|
event_data.yearly_by_month[month_index] = input.checked();
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_start_date_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.start_date = date;
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_start_time_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
if let Ok(time) = chrono::NaiveTime::parse_from_str(&input.value(), "%H:%M") {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.start_time = time;
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_end_date_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.end_date = date;
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_end_time_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
if let Ok(time) = chrono::NaiveTime::parse_from_str(&input.value(), "%H:%M") {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.end_time = time;
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_location_input = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.location = input.value();
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="tab-panel">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="event-title">{"Event Title *"}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="event-title"
|
||||||
|
class="form-input"
|
||||||
|
value={data.title.clone()}
|
||||||
|
oninput={on_title_input}
|
||||||
|
placeholder="Add a title"
|
||||||
|
required=true
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="event-description">{"Description"}</label>
|
||||||
|
<textarea
|
||||||
|
id="event-description"
|
||||||
|
class="form-input"
|
||||||
|
value={data.description.clone()}
|
||||||
|
oninput={on_description_input}
|
||||||
|
placeholder="Add a description"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="event-calendar">{"Calendar"}</label>
|
||||||
|
<select
|
||||||
|
id="event-calendar"
|
||||||
|
class="form-input"
|
||||||
|
onchange={on_calendar_change}
|
||||||
|
>
|
||||||
|
<option value="">{"Select Calendar"}</option>
|
||||||
|
{
|
||||||
|
props.available_calendars.iter().map(|calendar| {
|
||||||
|
html! {
|
||||||
|
<option
|
||||||
|
key={calendar.path.clone()}
|
||||||
|
value={calendar.path.clone()}
|
||||||
|
selected={data.selected_calendar.as_ref() == Some(&calendar.path)}
|
||||||
|
>
|
||||||
|
{&calendar.display_name}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={data.all_day}
|
||||||
|
onchange={on_all_day_change}
|
||||||
|
/>
|
||||||
|
{" All Day"}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="event-recurrence-basic">{"Repeat"}</label>
|
||||||
|
<select
|
||||||
|
id="event-recurrence-basic"
|
||||||
|
class="form-input"
|
||||||
|
onchange={on_recurrence_change}
|
||||||
|
>
|
||||||
|
<option value="none" selected={matches!(data.recurrence, RecurrenceType::None)}>{"Does not repeat"}</option>
|
||||||
|
<option value="daily" selected={matches!(data.recurrence, RecurrenceType::Daily)}>{"Daily"}</option>
|
||||||
|
<option value="weekly" selected={matches!(data.recurrence, RecurrenceType::Weekly)}>{"Weekly"}</option>
|
||||||
|
<option value="monthly" selected={matches!(data.recurrence, RecurrenceType::Monthly)}>{"Monthly"}</option>
|
||||||
|
<option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="event-reminder-basic">{"Reminder"}</label>
|
||||||
|
<select
|
||||||
|
id="event-reminder-basic"
|
||||||
|
class="form-input"
|
||||||
|
onchange={on_reminder_change}
|
||||||
|
>
|
||||||
|
<option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"None"}</option>
|
||||||
|
<option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option>
|
||||||
|
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option>
|
||||||
|
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option>
|
||||||
|
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// RECURRENCE OPTIONS GO RIGHT HERE - directly below repeat/reminder!
|
||||||
|
if matches!(data.recurrence, RecurrenceType::Weekly) {
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{"Repeat on"}</label>
|
||||||
|
<div class="weekday-selection">
|
||||||
|
{
|
||||||
|
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, day)| {
|
||||||
|
let day_checked = data.recurrence_days.get(i).cloned().unwrap_or(false);
|
||||||
|
let on_change = on_weekday_change(i);
|
||||||
|
html! {
|
||||||
|
<label key={i} class="weekday-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={day_checked}
|
||||||
|
onchange={on_change}
|
||||||
|
/>
|
||||||
|
<span class="weekday-label">{day}</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matches!(data.recurrence, RecurrenceType::None) {
|
||||||
|
<div class="recurrence-options">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="recurrence-interval">{"Every"}</label>
|
||||||
|
<div class="interval-input">
|
||||||
|
<input
|
||||||
|
id="recurrence-interval"
|
||||||
|
type="number"
|
||||||
|
class="form-input"
|
||||||
|
value={data.recurrence_interval.to_string()}
|
||||||
|
min="1"
|
||||||
|
max="999"
|
||||||
|
onchange={on_recurrence_interval_change}
|
||||||
|
/>
|
||||||
|
<span class="interval-unit">
|
||||||
|
{match data.recurrence {
|
||||||
|
RecurrenceType::Daily => if data.recurrence_interval == 1 { "day" } else { "days" },
|
||||||
|
RecurrenceType::Weekly => if data.recurrence_interval == 1 { "week" } else { "weeks" },
|
||||||
|
RecurrenceType::Monthly => if data.recurrence_interval == 1 { "month" } else { "months" },
|
||||||
|
RecurrenceType::Yearly => if data.recurrence_interval == 1 { "year" } else { "years" },
|
||||||
|
RecurrenceType::None => "",
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{"Ends"}</label>
|
||||||
|
<div class="end-options">
|
||||||
|
<div class="end-option">
|
||||||
|
<label class="radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="recurrence-end"
|
||||||
|
value="never"
|
||||||
|
checked={data.recurrence_until.is_none() && data.recurrence_count.is_none()}
|
||||||
|
onchange={{
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
let mut new_data = (*data).clone();
|
||||||
|
new_data.recurrence_until = None;
|
||||||
|
new_data.recurrence_count = None;
|
||||||
|
data.set(new_data);
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{"Never"}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="end-option">
|
||||||
|
<label class="radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="recurrence-end"
|
||||||
|
value="until"
|
||||||
|
checked={data.recurrence_until.is_some()}
|
||||||
|
onchange={{
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
let mut new_data = (*data).clone();
|
||||||
|
new_data.recurrence_count = None;
|
||||||
|
new_data.recurrence_until = Some(new_data.start_date);
|
||||||
|
data.set(new_data);
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{"Until"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-input"
|
||||||
|
value={data.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default()}
|
||||||
|
onchange={on_recurrence_until_change}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="end-option">
|
||||||
|
<label class="radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="recurrence-end"
|
||||||
|
value="count"
|
||||||
|
checked={data.recurrence_count.is_some()}
|
||||||
|
onchange={{
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
let mut new_data = (*data).clone();
|
||||||
|
new_data.recurrence_until = None;
|
||||||
|
new_data.recurrence_count = Some(10); // Default count
|
||||||
|
data.set(new_data);
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{"After"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-input count-input"
|
||||||
|
value={data.recurrence_count.map(|c| c.to_string()).unwrap_or_default()}
|
||||||
|
min="1"
|
||||||
|
max="999"
|
||||||
|
placeholder="1"
|
||||||
|
onchange={on_recurrence_count_change}
|
||||||
|
/>
|
||||||
|
<span class="count-unit">{"occurrences"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Monthly specific options
|
||||||
|
if matches!(data.recurrence, RecurrenceType::Monthly) {
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{"Repeat by"}</label>
|
||||||
|
<div class="monthly-options">
|
||||||
|
<div class="monthly-option">
|
||||||
|
<label class="radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="monthly-type"
|
||||||
|
checked={data.monthly_by_monthday.is_some()}
|
||||||
|
onchange={{
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
let mut new_data = (*data).clone();
|
||||||
|
new_data.monthly_by_day = None;
|
||||||
|
new_data.monthly_by_monthday = Some(new_data.start_date.day() as u8);
|
||||||
|
data.set(new_data);
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{"Day of month:"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-input day-input"
|
||||||
|
value={data.monthly_by_monthday.map(|d| d.to_string()).unwrap_or_else(|| data.start_date.day().to_string())}
|
||||||
|
min="1"
|
||||||
|
max="31"
|
||||||
|
onchange={on_monthly_by_monthday_change}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="monthly-option">
|
||||||
|
<label class="radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="monthly-type"
|
||||||
|
checked={data.monthly_by_day.is_some()}
|
||||||
|
onchange={{
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
let mut new_data = (*data).clone();
|
||||||
|
new_data.monthly_by_monthday = None;
|
||||||
|
new_data.monthly_by_day = Some("1MO".to_string()); // Default to first Monday
|
||||||
|
data.set(new_data);
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{"Day of week:"}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
class="form-input"
|
||||||
|
value={data.monthly_by_day.clone().unwrap_or_default()}
|
||||||
|
onchange={on_monthly_by_day_change}
|
||||||
|
>
|
||||||
|
<option value="none">{"Select..."}</option>
|
||||||
|
<option value="1MO">{"First Monday"}</option>
|
||||||
|
<option value="1TU">{"First Tuesday"}</option>
|
||||||
|
<option value="1WE">{"First Wednesday"}</option>
|
||||||
|
<option value="1TH">{"First Thursday"}</option>
|
||||||
|
<option value="1FR">{"First Friday"}</option>
|
||||||
|
<option value="1SA">{"First Saturday"}</option>
|
||||||
|
<option value="1SU">{"First Sunday"}</option>
|
||||||
|
<option value="2MO">{"Second Monday"}</option>
|
||||||
|
<option value="2TU">{"Second Tuesday"}</option>
|
||||||
|
<option value="2WE">{"Second Wednesday"}</option>
|
||||||
|
<option value="2TH">{"Second Thursday"}</option>
|
||||||
|
<option value="2FR">{"Second Friday"}</option>
|
||||||
|
<option value="2SA">{"Second Saturday"}</option>
|
||||||
|
<option value="2SU">{"Second Sunday"}</option>
|
||||||
|
<option value="3MO">{"Third Monday"}</option>
|
||||||
|
<option value="3TU">{"Third Tuesday"}</option>
|
||||||
|
<option value="3WE">{"Third Wednesday"}</option>
|
||||||
|
<option value="3TH">{"Third Thursday"}</option>
|
||||||
|
<option value="3FR">{"Third Friday"}</option>
|
||||||
|
<option value="3SA">{"Third Saturday"}</option>
|
||||||
|
<option value="3SU">{"Third Sunday"}</option>
|
||||||
|
<option value="4MO">{"Fourth Monday"}</option>
|
||||||
|
<option value="4TU">{"Fourth Tuesday"}</option>
|
||||||
|
<option value="4WE">{"Fourth Wednesday"}</option>
|
||||||
|
<option value="4TH">{"Fourth Thursday"}</option>
|
||||||
|
<option value="4FR">{"Fourth Friday"}</option>
|
||||||
|
<option value="4SA">{"Fourth Saturday"}</option>
|
||||||
|
<option value="4SU">{"Fourth Sunday"}</option>
|
||||||
|
<option value="-1MO">{"Last Monday"}</option>
|
||||||
|
<option value="-1TU">{"Last Tuesday"}</option>
|
||||||
|
<option value="-1WE">{"Last Wednesday"}</option>
|
||||||
|
<option value="-1TH">{"Last Thursday"}</option>
|
||||||
|
<option value="-1FR">{"Last Friday"}</option>
|
||||||
|
<option value="-1SA">{"Last Saturday"}</option>
|
||||||
|
<option value="-1SU">{"Last Sunday"}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yearly specific options
|
||||||
|
if matches!(data.recurrence, RecurrenceType::Yearly) {
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{"Repeat in months"}</label>
|
||||||
|
<div class="yearly-months">
|
||||||
|
{
|
||||||
|
["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, month)| {
|
||||||
|
let month_checked = data.yearly_by_month.get(i).cloned().unwrap_or(false);
|
||||||
|
let on_change = on_yearly_month_change(i);
|
||||||
|
html! {
|
||||||
|
<label key={i} class="month-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={month_checked}
|
||||||
|
onchange={on_change}
|
||||||
|
/>
|
||||||
|
<span class="month-label">{month}</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date and time fields go here AFTER recurrence options
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="start-date">{"Start Date *"}</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="start-date"
|
||||||
|
class="form-input"
|
||||||
|
value={data.start_date.format("%Y-%m-%d").to_string()}
|
||||||
|
onchange={on_start_date_change}
|
||||||
|
required=true
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if !data.all_day {
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="start-time">{"Start Time"}</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
id="start-time"
|
||||||
|
class="form-input"
|
||||||
|
value={data.start_time.format("%H:%M").to_string()}
|
||||||
|
onchange={on_start_time_change}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="end-date">{"End Date *"}</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="end-date"
|
||||||
|
class="form-input"
|
||||||
|
value={data.end_date.format("%Y-%m-%d").to_string()}
|
||||||
|
onchange={on_end_date_change}
|
||||||
|
required=true
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if !data.all_day {
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="end-time">{"End Time"}</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
id="end-time"
|
||||||
|
class="form-input"
|
||||||
|
value={data.end_time.format("%H:%M").to_string()}
|
||||||
|
onchange={on_end_time_change}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="event-location">{"Location"}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="event-location"
|
||||||
|
class="form-input"
|
||||||
|
value={data.location.clone()}
|
||||||
|
oninput={on_location_input}
|
||||||
|
placeholder="Enter event location"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
98
frontend/src/components/event_form/categories.rs
Normal file
98
frontend/src/components/event_form/categories.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
use super::types::*;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[function_component(CategoriesTab)]
|
||||||
|
pub fn categories_tab(props: &TabProps) -> Html {
|
||||||
|
let data = &props.data;
|
||||||
|
|
||||||
|
let on_categories_input = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.categories = input.value();
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let add_category = {
|
||||||
|
let data = data.clone();
|
||||||
|
move |category: &str| {
|
||||||
|
let data = data.clone();
|
||||||
|
let category = category.to_string();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
if event_data.categories.is_empty() {
|
||||||
|
event_data.categories = category.clone();
|
||||||
|
} else {
|
||||||
|
event_data.categories = format!("{}, {}", event_data.categories, category);
|
||||||
|
}
|
||||||
|
data.set(event_data);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="tab-panel">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="event-categories">{"Categories"}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="event-categories"
|
||||||
|
class="form-input"
|
||||||
|
value={data.categories.clone()}
|
||||||
|
oninput={on_categories_input}
|
||||||
|
placeholder="work, meeting, personal, project, urgent"
|
||||||
|
/>
|
||||||
|
<p class="form-help-text">{"Enter categories separated by commas to help organize and filter your events"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="categories-suggestions">
|
||||||
|
<h5>{"Common Categories"}</h5>
|
||||||
|
<div class="category-tags">
|
||||||
|
<button type="button" class="category-tag" onclick={add_category("work")}>{"work"}</button>
|
||||||
|
<button type="button" class="category-tag" onclick={add_category("meeting")}>{"meeting"}</button>
|
||||||
|
<button type="button" class="category-tag" onclick={add_category("personal")}>{"personal"}</button>
|
||||||
|
<button type="button" class="category-tag" onclick={add_category("project")}>{"project"}</button>
|
||||||
|
<button type="button" class="category-tag" onclick={add_category("urgent")}>{"urgent"}</button>
|
||||||
|
<button type="button" class="category-tag" onclick={add_category("social")}>{"social"}</button>
|
||||||
|
<button type="button" class="category-tag" onclick={add_category("travel")}>{"travel"}</button>
|
||||||
|
<button type="button" class="category-tag" onclick={add_category("health")}>{"health"}</button>
|
||||||
|
</div>
|
||||||
|
<p class="form-help-text">{"Click to add these common categories to your event"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="categories-info">
|
||||||
|
<h5>{"Event Organization & Filtering"}</h5>
|
||||||
|
<ul>
|
||||||
|
<li>{"Categories help organize events in calendar views"}</li>
|
||||||
|
<li>{"Filter events by category to focus on specific types"}</li>
|
||||||
|
<li>{"Categories are searchable and can be used for reporting"}</li>
|
||||||
|
<li>{"Multiple categories per event are fully supported"}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="categories-examples">
|
||||||
|
<h6>{"Category Usage Examples"}</h6>
|
||||||
|
<div class="category-example">
|
||||||
|
<strong>{"Work Events:"}</strong>
|
||||||
|
<span>{"work, meeting, project, urgent, deadline"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="category-example">
|
||||||
|
<strong>{"Personal Events:"}</strong>
|
||||||
|
<span>{"personal, family, health, social, travel"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="category-example">
|
||||||
|
<strong>{"Mixed Events:"}</strong>
|
||||||
|
<span>{"work, travel, client, important"}</span>
|
||||||
|
</div>
|
||||||
|
<p class="form-help-text">{"Categories follow RFC 5545 CATEGORIES property standards"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
118
frontend/src/components/event_form/location.rs
Normal file
118
frontend/src/components/event_form/location.rs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
use super::types::*;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[function_component(LocationTab)]
|
||||||
|
pub fn location_tab(props: &TabProps) -> Html {
|
||||||
|
let data = &props.data;
|
||||||
|
|
||||||
|
let on_location_input = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.location = input.value();
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let set_location = {
|
||||||
|
let data = data.clone();
|
||||||
|
move |location: &str| {
|
||||||
|
let data = data.clone();
|
||||||
|
let location = location.to_string();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.location = location.clone();
|
||||||
|
data.set(event_data);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="tab-panel">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="event-location-detailed">{"Event Location"}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="event-location-detailed"
|
||||||
|
class="form-input"
|
||||||
|
value={data.location.clone()}
|
||||||
|
oninput={on_location_input}
|
||||||
|
placeholder="Conference Room A, 123 Main St, City, State 12345"
|
||||||
|
/>
|
||||||
|
<p class="form-help-text">{"Enter the full address or location description for the event"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="location-suggestions">
|
||||||
|
<h5>{"Common Locations"}</h5>
|
||||||
|
<div class="location-tags">
|
||||||
|
<button type="button" class="location-tag" onclick={set_location("Conference Room")}>{"Conference Room"}</button>
|
||||||
|
<button type="button" class="location-tag" onclick={set_location("Online Meeting")}>{"Online Meeting"}</button>
|
||||||
|
<button type="button" class="location-tag" onclick={set_location("Main Office")}>{"Main Office"}</button>
|
||||||
|
<button type="button" class="location-tag" onclick={set_location("Client Site")}>{"Client Site"}</button>
|
||||||
|
<button type="button" class="location-tag" onclick={set_location("Home Office")}>{"Home Office"}</button>
|
||||||
|
<button type="button" class="location-tag" onclick={set_location("Remote")}>{"Remote"}</button>
|
||||||
|
</div>
|
||||||
|
<p class="form-help-text">{"Click to quickly set common location types"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="location-info">
|
||||||
|
<h5>{"Location Features & Integration"}</h5>
|
||||||
|
<ul>
|
||||||
|
<li>{"Location information is included in calendar invitations"}</li>
|
||||||
|
<li>{"Supports both physical addresses and virtual meeting links"}</li>
|
||||||
|
<li>{"Compatible with mapping and navigation applications"}</li>
|
||||||
|
<li>{"Room booking integration available for enterprise setups"}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="geo-section">
|
||||||
|
<h6>{"Geographic Coordinates (Advanced)"}</h6>
|
||||||
|
<p>{"Future versions will support:"}</p>
|
||||||
|
<div class="geo-features">
|
||||||
|
<div class="geo-item">
|
||||||
|
<strong>{"GPS Coordinates:"}</strong>
|
||||||
|
<span>{"Precise latitude/longitude positioning"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="geo-item">
|
||||||
|
<strong>{"Map Integration:"}</strong>
|
||||||
|
<span>{"Embedded maps in event details"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="geo-item">
|
||||||
|
<strong>{"Travel Time:"}</strong>
|
||||||
|
<span>{"Automatic travel time calculation"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="geo-item">
|
||||||
|
<strong>{"Proximity Alerts:"}</strong>
|
||||||
|
<span>{"Location-based notifications"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="form-help-text">{"Advanced geographic features will be implemented in future releases"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="virtual-meeting-section">
|
||||||
|
<h6>{"Virtual Meeting Integration"}</h6>
|
||||||
|
<div class="meeting-platforms">
|
||||||
|
<div class="platform-item">
|
||||||
|
<strong>{"Video Conferencing:"}</strong>
|
||||||
|
<span>{"Zoom, Teams, Google Meet links"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="platform-item">
|
||||||
|
<strong>{"Phone Conference:"}</strong>
|
||||||
|
<span>{"Dial-in numbers and access codes"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="platform-item">
|
||||||
|
<strong>{"Webinar Links:"}</strong>
|
||||||
|
<span>{"Live streaming and presentation URLs"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="form-help-text">{"Paste meeting links directly in the location field for virtual events"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
16
frontend/src/components/event_form/mod.rs
Normal file
16
frontend/src/components/event_form/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Event form components module
|
||||||
|
pub mod types;
|
||||||
|
pub mod basic_details;
|
||||||
|
pub mod advanced;
|
||||||
|
pub mod people;
|
||||||
|
pub mod categories;
|
||||||
|
pub mod location;
|
||||||
|
pub mod reminders;
|
||||||
|
|
||||||
|
pub use types::*;
|
||||||
|
pub use basic_details::BasicDetailsTab;
|
||||||
|
pub use advanced::AdvancedTab;
|
||||||
|
pub use people::PeopleTab;
|
||||||
|
pub use categories::CategoriesTab;
|
||||||
|
pub use location::LocationTab;
|
||||||
|
pub use reminders::RemindersTab;
|
||||||
103
frontend/src/components/event_form/people.rs
Normal file
103
frontend/src/components/event_form/people.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
use super::types::*;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::{HtmlInputElement, HtmlTextAreaElement};
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[function_component(PeopleTab)]
|
||||||
|
pub fn people_tab(props: &TabProps) -> Html {
|
||||||
|
let data = &props.data;
|
||||||
|
|
||||||
|
let on_organizer_input = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.organizer = input.value();
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_attendees_input = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Ok(textarea) = target.dyn_into::<HtmlTextAreaElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.attendees = textarea.value();
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="tab-panel">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="event-organizer">{"Organizer"}</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="event-organizer"
|
||||||
|
class="form-input"
|
||||||
|
value={data.organizer.clone()}
|
||||||
|
oninput={on_organizer_input}
|
||||||
|
placeholder="organizer@example.com"
|
||||||
|
/>
|
||||||
|
<p class="form-help-text">{"Email address of the person organizing this event"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="event-attendees">{"Attendees"}</label>
|
||||||
|
<textarea
|
||||||
|
id="event-attendees"
|
||||||
|
class="form-input"
|
||||||
|
value={data.attendees.clone()}
|
||||||
|
oninput={on_attendees_input}
|
||||||
|
placeholder="attendee1@example.com, attendee2@example.com, attendee3@example.com"
|
||||||
|
rows="4"
|
||||||
|
></textarea>
|
||||||
|
<p class="form-help-text">{"Enter attendee email addresses separated by commas"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="people-info">
|
||||||
|
<h5>{"Invitation & Response Management"}</h5>
|
||||||
|
<ul>
|
||||||
|
<li>{"Invitations are sent automatically when the event is saved"}</li>
|
||||||
|
<li>{"Attendees can respond with Accept, Decline, or Tentative"}</li>
|
||||||
|
<li>{"Response tracking follows RFC 5545 PARTSTAT standards"}</li>
|
||||||
|
<li>{"Delegation and role management available after event creation"}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="people-validation">
|
||||||
|
<h6>{"Email Validation"}</h6>
|
||||||
|
<p>{"Email addresses will be validated when you save the event. Invalid emails will be highlighted and must be corrected before proceeding."}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="attendee-roles-preview">
|
||||||
|
<h5>{"Advanced Attendee Features"}</h5>
|
||||||
|
<div class="role-examples">
|
||||||
|
<div class="role-item">
|
||||||
|
<strong>{"Required Participant:"}</strong>
|
||||||
|
<span>{"Must attend for meeting to proceed"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="role-item">
|
||||||
|
<strong>{"Optional Participant:"}</strong>
|
||||||
|
<span>{"Attendance welcome but not required"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="role-item">
|
||||||
|
<strong>{"Resource:"}</strong>
|
||||||
|
<span>{"Meeting room, equipment, or facility"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="role-item">
|
||||||
|
<strong>{"Non-Participant:"}</strong>
|
||||||
|
<span>{"For information only"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="form-help-text">{"Advanced role assignment and RSVP management will be available in future versions"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
100
frontend/src/components/event_form/reminders.rs
Normal file
100
frontend/src/components/event_form/reminders.rs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
use super::types::*;
|
||||||
|
// Types are already imported from super::types::*
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::HtmlSelectElement;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[function_component(RemindersTab)]
|
||||||
|
pub fn reminders_tab(props: &TabProps) -> Html {
|
||||||
|
let data = &props.data;
|
||||||
|
|
||||||
|
let on_reminder_change = {
|
||||||
|
let data = data.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
||||||
|
let mut event_data = (*data).clone();
|
||||||
|
event_data.reminder = match select.value().as_str() {
|
||||||
|
"15min" => ReminderType::Minutes15,
|
||||||
|
"30min" => ReminderType::Minutes30,
|
||||||
|
"1hour" => ReminderType::Hour1,
|
||||||
|
"1day" => ReminderType::Day1,
|
||||||
|
"2days" => ReminderType::Days2,
|
||||||
|
"1week" => ReminderType::Week1,
|
||||||
|
_ => ReminderType::None,
|
||||||
|
};
|
||||||
|
data.set(event_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="tab-panel">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="event-reminder-main">{"Primary Reminder"}</label>
|
||||||
|
<select
|
||||||
|
id="event-reminder-main"
|
||||||
|
class="form-input"
|
||||||
|
onchange={on_reminder_change}
|
||||||
|
>
|
||||||
|
<option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"No reminder"}</option>
|
||||||
|
<option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option>
|
||||||
|
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option>
|
||||||
|
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option>
|
||||||
|
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option>
|
||||||
|
<option value="2days" selected={matches!(data.reminder, ReminderType::Days2)}>{"2 days before"}</option>
|
||||||
|
<option value="1week" selected={matches!(data.reminder, ReminderType::Week1)}>{"1 week before"}</option>
|
||||||
|
</select>
|
||||||
|
<p class="form-help-text">{"Choose when you'd like to be reminded about this event"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reminder-types">
|
||||||
|
<h5>{"Reminder & Alarm Types"}</h5>
|
||||||
|
<div class="alarm-examples">
|
||||||
|
<div class="alarm-type">
|
||||||
|
<strong>{"Display Alarm"}</strong>
|
||||||
|
<p>{"Pop-up notification on your device"}</p>
|
||||||
|
</div>
|
||||||
|
<div class="alarm-type">
|
||||||
|
<strong>{"Email Reminder"}</strong>
|
||||||
|
<p>{"Email notification sent to your address"}</p>
|
||||||
|
</div>
|
||||||
|
<div class="alarm-type">
|
||||||
|
<strong>{"Audio Alert"}</strong>
|
||||||
|
<p>{"Sound notification with custom audio"}</p>
|
||||||
|
</div>
|
||||||
|
<div class="alarm-type">
|
||||||
|
<strong>{"SMS/Text"}</strong>
|
||||||
|
<p>{"Text message reminder (enterprise feature)"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="form-help-text">{"Multiple alarm types follow RFC 5545 VALARM standards"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reminder-info">
|
||||||
|
<h5>{"Advanced Reminder Features"}</h5>
|
||||||
|
<ul>
|
||||||
|
<li>{"Multiple reminders per event with different timing"}</li>
|
||||||
|
<li>{"Custom reminder messages and descriptions"}</li>
|
||||||
|
<li>{"Recurring reminders for recurring events"}</li>
|
||||||
|
<li>{"Snooze and dismiss functionality"}</li>
|
||||||
|
<li>{"Integration with system notifications"}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="attachments-section">
|
||||||
|
<h6>{"File Attachments & Documents"}</h6>
|
||||||
|
<p>{"Future attachment features will include:"}</p>
|
||||||
|
<ul>
|
||||||
|
<li>{"Drag-and-drop file uploads"}</li>
|
||||||
|
<li>{"Document preview and thumbnails"}</li>
|
||||||
|
<li>{"Cloud storage integration (Google Drive, OneDrive)"}</li>
|
||||||
|
<li>{"Version control for updated documents"}</li>
|
||||||
|
<li>{"Shared access permissions for attendees"}</li>
|
||||||
|
</ul>
|
||||||
|
<p class="form-help-text">{"Attachment functionality will be implemented in a future release."}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
225
frontend/src/components/event_form/types.rs
Normal file
225
frontend/src/components/event_form/types.rs
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
use crate::services::calendar_service::CalendarInfo;
|
||||||
|
use chrono::{Local, NaiveDate, NaiveTime};
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
|
pub enum EventStatus {
|
||||||
|
Confirmed,
|
||||||
|
Tentative,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EventStatus {
|
||||||
|
fn default() -> Self {
|
||||||
|
EventStatus::Confirmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
|
pub enum EventClass {
|
||||||
|
Public,
|
||||||
|
Private,
|
||||||
|
Confidential,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EventClass {
|
||||||
|
fn default() -> Self {
|
||||||
|
EventClass::Public
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
|
pub enum ReminderType {
|
||||||
|
None,
|
||||||
|
Minutes15,
|
||||||
|
Minutes30,
|
||||||
|
Hour1,
|
||||||
|
Day1,
|
||||||
|
Days2,
|
||||||
|
Week1,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ReminderType {
|
||||||
|
fn default() -> Self {
|
||||||
|
ReminderType::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
|
pub enum RecurrenceType {
|
||||||
|
None,
|
||||||
|
Daily,
|
||||||
|
Weekly,
|
||||||
|
Monthly,
|
||||||
|
Yearly,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RecurrenceType {
|
||||||
|
fn default() -> Self {
|
||||||
|
RecurrenceType::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum ModalTab {
|
||||||
|
BasicDetails,
|
||||||
|
Advanced,
|
||||||
|
People,
|
||||||
|
Categories,
|
||||||
|
Location,
|
||||||
|
Reminders,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ModalTab {
|
||||||
|
fn default() -> Self {
|
||||||
|
ModalTab::BasicDetails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditAction is now imported from event_context_menu - this duplicate removed
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
|
pub struct EventCreationData {
|
||||||
|
// Basic event info
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub location: String,
|
||||||
|
pub all_day: bool,
|
||||||
|
|
||||||
|
// Timing
|
||||||
|
pub start_date: NaiveDate,
|
||||||
|
pub end_date: NaiveDate,
|
||||||
|
pub start_time: NaiveTime,
|
||||||
|
pub end_time: NaiveTime,
|
||||||
|
|
||||||
|
// Classification
|
||||||
|
pub status: EventStatus,
|
||||||
|
pub class: EventClass,
|
||||||
|
pub priority: Option<u8>,
|
||||||
|
|
||||||
|
// People
|
||||||
|
pub organizer: String,
|
||||||
|
pub attendees: String,
|
||||||
|
|
||||||
|
// Categorization
|
||||||
|
pub categories: String,
|
||||||
|
|
||||||
|
// Reminders
|
||||||
|
pub reminder: ReminderType,
|
||||||
|
|
||||||
|
// Recurrence
|
||||||
|
pub recurrence: RecurrenceType,
|
||||||
|
pub recurrence_interval: u32,
|
||||||
|
pub recurrence_until: Option<NaiveDate>,
|
||||||
|
pub recurrence_count: Option<u32>,
|
||||||
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
|
||||||
|
|
||||||
|
// Advanced recurrence
|
||||||
|
pub monthly_by_day: Option<String>, // e.g., "1MO" for first Monday
|
||||||
|
pub monthly_by_monthday: Option<u8>, // e.g., 15 for 15th day of month
|
||||||
|
pub yearly_by_month: Vec<bool>, // [Jan, Feb, Mar, ...]
|
||||||
|
|
||||||
|
// Calendar selection
|
||||||
|
pub selected_calendar: Option<String>,
|
||||||
|
|
||||||
|
// Edit tracking (for recurring events)
|
||||||
|
pub edit_scope: Option<crate::components::EditAction>,
|
||||||
|
pub changed_fields: Vec<String>,
|
||||||
|
pub original_uid: Option<String>, // Set when editing existing events
|
||||||
|
pub occurrence_date: Option<NaiveDate>, // The specific occurrence date being edited
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventCreationData {
|
||||||
|
pub fn to_create_event_params(&self) -> (
|
||||||
|
String, // title
|
||||||
|
String, // description
|
||||||
|
String, // start_date
|
||||||
|
String, // start_time
|
||||||
|
String, // end_date
|
||||||
|
String, // end_time
|
||||||
|
String, // location
|
||||||
|
bool, // all_day
|
||||||
|
String, // status
|
||||||
|
String, // class
|
||||||
|
Option<u8>, // priority
|
||||||
|
String, // organizer
|
||||||
|
String, // attendees
|
||||||
|
String, // categories
|
||||||
|
String, // reminder
|
||||||
|
String, // recurrence
|
||||||
|
Vec<bool>, // recurrence_days
|
||||||
|
Option<String>, // calendar_path
|
||||||
|
Option<u32>, // recurrence_count
|
||||||
|
Option<String>, // recurrence_until
|
||||||
|
) {
|
||||||
|
(
|
||||||
|
self.title.clone(),
|
||||||
|
self.description.clone(),
|
||||||
|
self.start_date.format("%Y-%m-%d").to_string(),
|
||||||
|
self.start_time.format("%H:%M").to_string(),
|
||||||
|
self.end_date.format("%Y-%m-%d").to_string(),
|
||||||
|
self.end_time.format("%H:%M").to_string(),
|
||||||
|
self.location.clone(),
|
||||||
|
self.all_day,
|
||||||
|
format!("{:?}", self.status).to_uppercase(),
|
||||||
|
format!("{:?}", self.class).to_uppercase(),
|
||||||
|
self.priority,
|
||||||
|
self.organizer.clone(),
|
||||||
|
self.attendees.clone(),
|
||||||
|
self.categories.clone(),
|
||||||
|
format!("{:?}", self.reminder),
|
||||||
|
format!("{:?}", self.recurrence),
|
||||||
|
self.recurrence_days.clone(),
|
||||||
|
self.selected_calendar.clone(),
|
||||||
|
self.recurrence_count,
|
||||||
|
self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EventCreationData {
|
||||||
|
fn default() -> Self {
|
||||||
|
let now_local = Local::now();
|
||||||
|
let start_date = now_local.date_naive();
|
||||||
|
let start_time = NaiveTime::from_hms_opt(9, 0, 0).unwrap_or_default();
|
||||||
|
let end_time = NaiveTime::from_hms_opt(10, 0, 0).unwrap_or_default();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
title: String::new(),
|
||||||
|
description: String::new(),
|
||||||
|
location: String::new(),
|
||||||
|
all_day: false,
|
||||||
|
start_date,
|
||||||
|
end_date: start_date,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
status: EventStatus::default(),
|
||||||
|
class: EventClass::default(),
|
||||||
|
priority: None,
|
||||||
|
organizer: String::new(),
|
||||||
|
attendees: String::new(),
|
||||||
|
categories: String::new(),
|
||||||
|
reminder: ReminderType::default(),
|
||||||
|
recurrence: RecurrenceType::default(),
|
||||||
|
recurrence_interval: 1,
|
||||||
|
recurrence_until: None,
|
||||||
|
recurrence_count: None,
|
||||||
|
recurrence_days: vec![false; 7],
|
||||||
|
monthly_by_day: None,
|
||||||
|
monthly_by_monthday: None,
|
||||||
|
yearly_by_month: vec![false; 12],
|
||||||
|
selected_calendar: None,
|
||||||
|
edit_scope: None,
|
||||||
|
changed_fields: vec![],
|
||||||
|
original_uid: None,
|
||||||
|
occurrence_date: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common props for all tab components
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct TabProps {
|
||||||
|
pub data: UseStateHandle<EventCreationData>,
|
||||||
|
pub available_calendars: Vec<CalendarInfo>,
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct EventModalProps {
|
pub struct EventModalProps {
|
||||||
@@ -63,7 +63,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
html! {
|
html! {
|
||||||
<div class="event-detail">
|
<div class="event-detail">
|
||||||
<strong>{"End:"}</strong>
|
<strong>{"End:"}</strong>
|
||||||
<span>{format_datetime(end, event.all_day)}</span>
|
<span>{format_datetime_end(end, event.all_day)}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -221,6 +221,17 @@ fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_datetime_end(dt: &DateTime<Utc>, all_day: bool) -> String {
|
||||||
|
if all_day {
|
||||||
|
// For all-day events, subtract one day from end date for display
|
||||||
|
// RFC-5545 uses exclusive end dates, but users expect inclusive display
|
||||||
|
let display_date = *dt - chrono::Duration::days(1);
|
||||||
|
display_date.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 {
|
fn format_recurrence_rule(rrule: &str) -> String {
|
||||||
// Basic parsing of RRULE to display user-friendly text
|
// Basic parsing of RRULE to display user-friendly text
|
||||||
if rrule.contains("FREQ=DAILY") {
|
if rrule.contains("FREQ=DAILY") {
|
||||||
@@ -236,4 +247,3 @@ fn format_recurrence_rule(rrule: &str) -> String {
|
|||||||
format!("Custom ({})", rrule)
|
format!("Custom ({})", rrule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use web_sys::HtmlInputElement;
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct LoginProps {
|
pub struct LoginProps {
|
||||||
@@ -9,12 +9,21 @@ pub struct LoginProps {
|
|||||||
|
|
||||||
#[function_component]
|
#[function_component]
|
||||||
pub fn Login(props: &LoginProps) -> Html {
|
pub fn Login(props: &LoginProps) -> Html {
|
||||||
let server_url = use_state(String::new);
|
// Load remembered values from LocalStorage on mount
|
||||||
let username = use_state(String::new);
|
let server_url = use_state(|| {
|
||||||
|
LocalStorage::get::<String>("remembered_server_url").unwrap_or_default()
|
||||||
|
});
|
||||||
|
let username = use_state(|| {
|
||||||
|
LocalStorage::get::<String>("remembered_username").unwrap_or_default()
|
||||||
|
});
|
||||||
let password = use_state(String::new);
|
let password = use_state(String::new);
|
||||||
let error_message = use_state(|| Option::<String>::None);
|
let error_message = use_state(|| Option::<String>::None);
|
||||||
let is_loading = use_state(|| false);
|
let is_loading = use_state(|| false);
|
||||||
|
|
||||||
|
// Remember checkboxes state - default to checked
|
||||||
|
let remember_server = use_state(|| true);
|
||||||
|
let remember_username = use_state(|| true);
|
||||||
|
|
||||||
let server_url_ref = use_node_ref();
|
let server_url_ref = use_node_ref();
|
||||||
let username_ref = use_node_ref();
|
let username_ref = use_node_ref();
|
||||||
let password_ref = use_node_ref();
|
let password_ref = use_node_ref();
|
||||||
@@ -43,6 +52,38 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let on_remember_server_change = {
|
||||||
|
let remember_server = remember_server.clone();
|
||||||
|
let server_url = server_url.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
|
let checked = target.checked();
|
||||||
|
remember_server.set(checked);
|
||||||
|
|
||||||
|
if checked {
|
||||||
|
let _ = LocalStorage::set("remembered_server_url", (*server_url).clone());
|
||||||
|
} else {
|
||||||
|
let _ = LocalStorage::delete("remembered_server_url");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_remember_username_change = {
|
||||||
|
let remember_username = remember_username.clone();
|
||||||
|
let username = username.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
|
let checked = target.checked();
|
||||||
|
remember_username.set(checked);
|
||||||
|
|
||||||
|
if checked {
|
||||||
|
let _ = LocalStorage::set("remembered_username", (*username).clone());
|
||||||
|
} else {
|
||||||
|
let _ = LocalStorage::delete("remembered_username");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let on_submit = {
|
let on_submit = {
|
||||||
let server_url = server_url.clone();
|
let server_url = server_url.clone();
|
||||||
let username = username.clone();
|
let username = username.clone();
|
||||||
@@ -73,11 +114,18 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
web_sys::console::log_1(&"🚀 Starting login process...".into());
|
web_sys::console::log_1(&"🚀 Starting login process...".into());
|
||||||
match perform_login(server_url.clone(), username.clone(), password.clone()).await {
|
match perform_login(server_url.clone(), username.clone(), password.clone()).await {
|
||||||
Ok((token, credentials)) => {
|
Ok((token, session_token, credentials, preferences)) => {
|
||||||
web_sys::console::log_1(&"✅ Login successful!".into());
|
web_sys::console::log_1(&"✅ Login successful!".into());
|
||||||
// Store token and credentials in local storage
|
// Store token and credentials in local storage
|
||||||
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
if let Err(_) = LocalStorage::set("auth_token", &token) {
|
||||||
error_message.set(Some("Failed to store authentication token".to_string()));
|
error_message
|
||||||
|
.set(Some("Failed to store authentication token".to_string()));
|
||||||
|
is_loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(_) = LocalStorage::set("session_token", &session_token) {
|
||||||
|
error_message
|
||||||
|
.set(Some("Failed to store session token".to_string()));
|
||||||
is_loading.set(false);
|
is_loading.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -87,6 +135,11 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store preferences from database
|
||||||
|
if let Ok(prefs_json) = serde_json::to_string(&preferences) {
|
||||||
|
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
||||||
|
}
|
||||||
|
|
||||||
is_loading.set(false);
|
is_loading.set(false);
|
||||||
on_login.emit(token);
|
on_login.emit(token);
|
||||||
}
|
}
|
||||||
@@ -116,6 +169,15 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
onchange={on_server_url_change}
|
onchange={on_server_url_change}
|
||||||
disabled={*is_loading}
|
disabled={*is_loading}
|
||||||
/>
|
/>
|
||||||
|
<div class="remember-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="remember_server"
|
||||||
|
checked={*remember_server}
|
||||||
|
onchange={on_remember_server_change}
|
||||||
|
/>
|
||||||
|
<label for="remember_server">{"Remember server"}</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -129,6 +191,15 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
onchange={on_username_change}
|
onchange={on_username_change}
|
||||||
disabled={*is_loading}
|
disabled={*is_loading}
|
||||||
/>
|
/>
|
||||||
|
<div class="remember-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="remember_username"
|
||||||
|
checked={*remember_username}
|
||||||
|
onchange={on_remember_username_change}
|
||||||
|
/>
|
||||||
|
<label for="remember_username">{"Remember username"}</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -172,7 +243,11 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Perform login using the CalDAV auth service
|
/// Perform login using the CalDAV auth service
|
||||||
async fn perform_login(server_url: String, username: String, password: String) -> Result<(String, String), String> {
|
async fn perform_login(
|
||||||
|
server_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<(String, String, String, serde_json::Value), String> {
|
||||||
use crate::auth::{AuthService, CalDAVLoginRequest};
|
use crate::auth::{AuthService, CalDAVLoginRequest};
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
|
||||||
@@ -182,7 +257,7 @@ async fn perform_login(server_url: String, username: String, password: String) -
|
|||||||
let request = CalDAVLoginRequest {
|
let request = CalDAVLoginRequest {
|
||||||
server_url: server_url.clone(),
|
server_url: server_url.clone(),
|
||||||
username: username.clone(),
|
username: username.clone(),
|
||||||
password: password.clone()
|
password: password.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
|
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
|
||||||
@@ -196,11 +271,21 @@ async fn perform_login(server_url: String, username: String, password: String) -
|
|||||||
"username": username,
|
"username": username,
|
||||||
"password": password
|
"password": password
|
||||||
});
|
});
|
||||||
Ok((response.token, credentials.to_string()))
|
|
||||||
},
|
// Extract preferences as JSON
|
||||||
|
let preferences = serde_json::json!({
|
||||||
|
"calendar_selected_date": response.preferences.calendar_selected_date,
|
||||||
|
"calendar_time_increment": response.preferences.calendar_time_increment,
|
||||||
|
"calendar_view_mode": response.preferences.calendar_view_mode,
|
||||||
|
"calendar_theme": response.preferences.calendar_theme,
|
||||||
|
"calendar_colors": response.preferences.calendar_colors,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((response.token, response.session_token, credentials.to_string(), preferences))
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
|
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
|
||||||
Err(err)
|
Err(err)
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,31 +1,34 @@
|
|||||||
pub mod login;
|
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod calendar_header;
|
|
||||||
pub mod month_view;
|
|
||||||
pub mod week_view;
|
|
||||||
pub mod event_modal;
|
|
||||||
pub mod create_calendar_modal;
|
|
||||||
pub mod context_menu;
|
|
||||||
pub mod event_context_menu;
|
|
||||||
pub mod calendar_context_menu;
|
pub mod calendar_context_menu;
|
||||||
pub mod create_event_modal;
|
pub mod calendar_header;
|
||||||
pub mod sidebar;
|
|
||||||
pub mod calendar_list_item;
|
pub mod calendar_list_item;
|
||||||
pub mod route_handler;
|
pub mod context_menu;
|
||||||
|
pub mod create_calendar_modal;
|
||||||
|
pub mod create_event_modal;
|
||||||
|
pub mod event_context_menu;
|
||||||
|
pub mod event_form;
|
||||||
|
pub mod event_modal;
|
||||||
|
pub mod login;
|
||||||
|
pub mod month_view;
|
||||||
pub mod recurring_edit_modal;
|
pub mod recurring_edit_modal;
|
||||||
|
pub mod route_handler;
|
||||||
|
pub mod sidebar;
|
||||||
|
pub mod week_view;
|
||||||
|
|
||||||
pub use login::Login;
|
|
||||||
pub use calendar::Calendar;
|
pub use calendar::Calendar;
|
||||||
pub use calendar_header::CalendarHeader;
|
|
||||||
pub use month_view::MonthView;
|
|
||||||
pub use week_view::WeekView;
|
|
||||||
pub use event_modal::EventModal;
|
|
||||||
pub use create_calendar_modal::CreateCalendarModal;
|
|
||||||
pub use context_menu::ContextMenu;
|
|
||||||
pub use event_context_menu::{EventContextMenu, DeleteAction, EditAction};
|
|
||||||
pub use calendar_context_menu::CalendarContextMenu;
|
pub use calendar_context_menu::CalendarContextMenu;
|
||||||
pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
|
pub use calendar_header::CalendarHeader;
|
||||||
pub use sidebar::{Sidebar, ViewMode, Theme};
|
|
||||||
pub use calendar_list_item::CalendarListItem;
|
pub use calendar_list_item::CalendarListItem;
|
||||||
|
pub use context_menu::ContextMenu;
|
||||||
|
pub use create_calendar_modal::CreateCalendarModal;
|
||||||
|
pub use create_event_modal::CreateEventModal;
|
||||||
|
// Re-export event form types for backwards compatibility
|
||||||
|
pub use event_form::EventCreationData;
|
||||||
|
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
|
||||||
|
pub use event_modal::EventModal;
|
||||||
|
pub use login::Login;
|
||||||
|
pub use month_view::MonthView;
|
||||||
|
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
|
||||||
pub use route_handler::RouteHandler;
|
pub use route_handler::RouteHandler;
|
||||||
pub use recurring_edit_modal::{RecurringEditModal, RecurringEditAction};
|
pub use sidebar::{Sidebar, Theme, ViewMode};
|
||||||
|
pub use week_view::WeekView;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use yew::prelude::*;
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
use chrono::{Datelike, NaiveDate, Weekday};
|
use chrono::{Datelike, NaiveDate, Weekday};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use web_sys::window;
|
|
||||||
use wasm_bindgen::{prelude::*, JsCast};
|
use wasm_bindgen::{prelude::*, JsCast};
|
||||||
use crate::services::calendar_service::UserInfo;
|
use web_sys::window;
|
||||||
use crate::models::ical::VEvent;
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct MonthViewProps {
|
pub struct MonthViewProps {
|
||||||
@@ -72,7 +72,10 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
}) as Box<dyn Fn()>);
|
}) as Box<dyn Fn()>);
|
||||||
|
|
||||||
if let Some(window) = window() {
|
if let Some(window) = window() {
|
||||||
let _ = window.add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref());
|
let _ = window.add_event_listener_with_callback(
|
||||||
|
"resize",
|
||||||
|
resize_closure.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
resize_closure.forget(); // Keep the closure alive
|
resize_closure.forget(); // Keep the closure alive
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +87,11 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
let get_event_color = |event: &VEvent| -> String {
|
let get_event_color = |event: &VEvent| -> String {
|
||||||
if let Some(user_info) = &props.user_info {
|
if let Some(user_info) = &props.user_info {
|
||||||
if let Some(calendar_path) = &event.calendar_path {
|
if let Some(calendar_path) = &event.calendar_path {
|
||||||
if let Some(calendar) = user_info.calendars.iter()
|
if let Some(calendar) = user_info
|
||||||
.find(|cal| &cal.path == calendar_path) {
|
.calendars
|
||||||
|
.iter()
|
||||||
|
.find(|cal| &cal.path == calendar_path)
|
||||||
|
{
|
||||||
return calendar.color.clone();
|
return calendar.color.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,20 +227,34 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
|
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
|
||||||
let total_slots = 42; // 6 rows x 7 days
|
let total_slots = 42; // 6 rows x 7 days
|
||||||
let used_slots = prev_days_count + current_days_count as usize;
|
let used_slots = prev_days_count + current_days_count as usize;
|
||||||
let remaining_slots = if used_slots < total_slots { total_slots - used_slots } else { 0 };
|
let remaining_slots = if used_slots < total_slots {
|
||||||
|
total_slots - used_slots
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
(1..=remaining_slots).map(|day| {
|
(1..=remaining_slots)
|
||||||
html! {
|
.map(|day| {
|
||||||
<div class="calendar-day next-month">{day}</div>
|
html! {
|
||||||
}
|
<div class="calendar-day next-month">{day}</div>
|
||||||
}).collect::<Html>()
|
}
|
||||||
|
})
|
||||||
|
.collect::<Html>()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_days_in_month(date: NaiveDate) -> u32 {
|
fn get_days_in_month(date: NaiveDate) -> u32 {
|
||||||
NaiveDate::from_ymd_opt(
|
NaiveDate::from_ymd_opt(
|
||||||
if date.month() == 12 { date.year() + 1 } else { date.year() },
|
if date.month() == 12 {
|
||||||
if date.month() == 12 { 1 } else { date.month() + 1 },
|
date.year() + 1
|
||||||
1
|
} else {
|
||||||
|
date.year()
|
||||||
|
},
|
||||||
|
if date.month() == 12 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
date.month() + 1
|
||||||
|
},
|
||||||
|
1,
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.pred_opt()
|
.pred_opt()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use yew::prelude::*;
|
|
||||||
use chrono::NaiveDateTime;
|
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
pub enum RecurringEditAction {
|
pub enum RecurringEditAction {
|
||||||
@@ -25,7 +25,12 @@ pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
|
|||||||
return html! {};
|
return html! {};
|
||||||
}
|
}
|
||||||
|
|
||||||
let event_title = props.event.summary.as_ref().map(|s| s.as_str()).unwrap_or("Untitled Event");
|
let event_title = props
|
||||||
|
.event
|
||||||
|
.summary
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("Untitled Event");
|
||||||
|
|
||||||
let on_this_event = {
|
let on_this_event = {
|
||||||
let on_choice = props.on_choice.clone();
|
let on_choice = props.on_choice.clone();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
use crate::components::{Login, ViewMode};
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
use crate::components::{Login, ViewMode};
|
|
||||||
use crate::services::calendar_service::UserInfo;
|
|
||||||
use crate::models::ical::VEvent;
|
|
||||||
|
|
||||||
#[derive(Clone, Routable, PartialEq)]
|
#[derive(Clone, Routable, PartialEq)]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
@@ -28,7 +28,17 @@ pub struct RouteHandlerProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
|
pub on_event_update_request: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
}
|
}
|
||||||
@@ -106,192 +116,36 @@ pub struct CalendarViewProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
|
pub on_event_update_request: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
chrono::NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
|
||||||
use crate::services::CalendarService;
|
|
||||||
use crate::components::Calendar;
|
use crate::components::Calendar;
|
||||||
use std::collections::HashMap;
|
|
||||||
use chrono::{Local, NaiveDate, Datelike};
|
|
||||||
|
|
||||||
#[function_component(CalendarView)]
|
#[function_component(CalendarView)]
|
||||||
pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
||||||
let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new());
|
|
||||||
let loading = use_state(|| true);
|
|
||||||
let error = use_state(|| None::<String>);
|
|
||||||
let refreshing_event = use_state(|| None::<String>);
|
|
||||||
|
|
||||||
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
|
||||||
|
|
||||||
|
|
||||||
let today = Local::now().date_naive();
|
|
||||||
let current_year = today.year();
|
|
||||||
let current_month = today.month();
|
|
||||||
|
|
||||||
let on_event_click = {
|
|
||||||
let events = events.clone();
|
|
||||||
let refreshing_event = refreshing_event.clone();
|
|
||||||
let auth_token = auth_token.clone();
|
|
||||||
|
|
||||||
Callback::from(move |event: VEvent| {
|
|
||||||
if let Some(token) = auth_token.clone() {
|
|
||||||
let events = events.clone();
|
|
||||||
let refreshing_event = refreshing_event.clone();
|
|
||||||
let uid = event.uid.clone();
|
|
||||||
|
|
||||||
refreshing_event.set(Some(uid.clone()));
|
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
let calendar_service = CalendarService::new();
|
|
||||||
|
|
||||||
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
|
||||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
|
||||||
credentials["password"].as_str().unwrap_or("").to_string()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
match calendar_service.refresh_event(&token, &password, &uid).await {
|
|
||||||
Ok(Some(refreshed_event)) => {
|
|
||||||
let refreshed_vevent = refreshed_event; // CalendarEvent is now VEvent
|
|
||||||
let mut updated_events = (*events).clone();
|
|
||||||
|
|
||||||
for (_, day_events) in updated_events.iter_mut() {
|
|
||||||
day_events.retain(|e| e.uid != uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if refreshed_vevent.rrule.is_some() {
|
|
||||||
let new_occurrences = CalendarService::expand_recurring_events(vec![refreshed_vevent.clone()]);
|
|
||||||
|
|
||||||
for occurrence in new_occurrences {
|
|
||||||
let date = occurrence.get_date();
|
|
||||||
updated_events.entry(date)
|
|
||||||
.or_insert_with(Vec::new)
|
|
||||||
.push(occurrence);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let date = refreshed_vevent.get_date();
|
|
||||||
updated_events.entry(date)
|
|
||||||
.or_insert_with(Vec::new)
|
|
||||||
.push(refreshed_vevent);
|
|
||||||
}
|
|
||||||
|
|
||||||
events.set(updated_events);
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
let mut updated_events = (*events).clone();
|
|
||||||
for (_, day_events) in updated_events.iter_mut() {
|
|
||||||
day_events.retain(|e| e.uid != uid);
|
|
||||||
}
|
|
||||||
events.set(updated_events);
|
|
||||||
}
|
|
||||||
Err(_err) => {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshing_event.set(None);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
{
|
|
||||||
let events = events.clone();
|
|
||||||
let loading = loading.clone();
|
|
||||||
let error = error.clone();
|
|
||||||
let auth_token = auth_token.clone();
|
|
||||||
|
|
||||||
use_effect_with((), move |_| {
|
|
||||||
if let Some(token) = auth_token {
|
|
||||||
let events = events.clone();
|
|
||||||
let loading = loading.clone();
|
|
||||||
let error = error.clone();
|
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
let calendar_service = CalendarService::new();
|
|
||||||
|
|
||||||
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
|
|
||||||
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
|
|
||||||
credentials["password"].as_str().unwrap_or("").to_string()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
match calendar_service.fetch_events_for_month_vevent(&token, &password, current_year, current_month).await {
|
|
||||||
Ok(vevents) => {
|
|
||||||
let grouped_events = CalendarService::group_events_by_date(vevents);
|
|
||||||
events.set(grouped_events);
|
|
||||||
loading.set(false);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
error.set(Some(format!("Failed to load events: {}", err)));
|
|
||||||
loading.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
loading.set(false);
|
|
||||||
error.set(Some("No authentication token found".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
|| ()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="calendar-view">
|
<div class="calendar-view">
|
||||||
{
|
<Calendar
|
||||||
if *loading {
|
user_info={props.user_info.clone()}
|
||||||
html! {
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
<div class="calendar-loading">
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
<p>{"Loading calendar events..."}</p>
|
view={props.view.clone()}
|
||||||
</div>
|
on_create_event_request={props.on_create_event_request.clone()}
|
||||||
}
|
on_event_update_request={props.on_event_update_request.clone()}
|
||||||
} else if let Some(err) = (*error).clone() {
|
context_menus_open={props.context_menus_open}
|
||||||
let dummy_callback = Callback::from(|_: VEvent| {});
|
/>
|
||||||
html! {
|
|
||||||
<div class="calendar-error">
|
|
||||||
<p>{format!("Error: {}", err)}</p>
|
|
||||||
<Calendar
|
|
||||||
events={HashMap::new()}
|
|
||||||
on_event_click={dummy_callback}
|
|
||||||
refreshing_event_uid={(*refreshing_event).clone()}
|
|
||||||
user_info={props.user_info.clone()}
|
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
|
||||||
view={props.view.clone()}
|
|
||||||
on_create_event_request={props.on_create_event_request.clone()}
|
|
||||||
on_event_update_request={props.on_event_update_request.clone()}
|
|
||||||
context_menus_open={props.context_menus_open}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
<Calendar
|
|
||||||
events={(*events).clone()}
|
|
||||||
on_event_click={on_event_click}
|
|
||||||
refreshing_event_uid={(*refreshing_event).clone()}
|
|
||||||
user_info={props.user_info.clone()}
|
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
|
||||||
view={props.view.clone()}
|
|
||||||
on_create_event_request={props.on_create_event_request.clone()}
|
|
||||||
on_event_update_request={props.on_event_update_request.clone()}
|
|
||||||
context_menus_open={props.context_menus_open}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
|
use crate::components::CalendarListItem;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
|
use web_sys::HtmlSelectElement;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
use web_sys::HtmlSelectElement;
|
|
||||||
use crate::services::calendar_service::UserInfo;
|
|
||||||
use crate::components::CalendarListItem;
|
|
||||||
|
|
||||||
#[derive(Clone, Routable, PartialEq)]
|
#[derive(Clone, Routable, PartialEq)]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
@@ -32,8 +32,13 @@ pub enum Theme {
|
|||||||
Mint,
|
Mint,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Theme {
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum Style {
|
||||||
|
Default,
|
||||||
|
Google,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
pub fn value(&self) -> &'static str {
|
pub fn value(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Theme::Default => "default",
|
Theme::Default => "default",
|
||||||
@@ -61,6 +66,30 @@ impl Theme {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
impl Default for ViewMode {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
ViewMode::Month
|
ViewMode::Month
|
||||||
@@ -81,6 +110,8 @@ pub struct SidebarProps {
|
|||||||
pub on_view_change: Callback<ViewMode>,
|
pub on_view_change: Callback<ViewMode>,
|
||||||
pub current_theme: Theme,
|
pub current_theme: Theme,
|
||||||
pub on_theme_change: Callback<Theme>,
|
pub on_theme_change: Callback<Theme>,
|
||||||
|
pub current_style: Style,
|
||||||
|
pub on_style_change: Callback<Style>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component(Sidebar)]
|
#[function_component(Sidebar)]
|
||||||
@@ -112,10 +143,22 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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! {
|
html! {
|
||||||
<aside class="app-sidebar">
|
<aside class="app-sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h1>{"Calendar App"}</h1>
|
<h1>{"Runway"}</h1>
|
||||||
{
|
{
|
||||||
if let Some(ref info) = props.user_info {
|
if let Some(ref info) = props.user_info {
|
||||||
html! {
|
html! {
|
||||||
@@ -188,6 +231,13 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="style-selector">
|
||||||
|
<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>
|
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use yew::prelude::*;
|
use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal};
|
||||||
use chrono::{Datelike, NaiveDate, Duration, Weekday, Local, Timelike, NaiveDateTime, NaiveTime};
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::UserInfo;
|
||||||
|
use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Weekday};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
use crate::services::calendar_service::UserInfo;
|
use yew::prelude::*;
|
||||||
use crate::models::ical::VEvent;
|
|
||||||
use crate::components::{RecurringEditModal, RecurringEditAction, EventCreationData};
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct WeekViewProps {
|
pub struct WeekViewProps {
|
||||||
@@ -25,7 +25,17 @@ pub struct WeekViewProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_event_update: Option<Callback<(VEvent, NaiveDateTime, NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
|
pub on_event_update: Option<
|
||||||
|
Callback<(
|
||||||
|
VEvent,
|
||||||
|
NaiveDateTime,
|
||||||
|
NaiveDateTime,
|
||||||
|
bool,
|
||||||
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
)>,
|
||||||
|
>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
@@ -47,16 +57,14 @@ struct DragState {
|
|||||||
start_date: NaiveDate,
|
start_date: NaiveDate,
|
||||||
start_y: f64,
|
start_y: f64,
|
||||||
current_y: f64,
|
current_y: f64,
|
||||||
offset_y: f64, // For event moves, this is the offset from the event's top
|
offset_y: f64, // For event moves, this is the offset from the event's top
|
||||||
has_moved: bool, // Track if we've moved enough to constitute a real drag
|
has_moved: bool, // Track if we've moved enough to constitute a real drag
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component(WeekView)]
|
#[function_component(WeekView)]
|
||||||
pub fn week_view(props: &WeekViewProps) -> Html {
|
pub fn week_view(props: &WeekViewProps) -> Html {
|
||||||
let start_of_week = get_start_of_week(props.current_date);
|
let start_of_week = get_start_of_week(props.current_date);
|
||||||
let week_days: Vec<NaiveDate> = (0..7)
|
let week_days: Vec<NaiveDate> = (0..7).map(|i| start_of_week + Duration::days(i)).collect();
|
||||||
.map(|i| start_of_week + Duration::days(i))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Drag state for event creation
|
// Drag state for event creation
|
||||||
let drag_state = use_state(|| None::<DragState>);
|
let drag_state = use_state(|| None::<DragState>);
|
||||||
@@ -75,8 +83,11 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let get_event_color = |event: &VEvent| -> String {
|
let get_event_color = |event: &VEvent| -> String {
|
||||||
if let Some(user_info) = &props.user_info {
|
if let Some(user_info) = &props.user_info {
|
||||||
if let Some(calendar_path) = &event.calendar_path {
|
if let Some(calendar_path) = &event.calendar_path {
|
||||||
if let Some(calendar) = user_info.calendars.iter()
|
if let Some(calendar) = user_info
|
||||||
.find(|cal| &cal.path == calendar_path) {
|
.calendars
|
||||||
|
.iter()
|
||||||
|
.find(|cal| &cal.path == calendar_path)
|
||||||
|
{
|
||||||
return calendar.color.clone();
|
return calendar.color.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,22 +95,20 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
"#3B82F6".to_string()
|
"#3B82F6".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate time labels - 24 hours plus the final midnight boundary
|
// Generate time labels - 24 hours
|
||||||
let mut time_labels: Vec<String> = (0..24).map(|hour| {
|
let time_labels: Vec<String> = (0..24)
|
||||||
if hour == 0 {
|
.map(|hour| {
|
||||||
"12 AM".to_string()
|
if hour == 0 {
|
||||||
} else if hour < 12 {
|
"12 AM".to_string()
|
||||||
format!("{} AM", hour)
|
} else if hour < 12 {
|
||||||
} else if hour == 12 {
|
format!("{} AM", hour)
|
||||||
"12 PM".to_string()
|
} else if hour == 12 {
|
||||||
} else {
|
"12 PM".to_string()
|
||||||
format!("{} PM", hour - 12)
|
} else {
|
||||||
}
|
format!("{} PM", hour - 12)
|
||||||
}).collect();
|
}
|
||||||
|
})
|
||||||
// Add the final midnight boundary to show where the day ends
|
.collect();
|
||||||
time_labels.push("12 AM".to_string());
|
|
||||||
|
|
||||||
|
|
||||||
// Handlers for recurring event modification modal
|
// Handlers for recurring event modification modal
|
||||||
let on_recurring_choice = {
|
let on_recurring_choice = {
|
||||||
@@ -141,16 +150,16 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// 1. Add EXDATE to original series (excludes this occurrence)
|
// 1. Add EXDATE to original series (excludes this occurrence)
|
||||||
// 2. Create exception event with RECURRENCE-ID and user's modifications
|
// 2. Create exception event with RECURRENCE-ID and user's modifications
|
||||||
update_callback.emit((
|
update_callback.emit((
|
||||||
edit.event.clone(), // Original event (series to modify)
|
edit.event.clone(), // Original event (series to modify)
|
||||||
edit.new_start, // Dragged start time for exception
|
edit.new_start, // Dragged start time for exception
|
||||||
edit.new_end, // Dragged end time for exception
|
edit.new_end, // Dragged end time for exception
|
||||||
true, // preserve_rrule = true
|
true, // preserve_rrule = true
|
||||||
None, // No until_date for this_only
|
None, // No until_date for this_only
|
||||||
Some("this_only".to_string()), // Update scope
|
Some("this_only".to_string()), // Update scope
|
||||||
Some(occurrence_date) // Date of occurrence being modified
|
Some(occurrence_date), // Date of occurrence being modified
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
RecurringEditAction::FutureEvents => {
|
RecurringEditAction::FutureEvents => {
|
||||||
// RFC 5545 Compliant Series Splitting: "This and Future Events"
|
// RFC 5545 Compliant Series Splitting: "This and Future Events"
|
||||||
//
|
//
|
||||||
@@ -177,7 +186,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
if let Some(update_callback) = &on_event_update {
|
if let Some(update_callback) = &on_event_update {
|
||||||
// Find the original series event (not the occurrence)
|
// Find the original series event (not the occurrence)
|
||||||
// UIDs like "uuid-timestamp" need to split on the last hyphen, not the first
|
// UIDs like "uuid-timestamp" need to split on the last hyphen, not the first
|
||||||
let base_uid = if let Some(last_hyphen_pos) = edit.event.uid.rfind('-') {
|
let base_uid = if let Some(last_hyphen_pos) = edit.event.uid.rfind('-')
|
||||||
|
{
|
||||||
let suffix = &edit.event.uid[last_hyphen_pos + 1..];
|
let suffix = &edit.event.uid[last_hyphen_pos + 1..];
|
||||||
// Check if suffix is numeric (timestamp), if so remove it
|
// Check if suffix is numeric (timestamp), if so remove it
|
||||||
if suffix.chars().all(|c| c.is_numeric()) {
|
if suffix.chars().all(|c| c.is_numeric()) {
|
||||||
@@ -189,7 +199,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
edit.event.uid.clone()
|
edit.event.uid.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🔍 Looking for original series: '{}' from occurrence: '{}'", base_uid, edit.event.uid).into());
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🔍 Looking for original series: '{}' from occurrence: '{}'",
|
||||||
|
base_uid, edit.event.uid
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
// Find the original series event by searching for the base UID
|
// Find the original series event by searching for the base UID
|
||||||
let mut original_series = None;
|
let mut original_series = None;
|
||||||
@@ -207,9 +223,12 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
|
|
||||||
let original_series = match original_series {
|
let original_series = match original_series {
|
||||||
Some(series) => {
|
Some(series) => {
|
||||||
web_sys::console::log_1(&format!("✅ Found original series: '{}'", series.uid).into());
|
web_sys::console::log_1(
|
||||||
|
&format!("✅ Found original series: '{}'", series.uid)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
series
|
series
|
||||||
},
|
}
|
||||||
None => {
|
None => {
|
||||||
web_sys::console::log_1(&format!("⚠️ Could not find original series '{}', using occurrence but fixing UID", base_uid).into());
|
web_sys::console::log_1(&format!("⚠️ Could not find original series '{}', using occurrence but fixing UID", base_uid).into());
|
||||||
let mut fallback_event = edit.event.clone();
|
let mut fallback_event = edit.event.clone();
|
||||||
@@ -220,9 +239,15 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Calculate the day before this occurrence for UNTIL clause
|
// Calculate the day before this occurrence for UNTIL clause
|
||||||
let until_date = edit.event.dtstart.date_naive() - chrono::Duration::days(1);
|
let until_date =
|
||||||
let until_datetime = until_date.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
|
edit.event.dtstart.date_naive() - chrono::Duration::days(1);
|
||||||
let until_utc = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(until_datetime, chrono::Utc);
|
let until_datetime = until_date
|
||||||
|
.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
|
||||||
|
let until_utc =
|
||||||
|
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
|
||||||
|
until_datetime,
|
||||||
|
chrono::Utc,
|
||||||
|
);
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",
|
web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",
|
||||||
until_utc.format("%Y-%m-%d %H:%M:%S UTC"),
|
until_utc.format("%Y-%m-%d %H:%M:%S UTC"),
|
||||||
@@ -243,24 +268,32 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// 1. Add UNTIL clause to original series (stops before occurrence_date)
|
// 1. Add UNTIL clause to original series (stops before occurrence_date)
|
||||||
// 2. Create new series starting from occurrence_date with dragged times
|
// 2. Create new series starting from occurrence_date with dragged times
|
||||||
update_callback.emit((
|
update_callback.emit((
|
||||||
original_series, // Original event to terminate
|
original_series, // Original event to terminate
|
||||||
new_start, // Dragged start time for new series
|
new_start, // Dragged start time for new series
|
||||||
new_end, // Dragged end time for new series
|
new_end, // Dragged end time for new series
|
||||||
true, // preserve_rrule = true
|
true, // preserve_rrule = true
|
||||||
Some(until_utc), // UNTIL date for original series
|
Some(until_utc), // UNTIL date for original series
|
||||||
Some("this_and_future".to_string()), // Update scope
|
Some("this_and_future".to_string()), // Update scope
|
||||||
Some(occurrence_date) // Date of occurrence being modified
|
Some(occurrence_date), // Date of occurrence being modified
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
RecurringEditAction::AllEvents => {
|
RecurringEditAction::AllEvents => {
|
||||||
// Modify the entire series
|
// Modify the entire series
|
||||||
let series_event = edit.event.clone();
|
let series_event = edit.event.clone();
|
||||||
|
|
||||||
if let Some(callback) = &on_event_update {
|
if let Some(callback) = &on_event_update {
|
||||||
callback.emit((series_event, edit.new_start, edit.new_end, true, None, Some("all_in_series".to_string()), None)); // Regular drag operation - preserve RRULE, update_scope = all_in_series
|
callback.emit((
|
||||||
|
series_event,
|
||||||
|
edit.new_start,
|
||||||
|
edit.new_end,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
Some("all_in_series".to_string()),
|
||||||
|
None,
|
||||||
|
)); // Regular drag operation - preserve RRULE, update_scope = all_in_series
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pending_recurring_edit.set(None);
|
pending_recurring_edit.set(None);
|
||||||
@@ -284,10 +317,75 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let is_today = *date == props.today;
|
let is_today = *date == props.today;
|
||||||
let weekday_name = get_weekday_name(date.weekday());
|
let weekday_name = get_weekday_name(date.weekday());
|
||||||
|
|
||||||
|
// Collect all-day events that span this date (from any day in the week)
|
||||||
|
let mut all_day_events: Vec<&VEvent> = Vec::new();
|
||||||
|
for events_list in props.events.values() {
|
||||||
|
for event in events_list {
|
||||||
|
if event.all_day && event_spans_date(event, *date) {
|
||||||
|
all_day_events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove duplicates (same event might appear in multiple day buckets)
|
||||||
|
all_day_events.sort_by_key(|e| &e.uid);
|
||||||
|
all_day_events.dedup_by_key(|e| &e.uid);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
|
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
|
||||||
<div class="weekday-name">{weekday_name}</div>
|
<div class="day-header-content">
|
||||||
<div class="day-number">{date.day()}</div>
|
<div class="weekday-name">{weekday_name}</div>
|
||||||
|
<div class="day-number">{date.day()}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// All-day events section
|
||||||
|
{if !all_day_events.is_empty() {
|
||||||
|
html! {
|
||||||
|
<div class="all-day-events">
|
||||||
|
{
|
||||||
|
all_day_events.iter().map(|event| {
|
||||||
|
let event_color = get_event_color(event);
|
||||||
|
let onclick = {
|
||||||
|
let on_event_click = props.on_event_click.clone();
|
||||||
|
let event = (*event).clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
e.stop_propagation();
|
||||||
|
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();
|
||||||
|
e.stop_propagation(); // Prevent calendar context menu from also triggering
|
||||||
|
callback.emit((e, event.clone()));
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
class="all-day-event"
|
||||||
|
style={format!("background-color: {}", event_color)}
|
||||||
|
{onclick}
|
||||||
|
{oncontextmenu}
|
||||||
|
>
|
||||||
|
<span class="all-day-event-title">
|
||||||
|
{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
@@ -296,14 +394,17 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
|
|
||||||
// Scrollable content area with time grid
|
// Scrollable content area with time grid
|
||||||
<div class="week-content">
|
<div class="week-content">
|
||||||
<div class="time-grid">
|
<div class={classes!("time-grid", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
||||||
// Time labels
|
// Time labels
|
||||||
<div class="time-labels">
|
<div class={classes!("time-labels", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
||||||
{
|
{
|
||||||
time_labels.iter().enumerate().map(|(index, time)| {
|
time_labels.iter().map(|time| {
|
||||||
let is_final = index == time_labels.len() - 1;
|
let is_quarter_mode = props.time_increment == 15;
|
||||||
html! {
|
html! {
|
||||||
<div class={classes!("time-label", if is_final { Some("final-boundary") } else { None })}>
|
<div class={classes!(
|
||||||
|
"time-label",
|
||||||
|
if is_quarter_mode { Some("quarter-mode") } else { None }
|
||||||
|
)}>
|
||||||
{time}
|
{time}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -312,11 +413,12 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Day columns
|
// Day columns
|
||||||
<div class="week-days-grid">
|
<div class={classes!("week-days-grid", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
||||||
{
|
{
|
||||||
week_days.iter().enumerate().map(|(_column_index, date)| {
|
week_days.iter().enumerate().map(|(_column_index, date)| {
|
||||||
let is_today = *date == props.today;
|
let is_today = *date == props.today;
|
||||||
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
||||||
|
let event_layouts = calculate_event_layout(&day_events, *date, props.time_increment);
|
||||||
|
|
||||||
// Drag event handlers
|
// Drag event handlers
|
||||||
let drag_state_clone = drag_state.clone();
|
let drag_state_clone = drag_state.clone();
|
||||||
@@ -362,6 +464,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let drag_state = drag_state_clone.clone();
|
let drag_state = drag_state_clone.clone();
|
||||||
let time_increment = props.time_increment;
|
let time_increment = props.time_increment;
|
||||||
Callback::from(move |e: MouseEvent| {
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
// Only process mouse move if a button is still pressed
|
||||||
|
if e.buttons() == 0 {
|
||||||
|
// No mouse button pressed, clear drag state
|
||||||
|
drag_state.set(None);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(mut current_drag) = (*drag_state).clone() {
|
if let Some(mut current_drag) = (*drag_state).clone() {
|
||||||
if current_drag.is_dragging {
|
if current_drag.is_dragging {
|
||||||
// Use layer_y for consistent coordinate calculation
|
// Use layer_y for consistent coordinate calculation
|
||||||
@@ -400,8 +509,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
match ¤t_drag.drag_type {
|
match ¤t_drag.drag_type {
|
||||||
DragType::CreateEvent => {
|
DragType::CreateEvent => {
|
||||||
// Calculate start and end times
|
// Calculate start and end times
|
||||||
let start_time = pixels_to_time(current_drag.start_y);
|
let start_time = pixels_to_time(current_drag.start_y, time_increment);
|
||||||
let end_time = pixels_to_time(current_drag.current_y);
|
let end_time = pixels_to_time(current_drag.current_y, time_increment);
|
||||||
|
|
||||||
// Ensure start is before end
|
// Ensure start is before end
|
||||||
let (actual_start, actual_end) = if start_time <= end_time {
|
let (actual_start, actual_end) = if start_time <= end_time {
|
||||||
@@ -429,7 +538,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let unsnapped_position = current_drag.current_y - current_drag.offset_y;
|
let unsnapped_position = current_drag.current_y - current_drag.offset_y;
|
||||||
// Snap the final position to maintain time increment alignment
|
// Snap the final position to maintain time increment alignment
|
||||||
let event_top_position = snap_to_increment(unsnapped_position, time_increment);
|
let event_top_position = snap_to_increment(unsnapped_position, time_increment);
|
||||||
let new_start_time = pixels_to_time(event_top_position);
|
let new_start_time = pixels_to_time(event_top_position, time_increment);
|
||||||
|
|
||||||
// Calculate duration from original event
|
// Calculate duration from original event
|
||||||
let original_duration = if let Some(end) = event.dtend {
|
let original_duration = if let Some(end) = event.dtend {
|
||||||
@@ -458,7 +567,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
},
|
},
|
||||||
DragType::ResizeEventStart(event) => {
|
DragType::ResizeEventStart(event) => {
|
||||||
// Calculate new start time based on drag position
|
// Calculate new start time based on drag position
|
||||||
let new_start_time = pixels_to_time(current_drag.current_y);
|
let new_start_time = pixels_to_time(current_drag.current_y, time_increment);
|
||||||
|
|
||||||
// Keep the original end time
|
// Keep the original end time
|
||||||
let original_end = if let Some(end) = event.dtend {
|
let original_end = if let Some(end) = event.dtend {
|
||||||
@@ -494,7 +603,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
},
|
},
|
||||||
DragType::ResizeEventEnd(event) => {
|
DragType::ResizeEventEnd(event) => {
|
||||||
// Calculate new end time based on drag position
|
// Calculate new end time based on drag position
|
||||||
let new_end_time = pixels_to_time(current_drag.current_y);
|
let new_end_time = pixels_to_time(current_drag.current_y, time_increment);
|
||||||
|
|
||||||
// Keep the original start time
|
// Keep the original start time
|
||||||
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
||||||
@@ -531,9 +640,21 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if currently dragging to create an event
|
||||||
|
let is_creating_event = if let Some(drag) = (*drag_state).clone() {
|
||||||
|
matches!(drag.drag_type, DragType::CreateEvent) && drag.is_dragging
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class={classes!("week-day-column", if is_today { Some("today") } else { None })}
|
class={classes!(
|
||||||
|
"week-day-column",
|
||||||
|
if is_today { Some("today") } else { None },
|
||||||
|
if is_creating_event { Some("creating-event") } else { None },
|
||||||
|
if props.time_increment == 15 { Some("quarter-mode") } else { None }
|
||||||
|
)}
|
||||||
{onmousedown}
|
{onmousedown}
|
||||||
{onmousemove}
|
{onmousemove}
|
||||||
{onmouseup}
|
{onmouseup}
|
||||||
@@ -541,28 +662,39 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Time slot backgrounds - 24 hour slots to represent full day
|
// Time slot backgrounds - 24 hour slots to represent full day
|
||||||
{
|
{
|
||||||
(0..24).map(|_hour| {
|
(0..24).map(|_hour| {
|
||||||
|
let slots_per_hour = 60 / props.time_increment;
|
||||||
html! {
|
html! {
|
||||||
<div class="time-slot">
|
<div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
||||||
<div class="time-slot-half"></div>
|
{
|
||||||
<div class="time-slot-half"></div>
|
(0..slots_per_hour).map(|_slot| {
|
||||||
|
let slot_class = if props.time_increment == 15 {
|
||||||
|
"time-slot-quarter"
|
||||||
|
} else {
|
||||||
|
"time-slot-half"
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<div class={slot_class}></div>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
}
|
}
|
||||||
// Final boundary slot to complete the 24-hour visual grid - make it interactive like other slots
|
|
||||||
<div class="time-slot boundary-slot">
|
|
||||||
<div class="time-slot-half"></div>
|
|
||||||
<div class="time-slot-half"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Events positioned absolutely based on their actual times
|
// Events positioned absolutely based on their actual times
|
||||||
<div class="events-container">
|
<div class="events-container">
|
||||||
{
|
{
|
||||||
day_events.iter().filter_map(|event| {
|
day_events.iter().enumerate().filter_map(|(event_idx, event)| {
|
||||||
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
|
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date, props.time_increment);
|
||||||
|
|
||||||
|
// Skip all-day events (they're rendered in the header)
|
||||||
|
if is_all_day {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip events that don't belong on this date or have invalid positioning
|
// Skip events that don't belong on this date or have invalid positioning
|
||||||
if start_pixels == 0.0 && duration_pixels == 0.0 && !is_all_day {
|
if start_pixels == 0.0 && duration_pixels == 0.0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,7 +714,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let drag_state = drag_state.clone();
|
let drag_state = drag_state.clone();
|
||||||
let event_for_drag = event.clone();
|
let event_for_drag = event.clone();
|
||||||
let date_for_drag = *date;
|
let date_for_drag = *date;
|
||||||
let _time_increment = props.time_increment;
|
let time_increment = props.time_increment;
|
||||||
Callback::from(move |e: MouseEvent| {
|
Callback::from(move |e: MouseEvent| {
|
||||||
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
|
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
|
||||||
|
|
||||||
@@ -596,7 +728,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 };
|
let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 };
|
||||||
|
|
||||||
// Get event's current position in day column coordinates
|
// Get event's current position in day column coordinates
|
||||||
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag);
|
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment);
|
||||||
let event_start_pixels = event_start_pixels as f64;
|
let event_start_pixels = event_start_pixels as f64;
|
||||||
|
|
||||||
// Convert click position to day column coordinates
|
// Convert click position to day column coordinates
|
||||||
@@ -751,12 +883,28 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
if is_refreshing { Some("refreshing") } else { None },
|
if is_refreshing { Some("refreshing") } else { None },
|
||||||
if is_all_day { Some("all-day") } else { None }
|
if is_all_day { Some("all-day") } else { None }
|
||||||
)}
|
)}
|
||||||
style={format!(
|
style={
|
||||||
"background-color: {}; top: {}px; height: {}px;",
|
let (column_idx, total_columns) = event_layouts[event_idx];
|
||||||
event_color,
|
let column_width = if total_columns > 1 {
|
||||||
start_pixels,
|
format!("calc((100% - 8px) / {})", total_columns) // Account for 4px margins on each side
|
||||||
duration_pixels
|
} else {
|
||||||
)}
|
"calc(100% - 8px)".to_string()
|
||||||
|
};
|
||||||
|
let left_offset = if total_columns > 1 {
|
||||||
|
format!("calc(4px + {} * (100% - 8px) / {})", column_idx, total_columns)
|
||||||
|
} else {
|
||||||
|
"4px".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"background-color: {}; top: {}px; height: {}px; left: {}; width: {}; right: auto;",
|
||||||
|
event_color,
|
||||||
|
start_pixels,
|
||||||
|
duration_pixels,
|
||||||
|
left_offset,
|
||||||
|
column_width
|
||||||
|
)
|
||||||
|
}
|
||||||
{onclick}
|
{onclick}
|
||||||
{oncontextmenu}
|
{oncontextmenu}
|
||||||
onmousedown={onmousedown_event}
|
onmousedown={onmousedown_event}
|
||||||
@@ -776,7 +924,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Event content
|
// Event content
|
||||||
<div class="event-content">
|
<div class="event-content">
|
||||||
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
||||||
{if !is_all_day {
|
{if !is_all_day && duration_pixels > 30.0 {
|
||||||
html! { <div class="event-time">{time_display}</div> }
|
html! { <div class="event-time">{time_display}</div> }
|
||||||
} else {
|
} else {
|
||||||
html! {}
|
html! {}
|
||||||
@@ -804,7 +952,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Temporary event box during drag
|
// Temporary event box during drag
|
||||||
{
|
{
|
||||||
if let Some(drag) = (*drag_state).clone() {
|
if let Some(drag) = (*drag_state).clone() {
|
||||||
if drag.is_dragging && drag.start_date == *date {
|
if drag.is_dragging && drag.has_moved && drag.start_date == *date {
|
||||||
match &drag.drag_type {
|
match &drag.drag_type {
|
||||||
DragType::CreateEvent => {
|
DragType::CreateEvent => {
|
||||||
let start_y = drag.start_y.min(drag.current_y);
|
let start_y = drag.start_y.min(drag.current_y);
|
||||||
@@ -812,8 +960,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let height = (drag.current_y - drag.start_y).abs().max(20.0);
|
let height = (drag.current_y - drag.start_y).abs().max(20.0);
|
||||||
|
|
||||||
// Convert pixels to times for display
|
// Convert pixels to times for display
|
||||||
let start_time = pixels_to_time(start_y);
|
let start_time = pixels_to_time(start_y, props.time_increment);
|
||||||
let end_time = pixels_to_time(end_y);
|
let end_time = pixels_to_time(end_y, props.time_increment);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
@@ -829,7 +977,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let unsnapped_position = drag.current_y - drag.offset_y;
|
let unsnapped_position = drag.current_y - drag.offset_y;
|
||||||
// Snap the final position to maintain time increment alignment
|
// Snap the final position to maintain time increment alignment
|
||||||
let preview_position = snap_to_increment(unsnapped_position, props.time_increment);
|
let preview_position = snap_to_increment(unsnapped_position, props.time_increment);
|
||||||
let new_start_time = pixels_to_time(preview_position);
|
let new_start_time = pixels_to_time(preview_position, props.time_increment);
|
||||||
let original_duration = if let Some(end) = event.dtend {
|
let original_duration = if let Some(end) = event.dtend {
|
||||||
end.signed_duration_since(event.dtstart)
|
end.signed_duration_since(event.dtstart)
|
||||||
} else {
|
} else {
|
||||||
@@ -846,13 +994,17 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", preview_position, duration_pixels, event_color)}
|
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", preview_position, duration_pixels, event_color)}
|
||||||
>
|
>
|
||||||
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
||||||
<div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div>
|
{if duration_pixels > 30.0 {
|
||||||
|
html! { <div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div> }
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
DragType::ResizeEventStart(event) => {
|
DragType::ResizeEventStart(event) => {
|
||||||
// Show the event being resized from the start
|
// Show the event being resized from the start
|
||||||
let new_start_time = pixels_to_time(drag.current_y);
|
let new_start_time = pixels_to_time(drag.current_y, props.time_increment);
|
||||||
let original_end = if let Some(end) = event.dtend {
|
let original_end = if let Some(end) = event.dtend {
|
||||||
end.with_timezone(&chrono::Local).naive_local()
|
end.with_timezone(&chrono::Local).naive_local()
|
||||||
} else {
|
} else {
|
||||||
@@ -860,7 +1012,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Calculate positions for the preview
|
// Calculate positions for the preview
|
||||||
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date);
|
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment);
|
||||||
let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local());
|
let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local());
|
||||||
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
|
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
|
||||||
|
|
||||||
@@ -875,17 +1027,21 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", new_start_pixels, new_height, event_color)}
|
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", new_start_pixels, new_height, event_color)}
|
||||||
>
|
>
|
||||||
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
||||||
<div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), original_end.time().format("%I:%M %p"))}</div>
|
{if new_height > 30.0 {
|
||||||
|
html! { <div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), original_end.time().format("%I:%M %p"))}</div> }
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
DragType::ResizeEventEnd(event) => {
|
DragType::ResizeEventEnd(event) => {
|
||||||
// Show the event being resized from the end
|
// Show the event being resized from the end
|
||||||
let new_end_time = pixels_to_time(drag.current_y);
|
let new_end_time = pixels_to_time(drag.current_y, props.time_increment);
|
||||||
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
||||||
|
|
||||||
// Calculate positions for the preview
|
// Calculate positions for the preview
|
||||||
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date);
|
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment);
|
||||||
|
|
||||||
let new_end_pixels = drag.current_y;
|
let new_end_pixels = drag.current_y;
|
||||||
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
|
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
|
||||||
@@ -898,7 +1054,11 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", original_start_pixels, new_height, event_color)}
|
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", original_start_pixels, new_height, event_color)}
|
||||||
>
|
>
|
||||||
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
||||||
<div class="event-time">{format!("{} - {}", original_start.time().format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div>
|
{if new_height > 30.0 {
|
||||||
|
html! { <div class="event-time">{format!("{} - {}", original_start.time().format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div> }
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -962,22 +1122,25 @@ fn get_weekday_name(weekday: Weekday) -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the pixel position of an event based on its time
|
// Calculate the pixel position of an event based on its time
|
||||||
// Each hour is 60px, so we convert time to pixels
|
// Snap pixel position based on time increment and grid scaling
|
||||||
// Snap pixel position to 15-minute increments (15px = 15 minutes since 60px = 60 minutes)
|
// In 30-minute mode: 60px per hour (1px = 1 minute)
|
||||||
|
// In 15-minute mode: 120px per hour (2px = 1 minute)
|
||||||
fn snap_to_increment(pixels: f64, increment: u32) -> f64 {
|
fn snap_to_increment(pixels: f64, increment: u32) -> f64 {
|
||||||
let increment_px = increment as f64; // Convert to pixels (1px = 1 minute)
|
let pixels_per_minute = if increment == 15 { 2.0 } else { 1.0 };
|
||||||
|
let increment_px = increment as f64 * pixels_per_minute;
|
||||||
(pixels / increment_px).round() * increment_px
|
(pixels / increment_px).round() * increment_px
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert pixel position to time (inverse of time to pixels)
|
// Convert pixel position to time (inverse of time to pixels)
|
||||||
fn pixels_to_time(pixels: f64) -> NaiveTime {
|
fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime {
|
||||||
// Since 60px = 1 hour, pixels directly represent minutes
|
let pixels_per_minute = if time_increment == 15 { 2.0 } else { 1.0 };
|
||||||
let total_minutes = pixels; // 1px = 1 minute
|
let total_minutes = pixels / pixels_per_minute;
|
||||||
let hours = (total_minutes / 60.0) as u32;
|
let hours = (total_minutes / 60.0) as u32;
|
||||||
let minutes = (total_minutes % 60.0) as u32;
|
let minutes = (total_minutes % 60.0) as u32;
|
||||||
|
|
||||||
// Handle midnight boundary - if we're at exactly 1440 pixels (24:00), return midnight
|
// Handle midnight boundary - check against scaled boundary
|
||||||
if total_minutes >= 1440.0 {
|
let max_pixels = 1440.0 * pixels_per_minute; // 24 hours in pixels
|
||||||
|
if pixels >= max_pixels {
|
||||||
return NaiveTime::from_hms_opt(0, 0, 0).unwrap();
|
return NaiveTime::from_hms_opt(0, 0, 0).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,14 +1151,18 @@ fn pixels_to_time(pixels: f64) -> NaiveTime {
|
|||||||
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32) -> (f32, f32, bool) {
|
||||||
fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) {
|
|
||||||
// Convert UTC times to local time for display
|
// Convert UTC times to local time for display
|
||||||
let local_start = event.dtstart.with_timezone(&Local);
|
let local_start = event.dtstart.with_timezone(&Local);
|
||||||
let event_date = local_start.date_naive();
|
let event_date = local_start.date_naive();
|
||||||
|
|
||||||
// Only position events that are on this specific date
|
// Position events based on when they appear in local time, not their original date
|
||||||
if event_date != date {
|
// For timezone issues: an event created at 10 PM Sunday might be stored as Monday UTC
|
||||||
|
// but should still display on Sunday's column since that's when the user sees it
|
||||||
|
let should_display_here = event_date == date ||
|
||||||
|
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20);
|
||||||
|
|
||||||
|
if !should_display_here {
|
||||||
return (0.0, 0.0, false); // Event not on this date
|
return (0.0, 0.0, false); // Event not on this date
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,8 +1174,8 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool)
|
|||||||
// Calculate start position in pixels from midnight
|
// Calculate start position in pixels from midnight
|
||||||
let start_hour = local_start.hour() as f32;
|
let start_hour = local_start.hour() as f32;
|
||||||
let start_minute = local_start.minute() as f32;
|
let start_minute = local_start.minute() as f32;
|
||||||
let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour
|
let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 };
|
||||||
|
let start_pixels = (start_hour + start_minute / 60.0) * pixels_per_hour;
|
||||||
|
|
||||||
// Calculate duration and height
|
// Calculate duration and height
|
||||||
let duration_pixels = if let Some(end) = event.dtend {
|
let duration_pixels = if let Some(end) = event.dtend {
|
||||||
@@ -1017,17 +1184,164 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool)
|
|||||||
|
|
||||||
// Handle events that span multiple days by capping at midnight
|
// Handle events that span multiple days by capping at midnight
|
||||||
if end_date > date {
|
if end_date > date {
|
||||||
// Event continues past midnight, cap at 24:00 (1440px)
|
// Event continues past midnight, cap at 24:00
|
||||||
1440.0 - start_pixels
|
let max_pixels = 24.0 * pixels_per_hour;
|
||||||
|
max_pixels - start_pixels
|
||||||
} else {
|
} else {
|
||||||
let end_hour = local_end.hour() as f32;
|
let end_hour = local_end.hour() as f32;
|
||||||
let end_minute = local_end.minute() as f32;
|
let end_minute = local_end.minute() as f32;
|
||||||
let end_pixels = (end_hour + end_minute / 60.0) * 60.0;
|
let end_pixels = (end_hour + end_minute / 60.0) * pixels_per_hour;
|
||||||
(end_pixels - start_pixels).max(20.0) // Minimum 20px height
|
(end_pixels - start_pixels).max(20.0) // Minimum 20px height
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
60.0 // Default 1 hour if no end time
|
pixels_per_hour // Default 1 hour if no end time
|
||||||
};
|
};
|
||||||
|
|
||||||
(start_pixels, duration_pixels, false) // is_all_day = false
|
(start_pixels, duration_pixels, false) // is_all_day = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if two events overlap in time
|
||||||
|
fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
|
||||||
|
// All-day events don't overlap with timed events for width calculation purposes
|
||||||
|
if event1.all_day || event2.all_day {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let start1 = event1.dtstart.with_timezone(&Local).naive_local();
|
||||||
|
let end1 = if let Some(end) = event1.dtend {
|
||||||
|
end.with_timezone(&Local).naive_local()
|
||||||
|
} else {
|
||||||
|
start1 + chrono::Duration::hours(1) // Default 1 hour duration
|
||||||
|
};
|
||||||
|
|
||||||
|
let start2 = event2.dtstart.with_timezone(&Local).naive_local();
|
||||||
|
let end2 = if let Some(end) = event2.dtend {
|
||||||
|
end.with_timezone(&Local).naive_local()
|
||||||
|
} else {
|
||||||
|
start2 + chrono::Duration::hours(1) // Default 1 hour duration
|
||||||
|
};
|
||||||
|
|
||||||
|
// Events overlap if one starts before the other ends
|
||||||
|
start1 < end2 && start2 < end1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate layout columns for overlapping events
|
||||||
|
fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u32) -> Vec<(usize, usize)> {
|
||||||
|
|
||||||
|
// Filter and sort events that should appear on this date (excluding all-day events)
|
||||||
|
let mut day_events: Vec<_> = events.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(idx, event)| {
|
||||||
|
// Skip all-day events as they don't participate in timed event overlap calculations
|
||||||
|
if event.all_day {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, _, _) = calculate_event_position(event, date, time_increment);
|
||||||
|
let local_start = event.dtstart.with_timezone(&Local);
|
||||||
|
let event_date = local_start.date_naive();
|
||||||
|
if event_date == date ||
|
||||||
|
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20) {
|
||||||
|
Some((idx, event))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort by start time
|
||||||
|
day_events.sort_by_key(|(_, event)| event.dtstart.with_timezone(&Local).naive_local());
|
||||||
|
|
||||||
|
// For each event, find all events it overlaps with
|
||||||
|
let mut event_columns = vec![(0, 1); events.len()]; // (column_idx, total_columns)
|
||||||
|
|
||||||
|
for i in 0..day_events.len() {
|
||||||
|
let (orig_idx_i, event_i) = day_events[i];
|
||||||
|
|
||||||
|
// Find all events that overlap with this event
|
||||||
|
let mut overlapping_events = vec![i];
|
||||||
|
for j in 0..day_events.len() {
|
||||||
|
if i != j {
|
||||||
|
let (_, event_j) = day_events[j];
|
||||||
|
if events_overlap(event_i, event_j) {
|
||||||
|
overlapping_events.push(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this event doesn't overlap with anything, it gets full width
|
||||||
|
if overlapping_events.len() == 1 {
|
||||||
|
event_columns[orig_idx_i] = (0, 1);
|
||||||
|
} else {
|
||||||
|
// This event overlaps - we need to calculate column layout
|
||||||
|
// Sort the overlapping group by start time
|
||||||
|
overlapping_events.sort_by_key(|&idx| day_events[idx].1.dtstart.with_timezone(&Local).naive_local());
|
||||||
|
|
||||||
|
// Assign columns using a greedy algorithm
|
||||||
|
let mut columns: Vec<Vec<usize>> = Vec::new();
|
||||||
|
|
||||||
|
for &event_idx in &overlapping_events {
|
||||||
|
let (orig_idx, event) = day_events[event_idx];
|
||||||
|
|
||||||
|
// Find the first column where this event doesn't overlap with existing events
|
||||||
|
let mut placed = false;
|
||||||
|
for (col_idx, column) in columns.iter_mut().enumerate() {
|
||||||
|
let can_place = column.iter().all(|&existing_idx| {
|
||||||
|
let (_, existing_event) = day_events[existing_idx];
|
||||||
|
!events_overlap(event, existing_event)
|
||||||
|
});
|
||||||
|
|
||||||
|
if can_place {
|
||||||
|
column.push(event_idx);
|
||||||
|
event_columns[orig_idx] = (col_idx, columns.len());
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !placed {
|
||||||
|
// Create new column
|
||||||
|
columns.push(vec![event_idx]);
|
||||||
|
event_columns[orig_idx] = (columns.len() - 1, columns.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update total_columns for all events in this overlapping group
|
||||||
|
let total_columns = columns.len();
|
||||||
|
for &event_idx in &overlapping_events {
|
||||||
|
let (orig_idx, _) = day_events[event_idx];
|
||||||
|
event_columns[orig_idx].1 = total_columns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event_columns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if an all-day event spans the given date
|
||||||
|
fn event_spans_date(event: &VEvent, date: NaiveDate) -> bool {
|
||||||
|
let start_date = if event.all_day {
|
||||||
|
// For all-day events, extract date directly from UTC without timezone conversion
|
||||||
|
// since all-day events are stored at noon UTC to avoid timezone boundary issues
|
||||||
|
event.dtstart.date_naive()
|
||||||
|
} else {
|
||||||
|
event.dtstart.with_timezone(&Local).date_naive()
|
||||||
|
};
|
||||||
|
|
||||||
|
let end_date = if let Some(dtend) = event.dtend {
|
||||||
|
if event.all_day {
|
||||||
|
// For all-day events, dtend is set to the day after the last day (RFC 5545)
|
||||||
|
// Extract date directly from UTC and subtract a day to get actual last day
|
||||||
|
dtend.date_naive() - chrono::Duration::days(1)
|
||||||
|
} else {
|
||||||
|
// For timed events, use timezone conversion
|
||||||
|
dtend.with_timezone(&Local).date_naive()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single day event
|
||||||
|
start_date
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if the given date falls within the event's date range
|
||||||
|
date >= start_date && date <= end_date
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::env;
|
|
||||||
use base64::prelude::*;
|
|
||||||
|
|
||||||
/// Configuration for CalDAV server connection and authentication.
|
|
||||||
///
|
|
||||||
/// This struct holds all the necessary information to connect to a CalDAV server,
|
|
||||||
/// including server URL, credentials, and optional collection paths.
|
|
||||||
///
|
|
||||||
/// # Security Note
|
|
||||||
///
|
|
||||||
/// The password field contains sensitive information and should be handled carefully.
|
|
||||||
/// This struct implements `Debug` but in production, consider implementing a custom
|
|
||||||
/// `Debug` that masks the password field.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```rust
|
|
||||||
/// use crate::config::CalDAVConfig;
|
|
||||||
///
|
|
||||||
/// // Load configuration from environment variables
|
|
||||||
/// let config = CalDAVConfig::from_env()?;
|
|
||||||
///
|
|
||||||
/// // Use the configuration for HTTP requests
|
|
||||||
/// let auth_header = format!("Basic {}", config.get_basic_auth());
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CalDAVConfig {
|
|
||||||
/// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/")
|
|
||||||
pub server_url: String,
|
|
||||||
|
|
||||||
/// Username for authentication with the CalDAV server
|
|
||||||
pub username: String,
|
|
||||||
|
|
||||||
/// Password for authentication with the CalDAV server
|
|
||||||
///
|
|
||||||
/// **Security Note**: This contains sensitive information
|
|
||||||
pub password: String,
|
|
||||||
|
|
||||||
/// Optional path to the calendar collection on the server
|
|
||||||
///
|
|
||||||
/// If not provided, the client will need to discover available calendars
|
|
||||||
/// through CalDAV PROPFIND requests
|
|
||||||
pub calendar_path: Option<String>,
|
|
||||||
|
|
||||||
/// Optional path to the tasks/todo collection on the server
|
|
||||||
///
|
|
||||||
/// Some CalDAV servers store tasks separately from calendar events
|
|
||||||
pub tasks_path: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CalDAVConfig {
|
|
||||||
/// Creates a new CalDAVConfig by loading values from environment variables.
|
|
||||||
///
|
|
||||||
/// This method will attempt to load a `.env` file from the current directory
|
|
||||||
/// and then read the following required environment variables:
|
|
||||||
///
|
|
||||||
/// - `CALDAV_SERVER_URL`: The CalDAV server base URL
|
|
||||||
/// - `CALDAV_USERNAME`: Username for authentication
|
|
||||||
/// - `CALDAV_PASSWORD`: Password for authentication
|
|
||||||
///
|
|
||||||
/// Optional environment variables:
|
|
||||||
///
|
|
||||||
/// - `CALDAV_CALENDAR_PATH`: Path to calendar collection
|
|
||||||
/// - `CALDAV_TASKS_PATH`: Path to tasks collection
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns `ConfigError::MissingVar` if any required environment variable
|
|
||||||
/// is not set or cannot be read.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```rust
|
|
||||||
/// use crate::config::CalDAVConfig;
|
|
||||||
///
|
|
||||||
/// match CalDAVConfig::from_env() {
|
|
||||||
/// Ok(config) => {
|
|
||||||
/// println!("Loaded config for server: {}", config.server_url);
|
|
||||||
/// }
|
|
||||||
/// Err(e) => {
|
|
||||||
/// eprintln!("Failed to load config: {}", e);
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub fn from_env() -> Result<Self, ConfigError> {
|
|
||||||
// Attempt to load .env file, but don't fail if it doesn't exist
|
|
||||||
dotenvy::dotenv().ok();
|
|
||||||
|
|
||||||
let server_url = env::var("CALDAV_SERVER_URL")
|
|
||||||
.map_err(|_| ConfigError::MissingVar("CALDAV_SERVER_URL".to_string()))?;
|
|
||||||
|
|
||||||
let username = env::var("CALDAV_USERNAME")
|
|
||||||
.map_err(|_| ConfigError::MissingVar("CALDAV_USERNAME".to_string()))?;
|
|
||||||
|
|
||||||
let password = env::var("CALDAV_PASSWORD")
|
|
||||||
.map_err(|_| ConfigError::MissingVar("CALDAV_PASSWORD".to_string()))?;
|
|
||||||
|
|
||||||
// Optional paths - it's fine if these are not set
|
|
||||||
let calendar_path = env::var("CALDAV_CALENDAR_PATH").ok();
|
|
||||||
let tasks_path = env::var("CALDAV_TASKS_PATH").ok();
|
|
||||||
|
|
||||||
Ok(CalDAVConfig {
|
|
||||||
server_url,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
calendar_path,
|
|
||||||
tasks_path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generates a Base64-encoded string for HTTP Basic Authentication.
|
|
||||||
///
|
|
||||||
/// This method combines the username and password in the format
|
|
||||||
/// `username:password` and encodes it using Base64, which is the
|
|
||||||
/// standard format for the `Authorization: Basic` HTTP header.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// A Base64-encoded string that can be used directly in the
|
|
||||||
/// `Authorization` header: `Authorization: Basic <returned_value>`
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```rust
|
|
||||||
/// use crate::config::CalDAVConfig;
|
|
||||||
///
|
|
||||||
/// let config = CalDAVConfig {
|
|
||||||
/// server_url: "https://example.com".to_string(),
|
|
||||||
/// username: "user".to_string(),
|
|
||||||
/// password: "pass".to_string(),
|
|
||||||
/// calendar_path: None,
|
|
||||||
/// tasks_path: None,
|
|
||||||
/// };
|
|
||||||
///
|
|
||||||
/// let auth_value = config.get_basic_auth();
|
|
||||||
/// let auth_header = format!("Basic {}", auth_value);
|
|
||||||
/// ```
|
|
||||||
pub fn get_basic_auth(&self) -> String {
|
|
||||||
let credentials = format!("{}:{}", self.username, self.password);
|
|
||||||
BASE64_STANDARD.encode(&credentials)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Errors that can occur when loading or using CalDAV configuration.
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum ConfigError {
|
|
||||||
/// A required environment variable is missing or cannot be read.
|
|
||||||
///
|
|
||||||
/// This error occurs when calling `CalDAVConfig::from_env()` and one of the
|
|
||||||
/// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`,
|
|
||||||
/// or `CALDAV_PASSWORD`) is not set.
|
|
||||||
#[error("Missing environment variable: {0}")]
|
|
||||||
MissingVar(String),
|
|
||||||
|
|
||||||
/// The configuration contains invalid or malformed values.
|
|
||||||
///
|
|
||||||
/// This could include malformed URLs, invalid authentication credentials,
|
|
||||||
/// or other configuration issues that prevent proper CalDAV operation.
|
|
||||||
#[error("Invalid configuration: {0}")]
|
|
||||||
Invalid(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_basic_auth_encoding() {
|
|
||||||
let config = CalDAVConfig {
|
|
||||||
server_url: "https://example.com".to_string(),
|
|
||||||
username: "testuser".to_string(),
|
|
||||||
password: "testpass".to_string(),
|
|
||||||
calendar_path: None,
|
|
||||||
tasks_path: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let auth = config.get_basic_auth();
|
|
||||||
let expected = BASE64_STANDARD.encode("testuser:testpass");
|
|
||||||
assert_eq!(auth, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Integration test that authenticates with the actual Baikal CalDAV server
|
|
||||||
///
|
|
||||||
/// This test requires a valid .env file with:
|
|
||||||
/// - CALDAV_SERVER_URL
|
|
||||||
/// - CALDAV_USERNAME
|
|
||||||
/// - CALDAV_PASSWORD
|
|
||||||
///
|
|
||||||
/// Run with: `cargo test test_baikal_auth`
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_baikal_auth() {
|
|
||||||
// Load config from .env
|
|
||||||
let config = CalDAVConfig::from_env()
|
|
||||||
.expect("Failed to load CalDAV config from environment");
|
|
||||||
|
|
||||||
println!("Testing authentication to: {}", config.server_url);
|
|
||||||
|
|
||||||
// Create HTTP client
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
// Make a simple OPTIONS request to test authentication
|
|
||||||
let response = client
|
|
||||||
.request(reqwest::Method::OPTIONS, &config.server_url)
|
|
||||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to send request to CalDAV server");
|
|
||||||
|
|
||||||
println!("Response status: {}", response.status());
|
|
||||||
println!("Response headers: {:#?}", response.headers());
|
|
||||||
|
|
||||||
// Check if we got a successful response or at least not a 401 Unauthorized
|
|
||||||
assert!(
|
|
||||||
response.status().is_success() || response.status() != 401,
|
|
||||||
"Authentication failed with status: {}. Check your credentials in .env",
|
|
||||||
response.status()
|
|
||||||
);
|
|
||||||
|
|
||||||
// For Baikal/CalDAV servers, we should see DAV headers
|
|
||||||
assert!(
|
|
||||||
response.headers().contains_key("dav") ||
|
|
||||||
response.headers().contains_key("DAV") ||
|
|
||||||
response.status().is_success(),
|
|
||||||
"Server doesn't appear to be a CalDAV server - missing DAV headers"
|
|
||||||
);
|
|
||||||
|
|
||||||
println!("✓ Authentication test passed!");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test making a PROPFIND request to discover calendars
|
|
||||||
///
|
|
||||||
/// This test requires a valid .env file and makes an actual CalDAV PROPFIND request
|
|
||||||
///
|
|
||||||
/// Run with: `cargo test test_propfind_calendars`
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_propfind_calendars() {
|
|
||||||
let config = CalDAVConfig::from_env()
|
|
||||||
.expect("Failed to load CalDAV config from environment");
|
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
// CalDAV PROPFIND request to discover calendars
|
|
||||||
let propfind_body = r#"<?xml version="1.0" encoding="utf-8" ?>
|
|
||||||
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
||||||
<d:prop>
|
|
||||||
<d:resourcetype />
|
|
||||||
<d:displayname />
|
|
||||||
<c:calendar-description />
|
|
||||||
<c:supported-calendar-component-set />
|
|
||||||
</d:prop>
|
|
||||||
</d:propfind>"#;
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url)
|
|
||||||
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
|
|
||||||
.header("Content-Type", "application/xml")
|
|
||||||
.header("Depth", "1")
|
|
||||||
.header("User-Agent", "calendar-app/0.1.0")
|
|
||||||
.body(propfind_body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to send PROPFIND request");
|
|
||||||
|
|
||||||
let status = response.status();
|
|
||||||
println!("PROPFIND Response status: {}", status);
|
|
||||||
|
|
||||||
let body = response.text().await.expect("Failed to read response body");
|
|
||||||
println!("PROPFIND Response body: {}", body);
|
|
||||||
|
|
||||||
// We should get a 207 Multi-Status for PROPFIND
|
|
||||||
assert_eq!(
|
|
||||||
status,
|
|
||||||
reqwest::StatusCode::from_u16(207).unwrap(),
|
|
||||||
"PROPFIND should return 207 Multi-Status"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The response should contain XML with calendar information
|
|
||||||
assert!(body.contains("calendar"), "Response should contain calendar information");
|
|
||||||
|
|
||||||
println!("✓ PROPFIND calendars test passed!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod components;
|
mod components;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
pub mod calendar_service;
|
pub mod calendar_service;
|
||||||
|
pub mod preferences;
|
||||||
|
|
||||||
pub use calendar_service::CalendarService;
|
pub use calendar_service::CalendarService;
|
||||||
206
frontend/src/services/preferences.rs
Normal file
206
frontend/src/services/preferences.rs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
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>,
|
||||||
|
pub last_used_calendar: 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>,
|
||||||
|
pub last_used_calendar: 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,
|
||||||
|
last_used_calendar: 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(),
|
||||||
|
last_used_calendar: preferences.last_used_calendar.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(),
|
||||||
|
last_used_calendar: LocalStorage::get::<String>("last_used_calendar").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()
|
||||||
|
|| request.last_used_calendar.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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the last used calendar and sync with backend
|
||||||
|
pub async fn update_last_used_calendar(&self, calendar_path: &str) -> Result<(), String> {
|
||||||
|
// Get session token
|
||||||
|
let session_token = LocalStorage::get::<String>("session_token")
|
||||||
|
.map_err(|_| "No session token found".to_string())?;
|
||||||
|
|
||||||
|
// Create minimal update request with only the last used calendar
|
||||||
|
let request = UpdatePreferencesRequest {
|
||||||
|
calendar_selected_date: None,
|
||||||
|
calendar_time_increment: None,
|
||||||
|
calendar_view_mode: None,
|
||||||
|
calendar_theme: None,
|
||||||
|
calendar_colors: None,
|
||||||
|
last_used_calendar: Some(calendar_path.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync to backend
|
||||||
|
self.sync_preferences(&session_token, &request).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,120 @@
|
|||||||
|
/* Base Styles - Always Loaded */
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* CSS Variables for Style System */
|
||||||
|
--border-radius-small: 4px;
|
||||||
|
--border-radius-medium: 8px;
|
||||||
|
--border-radius-large: 12px;
|
||||||
|
--spacing-xs: 4px;
|
||||||
|
--spacing-sm: 8px;
|
||||||
|
--spacing-md: 16px;
|
||||||
|
--spacing-lg: 24px;
|
||||||
|
--spacing-xl: 32px;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
--shadow-lg: 0 8px 25px rgba(0,0,0,0.15);
|
||||||
|
--border-light: 1px solid #e9ecef;
|
||||||
|
--border-medium: 1px solid #dee2e6;
|
||||||
|
--transition-fast: 0.15s ease;
|
||||||
|
--transition-normal: 0.2s ease;
|
||||||
|
--transition-slow: 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme Data Attributes for Color Schemes */
|
||||||
|
[data-theme="default"] {
|
||||||
|
--primary-color: #667eea;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="ocean"] {
|
||||||
|
--primary-color: #006994;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #006994 0%, #0891b2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="forest"] {
|
||||||
|
--primary-color: #065f46;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #065f46 0%, #047857 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="sunset"] {
|
||||||
|
--primary-color: #ea580c;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #ea580c 0%, #dc2626 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="purple"] {
|
||||||
|
--primary-color: #7c3aed;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--primary-color: #374151;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #374151 0%, #1f2937 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="rose"] {
|
||||||
|
--primary-color: #e11d48;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #e11d48 0%, #f43f5e 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="mint"] {
|
||||||
|
--primary-color: #10b981;
|
||||||
|
--primary-gradient: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||||
|
}* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
@@ -289,6 +400,30 @@ body {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.remember-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-checkbox input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-checkbox label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.login-button, .register-button {
|
.login-button, .register-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
@@ -515,11 +650,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.week-day-header {
|
.week-day-header {
|
||||||
padding: 1rem;
|
padding: 0.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-right: 1px solid var(--time-label-border, #e9ecef);
|
border-right: 1px solid var(--time-label-border, #e9ecef);
|
||||||
background: var(--weekday-header-bg, #f8f9fa);
|
background: var(--weekday-header-bg, #f8f9fa);
|
||||||
color: var(--weekday-header-text, inherit);
|
color: var(--weekday-header-text, inherit);
|
||||||
|
min-height: 70px; /* Ensure space for all-day events */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-day-header.today {
|
.week-day-header.today {
|
||||||
@@ -545,17 +683,61 @@ body {
|
|||||||
color: var(--calendar-today-text, #1976d2);
|
color: var(--calendar-today-text, #1976d2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* All-day events in header */
|
||||||
|
.day-header-content {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-day-events {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-day-event {
|
||||||
|
background: #3B82F6;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
min-height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-day-event:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-day-event-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Week Content */
|
/* Week Content */
|
||||||
.week-content {
|
.week-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
min-height: 0; /* Allow flex item to shrink below content size */
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-grid {
|
.time-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 80px 1fr;
|
grid-template-columns: 80px 1fr;
|
||||||
min-height: 100%;
|
min-height: 1530px; /* 30-minute mode */
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-grid.quarter-mode {
|
||||||
|
min-height: 2970px; /* 15-minute mode */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Time Labels */
|
/* Time Labels */
|
||||||
@@ -565,8 +747,15 @@ body {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
|
min-height: 1530px; /* 30-minute mode */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scale time labels container for 15-minute mode */
|
||||||
|
.time-labels.quarter-mode {
|
||||||
|
min-height: 2970px; /* 15-minute mode */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default time label height for 30-minute mode */
|
||||||
.time-label {
|
.time-label {
|
||||||
height: 60px;
|
height: 60px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -579,23 +768,31 @@ body {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-label.final-boundary {
|
/* Time label height for 15-minute mode - double height */
|
||||||
height: 60px; /* Keep same height but this marks the end boundary */
|
.time-label.quarter-mode {
|
||||||
border-bottom: 2px solid #e9ecef; /* Stronger border to show day end */
|
height: 120px;
|
||||||
color: #999; /* Lighter color to indicate it's the boundary */
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Week Days Grid */
|
/* Week Days Grid */
|
||||||
.week-days-grid {
|
.week-days-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
min-height: 1530px; /* 30-minute mode */
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-days-grid.quarter-mode {
|
||||||
|
min-height: 2970px; /* 15-minute mode */
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-day-column {
|
.week-day-column {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-right: 1px solid var(--time-label-border, #e9ecef);
|
border-right: 1px solid var(--time-label-border, #e9ecef);
|
||||||
min-height: 1500px; /* 25 time labels × 60px = 1500px total */
|
min-height: 1530px; /* 30-minute mode */
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-day-column.quarter-mode {
|
||||||
|
min-height: 2970px; /* 15-minute mode */
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-day-column:last-child {
|
.week-day-column:last-child {
|
||||||
@@ -608,12 +805,16 @@ body {
|
|||||||
|
|
||||||
/* Time Slots */
|
/* Time Slots */
|
||||||
.time-slot {
|
.time-slot {
|
||||||
height: 60px;
|
height: 60px; /* 30-minute mode: 2 slots × 30px = 60px */
|
||||||
border-bottom: 1px solid var(--calendar-border, #f0f0f0);
|
border-bottom: 1px solid var(--calendar-border, #f0f0f0);
|
||||||
position: relative;
|
position: relative;
|
||||||
pointer-events: none; /* Don't capture mouse events */
|
pointer-events: none; /* Don't capture mouse events */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-slot.quarter-mode {
|
||||||
|
height: 120px; /* 15-minute mode: 4 slots × 30px = 120px */
|
||||||
|
}
|
||||||
|
|
||||||
.time-slot-half {
|
.time-slot-half {
|
||||||
height: 30px;
|
height: 30px;
|
||||||
border-bottom: 1px dotted var(--calendar-border, #f5f5f5);
|
border-bottom: 1px dotted var(--calendar-border, #f5f5f5);
|
||||||
@@ -624,13 +825,17 @@ body {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-slot.boundary-slot {
|
.time-slot-quarter {
|
||||||
height: 60px; /* Match the final time label height */
|
height: 30px;
|
||||||
border-bottom: 2px solid #e9ecef; /* Strong border to match final boundary */
|
border-bottom: 1px dotted var(--calendar-border-light, #f8f8f8);
|
||||||
background: rgba(0,0,0,0.02); /* Slightly different background to indicate boundary */
|
|
||||||
pointer-events: none; /* Don't capture mouse events */
|
pointer-events: none; /* Don't capture mouse events */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-slot-quarter:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Events Container */
|
/* Events Container */
|
||||||
.events-container {
|
.events-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -644,8 +849,7 @@ body {
|
|||||||
/* Week Events */
|
/* Week Events */
|
||||||
.week-event {
|
.week-event {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
left: 4px;
|
/* left and width are now set inline for overlap handling */
|
||||||
right: 4px;
|
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
background: #3B82F6;
|
background: #3B82F6;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -665,6 +869,20 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disable pointer events on existing events when creating a new event */
|
||||||
|
.week-day-column.creating-event .week-event {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.6; /* Visual feedback that events are not interactive */
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-day-column.creating-event .week-event .event-content {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-day-column.creating-event .week-event .resize-handle {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.week-event:hover {
|
.week-event:hover {
|
||||||
filter: brightness(1.1);
|
filter: brightness(1.1);
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
@@ -2990,6 +3208,50 @@ body {
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Style Selector Styles */
|
||||||
|
.style-selector {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-selector label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-selector-dropdown {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-selector-dropdown:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-selector-dropdown:focus {
|
||||||
|
outline: none;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-selector-dropdown option {
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Theme Definitions */
|
/* Theme Definitions */
|
||||||
:root {
|
:root {
|
||||||
/* Default Theme */
|
/* Default Theme */
|
||||||
@@ -3005,6 +3267,7 @@ body {
|
|||||||
--accent-color: #667eea;
|
--accent-color: #667eea;
|
||||||
--calendar-bg: white;
|
--calendar-bg: white;
|
||||||
--calendar-border: #f0f0f0;
|
--calendar-border: #f0f0f0;
|
||||||
|
--calendar-border-light: #f8f8f8;
|
||||||
--calendar-day-bg: white;
|
--calendar-day-bg: white;
|
||||||
--calendar-day-hover: #f8f9ff;
|
--calendar-day-hover: #f8f9ff;
|
||||||
--calendar-day-prev-next: #fafafa;
|
--calendar-day-prev-next: #fafafa;
|
||||||
@@ -3038,6 +3301,7 @@ body {
|
|||||||
--accent-color: #2196F3;
|
--accent-color: #2196F3;
|
||||||
--calendar-bg: #ffffff;
|
--calendar-bg: #ffffff;
|
||||||
--calendar-border: #bbdefb;
|
--calendar-border: #bbdefb;
|
||||||
|
--calendar-border-light: #e3f2fd;
|
||||||
--calendar-day-bg: #ffffff;
|
--calendar-day-bg: #ffffff;
|
||||||
--calendar-day-hover: #e1f5fe;
|
--calendar-day-hover: #e1f5fe;
|
||||||
--calendar-day-prev-next: #f3f8ff;
|
--calendar-day-prev-next: #f3f8ff;
|
||||||
@@ -3080,6 +3344,7 @@ body {
|
|||||||
--accent-color: #4CAF50;
|
--accent-color: #4CAF50;
|
||||||
--calendar-bg: #ffffff;
|
--calendar-bg: #ffffff;
|
||||||
--calendar-border: #c8e6c9;
|
--calendar-border: #c8e6c9;
|
||||||
|
--calendar-border-light: #e8f5e8;
|
||||||
--calendar-day-bg: #ffffff;
|
--calendar-day-bg: #ffffff;
|
||||||
--calendar-day-hover: #f1f8e9;
|
--calendar-day-hover: #f1f8e9;
|
||||||
--calendar-day-prev-next: #f9fbe7;
|
--calendar-day-prev-next: #f9fbe7;
|
||||||
@@ -3122,6 +3387,7 @@ body {
|
|||||||
--accent-color: #FF9800;
|
--accent-color: #FF9800;
|
||||||
--calendar-bg: #ffffff;
|
--calendar-bg: #ffffff;
|
||||||
--calendar-border: #ffe0b2;
|
--calendar-border: #ffe0b2;
|
||||||
|
--calendar-border-light: #fff3e0;
|
||||||
--calendar-day-bg: #ffffff;
|
--calendar-day-bg: #ffffff;
|
||||||
--calendar-day-hover: #fff8e1;
|
--calendar-day-hover: #fff8e1;
|
||||||
--calendar-day-prev-next: #fffde7;
|
--calendar-day-prev-next: #fffde7;
|
||||||
@@ -3164,6 +3430,7 @@ body {
|
|||||||
--accent-color: #9C27B0;
|
--accent-color: #9C27B0;
|
||||||
--calendar-bg: #ffffff;
|
--calendar-bg: #ffffff;
|
||||||
--calendar-border: #ce93d8;
|
--calendar-border: #ce93d8;
|
||||||
|
--calendar-border-light: #f3e5f5;
|
||||||
--calendar-day-bg: #ffffff;
|
--calendar-day-bg: #ffffff;
|
||||||
--calendar-day-hover: #f8e9fc;
|
--calendar-day-hover: #f8e9fc;
|
||||||
--calendar-day-prev-next: #fce4ec;
|
--calendar-day-prev-next: #fce4ec;
|
||||||
@@ -3206,6 +3473,7 @@ body {
|
|||||||
--accent-color: #666666;
|
--accent-color: #666666;
|
||||||
--calendar-bg: #1f1f1f;
|
--calendar-bg: #1f1f1f;
|
||||||
--calendar-border: #333333;
|
--calendar-border: #333333;
|
||||||
|
--calendar-border-light: #2a2a2a;
|
||||||
--calendar-day-bg: #1f1f1f;
|
--calendar-day-bg: #1f1f1f;
|
||||||
--calendar-day-hover: #2a2a2a;
|
--calendar-day-hover: #2a2a2a;
|
||||||
--calendar-day-prev-next: #1a1a1a;
|
--calendar-day-prev-next: #1a1a1a;
|
||||||
@@ -3258,6 +3526,7 @@ body {
|
|||||||
--accent-color: #E91E63;
|
--accent-color: #E91E63;
|
||||||
--calendar-bg: #ffffff;
|
--calendar-bg: #ffffff;
|
||||||
--calendar-border: #f8bbd9;
|
--calendar-border: #f8bbd9;
|
||||||
|
--calendar-border-light: #fce4ec;
|
||||||
--calendar-day-bg: #ffffff;
|
--calendar-day-bg: #ffffff;
|
||||||
--calendar-day-hover: #fdf2f8;
|
--calendar-day-hover: #fdf2f8;
|
||||||
--calendar-day-prev-next: #fef7ff;
|
--calendar-day-prev-next: #fef7ff;
|
||||||
@@ -3300,6 +3569,7 @@ body {
|
|||||||
--accent-color: #26A69A;
|
--accent-color: #26A69A;
|
||||||
--calendar-bg: #ffffff;
|
--calendar-bg: #ffffff;
|
||||||
--calendar-border: #b2dfdb;
|
--calendar-border: #b2dfdb;
|
||||||
|
--calendar-border-light: #e0f2f1;
|
||||||
--calendar-day-bg: #ffffff;
|
--calendar-day-bg: #ffffff;
|
||||||
--calendar-day-hover: #f0fdfc;
|
--calendar-day-hover: #f0fdfc;
|
||||||
--calendar-day-prev-next: #f7ffff;
|
--calendar-day-prev-next: #f7ffff;
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
3511
frontend/styles/default.css
Normal file
3511
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;
|
||||||
|
}
|
||||||
BIN
sample.png
Normal file
BIN
sample.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Reference in New Issue
Block a user