From c6f63a56a90a082151074627bc37115aeca38f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Mummert?= Date: Fri, 27 Mar 2026 22:16:48 +0100 Subject: [PATCH] Release: CalDAV sync bundle hardening and LMW sync --- README.md | 118 ++++++++ composer.json | 29 ++ config/services.yaml | 17 ++ contao/config/config.php | 3 + contao/dca/tl_calendar.php | 76 +++++ contao/dca/tl_calendar_events.php | 80 ++++++ contao/languages/de/tl_calendar.php | 12 + contao/languages/de/tl_calendar_events.php | 13 + src/CalDav/Parser/CalDavXmlParser.php | 138 ++++++++++ src/CalDav/Parser/ICalendarParser.php | 104 +++++++ src/CalDav/Parser/ICalendarSerializer.php | 54 ++++ src/CalDav/Remote/RemoteCalendarLister.php | 244 ++++++++++++++++ src/CalDav/Remote/RemoteCalendarReader.php | 130 +++++++++ src/CalDav/Remote/RemoteCalendarWriter.php | 124 +++++++++ src/CalDav/Transport/CalDavTransport.php | 188 +++++++++++++ .../Transport/CalDavTransportInterface.php | 33 +++ src/CalDav/Transport/TransportResponse.php | 25 ++ src/CalDavSyncBundle.php | 15 + src/Command/CalDavSyncCommand.php | 110 ++++++++ src/Config/CalendarSyncConfig.php | 56 ++++ src/Contao/Dca/CalendarDcaCallbacks.php | 86 ++++++ src/Contao/Manager/Plugin.php | 23 ++ .../CalDavSyncExtension.php | 19 ++ src/Migration/Version20260327000000.php | 66 +++++ src/Migration/Version20260327223000.php | 28 ++ .../ContaoCalendarEventRepository.php | 172 ++++++++++++ src/Repository/ContaoCalendarRepository.php | 35 +++ src/Service/CalendarConfigProvider.php | 84 ++++++ src/Sync/EventMatchResolver.php | 28 ++ src/Sync/LocalToRemoteSynchronizer.php | 243 ++++++++++++++++ src/Sync/RemoteEvent.php | 28 ++ src/Sync/RemoteToLocalSynchronizer.php | 216 +++++++++++++++ src/Sync/SyncFieldExtractor.php | 260 ++++++++++++++++++ src/Sync/SyncHashGenerator.php | 18 ++ src/Sync/SyncResult.php | 23 ++ src/Sync/SyncRunner.php | 95 +++++++ 36 files changed, 2993 insertions(+) create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/services.yaml create mode 100644 contao/config/config.php create mode 100644 contao/dca/tl_calendar.php create mode 100644 contao/dca/tl_calendar_events.php create mode 100644 contao/languages/de/tl_calendar.php create mode 100644 contao/languages/de/tl_calendar_events.php create mode 100644 src/CalDav/Parser/CalDavXmlParser.php create mode 100644 src/CalDav/Parser/ICalendarParser.php create mode 100644 src/CalDav/Parser/ICalendarSerializer.php create mode 100644 src/CalDav/Remote/RemoteCalendarLister.php create mode 100644 src/CalDav/Remote/RemoteCalendarReader.php create mode 100644 src/CalDav/Remote/RemoteCalendarWriter.php create mode 100644 src/CalDav/Transport/CalDavTransport.php create mode 100644 src/CalDav/Transport/CalDavTransportInterface.php create mode 100644 src/CalDav/Transport/TransportResponse.php create mode 100644 src/CalDavSyncBundle.php create mode 100644 src/Command/CalDavSyncCommand.php create mode 100644 src/Config/CalendarSyncConfig.php create mode 100644 src/Contao/Dca/CalendarDcaCallbacks.php create mode 100644 src/Contao/Manager/Plugin.php create mode 100644 src/DependencyInjection/CalDavSyncExtension.php create mode 100644 src/Migration/Version20260327000000.php create mode 100644 src/Migration/Version20260327223000.php create mode 100644 src/Repository/ContaoCalendarEventRepository.php create mode 100644 src/Repository/ContaoCalendarRepository.php create mode 100644 src/Service/CalendarConfigProvider.php create mode 100644 src/Sync/EventMatchResolver.php create mode 100644 src/Sync/LocalToRemoteSynchronizer.php create mode 100644 src/Sync/RemoteEvent.php create mode 100644 src/Sync/RemoteToLocalSynchronizer.php create mode 100644 src/Sync/SyncFieldExtractor.php create mode 100644 src/Sync/SyncHashGenerator.php create mode 100644 src/Sync/SyncResult.php create mode 100644 src/Sync/SyncRunner.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b2cd67 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# CalDAV Sync Bundle (Contao 5.7) + +Produktionsreifes Grundgeruest fuer einen 2-Way-CalDAV-Sync (V1) zwischen `tl_calendar_events` und einem CalDAV-Kalender. + +## V1-Scope + +- Kein RRULE/EXDATE/RECURRENCE-ID +- Kein Backend-Button, nur Command +- Harte Loeschung bei fehlendem Gegenstueck +- Konfliktregel: remote gewinnt +- Contao-only-Felder werden niemals remote geschrieben + +## Verzeichnisstruktur + +```text +caldav-sync-bundle/ + composer.json + config/ + services.yaml + contao/ + dca/ + tl_calendar.php + tl_calendar_events.php + languages/ + de/ + tl_calendar.php + tl_calendar_events.php + src/ + CalDavSyncBundle.php + Command/ + CalDavSyncCommand.php + Config/ + CalendarSyncConfig.php + Contao/Manager/ + Plugin.php + DependencyInjection/ + CalDavSyncExtension.php + Repository/ + ContaoCalendarRepository.php + ContaoCalendarEventRepository.php + Migration/ + Version20260327000000.php + Service/ + CalendarConfigProvider.php + CalDav/ + Transport/ + CalDavTransportInterface.php + CalDavTransport.php + TransportResponse.php + Parser/ + CalDavXmlParser.php + ICalendarParser.php + ICalendarSerializer.php + Remote/ + RemoteCalendarReader.php + RemoteCalendarWriter.php + Sync/ + RemoteEvent.php + EventMatchResolver.php + SyncFieldExtractor.php + SyncHashGenerator.php + SyncResult.php + RemoteToLocalSynchronizer.php + LocalToRemoteSynchronizer.php + SyncRunner.php +``` + +## Klassenuebersicht + +- `CalendarConfigProvider`: Liefert pro `tl_calendar` die Sync-Konfiguration. +- `ContaoCalendarRepository`: Zugriff auf Sync-faehige Kalender. +- `ContaoCalendarEventRepository`: CRUD fuer `tl_calendar_events`. +- `CalDavTransport`: HTTP/WebDAV via Symfony HttpClient (`PROPFIND`, `REPORT`, `GET`, `PUT`, `DELETE`). +- `CalDavXmlParser`: Parsing von WebDAV-Multistatus XML. +- `ICalendarParser`: VEVENT-Parsing via `sabre/vobject`. +- `ICalendarSerializer`: VEVENT-Serialisierung via `sabre/vobject`. +- `RemoteCalendarReader`: Liest Remote-Kalender. +- `RemoteCalendarWriter`: Schreibt/loescht Remote-Events mit ETag-Unterstuetzung. +- `EventMatchResolver`: Matching zuerst ueber `caldavHref`, dann `caldavUid`. +- `SyncFieldExtractor`: Trennt bidirektionale Sync-Felder von lokalen Contao-only-Feldern. +- `SyncHashGenerator`: SHA-256 ueber kanonische Sync-Felder. +- `RemoteToLocalSynchronizer`: Pull inkl. Konfliktregel und Hard-Delete lokal. +- `LocalToRemoteSynchronizer`: Push inkl. Konfliktregel und Hard-Delete remote. +- `SyncRunner`: Orchestrierung je Kalender und Richtung. +- `CalDavSyncCommand`: CLI-Einstiegspunkt. + +## Mapping-Strategie + +Bidirektionale Felder (sync-relevant): + +- `title` +- `start/end` +- `all-day / with-time` (`addTime` Mapping) +- `description` (`details`) +- `location` +- optional `url` + +Contao-only-Felder sind explizit nicht Teil des Sync-Hashes und loesen damit keinen Push aus. + +## Logging-Strategie + +- `info`: erfolgreicher Abschluss pro Kalender inkl. Zaehler +- `warning`: Konflikte (`remote wins`) mit Kontext (`calendarId`, `eventId`, `href`, `uid`) +- Fehler laufen als Exceptions hoch und werden im Command als `FAILURE` signalisiert + +## Command + +```bash +php bin/console contao:caldav:sync --direction=both +php bin/console contao:caldav:sync --calendar=3 --direction=pull +php bin/console contao:caldav:sync --direction=push --dry-run +``` + +Optionen: + +- `--calendar=ID` +- `--direction=pull|push|both` +- `--dry-run` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..db8acdd --- /dev/null +++ b/composer.json @@ -0,0 +1,29 @@ +{ + "name": "mummert/caldav-sync-bundle", + "description": "Two-way CalDAV sync for Contao 5.7 calendars.", + "type": "contao-bundle", + "license": "proprietary", + "require": { + "php": "^8.4", + "contao/calendar-bundle": "^5.7", + "contao/core-bundle": "^5.7", + "contao/manager-plugin": "^2.0", + "sabre/vobject": "^4.5", + "symfony/http-client": "^7.3" + }, + "autoload": { + "psr-4": { + "Mummert\\CalDavSyncBundle\\": "src/" + } + }, + "extra": { + "contao-manager-plugin": "Mummert\\CalDavSyncBundle\\Contao\\Manager\\Plugin" + }, + "prefer-stable": true, + "config": { + "allow-plugins": { + "contao-components/installer": true, + "contao/manager-plugin": true + } + } +} diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 0000000..56699f4 --- /dev/null +++ b/config/services.yaml @@ -0,0 +1,17 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + Mummert\CalDavSyncBundle\: + resource: ../src/ + exclude: + - ../src/Contao/Manager/ + - ../src/DependencyInjection/ + - ../src/CalDavSyncBundle.php + + Mummert\CalDavSyncBundle\Command\CalDavSyncCommand: + tags: ['console.command'] + + Mummert\CalDavSyncBundle\CalDav\Remote\RemoteCalendarLister: + public: true diff --git a/contao/config/config.php b/contao/config/config.php new file mode 100644 index 0000000..174d7fd --- /dev/null +++ b/contao/config/config.php @@ -0,0 +1,3 @@ +addLegend('caldav_legend', 'title_legend', PaletteManipulator::POSITION_AFTER) + ->addField('caldavSyncEnabled', 'caldav_legend', PaletteManipulator::POSITION_APPEND) + ->applyToPalette($paletteName, 'tl_calendar') + ; +} + +$GLOBALS['TL_DCA']['tl_calendar']['subpalettes']['caldavSyncEnabled'] = 'caldavUrl,caldavUsername,caldavPassword,caldavAuthorId,caldavTimezone,caldavCalendarHrefs'; + +$GLOBALS['TL_DCA']['tl_calendar']['fields']['caldavSyncEnabled'] = [ + 'exclude' => true, + 'inputType' => 'checkbox', + 'eval' => ['submitOnChange' => true], + 'sql' => "char(1) NOT NULL default ''", +]; + +$GLOBALS['TL_DCA']['tl_calendar']['fields']['caldavUrl'] = [ + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['mandatory' => true, 'maxlength' => 2048, 'rgxp' => 'url', 'decodeEntities' => true, 'tl_class' => 'w50'], + 'sql' => "varchar(2048) NOT NULL default ''", +]; + +$GLOBALS['TL_DCA']['tl_calendar']['fields']['caldavUsername'] = [ + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => "varchar(255) NOT NULL default ''", +]; + +$GLOBALS['TL_DCA']['tl_calendar']['fields']['caldavPassword'] = [ + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['mandatory' => true, 'maxlength' => 255, 'preserveTags' => true, 'hideInput' => true, 'tl_class' => 'w50'], + 'sql' => "varchar(255) NOT NULL default ''", +]; + +$GLOBALS['TL_DCA']['tl_calendar']['fields']['caldavAuthorId'] = [ + 'exclude' => true, + 'inputType' => 'select', + 'foreignKey' => 'tl_user.name', + 'eval' => ['mandatory' => true, 'chosen' => true, 'includeBlankOption' => true, 'tl_class' => 'w50'], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy', 'table' => 'tl_user'], + 'sql' => "int(10) unsigned NOT NULL default 0", +]; + +$GLOBALS['TL_DCA']['tl_calendar']['fields']['caldavTimezone'] = [ + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 64, 'placeholder' => 'Europe/Berlin', 'tl_class' => 'w50'], + 'sql' => "varchar(64) NOT NULL default ''", +]; + +$GLOBALS['TL_DCA']['tl_calendar']['fields']['caldavCalendarHrefs'] = [ + 'exclude' => true, + 'inputType' => 'checkboxWizard', + 'options_callback' => [CalendarDcaCallbacks::class, 'getAvailableRemoteCalendars'], + 'save_callback' => [[CalendarDcaCallbacks::class, 'normalizeSelectedRemoteCalendars']], + 'eval' => ['multiple' => true, 'tl_class' => 'clr'], + 'sql' => 'blob NULL', +]; + diff --git a/contao/dca/tl_calendar_events.php b/contao/dca/tl_calendar_events.php new file mode 100644 index 0000000..66889c4 --- /dev/null +++ b/contao/dca/tl_calendar_events.php @@ -0,0 +1,80 @@ +addLegend('caldav_legend', 'expert_legend', PaletteManipulator::POSITION_AFTER) + ->addField('caldavCalendarHref', 'caldav_legend', PaletteManipulator::POSITION_APPEND) + ->addField('caldavUid', 'caldav_legend', PaletteManipulator::POSITION_APPEND) + ->addField('caldavHref', 'caldav_legend', PaletteManipulator::POSITION_APPEND) + ->addField('caldavEtag', 'caldav_legend', PaletteManipulator::POSITION_APPEND) + ->addField('caldavSyncHash', 'caldav_legend', PaletteManipulator::POSITION_APPEND) + ->addField('caldavLastSync', 'caldav_legend', PaletteManipulator::POSITION_APPEND) + ->addField('caldavOrigin', 'caldav_legend', PaletteManipulator::POSITION_APPEND) + ->addField('caldavSyncState', 'caldav_legend', PaletteManipulator::POSITION_APPEND) + ->applyToPalette($paletteName, 'tl_calendar_events') + ; +} + +$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavCalendarHref'] = [ + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 2048, 'readonly' => true, 'doNotCopy' => true, 'tl_class' => 'clr'], + 'sql' => "varchar(2048) NOT NULL default ''", +]; + +$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavUid'] = [ + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'readonly' => true, 'doNotCopy' => true, 'tl_class' => 'clr'], + 'sql' => "varchar(255) NOT NULL default ''", +]; + +$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavHref'] = [ + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 2048, 'readonly' => true, 'doNotCopy' => true, 'tl_class' => 'clr'], + 'sql' => "varchar(2048) NOT NULL default ''", +]; + +$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavEtag'] = [ + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'readonly' => true, 'doNotCopy' => true, 'tl_class' => 'clr'], + 'sql' => "varchar(255) NOT NULL default ''", +]; + +$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavSyncHash'] = [ + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 64, 'readonly' => true, 'doNotCopy' => true, 'tl_class' => 'clr'], + 'sql' => "varchar(64) NOT NULL default ''", +]; + +$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavLastSync'] = [ + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['readonly' => true, 'doNotCopy' => true, 'tl_class' => 'w50'], + 'sql' => "int(10) unsigned NOT NULL default 0", +]; + +$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavOrigin'] = [ + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 16, 'readonly' => true, 'doNotCopy' => true, 'tl_class' => 'w50'], + 'sql' => "varchar(16) NOT NULL default ''", +]; + +$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavSyncState'] = [ + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 32, 'readonly' => true, 'doNotCopy' => true, 'tl_class' => 'w50'], + 'sql' => "varchar(32) NOT NULL default ''", +]; diff --git a/contao/languages/de/tl_calendar.php b/contao/languages/de/tl_calendar.php new file mode 100644 index 0000000..73a4286 --- /dev/null +++ b/contao/languages/de/tl_calendar.php @@ -0,0 +1,12 @@ + + */ + public function parseCalendarMultistatus(string $xml): array + { + if ('' === trim($xml)) { + return []; + } + + $document = new DOMDocument(); + $loaded = @$document->loadXML($xml); + + if (false === $loaded) { + throw new RuntimeException('Invalid WebDAV XML response.'); + } + + $xpath = new DOMXPath($document); + $xpath->registerNamespace('d', 'DAV:'); + $xpath->registerNamespace('c', 'urn:ietf:params:xml:ns:caldav'); + + $responseNodes = $xpath->query('/d:multistatus/d:response'); + $items = []; + + if (false === $responseNodes) { + return []; + } + + foreach ($responseNodes as $responseNode) { + $href = trim((string) $xpath->evaluate('string(d:href)', $responseNode)); + $etag = trim((string) $xpath->evaluate('string(d:propstat/d:prop/d:getetag)', $responseNode)); + $calendarData = trim((string) $xpath->evaluate('string(d:propstat/d:prop/c:calendar-data)', $responseNode)); + + if ('' === $href || '' === $calendarData) { + continue; + } + + $items[] = [ + 'href' => $href, + 'etag' => trim($etag, '"'), + 'calendarData' => $calendarData, + ]; + } + + return $items; + } + + public function parseCurrentUserPrincipalHref(string $xml): ?string + { + $xpath = $this->createXPath($xml); + + return $this->stringOrNull($xpath->evaluate('string(/d:multistatus/d:response/d:propstat/d:prop/d:current-user-principal/d:href)')); + } + + public function parseCalendarHomeSetHref(string $xml): ?string + { + $xpath = $this->createXPath($xml); + + return $this->stringOrNull($xpath->evaluate('string(/d:multistatus/d:response/d:propstat/d:prop/c:calendar-home-set/d:href)')); + } + + /** + * @return list + */ + public function parseCalendarCollections(string $xml): array + { + $xpath = $this->createXPath($xml); + $responseNodes = $xpath->query('/d:multistatus/d:response'); + + if (false === $responseNodes) { + return []; + } + + $collections = []; + + foreach ($responseNodes as $responseNode) { + $isCalendar = (bool) $xpath->evaluate('boolean(d:propstat/d:prop/d:resourcetype/c:calendar)', $responseNode); + if (!$isCalendar) { + continue; + } + + $href = trim((string) $xpath->evaluate('string(d:href)', $responseNode)); + if ('' === $href) { + continue; + } + + $displayName = trim((string) $xpath->evaluate('string(d:propstat/d:prop/d:displayname)', $responseNode)); + if ('' === $displayName) { + $displayName = $href; + } + + $collections[] = [ + 'href' => $href, + 'displayName' => $displayName, + ]; + } + + return $collections; + } + + private function createXPath(string $xml): DOMXPath + { + if ('' === trim($xml)) { + throw new RuntimeException('Empty WebDAV XML response.'); + } + + $document = new DOMDocument(); + $loaded = @$document->loadXML($xml); + + if (false === $loaded) { + throw new RuntimeException('Invalid WebDAV XML response.'); + } + + $xpath = new DOMXPath($document); + $xpath->registerNamespace('d', 'DAV:'); + $xpath->registerNamespace('c', 'urn:ietf:params:xml:ns:caldav'); + + return $xpath; + } + + private function stringOrNull(mixed $value): ?string + { + $normalized = trim((string) $value); + + return '' !== $normalized ? $normalized : null; + } +} diff --git a/src/CalDav/Parser/ICalendarParser.php b/src/CalDav/Parser/ICalendarParser.php new file mode 100644 index 0000000..1d9fe39 --- /dev/null +++ b/src/CalDav/Parser/ICalendarParser.php @@ -0,0 +1,104 @@ +getComponents(); + + foreach ($components as $component) { + if ('VEVENT' !== $component->name) { + continue; + } + + $uid = trim((string) ($component->UID ?? '')); + if ('' === $uid) { + continue; + } + + $hasTitle = isset($component->SUMMARY); + $hasDescription = isset($component->DESCRIPTION); + $hasLocation = isset($component->LOCATION); + $hasUrl = isset($component->URL); + + $title = trim((string) ($component->SUMMARY ?? '')); + $description = trim((string) ($component->DESCRIPTION ?? '')); + $location = trim((string) ($component->LOCATION ?? '')); + $url = isset($component->URL) ? trim((string) $component->URL) : null; + $url = '' === $url ? null : $url; + + $dtStart = $component->DTSTART ?? null; + $dtEnd = $component->DTEND ?? null; + $lastModifiedAt = $this->resolveLastModifiedTimestamp($component); + + if (null === $dtStart || null === $dtEnd) { + throw new RuntimeException(sprintf('Invalid VEVENT without DTSTART/DTEND for href %s', $href)); + } + + $startDate = DateTimeImmutable::createFromInterface($dtStart->getDateTime()); + $endDate = DateTimeImmutable::createFromInterface($dtEnd->getDateTime()); + + $allDay = 'DATE' === strtoupper((string) $dtStart['VALUE']); + $timezone = $defaultTimezone; + + if (isset($dtStart['TZID'])) { + $timezone = (string) $dtStart['TZID']; + } + + if ($allDay) { + $tz = new DateTimeZone($timezone); + $startDate = $startDate->setTimezone($tz)->setTime(0, 0); + $endDate = $endDate->setTimezone($tz)->setTime(0, 0); + } + + return new RemoteEvent( + $href, + $uid, + $etag, + $lastModifiedAt, + $hasTitle, + $hasDescription, + $hasLocation, + $hasUrl, + $title, + $description, + $location, + $url, + $startDate->getTimestamp(), + $endDate->getTimestamp(), + $allDay, + $timezone, + ); + } + + return null; + } + + private function resolveLastModifiedTimestamp(object $component): int + { + try { + if (isset($component->{'LAST-MODIFIED'})) { + return DateTimeImmutable::createFromInterface($component->{'LAST-MODIFIED'}->getDateTime())->getTimestamp(); + } + + if (isset($component->DTSTAMP)) { + return DateTimeImmutable::createFromInterface($component->DTSTAMP->getDateTime())->getTimestamp(); + } + } catch (\Throwable) { + // Use fallback below. + } + + return 0; + } +} diff --git a/src/CalDav/Parser/ICalendarSerializer.php b/src/CalDav/Parser/ICalendarSerializer.php new file mode 100644 index 0000000..f8cb0e4 --- /dev/null +++ b/src/CalDav/Parser/ICalendarSerializer.php @@ -0,0 +1,54 @@ +lastModifiedAt > 0 ? $event->lastModifiedAt : time(); + + $vcalendar = new VCalendar(); + $vevent = $vcalendar->add('VEVENT', [ + 'UID' => $event->uid, + 'SUMMARY' => $event->title, + 'DESCRIPTION' => $event->description, + 'LOCATION' => $event->location, + 'LAST-MODIFIED' => gmdate('Ymd\\THis\\Z', $lastModifiedAt), + 'DTSTAMP' => gmdate('Ymd\\THis\\Z'), + ]); + + if (null !== $event->url && '' !== trim($event->url)) { + $vevent->add('URL', $event->url); + } + + if ($event->allDay) { + $start = (new DateTimeImmutable('@'.$event->startAt))->setTimezone(new DateTimeZone($event->timezone)); + $end = (new DateTimeImmutable('@'.$event->endAt))->setTimezone(new DateTimeZone($event->timezone)); + if ($end <= $start) { + $end = $start->modify('+1 day'); + } + + $vevent->add('DTSTART', $start->format('Ymd'), ['VALUE' => 'DATE']); + $vevent->add('DTEND', $end->format('Ymd'), ['VALUE' => 'DATE']); + } else { + $startUtc = (new DateTimeImmutable('@'.$event->startAt))->setTimezone(new DateTimeZone('UTC')); + $endUtc = (new DateTimeImmutable('@'.$event->endAt))->setTimezone(new DateTimeZone('UTC')); + if ($endUtc <= $startUtc) { + $endUtc = $startUtc->modify('+1 hour'); + } + + $vevent->add('DTSTART', $startUtc->format('Ymd\\THis\\Z')); + $vevent->add('DTEND', $endUtc->format('Ymd\\THis\\Z')); + } + + return $vcalendar->serialize(); + } +} diff --git a/src/CalDav/Remote/RemoteCalendarLister.php b/src/CalDav/Remote/RemoteCalendarLister.php new file mode 100644 index 0000000..874c502 --- /dev/null +++ b/src/CalDav/Remote/RemoteCalendarLister.php @@ -0,0 +1,244 @@ + + */ + public function listCalendarOptions(string $baseUrl, string $username, string $password): array + { + $baseUrl = trim($baseUrl); + if ('' === $baseUrl || '' === trim($username)) { + return []; + } + + $candidates = array_values(array_unique(array_filter([ + $this->discoverCalendarHomeUrl($baseUrl, $username, $password), + $this->inferBaikalCalendarHomeUrl($baseUrl, $username), + $baseUrl, + rtrim($baseUrl, '/').'/', + $this->parentUrl($baseUrl), + ]))); + + $collections = []; + $collectionBaseUrl = $baseUrl; + + foreach ($candidates as $candidate) { + $candidateResult = $this->fetchCalendarCollections($candidate, $username, $password); + if ([] === $candidateResult) { + $this->logger->info('CalDAV discovery candidate produced no calendar collections.', ['candidate' => $candidate]); + continue; + } + + $collections = $candidateResult; + $collectionBaseUrl = $candidate; + break; + } + + $options = []; + + foreach ($collections as $collection) { + $absoluteHref = $this->absoluteUrl($collectionBaseUrl, $collection['href']); + $options[$absoluteHref] = sprintf('%s (%s)', $collection['displayName'], $absoluteHref); + } + + if ([] === $options) { + $this->logger->warning('CalDAV discovery returned no remote calendars.', ['baseUrl' => $baseUrl]); + $options[$baseUrl] = $baseUrl; + } + + return $options; + } + + private function discoverCalendarHomeUrl(string $baseUrl, string $username, string $password): ?string + { + try { + $principalResponse = $this->transport->propfind( + $baseUrl, + $username, + $password, + $this->buildPrincipalPropfindBody(), + 0, + ['Content-Type' => 'application/xml; charset=utf-8'], + ); + + if (!in_array($principalResponse->statusCode, [200, 207], true)) { + $this->logger->info('CalDAV principal discovery failed.', ['url' => $baseUrl, 'statusCode' => $principalResponse->statusCode]); + return null; + } + + $principalHref = $this->xmlParser->parseCurrentUserPrincipalHref($principalResponse->body); + if (null === $principalHref) { + $this->logger->info('CalDAV principal href not found in response.', ['url' => $baseUrl]); + return null; + } + + $principalUrl = $this->absoluteUrl($baseUrl, $principalHref); + $homeResponse = $this->transport->propfind( + $principalUrl, + $username, + $password, + $this->buildCalendarHomeSetPropfindBody(), + 0, + ['Content-Type' => 'application/xml; charset=utf-8'], + ); + + if (!in_array($homeResponse->statusCode, [200, 207], true)) { + $this->logger->info('CalDAV calendar-home-set discovery failed.', ['url' => $principalUrl, 'statusCode' => $homeResponse->statusCode]); + return null; + } + + $homeHref = $this->xmlParser->parseCalendarHomeSetHref($homeResponse->body); + + return null !== $homeHref ? $this->absoluteUrl($baseUrl, $homeHref) : null; + } catch (\Throwable) { + $this->logger->info('CalDAV calendar-home-set discovery threw an exception.', ['url' => $baseUrl]); + return null; + } + } + + /** + * @return list + */ + private function fetchCalendarCollections(string $url, string $username, string $password): array + { + try { + $response = $this->transport->propfind( + $url, + $username, + $password, + $this->buildCollectionPropfindBody(), + 1, + ['Content-Type' => 'application/xml; charset=utf-8'], + ); + + if (!in_array($response->statusCode, [200, 207], true)) { + $this->logger->info('CalDAV collection PROPFIND failed.', ['url' => $url, 'statusCode' => $response->statusCode]); + return []; + } + + return $this->xmlParser->parseCalendarCollections($response->body); + } catch (\Throwable) { + $this->logger->info('CalDAV collection PROPFIND threw an exception.', ['url' => $url]); + return []; + } + } + + private function parentUrl(string $url): ?string + { + $parts = parse_url($url); + if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) { + return null; + } + + $path = isset($parts['path']) ? trim((string) $parts['path']) : ''; + if ('' === $path || '/' === $path) { + return null; + } + + $segments = explode('/', trim($path, '/')); + array_pop($segments); + $parentPath = '/'.implode('/', $segments); + if ('/' === $parentPath || '' === trim($parentPath, '/')) { + return null; + } + + $prefix = $parts['scheme'].'://'.$parts['host']; + if (isset($parts['port'])) { + $prefix .= ':'.$parts['port']; + } + + return $prefix.$parentPath; + } + + private function inferBaikalCalendarHomeUrl(string $baseUrl, string $username): ?string + { + $parts = parse_url($baseUrl); + if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) { + return null; + } + + $path = (string) ($parts['path'] ?? ''); + if ('' === $path) { + return null; + } + + $prefix = $parts['scheme'].'://'.$parts['host']; + if (isset($parts['port'])) { + $prefix .= ':'.$parts['port']; + } + + return $prefix.'/'.trim($path, '/').'/calendars/'.rawurlencode($username).'/'; + } + + private function buildPrincipalPropfindBody(): string + { + return <<<'XML' + + + + + + +XML; + } + + private function buildCalendarHomeSetPropfindBody(): string + { + return <<<'XML' + + + + + + +XML; + } + + private function buildCollectionPropfindBody(): string + { + return <<<'XML' + + + + + + + +XML; + } + + private function absoluteUrl(string $baseUrl, string $href): string + { + if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) { + return $href; + } + + $base = parse_url($baseUrl); + if (!is_array($base) || !isset($base['scheme'], $base['host'])) { + return $href; + } + + $prefix = $base['scheme'].'://'.$base['host']; + if (isset($base['port'])) { + $prefix .= ':'.$base['port']; + } + + return $prefix.'/'.ltrim($href, '/'); + } +} diff --git a/src/CalDav/Remote/RemoteCalendarReader.php b/src/CalDav/Remote/RemoteCalendarReader.php new file mode 100644 index 0000000..597956e --- /dev/null +++ b/src/CalDav/Remote/RemoteCalendarReader.php @@ -0,0 +1,130 @@ + + */ + public function readEvents(CalendarSyncConfig $config): array + { + $response = $this->transport->report( + $config->caldavUrl, + $config->caldavUsername, + $config->caldavPassword, + $this->buildCalendarQueryBody(), + 1, + ['Content-Type' => 'application/xml; charset=utf-8'], + ); + + if (!in_array($response->statusCode, [200, 207], true)) { + $this->logger->warning('CalDAV calendar query failed.', [ + 'calendarId' => $config->calendarId, + 'url' => $config->caldavUrl, + 'statusCode' => $response->statusCode, + ]); + + return []; + } + + $parsedItems = $this->xmlParser->parseCalendarMultistatus($response->body); + $events = []; + + foreach ($parsedItems as $item) { + $event = $this->icalendarParser->parseEvent( + $item['href'], + $item['etag'], + $item['calendarData'], + $config->timezoneOrDefault(), + ); + + if (null !== $event) { + $events[] = $event; + } + } + + return $events; + } + + public function readEventByHref(CalendarSyncConfig $config, string $href): ?RemoteEvent + { + $response = $this->transport->get( + $this->absoluteUrl($config->caldavUrl, $href), + $config->caldavUsername, + $config->caldavPassword, + ['Accept' => 'text/calendar'], + ); + + if (200 !== $response->statusCode) { + $this->logger->warning('CalDAV GET by href failed.', [ + 'calendarId' => $config->calendarId, + 'url' => $href, + 'statusCode' => $response->statusCode, + ]); + + return null; + } + + return $this->icalendarParser->parseEvent( + $href, + trim((string) $response->header('etag'), '"'), + $response->body, + $config->timezoneOrDefault(), + ); + } + + private function buildCalendarQueryBody(): string + { + return <<<'XML' + + + + + + + + + + + + +XML; + } + + private function absoluteUrl(string $calendarUrl, string $href): string + { + if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) { + return $href; + } + + $base = parse_url($calendarUrl); + if (!is_array($base) || !isset($base['scheme'], $base['host'])) { + return $href; + } + + $prefix = $base['scheme'].'://'.$base['host']; + if (isset($base['port'])) { + $prefix .= ':'.$base['port']; + } + + return $prefix.$href; + } +} diff --git a/src/CalDav/Remote/RemoteCalendarWriter.php b/src/CalDav/Remote/RemoteCalendarWriter.php new file mode 100644 index 0000000..e950984 --- /dev/null +++ b/src/CalDav/Remote/RemoteCalendarWriter.php @@ -0,0 +1,124 @@ +buildHref($event->uid); + $targetUrl = $this->absoluteUrl($config->caldavUrl, $targetHref); + + if ($dryRun) { + return ['href' => $targetHref, 'etag' => $etag ?? 'dry-run']; + } + + $headers = [ + 'Content-Type' => 'text/calendar; charset=utf-8', + 'If-Match' => '' !== trim((string) $etag) ? $this->formatEntityTag((string) $etag) : '*', + ]; + + if ('' === trim((string) $etag)) { + unset($headers['If-Match']); + $headers['If-None-Match'] = '*'; + } + + $response = $this->transport->put( + $targetUrl, + $config->caldavUsername, + $config->caldavPassword, + $this->serializer->serializeEvent($event), + $headers, + ); + + if ($response->statusCode < 200 || $response->statusCode >= 300) { + throw new RuntimeException(sprintf('CalDAV PUT failed for %s (status %d).', $targetUrl, $response->statusCode)); + } + + $newEtag = trim((string) $response->header('etag'), '"'); + + return [ + 'href' => $targetHref, + 'etag' => '' !== $newEtag ? $newEtag : ($etag ?? ''), + ]; + } + + public function deleteEvent(CalendarSyncConfig $config, string $href, ?string $etag, bool $dryRun): void + { + if ($dryRun) { + return; + } + + $headers = []; + if ('' !== trim((string) $etag)) { + $headers['If-Match'] = (string) $etag; + } + + $this->transport->delete( + $this->absoluteUrl($config->caldavUrl, $href), + $config->caldavUsername, + $config->caldavPassword, + $headers, + ); + } + + private function buildHref(string $uid): string + { + return '/'.rawurlencode($uid).'.ics'; + } + + private function absoluteUrl(string $calendarUrl, string $href): string + { + if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) { + return $href; + } + + if (str_starts_with($href, '/')) { + $base = parse_url($calendarUrl); + if (is_array($base) && isset($base['scheme'], $base['host'])) { + $prefix = $base['scheme'].'://'.$base['host']; + if (isset($base['port'])) { + $prefix .= ':'.$base['port']; + } + + return $prefix.$href; + } + } + + $trimmedCalendarUrl = rtrim($calendarUrl, '/'); + $trimmedHref = ltrim($href, '/'); + + return $trimmedCalendarUrl.'/'.$trimmedHref; + } + + private function formatEntityTag(string $etag): string + { + $trimmed = trim($etag); + if ('' === $trimmed || '*' === $trimmed) { + return $trimmed; + } + + if (preg_match('/^W?\/.+$/', $trimmed) || str_starts_with($trimmed, '"')) { + return $trimmed; + } + + return '"'.$trimmed.'"'; + } +} diff --git a/src/CalDav/Transport/CalDavTransport.php b/src/CalDav/Transport/CalDavTransport.php new file mode 100644 index 0000000..e60adc9 --- /dev/null +++ b/src/CalDav/Transport/CalDavTransport.php @@ -0,0 +1,188 @@ + (string) $depth], $headers); + + return $this->request('PROPFIND', $url, $username, $password, $body, $headers); + } + + public function report(string $url, string $username, string $password, string $body, int $depth = 1, array $headers = []): TransportResponse + { + $headers = array_merge(['Depth' => (string) $depth], $headers); + + return $this->request('REPORT', $url, $username, $password, $body, $headers); + } + + public function get(string $url, string $username, string $password, array $headers = []): TransportResponse + { + return $this->request('GET', $url, $username, $password, null, $headers); + } + + public function put(string $url, string $username, string $password, string $body, array $headers = []): TransportResponse + { + return $this->request('PUT', $url, $username, $password, $body, $headers); + } + + public function delete(string $url, string $username, string $password, array $headers = []): TransportResponse + { + return $this->request('DELETE', $url, $username, $password, null, $headers); + } + + /** + * @param array $headers + */ + private function request(string $method, string $url, string $username, string $password, ?string $body, array $headers): TransportResponse + { + $requestHeaders = [ + 'Accept' => 'application/xml,text/xml,text/calendar,*/*', + ...$headers, + ]; + + try { + $response = $this->httpClient->request($method, $url, [ + 'auth_basic' => [$username, $password], + 'headers' => $requestHeaders, + 'body' => $body, + ]); + + $statusCode = $response->getStatusCode(); + $rawHeaders = $response->getHeaders(false); + $normalizedHeaders = []; + + foreach ($rawHeaders as $name => $values) { + $normalizedHeaders[strtolower($name)] = implode(', ', $values); + } + + if (401 === $statusCode && isset($normalizedHeaders['www-authenticate']) && str_contains(strtolower($normalizedHeaders['www-authenticate']), 'digest')) { + $digestAuthorization = $this->buildDigestAuthorizationHeader( + $normalizedHeaders['www-authenticate'], + $method, + $url, + $username, + $password, + ); + + if (null !== $digestAuthorization) { + $retryResponse = $this->httpClient->request($method, $url, [ + 'headers' => [ + ...$requestHeaders, + 'Authorization' => $digestAuthorization, + ], + 'body' => $body, + ]); + + $statusCode = $retryResponse->getStatusCode(); + $rawHeaders = $retryResponse->getHeaders(false); + $normalizedHeaders = []; + + foreach ($rawHeaders as $name => $values) { + $normalizedHeaders[strtolower($name)] = implode(', ', $values); + } + + $content = $retryResponse->getContent(false); + + return new TransportResponse($statusCode, $content, $normalizedHeaders); + } + } + + $content = $response->getContent(false); + + return new TransportResponse($statusCode, $content, $normalizedHeaders); + } catch (TransportExceptionInterface $e) { + throw new RuntimeException(sprintf('CalDAV transport error for %s %s: %s', $method, $url, $e->getMessage()), 0, $e); + } + } + + private function buildDigestAuthorizationHeader(string $wwwAuthenticateHeader, string $method, string $url, string $username, string $password): ?string + { + $challenge = trim($wwwAuthenticateHeader); + $digestPos = stripos($challenge, 'Digest '); + if (false === $digestPos) { + return null; + } + + $challengeBody = trim(substr($challenge, $digestPos + 7)); + preg_match_all('/([a-zA-Z0-9_-]+)=("[^"]*"|[^,]+)/', $challengeBody, $matches, PREG_SET_ORDER); + + $params = []; + foreach ($matches as $match) { + $params[strtolower($match[1])] = trim($match[2], " \t\n\r\0\x0B\""); + } + + $realm = $params['realm'] ?? null; + $nonce = $params['nonce'] ?? null; + if (null === $realm || null === $nonce) { + return null; + } + + $qop = null; + if (isset($params['qop'])) { + $qopCandidates = array_map('trim', explode(',', $params['qop'])); + $qop = in_array('auth', $qopCandidates, true) ? 'auth' : ('' !== ($qopCandidates[0] ?? '') ? $qopCandidates[0] : null); + } + + $algorithm = strtoupper((string) ($params['algorithm'] ?? 'MD5')); + $uri = (string) (parse_url($url, PHP_URL_PATH) ?? '/'); + $query = parse_url($url, PHP_URL_QUERY); + if (is_string($query) && '' !== $query) { + $uri .= '?'.$query; + } + + $cnonce = bin2hex(random_bytes(8)); + $nc = '00000001'; + + $ha1 = md5($username.':'.$realm.':'.$password); + if ('MD5-SESS' === $algorithm) { + $ha1 = md5($ha1.':'.$nonce.':'.$cnonce); + } + + $ha2 = md5($method.':'.$uri); + + if (null !== $qop) { + $response = md5($ha1.':'.$nonce.':'.$nc.':'.$cnonce.':'.$qop.':'.$ha2); + } else { + $response = md5($ha1.':'.$nonce.':'.$ha2); + } + + $headerParts = [ + 'Digest username="'.$this->escapeDigestValue($username).'"', + 'realm="'.$this->escapeDigestValue($realm).'"', + 'nonce="'.$this->escapeDigestValue($nonce).'"', + 'uri="'.$this->escapeDigestValue($uri).'"', + 'response="'.$response.'"', + 'algorithm='.$algorithm, + ]; + + if (isset($params['opaque'])) { + $headerParts[] = 'opaque="'.$this->escapeDigestValue((string) $params['opaque']).'"'; + } + + if (null !== $qop) { + $headerParts[] = 'qop='.$qop; + $headerParts[] = 'nc='.$nc; + $headerParts[] = 'cnonce="'.$cnonce.'"'; + } + + return implode(', ', $headerParts); + } + + private function escapeDigestValue(string $value): string + { + return addcslashes($value, "\\\""); + } +} diff --git a/src/CalDav/Transport/CalDavTransportInterface.php b/src/CalDav/Transport/CalDavTransportInterface.php new file mode 100644 index 0000000..89ab2be --- /dev/null +++ b/src/CalDav/Transport/CalDavTransportInterface.php @@ -0,0 +1,33 @@ + $headers + */ + public function propfind(string $url, string $username, string $password, string $body, int $depth = 1, array $headers = []): TransportResponse; + + /** + * @param array $headers + */ + public function report(string $url, string $username, string $password, string $body, int $depth = 1, array $headers = []): TransportResponse; + + /** + * @param array $headers + */ + public function get(string $url, string $username, string $password, array $headers = []): TransportResponse; + + /** + * @param array $headers + */ + public function put(string $url, string $username, string $password, string $body, array $headers = []): TransportResponse; + + /** + * @param array $headers + */ + public function delete(string $url, string $username, string $password, array $headers = []): TransportResponse; +} diff --git a/src/CalDav/Transport/TransportResponse.php b/src/CalDav/Transport/TransportResponse.php new file mode 100644 index 0000000..05e01ff --- /dev/null +++ b/src/CalDav/Transport/TransportResponse.php @@ -0,0 +1,25 @@ + $headers + */ + public function __construct( + public int $statusCode, + public string $body, + public array $headers, + ) { + } + + public function header(string $name): ?string + { + $normalized = strtolower($name); + + return $this->headers[$normalized] ?? null; + } +} diff --git a/src/CalDavSyncBundle.php b/src/CalDavSyncBundle.php new file mode 100644 index 0000000..dd0220d --- /dev/null +++ b/src/CalDavSyncBundle.php @@ -0,0 +1,15 @@ +addOption('calendar', null, InputOption::VALUE_REQUIRED, 'Run sync only for a specific tl_calendar id') + ->addOption('direction', null, InputOption::VALUE_REQUIRED, 'Sync direction: pull|push|both', 'both') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simulate sync changes without writing to local DB or remote CalDAV server') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $calendarId = $input->getOption('calendar'); + $direction = (string) $input->getOption('direction'); + $dryRun = (bool) $input->getOption('dry-run'); + + if (!in_array($direction, ['pull', 'push', 'both'], true)) { + $io->error('Invalid --direction value. Allowed: pull|push|both'); + + return Command::INVALID; + } + + $calendarIdValue = null; + if (null !== $calendarId) { + if (!ctype_digit((string) $calendarId)) { + $io->error('Option --calendar must be a numeric tl_calendar id.'); + + return Command::INVALID; + } + $calendarIdValue = (int) $calendarId; + } + + $io->section('CalDAV sync start'); + $io->text(sprintf('Direction: %s', $direction)); + $io->text(sprintf('Calendar filter: %s', null !== $calendarIdValue ? (string) $calendarIdValue : 'all sync-enabled calendars')); + $io->text(sprintf('Dry-run: %s', $dryRun ? 'yes' : 'no')); + + try { + $results = $this->syncRunner->run($calendarIdValue, $direction, $dryRun); + } catch (\Throwable $e) { + $io->error(sprintf('Sync failed: %s', $e->getMessage())); + + return Command::FAILURE; + } + + if ([] === $results) { + $io->warning('No sync-enabled calendars found for the given filter.'); + + return Command::SUCCESS; + } + + $tableRows = []; + foreach ($results as $result) { + $tableRows[] = [ + (string) $result['calendarId'], + (string) $result['pull']->created, + (string) $result['pull']->updated, + (string) $result['pull']->deleted, + (string) $result['pull']->skipped, + (string) $result['pull']->conflicts, + (string) $result['push']->created, + (string) $result['push']->updated, + (string) $result['push']->deleted, + (string) $result['push']->skipped, + (string) $result['push']->conflicts, + ]; + } + + $io->table([ + 'Calendar', + 'Pull +', + 'Pull ~', + 'Pull -', + 'Pull =', + 'Pull !', + 'Push +', + 'Push ~', + 'Push -', + 'Push =', + 'Push !', + ], $tableRows); + + $io->success('CalDAV sync finished.'); + + return Command::SUCCESS; + } +} diff --git a/src/Config/CalendarSyncConfig.php b/src/Config/CalendarSyncConfig.php new file mode 100644 index 0000000..6c81921 --- /dev/null +++ b/src/Config/CalendarSyncConfig.php @@ -0,0 +1,56 @@ + $selectedCalendarUrls + */ + public function __construct( + public int $calendarId, + public string $caldavUrl, + public string $caldavUsername, + public string $caldavPassword, + public int $caldavAuthorId, + public string $caldavTimezone, + public array $selectedCalendarUrls, + ) { + } + + public function timezoneOrDefault(): string + { + return '' !== trim($this->caldavTimezone) ? $this->caldavTimezone : 'UTC'; + } + + public function hasMultipleRemoteCalendars(): bool + { + return count($this->selectedCalendarUrls) > 1; + } + + public function shouldManageEventForThisRemoteCalendar(string $eventCalendarUrl): bool + { + $normalizedEventUrl = trim($eventCalendarUrl); + if ('' === $normalizedEventUrl) { + return !$this->hasMultipleRemoteCalendars(); + } + + return $normalizedEventUrl === $this->caldavUrl; + } + + public function resolveTargetCalendarForLocalEvent(string $eventCalendarUrl): ?string + { + $normalizedEventUrl = trim($eventCalendarUrl); + if ('' !== $normalizedEventUrl) { + return $normalizedEventUrl; + } + + if ([] === $this->selectedCalendarUrls) { + return null; + } + + return $this->selectedCalendarUrls[0]; + } +} diff --git a/src/Contao/Dca/CalendarDcaCallbacks.php b/src/Contao/Dca/CalendarDcaCallbacks.php new file mode 100644 index 0000000..cbe7611 --- /dev/null +++ b/src/Contao/Dca/CalendarDcaCallbacks.php @@ -0,0 +1,86 @@ + + */ + public static function getAvailableRemoteCalendars(?DataContainer $dataContainer = null): array + { + $record = $dataContainer?->activeRecord; + + $caldavUrl = trim((string) Input::post('caldavUrl')); + if ('' === $caldavUrl) { + $caldavUrl = trim((string) ($record->caldavUrl ?? '')); + } + + $caldavUsername = trim((string) Input::post('caldavUsername')); + if ('' === $caldavUsername) { + $caldavUsername = trim((string) ($record->caldavUsername ?? '')); + } + + $caldavPassword = (string) Input::post('caldavPassword'); + if ('' === $caldavPassword) { + $caldavPassword = (string) ($record->caldavPassword ?? ''); + } + + if ('' === $caldavUrl || '' === $caldavUsername) { + return []; + } + + $container = System::getContainer(); + if (null === $container) { + return []; + } + + try { + /** @var RemoteCalendarLister $lister */ + $lister = $container->get(RemoteCalendarLister::class); + + return $lister->listCalendarOptions($caldavUrl, $caldavUsername, $caldavPassword); + } catch (\Throwable $exception) { + try { + /** @var LoggerInterface|null $logger */ + $logger = $container->get('monolog.logger.contao.error'); + $logger?->warning('CalDAV remote calendar options callback failed.', [ + 'message' => $exception->getMessage(), + 'url' => $caldavUrl, + 'username' => $caldavUsername, + ]); + } catch (\Throwable) { + // Ignore secondary logging failures. + } + + return []; + } + } + + /** + * @return list + */ + public static function normalizeSelectedRemoteCalendars(mixed $value): array + { + $values = StringUtil::deserialize($value, true); + $normalized = []; + + foreach ($values as $entry) { + $entry = trim((string) $entry); + if ('' !== $entry) { + $normalized[] = $entry; + } + } + + return array_values(array_unique($normalized)); + } +} diff --git a/src/Contao/Manager/Plugin.php b/src/Contao/Manager/Plugin.php new file mode 100644 index 0000000..6ccf6a9 --- /dev/null +++ b/src/Contao/Manager/Plugin.php @@ -0,0 +1,23 @@ +setLoadAfter([ContaoCoreBundle::class, ContaoCalendarBundle::class]), + ]; + } +} diff --git a/src/DependencyInjection/CalDavSyncExtension.php b/src/DependencyInjection/CalDavSyncExtension.php new file mode 100644 index 0000000..1a10cf6 --- /dev/null +++ b/src/DependencyInjection/CalDavSyncExtension.php @@ -0,0 +1,19 @@ +load('services.yaml'); + } +} diff --git a/src/Migration/Version20260327000000.php b/src/Migration/Version20260327000000.php new file mode 100644 index 0000000..aacd64b --- /dev/null +++ b/src/Migration/Version20260327000000.php @@ -0,0 +1,66 @@ +addSql("ALTER TABLE tl_calendar ADD caldavSyncEnabled CHAR(1) NOT NULL DEFAULT ''"); + $this->addSql("ALTER TABLE tl_calendar ADD caldavUrl VARCHAR(2048) NOT NULL DEFAULT ''"); + $this->addSql("ALTER TABLE tl_calendar ADD caldavUsername VARCHAR(255) NOT NULL DEFAULT ''"); + $this->addSql("ALTER TABLE tl_calendar ADD caldavPassword VARCHAR(255) NOT NULL DEFAULT ''"); + $this->addSql("ALTER TABLE tl_calendar ADD caldavTimezone VARCHAR(64) NOT NULL DEFAULT ''"); + $this->addSql('ALTER TABLE tl_calendar ADD caldavCalendarHrefs BLOB DEFAULT NULL'); + + $this->addSql("ALTER TABLE tl_calendar_events ADD caldavCalendarHref VARCHAR(2048) NOT NULL DEFAULT ''"); + $this->addSql("ALTER TABLE tl_calendar_events ADD caldavUid VARCHAR(255) NOT NULL DEFAULT ''"); + $this->addSql("ALTER TABLE tl_calendar_events ADD caldavHref VARCHAR(2048) NOT NULL DEFAULT ''"); + $this->addSql("ALTER TABLE tl_calendar_events ADD caldavEtag VARCHAR(255) NOT NULL DEFAULT ''"); + $this->addSql("ALTER TABLE tl_calendar_events ADD caldavSyncHash VARCHAR(64) NOT NULL DEFAULT ''"); + $this->addSql("ALTER TABLE tl_calendar_events ADD caldavLastSync INT UNSIGNED NOT NULL DEFAULT 0"); + $this->addSql("ALTER TABLE tl_calendar_events ADD caldavOrigin VARCHAR(16) NOT NULL DEFAULT ''"); + $this->addSql("ALTER TABLE tl_calendar_events ADD caldavSyncState VARCHAR(32) NOT NULL DEFAULT ''"); + + $this->addSql('CREATE INDEX idx_tl_calendar_caldav_enabled ON tl_calendar (caldavSyncEnabled)'); + $this->addSql('CREATE INDEX idx_tl_calendar_events_caldav_calendar_href ON tl_calendar_events (caldavCalendarHref(191))'); + $this->addSql('CREATE INDEX idx_tl_calendar_events_caldav_href ON tl_calendar_events (caldavHref(191))'); + $this->addSql('CREATE INDEX idx_tl_calendar_events_caldav_uid ON tl_calendar_events (caldavUid)'); + $this->addSql('CREATE INDEX idx_tl_calendar_events_pid ON tl_calendar_events (pid)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX idx_tl_calendar_events_pid ON tl_calendar_events'); + $this->addSql('DROP INDEX idx_tl_calendar_events_caldav_uid ON tl_calendar_events'); + $this->addSql('DROP INDEX idx_tl_calendar_events_caldav_href ON tl_calendar_events'); + $this->addSql('DROP INDEX idx_tl_calendar_events_caldav_calendar_href ON tl_calendar_events'); + $this->addSql('DROP INDEX idx_tl_calendar_caldav_enabled ON tl_calendar'); + + $this->addSql('ALTER TABLE tl_calendar_events DROP caldavSyncState'); + $this->addSql('ALTER TABLE tl_calendar_events DROP caldavOrigin'); + $this->addSql('ALTER TABLE tl_calendar_events DROP caldavLastSync'); + $this->addSql('ALTER TABLE tl_calendar_events DROP caldavSyncHash'); + $this->addSql('ALTER TABLE tl_calendar_events DROP caldavEtag'); + $this->addSql('ALTER TABLE tl_calendar_events DROP caldavHref'); + $this->addSql('ALTER TABLE tl_calendar_events DROP caldavUid'); + $this->addSql('ALTER TABLE tl_calendar_events DROP caldavCalendarHref'); + + $this->addSql('ALTER TABLE tl_calendar DROP caldavCalendarHrefs'); + $this->addSql('ALTER TABLE tl_calendar DROP caldavTimezone'); + $this->addSql('ALTER TABLE tl_calendar DROP caldavPassword'); + $this->addSql('ALTER TABLE tl_calendar DROP caldavUsername'); + $this->addSql('ALTER TABLE tl_calendar DROP caldavUrl'); + $this->addSql('ALTER TABLE tl_calendar DROP caldavSyncEnabled'); + } +} diff --git a/src/Migration/Version20260327223000.php b/src/Migration/Version20260327223000.php new file mode 100644 index 0000000..8c427f9 --- /dev/null +++ b/src/Migration/Version20260327223000.php @@ -0,0 +1,28 @@ +addSql("ALTER TABLE tl_calendar ADD caldavAuthorId INT UNSIGNED NOT NULL DEFAULT 0"); + $this->addSql('CREATE INDEX idx_tl_calendar_caldav_author_id ON tl_calendar (caldavAuthorId)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX idx_tl_calendar_caldav_author_id ON tl_calendar'); + $this->addSql('ALTER TABLE tl_calendar DROP caldavAuthorId'); + } +} diff --git a/src/Repository/ContaoCalendarEventRepository.php b/src/Repository/ContaoCalendarEventRepository.php new file mode 100644 index 0000000..6bf5dc0 --- /dev/null +++ b/src/Repository/ContaoCalendarEventRepository.php @@ -0,0 +1,172 @@ +> + */ + public function findByCalendarId(int $calendarId): array + { + /** @var list> $rows */ + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM tl_calendar_events WHERE pid = :pid ORDER BY id ASC', + ['pid' => $calendarId], + ); + + return $rows; + } + + public function insert(array $data): int + { + $filteredData = $this->filterToExistingColumns($data); + $this->connection->insert('tl_calendar_events', $filteredData); + + return (int) $this->connection->lastInsertId(); + } + + public function update(int $id, array $data): void + { + $filteredData = $this->filterToExistingColumns($data); + if ([] === $filteredData) { + return; + } + + $this->connection->update('tl_calendar_events', $filteredData, ['id' => $id]); + } + + public function delete(int $id): void + { + $this->connection->delete('tl_calendar_events', ['id' => $id]); + } + + /** + * @param list $selectedRemoteCalendarUrls + */ + public function deleteRemoteImportedByCalendarExcludingUrls(int $calendarId, array $selectedRemoteCalendarUrls): int + { + $sql = <<<'SQL' +DELETE FROM tl_calendar_events +WHERE pid = :pid + AND (caldavHref <> '' OR caldavUid <> '' OR caldavOrigin = 'remote') + AND caldavCalendarHref <> '' +SQL; + + $params = ['pid' => $calendarId]; + $types = ['pid' => ParameterType::INTEGER]; + + if ([] !== $selectedRemoteCalendarUrls) { + $sql .= ' AND caldavCalendarHref NOT IN (:selectedUrls)'; + $params['selectedUrls'] = $selectedRemoteCalendarUrls; + $types['selectedUrls'] = ArrayParameterType::STRING; + } + + return $this->connection->executeStatement($sql, $params, $types); + } + + /** + * @param list $selectedRemoteCalendarUrls + */ + public function countRemoteImportedByCalendarExcludingUrls(int $calendarId, array $selectedRemoteCalendarUrls): int + { + $sql = <<<'SQL' +SELECT COUNT(*) +FROM tl_calendar_events +WHERE pid = :pid + AND (caldavHref <> '' OR caldavUid <> '' OR caldavOrigin = 'remote') + AND caldavCalendarHref <> '' +SQL; + + $params = ['pid' => $calendarId]; + $types = ['pid' => ParameterType::INTEGER]; + + if ([] !== $selectedRemoteCalendarUrls) { + $sql .= ' AND caldavCalendarHref NOT IN (:selectedUrls)'; + $params['selectedUrls'] = $selectedRemoteCalendarUrls; + $types['selectedUrls'] = ArrayParameterType::STRING; + } + + return (int) $this->connection->fetchOne($sql, $params, $types); + } + + public function publishAllRemoteImported(int $calendarId): int + { + return $this->connection->executeStatement( + <<<'SQL' +UPDATE tl_calendar_events +SET published = '1' +WHERE pid = :pid + AND (caldavHref <> '' OR caldavUid <> '' OR caldavOrigin = 'remote') + AND (published IS NULL OR published <> '1') +SQL, + ['pid' => $calendarId], + ['pid' => ParameterType::INTEGER], + ); + } + + public function generateUniqueAlias(string $baseAlias): string + { + $maxAliasLength = 40; + $alias = substr(trim($baseAlias), 0, $maxAliasLength); + if ('' === $alias) { + $alias = 'event'; + } + + if (!$this->aliasExists($alias)) { + return $alias; + } + + $counter = 2; + + while ($counter < 1000) { + $suffix = '_'.$counter; + $prefixLength = $maxAliasLength - strlen($suffix); + $candidate = substr($alias, 0, max(1, $prefixLength)).$suffix; + + if (!$this->aliasExists($candidate)) { + return $candidate; + } + + ++$counter; + } + + return substr($alias.'_'.uniqid('', false), 0, $maxAliasLength); + } + + /** + * @param array $data + * + * @return array + */ + private function filterToExistingColumns(array $data): array + { + $columnNames = array_keys($this->connection->createSchemaManager()->listTableColumns('tl_calendar_events')); + $columnMap = array_fill_keys(array_map('strtolower', $columnNames), true); + + return array_filter( + $data, + static fn (string $column): bool => isset($columnMap[strtolower($column)]), + ARRAY_FILTER_USE_KEY, + ); + } + + private function aliasExists(string $alias): bool + { + return (int) $this->connection->fetchOne( + 'SELECT COUNT(*) FROM tl_calendar_events WHERE alias = :alias', + ['alias' => $alias], + ['alias' => ParameterType::STRING], + ) > 0; + } +} diff --git a/src/Repository/ContaoCalendarRepository.php b/src/Repository/ContaoCalendarRepository.php new file mode 100644 index 0000000..b5830e2 --- /dev/null +++ b/src/Repository/ContaoCalendarRepository.php @@ -0,0 +1,35 @@ +> + */ + public function findSyncEnabled(?int $calendarId = null): array + { + $sql = 'SELECT * FROM tl_calendar WHERE caldavSyncEnabled = :enabled'; + $params = ['enabled' => '1']; + + if (null !== $calendarId) { + $sql .= ' AND id = :id'; + $params['id'] = $calendarId; + } + + $sql .= ' ORDER BY id ASC'; + + /** @var list> $rows */ + $rows = $this->connection->fetchAllAssociative($sql, $params); + + return $rows; + } +} diff --git a/src/Service/CalendarConfigProvider.php b/src/Service/CalendarConfigProvider.php new file mode 100644 index 0000000..754c68c --- /dev/null +++ b/src/Service/CalendarConfigProvider.php @@ -0,0 +1,84 @@ + + */ + public function getSyncEnabledCalendars(?int $calendarId = null): array + { + $rows = $this->calendarRepository->findSyncEnabled($calendarId); + $configs = []; + + foreach ($rows as $row) { + if ('' === trim((string) ($row['caldavUrl'] ?? '')) || '' === trim((string) ($row['caldavUsername'] ?? ''))) { + continue; + } + + $selectedCalendarUrls = $this->resolveSelectedCalendarUrls($row); + if ([] === $selectedCalendarUrls) { + $selectedCalendarUrls = array_keys($this->remoteCalendarLister->listCalendarOptions( + (string) $row['caldavUrl'], + (string) $row['caldavUsername'], + (string) ($row['caldavPassword'] ?? ''), + )); + } + + if ([] === $selectedCalendarUrls) { + $fallback = trim((string) ($row['caldavUrl'] ?? '')); + if ('' !== $fallback) { + $selectedCalendarUrls[] = $fallback; + } + } + + foreach ($selectedCalendarUrls as $selectedCalendarUrl) { + $configs[] = new CalendarSyncConfig( + (int) $row['id'], + $selectedCalendarUrl, + (string) $row['caldavUsername'], + (string) ($row['caldavPassword'] ?? ''), + (int) ($row['caldavAuthorId'] ?? 0), + (string) ($row['caldavTimezone'] ?? ''), + $selectedCalendarUrls, + ); + } + } + + return $configs; + } + + /** + * @param array $row + * + * @return list + */ + private function resolveSelectedCalendarUrls(array $row): array + { + $storedValues = StringUtil::deserialize((string) ($row['caldavCalendarHrefs'] ?? ''), true); + $urls = []; + + foreach ($storedValues as $storedValue) { + $value = trim((string) $storedValue); + if ('' !== $value) { + $urls[] = $value; + } + } + + return array_values(array_unique($urls)); + } +} diff --git a/src/Sync/EventMatchResolver.php b/src/Sync/EventMatchResolver.php new file mode 100644 index 0000000..3a4f845 --- /dev/null +++ b/src/Sync/EventMatchResolver.php @@ -0,0 +1,28 @@ +> $localEvents + */ + public function resolve(array $localEvents, RemoteEvent $remoteEvent): ?array + { + foreach ($localEvents as $localEvent) { + if ('' !== (string) ($localEvent['caldavHref'] ?? '') && (string) $localEvent['caldavHref'] === $remoteEvent->href) { + return $localEvent; + } + } + + foreach ($localEvents as $localEvent) { + if ('' !== (string) ($localEvent['caldavUid'] ?? '') && (string) $localEvent['caldavUid'] === $remoteEvent->uid) { + return $localEvent; + } + } + + return null; + } +} diff --git a/src/Sync/LocalToRemoteSynchronizer.php b/src/Sync/LocalToRemoteSynchronizer.php new file mode 100644 index 0000000..5d11fe4 --- /dev/null +++ b/src/Sync/LocalToRemoteSynchronizer.php @@ -0,0 +1,243 @@ +eventRepository->findByCalendarId($config->calendarId); + $localEvents = array_values(array_filter( + $allLocalEvents, + fn (array $event): bool => $config->shouldManageEventForThisRemoteCalendar((string) ($event['caldavCalendarHref'] ?? '')), + )); + $remoteEvents = $this->remoteReader->readEvents($config); + $remotePseudoLocalRows = $this->buildRemotePseudoLocalRows($remoteEvents); + + $localHrefs = []; + $localUids = []; + + foreach ($localEvents as $localEvent) { + $targetCalendarUrl = $config->resolveTargetCalendarForLocalEvent((string) ($localEvent['caldavCalendarHref'] ?? '')); + if (null === $targetCalendarUrl || $targetCalendarUrl !== $config->caldavUrl) { + ++$result->skipped; + + continue; + } + + $href = (string) ($localEvent['caldavHref'] ?? ''); + $uid = (string) ($localEvent['caldavUid'] ?? ''); + + if ('' !== $href) { + $localHrefs[$href] = true; + } + + if ('' !== $uid) { + $localUids[$uid] = true; + } + + $localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent); + $currentHash = $this->hashGenerator->generate($localSyncFields); + $storedHash = (string) ($localEvent['caldavSyncHash'] ?? ''); + $localChanged = '' === $storedHash || $storedHash !== $currentHash; + + if (!$localChanged) { + ++$result->skipped; + continue; + } + + $remoteMatch = $this->matchResolver->resolve($remotePseudoLocalRows, $this->fieldExtractor->toRemoteEvent($localEvent, $config->timezoneOrDefault())); + $matchingRemoteEvent = $this->resolveRemoteFromMatch($remoteEvents, $remoteMatch); + + if (null !== $matchingRemoteEvent && '' !== $storedHash) { + $remoteSyncFields = $this->fieldExtractor->extractFromRemoteEvent($matchingRemoteEvent); + $remoteHash = $this->hashGenerator->generate($remoteSyncFields); + + if ($remoteHash !== $storedHash) { + $localModifiedAt = (int) ($localEvent['tstamp'] ?? 0); + $remoteModifiedAt = $matchingRemoteEvent->lastModifiedAt; + + if ($this->preferLocalVersion($localModifiedAt, $remoteModifiedAt)) { + $this->logger->info('CalDAV conflict detected while pushing. Local newer timestamp wins.', [ + 'calendarId' => $config->calendarId, + 'eventId' => (int) $localEvent['id'], + 'href' => $matchingRemoteEvent->href, + 'uid' => $matchingRemoteEvent->uid, + 'localModifiedAt' => $localModifiedAt, + 'remoteModifiedAt' => $remoteModifiedAt, + ]); + } else { + ++$result->conflicts; + $this->logger->warning('CalDAV conflict detected while pushing. Remote newer timestamp wins.', [ + 'calendarId' => $config->calendarId, + 'eventId' => (int) $localEvent['id'], + 'href' => $matchingRemoteEvent->href, + 'uid' => $matchingRemoteEvent->uid, + 'localModifiedAt' => $localModifiedAt, + 'remoteModifiedAt' => $remoteModifiedAt, + ]); + + ++$result->skipped; + continue; + } + } + } + + $payloadEvent = $this->fieldExtractor->toRemoteEvent($localEvent, $config->timezoneOrDefault()); + if (null !== $matchingRemoteEvent) { + $payloadEvent = new RemoteEvent( + $payloadEvent->href, + $payloadEvent->uid, + $payloadEvent->etag, + $payloadEvent->lastModifiedAt, + $matchingRemoteEvent->hasTitle, + $matchingRemoteEvent->hasDescription, + $matchingRemoteEvent->hasLocation, + $matchingRemoteEvent->hasUrl, + $matchingRemoteEvent->hasTitle ? $payloadEvent->title : $matchingRemoteEvent->title, + $matchingRemoteEvent->hasDescription ? $payloadEvent->description : $matchingRemoteEvent->description, + $matchingRemoteEvent->hasLocation ? $payloadEvent->location : $matchingRemoteEvent->location, + $matchingRemoteEvent->hasUrl ? $payloadEvent->url : $matchingRemoteEvent->url, + $payloadEvent->startAt, + $payloadEvent->endAt, + $payloadEvent->allDay, + $payloadEvent->timezone, + ); + } + + $targetHref = null !== $matchingRemoteEvent + ? $matchingRemoteEvent->href + : ('' !== trim((string) ($localEvent['caldavHref'] ?? '')) ? (string) $localEvent['caldavHref'] : null) + ; + $targetEtag = null !== $matchingRemoteEvent + ? $matchingRemoteEvent->etag + : ('' !== trim((string) ($localEvent['caldavEtag'] ?? '')) ? (string) $localEvent['caldavEtag'] : null) + ; + + $upsertResult = $this->remoteWriter->upsertEvent( + $config, + $payloadEvent, + $targetHref, + $targetEtag, + $dryRun, + ); + + if (!$dryRun) { + $this->eventRepository->update((int) $localEvent['id'], [ + 'tstamp' => time(), + 'caldavCalendarHref' => $config->caldavUrl, + 'caldavUid' => $payloadEvent->uid, + 'caldavHref' => $upsertResult['href'], + 'caldavEtag' => $upsertResult['etag'], + 'caldavSyncHash' => $currentHash, + 'caldavLastSync' => time(), + 'caldavOrigin' => 'local', + 'caldavSyncState' => 'synced', + ]); + } + + if ('' !== $upsertResult['href']) { + $localHrefs[$upsertResult['href']] = true; + } + $localUids[$payloadEvent->uid] = true; + + if (null === $matchingRemoteEvent) { + ++$result->created; + } else { + ++$result->updated; + } + } + + foreach ($remoteEvents as $remoteEvent) { + if (isset($localHrefs[$remoteEvent->href]) || isset($localUids[$remoteEvent->uid])) { + continue; + } + + $this->remoteWriter->deleteEvent($config, $remoteEvent->href, $remoteEvent->etag, $dryRun); + ++$result->deleted; + } + + return $result; + } + + /** + * @param list $remoteEvents + * + * @return list> + */ + private function buildRemotePseudoLocalRows(array $remoteEvents): array + { + $rows = []; + + foreach ($remoteEvents as $remoteEvent) { + $rows[] = [ + 'caldavHref' => $remoteEvent->href, + 'caldavUid' => $remoteEvent->uid, + ]; + } + + return $rows; + } + + /** + * @param list $remoteEvents + */ + private function resolveRemoteFromMatch(array $remoteEvents, ?array $match): ?RemoteEvent + { + if (null === $match) { + return null; + } + + foreach ($remoteEvents as $remoteEvent) { + if ( + ((string) ($match['caldavHref'] ?? '') !== '' && $remoteEvent->href === (string) $match['caldavHref']) + || ((string) ($match['caldavUid'] ?? '') !== '' && $remoteEvent->uid === (string) $match['caldavUid']) + ) { + return $remoteEvent; + } + } + + return null; + } + + private function preferLocalVersion(int $localModifiedAt, int $remoteModifiedAt): bool + { + if ($localModifiedAt > 0 && $remoteModifiedAt <= 0) { + return true; + } + + if ($localModifiedAt <= 0 && $remoteModifiedAt > 0) { + return false; + } + + if ($localModifiedAt <= 0 && $remoteModifiedAt <= 0) { + // Be conservative and keep local edits when remote recency is unknown. + return true; + } + + return $localModifiedAt > ($remoteModifiedAt + self::MODIFIED_TIME_SKEW_SECONDS); + } +} diff --git a/src/Sync/RemoteEvent.php b/src/Sync/RemoteEvent.php new file mode 100644 index 0000000..966cecb --- /dev/null +++ b/src/Sync/RemoteEvent.php @@ -0,0 +1,28 @@ +remoteReader->readEvents($config); + $allLocalEvents = $this->eventRepository->findByCalendarId($config->calendarId); + $localEvents = array_values(array_filter( + $allLocalEvents, + fn (array $event): bool => $config->shouldManageEventForThisRemoteCalendar((string) ($event['caldavCalendarHref'] ?? '')), + )); + + $seenHrefs = []; + $seenUids = []; + + foreach ($remoteEvents as $remoteEvent) { + $seenHrefs[$remoteEvent->href] = true; + $seenUids[$remoteEvent->uid] = true; + + $localEvent = $this->matchResolver->resolve($localEvents, $remoteEvent); + $remoteSyncFields = $this->fieldExtractor->extractFromRemoteEvent($remoteEvent); + $remoteHash = $this->hashGenerator->generate($remoteSyncFields); + $expectedLocalFields = $this->fieldExtractor->applyRemoteToLocalFields($remoteEvent); + + if (null === $localEvent) { + $alias = $this->eventRepository->generateUniqueAlias( + $this->fieldExtractor->generateAliasFromRemoteEvent($remoteEvent), + ); + + $insertData = [ + ...$this->fieldExtractor->applyRemoteToLocalFields($remoteEvent), + 'alias' => $alias, + 'pid' => $config->calendarId, + 'author' => $config->caldavAuthorId, + 'tstamp' => time(), + 'cdate' => time(), + 'caldavCalendarHref' => $config->caldavUrl, + 'caldavUid' => $remoteEvent->uid, + 'caldavHref' => $remoteEvent->href, + 'caldavEtag' => $remoteEvent->etag, + 'caldavSyncHash' => $remoteHash, + 'caldavLastSync' => time(), + 'caldavOrigin' => 'remote', + 'caldavSyncState' => 'synced', + ]; + + if (!$dryRun) { + $this->eventRepository->insert($insertData); + } + + ++$result->created; + continue; + } + + $localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent); + $localCurrentHash = $this->hashGenerator->generate($localSyncFields); + $storedHash = (string) ($localEvent['caldavSyncHash'] ?? ''); + $storedEtag = (string) ($localEvent['caldavEtag'] ?? ''); + + $localChanged = '' !== $storedHash && $localCurrentHash !== $storedHash; + $remoteChanged = '' !== $storedEtag && '' !== $remoteEvent->etag && $storedEtag !== $remoteEvent->etag; + $localModifiedAt = (int) ($localEvent['tstamp'] ?? 0); + $remoteModifiedAt = $remoteEvent->lastModifiedAt; + $localWinsByTimestamp = $this->preferLocalVersion($localModifiedAt, $remoteModifiedAt); + $missingDateFields = (int) ($localEvent['startDate'] ?? 0) <= 0; + $dateFieldMismatch = (string) ($localEvent['startDate'] ?? '') !== (string) ($expectedLocalFields['startDate'] ?? '') + || (string) ($localEvent['endDate'] ?? '') !== (string) ($expectedLocalFields['endDate'] ?? ''); + $timeFieldMismatch = (string) ($localEvent['startTime'] ?? '') !== (string) ($expectedLocalFields['startTime'] ?? '') + || (string) ($localEvent['endTime'] ?? '') !== (string) ($expectedLocalFields['endTime'] ?? ''); + $teaserFieldMismatch = (string) ($localEvent['teaser'] ?? '') !== (string) ($expectedLocalFields['teaser'] ?? ''); + + if ($localChanged && $remoteChanged) { + if ($localWinsByTimestamp) { + ++$result->conflicts; + $this->logger->warning('CalDAV conflict detected. Local newer timestamp wins (pull update skipped).', [ + 'calendarId' => $config->calendarId, + 'eventId' => (int) $localEvent['id'], + 'href' => $remoteEvent->href, + 'uid' => $remoteEvent->uid, + 'localModifiedAt' => $localModifiedAt, + 'remoteModifiedAt' => $remoteModifiedAt, + ]); + + ++$result->skipped; + continue; + } + + ++$result->conflicts; + $this->logger->warning('CalDAV conflict detected. Remote newer timestamp wins.', [ + 'calendarId' => $config->calendarId, + 'eventId' => (int) $localEvent['id'], + 'href' => $remoteEvent->href, + 'uid' => $remoteEvent->uid, + 'localModifiedAt' => $localModifiedAt, + 'remoteModifiedAt' => $remoteModifiedAt, + ]); + } + + if ($localWinsByTimestamp && !$remoteChanged) { + ++$result->skipped; + continue; + } + + $mustUpdate = $remoteChanged + || $missingDateFields + || $dateFieldMismatch + || $timeFieldMismatch + || $teaserFieldMismatch + || '' === $storedHash + || (string) ($localEvent['caldavHref'] ?? '') !== $remoteEvent->href + || (string) ($localEvent['caldavUid'] ?? '') !== $remoteEvent->uid + ; + + if (!$mustUpdate) { + ++$result->skipped; + continue; + } + + $updateData = [ + ...$this->fieldExtractor->applyRemoteToLocalFields($remoteEvent), + 'tstamp' => time(), + 'caldavCalendarHref' => $config->caldavUrl, + 'caldavUid' => $remoteEvent->uid, + 'caldavHref' => $remoteEvent->href, + 'caldavEtag' => $remoteEvent->etag, + 'caldavSyncHash' => $remoteHash, + 'caldavLastSync' => time(), + 'caldavSyncState' => 'synced', + ]; + + if (!$remoteEvent->hasTitle) { + unset($updateData['title']); + } + + if (!$remoteEvent->hasDescription) { + unset($updateData['teaser']); + } + + if (!$remoteEvent->hasLocation) { + unset($updateData['location']); + } + + if (!$remoteEvent->hasUrl) { + unset($updateData['url']); + } + + if (!$dryRun) { + $this->eventRepository->update((int) $localEvent['id'], $updateData); + } + + ++$result->updated; + } + + foreach ($localEvents as $localEvent) { + $localHref = (string) ($localEvent['caldavHref'] ?? ''); + $localUid = (string) ($localEvent['caldavUid'] ?? ''); + + if ('' === $localHref && '' === $localUid) { + continue; + } + + if (isset($seenHrefs[$localHref]) || ('' !== $localUid && isset($seenUids[$localUid]))) { + continue; + } + + if (!$dryRun) { + $this->eventRepository->delete((int) $localEvent['id']); + } + + ++$result->deleted; + } + + return $result; + } + + private function preferLocalVersion(int $localModifiedAt, int $remoteModifiedAt): bool + { + if ($localModifiedAt > 0 && $remoteModifiedAt <= 0) { + return true; + } + + if ($localModifiedAt <= 0 && $remoteModifiedAt > 0) { + return false; + } + + if ($localModifiedAt <= 0 && $remoteModifiedAt <= 0) { + // Be conservative and keep local edits when remote recency is unknown. + return true; + } + + return $localModifiedAt > ($remoteModifiedAt + self::MODIFIED_TIME_SKEW_SECONDS); + } +} diff --git a/src/Sync/SyncFieldExtractor.php b/src/Sync/SyncFieldExtractor.php new file mode 100644 index 0000000..5de082e --- /dev/null +++ b/src/Sync/SyncFieldExtractor.php @@ -0,0 +1,260 @@ +timezone; + + try { + $datePrefix = (new DateTimeImmutable('@'.$remoteEvent->startAt)) + ->setTimezone(new DateTimeZone($timezone)) + ->format('Y-m-d'); + } catch (\Throwable) { + $datePrefix = gmdate('Y-m-d', $remoteEvent->startAt); + } + + $slug = $this->slugifyTitle($remoteEvent->title); + if ('' === $slug) { + $slug = 'event'; + } + + $maxTitleLength = 40 - strlen($datePrefix) - 1; + if ($maxTitleLength < 1) { + return substr($datePrefix, 0, 40); + } + + $slug = substr($slug, 0, $maxTitleLength); + $slug = trim($slug, '_'); + + if ('' === $slug) { + $slug = 'event'; + } + + return substr($datePrefix.'_'.$slug, 0, 40); + } + + /** + * @return array + */ + public function extractFromLocalEvent(array $localEvent): array + { + $allDay = '1' !== (string) ($localEvent['addTime'] ?? ''); + $start = (int) ($localEvent['startTime'] ?? 0); + if ($start <= 0) { + $start = (int) ($localEvent['startDate'] ?? 0); + } + + $end = (int) ($localEvent['endTime'] ?? 0); + $usesEndDateFallback = false; + + if ($end <= 0) { + $end = (int) ($localEvent['endDate'] ?? 0); + $usesEndDateFallback = $end > 0; + } + + // Contao stores endDate inclusive for all-day events, CalDAV uses exclusive DTEND. + if ($allDay && $usesEndDateFallback) { + $end += 86400; + } + + return $this->normalize([ + 'title' => (string) ($localEvent['title'] ?? ''), + 'start' => $start, + 'end' => $end, + 'allDay' => $allDay, + 'description' => $this->teaserToPlainText((string) ($localEvent['teaser'] ?? '')), + 'location' => (string) ($localEvent['location'] ?? ''), + 'url' => (string) ($localEvent['url'] ?? ''), + ]); + } + + /** + * @return array + */ + public function extractFromRemoteEvent(RemoteEvent $remoteEvent): array + { + return $this->normalize([ + 'title' => $remoteEvent->title, + 'start' => $remoteEvent->startAt, + 'end' => $remoteEvent->endAt, + 'allDay' => $remoteEvent->allDay, + 'description' => $remoteEvent->description, + 'location' => $remoteEvent->location, + 'url' => $remoteEvent->url ?? '', + ]); + } + + public function toRemoteEvent(array $localEvent, string $timezone): RemoteEvent + { + $uid = trim((string) ($localEvent['caldavUid'] ?? '')); + if ('' === $uid) { + $uid = sprintf('contao-%d-%d@local', (int) ($localEvent['pid'] ?? 0), (int) ($localEvent['id'] ?? 0)); + } + + $allDay = '1' !== (string) ($localEvent['addTime'] ?? ''); + $startAt = (int) ($localEvent['startTime'] ?? 0); + if ($startAt <= 0) { + $startAt = (int) ($localEvent['startDate'] ?? 0); + } + + $endAt = (int) ($localEvent['endTime'] ?? 0); + $usesEndDateFallback = false; + + if ($endAt <= 0) { + $endAt = (int) ($localEvent['endDate'] ?? 0); + $usesEndDateFallback = $endAt > 0; + } + + // Convert inclusive local endDate to exclusive CalDAV DTEND for all-day events. + if ($allDay && $usesEndDateFallback) { + $endAt += 86400; + } + + if ($endAt <= $startAt) { + $endAt = $allDay + ? (new DateTimeImmutable('@'.$startAt))->modify('+1 day')->getTimestamp() + : $startAt + 3600; + } + + return new RemoteEvent( + (string) ($localEvent['caldavHref'] ?? ''), + $uid, + (string) ($localEvent['caldavEtag'] ?? ''), + (int) ($localEvent['tstamp'] ?? 0), + '' !== trim((string) ($localEvent['title'] ?? '')), + '' !== $this->teaserToPlainText((string) ($localEvent['teaser'] ?? '')), + '' !== trim((string) ($localEvent['location'] ?? '')), + '' !== trim((string) ($localEvent['url'] ?? '')), + trim((string) ($localEvent['title'] ?? '')), + $this->teaserToPlainText((string) ($localEvent['teaser'] ?? '')), + trim((string) ($localEvent['location'] ?? '')), + '' !== trim((string) ($localEvent['url'] ?? '')) ? trim((string) $localEvent['url']) : null, + $startAt, + $endAt, + $allDay, + $timezone, + ); + } + + /** + * @return array + */ + public function applyRemoteToLocalFields(RemoteEvent $remoteEvent): array + { + $startDate = $remoteEvent->startAt; + $endDate = $remoteEvent->allDay + ? ($remoteEvent->endAt > $remoteEvent->startAt ? $remoteEvent->endAt - 86400 : $remoteEvent->startAt) + : $remoteEvent->endAt; + + if ($this->isSameDay($startDate, $endDate)) { + $endDate = null; + } + + $startTime = $remoteEvent->allDay ? $startDate : $remoteEvent->startAt; + $endTime = $remoteEvent->allDay + ? (null === $endDate ? $startDate : (int) $endDate) + : $remoteEvent->endAt; + + return [ + 'title' => $remoteEvent->title, + 'published' => '1', + 'startDate' => $startDate, + 'endDate' => $endDate, + 'startTime' => $startTime, + 'endTime' => $endTime, + 'addTime' => $remoteEvent->allDay ? 0 : 1, + 'teaser' => $this->plainTextToTeaserHtml($remoteEvent->description), + 'location' => $remoteEvent->location, + 'url' => (string) ($remoteEvent->url ?? ''), + ]; + } + + /** + * @param array $dataset + * + * @return array + */ + private function normalize(array $dataset): array + { + $normalized = [ + 'title' => trim((string) ($dataset['title'] ?? '')), + 'start' => (int) ($dataset['start'] ?? 0), + 'end' => (int) ($dataset['end'] ?? 0), + 'allDay' => (bool) ($dataset['allDay'] ?? false), + 'description' => str_replace(["\r\n", "\r"], "\n", trim((string) ($dataset['description'] ?? ''))), + 'location' => trim((string) ($dataset['location'] ?? '')), + 'url' => trim((string) ($dataset['url'] ?? '')), + ]; + + if ($normalized['end'] <= $normalized['start']) { + $normalized['end'] = $normalized['allDay'] ? $normalized['start'] + 86400 : $normalized['start'] + 3600; + } + + return $normalized; + } + + private function slugifyTitle(string $title): string + { + $normalized = trim($title); + if ('' === $normalized) { + return ''; + } + + $transliterated = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized); + if (false !== $transliterated) { + $normalized = $transliterated; + } + + $normalized = strtolower($normalized); + $normalized = preg_replace('/[^a-z0-9]+/', '_', $normalized) ?? ''; + + return trim($normalized, '_'); + } + + private function plainTextToTeaserHtml(string $text): string + { + $normalized = str_replace(["\r\n", "\r"], "\n", trim($text)); + if ('' === $normalized) { + return ''; + } + + $paragraphs = preg_split('/\n{2,}/', $normalized) ?: []; + $htmlParagraphs = []; + + foreach ($paragraphs as $paragraph) { + $escaped = htmlspecialchars(trim($paragraph), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + if ('' === $escaped) { + continue; + } + + $htmlParagraphs[] = '

'.str_replace("\n", '
', $escaped).'

'; + } + + return implode("\n", $htmlParagraphs); + } + + private function teaserToPlainText(string $teaser): string + { + $text = str_replace(["\r\n", "\r"], "\n", $teaser); + $text = preg_replace('//i', "\n", $text) ?? $text; + $text = preg_replace('/<\/p\s*>/i', "\n\n", $text) ?? $text; + $text = preg_replace('/]*>/i', '', $text) ?? $text; + $text = strip_tags($text); + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + return trim($text); + } + + private function isSameDay(int $leftTimestamp, int $rightTimestamp): bool + { + return gmdate('Ymd', $leftTimestamp) === gmdate('Ymd', $rightTimestamp); + } +} diff --git a/src/Sync/SyncHashGenerator.php b/src/Sync/SyncHashGenerator.php new file mode 100644 index 0000000..fc52a97 --- /dev/null +++ b/src/Sync/SyncHashGenerator.php @@ -0,0 +1,18 @@ + $syncFields + */ + public function generate(array $syncFields): string + { + ksort($syncFields); + + return hash('sha256', json_encode($syncFields, JSON_THROW_ON_ERROR)); + } +} diff --git a/src/Sync/SyncResult.php b/src/Sync/SyncResult.php new file mode 100644 index 0000000..197f043 --- /dev/null +++ b/src/Sync/SyncResult.php @@ -0,0 +1,23 @@ +created += $other->created; + $this->updated += $other->updated; + $this->deleted += $other->deleted; + $this->skipped += $other->skipped; + $this->conflicts += $other->conflicts; + } +} diff --git a/src/Sync/SyncRunner.php b/src/Sync/SyncRunner.php new file mode 100644 index 0000000..fa73fa4 --- /dev/null +++ b/src/Sync/SyncRunner.php @@ -0,0 +1,95 @@ + + */ + public function run(?int $calendarId, string $direction, bool $dryRun): array + { + if (!in_array($direction, ['pull', 'push', 'both'], true)) { + throw new InvalidArgumentException(sprintf('Unsupported direction "%s".', $direction)); + } + + $configs = $this->configProvider->getSyncEnabledCalendars($calendarId); + $results = []; + $cleanupProcessedByCalendar = []; + + foreach ($configs as $config) { + $pullResult = new SyncResult(); + $pushResult = new SyncResult(); + + if ('push' === $direction || 'both' === $direction) { + $pushResult = $this->localToRemoteSynchronizer->synchronize($config, $dryRun); + } + + if ('pull' === $direction || 'both' === $direction) { + if (!isset($cleanupProcessedByCalendar[$config->calendarId])) { + $cleanupDeleted = $dryRun + ? $this->eventRepository->countRemoteImportedByCalendarExcludingUrls($config->calendarId, $config->selectedCalendarUrls) + : $this->eventRepository->deleteRemoteImportedByCalendarExcludingUrls($config->calendarId, $config->selectedCalendarUrls) + ; + + if (!$dryRun) { + $this->eventRepository->publishAllRemoteImported($config->calendarId); + } + + $cleanupProcessedByCalendar[$config->calendarId] = $cleanupDeleted; + } + + $pullResult = $this->remoteToLocalSynchronizer->synchronize($config, $dryRun); + + if (isset($cleanupProcessedByCalendar[$config->calendarId])) { + $pullResult->deleted += (int) $cleanupProcessedByCalendar[$config->calendarId]; + } + } + + $results[] = [ + 'calendarId' => $config->calendarId, + 'pull' => $pullResult, + 'push' => $pushResult, + ]; + + $this->logger->info('CalDAV calendar sync finished.', [ + 'calendarId' => $config->calendarId, + 'remoteCalendarUrl' => $config->caldavUrl, + 'direction' => $direction, + 'dryRun' => $dryRun, + 'pull' => [ + 'created' => $pullResult->created, + 'updated' => $pullResult->updated, + 'deleted' => $pullResult->deleted, + 'skipped' => $pullResult->skipped, + 'conflicts' => $pullResult->conflicts, + ], + 'push' => [ + 'created' => $pushResult->created, + 'updated' => $pushResult->updated, + 'deleted' => $pushResult->deleted, + 'skipped' => $pushResult->skipped, + 'conflicts' => $pushResult->conflicts, + ], + ]); + } + + return $results; + } +}