generated from Nodarx/template
refactor: implement ical converters
Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
+29
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user