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",
|
"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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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([
|
||||||
|
|||||||
Reference in New Issue
Block a user