feat(calendar): U8 iCal/ICS import and export

ICSProvider parses .ics files (icalendar library) and creates local
CalendarEvents, skipping duplicate UIDs. Export builds an iCalendar
from events in a date range, deduplicating recurring event
occurrences back to a single VEVENT with RRULE. REST endpoints:
POST /import-ics (multipart upload), GET /export-ics (download).

- src/agentkit/calendar/sync/__init__.py — sync subpackage init
- src/agentkit/calendar/sync/ics_provider.py — ICSProvider (import/export)
- src/agentkit/calendar/db.py — added get_event_by_external_id() for dedup
- src/agentkit/server/routes/calendar.py — import-ics and export-ics endpoints
- pyproject.toml — added icalendar>=5.0 dependency
- tests/unit/calendar/test_ics_provider.py — 8 tests
This commit is contained in:
chiguyong 2026-06-23 22:20:07 +08:00
parent 26efbb51db
commit ffb184acc7
5 changed files with 493 additions and 1 deletions

View File

@ -26,6 +26,8 @@ dependencies = [
"aiosqlite>=0.20",
# Calendar & schedule (RRULE expansion)
"python-dateutil>=2.9",
# Calendar ICS import/export (U8)
"icalendar>=5.0",
# Document processing (U1-U9)
"python-docx>=1.1",
"openpyxl>=3.1",

View File

View File

@ -0,0 +1,175 @@
"""ICS (iCalendar) import/export provider (U8).
Uses the ``icalendar`` library for RFC 5545 compliant parsing/serialization.
Import delegates to ``calendar.db.insert_event`` for direct persistence with
``external_id`` set (so duplicate UIDs can be skipped on re-import).
Export reads via ``CalendarService.list_events``.
"""
from __future__ import annotations
import logging
import uuid
from datetime import date, datetime, timezone
from typing import Any
from icalendar import Calendar, Event
from icalendar.prop import vRecur
from agentkit.calendar.db import get_event_by_external_id, insert_event
from agentkit.calendar.models import CalendarEvent, _now_iso
from agentkit.calendar.service import CalendarService
logger = logging.getLogger(__name__)
def _to_iso_utc(dt: datetime) -> str:
"""Convert a datetime to ISO 8601 UTC string."""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat()
def _parse_iso(dt_str: str) -> datetime:
"""Parse an ISO 8601 string to a UTC-aware datetime."""
dt = datetime.fromisoformat(dt_str)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _extract_dt(component: Any, key: str) -> tuple[str, bool]:
"""Extract a date/datetime property from an icalendar component.
Returns ``(iso_string, is_all_day)``. ``is_all_day`` is True when the
value is a bare ``date`` (not a ``datetime``).
"""
prop = component.get(key)
if prop is None:
return "", False
val = prop.dt
if isinstance(val, date) and not isinstance(val, datetime):
return val.isoformat(), True
return _to_iso_utc(val), False
class ICSProvider:
"""Import/export calendar events as iCalendar (.ics) files."""
def __init__(self, service: CalendarService) -> None:
self.service = service
async def import_ics(self, ics_bytes: bytes, user_id: str) -> dict[str, Any]:
"""Parse ICS bytes and create events for *user_id*.
Returns ``{"imported": N, "skipped": M, "errors": [...]}``.
Raises ``ValueError`` if the ICS content cannot be parsed at all.
"""
imported = 0
skipped = 0
errors: list[str] = []
try:
cal = Calendar.from_ical(ics_bytes)
except Exception as e:
raise ValueError(f"Failed to parse ICS: {e}") from e
for component in cal.walk("VEVENT"):
try:
uid = str(component.get("UID", "") or "") or None
# Dedup by (external_id, provider, user_id)
if uid:
existing = await get_event_by_external_id(
uid, "ics", user_id, self.service.db_path
)
if existing is not None:
skipped += 1
continue
title = str(component.get("SUMMARY", "") or "")
if not title:
errors.append("VEVENT missing SUMMARY, skipped")
continue
start_str, is_all_day = _extract_dt(component, "DTSTART")
end_str, _ = _extract_dt(component, "DTEND")
if not end_str:
end_str = start_str
description = str(component.get("DESCRIPTION", "") or "")
location = str(component.get("LOCATION", "") or "")
rrule_str: str | None = None
rrule = component.get("RRULE")
if rrule is not None:
rrule_str = rrule.to_ical().decode("utf-8")
now = _now_iso()
event = CalendarEvent(
id=uuid.uuid4().hex,
user_id=user_id,
title=title,
description=description,
start_time=start_str,
end_time=end_str,
is_all_day=is_all_day,
location=location,
rrule=rrule_str,
source="manual",
external_id=uid,
external_provider="ics" if uid else None,
last_modified=now,
created_at=now,
)
await insert_event(event, self.service.db_path)
imported += 1
except Exception as e:
errors.append(f"Failed to import VEVENT: {e}")
logger.warning("ICS import error: %s", e)
return {"imported": imported, "skipped": skipped, "errors": errors}
def export_ics(self, events: list[CalendarEvent]) -> bytes:
"""Serialize *events* to ICS bytes."""
cal = Calendar()
cal.add("prodid", "-//Fischer AgentKit//Calendar//EN")
cal.add("version", "2.0")
# ponytail: list_events expands RRULE into occurrences (same event.id).
# ICS wants one VEVENT with RRULE, not N copies — dedup by id.
seen_ids: set[str] = set()
for event in events:
if event.id in seen_ids:
continue
seen_ids.add(event.id)
cal.add_component(self._event_to_vevent(event))
return cal.to_ical()
def _event_to_vevent(self, event: CalendarEvent) -> Event:
"""Convert a :class:`CalendarEvent` to an icalendar ``Event`` component."""
vevent = Event()
vevent.add("uid", event.id)
vevent.add("summary", event.title)
start_dt = _parse_iso(event.start_time)
end_dt = _parse_iso(event.end_time)
if event.is_all_day:
vevent.add("dtstart", start_dt.date())
vevent.add("dtend", end_dt.date())
else:
vevent.add("dtstart", start_dt)
vevent.add("dtend", end_dt)
if event.description:
vevent.add("description", event.description)
if event.location:
vevent.add("location", event.location)
if event.rrule:
# ponytail: vRecur.from_ical reorders keys (e.g. COUNT before BYDAY)
# but the result is semantically equivalent RFC 5545.
vevent.add("rrule", vRecur.from_ical(event.rrule))
return vevent

View File

@ -25,9 +25,11 @@ from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel, Field
from agentkit.calendar.sync.ics_provider import ICSProvider
from agentkit.server.auth.dependencies import require_authenticated
logger = logging.getLogger(__name__)
@ -397,3 +399,44 @@ async def create_tag(
service = _get_calendar_service(request)
tag = await service.create_tag(user_id=user["user_id"], name=body.name)
return {"success": True, "tag": tag.to_dict()}
# ---------------------------------------------------------------------------
# ICS import/export (U8)
# ---------------------------------------------------------------------------
@router.post("/import-ics")
async def import_ics(
request: Request,
file: UploadFile = File(...),
user: dict = Depends(require_authenticated),
) -> dict[str, Any]:
"""Import events from an uploaded .ics file."""
service = _get_calendar_service(request)
content = await file.read()
provider = ICSProvider(service)
try:
result = await provider.import_ics(content, user["user_id"])
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"success": True, **result}
@router.get("/export-ics")
async def export_ics(
request: Request,
start: str | None = Query(None),
end: str | None = Query(None),
user: dict = Depends(require_authenticated),
) -> Response:
"""Export the current user's events to a downloadable .ics file."""
service = _get_calendar_service(request)
events = await service.list_events(user_id=user["user_id"], start=start, end=end)
provider = ICSProvider(service)
ics_bytes = provider.export_ics(events)
return Response(
content=ics_bytes,
media_type="text/calendar",
headers={"Content-Disposition": 'attachment; filename="calendar.ics"'},
)

View File

@ -0,0 +1,272 @@
"""Tests for ICSProvider — iCalendar import/export (U8)."""
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from icalendar import Calendar
from agentkit.calendar.db import init_calendar_db, list_events as db_list_events
from agentkit.calendar.service import CalendarService
from agentkit.calendar.sync.ics_provider import ICSProvider
from agentkit.server.auth.models import init_auth_db
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def calendar_db_path(tmp_path: Path) -> Path:
path = tmp_path / "test_calendar.db"
asyncio.run(init_calendar_db(path))
return path
@pytest.fixture
def auth_db_path(tmp_path: Path) -> Path:
path = tmp_path / "test_auth.db"
asyncio.run(init_auth_db(path))
return path
@pytest.fixture
def service(calendar_db_path: Path, auth_db_path: Path) -> CalendarService:
return CalendarService(db_path=calendar_db_path, auth_db_path=auth_db_path)
@pytest.fixture
def provider(service: CalendarService) -> ICSProvider:
return ICSProvider(service)
USER_ID = "user-1"
# ---------------------------------------------------------------------------
# ICS sample strings
# ---------------------------------------------------------------------------
SIMPLE_ICS = (
b"BEGIN:VCALENDAR\n"
b"VERSION:2.0\n"
b"PRODID:-//Test//Test//EN\n"
b"BEGIN:VEVENT\n"
b"UID:simple-uid@test\n"
b"SUMMARY:Test Event\n"
b"DTSTART:20260701T100000Z\n"
b"DTEND:20260701T110000Z\n"
b"DESCRIPTION:Test description\n"
b"LOCATION:Room A\n"
b"END:VEVENT\n"
b"END:VCALENDAR\n"
)
RECURRING_ICS = (
b"BEGIN:VCALENDAR\n"
b"VERSION:2.0\n"
b"PRODID:-//Test//Test//EN\n"
b"BEGIN:VEVENT\n"
b"UID:recur-uid@test\n"
b"SUMMARY:Weekly Meeting\n"
b"DTSTART:20260706T100000Z\n"
b"DTEND:20260706T110000Z\n"
b"RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4\n"
b"END:VEVENT\n"
b"END:VCALENDAR\n"
)
ALL_DAY_ICS = (
b"BEGIN:VCALENDAR\n"
b"VERSION:2.0\n"
b"PRODID:-//Test//Test//EN\n"
b"BEGIN:VEVENT\n"
b"UID:allday-uid@test\n"
b"SUMMARY:All Day Event\n"
b"DTSTART;VALUE=DATE:20260701\n"
b"DTEND;VALUE=DATE:20260702\n"
b"END:VEVENT\n"
b"END:VCALENDAR\n"
)
# ---------------------------------------------------------------------------
# Import tests
# ---------------------------------------------------------------------------
async def test_import_simple_ics_creates_event(
provider: ICSProvider, calendar_db_path: Path
) -> None:
"""Single VEVENT ICS → event created with correct fields."""
result = await provider.import_ics(SIMPLE_ICS, USER_ID)
assert result["imported"] == 1
assert result["skipped"] == 0
assert result["errors"] == []
events = await db_list_events(USER_ID, db_path=calendar_db_path)
assert len(events) == 1
event = events[0]
assert event.title == "Test Event"
assert event.description == "Test description"
assert event.location == "Room A"
assert event.start_time == "2026-07-01T10:00:00+00:00"
assert event.end_time == "2026-07-01T11:00:00+00:00"
assert event.is_all_day is False
assert event.external_id == "simple-uid@test"
assert event.external_provider == "ics"
async def test_import_recurring_ics_preserves_rrule(
provider: ICSProvider, calendar_db_path: Path
) -> None:
"""VEVENT with RRULE → rrule field set on the created event."""
result = await provider.import_ics(RECURRING_ICS, USER_ID)
assert result["imported"] == 1
assert result["errors"] == []
events = await db_list_events(USER_ID, db_path=calendar_db_path)
assert len(events) == 1
event = events[0]
assert event.rrule is not None
assert "FREQ=WEEKLY" in event.rrule
assert "BYDAY=MO" in event.rrule
assert "COUNT=4" in event.rrule
async def test_import_all_day_event(provider: ICSProvider, calendar_db_path: Path) -> None:
"""DTSTART is date (not datetime) → is_all_day=True, ISO date stored."""
result = await provider.import_ics(ALL_DAY_ICS, USER_ID)
assert result["imported"] == 1
assert result["errors"] == []
events = await db_list_events(USER_ID, db_path=calendar_db_path)
assert len(events) == 1
event = events[0]
assert event.is_all_day is True
assert event.start_time == "2026-07-01"
assert event.end_time == "2026-07-02"
async def test_import_skips_duplicate_uid(provider: ICSProvider, calendar_db_path: Path) -> None:
"""Importing the same ICS twice → second import skips the duplicate UID."""
first = await provider.import_ics(SIMPLE_ICS, USER_ID)
assert first["imported"] == 1
assert first["skipped"] == 0
second = await provider.import_ics(SIMPLE_ICS, USER_ID)
assert second["imported"] == 0
assert second["skipped"] == 1
events = await db_list_events(USER_ID, db_path=calendar_db_path)
assert len(events) == 1 # Still only one event
async def test_import_malformed_ics_raises_error(provider: ICSProvider) -> None:
"""Invalid ICS bytes → ValueError raised (graceful, not a crash)."""
with pytest.raises(ValueError, match="Failed to parse ICS"):
await provider.import_ics(b"this is definitely not valid ics at all", USER_ID)
# ---------------------------------------------------------------------------
# Export tests
# ---------------------------------------------------------------------------
async def test_export_produces_valid_ics(provider: ICSProvider, service: CalendarService) -> None:
"""Create event, export, parse result with icalendar → roundtrip."""
await service.create_event(
user_id=USER_ID,
title="Export Test",
start_time="2026-07-01T10:00:00+00:00",
end_time="2026-07-01T11:00:00+00:00",
description="Desc",
location="Room B",
)
events = await service.list_events(USER_ID)
ics_bytes = provider.export_ics(events)
# Parse the exported ICS back
cal = Calendar.from_ical(ics_bytes)
vevents = list(cal.walk("VEVENT"))
assert len(vevents) == 1
vevent = vevents[0]
assert str(vevent.get("SUMMARY")) == "Export Test"
assert str(vevent.get("DESCRIPTION")) == "Desc"
assert str(vevent.get("LOCATION")) == "Room B"
# DTSTART should be a datetime (not all-day)
dtstart = vevent.get("DTSTART").dt
assert dtstart.year == 2026
assert dtstart.month == 7
assert dtstart.day == 1
async def test_export_includes_recurrence(provider: ICSProvider, service: CalendarService) -> None:
"""Event with rrule → exported ICS contains RRULE line."""
await service.create_event(
user_id=USER_ID,
title="Recurring Export",
start_time="2026-07-06T10:00:00+00:00",
end_time="2026-07-06T11:00:00+00:00",
rrule="FREQ=WEEKLY;BYDAY=MO;COUNT=4",
)
events = await service.list_events(USER_ID)
ics_bytes = provider.export_ics(events)
cal = Calendar.from_ical(ics_bytes)
vevents = list(cal.walk("VEVENT"))
assert len(vevents) == 1
vevent = vevents[0]
rrule = vevent.get("RRULE")
assert rrule is not None
rrule_str = rrule.to_ical().decode("utf-8")
assert "FREQ=WEEKLY" in rrule_str
assert "BYDAY=MO" in rrule_str
assert "COUNT=4" in rrule_str
async def test_export_date_range_filter(provider: ICSProvider, service: CalendarService) -> None:
"""3 events, export range covering 2 → only 2 in ICS output."""
# Event 1: July 1
await service.create_event(
user_id=USER_ID,
title="Event 1",
start_time="2026-07-01T10:00:00+00:00",
end_time="2026-07-01T11:00:00+00:00",
)
# Event 2: July 5
await service.create_event(
user_id=USER_ID,
title="Event 2",
start_time="2026-07-05T10:00:00+00:00",
end_time="2026-07-05T11:00:00+00:00",
)
# Event 3: July 20
await service.create_event(
user_id=USER_ID,
title="Event 3",
start_time="2026-07-20T10:00:00+00:00",
end_time="2026-07-20T11:00:00+00:00",
)
# Export range: July 1 July 10 (covers Event 1 and Event 2)
events = await service.list_events(
USER_ID,
start="2026-07-01T00:00:00+00:00",
end="2026-07-10T00:00:00+00:00",
)
ics_bytes = provider.export_ics(events)
cal = Calendar.from_ical(ics_bytes)
vevents = list(cal.walk("VEVENT"))
assert len(vevents) == 2
summaries = {str(v.get("SUMMARY")) for v in vevents}
assert summaries == {"Event 1", "Event 2"}