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
+29
View File
@@ -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
+2 -1
View File
@@ -5,7 +5,8 @@
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"require": { "require": {
"php": ">=8.1", "php": ">=8.1",
"sabre/dav": "^4.7" "sabre/dav": "^4.7",
"sabre/vobject": "^4.5"
}, },
"config": { "config": {
"vendor-dir": "lib/vendor", "vendor-dir": "lib/vendor",
Generated
+9 -9
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "ad6d240cf2a7ee11decdfcd3995cf6f0", "content-hash": "110de8a6029589a0ec26aeedd523da7a",
"packages": [ "packages": [
{ {
"name": "psr/log", "name": "psr/log",
@@ -330,16 +330,16 @@
}, },
{ {
"name": "sabre/vobject", "name": "sabre/vobject",
"version": "4.5.8", "version": "4.6.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sabre-io/vobject.git", "url": "https://github.com/sabre-io/vobject.git",
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1" "reference": "63613f6c53a0a2bddfe22caba0d052e6c59f7d0e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/d554eb24d64232922e1eab5896cc2f84b3b9ffb1", "url": "https://api.github.com/repos/sabre-io/vobject/zipball/63613f6c53a0a2bddfe22caba0d052e6c59f7d0e",
"reference": "d554eb24d64232922e1eab5896cc2f84b3b9ffb1", "reference": "63613f6c53a0a2bddfe22caba0d052e6c59f7d0e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -430,7 +430,7 @@
"issues": "https://github.com/sabre-io/vobject/issues", "issues": "https://github.com/sabre-io/vobject/issues",
"source": "https://github.com/fruux/sabre-vobject" "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", "name": "sabre/xml",
@@ -505,12 +505,12 @@
"packages-dev": [], "packages-dev": [],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": [], "stability-flags": {},
"prefer-stable": false, "prefer-stable": false,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": ">=8.1" "php": ">=8.1"
}, },
"platform-dev": [], "platform-dev": {},
"plugin-api-version": "2.3.0" "plugin-api-version": "2.6.0"
} }
+49 -87
View File
@@ -6,6 +6,10 @@ namespace KTXM\ServiceDav\Backends;
use KTXC\SessionIdentity; use KTXC\SessionIdentity;
use KTXC\SessionTenant; 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\ChronoManager\Manager as ChronoManager;
use KTXM\ServiceDav\DavProperties; use KTXM\ServiceDav\DavProperties;
use KTXM\ServiceDav\DavUriMap; use KTXM\ServiceDav\DavUriMap;
@@ -14,7 +18,9 @@ use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifiers; use KTXF\Resource\Identifier\ResourceIdentifiers;
use Sabre\CalDAV\Backend\AbstractBackend; use Sabre\CalDAV\Backend\AbstractBackend;
use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet; 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; use Sabre\VObject\Reader;
/** /**
@@ -36,6 +42,8 @@ class CalDavBackend extends AbstractBackend
private readonly SessionIdentity $identity, private readonly SessionIdentity $identity,
private readonly SessionTenant $tenant, private readonly SessionTenant $tenant,
private readonly DavUriMap $uriMap, private readonly DavUriMap $uriMap,
private readonly IcalEncoder $encoder,
private readonly IcalDecoder $decoder,
) {} ) {}
public function getCalendarsForUser($principalUri): array public function getCalendarsForUser($principalUri): array
@@ -60,7 +68,7 @@ class CalDavBackend extends AbstractBackend
DavProperties::DISPLAYNAME => $label, DavProperties::DISPLAYNAME => $label,
DavProperties::CALDAV_DESCRIPTION => $props->getDescription() ?? '', DavProperties::CALDAV_DESCRIPTION => $props->getDescription() ?? '',
DavProperties::APPLE_COLOR => $props->getColor() ?? '#3a87ad', 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::CS_CTAG => md5($calId . (string) ($collection->modified()?->getTimestamp() ?? 0)),
DavProperties::SYNC_TOKEN => '1', DavProperties::SYNC_TOKEN => '1',
DavProperties::PRINCIPAL_URI => $principalUri, DavProperties::PRINCIPAL_URI => $principalUri,
@@ -100,7 +108,7 @@ class CalDavBackend extends AbstractBackend
} }
$entityId = (string) $entity->identifier(); $entityId = (string) $entity->identifier();
$objectUri = $this->uriMap->getObjectUri($mapId, $entityId) ?? ($entityId . '.ics'); $objectUri = $this->uriMap->getObjectUri($mapId, $entityId) ?? ($entityId . '.ics');
$data = $this->entityToIcal($entity); [$component, $data] = $this->entityToIcal($entity);
$objects[] = [ $objects[] = [
DavProperties::ID => $entityId, DavProperties::ID => $entityId,
@@ -108,7 +116,7 @@ class CalDavBackend extends AbstractBackend
DavProperties::LAST_MODIFIED => $entity->modified()?->getTimestamp() ?? time(), DavProperties::LAST_MODIFIED => $entity->modified()?->getTimestamp() ?? time(),
DavProperties::ETAG => '"' . md5($data) . '"', DavProperties::ETAG => '"' . md5($data) . '"',
DavProperties::SIZE => strlen($data), DavProperties::SIZE => strlen($data),
DavProperties::COMPONENT => 'VEVENT', DavProperties::COMPONENT => $component,
DavProperties::CALENDAR_DATA => null, // lazy - fetched on demand DavProperties::CALENDAR_DATA => null, // lazy - fetched on demand
]; ];
} }
@@ -135,7 +143,7 @@ class CalDavBackend extends AbstractBackend
return null; return null;
} }
$data = $this->entityToIcal($entity); [$component, $data] = $this->entityToIcal($entity);
return [ return [
DavProperties::ID => $entityId, DavProperties::ID => $entityId,
@@ -143,7 +151,7 @@ class CalDavBackend extends AbstractBackend
DavProperties::LAST_MODIFIED => $entity->modified()?->getTimestamp() ?? time(), DavProperties::LAST_MODIFIED => $entity->modified()?->getTimestamp() ?? time(),
DavProperties::ETAG => '"' . md5($data) . '"', DavProperties::ETAG => '"' . md5($data) . '"',
DavProperties::SIZE => strlen($data), DavProperties::SIZE => strlen($data),
DavProperties::COMPONENT => 'VEVENT', DavProperties::COMPONENT => $component,
DavProperties::CALENDAR_DATA => $data, DavProperties::CALENDAR_DATA => $data,
]; ];
} }
@@ -174,7 +182,9 @@ class CalDavBackend extends AbstractBackend
(string) $entity->identifier(), (string) $entity->identifier(),
); );
return '"' . md5($this->entityToIcal($entity)) . '"'; [, $data] = $this->entityToIcal($entity);
return '"' . md5($data) . '"';
} }
public function updateCalendarObject($calendarId, $objectUri, $calendarData): ?string public function updateCalendarObject($calendarId, $objectUri, $calendarData): ?string
@@ -192,7 +202,9 @@ class CalDavBackend extends AbstractBackend
$target = new EntityIdentifier($providerId, $serviceId, $collectionId, $entityId); $target = new EntityIdentifier($providerId, $serviceId, $collectionId, $entityId);
$entity = $this->manager->entityModify($tenantId, $userId, $target, $props); $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 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 * getProperties() is the typed entity object, encoded with full fidelity.
* with fields matching EventCommonObject property names (label, startsOn, endsOn, ...). * 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() ?? []); $hydrated = $entity->getProperties();
$utc = new \DateTimeZone('UTC'); $hydrated->urid ??= (string) $entity->identifier();
$vcal = new VCalendar(); $hydrated->created ??= $entity->created();
$hydrated->modified ??= $entity->modified();
// Create the VEVENT without sabre's auto-generated UID/DTSTAMP defaults, $component = match (true) {
// otherwise they appear twice (we set our own below), which is invalid. $hydrated instanceof TaskObject => 'VTODO',
/** @var \Sabre\VObject\Component $vevent */ $hydrated instanceof JournalObject => 'VJOURNAL',
$vevent = $vcal->add('VEVENT', [], false); default => 'VEVENT',
$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),
);
if ($created = $entity->created()) { return [$component, $this->encoder->toIcsString([$hydrated])];
$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();
} }
/** /**
* Convert a parsed VCALENDAR to an array for entityCreate/entityUpdate. * Convert the first calendar component of a parsed VCALENDAR to a property
* Field names match EventCommonObject property names used in serialization. * 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; foreach ($vcal->children() as $component) {
if ($comp === null) { if ($component instanceof VEvent || $component instanceof VTodo || $component instanceof VJournal) {
return []; return $this->decoder->fromComponent($component)->jsonSerialize();
}
} }
$props = []; return [];
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;
} }
} }
+3 -1
View File
@@ -11,6 +11,8 @@ use KTXC\Http\Response\StreamedResponse;
use KTXC\Service\SecurityService; use KTXC\Service\SecurityService;
use KTXC\SessionIdentity; use KTXC\SessionIdentity;
use KTXC\SessionTenant; use KTXC\SessionTenant;
use KTXM\ChronoManager\Conversion\IcalDecoder;
use KTXM\ChronoManager\Conversion\IcalEncoder;
use KTXM\ChronoManager\Manager as ChronoManager; use KTXM\ChronoManager\Manager as ChronoManager;
use KTXM\DocumentsManager\Manager as DocumentsManager; use KTXM\DocumentsManager\Manager as DocumentsManager;
use KTXM\PeopleManager\Manager as PeopleManager; use KTXM\PeopleManager\Manager as PeopleManager;
@@ -98,7 +100,7 @@ class DavServer
private function buildServer(): Server private function buildServer(): Server
{ {
$principalBackend = new PrincipalBackend($this->identity, $this->tenant); $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); $cardDavBackend = new CardDavBackend($this->peopleManager, $this->identity, $this->tenant, $this->uriMap);
$server = new Server([ $server = new Server([