From ffb184acc7a1c924d237ba76654b24717d53a96b Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 23 Jun 2026 22:20:07 +0800 Subject: [PATCH] feat(calendar): U8 iCal/ICS import and export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pyproject.toml | 2 + src/agentkit/calendar/sync/__init__.py | 0 src/agentkit/calendar/sync/ics_provider.py | 175 +++++++++++++ src/agentkit/server/routes/calendar.py | 45 +++- tests/unit/calendar/test_ics_provider.py | 272 +++++++++++++++++++++ 5 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 src/agentkit/calendar/sync/__init__.py create mode 100644 src/agentkit/calendar/sync/ics_provider.py create mode 100644 tests/unit/calendar/test_ics_provider.py diff --git a/pyproject.toml b/pyproject.toml index 56f2e95..b734b63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/agentkit/calendar/sync/__init__.py b/src/agentkit/calendar/sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agentkit/calendar/sync/ics_provider.py b/src/agentkit/calendar/sync/ics_provider.py new file mode 100644 index 0000000..17ed738 --- /dev/null +++ b/src/agentkit/calendar/sync/ics_provider.py @@ -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 diff --git a/src/agentkit/server/routes/calendar.py b/src/agentkit/server/routes/calendar.py index 1b0b017..932f668 100644 --- a/src/agentkit/server/routes/calendar.py +++ b/src/agentkit/server/routes/calendar.py @@ -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"'}, + ) diff --git a/tests/unit/calendar/test_ics_provider.py b/tests/unit/calendar/test_ics_provider.py new file mode 100644 index 0000000..f69e365 --- /dev/null +++ b/tests/unit/calendar/test_ics_provider.py @@ -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"}