calink/main.py
2026-02-20 15:50:32 +02:00

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)