Collection (within a provider service) * Calendar object <=> Entity (VEVENT / VTODO) * * Calendar identifiers use the composite key "{providerId}:{serviceId}:{collectionId}". * Object URIs are client-chosen names (e.g. "event-uid.ics") mapped to provider * entity identifiers via DavUriMap. */ class CalDavBackend extends AbstractBackend { private const ID_SEP = ':'; public function __construct( private readonly ChronoManager $manager, private readonly SessionIdentity $identity, private readonly SessionTenant $tenant, private readonly DavUriMap $uriMap, ) {} public function getCalendarsForUser($principalUri): array { $result = []; $allCollections = $this->manager->collectionList($this->tenant->identifier(), $this->identity->identifier()); foreach ($allCollections as $providerId => $providerServices) { foreach ($providerServices as $serviceId => $collections) { foreach ($collections as $collectionId => $collection) { if (!is_object($collection)) { continue; } $props = $collection->getProperties(); $label = $props->getLabel(); $calId = implode(self::ID_SEP, [(string) $providerId, (string) $serviceId, (string) $collectionId]); $result[] = [ DavProperties::ID => $calId, DavProperties::URI => $this->slugify($label), DavProperties::DISPLAYNAME => $label, DavProperties::CALDAV_DESCRIPTION => $props->getDescription() ?? '', DavProperties::APPLE_COLOR => $props->getColor() ?? '#3a87ad', DavProperties::CALDAV_COMPONENTS => new SupportedCalendarComponentSet(['VEVENT', 'VTODO']), DavProperties::CS_CTAG => md5($calId . (string) ($collection->modified()?->getTimestamp() ?? 0)), DavProperties::SYNC_TOKEN => '1', DavProperties::PRINCIPAL_URI => $principalUri, ]; } } } return $result; } public function createCalendar($principalUri, $calendarUri, array $properties): string { throw new \Sabre\DAV\Exception\Forbidden('Calendar creation is not supported via DAV'); } public function deleteCalendar($calendarId): void { throw new \Sabre\DAV\Exception\Forbidden('Calendar deletion is not supported via DAV'); } public function getCalendarObjects($calendarId): array { [$providerId, $serviceId, $collectionId] = $this->parseId($calendarId); $tenantId = $this->tenant->identifier(); $userId = $this->identity->identifier(); $targets = ResourceIdentifiers::fromArray([$calendarId]); $all = $this->manager->entityListBulk($tenantId, $userId, $targets); $entities = $all[(string) $providerId][(string) $serviceId][(string) $collectionId] ?? []; $mapId = $this->mapId($tenantId, $userId, $calendarId); $objects = []; foreach ($entities as $entity) { if (!is_object($entity)) { continue; } $entityId = (string) $entity->identifier(); $objectUri = $this->uriMap->getObjectUri($mapId, $entityId) ?? ($entityId . '.ics'); $data = $this->entityToIcal($entity); $objects[] = [ DavProperties::ID => $entityId, DavProperties::URI => $objectUri, DavProperties::LAST_MODIFIED => $entity->modified()?->getTimestamp() ?? time(), DavProperties::ETAG => '"' . md5($data) . '"', DavProperties::SIZE => strlen($data), DavProperties::COMPONENT => 'VEVENT', DavProperties::CALENDAR_DATA => null, // lazy - fetched on demand ]; } return $objects; } public function getCalendarObject($calendarId, $objectUri): ?array { [$providerId, $serviceId, $collectionId] = $this->parseId($calendarId); $tenantId = $this->tenant->identifier(); $userId = $this->identity->identifier(); $mapId = $this->mapId($tenantId, $userId, $calendarId); // Resolve URI => entityId via the map; fall back to treating stripped URI as entityId $entityId = $this->uriMap->getEntityId($mapId, $objectUri) ?? preg_replace('/\.ics$/i', '', $objectUri); $identifier = new EntityIdentifier($providerId, $serviceId, $collectionId, $entityId); $fetched = $this->manager->entityFetchBulk($tenantId, $userId, $identifier); $entity = reset($fetched) ?: null; if ($entity === null) { return null; } $data = $this->entityToIcal($entity); return [ DavProperties::ID => $entityId, DavProperties::URI => $objectUri, DavProperties::LAST_MODIFIED => $entity->modified()?->getTimestamp() ?? time(), DavProperties::ETAG => '"' . md5($data) . '"', DavProperties::SIZE => strlen($data), DavProperties::COMPONENT => 'VEVENT', DavProperties::CALENDAR_DATA => $data, ]; } public function getMultipleCalendarObjects($calendarId, array $uris): array { return array_values(array_filter(array_map( fn($uri) => $this->getCalendarObject($calendarId, $uri), $uris, ))); } public function createCalendarObject($calendarId, $objectUri, $calendarData): ?string { [$providerId, $serviceId, $collectionId] = $this->parseId($calendarId); $tenantId = $this->tenant->identifier(); $userId = $this->identity->identifier(); $vcal = Reader::read($calendarData); $props = $this->icalToEntityProps($vcal); $target = new CollectionIdentifier($providerId, $serviceId, $collectionId); $entity = $this->manager->entityCreate($tenantId, $userId, $target, $props); // Persist the client-chosen URI => provider entityId mapping $this->uriMap->set( $this->mapId($tenantId, $userId, $calendarId), $objectUri, (string) $entity->identifier(), ); return '"' . md5($this->entityToIcal($entity)) . '"'; } public function updateCalendarObject($calendarId, $objectUri, $calendarData): ?string { [$providerId, $serviceId, $collectionId] = $this->parseId($calendarId); $tenantId = $this->tenant->identifier(); $userId = $this->identity->identifier(); $mapId = $this->mapId($tenantId, $userId, $calendarId); $entityId = $this->uriMap->getEntityId($mapId, $objectUri) ?? preg_replace('/\.ics$/i', '', $objectUri); $vcal = Reader::read($calendarData); $props = $this->icalToEntityProps($vcal); $target = new EntityIdentifier($providerId, $serviceId, $collectionId, $entityId); $entity = $this->manager->entityModify($tenantId, $userId, $target, $props); return '"' . md5($this->entityToIcal($entity)) . '"'; } public function deleteCalendarObject($calendarId, $objectUri): void { [$providerId, $serviceId, $collectionId] = $this->parseId($calendarId); $tenantId = $this->tenant->identifier(); $userId = $this->identity->identifier(); $mapId = $this->mapId($tenantId, $userId, $calendarId); $entityId = $this->uriMap->getEntityId($mapId, $objectUri) ?? preg_replace('/\.ics$/i', '', $objectUri); $target = new EntityIdentifier($providerId, $serviceId, $collectionId, $entityId); $this->manager->entityDelete($tenantId, $userId, $target); $this->uriMap->delete($mapId, $objectUri); } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- private function parseId(string $calendarId): array { return explode(self::ID_SEP, $calendarId, 3); } private function mapId(string $tenantId, string $userId, string $calendarId): string { return 'cal:' . $tenantId . ':' . $userId . ':' . $calendarId; } private function slugify(string $label): string { return strtolower(preg_replace('/[^a-z0-9]+/i', '-', trim($label))); } /** * Serialize a domain entity to an iCalendar string. * * The raw data from getDataRaw() is a serialized EventObject array * with fields matching EventCommonObject property names (label, startsOn, endsOn, ...). */ private function entityToIcal(object $entity): string { $raw = (array) ($entity->getProperties()->getDataRaw() ?? []); $utc = new \DateTimeZone('UTC'); $vcal = new VCalendar(); // Create the VEVENT without sabre's auto-generated UID/DTSTAMP defaults, // otherwise they appear twice (we set our own below), which is invalid. /** @var \Sabre\VObject\Component $vevent */ $vevent = $vcal->add('VEVENT', [], false); $vevent->add('UID', (string) $entity->identifier()); // Always emit date-times in UTC ("...Z"). A DateTime carrying a fixed // numeric offset (e.g. from an RFC3339 string) would otherwise serialize // as TZID=-04:00 with no matching VTIMEZONE, which clients reject. // DTSTAMP participates in the serialized data used for the ETag. Using // the current time here made an unchanged entity produce a different // ETag on every PROPFIND/REPORT and triggered perpetual client syncs. $stamp = $entity->modified() ?? $entity->created(); $vevent->add( 'DTSTAMP', $stamp !== null ? \DateTime::createFromImmutable($stamp)->setTimezone($utc) : (new \DateTime('@0'))->setTimezone($utc), ); if ($created = $entity->created()) { $vevent->add('CREATED', \DateTime::createFromImmutable($created)->setTimezone($utc)); } if ($modified = $entity->modified()) { $vevent->add('LAST-MODIFIED', \DateTime::createFromImmutable($modified)->setTimezone($utc)); } if (!empty($raw['label'])) { $vevent->add('SUMMARY', (string) $raw['label']); } if (!empty($raw['description'])) { $vevent->add('DESCRIPTION', (string) $raw['description']); } if (!empty($raw['startsOn'])) { $vevent->add('DTSTART', (new \DateTime((string) $raw['startsOn']))->setTimezone($utc)); } if (!empty($raw['endsOn'])) { $vevent->add('DTEND', (new \DateTime((string) $raw['endsOn']))->setTimezone($utc)); } // iCal supports one LOCATION property; use first physical location if (!empty($raw['locationsPhysical']) && is_array($raw['locationsPhysical'])) { foreach ($raw['locationsPhysical'] as $loc) { $addr = $loc['address'] ?? $loc['name'] ?? $loc['label'] ?? null; if ($addr) { $vevent->add('LOCATION', (string) $addr); break; } } } return $vcal->serialize(); } /** * Convert a parsed VCALENDAR to an array for entityCreate/entityUpdate. * Field names match EventCommonObject property names used in serialization. */ private function icalToEntityProps(VCalendar $vcal): array { $comp = $vcal->VEVENT ?? $vcal->VTODO ?? $vcal->VJOURNAL ?? null; if ($comp === null) { return []; } $props = []; if ($summary = (string) ($comp->SUMMARY ?? '')) { $props['label'] = $summary; } if ($description = (string) ($comp->DESCRIPTION ?? '')) { $props['description'] = $description; } if ($dtstart = $comp->DTSTART ?? null) { $props['startsOn'] = $dtstart->getDateTime()->format(\DateTime::RFC3339); } if ($dtend = $comp->DTEND ?? $comp->DUE ?? null) { $props['endsOn'] = $dtend->getDateTime()->format(\DateTime::RFC3339); } if ($location = (string) ($comp->LOCATION ?? '')) { $props['locationsPhysical'] = [['label' => $location]]; } return $props; } }