Files
service_dav/lib/Backends/CalDavBackend.php
T
2026-07-27 00:50:43 -04:00

288 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXM\ServiceDav\Backends;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXF\Chrono\Entity\JournalObject;
use KTXF\Chrono\Entity\TaskObject;
use KTXM\ChronoManager\Conversion\IcalDecoder;
use KTXM\ChronoManager\Conversion\IcalEncoder;
use KTXM\ChronoManager\Manager as ChronoManager;
use KTXM\ServiceDav\DavProperties;
use KTXM\ServiceDav\DavUriMap;
use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifiers;
use Sabre\CalDAV\Backend\AbstractBackend;
use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Component\VJournal;
use Sabre\VObject\Component\VTodo;
use Sabre\VObject\Reader;
/**
* CalDAV backend bridging sabre/dav to the KTXM ChronoManager.
*
* Calendar <=> 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 IdentityContextInterface $identityContext,
private readonly TenantContextInterface $tenantContext,
private readonly DavUriMap $uriMap,
private readonly IcalEncoder $encoder,
private readonly IcalDecoder $decoder,
) {}
public function getCalendarsForUser($principalUri): array
{
$result = [];
$allCollections = $this->manager->collectionList($this->tenantContext->identifier(), $this->identityContext->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->tenantContext->identifier();
$userId = $this->identityContext->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->tenantContext->identifier();
$userId = $this->identityContext->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->tenantContext->identifier();
$userId = $this->identityContext->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->tenantContext->identifier();
$userId = $this->identityContext->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->tenantContext->identifier();
$userId = $this->identityContext->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 [];
}
}