""" 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)