2 Commits
Author SHA1 Message Date
zemion 8e36f8b3d2 fix(calendar): bound recurrence work without hiding busy intervals
Module Package Release / publish-packages (push) Successful in 14s
Release v0.1.24. Coordinated integrity review: GovOPlaN/govoplan-core#298.
2026-09-08 12:19:36 +02:00
zemion 3e5fc05ca3 feat: promote calendar to a stable product destination
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 17:58:27 +02:00
11 changed files with 578 additions and 86 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/calendar-webui", "name": "@govoplan/calendar-webui",
"version": "0.1.22", "version": "0.1.24",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.44",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-calendar" name = "govoplan-calendar"
version = "0.1.22" version = "0.1.24"
description = "GovOPlaN calendar module with VEVENT storage and WebUI integration." description = "GovOPlaN calendar module with VEVENT storage and WebUI integration."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.18", "govoplan-core>=0.1.46",
"govoplan-access>=0.1.18", "govoplan-access>=0.1.18",
"defusedxml>=0.7,<1", "defusedxml>=0.7,<1",
"icalendar>=7.2", "icalendar>=7.2",
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.22" __version__ = "0.1.24"
+86 -5
View File
@@ -215,7 +215,82 @@ def add_event_raw_records(component: Event, event: Any) -> None:
add_raw_property(component, "RELATED-TO", record) add_raw_property(component, "RELATED-TO", record)
def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 1000) -> list[dict[str, Any]]: def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 10_000) -> list[dict[str, Any]]:
"""Return every occurrence in range, or explicitly reject an unsafe expansion."""
return expand_events_occurrences([event], range_start, range_end, limit=limit)[0]
def expand_events_occurrences(
events: list[Any], range_start: datetime, range_end: datetime,
*, limit: int = 10_000, admission: Any = None,
) -> list[list[dict[str, Any]]]:
from govoplan_core.security.bounded_process import (
ProcessBudgetError, bounded_operation_admission, run_bounded_operation,
)
from govoplan_core.security.worker_payload import (
WorkerPayloadError, decode_worker_payload, encode_worker_payload,
)
from govoplan_calendar.backend.recurrence_worker import RECURRENCE_LIMITS, expand_recurrences_worker
if not events:
return []
if type(limit) is not int or not 1 <= limit <= 10_000 or len(events) > 2_000:
raise ICalendarError("Recurrence expansion exceeds its limit; request a smaller range or fewer calendars.")
try:
if admission is None:
with bounded_operation_admission() as reserved:
return expand_events_occurrences(events, range_start, range_end, limit=limit, admission=reserved)
projections = [
{
"uid": item.uid,
"start_at": item.start_at,
"end_at": item.end_at,
"duration_seconds": getattr(item, "duration_seconds", None),
"all_day": item.all_day,
"timezone": getattr(item, "timezone", None),
"rrule": item.rrule,
"rdate": item.rdate or [],
"exdate": item.exdate or [],
}
for item in events
]
payload = encode_worker_payload(
{"events": projections, "start": range_start, "end": range_end, "limit": limit},
max_bytes=RECURRENCE_LIMITS.input_bytes,
)
result = decode_worker_payload(
run_bounded_operation(expand_recurrences_worker, payload, limits=RECURRENCE_LIMITS, admission=admission),
max_bytes=RECURRENCE_LIMITS.output_bytes,
)
if not isinstance(result, dict) or result.get("error"):
raise ICalendarError("Recurrence expansion is invalid or exceeds its limit; request a smaller range or fewer calendars.")
batches = result.get("occurrences")
if not isinstance(batches, list) or len(batches) != len(events):
raise ICalendarError("Recurrence worker returned an invalid result.")
count = 0
for source, batch in zip(events, batches, strict=True):
if not isinstance(batch, list):
raise ICalendarError("Recurrence worker returned an invalid result.")
count += len(batch)
if count > limit:
raise ICalendarError("Recurrence worker exceeded its result limit.")
for item in batch:
if (
not isinstance(item, dict)
or set(item) != {"uid", "recurrence_id", "start_at", "end_at", "all_day"}
or item["uid"] != source.uid
or not isinstance(item["recurrence_id"], str)
or not isinstance(item["start_at"], datetime)
or (item["end_at"] is not None and not isinstance(item["end_at"], datetime))
or type(item["all_day"]) is not bool
):
raise ICalendarError("Recurrence worker returned an invalid result.")
return batches
except (ProcessBudgetError, WorkerPayloadError) as exc:
raise ICalendarError("Recurrence expansion could not complete within its resource limits; narrow the request or retry later.") from exc
def _expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 10_000) -> list[dict[str, Any]]:
"""Expand an event's recurrence primitives within a range. """Expand an event's recurrence primitives within a range.
This is a backend primitive for later API/UI recurrence handling. It expands This is a backend primitive for later API/UI recurrence handling. It expands
@@ -240,14 +315,20 @@ def expand_event_occurrences(event: Any, range_start: datetime, range_end: datet
for exdate in temporal_values_from_records(event.exdate or []): for exdate in temporal_values_from_records(event.exdate or []):
rules.exdate(exdate) rules.exdate(exdate)
starts = rules.between(range_start - duration, range_end, inc=True)
occurrences: list[dict[str, Any]] = [] occurrences: list[dict[str, Any]] = []
for occurrence_start in starts: # Never materialize rules.between(): dense rules allocate the whole range
# before a result limit can run. Sparse rules and pre-range scans are also
# covered by the disposable worker's CPU/wall/memory limits.
for scanned, occurrence_start in enumerate(rules, start=1):
if scanned > 100_000:
raise ICalendarError("Recurrence scan limit exceeded.")
occurrence_start = normalize_datetime(occurrence_start) occurrence_start = normalize_datetime(occurrence_start)
if occurrence_start > range_end:
break
if occurrence_overlaps(occurrence_start, duration, range_start, range_end): if occurrence_overlaps(occurrence_start, duration, range_start, range_end):
occurrences.append(occurrence_payload(occurrence_start, duration, event))
if len(occurrences) >= limit: if len(occurrences) >= limit:
break raise ICalendarError("Recurrence result limit exceeded.")
occurrences.append(occurrence_payload(occurrence_start, duration, event))
return occurrences return occurrences
+80 -3
View File
@@ -39,7 +39,9 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
ProductAvailabilityExplanation,
ProductAreaContribution, ProductAreaContribution,
ProductSurfaceContribution,
QuickAccessTool, QuickAccessTool,
RoleTemplate, RoleTemplate,
) )
@@ -618,7 +620,7 @@ def _open_xchange_provider_states(context):
manifest = ModuleManifest( manifest = ModuleManifest(
id="calendar", id="calendar",
name="Calendar", name="Calendar",
version="0.1.22", version="0.1.24",
required_capabilities=( required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -689,6 +691,54 @@ manifest = ModuleManifest(
), ),
), ),
documentation=( documentation=(
DocumentationTopic(
id="calendar.complete-bounded-availability",
title="Complete availability and bounded recurrence expansion",
summary="Availability either includes every busy occurrence or reports that the request could not complete.",
body=(
"Expanded ranges are limited to 400 days. A request admits at most 2,000 calendars and loads at most 2,000 "
"series, direct events, or override candidates per category. Recurrences run together in one disposable, "
"data-only worker with shared non-queuing admission, 5 seconds wall time, 3 seconds CPU, 256 MiB memory, "
"and 4 MiB input/output budgets. Results are limited to 10,000 occurrences in total; each series scans at "
"most 100,000 recurrence candidates. These are resource boundaries, not an arbitrary-code sandbox. "
"On SQLite and PostgreSQL, candidate identity/size projections enforce a shared 4 MiB data budget in SQL "
"before text/JSON fields reach the application; raw iCalendar source is never loaded for expansion. The full "
"expanded response has a separate 4 MiB JSON byte limit, including repeated descriptions and metadata. "
"Free/busy loads only scheduling fields, so large descriptions or attachments do not prevent an otherwise "
"bounded, complete availability answer. Unsupported database dialects fail explicitly. "
"An exceeded limit or unavailable worker causes an explicit error, never a partial free/busy answer. "
"Narrow the range or calendars, or retry if capacity is busy. A UI response limit only slices a successfully "
"completed result; it does not hide failed expansion. Existing tenant/calendar visibility, overrides, "
"cancellations, exclusions, timezone handling, and stored iCalendar properties remain unchanged. "
"Verifying an individual recurrence uses the same worker boundary; failure does not edit the occurrence."
),
layer="always", documentation_types=("user", "admin"),
audience=("user", "calendar_manager", "administrator"), order=19,
translations={"de": {
"title": "Vollständige Verfügbarkeit und begrenzte Serienberechnung",
"summary": "Die Verfügbarkeit enthält alle belegten Termine oder meldet ausdrücklich eine unvollendete Anfrage.",
"body": (
"Erweiterte Zeiträume sind auf 400 Tage begrenzt. Eine Anfrage umfasst höchstens 2.000 Kalender und lädt "
"jeweils höchstens 2.000 Serien-, Direkttermin- oder Ausnahmekandidaten. Serien werden gemeinsam in einem "
"kurzlebigen Worker ohne Datenbankzugriff berechnet: gemeinsame Zulassung ohne Warteschlange, 5 Sekunden "
"Laufzeit, 3 Sekunden CPU, 256 MiB Speicher und je 4 MiB Ein-/Ausgabe. Insgesamt sind höchstens 10.000 "
"Vorkommen erlaubt; pro Serie werden maximal 100.000 Kandidaten durchlaufen. Dies ist eine Ressourcengrenze, "
"keine Sandbox für beliebigen Code. Unter SQLite und PostgreSQL begrenzen Kennungs-/Größenprojektionen "
"bereits in SQL die gemeinsam geladenen Kandidatendaten auf 4 MiB, bevor Text-/JSON-Felder die Anwendung "
"erreichen; die rohe iCalendar-Quelle wird für die Erweiterung nicht geladen. Die vollständige erweiterte "
"Antwort hat ein separates JSON-Bytelimit von 4 MiB einschließlich wiederholter Beschreibungen und Metadaten. "
"Frei/Belegt lädt nur Planungsfelder, sodass große Beschreibungen oder Anhänge eine ansonsten begrenzte, "
"vollständige Verfügbarkeitsantwort nicht verhindern. Nicht unterstützte Datenbankdialekte melden einen "
"ausdrücklichen Fehler. Überschrittene Grenzen oder nicht verfügbare Worker erzeugen einen "
"ausdrücklichen Fehler, niemals eine unvollständige Frei-/Belegt-Antwort. Zeitraum oder Kalenderauswahl "
"verkleinern beziehungsweise bei ausgelasteter Kapazität später erneut versuchen. Ein UI-Ausgabelimit kürzt "
"nur ein vollständig berechnetes Ergebnis und verdeckt keine fehlgeschlagene Berechnung. Bestehende "
"Mandanten-/Kalendersichtbarkeit, Ausnahmen, Absagen, Ausschlüsse, Zeitzonenbehandlung und gespeicherte "
"iCalendar-Eigenschaften bleiben erhalten. Einzelne Serienvorkommen werden unter derselben Worker-Grenze "
"geprüft; ein Fehlschlag ändert das Vorkommen nicht."
),
}},
),
DocumentationTopic( DocumentationTopic(
id="calendar.privacy.data-subject-requests", id="calendar.privacy.data-subject-requests",
title="Review Calendar data in a data-subject request", title="Review Calendar data in a data-subject request",
@@ -779,7 +829,8 @@ manifest = ModuleManifest(
title="Calendar in product navigation and Quick Access", title="Calendar in product navigation and Quick Access",
summary="Use Calendar in Meetings and decisions and keep an optional compact agenda beside current work.", summary="Use Calendar in Meetings and decisions and keep an optional compact agenda beside current work.",
body=( body=(
"Calendar contributes its workspace to Meetings and decisions. When Quick Access is enabled, " "Calendar contributes its workspace to the stable Calendar destination at /agenda in Meetings and decisions. "
"The owner route /calendar remains available through All available tools and as a compatible deep link. When Quick Access is enabled, "
"the owner-rendered agenda shows at most seven authorized events across the current or explicitly selected temporal " "the owner-rendered agenda shows at most seven authorized events across the current or explicitly selected temporal "
"context and links to the full workspace. Event selection returns a typed Calendar reference; accounts with event-write " "context and links to the full workspace. Event selection returns a typed Calendar reference; accounts with event-write "
"permission can launch the full owner-rendered creation dialog at that date. Calendar rechecks tenant, scope, private " "permission can launch the full owner-rendered creation dialog at that date. Calendar rechecks tenant, scope, private "
@@ -795,7 +846,8 @@ manifest = ModuleManifest(
"title": "Kalender in Produktnavigation und Schnellzugriff", "title": "Kalender in Produktnavigation und Schnellzugriff",
"summary": "Den Kalender unter Termine und Entscheidungen sowie optional als kompakte Agenda neben der aktuellen Arbeit verwenden.", "summary": "Den Kalender unter Termine und Entscheidungen sowie optional als kompakte Agenda neben der aktuellen Arbeit verwenden.",
"body": ( "body": (
"Calendar ordnet seinen Arbeitsbereich Termine und Entscheidungen zu. Ist der Schnellzugriff aktiviert, " "Calendar ordnet seinen Arbeitsbereich dem stabilen Produktziel Kalender unter /agenda in Termine und Entscheidungen zu. "
"Der Eigentümerpfad /calendar bleibt unter Alle verfügbaren Werkzeuge und als kompatibler Direktlink erreichbar. Ist der Schnellzugriff aktiviert, "
"zeigt die vom Kalender gerenderte Agenda höchstens sieben berechtigte Termine im aktuellen oder ausdrücklich " "zeigt die vom Kalender gerenderte Agenda höchstens sieben berechtigte Termine im aktuellen oder ausdrücklich "
"gewählten Zeitkontext. Die Terminauswahl liefert eine typisierte Kalenderreferenz; mit Schreibberechtigung lässt " "gewählten Zeitkontext. Die Terminauswahl liefert eine typisierte Kalenderreferenz; mit Schreibberechtigung lässt "
"sich der vollständige Termineditor für dieses Datum öffnen. Calendar prüft Mandant, Berechtigung, private " "sich der vollständige Termineditor für dieses Datum öffnen. Calendar prüft Mandant, Berechtigung, private "
@@ -1169,6 +1221,31 @@ manifest = ModuleManifest(
order=50, order=50,
), ),
), ),
product_surfaces=(
ProductSurfaceContribution(
id="meetings.calendar",
module_id="calendar",
label="i18n:govoplan-core.product_surface.calendar",
description="i18n:govoplan-core.product_surface.calendar_description",
icon="calendar",
entry_path="/agenda",
route_path="/calendar",
surface_ids=("calendar.nav.calendar", "calendar.route.calendar"),
presentations=("task", "reader"),
search_source_ids=("calendar.events",),
help_context_ids=("calendar.page",),
documentation_topic_ids=("calendar.quick-access-and-product-area",),
required_any=("calendar:event:read",),
order=10,
unavailable=ProductAvailabilityExplanation(
reason="authorization",
title="i18n:govoplan-core.product_surface.unavailable",
description="i18n:govoplan-core.product_surface.unavailable_description",
resolution="i18n:govoplan-core.product_surface.unavailable_resolution",
responsible_role="i18n:govoplan-core.access_administrator",
),
),
),
quick_access_tools=( quick_access_tools=(
QuickAccessTool( QuickAccessTool(
id="calendar.agenda", id="calendar.agenda",
+44
View File
@@ -0,0 +1,44 @@
"""Data-only recurrence work; resource boundary, not an arbitrary-code sandbox."""
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from govoplan_core.security.bounded_process import ProcessLimits
from govoplan_core.security.worker_payload import decode_worker_payload, encode_worker_payload
RECURRENCE_LIMITS = ProcessLimits(
wall_seconds=5, cpu_seconds=3, memory_bytes=256 * 1024 * 1024,
input_bytes=4 * 1024 * 1024, output_bytes=4 * 1024 * 1024,
)
def expand_recurrences_worker(payload: bytes) -> bytes:
from govoplan_calendar.backend.ical import _expand_event_occurrences
value = decode_worker_payload(payload, max_bytes=RECURRENCE_LIMITS.input_bytes)
try:
if not isinstance(value, dict) or set(value) != {"events", "start", "end", "limit"}:
raise ValueError("Invalid request")
if not isinstance(value["events"], list) or len(value["events"]) > 2_000:
raise ValueError("Invalid event count")
if type(value["limit"]) is not int or not 1 <= value["limit"] <= 10_000:
raise ValueError("Invalid result limit")
if not isinstance(value["start"], datetime) or not isinstance(value["end"], datetime):
raise ValueError("Invalid range")
remaining = value["limit"]
batches = []
for event in value["events"]:
if not isinstance(event, dict) or set(event) != {
"uid", "start_at", "end_at", "duration_seconds", "all_day", "timezone", "rrule", "rdate", "exdate",
}:
raise ValueError("Invalid event")
batch = _expand_event_occurrences(
SimpleNamespace(**event), value["start"], value["end"], limit=remaining,
)
remaining -= len(batch)
batches.append(batch)
result = {"occurrences": batches}
except (ValueError, TypeError, OverflowError, KeyError, AttributeError):
result = {"error": "invalid_or_excessive_recurrence"}
return encode_worker_payload(result, max_bytes=RECURRENCE_LIMITS.output_bytes)
+168 -65
View File
@@ -11,9 +11,10 @@ import urllib.error
import urllib.request import urllib.request
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Any, Callable, Iterable from typing import Any, Callable, Iterable
from sqlalchemy import func, or_ from sqlalchemy import LargeBinary, Text, case, cast, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.security.outbound_http import ( from govoplan_core.security.outbound_http import (
@@ -58,7 +59,9 @@ from govoplan_calendar.backend.ews import (
) )
from govoplan_calendar.backend.graph import graph_event_payload from govoplan_calendar.backend.graph import graph_event_payload
from govoplan_calendar.backend.ical import ( from govoplan_calendar.backend.ical import (
ICalendarError,
expand_event_occurrences, expand_event_occurrences,
expand_events_occurrences,
normalized_recurrence_id, normalized_recurrence_id,
parse_vevent, parse_vevent,
parse_vevents, parse_vevents,
@@ -102,6 +105,19 @@ CALENDAR_EVENT_RESOURCE = "calendar_event"
SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500 SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
REMOTE_SYNC_MAX_ITEMS = 10_000 REMOTE_SYNC_MAX_ITEMS = 10_000
MAX_OCCURRENCE_RANGE_DAYS = 400 MAX_OCCURRENCE_RANGE_DAYS = 400
MAX_OCCURRENCE_CANDIDATES = 2_000
MAX_OCCURRENCE_RESULTS = 10_000
MAX_OCCURRENCE_PROJECTION_BYTES = 4 * 1024 * 1024
MAX_OCCURRENCE_RESPONSE_BYTES = 4 * 1024 * 1024
OCCURRENCE_AVAILABILITY_FIELDS = (
"id", "calendar_id", "uid", "recurrence_id", "status", "transparency",
"start_at", "end_at", "duration_seconds", "all_day", "timezone", "rrule", "rdate", "exdate",
)
OCCURRENCE_DETAIL_FIELDS = OCCURRENCE_AVAILABILITY_FIELDS + (
"tenant_id", "sequence", "summary", "description", "location", "classification",
"organizer", "attendees", "categories", "reminders", "attachments", "related_to",
"source_kind", "source_href", "etag", "icalendar", "created_at", "updated_at", "metadata_",
)
DEFAULT_CALENDAR_VIEW_PREFERENCES: dict[str, bool | int] = { DEFAULT_CALENDAR_VIEW_PREFERENCES: dict[str, bool | int] = {
"dim_weekends": True, "dim_weekends": True,
"dim_off_hours": True, "dim_off_hours": True,
@@ -3850,6 +3866,60 @@ def list_events(
return query.all() return query.all()
def _bounded_occurrence_candidates(query, fields: tuple[str, ...], remaining: list[int]):
"""Project only needed columns, hiding oversized values inside the SQL read.
A separate size-check query followed by ORM loading would race an update and
would still decode all JSON/Text in the driver before checking the budget.
CASE and the measured values here share one statement snapshot; a cumulative
SQL budget bounds all payload columns before the driver can decode them.
"""
dialect = query.session.get_bind().dialect.name
if dialect not in {"sqlite", "postgresql"}:
raise CalendarError("Bounded calendar projections require SQLite or PostgreSQL.")
columns = [getattr(CalendarEvent, name) for name in fields]
sizes = []
for column in columns:
text_value = cast(column, Text)
byte_length = func.length(cast(text_value, LargeBinary)) if dialect == "sqlite" else func.octet_length(text_value)
sizes.append(func.coalesce(byte_length, 0))
size = sum(sizes)
sizes_query = query.with_entities(
CalendarEvent.id.label("candidate_id"), size.label("projection_bytes"),
).subquery()
budgeted = select(
sizes_query,
func.sum(sizes_query.c.projection_bytes).over(order_by=sizes_query.c.candidate_id).label("cumulative_bytes"),
).subquery()
# The window is evaluated on small identity/size rows before selecting any
# payload. Even a buffering driver/sort can receive at most the remaining
# aggregate bytes, rather than thousands of individually allowed big events.
projected = query.session.query(
budgeted.c.projection_bytes,
*(case((budgeted.c.cumulative_bytes <= remaining[0], column), else_=None).label(name)
for name, column in zip(fields, columns, strict=True)),
).join(CalendarEvent, CalendarEvent.id == budgeted.c.candidate_id).order_by(budgeted.c.candidate_id)
rows = []
iterator = iter(projected.yield_per(1))
try:
for row in iterator:
remaining[0] -= row.projection_bytes
if remaining[0] < 0:
raise CalendarError("Calendar candidate projection exceeds its byte limit; request fewer calendars or a smaller range.")
rows.append(SimpleNamespace(**{name: getattr(row, name) for name in fields}))
finally:
close = getattr(iterator, "close", None)
if close is not None:
close()
return rows
def _occurrence_json_default(value: object) -> str:
if isinstance(value, datetime):
return value.isoformat()
raise CalendarError("Calendar occurrence data cannot be encoded safely.")
def list_event_occurrences( def list_event_occurrences(
session: Session, session: Session,
*, *,
@@ -3859,6 +3929,25 @@ def list_event_occurrences(
calendar_id: str | None = None, calendar_id: str | None = None,
visible_calendar_ids: Iterable[str] | None = None, visible_calendar_ids: Iterable[str] | None = None,
limit: int | None = None, limit: int | None = None,
_availability_only: bool = False,
) -> list[dict[str, Any]]:
from govoplan_core.security.bounded_process import ProcessBudgetError, bounded_operation_admission
try:
with bounded_operation_admission() as admission:
return _list_event_occurrences(
session, tenant_id=tenant_id, start_at=start_at, end_at=end_at,
calendar_id=calendar_id, visible_calendar_ids=visible_calendar_ids,
limit=limit, admission=admission, availability_only=_availability_only,
)
except (ProcessBudgetError, ICalendarError) as exc:
raise CalendarError("Calendar expansion could not complete within its limits; request a smaller range or fewer calendars, or retry later.") from exc
def _list_event_occurrences(
session: Session, *, tenant_id: str, start_at: datetime, end_at: datetime,
calendar_id: str | None, visible_calendar_ids: Iterable[str] | None,
limit: int | None, admission: Any, availability_only: bool = False,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Return range-bounded events with recurring series fully reconciled.""" """Return range-bounded events with recurring series fully reconciled."""
@@ -3879,13 +3968,17 @@ def list_event_occurrences(
normalized_calendar_ids = tuple(dict.fromkeys(visible_calendar_ids)) normalized_calendar_ids = tuple(dict.fromkeys(visible_calendar_ids))
if not normalized_calendar_ids: if not normalized_calendar_ids:
return [] return []
if len(normalized_calendar_ids) > MAX_OCCURRENCE_CANDIDATES:
raise CalendarError("Too many calendars for complete expansion; request fewer calendars.")
base_query = base_query.filter( base_query = base_query.filter(
CalendarEvent.calendar_id.in_(normalized_calendar_ids) CalendarEvent.calendar_id.in_(normalized_calendar_ids)
) )
if calendar_id: if calendar_id:
base_query = base_query.filter(CalendarEvent.calendar_id == calendar_id) base_query = base_query.filter(CalendarEvent.calendar_id == calendar_id)
recurring_masters = ( fields = OCCURRENCE_AVAILABILITY_FIELDS if availability_only else OCCURRENCE_DETAIL_FIELDS
projection_budget = [MAX_OCCURRENCE_PROJECTION_BYTES]
recurring_masters = _bounded_occurrence_candidates(
base_query.filter( base_query.filter(
CalendarEvent.recurrence_id.is_(None), CalendarEvent.recurrence_id.is_(None),
or_( or_(
@@ -3894,9 +3987,11 @@ def list_event_occurrences(
), ),
) )
.order_by(CalendarEvent.start_at.asc(), CalendarEvent.id.asc()) .order_by(CalendarEvent.start_at.asc(), CalendarEvent.id.asc())
.all() .limit(MAX_OCCURRENCE_CANDIDATES + 1), fields, projection_budget,
) )
direct_events = ( if len(recurring_masters) > MAX_OCCURRENCE_CANDIDATES:
raise CalendarError("Too many recurring series for complete expansion; request fewer calendars.")
direct_events = _bounded_occurrence_candidates(
base_query.filter( base_query.filter(
or_( or_(
CalendarEvent.end_at.is_(None), CalendarEvent.end_at.is_(None),
@@ -3905,8 +4000,10 @@ def list_event_occurrences(
CalendarEvent.start_at <= range_end, CalendarEvent.start_at <= range_end,
) )
.order_by(CalendarEvent.start_at.asc(), CalendarEvent.id.asc()) .order_by(CalendarEvent.start_at.asc(), CalendarEvent.id.asc())
.all() .limit(MAX_OCCURRENCE_CANDIDATES + 1), fields, projection_budget,
) )
if len(direct_events) > MAX_OCCURRENCE_CANDIDATES:
raise CalendarError("Too many events for complete expansion; request a smaller range or fewer calendars.")
series_keys = { series_keys = {
(event.calendar_id, event.uid) for event in recurring_masters (event.calendar_id, event.uid) for event in recurring_masters
@@ -3914,12 +4011,16 @@ def list_event_occurrences(
overrides: list[CalendarEvent] = [] overrides: list[CalendarEvent] = []
if series_keys: if series_keys:
series_uids = {uid for _calendar_id, uid in series_keys} series_uids = {uid for _calendar_id, uid in series_keys}
overrides = [ override_candidates = _bounded_occurrence_candidates(
event base_query.filter(
for event in base_query.filter(
CalendarEvent.recurrence_id.is_not(None), CalendarEvent.recurrence_id.is_not(None),
CalendarEvent.uid.in_(series_uids), CalendarEvent.uid.in_(series_uids),
).all() ).limit(MAX_OCCURRENCE_CANDIDATES + 1), fields, projection_budget,
)
if len(override_candidates) > MAX_OCCURRENCE_CANDIDATES:
raise CalendarError("Too many overrides for complete expansion; request fewer calendars.")
overrides = [
event for event in override_candidates
if (event.calendar_id, event.uid) in series_keys if (event.calendar_id, event.uid) in series_keys
] ]
@@ -3934,19 +4035,31 @@ def list_event_occurrences(
)[recurrence_key] = override )[recurrence_key] = override
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
response_bytes = 2 # JSON list brackets; charge the complete unsliced result.
def append_occurrence(event, **kwargs) -> None:
nonlocal response_bytes
if len(results) >= MAX_OCCURRENCE_RESULTS:
raise CalendarError("Too many occurrences for a complete result; request a smaller range or fewer calendars.")
payload = expanded_event_response(event, availability_only=availability_only, **kwargs)
response_bytes += len(json.dumps(payload, default=_occurrence_json_default, ensure_ascii=True).encode("utf-8")) + 2
if response_bytes > MAX_OCCURRENCE_RESPONSE_BYTES:
raise CalendarError("Expanded calendar response exceeds its byte limit; request a smaller range or fewer calendars.")
results.append(payload)
consumed_event_ids: set[str] = set() consumed_event_ids: set[str] = set()
recurring_master_ids = {event.id for event in recurring_masters} recurring_master_ids = {event.id for event in recurring_masters}
recurring_master_by_series = { recurring_master_by_series = {
(event.calendar_id, event.uid): event for event in recurring_masters (event.calendar_id, event.uid): event for event in recurring_masters
} }
for master in recurring_masters: expanded_batches = expand_events_occurrences(
recurring_masters, range_start, range_end,
limit=MAX_OCCURRENCE_RESULTS, admission=admission,
)
for master, expanded in zip(recurring_masters, expanded_batches, strict=True):
series_key = (master.calendar_id, master.uid) series_key = (master.calendar_id, master.uid)
series_overrides = overrides_by_series.get(series_key, {}) series_overrides = overrides_by_series.get(series_key, {})
for occurrence in expand_event_occurrences( for occurrence in expanded:
master,
range_start,
range_end,
):
occurrence_recurrence_id = str( occurrence_recurrence_id = str(
occurrence.get("recurrence_id") or "" occurrence.get("recurrence_id") or ""
) )
@@ -3967,25 +4080,18 @@ def list_event_occurrences(
range_start=range_start, range_start=range_start,
range_end=range_end, range_end=range_end,
): ):
results.append( append_occurrence(
expanded_event_response( override,
override, series_event_id=master.id,
series_event_id=master.id, recurrence_id=(override.recurrence_id or occurrence_recurrence_id),
recurrence_id=( is_override=True,
override.recurrence_id
or occurrence_recurrence_id
),
is_override=True,
)
) )
continue continue
results.append( append_occurrence(
expanded_event_response( master,
master, series_event_id=master.id,
series_event_id=master.id, recurrence_id=occurrence_recurrence_id,
recurrence_id=occurrence_recurrence_id, occurrence=occurrence,
occurrence=occurrence,
)
) )
for event in direct_events: for event in direct_events:
@@ -4002,20 +4108,18 @@ def list_event_occurrences(
is_override = True is_override = True
if event.status.upper() == "CANCELLED": if event.status.upper() == "CANCELLED":
continue continue
results.append( append_occurrence(
expanded_event_response( event,
event, series_event_id=series_event_id,
series_event_id=series_event_id, recurrence_id=normalized_recurrence_id(event.recurrence_id),
recurrence_id=normalized_recurrence_id(event.recurrence_id), is_override=is_override,
is_override=is_override,
)
) )
sorted_results = sorted( sorted_results = sorted(
results, results,
key=lambda item: ( key=lambda item: (
item["start_at"], item["start_at"],
item["calendar_id"], item["calendar_id"],
item["summary"], item.get("summary", ""),
item["instance_id"], item["instance_id"],
), ),
) )
@@ -4044,8 +4148,16 @@ def expanded_event_response(
recurrence_id: str | None, recurrence_id: str | None,
occurrence: dict[str, Any] | None = None, occurrence: dict[str, Any] | None = None,
is_override: bool = False, is_override: bool = False,
availability_only: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
payload = event_response(event) payload = (
{
"id": event.id, "calendar_id": event.calendar_id, "uid": event.uid,
"start_at": response_datetime(event.start_at), "end_at": response_datetime(event.end_at),
"all_day": event.all_day, "status": event.status, "transparency": event.transparency,
}
if availability_only else event_response(event)
)
if occurrence is not None: if occurrence is not None:
payload["start_at"] = response_datetime(occurrence["start_at"]) payload["start_at"] = response_datetime(occurrence["start_at"])
payload["end_at"] = response_datetime(occurrence.get("end_at")) payload["end_at"] = response_datetime(occurrence.get("end_at"))
@@ -4074,25 +4186,13 @@ def list_freebusy(
range_end = normalize_datetime(end_at) range_end = normalize_datetime(end_at)
if range_end < range_start: if range_end < range_start:
raise CalendarError("Free/busy end must be after start") raise CalendarError("Free/busy end must be after start")
events: list[dict[str, Any]] = [] # One admission and one worker budget for the entire requested collection,
if calendar_ids: # not one independently reset expansion allowance per calendar.
for calendar_id in dict.fromkeys(calendar_ids): events = list_event_occurrences(
events.extend( session, tenant_id=tenant_id, start_at=range_start, end_at=range_end,
list_event_occurrences( visible_calendar_ids=calendar_ids or None,
session, _availability_only=True,
tenant_id=tenant_id, )
calendar_id=calendar_id,
start_at=range_start,
end_at=range_end,
)
)
else:
events = list_event_occurrences(
session,
tenant_id=tenant_id,
start_at=range_start,
end_at=range_end,
)
busy = [ busy = [
{ {
"calendar_id": event["calendar_id"], "calendar_id": event["calendar_id"],
@@ -4600,11 +4700,14 @@ def recurrence_occurrence(
if recurrence_start is None: if recurrence_start is None:
raise CalendarError("Invalid recurrence_id") raise CalendarError("Invalid recurrence_id")
recurrence_key = normalized_recurrence_id(recurrence_id) recurrence_key = normalized_recurrence_id(recurrence_id)
candidates = expand_event_occurrences( try:
master, candidates = expand_event_occurrences(
recurrence_start - timedelta(seconds=1), master,
recurrence_start + timedelta(seconds=1), recurrence_start - timedelta(seconds=1),
) recurrence_start + timedelta(seconds=1),
)
except ICalendarError as exc:
raise CalendarError("The recurrence could not be verified within its limits; the occurrence was not changed.") from exc
occurrence = next( occurrence = next(
( (
item item
+30 -1
View File
@@ -3,11 +3,40 @@ from __future__ import annotations
import unittest import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch
from govoplan_calendar.backend.ical import expand_event_occurrences, event_to_ics, parse_vevent from govoplan_calendar.backend.ical import ICalendarError, _expand_event_occurrences, expand_event_occurrences, event_to_ics, parse_vevent
class ICalendarParsingTests(unittest.TestCase): class ICalendarParsingTests(unittest.TestCase):
def test_recurrence_limit_rejects_without_materializing_between(self) -> None:
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
event = SimpleNamespace(
uid="bounded@example.test", start_at=start, end_at=None, duration_seconds=None, all_day=False,
rrule={"FREQ": "SECONDLY", "COUNT": "121"}, rdate=[], exdate=[],
)
with patch("dateutil.rrule.rruleset.between", side_effect=AssertionError("Unbounded allocation")):
with self.assertRaisesRegex(ICalendarError, "result limit"):
_expand_event_occurrences(
event, start, datetime(2026, 7, 1, 0, 2, tzinfo=timezone.utc), limit=1,
)
def test_invalid_worker_ack_is_rejected(self) -> None:
from govoplan_core.security.worker_payload import encode_worker_payload
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
event = SimpleNamespace(
uid="expected@example.test", start_at=start, end_at=None, all_day=False,
rrule={"FREQ": "DAILY", "COUNT": "1"}, rdate=[], exdate=[],
)
wrong = encode_worker_payload({"occurrences": [[{
"uid": "different@example.test", "recurrence_id": "20260701T000000Z",
"start_at": start, "end_at": None, "all_day": False,
}]]})
with patch("govoplan_core.security.bounded_process.run_bounded_operation", return_value=wrong):
with self.assertRaisesRegex(ICalendarError, "invalid result"):
expand_event_occurrences(event, start, start)
def test_parse_vevent_preserves_unknown_properties_and_params(self) -> None: def test_parse_vevent_preserves_unknown_properties_and_params(self) -> None:
payload = """BEGIN:VCALENDAR payload = """BEGIN:VCALENDAR
VERSION:2.0 VERSION:2.0
+160 -3
View File
@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from sqlalchemy import create_engine from sqlalchemy import create_engine, create_mock_engine, event as sqlalchemy_event
from sqlalchemy.orm import sessionmaker from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import Query, Session, sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401 from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_calendar.backend.db.models import CalendarEvent from govoplan_calendar.backend.db.models import CalendarEvent
@@ -16,6 +18,8 @@ from govoplan_calendar.backend.schemas import (
) )
from govoplan_calendar.backend.service import ( from govoplan_calendar.backend.service import (
CalendarError, CalendarError,
_bounded_occurrence_candidates,
OCCURRENCE_DETAIL_FIELDS,
create_calendar, create_calendar,
create_event, create_event,
delete_event, delete_event,
@@ -73,6 +77,159 @@ class CalendarRecurrenceAndPreferenceTests(unittest.TestCase):
), ),
) )
def test_freebusy_returns_all_1001_hourly_occurrences(self) -> None:
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
create_event(
self.session, tenant_id="tenant-1", user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id, uid="dense@example.test", summary="Dense series",
start_at=start, end_at=start + timedelta(minutes=1),
rrule={"FREQ": "HOURLY", "COUNT": "1001"},
),
)
self.session.commit()
busy = list_freebusy(
self.session, tenant_id="tenant-1", calendar_ids=[self.calendar.id],
start_at=start, end_at=start + timedelta(days=43),
)
self.assertEqual(1001, len(busy))
self.assertEqual(start + timedelta(hours=1000), busy[-1]["start_at"])
def test_freebusy_over_budget_fails_without_partial_busy_slots(self) -> None:
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
create_event(
self.session, tenant_id="tenant-1", user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id, uid="excessive@example.test", summary="Excessive series",
start_at=start, end_at=start + timedelta(seconds=1),
rrule={"FREQ": "MINUTELY", "COUNT": "10001"},
),
)
self.session.commit()
with self.assertRaisesRegex(CalendarError, "could not complete"):
list_freebusy(
self.session, tenant_id="tenant-1", calendar_ids=[self.calendar.id],
start_at=start, end_at=start + timedelta(days=8),
)
self.assertEqual(1, self.session.query(CalendarEvent).count())
def test_full_expansion_byte_cap_and_lightweight_complete_freebusy(self) -> None:
from govoplan_calendar.backend import service
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
create_event(
self.session, tenant_id="tenant-1", user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id, uid="heavy@example.test", summary="Heavy series",
description="x" * 65536, start_at=start, end_at=start + timedelta(minutes=1),
rrule={"FREQ": "HOURLY", "COUNT": "1001"},
),
)
self.session.commit()
calendar_id = self.calendar.id
self.session.expunge_all()
with patch.object(service, "expanded_event_response", wraps=service.expanded_event_response) as expand:
with self.assertRaisesRegex(CalendarError, "response exceeds its byte limit"):
list_event_occurrences(self.session, tenant_id="tenant-1", start_at=start, end_at=start + timedelta(days=43), limit=1)
self.assertLess(expand.call_count, 70)
statements = []
loaded = []
def capture(conn, cursor, statement, parameters, context, executemany):
statements.append(statement)
def capture_loaded(session, instance):
if isinstance(instance, CalendarEvent):
loaded.append(instance)
sqlalchemy_event.listen(self.engine, "before_cursor_execute", capture)
sqlalchemy_event.listen(self.session, "loaded_as_persistent", capture_loaded)
try:
with patch.object(service, "event_response", side_effect=AssertionError("Full event data is unnecessary for availability")):
busy = list_freebusy(self.session, tenant_id="tenant-1", calendar_ids=[calendar_id], start_at=start, end_at=start + timedelta(days=43))
self.assertEqual(1001, len(busy))
self.assertEqual([], loaded)
for field in ("description", "raw_ics", "icalendar", "metadata", "attendees", "attachments"):
self.assertNotIn(f"calendar_events.{field}", "\n".join(statements))
finally:
sqlalchemy_event.remove(self.engine, "before_cursor_execute", capture)
sqlalchemy_event.remove(self.session, "loaded_as_persistent", capture_loaded)
def test_aggregate_sql_projection_hides_over_budget_values_before_driver_decoding(self) -> None:
from govoplan_calendar.backend import service
first = self.recurring_master()
second = create_event(
self.session, tenant_id="tenant-1", user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id, uid="second@example.test", summary="Second",
start_at=first.start_at, end_at=first.end_at, rrule={"FREQ": "DAILY", "COUNT": "2"},
),
)
first.description = second.description = "x" * 1500
self.session.commit()
self.session.expunge_all()
captured = []
def capture(conn, cursor, statement, parameters, context, executemany):
captured.append((statement, parameters))
sqlalchemy_event.listen(self.engine, "before_cursor_execute", capture)
try:
with patch.object(service, "MAX_OCCURRENCE_PROJECTION_BYTES", 3000), patch.object(service, "expand_events_occurrences") as worker:
with self.assertRaisesRegex(CalendarError, "candidate projection exceeds its byte limit"):
list_event_occurrences(
self.session, tenant_id="tenant-1", start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
)
worker.assert_not_called()
finally:
sqlalchemy_event.remove(self.engine, "before_cursor_execute", capture)
self.assertEqual(1, len(captured))
statement, parameters = captured[0]
self.assertIn("sum(", statement)
self.assertIn("OVER (ORDER BY", statement)
with self.engine.connect() as connection:
raw = list(connection.exec_driver_sql(statement, parameters))
self.assertEqual(2, len(raw))
self.assertIsNotNone(raw[0][1])
self.assertTrue(all(value is None for value in raw[1][1:]))
def test_sql_projection_compiles_postgresql_without_binary_json_casts(self) -> None:
statements = []
session = Session(bind=create_mock_engine("postgresql://", lambda *args, **kwargs: None))
def inspect_query(query):
statements.append(str(query.statement.compile(dialect=postgresql.dialect())))
return iter(())
with patch.object(Query, "__iter__", inspect_query):
self.assertEqual([], _bounded_occurrence_candidates(
session.query(CalendarEvent).filter(CalendarEvent.tenant_id == "tenant-1").limit(2001),
OCCURRENCE_DETAIL_FIELDS, [4096],
))
self.assertIn("octet_length(CAST(calendar_events.icalendar AS TEXT))", statements[0])
self.assertIn("CASE WHEN", statements[0])
self.assertIn("OVER (ORDER BY", statements[0])
self.assertNotIn("BYTEA", statements[0])
def test_candidate_budget_is_not_bypassed_by_response_limit(self) -> None:
self.recurring_master()
self.session.commit()
with patch("govoplan_calendar.backend.service.MAX_OCCURRENCE_CANDIDATES", 0):
with self.assertRaisesRegex(CalendarError, "Too many recurring series"):
list_event_occurrences(
self.session, tenant_id="tenant-1", limit=1,
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
)
def test_admission_rejection_precedes_database_projection(self) -> None:
from govoplan_core.security.bounded_process import ProcessBudgetError
with patch("govoplan_core.security.bounded_process.bounded_operation_admission", side_effect=ProcessBudgetError("busy")):
with patch.object(self.session, "query") as query:
with self.assertRaisesRegex(CalendarError, "could not complete"):
list_event_occurrences(
self.session, tenant_id="tenant-1",
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
)
query.assert_not_called()
def test_occurrence_override_and_cancellation_reconcile_list_and_freebusy( def test_occurrence_override_and_cancellation_reconcile_list_and_freebusy(
self, self,
) -> None: ) -> None:
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/calendar-webui", "name": "@govoplan/calendar-webui",
"version": "0.1.22", "version": "0.1.24",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -18,7 +18,7 @@
"test:calendar-page": "tsc -p tsconfig.calendar-page-tests.json && node --experimental-strip-types tests/calendar-view-model.test.ts && node tests/calendar-page-structure.test.mjs" "test:calendar-page": "tsc -p tsconfig.calendar-page-tests.json && node --experimental-strip-types tests/calendar-view-model.test.ts && node tests/calendar-page-structure.test.mjs"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.44",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+3 -2
View File
@@ -6,6 +6,7 @@ import type {
QuickAccessToolsUiCapability, QuickAccessToolsUiCapability,
SettingsSectionsUiCapability SettingsSectionsUiCapability
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { generatedTranslations as productSurfaceTranslations } from "@govoplan/core-webui/outcome-product-surface-translations";
import "./styles/calendar.css"; import "./styles/calendar.css";
import { generatedTranslations } from "./i18n/generatedTranslations"; import { generatedTranslations } from "./i18n/generatedTranslations";
import CalendarPicker from "./features/calendar/CalendarPicker"; import CalendarPicker from "./features/calendar/CalendarPicker";
@@ -19,8 +20,8 @@ const CalendarSettingsPanel = lazy(
const eventRead = ["calendar:event:read"]; const eventRead = ["calendar:event:read"];
const translations = { const translations = {
en: generatedTranslations.en, en: { ...generatedTranslations.en, ...productSurfaceTranslations.en },
de: generatedTranslations.de de: { ...generatedTranslations.de, ...productSurfaceTranslations.de }
}; };
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker }; const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };