refactor: implement ical converters

Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
2026-07-17 19:15:52 -04:00
parent f6cc00ee5e
commit 4d8ee0c668
5 changed files with 92 additions and 98 deletions
+49 -87
View File
@@ -6,6 +6,10 @@ namespace KTXM\ServiceDav\Backends;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
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;
@@ -14,7 +18,9 @@ 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\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Component\VJournal;
use Sabre\VObject\Component\VTodo;
use Sabre\VObject\Reader;
/**
@@ -36,6 +42,8 @@ class CalDavBackend extends AbstractBackend
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
@@ -60,7 +68,7 @@ class CalDavBackend extends AbstractBackend
DavProperties::DISPLAYNAME => $label,
DavProperties::CALDAV_DESCRIPTION => $props->getDescription() ?? '',
DavProperties::APPLE_COLOR => $props->getColor() ?? '#3a87ad',
DavProperties::CALDAV_COMPONENTS => new SupportedCalendarComponentSet(['VEVENT', 'VTODO']),
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,
@@ -100,7 +108,7 @@ class CalDavBackend extends AbstractBackend
}
$entityId = (string) $entity->identifier();
$objectUri = $this->uriMap->getObjectUri($mapId, $entityId) ?? ($entityId . '.ics');
$data = $this->entityToIcal($entity);
[$component, $data] = $this->entityToIcal($entity);
$objects[] = [
DavProperties::ID => $entityId,
@@ -108,7 +116,7 @@ class CalDavBackend extends AbstractBackend
DavProperties::LAST_MODIFIED => $entity->modified()?->getTimestamp() ?? time(),
DavProperties::ETAG => '"' . md5($data) . '"',
DavProperties::SIZE => strlen($data),
DavProperties::COMPONENT => 'VEVENT',
DavProperties::COMPONENT => $component,
DavProperties::CALENDAR_DATA => null, // lazy - fetched on demand
];
}
@@ -135,7 +143,7 @@ class CalDavBackend extends AbstractBackend
return null;
}
$data = $this->entityToIcal($entity);
[$component, $data] = $this->entityToIcal($entity);
return [
DavProperties::ID => $entityId,
@@ -143,7 +151,7 @@ class CalDavBackend extends AbstractBackend
DavProperties::LAST_MODIFIED => $entity->modified()?->getTimestamp() ?? time(),
DavProperties::ETAG => '"' . md5($data) . '"',
DavProperties::SIZE => strlen($data),
DavProperties::COMPONENT => 'VEVENT',
DavProperties::COMPONENT => $component,
DavProperties::CALENDAR_DATA => $data,
];
}
@@ -174,7 +182,9 @@ class CalDavBackend extends AbstractBackend
(string) $entity->identifier(),
);
return '"' . md5($this->entityToIcal($entity)) . '"';
[, $data] = $this->entityToIcal($entity);
return '"' . md5($data) . '"';
}
public function updateCalendarObject($calendarId, $objectUri, $calendarData): ?string
@@ -192,7 +202,9 @@ class CalDavBackend extends AbstractBackend
$target = new EntityIdentifier($providerId, $serviceId, $collectionId, $entityId);
$entity = $this->manager->entityModify($tenantId, $userId, $target, $props);
return '"' . md5($this->entityToIcal($entity)) . '"';
[, $data] = $this->entityToIcal($entity);
return '"' . md5($data) . '"';
}
public function deleteCalendarObject($calendarId, $objectUri): void
@@ -230,96 +242,46 @@ class CalDavBackend extends AbstractBackend
}
/**
* Serialize a domain entity to an iCalendar string.
* Serialize a domain entity to an iCalendar document via the unified
* chrono_manager conversion layer.
*
* The raw data from getDataRaw() is a serialized EventObject array
* with fields matching EventCommonObject property names (label, startsOn, endsOn, ...).
* 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): string
private function entityToIcal(object $entity): array
{
$raw = (array) ($entity->getProperties()->getDataRaw() ?? []);
$utc = new \DateTimeZone('UTC');
$vcal = new VCalendar();
$hydrated = $entity->getProperties();
$hydrated->urid ??= (string) $entity->identifier();
$hydrated->created ??= $entity->created();
$hydrated->modified ??= $entity->modified();
// 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),
);
$component = match (true) {
$hydrated instanceof TaskObject => 'VTODO',
$hydrated instanceof JournalObject => 'VJOURNAL',
default => 'VEVENT',
};
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();
return [$component, $this->encoder->toIcsString([$hydrated])];
}
/**
* Convert a parsed VCALENDAR to an array for entityCreate/entityUpdate.
* Field names match EventCommonObject property names used in serialization.
* Convert the first calendar component of a parsed VCALENDAR to a property
* array for entityCreate/entityModify via the unified conversion layer.
*/
private function icalToEntityProps(VCalendar $vcal): array
private function icalToEntityProps($vcal): array
{
$comp = $vcal->VEVENT ?? $vcal->VTODO ?? $vcal->VJOURNAL ?? null;
if ($comp === null) {
return [];
foreach ($vcal->children() as $component) {
if ($component instanceof VEvent || $component instanceof VTodo || $component instanceof VJournal) {
return $this->decoder->fromComponent($component)->jsonSerialize();
}
}
$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;
return [];
}
}