First commit

This commit is contained in:
dvirlabs 2026-02-20 15:50:32 +02:00
commit 19bba1a0d1
43 changed files with 6022 additions and 0 deletions

65
.gitignore vendored Normal file
View File

@ -0,0 +1,65 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
ENV/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Testing
.pytest_cache/
.coverage
htmlcov/
# Generated ICS files
*.ics
# SQLite database
data/
*.db
*.db-journal
# Node
node_modules/
dist/
.npm
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Helm examples
tasko/
tasko-chart/

121
.woodpecker.yml Normal file
View File

@ -0,0 +1,121 @@
steps:
build-frontend:
name: Build & Push Frontend
image: woodpeckerci/plugin-kaniko
when:
branch: [ master, develop ]
event: [ push, pull_request, tag ]
path:
include: [ frontend/** ]
settings:
registry: harbor.dvirlabs.com
repo: my-apps/${CI_REPO_NAME}-frontend
dockerfile: frontend/Dockerfile
context: frontend
tags:
- latest
- ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}}
username:
from_secret: DOCKER_USERNAME
password:
from_secret: DOCKER_PASSWORD
build-backend:
name: Build & Push Backend
image: woodpeckerci/plugin-kaniko
when:
branch: [ master, develop ]
event: [ push, pull_request, tag ]
path:
include: [ backend/** ]
settings:
registry: harbor-core.dev-tools.svc.cluster.local
repo: my-apps/${CI_REPO_NAME}-backend
dockerfile: backend/Dockerfile
context: backend
tags:
- latest
- ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}}
username:
from_secret: DOCKER_USERNAME
password:
from_secret: DOCKER_PASSWORD
update-values-frontend:
name: Update frontend tag in values.yaml
image: alpine:3.19
when:
branch: [ master, develop ]
event: [ push ]
path:
include: [ frontend/** ]
environment:
GIT_USERNAME:
from_secret: GIT_USERNAME
GIT_TOKEN:
from_secret: GIT_TOKEN
commands:
- apk add --no-cache git yq
- git config --global user.name "woodpecker-bot"
- git config --global user.email "ci@dvirlabs.com"
- git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/my-apps.git"
- cd my-apps
- |
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
echo "💡 Setting frontend tag to: $TAG"
yq -i ".frontend.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
git add manifests/${CI_REPO_NAME}/values.yaml
git commit -m "frontend: update tag to $TAG" || echo "No changes"
git push origin HEAD
update-values-backend:
name: Update backend tag in values.yaml
image: alpine:3.19
when:
branch: [ master, develop ]
event: [ push ]
path:
include: [ backend/** ]
environment:
GIT_USERNAME:
from_secret: GIT_USERNAME
GIT_TOKEN:
from_secret: GIT_TOKEN
commands:
- apk add --no-cache git yq
- git config --global user.name "woodpecker-bot"
- git config --global user.email "ci@dvirlabs.com"
- |
if [ ! -d "my-apps" ]; then
git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/my-apps.git"
fi
- cd my-apps
- |
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
echo "💡 Setting backend tag to: $TAG"
yq -i ".backend.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
git add manifests/${CI_REPO_NAME}/values.yaml
git commit -m "backend: update tag to $TAG" || echo "No changes"
git push origin HEAD
trigger-gitops-via-push:
when:
branch: [ master, develop ]
event: [ push ]
name: Trigger apps-gitops via Git push
image: alpine/git
environment:
GIT_USERNAME:
from_secret: GIT_USERNAME
GIT_TOKEN:
from_secret: GIT_TOKEN
commands: |
git config --global user.name "woodpecker-bot"
git config --global user.email "ci@dvirlabs.com"
git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/apps-gitops.git"
cd apps-gitops
echo "# trigger at $(date) by $${CI_REPO_NAME}" >> .trigger
git add .trigger
git commit -m "ci: trigger apps-gitops build" || echo "no changes"
git push origin HEAD

170
QUICKSTART.md Normal file
View File

@ -0,0 +1,170 @@
# Calink Quick Start Guide
Get Calink running in under 5 minutes!
## Option 1: Docker Compose (Recommended)
**Requirements:** Docker and Docker Compose
```bash
# Clone or navigate to the project
cd ics-generator
# Start everything
docker-compose up -d
# View logs
docker-compose logs -f
# Access the app
open http://localhost
```
That's it! Calink is now running on http://localhost
To stop:
```bash
docker-compose down
```
## Option 2: Local Development
**Requirements:** Python 3.11+, Node.js 18+
### Terminal 1 - Backend
```bash
cd backend
pip install -r requirements.txt
python main.py
```
Backend runs on: http://localhost:8000
### Terminal 2 - Frontend
```bash
cd frontend
npm install
npm run dev
```
Frontend runs on: http://localhost:5173
## Option 3: Kubernetes with Helm
**Requirements:** Kubernetes cluster, Helm 3+
```bash
# Install Calink
helm install calink ./helm/calink
# Check status
helm status calink
# Get access info
kubectl get ingress
```
## First Steps
1. **Create an Event**
- Open the web UI
- Fill in event title and start date/time
- Add reminders using quick buttons or custom values
- Click "Download .ics"
2. **Use History**
- Switch to "History" tab
- Click "Load" on any past event to reuse it
- Click "Delete" to remove old events
3. **Create Templates**
- Set up your favorite reminder configuration
- Switch to "Templates" tab
- Click "Save Current Reminders"
- Name your template
- Apply it anytime in future events
## API Examples
### Generate ICS File
```bash
curl -X POST "http://localhost:8000/api/ics" \
-H "Content-Type: application/json" \
-d '{
"title": "Team Meeting",
"start_dt": "2026-03-01T10:00:00Z",
"reminders": [
{"amount": 10, "unit": "minutes"}
]
}' \
--output meeting.ics
```
### Get History
```bash
curl http://localhost:8000/api/history
```
### Create Template
```bash
curl -X POST "http://localhost:8000/api/templates" \
-H "Content-Type: application/json" \
-d '{
"name": "Standard Reminders",
"reminders": [
{"amount": 10, "unit": "minutes"},
{"amount": 1, "unit": "hours"},
{"amount": 1, "unit": "days"}
]
}'
```
## Troubleshooting
**"Failed to fetch" in UI:**
- Ensure backend is running on port 8000
- Check browser console for errors
- Verify CORS is enabled (it should be by default)
**Docker: "Port already allocated":**
```bash
# Stop conflicting service
docker-compose down
# Or change ports in docker-compose.yml
```
**Backend: "No module named 'fastapi'":**
```bash
cd backend
pip install -r requirements.txt
```
**Frontend: Build errors:**
```bash
cd frontend
rm -rf node_modules package-lock.json
npm install
```
## Next Steps
- Read the full [README.md](README.md) for detailed documentation
- Explore API documentation at http://localhost:8000/docs
- Customize Helm values for production deployment
- Set up ingress for custom domain
## Support
- API Docs: http://localhost:8000/docs (when backend is running)
- Health Check: http://localhost:8000/health
- Full Documentation: [README.md](README.md)
---
**Happy event creating! 🎉**

422
README.md Normal file
View File

@ -0,0 +1,422 @@
# Calink
**Calink** lets you create and share calendar events with custom reminders using downloadable .ics files.
## Features
- 📅 Generate valid iCalendar (.ics) files
- 🔔 Customizable reminders (minutes, hours, days, weeks)
- 📋 Event history for quick reuse
- ⚡ Reminder templates for common setups
- 🌍 Timezone support
- 🐳 Docker & Docker Compose ready
- ☸️ Kubernetes Helm chart included
- 🎨 Modern, responsive UI
## Quick Start
### Local Development
#### Backend
```bash
cd backend
pip install -r requirements.txt
python main.py
```
Backend will run on: http://localhost:8000
- API docs: http://localhost:8000/docs
- Health check: http://localhost:8000/health
#### Frontend
```bash
cd frontend
npm install
npm run dev
```
Frontend will run on: http://localhost:5173
## Docker Usage
### Build Images
```bash
# Build backend
docker build -t calink-backend:latest ./backend
# Build frontend
docker build -t calink-frontend:latest ./frontend
```
### Run with Docker Compose
```bash
docker-compose up -d
```
This will start:
- Backend on port 8000
- Frontend on port 80
- Persistent storage volume for SQLite database
Access the application at: http://localhost
To stop:
```bash
docker-compose down
```
To view logs:
```bash
docker-compose logs -f
```
## Kubernetes Deployment
### Using Helm
1. **Install the chart:**
```bash
helm install calink ./helm/calink
```
2. **With custom values:**
```bash
helm install calink ./helm/calink -f custom-values.yaml
```
3. **Upgrade release:**
```bash
helm upgrade calink ./helm/calink
```
4. **Uninstall:**
```bash
helm uninstall calink
```
### Configuration
Edit `helm/calink/values.yaml` to customize:
- **Images**: Change repository and tags
- **Resources**: CPU/memory limits
- **Persistence**: Storage size and class
- **Ingress**: Domain and TLS settings
- **Replicas**: Scale deployments
Example custom values:
```yaml
backend:
image:
repository: your-registry/calink-backend
tag: v1.0.0
replicas: 2
persistence:
size: 5Gi
frontend:
image:
repository: your-registry/calink-frontend
tag: v1.0.0
replicas: 3
ingress:
enabled: true
hosts:
- host: calink.yourdomain.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: calink-tls
hosts:
- calink.yourdomain.com
```
## API Endpoints
### Backend API
#### Health Check
```
GET /health
```
#### Generate ICS File
```
POST /api/ics
Content-Type: application/json
{
"title": "Team Meeting",
"description": "Monthly sync",
"location": "Conference Room A",
"start_dt": "2026-03-01T10:00:00Z",
"end_dt": "2026-03-01T11:00:00Z",
"timezone": "UTC",
"reminders": [
{"amount": 10, "unit": "minutes"},
{"amount": 1, "unit": "hours"}
]
}
```
#### Preview ICS Content
```
POST /api/preview
Content-Type: application/json
```
(Same body as `/api/ics`)
#### Get History
```
GET /api/history?limit=20
```
#### Get Specific Event
```
GET /api/history/{event_id}
```
#### Delete Event
```
DELETE /api/history/{event_id}
```
#### Get Templates
```
GET /api/templates
```
#### Create Template
```
POST /api/templates
Content-Type: application/json
{
"name": "Quick Reminders",
"reminders": [
{"amount": 10, "unit": "minutes"},
{"amount": 1, "unit": "hours"}
]
}
```
#### Delete Template
```
DELETE /api/templates/{template_id}
```
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Frontend │
│ (React + TypeScript + Tailwind CSS) │
│ Nginx (Port 80) │
└────────────────────┬────────────────────────────────────┘
│ /api/*
┌────────────────────▼────────────────────────────────────┐
│ Backend │
│ (FastAPI + Python + SQLite) │
│ Port 8000 │
└────────────────────┬────────────────────────────────────┘
┌────────────────────▼────────────────────────────────────┐
│ SQLite Database │
│ /data/app.db (Persistent) │
│ │
│ Tables: │
│ - events_history │
│ - reminder_templates │
└─────────────────────────────────────────────────────────┘
```
## Environment Variables
### Backend
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_PATH` | `./data/app.db` | Path to SQLite database |
### Frontend
| Variable | Default | Description |
|----------|---------|-------------|
| `VITE_API_BASE_URL` | `/api` | Backend API base URL |
For local development, create `frontend/.env.development`:
```
VITE_API_BASE_URL=http://localhost:8000
```
## Development
### Backend
```bash
cd backend
# Install dependencies
pip install -r requirements.txt
# Run with auto-reload
python main.py
# Or use uvicorn directly
uvicorn main:app --reload --port 8000
```
### Frontend
```bash
cd frontend
# Install dependencies
npm install
# Run development server
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
```
## Features Guide
### Event Creation
1. Fill in event details (title, description, location)
2. Set start and end date/time
3. Select timezone
4. Add custom reminders or use quick-add buttons
5. Click "Download .ics" to save the calendar file
### History Management
- All created events are automatically saved to history
- Click "Load" to reuse an event
- Click "Delete" to remove from history
- View up to 20 recent events
### Template Management
- Save current reminder setup as a template
- Name your template for easy identification
- Apply templates to quickly set reminders
- Delete templates you no longer need
## Database Schema
### events_history
```sql
CREATE TABLE events_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
location TEXT,
start_dt TEXT NOT NULL,
end_dt TEXT,
timezone TEXT NOT NULL,
reminders_json TEXT,
last_downloaded_at TEXT
);
```
### reminder_templates
```sql
CREATE TABLE reminder_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
reminders_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
```
## Troubleshooting
### Backend Issues
**Port 8000 already in use:**
```bash
# Find and kill the process
lsof -ti:8000 | xargs kill -9
# Or use a different port
uvicorn main:app --port 8001
```
**Database not found:**
- Check DATABASE_PATH environment variable
- Ensure /data directory has write permissions
- Database is auto-created on first run
### Frontend Issues
**Failed to fetch:**
- Verify backend is running on port 8000
- Check CORS settings in backend
- Verify API_BASE_URL in .env.development
**Build fails:**
```bash
# Clear cache and reinstall
rm -rf node_modules package-lock.json
npm install
npm run build
```
### Docker Issues
**Cannot connect to backend:**
- Ensure both containers are on the same network
- Check docker-compose logs
- Verify service names match in nginx.conf
**Volume permission errors:**
```bash
# Change volume permissions
docker-compose down
docker volume rm ics-generator_calink-data
docker-compose up -d
```
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test thoroughly
5. Submit a pull request
## License
MIT
## Support
For issues and questions:
- Check the troubleshooting section
- Review API documentation at `/docs`
- Open an issue on GitHub
---
**Powered by Calink** • FastAPI + React + Tailwind CSS

23
backend/.dockerignore Normal file
View File

@ -0,0 +1,23 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
ENV/
.venv
*.so
.git
.gitignore
.dockerignore
README.md
.pytest_cache
.coverage
htmlcov/
dist/
build/
*.egg-info/
data/*.db
data/*.db-journal
.DS_Store

26
backend/Dockerfile Normal file
View File

@ -0,0 +1,26 @@
FROM python:3.11-slim
# Create non-root user
RUN useradd -m -u 1000 calink
# Set working directory
WORKDIR /app
# Copy requirements and install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY main.py .
# Create data directory and set permissions
RUN mkdir -p /data && chown -R calink:calink /app /data
# Switch to non-root user
USER calink
# Expose port
EXPOSE 8000
# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

536
backend/main.py Normal file
View File

@ -0,0 +1,536 @@
"""
Calink API - Generate reusable calendar invitation links (.ics)
Requirements:
pip install fastapi uvicorn pydantic aiosqlite
Example usage:
# Run the server
python main.py
"""
from datetime import datetime, timedelta
from enum import Enum
from typing import Optional, List
import os
import json
import uuid
import sqlite3
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
from pydantic import BaseModel, Field, field_validator
from zoneinfo import ZoneInfo
# Configuration
DATABASE_PATH = os.getenv("DATABASE_PATH", "./data/app.db")
# Database initialization
def init_db():
"""Initialize SQLite database with required tables."""
os.makedirs(os.path.dirname(DATABASE_PATH) if os.path.dirname(DATABASE_PATH) else "./data", exist_ok=True)
conn = sqlite3.connect(DATABASE_PATH)
cursor = conn.cursor()
# Events history table
cursor.execute("""
CREATE TABLE IF NOT EXISTS events_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
location TEXT,
start_dt TEXT NOT NULL,
end_dt TEXT,
timezone TEXT NOT NULL,
reminders_json TEXT,
last_downloaded_at TEXT
)
""")
# Reminder templates table
cursor.execute("""
CREATE TABLE IF NOT EXISTS reminder_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
reminders_json TEXT NOT NULL,
created_at TEXT NOT NULL
)
""")
conn.commit()
conn.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
init_db()
yield
# FastAPI app
app = FastAPI(
title="Calink API",
description="Calink API Generate reusable calendar invitation links (.ics)",
version="1.0.0",
lifespan=lifespan
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173", "*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Models
class ReminderUnit(str, Enum):
"""Supported reminder time units."""
minutes = "minutes"
hours = "hours"
days = "days"
weeks = "weeks"
class Reminder(BaseModel):
"""Reminder configuration for an event."""
amount: int = Field(..., gt=0, description="Amount of time before event")
unit: ReminderUnit = Field(..., description="Time unit for reminder")
class EventRequest(BaseModel):
"""Request model for creating an event."""
title: str = Field(..., min_length=1, description="Event title/summary")
description: Optional[str] = Field(None, description="Event description")
location: Optional[str] = Field(None, description="Event location")
start_dt: str = Field(..., description="Event start datetime (ISO 8601 string)")
end_dt: Optional[str] = Field(None, description="Event end datetime (ISO 8601 string)")
timezone: str = Field("UTC", description="Timezone for naive datetimes")
reminders: Optional[List[Reminder]] = Field(None, max_length=10, description="Event reminders (max 10)")
@field_validator('reminders')
@classmethod
def validate_reminders(cls, v):
"""Validate reminders list."""
if v is not None and len(v) > 10:
raise ValueError("Maximum 10 reminders allowed")
return v
class ReminderTemplate(BaseModel):
"""Reminder template model."""
name: str = Field(..., min_length=1, max_length=100)
reminders: List[Reminder] = Field(..., max_length=10)
class HistoryEvent(BaseModel):
"""History event response model."""
id: int
created_at: str
title: str
description: Optional[str]
location: Optional[str]
start_dt: str
end_dt: Optional[str]
timezone: str
reminders: Optional[List[Reminder]]
last_downloaded_at: Optional[str]
class TemplateResponse(BaseModel):
"""Template response model."""
id: int
name: str
reminders: List[Reminder]
created_at: str
# Helper functions
def get_db():
"""Get database connection."""
conn = sqlite3.connect(DATABASE_PATH)
conn.row_factory = sqlite3.Row
return conn
def parse_datetime(dt_str: str, tz: str = "UTC") -> datetime:
"""Parse ISO datetime string and apply timezone if naive."""
try:
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo(tz))
return dt
except Exception as e:
raise ValueError(f"Invalid datetime format: {dt_str}. Expected ISO 8601 format.")
def format_datetime_for_ics(dt: datetime) -> str:
"""Format datetime for iCalendar format (UTC with Z suffix)."""
utc_dt = dt.astimezone(ZoneInfo("UTC"))
return utc_dt.strftime("%Y%m%dT%H%M%SZ")
def reminder_to_trigger(reminder: Reminder) -> str:
"""Convert reminder to iCalendar TRIGGER format."""
amount = reminder.amount
unit = reminder.unit
if unit == ReminderUnit.minutes:
return f"-PT{amount}M"
elif unit == ReminderUnit.hours:
return f"-PT{amount}H"
elif unit == ReminderUnit.days:
return f"-P{amount}D"
elif unit == ReminderUnit.weeks:
return f"-P{amount}W"
return "-PT15M"
def generate_ics(event: EventRequest) -> str:
"""Generate iCalendar .ics file content from event parameters."""
# Parse datetimes
start_dt = parse_datetime(event.start_dt, event.timezone)
# Calculate end_dt if not provided (default 60 minutes)
if event.end_dt:
end_dt = parse_datetime(event.end_dt, event.timezone)
else:
end_dt = start_dt + timedelta(minutes=60)
# Validate end_dt is after start_dt
if end_dt <= start_dt:
raise ValueError("end_dt must be after start_dt")
# Generate unique ID and timestamp
uid = str(uuid.uuid4())
dtstamp = datetime.now(ZoneInfo("UTC"))
# Start building ICS content
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Calink//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{format_datetime_for_ics(dtstamp)}",
f"DTSTART:{format_datetime_for_ics(start_dt)}",
f"DTEND:{format_datetime_for_ics(end_dt)}",
f"SUMMARY:{event.title}",
]
# Add optional DESCRIPTION
if event.description:
desc = event.description.replace("\\", "\\\\").replace(",", "\\,").replace(";", "\\;").replace("\n", "\\n")
lines.append(f"DESCRIPTION:{desc}")
# Add optional LOCATION
if event.location:
loc = event.location.replace("\\", "\\\\").replace(",", "\\,").replace(";", "\\;")
lines.append(f"LOCATION:{loc}")
# Add VALARM blocks for reminders
if event.reminders:
for reminder in event.reminders:
trigger = reminder_to_trigger(reminder)
lines.extend([
"BEGIN:VALARM",
"ACTION:DISPLAY",
"DESCRIPTION:Reminder",
f"TRIGGER:{trigger}",
"END:VALARM"
])
# Close VEVENT and VCALENDAR
lines.extend([
"END:VEVENT",
"END:VCALENDAR"
])
# Join with CRLF and ensure trailing CRLF
ics_content = "\r\n".join(lines) + "\r\n"
return ics_content
def save_to_history(event: EventRequest, is_download: bool = False):
"""Save event to history."""
conn = get_db()
cursor = conn.cursor()
reminders_json = json.dumps([r.dict() for r in event.reminders]) if event.reminders else None
now = datetime.now(ZoneInfo("UTC")).isoformat()
cursor.execute("""
INSERT INTO events_history
(created_at, title, description, location, start_dt, end_dt, timezone, reminders_json, last_downloaded_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
now,
event.title,
event.description,
event.location,
event.start_dt,
event.end_dt,
event.timezone,
reminders_json,
now if is_download else None
))
conn.commit()
conn.close()
# API Endpoints
@app.get("/health")
async def health():
"""Health check endpoint."""
return {"ok": True}
@app.get("/")
async def root():
"""Root endpoint with API information."""
return {
"app": "Calink",
"description": "Generate reusable calendar invitation links (.ics)",
"docs": "/docs",
"health": "/health"
}
@app.post("/api/ics")
async def create_ics(event: EventRequest):
"""Generate and download an iCalendar .ics file."""
try:
ics_content = generate_ics(event)
save_to_history(event, is_download=True)
return Response(
content=ics_content,
media_type="text/calendar; charset=utf-8",
headers={
"Content-Disposition": 'attachment; filename="event.ics"'
}
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error generating ICS file: {str(e)}")
@app.post("/api/preview")
async def preview_ics(event: EventRequest):
"""Generate and return ICS content as plain text for preview."""
try:
ics_content = generate_ics(event)
save_to_history(event, is_download=False)
return Response(
content=ics_content,
media_type="text/plain; charset=utf-8"
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error generating ICS file: {str(e)}")
@app.get("/api/history")
async def get_history(limit: int = Query(20, ge=1, le=100)):
"""Get events history."""
conn = get_db()
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM events_history
ORDER BY created_at DESC
LIMIT ?
""", (limit,))
rows = cursor.fetchall()
conn.close()
events = []
for row in rows:
reminders = None
if row['reminders_json']:
reminders_data = json.loads(row['reminders_json'])
reminders = [Reminder(**r) for r in reminders_data]
events.append(HistoryEvent(
id=row['id'],
created_at=row['created_at'],
title=row['title'],
description=row['description'],
location=row['location'],
start_dt=row['start_dt'],
end_dt=row['end_dt'],
timezone=row['timezone'],
reminders=reminders,
last_downloaded_at=row['last_downloaded_at']
))
return events
@app.get("/api/history/{event_id}")
async def get_history_event(event_id: int):
"""Get a specific event from history."""
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT * FROM events_history WHERE id = ?", (event_id,))
row = cursor.fetchone()
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Event not found")
reminders = None
if row['reminders_json']:
reminders_data = json.loads(row['reminders_json'])
reminders = [Reminder(**r) for r in reminders_data]
return HistoryEvent(
id=row['id'],
created_at=row['created_at'],
title=row['title'],
description=row['description'],
location=row['location'],
start_dt=row['start_dt'],
end_dt=row['end_dt'],
timezone=row['timezone'],
reminders=reminders,
last_downloaded_at=row['last_downloaded_at']
)
@app.delete("/api/history/{event_id}")
async def delete_history_event(event_id: int):
"""Delete an event from history."""
conn = get_db()
cursor = conn.cursor()
cursor.execute("DELETE FROM events_history WHERE id = ?", (event_id,))
if cursor.rowcount == 0:
conn.close()
raise HTTPException(status_code=404, detail="Event not found")
conn.commit()
conn.close()
return {"message": "Event deleted successfully"}
@app.post("/api/templates")
async def create_template(template: ReminderTemplate):
"""Create a new reminder template."""
conn = get_db()
cursor = conn.cursor()
reminders_json = json.dumps([r.dict() for r in template.reminders])
now = datetime.now(ZoneInfo("UTC")).isoformat()
try:
cursor.execute("""
INSERT INTO reminder_templates (name, reminders_json, created_at)
VALUES (?, ?, ?)
""", (template.name, reminders_json, now))
template_id = cursor.lastrowid
conn.commit()
conn.close()
return {
"id": template_id,
"name": template.name,
"reminders": template.reminders,
"created_at": now
}
except sqlite3.IntegrityError:
conn.close()
raise HTTPException(status_code=400, detail="Template with this name already exists")
@app.get("/api/templates")
async def get_templates():
"""Get all reminder templates."""
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT * FROM reminder_templates ORDER BY created_at DESC")
rows = cursor.fetchall()
conn.close()
templates = []
for row in rows:
reminders_data = json.loads(row['reminders_json'])
reminders = [Reminder(**r) for r in reminders_data]
templates.append(TemplateResponse(
id=row['id'],
name=row['name'],
reminders=reminders,
created_at=row['created_at']
))
return templates
@app.get("/api/templates/{template_id}")
async def get_template(template_id: int):
"""Get a specific template."""
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT * FROM reminder_templates WHERE id = ?", (template_id,))
row = cursor.fetchone()
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Template not found")
reminders_data = json.loads(row['reminders_json'])
reminders = [Reminder(**r) for r in reminders_data]
return TemplateResponse(
id=row['id'],
name=row['name'],
reminders=reminders,
created_at=row['created_at']
)
@app.delete("/api/templates/{template_id}")
async def delete_template(template_id: int):
"""Delete a template."""
conn = get_db()
cursor = conn.cursor()
cursor.execute("DELETE FROM reminder_templates WHERE id = ?", (template_id,))
if cursor.rowcount == 0:
conn.close()
raise HTTPException(status_code=404, detail="Template not found")
conn.commit()
conn.close()
return {"message": "Template deleted successfully"}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

5
backend/requirements.txt Normal file
View File

@ -0,0 +1,5 @@
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
pydantic>=2.0.0
python-multipart>=0.0.6
aiosqlite>=0.19.0

6
calink-chart/Chart.yaml Normal file
View File

@ -0,0 +1,6 @@
apiVersion: v2
name: calink
description: Calink calendar event generator
type: application
version: 1.0.0
appVersion: "1.0.0"

View File

@ -0,0 +1,29 @@
Thank you for installing {{ .Chart.Name }}!
Your release is named {{ .Release.Name }}.
To learn more about the release, try:
$ helm status {{ .Release.Name }}
$ helm get all {{ .Release.Name }}
{{- if .Values.ingress.enabled }}
Calink is accessible at:
{{- range .Values.ingress.hosts }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }}
{{- end }}
{{- else }}
To access Calink, forward the frontend port:
kubectl port-forward svc/{{ include "calink.fullname" . }}-frontend 8080:80
Then visit http://localhost:8080
{{- end }}
Backend API documentation is available at:
/docs
Health check endpoint:
/health

View File

@ -0,0 +1,84 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "calink.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "calink.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "calink.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "calink.labels" -}}
helm.sh/chart: {{ include "calink.chart" . }}
{{ include "calink.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- with .Values.commonLabels }}
{{ toYaml . }}
{{- end }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "calink.selectorLabels" -}}
app.kubernetes.io/name: {{ include "calink.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Backend labels
*/}}
{{- define "calink.backend.labels" -}}
{{ include "calink.labels" . }}
app.kubernetes.io/component: backend
{{- end }}
{{/*
Backend selector labels
*/}}
{{- define "calink.backend.selectorLabels" -}}
{{ include "calink.selectorLabels" . }}
app.kubernetes.io/component: backend
{{- end }}
{{/*
Frontend labels
*/}}
{{- define "calink.frontend.labels" -}}
{{ include "calink.labels" . }}
app.kubernetes.io/component: frontend
{{- end }}
{{/*
Frontend selector labels
*/}}
{{- define "calink.frontend.selectorLabels" -}}
{{ include "calink.selectorLabels" . }}
app.kubernetes.io/component: frontend
{{- end }}

View File

@ -0,0 +1,59 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "calink.fullname" . }}-backend
labels:
{{- include "calink.backend.labels" . | nindent 4 }}
{{- with .Values.commonAnnotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
replicas: {{ .Values.backend.replicas }}
selector:
matchLabels:
{{- include "calink.backend.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "calink.backend.selectorLabels" . | nindent 8 }}
{{- with .Values.commonAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
containers:
- name: backend
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}"
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
ports:
- name: http
containerPort: 8000
protocol: TCP
env:
{{- toYaml .Values.backend.env | nindent 12 }}
{{- if .Values.backend.persistence.enabled }}
volumeMounts:
- name: data
mountPath: {{ .Values.backend.persistence.mountPath }}
{{- end }}
livenessProbe:
httpGet:
path: {{ .Values.backend.healthCheck.path }}
port: http
initialDelaySeconds: {{ .Values.backend.healthCheck.initialDelaySeconds }}
periodSeconds: {{ .Values.backend.healthCheck.periodSeconds }}
readinessProbe:
httpGet:
path: {{ .Values.backend.healthCheck.path }}
port: http
initialDelaySeconds: {{ .Values.backend.healthCheck.initialDelaySeconds }}
periodSeconds: {{ .Values.backend.healthCheck.periodSeconds }}
resources:
{{- toYaml .Values.backend.resources | nindent 12 }}
{{- if .Values.backend.persistence.enabled }}
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ include "calink.fullname" . }}-backend-data
{{- end }}

View File

@ -0,0 +1,46 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "calink.fullname" . }}-frontend
labels:
{{- include "calink.frontend.labels" . | nindent 4 }}
{{- with .Values.commonAnnotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
replicas: {{ .Values.frontend.replicas }}
selector:
matchLabels:
{{- include "calink.frontend.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "calink.frontend.selectorLabels" . | nindent 8 }}
{{- with .Values.commonAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
containers:
- name: frontend
image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}"
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
ports:
- name: http
containerPort: 80
protocol: TCP
livenessProbe:
httpGet:
path: {{ .Values.frontend.healthCheck.path }}
port: http
initialDelaySeconds: {{ .Values.frontend.healthCheck.initialDelaySeconds }}
periodSeconds: {{ .Values.frontend.healthCheck.periodSeconds }}
readinessProbe:
httpGet:
path: {{ .Values.frontend.healthCheck.path }}
port: http
initialDelaySeconds: {{ .Values.frontend.healthCheck.initialDelaySeconds }}
periodSeconds: {{ .Values.frontend.healthCheck.periodSeconds }}
resources:
{{- toYaml .Values.frontend.resources | nindent 12 }}

View File

@ -0,0 +1,56 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "calink.fullname" . }}
labels:
{{- include "calink.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.commonAnnotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: {{ include "calink.fullname" $ }}-backend
port:
number: {{ $.Values.backend.service.port }}
- path: /health
pathType: Prefix
backend:
service:
name: {{ include "calink.fullname" $ }}-backend
port:
number: {{ $.Values.backend.service.port }}
- path: /
pathType: Prefix
backend:
service:
name: {{ include "calink.fullname" $ }}-frontend
port:
number: {{ $.Values.frontend.service.port }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,21 @@
{{- if .Values.backend.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "calink.fullname" . }}-backend-data
labels:
{{- include "calink.backend.labels" . | nindent 4 }}
{{- with .Values.commonAnnotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
- ReadWriteOnce
{{- if .Values.backend.persistence.storageClass }}
storageClassName: {{ .Values.backend.persistence.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.backend.persistence.size }}
{{- end }}

View File

@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "calink.fullname" . }}-backend
labels:
{{- include "calink.backend.labels" . | nindent 4 }}
{{- with .Values.commonAnnotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.backend.service.type }}
ports:
- port: {{ .Values.backend.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "calink.backend.selectorLabels" . | nindent 4 }}

View File

@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "calink.fullname" . }}-frontend
labels:
{{- include "calink.frontend.labels" . | nindent 4 }}
{{- with .Values.commonAnnotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.frontend.service.type }}
ports:
- port: {{ .Values.frontend.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "calink.frontend.selectorLabels" . | nindent 4 }}

81
calink-chart/values.yaml Normal file
View File

@ -0,0 +1,81 @@
nameOverride: ""
fullnameOverride: ""
commonLabels: {}
commonAnnotations: {}
backend:
image:
repository: calink-backend
tag: latest
pullPolicy: IfNotPresent
replicas: 1
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 250m
memory: 256Mi
env:
- name: DATABASE_PATH
value: "/data/app.db"
persistence:
enabled: true
storageClass: ""
size: 1Gi
mountPath: /data
service:
type: ClusterIP
port: 8000
healthCheck:
path: /health
initialDelaySeconds: 10
periodSeconds: 30
frontend:
image:
repository: calink-frontend
tag: latest
pullPolicy: IfNotPresent
replicas: 1
resources:
limits:
cpu: 200m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
service:
type: ClusterIP
port: 80
healthCheck:
path: /
initialDelaySeconds: 5
periodSeconds: 30
ingress:
enabled: true
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: calink.example.com
paths:
- path: /
pathType: Prefix
tls: []
# - secretName: calink-tls
# hosts:
# - calink.example.com

40
docker-compose.yml Normal file
View File

@ -0,0 +1,40 @@
version: '3.8'
services:
calink-backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: calink-backend
environment:
- DATABASE_PATH=/data/app.db
volumes:
- calink-data:/data
ports:
- "8000:8000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
calink-frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: calink-frontend
ports:
- "80:80"
depends_on:
- calink-backend
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
volumes:
calink-data:
driver: local

13
frontend/.dockerignore Normal file
View File

@ -0,0 +1,13 @@
node_modules
dist
.git
.gitignore
.dockerignore
README.md
.env.development
.DS_Store
*.log
npm-debug.log*
coverage
.vscode
.idea

1
frontend/.env Normal file
View File

@ -0,0 +1 @@
VITE_API_BASE_URL=/api

View File

@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8000/api

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

31
frontend/Dockerfile Normal file
View File

@ -0,0 +1,31 @@
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source files
COPY . .
# Build the app
RUN npm run build
# Stage 2: Production
FROM nginx:alpine
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built assets from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/calendar.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Calink Calendar Link Generator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

32
frontend/nginx.conf Normal file
View File

@ -0,0 +1,32 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Frontend routes
location / {
try_files $uri $uri/ /index.html;
}
# API proxy to backend
location /api {
proxy_pass http://calink-backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Health check endpoint
location /health {
proxy_pass http://calink-backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
}

2666
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
frontend/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "calink-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

346
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,346 @@
import { useState, FormEvent } from 'react';
import ReminderEditor from './components/ReminderEditor';
import HistoryPanel from './components/HistoryPanel';
import TemplatesPanel from './components/TemplatesPanel';
import { createIcs, previewIcs, Reminder, EventPayload, HistoryEvent } from './api';
function App() {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [location, setLocation] = useState('');
const [startDt, setStartDt] = useState('');
const [endDt, setEndDt] = useState('');
const [timezone, setTimezone] = useState('Asia/Jerusalem');
const [reminders, setReminders] = useState<Reminder[]>([]);
const [previewText, setPreviewText] = useState('');
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<'history' | 'templates'>('history');
const isFormValid = () => {
if (!title.trim() || !startDt) return false;
if (reminders.some(r => r.amount <= 0)) return false;
return true;
};
const buildPayload = (): EventPayload => {
// Convert datetime-local to ISO 8601
const startIso = startDt ? new Date(startDt).toISOString() : '';
const endIso = endDt ? new Date(endDt).toISOString() : undefined;
return {
title,
description: description || undefined,
location: location || undefined,
start_dt: startIso,
end_dt: endIso,
timezone,
reminders: reminders.length > 0 ? reminders : undefined,
};
};
const handleDownload = async (e: FormEvent) => {
e.preventDefault();
if (!isFormValid()) return;
setLoading(true);
setMessage(null);
try {
const payload = buildPayload();
const blob = await createIcs(payload);
// Trigger download
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'event.ics';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
setMessage({ type: 'success', text: 'ICS file downloaded successfully!' });
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Failed to generate ICS file'
});
} finally {
setLoading(false);
}
};
const handlePreview = async () => {
if (!isFormValid()) return;
setLoading(true);
setMessage(null);
try {
const payload = buildPayload();
const text = await previewIcs(payload);
setPreviewText(text);
setMessage({ type: 'success', text: 'Preview generated!' });
} catch (error) {
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Failed to preview ICS file'
});
setPreviewText('');
} finally {
setLoading(false);
}
};
const handleLoadEvent = (event: HistoryEvent) => {
setTitle(event.title);
setDescription(event.description || '');
setLocation(event.location || '');
// Convert ISO strings back to datetime-local format
try {
const startDate = new Date(event.start_dt);
setStartDt(startDate.toISOString().slice(0, 16));
if (event.end_dt) {
const endDate = new Date(event.end_dt);
setEndDt(endDate.toISOString().slice(0, 16));
} else {
setEndDt('');
}
} catch (e) {
console.error('Error parsing dates:', e);
}
setTimezone(event.timezone);
setReminders(event.reminders || []);
setMessage({ type: 'success', text: 'Event loaded from history!' });
};
const handleApplyTemplate = (templateReminders: Reminder[]) => {
setReminders(templateReminders);
setMessage({ type: 'success', text: 'Template applied!' });
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50 py-8 px-4">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-5xl font-bold text-indigo-600 mb-2">
CALINK
</h1>
<p className="text-gray-600 text-lg">
Create & share calendar events instantly
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Form - Takes up 2 columns on large screens */}
<div className="lg:col-span-2">
<div className="bg-white rounded-xl shadow-lg p-8">
<form onSubmit={handleDownload} className="space-y-6">
{/* Title */}
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
Title <span className="text-red-500">*</span>
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="Team Meeting"
required
/>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="Discuss project updates..."
/>
</div>
{/* Location */}
<div>
<label htmlFor="location" className="block text-sm font-medium text-gray-700 mb-1">
Location
</label>
<input
type="text"
id="location"
value={location}
onChange={(e) => setLocation(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="Conference Room A"
/>
</div>
{/* Date/Time Section */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Start DateTime */}
<div>
<label htmlFor="startDt" className="block text-sm font-medium text-gray-700 mb-1">
Start Date & Time <span className="text-red-500">*</span>
</label>
<input
type="datetime-local"
id="startDt"
value={startDt}
onChange={(e) => setStartDt(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
required
/>
</div>
{/* End DateTime */}
<div>
<label htmlFor="endDt" className="block text-sm font-medium text-gray-700 mb-1">
End Date & Time
</label>
<input
type="datetime-local"
id="endDt"
value={endDt}
onChange={(e) => setEndDt(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
<p className="text-xs text-gray-500 mt-1">
Defaults to +60 minutes if not set
</p>
</div>
</div>
{/* Timezone */}
<div>
<label htmlFor="timezone" className="block text-sm font-medium text-gray-700 mb-1">
Timezone
</label>
<select
id="timezone"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="UTC">UTC</option>
<option value="Asia/Jerusalem">Asia/Jerusalem</option>
<option value="Europe/London">Europe/London</option>
<option value="America/New_York">America/New York</option>
<option value="America/Chicago">America/Chicago</option>
<option value="America/Los_Angeles">America/Los Angeles</option>
<option value="Europe/Paris">Europe/Paris</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
</select>
</div>
{/* Reminders */}
<ReminderEditor reminders={reminders} onChange={setReminders} />
{/* Message Display */}
{message && (
<div
className={`p-4 rounded-md ${
message.type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}
>
{message.text}
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3">
<button
type="submit"
disabled={!isFormValid() || loading}
className="flex-1 bg-indigo-600 text-white px-6 py-3 rounded-md hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed font-medium transition-colors"
>
{loading ? 'Processing...' : '📥 Download .ics'}
</button>
<button
type="button"
onClick={handlePreview}
disabled={!isFormValid() || loading}
className="flex-1 bg-white text-indigo-600 border-2 border-indigo-600 px-6 py-3 rounded-md hover:bg-indigo-50 disabled:border-gray-300 disabled:text-gray-300 disabled:cursor-not-allowed font-medium transition-colors"
>
👁 Preview
</button>
</div>
</form>
{/* Preview Section */}
{previewText && (
<div className="mt-6 border-t pt-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Preview</h3>
<pre className="bg-gray-50 p-4 rounded-md overflow-x-auto text-xs border border-gray-200 font-mono">
{previewText}
</pre>
</div>
)}
</div>
</div>
{/* Side Panel - History & Templates */}
<div className="lg:col-span-1">
<div className="bg-white rounded-xl shadow-lg p-6 sticky top-4">
{/* Tabs */}
<div className="flex border-b border-gray-200 mb-4">
<button
onClick={() => setActiveTab('history')}
className={`flex-1 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'history'
? 'border-indigo-600 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
📋 History
</button>
<button
onClick={() => setActiveTab('templates')}
className={`flex-1 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'templates'
? 'border-indigo-600 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Templates
</button>
</div>
{/* Tab Content */}
<div className="max-h-[600px] overflow-y-auto">
{activeTab === 'history' ? (
<HistoryPanel onLoadEvent={handleLoadEvent} />
) : (
<TemplatesPanel
currentReminders={reminders}
onApplyTemplate={handleApplyTemplate}
/>
)}
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="text-center mt-8 text-sm text-gray-500">
<p>Powered by Calink FastAPI + React + Tailwind CSS</p>
</div>
</div>
</div>
);
}
export default App;

139
frontend/src/api.ts Normal file
View File

@ -0,0 +1,139 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
export type ReminderUnit = 'minutes' | 'hours' | 'days' | 'weeks';
export interface Reminder {
amount: number;
unit: ReminderUnit;
}
export interface EventPayload {
title: string;
description?: string;
location?: string;
start_dt: string;
end_dt?: string;
timezone: string;
reminders?: Reminder[];
}
export interface HistoryEvent {
id: number;
created_at: string;
title: string;
description?: string;
location?: string;
start_dt: string;
end_dt?: string;
timezone: string;
reminders?: Reminder[];
last_downloaded_at?: string;
}
export interface ReminderTemplate {
id: number;
name: string;
reminders: Reminder[];
created_at: string;
}
export async function createIcs(payload: EventPayload): Promise<Blob> {
const response = await fetch(`${API_BASE_URL}/ics`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Failed to generate ICS file' }));
throw new Error(error.detail || 'Failed to generate ICS file');
}
return response.blob();
}
export async function previewIcs(payload: EventPayload): Promise<string> {
const response = await fetch(`${API_BASE_URL}/preview`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Failed to preview ICS file' }));
throw new Error(error.detail || 'Failed to preview ICS file');
}
return response.text();
}
export async function getHistory(limit: number = 20): Promise<HistoryEvent[]> {
const response = await fetch(`${API_BASE_URL}/history?limit=${limit}`);
if (!response.ok) {
throw new Error('Failed to fetch history');
}
return response.json();
}
export async function getHistoryEvent(id: number): Promise<HistoryEvent> {
const response = await fetch(`${API_BASE_URL}/history/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch event');
}
return response.json();
}
export async function deleteHistoryEvent(id: number): Promise<void> {
const response = await fetch(`${API_BASE_URL}/history/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete event');
}
}
export async function getTemplates(): Promise<ReminderTemplate[]> {
const response = await fetch(`${API_BASE_URL}/templates`);
if (!response.ok) {
throw new Error('Failed to fetch templates');
}
return response.json();
}
export async function createTemplate(name: string, reminders: Reminder[]): Promise<ReminderTemplate> {
const response = await fetch(`${API_BASE_URL}/templates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, reminders }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Failed to create template' }));
throw new Error(error.detail || 'Failed to create template');
}
return response.json();
}
export async function deleteTemplate(id: number): Promise<void> {
const response = await fetch(`${API_BASE_URL}/templates/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete template');
}
}

View File

@ -0,0 +1,110 @@
import { useState, useEffect } from 'react';
import { getHistory, deleteHistoryEvent, HistoryEvent } from '../api';
interface HistoryPanelProps {
onLoadEvent: (event: HistoryEvent) => void;
}
export default function HistoryPanel({ onLoadEvent }: HistoryPanelProps) {
const [events, setEvents] = useState<HistoryEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadHistory = async () => {
setLoading(true);
setError(null);
try {
const data = await getHistory(20);
setEvents(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load history');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadHistory();
}, []);
const handleDelete = async (id: number) => {
if (!confirm('Delete this event from history?')) return;
try {
await deleteHistoryEvent(id);
setEvents(events.filter(e => e.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete event');
}
};
const formatDate = (isoString: string) => {
try {
return new Date(isoString).toLocaleString();
} catch {
return isoString;
}
};
if (loading) {
return <div className="text-center text-gray-500 py-4">Loading...</div>;
}
if (error) {
return (
<div className="text-red-600 text-sm p-3 bg-red-50 rounded-md">
{error}
</div>
);
}
if (events.length === 0) {
return (
<div className="text-center text-gray-500 py-8">
<p>No history yet</p>
<p className="text-xs mt-1">Events you create will appear here</p>
</div>
);
}
return (
<div className="space-y-2">
{events.map((event) => (
<div
key={event.id}
className="border border-gray-200 rounded-md p-3 hover:border-indigo-300 transition-colors"
>
<div className="flex justify-between items-start mb-2">
<div className="flex-1">
<h4 className="font-medium text-gray-900 text-sm">{event.title}</h4>
<p className="text-xs text-gray-500 mt-1">
{formatDate(event.start_dt)}
</p>
</div>
</div>
{event.reminders && event.reminders.length > 0 && (
<div className="text-xs text-gray-600 mb-2">
🔔 {event.reminders.length} reminder{event.reminders.length > 1 ? 's' : ''}
</div>
)}
<div className="flex gap-2">
<button
onClick={() => onLoadEvent(event)}
className="flex-1 px-3 py-1 text-xs font-medium text-indigo-600 bg-indigo-50 rounded hover:bg-indigo-100 transition-colors"
>
Load
</button>
<button
onClick={() => handleDelete(event.id)}
className="px-3 py-1 text-xs font-medium text-red-600 bg-red-50 rounded hover:bg-red-100 transition-colors"
>
Delete
</button>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,119 @@
import { Reminder, ReminderUnit } from '../api';
interface ReminderEditorProps {
reminders: Reminder[];
onChange: (reminders: Reminder[]) => void;
}
const QUICK_ADD_REMINDERS: Reminder[] = [
{ amount: 10, unit: 'minutes' },
{ amount: 1, unit: 'hours' },
{ amount: 1, unit: 'days' },
{ amount: 1, unit: 'weeks' },
];
export default function ReminderEditor({ reminders, onChange }: ReminderEditorProps) {
const addReminder = (reminder?: Reminder) => {
if (reminders.length >= 10) {
return;
}
onChange([...reminders, reminder || { amount: 10, unit: 'minutes' }]);
};
const removeReminder = (index: number) => {
onChange(reminders.filter((_, i) => i !== index));
};
const updateReminder = (index: number, field: keyof Reminder, value: number | ReminderUnit) => {
const updated = [...reminders];
updated[index] = { ...updated[index], [field]: value };
onChange(updated);
};
const isValidReminder = (reminder: Reminder) => {
return reminder.amount > 0;
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700">
Reminders
</label>
<span className="text-xs text-gray-500">
{reminders.length}/10
</span>
</div>
{/* Quick add buttons */}
<div className="flex flex-wrap gap-2">
{QUICK_ADD_REMINDERS.map((reminder, index) => (
<button
key={index}
type="button"
onClick={() => addReminder(reminder)}
disabled={reminders.length >= 10}
className="px-3 py-1 text-xs font-medium text-indigo-600 bg-indigo-50 rounded-md hover:bg-indigo-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
+ {reminder.amount} {reminder.unit}
</button>
))}
</div>
{/* Reminder list */}
{reminders.length > 0 && (
<div className="space-y-2">
{reminders.map((reminder, index) => (
<div key={index} className="flex gap-2 items-start">
<input
type="number"
min="1"
value={reminder.amount}
onChange={(e) => updateReminder(index, 'amount', parseInt(e.target.value) || 0)}
className={`flex-1 px-3 py-2 border rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${
!isValidReminder(reminder) ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="Amount"
/>
<select
value={reminder.unit}
onChange={(e) => updateReminder(index, 'unit', e.target.value as ReminderUnit)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="minutes">Minutes</option>
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="weeks">Weeks</option>
</select>
<button
type="button"
onClick={() => removeReminder(index)}
className="px-3 py-2 text-red-600 border border-red-300 rounded-md hover:bg-red-50 transition-colors"
title="Remove reminder"
>
</button>
</div>
))}
</div>
)}
{/* Add button */}
<button
type="button"
onClick={() => addReminder()}
disabled={reminders.length >= 10}
className="w-full px-4 py-2 text-sm font-medium text-indigo-600 border border-indigo-300 rounded-md hover:bg-indigo-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
+ Add Custom Reminder
</button>
{/* Validation messages */}
{reminders.some(r => !isValidReminder(r)) && (
<p className="text-sm text-red-600">
All reminder amounts must be greater than 0
</p>
)}
</div>
);
}

View File

@ -0,0 +1,170 @@
import { useState, useEffect } from 'react';
import { getTemplates, createTemplate, deleteTemplate, ReminderTemplate, Reminder } from '../api';
interface TemplatesPanelProps {
currentReminders: Reminder[];
onApplyTemplate: (reminders: Reminder[]) => void;
}
export default function TemplatesPanel({ currentReminders, onApplyTemplate }: TemplatesPanelProps) {
const [templates, setTemplates] = useState<ReminderTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showSaveForm, setShowSaveForm] = useState(false);
const [newTemplateName, setNewTemplateName] = useState('');
const [saving, setSaving] = useState(false);
const loadTemplates = async () => {
setLoading(true);
setError(null);
try {
const data = await getTemplates();
setTemplates(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load templates');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTemplates();
}, []);
const handleSaveTemplate = async () => {
if (!newTemplateName.trim()) {
setError('Template name is required');
return;
}
if (currentReminders.length === 0) {
setError('Add at least one reminder to save as template');
return;
}
setSaving(true);
setError(null);
try {
const newTemplate = await createTemplate(newTemplateName.trim(), currentReminders);
setTemplates([newTemplate, ...templates]);
setNewTemplateName('');
setShowSaveForm(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save template');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm('Delete this template?')) return;
try {
await deleteTemplate(id);
setTemplates(templates.filter(t => t.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete template');
}
};
if (loading) {
return <div className="text-center text-gray-500 py-4">Loading...</div>;
}
return (
<div className="space-y-3">
{error && (
<div className="text-red-600 text-sm p-2 bg-red-50 rounded-md">
{error}
</div>
)}
{/* Save current reminders as template */}
<div className="border-b border-gray-200 pb-3">
{!showSaveForm ? (
<button
onClick={() => setShowSaveForm(true)}
disabled={currentReminders.length === 0}
className="w-full px-3 py-2 text-sm font-medium text-indigo-600 border border-indigo-300 rounded-md hover:bg-indigo-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
💾 Save Current Reminders
</button>
) : (
<div className="space-y-2">
<input
type="text"
value={newTemplateName}
onChange={(e) => setNewTemplateName(e.target.value)}
placeholder="Template name"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
<div className="flex gap-2">
<button
onClick={handleSaveTemplate}
disabled={saving}
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:bg-gray-300 transition-colors"
>
{saving ? 'Saving...' : 'Save'}
</button>
<button
onClick={() => {
setShowSaveForm(false);
setNewTemplateName('');
setError(null);
}}
className="px-3 py-2 text-sm font-medium text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
{/* Templates list */}
{templates.length === 0 ? (
<div className="text-center text-gray-500 py-8">
<p>No templates yet</p>
<p className="text-xs mt-1">Save your reminders for quick reuse</p>
</div>
) : (
<div className="space-y-2">
{templates.map((template) => (
<div
key={template.id}
className="border border-gray-200 rounded-md p-3 hover:border-indigo-300 transition-colors"
>
<h4 className="font-medium text-gray-900 text-sm mb-2">
{template.name}
</h4>
<div className="text-xs text-gray-600 mb-2 space-y-1">
{template.reminders.map((r, idx) => (
<div key={idx}>
{r.amount} {r.unit}
</div>
))}
</div>
<div className="flex gap-2">
<button
onClick={() => onApplyTemplate(template.reminders)}
className="flex-1 px-3 py-1 text-xs font-medium text-indigo-600 bg-indigo-50 rounded hover:bg-indigo-100 transition-colors"
>
Apply
</button>
<button
onClick={() => handleDelete(template.id)}
className="px-3 py-1 text-xs font-medium text-red-600 bg-red-50 rounded hover:bg-red-100 transition-colors"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

17
frontend/src/index.css Normal file
View File

@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

9
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

25
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

10
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
})

403
main.py Normal file
View File

@ -0,0 +1,403 @@
"""
FastAPI ICS Generator
Generate iCalendar .ics files from API parameters.
Requirements:
pip install fastapi uvicorn icalendar python-dateutil
Example usage:
# Run the server
python main.py
# Simple POST request with minimal parameters
curl -X POST "http://localhost:8000/ics" \
-H "Content-Type: application/json" \
-d '{
"title": "Team Meeting",
"start_dt": "2026-03-01T10:00:00Z"
}' \
--output event.ics
# Full POST request with all parameters including reminders
curl -X POST "http://localhost:8000/ics" \
-H "Content-Type: application/json" \
-d '{
"title": "Product Launch Meeting",
"description": "Discuss Q2 product launch strategy",
"location": "Conference Room A",
"start_dt": "2026-03-15T14:00:00-05:00",
"end_dt": "2026-03-15T16:00:00-05:00",
"timezone": "America/New_York",
"organizer_email": "manager@company.com",
"attendees": ["alice@company.com", "bob@company.com"],
"reminders": [
{"amount": 10, "unit": "minutes"},
{"amount": 1, "unit": "hours"},
{"amount": 1, "unit": "weeks"}
]
}' \
--output event.ics
# GET request with query parameters
curl "http://localhost:8000/ics?title=Quick%20Meeting&start_dt=2026-03-01T15:00:00Z" \
--output event.ics
"""
from datetime import datetime, timedelta
from enum import Enum
from typing import Optional, List
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
from pydantic import BaseModel, Field, field_validator
import uuid
from zoneinfo import ZoneInfo
app = FastAPI(
title="ICS Generator API",
description="Generate iCalendar .ics files from event parameters",
version="1.0.0"
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class ReminderUnit(str, Enum):
"""Supported reminder time units."""
minutes = "minutes"
hours = "hours"
days = "days"
weeks = "weeks"
class Reminder(BaseModel):
"""Reminder configuration for an event."""
amount: int = Field(..., gt=0, description="Amount of time before event")
unit: ReminderUnit = Field(..., description="Time unit for reminder")
class EventRequest(BaseModel):
"""Request model for creating an event."""
title: str = Field(..., min_length=1, description="Event title/summary")
description: Optional[str] = Field(None, description="Event description")
location: Optional[str] = Field(None, description="Event location")
start_dt: str = Field(..., description="Event start datetime (ISO 8601 string)")
end_dt: Optional[str] = Field(None, description="Event end datetime (ISO 8601 string)")
timezone: str = Field("UTC", description="Timezone for naive datetimes")
organizer_email: Optional[str] = Field(None, description="Organizer email address")
attendees: Optional[List[str]] = Field(None, description="List of attendee email addresses")
reminders: Optional[List[Reminder]] = Field(None, max_length=10, description="Event reminders (max 10)")
@field_validator('reminders')
@classmethod
def validate_reminders(cls, v):
"""Validate reminders list."""
if v is not None and len(v) > 10:
raise ValueError("Maximum 10 reminders allowed")
return v
def parse_datetime(dt_str: str, tz: str = "UTC") -> datetime:
"""Parse ISO datetime string and apply timezone if naive."""
try:
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo(tz))
return dt
except Exception as e:
raise ValueError(f"Invalid datetime format: {dt_str}. Expected ISO 8601 format.")
def format_datetime_for_ics(dt: datetime) -> str:
"""
Format datetime for iCalendar format.
Args:
dt: datetime object (can be naive or aware)
Returns:
Formatted datetime string for ICS file
"""
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
# Naive datetime - format without timezone
return dt.strftime("%Y%m%dT%H%M%S")
else:
# Aware datetime - convert to UTC and format with Z suffix
utc_dt = dt.astimezone(ZoneInfo("UTC"))
return utc_dt.strftime("%Y%m%dT%H%M%SZ")
def format_datetime_with_tzid(dt: datetime, timezone: str) -> tuple[str, str]:
"""
Format datetime with TZID parameter for iCalendar.
Args:
dt: datetime object
timezone: timezone string
Returns:
Tuple of (parameter, value) for DTSTART/DTEND
"""
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
# Naive datetime - apply timezone
dt = dt.replace(tzinfo=ZoneInfo(timezone))
if timezone == "UTC":
# Use UTC format with Z
return "", format_datetime_for_ics(dt)
else:
# Use TZID format
local_dt = dt.astimezone(ZoneInfo(timezone))
dt_str = local_dt.strftime("%Y%m%dT%H%M%S")
return f";TZID={timezone}", dt_str
def reminder_to_trigger(reminder: Reminder) -> str:
"""
Convert reminder to iCalendar TRIGGER format.
Args:
reminder: Reminder object
Returns:
ISO 8601 duration string (negative for before event)
"""
amount = reminder.amount
unit = reminder.unit
if unit == ReminderUnit.minutes:
return f"-PT{amount}M"
elif unit == ReminderUnit.hours:
return f"-PT{amount}H"
elif unit == ReminderUnit.days:
return f"-P{amount}D"
elif unit == ReminderUnit.weeks:
return f"-P{amount}W"
return "-PT15M" # Default fallback
def generate_ics(event: EventRequest) -> str:
"""
Generate iCalendar .ics file content from event parameters.
Args:
event: EventRequest object with event details
Returns:
ICS file content as string with CRLF line endings
"""
# Parse datetimes
start_dt = parse_datetime(event.start_dt, event.timezone)
# Calculate end_dt if not provided (default 60 minutes)
if event.end_dt:
end_dt = parse_datetime(event.end_dt, event.timezone)
else:
end_dt = start_dt + timedelta(minutes=60)
# Validate end_dt is after start_dt
if end_dt <= start_dt:
raise ValueError("end_dt must be after start_dt")
# Generate unique ID and timestamp
uid = str(uuid.uuid4())
dtstamp = datetime.now(ZoneInfo("UTC"))
# Start building ICS content
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//ICS Generator//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{format_datetime_for_ics(dtstamp)}",
]
# Add DTSTART and DTEND
lines.append(f"DTSTART:{format_datetime_for_ics(start_dt)}")
lines.append(f"DTEND:{format_datetime_for_ics(end_dt)}")
# Add SUMMARY (title)
lines.append(f"SUMMARY:{event.title}")
# Add optional DESCRIPTION
if event.description:
# Escape special characters and handle multiline
desc = event.description.replace("\\", "\\\\").replace(",", "\\,").replace(";", "\\;").replace("\n", "\\n")
lines.append(f"DESCRIPTION:{desc}")
# Add optional LOCATION
if event.location:
loc = event.location.replace("\\", "\\\\").replace(",", "\\,").replace(";", "\\;")
lines.append(f"LOCATION:{loc}")
# Add optional ORGANIZER
if event.organizer_email:
lines.append(f"ORGANIZER:mailto:{event.organizer_email}")
# Add optional ATTENDEES
if event.attendees:
for attendee in event.attendees:
lines.append(f"ATTENDEE:mailto:{attendee}")
# Add VALARM blocks for reminders
if event.reminders:
for reminder in event.reminders:
trigger = reminder_to_trigger(reminder)
lines.extend([
"BEGIN:VALARM",
"ACTION:DISPLAY",
"DESCRIPTION:Reminder",
f"TRIGGER:{trigger}",
"END:VALARM"
])
# Close VEVENT and VCALENDAR
lines.extend([
"END:VEVENT",
"END:VCALENDAR"
])
# Join with CRLF and ensure trailing CRLF
ics_content = "\r\n".join(lines) + "\r\n"
return ics_content
@app.get("/health")
async def health():
"""Health check endpoint."""
return {"ok": True}
@app.get("/")
async def root():
"""Root endpoint with API usage information."""
return {
"message": "ICS Generator API",
"usage": {
"endpoint": "/ics",
"methods": ["GET", "POST"],
"description": "Generate iCalendar .ics files from event parameters",
"docs": "/docs",
"example": {
"POST": {
"url": "/ics",
"body": {
"title": "Team Meeting",
"start_dt": "2026-03-01T10:00:00Z",
"description": "Monthly team sync",
"reminders": [
{"amount": 10, "unit": "minutes"},
{"amount": 1, "unit": "hours"}
]
}
},
"GET": {
"url": "/ics?title=Meeting&start_dt=2026-03-01T10:00:00Z"
}
}
}
}
@app.post("/ics")
async def create_ics_post(event: EventRequest):
"""
Generate and download an iCalendar .ics file from POST body parameters.
Returns:
Response with .ics file content and appropriate headers for download
"""
try:
ics_content = generate_ics(event)
return Response(
content=ics_content,
media_type="text/calendar; charset=utf-8",
headers={
"Content-Disposition": 'attachment; filename="event.ics"'
}
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error generating ICS file: {str(e)}")
@app.post("/preview")
async def preview_ics(event: EventRequest):
"""Generate and return ICS content as plain text for preview."""
try:
ics_content = generate_ics(event)
return Response(
content=ics_content,
media_type="text/plain; charset=utf-8"
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error generating ICS file: {str(e)}")
@app.get("/ics")
async def create_ics_get(
title: str = Query(..., description="Event title"),
start_dt: str = Query(..., description="Event start datetime (ISO 8601)"),
description: Optional[str] = Query(None, description="Event description"),
location: Optional[str] = Query(None, description="Event location"),
end_dt: Optional[str] = Query(None, description="Event end datetime (ISO 8601)"),
timezone: str = Query("UTC", description="Timezone for naive datetimes"),
organizer_email: Optional[str] = Query(None, description="Organizer email"),
):
"""
Generate and download an iCalendar .ics file from GET query parameters.
Note: This GET endpoint supports basic parameters only. For full functionality
including attendees and reminders, use the POST endpoint.
Returns:
Response with .ics file content and appropriate headers for download
"""
# Create EventRequest from query parameters
event = EventRequest(
title=title,
start_dt=start_dt,
description=description,
location=location,
end_dt=end_dt,
timezone=timezone,
organizer_email=organizer_email
)
try:
ics_content = generate_ics(event)
return Response(
content=ics_content,
media_type="text/calendar; charset=utf-8",
headers={
"Content-Disposition": 'attachment; filename="event.ics"'
}
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error generating ICS file: {str(e)}")
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
python-dateutil>=2.8.2