From 4d8ee0c668401022d7abea16f03b8f1524f84b62 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 17 Jul 2026 19:15:52 -0400 Subject: [PATCH] refactor: implement ical converters Signed-off-by: Sebastian --- .gitignore | 29 +++++++ composer.json | 3 +- composer.lock | 18 ++--- lib/Backends/CalDavBackend.php | 136 ++++++++++++--------------------- lib/DavServer.php | 4 +- 5 files changed, 92 insertions(+), 98 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..051fe15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Frontend development +node_modules/ +*.local +.env.local +.env.*.local +.cache/ +.vite/ +.temp/ +.tmp/ + +# Frontend build +/static/ + +# Backend development +vendor/ +coverage/ +phpunit.xml.cache +.phpunit.result.cache +.php-cs-fixer.cache +.phpstan.cache +.phpactor/ + +# Editors +.DS_Store +.vscode/ +.idea/ + +# Logs +*.log diff --git a/composer.json b/composer.json index 5f55b6b..ed8f86a 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,8 @@ "license": "AGPL-3.0-or-later", "require": { "php": ">=8.1", - "sabre/dav": "^4.7" + "sabre/dav": "^4.7", + "sabre/vobject": "^4.5" }, "config": { "vendor-dir": "lib/vendor", diff --git a/composer.lock b/composer.lock index a65d918..a9089b1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ad6d240cf2a7ee11decdfcd3995cf6f0", + "content-hash": "110de8a6029589a0ec26aeedd523da7a", "packages": [ { "name": "psr/log", @@ -330,16 +330,16 @@ }, { "name": "sabre/vobject", - "version": "4.5.8", + "version": "4.6.1", "source": { "type": "git", "url": "https://github.com/sabre-io/vobject.git", - "reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1" + "reference": "63613f6c53a0a2bddfe22caba0d052e6c59f7d0e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sabre-io/vobject/zipball/d554eb24d64232922e1eab5896cc2f84b3b9ffb1", - "reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1", + "url": "https://api.github.com/repos/sabre-io/vobject/zipball/63613f6c53a0a2bddfe22caba0d052e6c59f7d0e", + "reference": "63613f6c53a0a2bddfe22caba0d052e6c59f7d0e", "shasum": "" }, "require": { @@ -430,7 +430,7 @@ "issues": "https://github.com/sabre-io/vobject/issues", "source": "https://github.com/fruux/sabre-vobject" }, - "time": "2026-01-12T10:45:19+00:00" + "time": "2026-07-07T03:20:17+00:00" }, { "name": "sabre/xml", @@ -505,12 +505,12 @@ "packages-dev": [], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { "php": ">=8.1" }, - "platform-dev": [], - "plugin-api-version": "2.3.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/lib/Backends/CalDavBackend.php b/lib/Backends/CalDavBackend.php index 7ad574b..710add3 100644 --- a/lib/Backends/CalDavBackend.php +++ b/lib/Backends/CalDavBackend.php @@ -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 []; } } diff --git a/lib/DavServer.php b/lib/DavServer.php index ca20d3c..7715969 100644 --- a/lib/DavServer.php +++ b/lib/DavServer.php @@ -11,6 +11,8 @@ use KTXC\Http\Response\StreamedResponse; use KTXC\Service\SecurityService; use KTXC\SessionIdentity; use KTXC\SessionTenant; +use KTXM\ChronoManager\Conversion\IcalDecoder; +use KTXM\ChronoManager\Conversion\IcalEncoder; use KTXM\ChronoManager\Manager as ChronoManager; use KTXM\DocumentsManager\Manager as DocumentsManager; use KTXM\PeopleManager\Manager as PeopleManager; @@ -98,7 +100,7 @@ class DavServer private function buildServer(): Server { $principalBackend = new PrincipalBackend($this->identity, $this->tenant); - $calDavBackend = new CalDavBackend($this->chronoManager, $this->identity, $this->tenant, $this->uriMap); + $calDavBackend = new CalDavBackend($this->chronoManager, $this->identity, $this->tenant, $this->uriMap, new IcalEncoder(), new IcalDecoder()); $cardDavBackend = new CardDavBackend($this->peopleManager, $this->identity, $this->tenant, $this->uriMap); $server = new Server([