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",
"require": {
"php": ">=8.1",
"sabre/dav": "^4.7"
"sabre/dav": "^4.7",
"sabre/vobject": "^4.5"
},
"config": {
"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",
"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"
}
+50 -88
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) {
foreach ($vcal->children() as $component) {
if ($component instanceof VEvent || $component instanceof VTodo || $component instanceof VJournal) {
return $this->decoder->fromComponent($component)->jsonSerialize();
}
}
return [];
}
$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;
}
}
+3 -1
View File
@@ -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([