diff --git a/routes/calendar_routes.py b/routes/calendar_routes.py index 7e1523a4d..d1b621ad1 100644 --- a/routes/calendar_routes.py +++ b/routes/calendar_routes.py @@ -460,6 +460,9 @@ def _event_to_dict(ev: CalendarEvent) -> dict: # ── Recurrence expansion ── +_RRULE_EXPANSION_LIMIT = 1000 + + def _expand_rrule( ev: CalendarEvent, start: datetime, end: datetime ) -> List[dict]: @@ -482,6 +485,7 @@ def _expand_rrule( d = _event_to_dict(ev) d["is_recurrence"] = False d["series_uid"] = ev.uid + d["truncated"] = False return [d] # Parse the rrule, applying it to the base dtstart. @@ -507,6 +511,7 @@ def _expand_rrule( d = _event_to_dict(ev) d["is_recurrence"] = False d["series_uid"] = ev.uid + d["truncated"] = False # Malformed RRULE rows are fetched by the recurring SQL branch # with only dtstart < end_dt — the base event may not actually # overlap the window. Only return if it does. @@ -519,22 +524,26 @@ def _expand_rrule( # (matching non-recurring overlap semantics: dtstart < end AND # dtend > start). expand_start = start - duration - occurrences = rule.between(expand_start, end, inc=True) - if not occurrences: - return [] - results = [] + truncated = False base = _event_to_dict(ev) - for occ_start in occurrences: + for occ_start in rule.xafter(expand_start, inc=True): + if occ_start >= end: + break + occ_end = occ_start + duration # Overlap filter: occurrence must intersect [start, end). # This enforces exclusive-end semantics (occ_start >= end is # excluded) and includes multi-day crossings (occ_end > start). - if occ_start >= end or occ_end <= start: + if occ_end <= start: continue + if len(results) >= _RRULE_EXPANSION_LIMIT: + truncated = True + break + # Build the compound uid: {base_uid}::{date} or ::{datetime} if ev.all_day: occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%d')}" @@ -545,6 +554,7 @@ def _expand_rrule( d["uid"] = occ_uid d["series_uid"] = ev.uid d["is_recurrence"] = True + d["truncated"] = False if ev.all_day: d["dtstart"] = occ_start.strftime("%Y-%m-%d") @@ -557,6 +567,10 @@ def _expand_rrule( results.append(d) + if truncated: + for d in results: + d["truncated"] = True + return results @@ -786,8 +800,12 @@ def setup_calendar_routes() -> APIRouter: expanded.extend(_expand_rrule(e, start_dt, end_dt)) # Sort by occurrence start time for consistent frontend ordering. + truncated = any(e.get("truncated") for e in expanded) expanded.sort(key=lambda d: d["dtstart"]) - return {"events": expanded} + response: dict = {"events": expanded} + if truncated: + response["truncated"] = True + return response except HTTPException: raise except Exception as e: diff --git a/tests/test_calendar_recurrence.py b/tests/test_calendar_recurrence.py index cc806566c..bc78127ed 100644 --- a/tests/test_calendar_recurrence.py +++ b/tests/test_calendar_recurrence.py @@ -319,3 +319,20 @@ def test_expand_metadata_inheritance(): assert r["importance"] == "critical" assert r["event_type"] == "work" assert r["location"] == "Room 42" + + +def test_expand_daily_rrule_large_window_is_capped_and_marked_truncated(): + """Wide recurring windows must not materialize unbounded occurrence lists.""" + cal = _import_calendar_helpers() + ev = _make_event( + uid="evt-daily-cap", + dtstart=datetime(2020, 1, 1, 9, 0), + dtend=datetime(2020, 1, 1, 10, 0), + rrule="FREQ=DAILY", + ) + + results = cal._expand_rrule(ev, datetime(2020, 1, 1), datetime(2030, 1, 1)) + + assert len(results) == cal._RRULE_EXPANSION_LIMIT + assert results[-1]["uid"] == "evt-daily-cap::2022-09-26T09:00" + assert all(r["truncated"] is True for r in results)