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, private readonly IcalEncoder $encoder, private readonly IcalDecoder $decoder, ) {} 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', 'VJOURNAL']), 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'); [$component, $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 => $component, 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; } [$component, $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 => $component, 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(), ); [, $data] = $this->entityToIcal($entity); return '"' . md5($data) . '"'; } 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); [, $data] = $this->entityToIcal($entity); return '"' . md5($data) . '"'; } 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 document via the unified * chrono_manager conversion layer. * * getProperties() is the typed entity object, encoded with full fidelity. * The provider entity identifier is used as the UID fallback so DAV * object UIDs stay stable for entities that were never imported (no * urid). The encoder emits a deterministic DTSTAMP (modified/created, * never "now") so the md5-of-output ETag of an unchanged entity stays * stable across requests. * * @return array{0: string, 1: string} [component type (VEVENT|VTODO|VJOURNAL), iCalendar data] */ private function entityToIcal(object $entity): array { $hydrated = $entity->getProperties(); $hydrated->urid ??= (string) $entity->identifier(); $hydrated->created ??= $entity->created(); $hydrated->modified ??= $entity->modified(); $component = match (true) { $hydrated instanceof TaskObject => 'VTODO', $hydrated instanceof JournalObject => 'VJOURNAL', default => 'VEVENT', }; return [$component, $this->encoder->toIcsString([$hydrated])]; } /** * Convert the first calendar component of a parsed VCALENDAR to a property * array for entityCreate/entityModify via the unified conversion layer. */ private function icalToEntityProps($vcal): array { foreach ($vcal->children() as $component) { if ($component instanceof VEvent || $component instanceof VTodo || $component instanceof VJournal) { return $this->decoder->fromComponent($component)->jsonSerialize(); } } return []; } }