generated from Nodarx/template
3a68d03114
Signed-off-by: Sebastian <krupinski01@gmail.com>
288 lines
12 KiB
PHP
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 [];
|
|
}
|
|
}
|