404 lines
12 KiB
Python
404 lines
12 KiB
Python
"""
|
|
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)
|