ToolUp.Calendar.CalDAV
0.34.1
Prefix Reserved
dotnet add package ToolUp.Calendar.CalDAV --version 0.34.1
NuGet\Install-Package ToolUp.Calendar.CalDAV -Version 0.34.1
<PackageReference Include="ToolUp.Calendar.CalDAV" Version="0.34.1" />
<PackageVersion Include="ToolUp.Calendar.CalDAV" Version="0.34.1" />
<PackageReference Include="ToolUp.Calendar.CalDAV" />
paket add ToolUp.Calendar.CalDAV --version 0.34.1
#r "nuget: ToolUp.Calendar.CalDAV, 0.34.1"
#:package ToolUp.Calendar.CalDAV@0.34.1
#addin nuget:?package=ToolUp.Calendar.CalDAV&version=0.34.1
#tool nuget:?package=ToolUp.Calendar.CalDAV&version=0.34.1
CalDAV calendar bridge
Phase 20a ICalendarBridge companion. Mirrors ToolUp.Scheduling bookings into any
conforming CalDAV server — Fastmail, Nextcloud, iCloud, Apple Calendar Server, Radicale,
Baikal — over RFC 4791 with BCL HttpClient and the parent companion's own iCalendar
payloads. No vendor SDK, no paid dependency, and no OAuth application registration: it is
the bridge a deployment can turn on with a username and an app-password.
What it does
ICalendarBridge member |
CalDAV |
|---|---|
LinkResource |
PROPFIND Depth: 0 on the collection — does it exist, can we see it? |
UnlinkResource |
nothing remote (CalDAV has no subscription to cancel); always Ok |
Push |
PUT <collection>/<bookingId>.ics, or DELETE it when the booking is Cancelled |
Pull |
REPORT calendar-query with a time-range filter |
HandleWebhook |
no-op returning Ok [] — CalDAV servers do not call you |
Because the event's UID is the booking id, Push is idempotent by construction: a
second push of the same booking overwrites the first rather than creating a twin.
Capabilities it declares
BridgeCapabilities.pollingOnly — SupportsWebhooks = false, SupportsIncrementalPull = false, MinimumPollInterval = 15 minutes.
The second flag is the one worth reading twice. A calendar-query time-range filters on
event TIME, not modification time, so Pull's since argument is a lower bound on when
events START, not a change cursor. The sync engine reads that declaration and hands this
bridge no cursor; it reconciles by value instead.
Configuration
TOOLUP_CALDAV_URL # required — base URL, e.g. https://caldav.fastmail.com/
TOOLUP_CALDAV_USERNAME # required — the account the bridge authenticates as
TOOLUP_CALDAV_ENDPOINT # optional — replaces the base URL for every request
The password never appears in configuration. It is read from ISecretStore under
CalDAVSettings.SecretKey (CALDAV_PASSWORD) in the link's own scope, on every call,
so a rotated app-password takes effect without a restart and two tenants linking the same
provider never share a credential. With EnvironmentSecretStore that is
TOOLUP_SECRET_<SCOPE>_CALDAV_PASSWORD.
Most hosted providers require a server-issued app-password rather than the account password; issue one scoped to calendar access.
Composing it
open ToolUp.Calendar.CalDAV
open ToolUp.Calendar.CalDAVHealth
open ToolUp.Scheduling.SchedulingCompose
let settings = CalDAVSettings.fromEnv ()
let bridge = CalDAVCalendarBridge(secretStore, settings)
SchedulingServerApp.create ()
|> SchedulingServerApp.withConfig config
|> SchedulingServerApp.withCalendarBridge bridge
|> SchedulingServerApp.withHealthCheck (CalDAVCalendarBridgeHealth(secretStore, settings, "_platform"))
|> SchedulingServerApp.run
withCalendarBridge is what turns calendar sync on. A deployment that never calls it
registers no link entities, builds no sync engine and schedules no job — it is byte-for-byte
the deployment it was before (GP 13).
Linking a resource
let! link =
sync.LinkResource(
scopeId,
resourceId = "room-101",
kind = "CalDAV",
externalCalendarId = "/dav/calendars/user/alice/rooms/",
userId = "alice",
policy = LocalWins,
actor = EntityPrincipal.ofPrincipal "scheduling-admin")
The external calendar id may be absolute or relative; a relative one resolves against the configured base URL, so moving servers is a one-value change.
policy decides who wins when a booking changed on both sides since the last sync —
ExternalWins, LocalWins, or LatestModifiedWins (which compares the server's
getlastmodified against the instant this deployment last pushed the event, and treats an
event with no modification stamp as older, so a local edit is never silently lost).
Two limits, stated rather than half-implemented
- A poll does not detect external deletions. A pull sees the events that exist; an event that vanished is indistinguishable from one outside the pulled window. Cancel through the deployment, which pushes the deletion.
- An external edit applied locally records
BookingCreatedunless only the times moved, in which case it recordsBookingRescheduled.IBookingSchedulerexposesBook,CancelandRescheduleand no general update verb.
Testing against a real server
The test pack's live case runs only when TOOLUP_CALDAV_URL is set, so a fresh checkout is
green without credentials. Radicale is the cheapest local target:
it speaks enough of RFC 4791 for every verb this bridge uses.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- ToolUp.Platform.Core (>= 0.34.1)
- ToolUp.Platform.Server (>= 0.34.1)
- ToolUp.Scheduling.Core (>= 0.34.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.34.1 | 0 | 10/27/2026 |
| 0.34.0 | 45 | 10/26/2026 |
| 0.33.0 | 50 | 10/25/2026 |
| 0.32.0 | 60 | 10/24/2026 |
| 0.31.0 | 77 | 10/22/2026 |
| 0.30.0 | 89 | 10/20/2026 |
| 0.29.0 | 81 | 10/19/2026 |
| 0.28.0 | 86 | 10/19/2026 |
| 0.27.0 | 86 | 10/16/2026 |
| 0.26.0 | 96 | 10/12/2026 |
| 0.25.1 | 86 | 10/9/2026 |
| 0.24.1 | 91 | 10/6/2026 |
| 0.23.0 | 105 | 9/23/2026 |