Release: CalDAV sync bundle hardening and LMW sync

This commit is contained in:
Jürgen Mummert
2026-03-27 22:16:48 +01:00
commit c6f63a56a9
36 changed files with 2993 additions and 0 deletions
+118
View File
@@ -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`
+29
View File
@@ -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
}
}
}
+17
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
<?php
declare(strict_types=1);
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
use Contao\CoreBundle\DataContainer\PaletteManipulator;
use Mummert\CalDavSyncBundle\Contao\Dca\CalendarDcaCallbacks;
$GLOBALS['TL_DCA']['tl_calendar']['palettes']['__selector__'][] = 'caldavSyncEnabled';
foreach (array_keys($GLOBALS['TL_DCA']['tl_calendar']['palettes'] ?? []) as $paletteName) {
if ('__selector__' === $paletteName) {
continue;
}
PaletteManipulator::create()
->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',
];
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use Contao\CoreBundle\DataContainer\PaletteManipulator;
foreach (array_keys($GLOBALS['TL_DCA']['tl_calendar_events']['palettes'] ?? []) as $paletteName) {
if ('__selector__' === $paletteName) {
continue;
}
PaletteManipulator::create()
->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 ''",
];
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_calendar']['caldav_legend'] = 'CalDAV-Synchronisation';
$GLOBALS['TL_LANG']['tl_calendar']['caldavSyncEnabled'] = ['CalDAV-Sync aktivieren', 'Aktiviert den 2-Way-Sync mit einem CalDAV-Kalender.'];
$GLOBALS['TL_LANG']['tl_calendar']['caldavUrl'] = ['CalDAV-URL', 'Vollstaendige URL zum CalDAV-Kalender.'];
$GLOBALS['TL_LANG']['tl_calendar']['caldavUsername'] = ['CalDAV-Benutzername', 'Benutzername fuer den CalDAV-Zugriff.'];
$GLOBALS['TL_LANG']['tl_calendar']['caldavPassword'] = ['CalDAV-Passwort', 'Passwort oder App-Passwort fuer den CalDAV-Zugriff.'];
$GLOBALS['TL_LANG']['tl_calendar']['caldavAuthorId'] = ['CalDAV-Author', 'Pflichtauswahl des Contao-Benutzers fuer neu importierte CalDAV-Events.'];
$GLOBALS['TL_LANG']['tl_calendar']['caldavTimezone'] = ['CalDAV-Zeitzone', 'IANA-Zeitzone wie Europe/Berlin. Optional, faellt auf UTC zurueck.'];
$GLOBALS['TL_LANG']['tl_calendar']['caldavCalendarHrefs'] = ['Remote-Kalender', 'Vom Server geladene CalDAV-Kalender. Mehrfachauswahl ist moeglich.'];
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_calendar_events']['caldav_legend'] = 'CalDAV-Metadaten';
$GLOBALS['TL_LANG']['tl_calendar_events']['caldavCalendarHref'] = ['CalDAV Kalender-URL', 'Technische Zuordnung zum Remote-Kalender.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['caldavUid'] = ['CalDAV UID', 'Technische UID aus der VEVENT-Struktur.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['caldavHref'] = ['CalDAV Href', 'Technischer Remote-Pfad der Ressource.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['caldavEtag'] = ['CalDAV ETag', 'Remote-ETag zur Aenderungserkennung.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['caldavSyncHash'] = ['Sync-Hash', 'SHA-256 ueber bidirektionale Sync-Felder.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['caldavLastSync'] = ['Letzter Sync', 'Unix-Zeitstempel des letzten erfolgreichen Sync.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['caldavOrigin'] = ['Sync-Ursprung', 'Markiert, ob Event lokal oder remote angelegt wurde.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['caldavSyncState'] = ['Sync-Status', 'Technischer Status fuer den Sync-Prozess.'];
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Parser;
use DOMDocument;
use DOMXPath;
use RuntimeException;
final class CalDavXmlParser
{
/**
* @return list<array{href:string, etag:string, calendarData:string}>
*/
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<array{href:string,displayName:string}>
*/
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;
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Parser;
use DateTimeImmutable;
use DateTimeZone;
use Mummert\CalDavSyncBundle\Sync\RemoteEvent;
use RuntimeException;
use Sabre\VObject\Reader;
final class ICalendarParser
{
public function parseEvent(string $href, string $etag, string $ics, string $defaultTimezone): ?RemoteEvent
{
$vcalendar = Reader::read($ics);
$components = $vcalendar->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;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Parser;
use DateTimeImmutable;
use DateTimeZone;
use Mummert\CalDavSyncBundle\Sync\RemoteEvent;
use Sabre\VObject\Component\VCalendar;
final class ICalendarSerializer
{
public function serializeEvent(RemoteEvent $event): string
{
$lastModifiedAt = $event->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();
}
}
+244
View File
@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Remote;
use Mummert\CalDavSyncBundle\CalDav\Parser\CalDavXmlParser;
use Mummert\CalDavSyncBundle\CalDav\Transport\CalDavTransportInterface;
use Psr\Log\LoggerInterface;
final readonly class RemoteCalendarLister
{
public function __construct(
private CalDavTransportInterface $transport,
private CalDavXmlParser $xmlParser,
private LoggerInterface $logger,
) {
}
/**
* @return array<string,string>
*/
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<array{href:string,displayName:string}>
*/
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 version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:current-user-principal />
</d:prop>
</d:propfind>
XML;
}
private function buildCalendarHomeSetPropfindBody(): string
{
return <<<'XML'
<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<c:calendar-home-set />
</d:prop>
</d:propfind>
XML;
}
private function buildCollectionPropfindBody(): string
{
return <<<'XML'
<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:displayname />
<d:resourcetype />
</d:prop>
</d:propfind>
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, '/');
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Remote;
use Mummert\CalDavSyncBundle\CalDav\Parser\CalDavXmlParser;
use Mummert\CalDavSyncBundle\CalDav\Parser\ICalendarParser;
use Mummert\CalDavSyncBundle\CalDav\Transport\CalDavTransportInterface;
use Mummert\CalDavSyncBundle\Config\CalendarSyncConfig;
use Mummert\CalDavSyncBundle\Sync\RemoteEvent;
use Psr\Log\LoggerInterface;
final readonly class RemoteCalendarReader
{
public function __construct(
private CalDavTransportInterface $transport,
private CalDavXmlParser $xmlParser,
private ICalendarParser $icalendarParser,
private LoggerInterface $logger,
) {
}
/**
* @return list<RemoteEvent>
*/
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 version="1.0" encoding="utf-8" ?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag />
<c:calendar-data />
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT" />
</c:comp-filter>
</c:filter>
</c:calendar-query>
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;
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Remote;
use Mummert\CalDavSyncBundle\CalDav\Parser\ICalendarSerializer;
use Mummert\CalDavSyncBundle\CalDav\Transport\CalDavTransportInterface;
use Mummert\CalDavSyncBundle\Config\CalendarSyncConfig;
use Mummert\CalDavSyncBundle\Sync\RemoteEvent;
use RuntimeException;
final readonly class RemoteCalendarWriter
{
public function __construct(
private CalDavTransportInterface $transport,
private ICalendarSerializer $serializer,
) {
}
/**
* @return array{href:string,etag:string}
*/
public function upsertEvent(CalendarSyncConfig $config, RemoteEvent $event, ?string $href, ?string $etag, bool $dryRun): array
{
$targetHref = $href ?? $this->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.'"';
}
}
+188
View File
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Transport;
use RuntimeException;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class CalDavTransport implements CalDavTransportInterface
{
public function __construct(private HttpClientInterface $httpClient)
{
}
public function propfind(string $url, string $username, string $password, string $body, int $depth = 1, array $headers = []): TransportResponse
{
$headers = array_merge(['Depth' => (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<string, string> $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, "\\\"");
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Transport;
interface CalDavTransportInterface
{
/**
* @param array<string, string> $headers
*/
public function propfind(string $url, string $username, string $password, string $body, int $depth = 1, array $headers = []): TransportResponse;
/**
* @param array<string, string> $headers
*/
public function report(string $url, string $username, string $password, string $body, int $depth = 1, array $headers = []): TransportResponse;
/**
* @param array<string, string> $headers
*/
public function get(string $url, string $username, string $password, array $headers = []): TransportResponse;
/**
* @param array<string, string> $headers
*/
public function put(string $url, string $username, string $password, string $body, array $headers = []): TransportResponse;
/**
* @param array<string, string> $headers
*/
public function delete(string $url, string $username, string $password, array $headers = []): TransportResponse;
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Transport;
final readonly class TransportResponse
{
/**
* @param array<string, string> $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;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class CalDavSyncBundle extends Bundle
{
public function getPath(): string
{
return dirname(__DIR__);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Command;
use Mummert\CalDavSyncBundle\Sync\SyncRunner;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'contao:caldav:sync', description: 'Synchronize Contao calendar events with CalDAV calendars.')]
final class CalDavSyncCommand extends Command
{
public function __construct(private readonly SyncRunner $syncRunner)
{
parent::__construct();
}
protected function configure(): void
{
$this
->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;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Config;
final readonly class CalendarSyncConfig
{
/**
* @param list<string> $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];
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Contao\Dca;
use Contao\DataContainer;
use Contao\Input;
use Contao\StringUtil;
use Contao\System;
use Mummert\CalDavSyncBundle\CalDav\Remote\RemoteCalendarLister;
use Psr\Log\LoggerInterface;
final class CalendarDcaCallbacks
{
/**
* @return array<string,string>
*/
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<string>
*/
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));
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Contao\Manager;
use Contao\CalendarBundle\ContaoCalendarBundle;
use Contao\CoreBundle\ContaoCoreBundle;
use Contao\ManagerPlugin\Bundle\BundlePluginInterface;
use Contao\ManagerPlugin\Bundle\Config\BundleConfig;
use Contao\ManagerPlugin\Bundle\Parser\ParserInterface;
use Mummert\CalDavSyncBundle\CalDavSyncBundle;
class Plugin implements BundlePluginInterface
{
public function getBundles(ParserInterface $parser): iterable
{
return [
BundleConfig::create(CalDavSyncBundle::class)
->setLoadAfter([ContaoCoreBundle::class, ContaoCalendarBundle::class]),
];
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
class CalDavSyncExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new YamlFileLoader($container, new FileLocator(dirname(__DIR__, 2).'/config'));
$loader->load('services.yaml');
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260327000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add CalDAV sync columns to tl_calendar and tl_calendar_events';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260327223000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add configured CalDAV author field to tl_calendar';
}
public function up(Schema $schema): void
{
$this->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');
}
}
@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Repository;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\ParameterType;
final readonly class ContaoCalendarEventRepository
{
public function __construct(private Connection $connection)
{
}
/**
* @return list<array<string, mixed>>
*/
public function findByCalendarId(int $calendarId): array
{
/** @var list<array<string, mixed>> $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<string> $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<string> $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<string,mixed> $data
*
* @return array<string,mixed>
*/
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;
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Repository;
use Doctrine\DBAL\Connection;
final readonly class ContaoCalendarRepository
{
public function __construct(private Connection $connection)
{
}
/**
* @return list<array<string, mixed>>
*/
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<array<string, mixed>> $rows */
$rows = $this->connection->fetchAllAssociative($sql, $params);
return $rows;
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Service;
use Contao\StringUtil;
use Mummert\CalDavSyncBundle\CalDav\Remote\RemoteCalendarLister;
use Mummert\CalDavSyncBundle\Config\CalendarSyncConfig;
use Mummert\CalDavSyncBundle\Repository\ContaoCalendarRepository;
final readonly class CalendarConfigProvider
{
public function __construct(
private ContaoCalendarRepository $calendarRepository,
private RemoteCalendarLister $remoteCalendarLister,
) {
}
/**
* @return list<CalendarSyncConfig>
*/
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<string,mixed> $row
*
* @return list<string>
*/
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));
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Sync;
final class EventMatchResolver
{
/**
* @param list<array<string,mixed>> $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;
}
}
+243
View File
@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Sync;
use Mummert\CalDavSyncBundle\CalDav\Remote\RemoteCalendarReader;
use Mummert\CalDavSyncBundle\CalDav\Remote\RemoteCalendarWriter;
use Mummert\CalDavSyncBundle\Config\CalendarSyncConfig;
use Mummert\CalDavSyncBundle\Repository\ContaoCalendarEventRepository;
use Psr\Log\LoggerInterface;
final readonly class LocalToRemoteSynchronizer
{
private const MODIFIED_TIME_SKEW_SECONDS = 120;
public function __construct(
private ContaoCalendarEventRepository $eventRepository,
private RemoteCalendarReader $remoteReader,
private RemoteCalendarWriter $remoteWriter,
private EventMatchResolver $matchResolver,
private SyncFieldExtractor $fieldExtractor,
private SyncHashGenerator $hashGenerator,
private LoggerInterface $logger,
) {
}
public function synchronize(CalendarSyncConfig $config, bool $dryRun = false): SyncResult
{
$result = new SyncResult();
$allLocalEvents = $this->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<RemoteEvent> $remoteEvents
*
* @return list<array<string,mixed>>
*/
private function buildRemotePseudoLocalRows(array $remoteEvents): array
{
$rows = [];
foreach ($remoteEvents as $remoteEvent) {
$rows[] = [
'caldavHref' => $remoteEvent->href,
'caldavUid' => $remoteEvent->uid,
];
}
return $rows;
}
/**
* @param list<RemoteEvent> $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);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Sync;
final readonly class RemoteEvent
{
public function __construct(
public string $href,
public string $uid,
public string $etag,
public int $lastModifiedAt,
public bool $hasTitle,
public bool $hasDescription,
public bool $hasLocation,
public bool $hasUrl,
public string $title,
public string $description,
public string $location,
public ?string $url,
public int $startAt,
public int $endAt,
public bool $allDay,
public string $timezone,
) {
}
}
+216
View File
@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Sync;
use Mummert\CalDavSyncBundle\CalDav\Remote\RemoteCalendarReader;
use Mummert\CalDavSyncBundle\Config\CalendarSyncConfig;
use Mummert\CalDavSyncBundle\Repository\ContaoCalendarEventRepository;
use Psr\Log\LoggerInterface;
final readonly class RemoteToLocalSynchronizer
{
private const MODIFIED_TIME_SKEW_SECONDS = 120;
public function __construct(
private RemoteCalendarReader $remoteReader,
private ContaoCalendarEventRepository $eventRepository,
private EventMatchResolver $matchResolver,
private SyncFieldExtractor $fieldExtractor,
private SyncHashGenerator $hashGenerator,
private LoggerInterface $logger,
) {
}
public function synchronize(CalendarSyncConfig $config, bool $dryRun = false): SyncResult
{
$result = new SyncResult();
$remoteEvents = $this->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);
}
}
+260
View File
@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Sync;
use DateTimeImmutable;
use DateTimeZone;
final class SyncFieldExtractor
{
public function generateAliasFromRemoteEvent(RemoteEvent $remoteEvent): string
{
$timezone = $remoteEvent->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<string,mixed>
*/
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<string,mixed>
*/
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<string,mixed>
*/
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<string,mixed> $dataset
*
* @return array<string,mixed>
*/
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[] = '<p>'.str_replace("\n", '<br>', $escaped).'</p>';
}
return implode("\n", $htmlParagraphs);
}
private function teaserToPlainText(string $teaser): string
{
$text = str_replace(["\r\n", "\r"], "\n", $teaser);
$text = preg_replace('/<br\s*\/?>/i', "\n", $text) ?? $text;
$text = preg_replace('/<\/p\s*>/i', "\n\n", $text) ?? $text;
$text = preg_replace('/<p\b[^>]*>/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);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Sync;
final class SyncHashGenerator
{
/**
* @param array<string,mixed> $syncFields
*/
public function generate(array $syncFields): string
{
ksort($syncFields);
return hash('sha256', json_encode($syncFields, JSON_THROW_ON_ERROR));
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Sync;
final class SyncResult
{
public int $created = 0;
public int $updated = 0;
public int $deleted = 0;
public int $skipped = 0;
public int $conflicts = 0;
public function add(self $other): void
{
$this->created += $other->created;
$this->updated += $other->updated;
$this->deleted += $other->deleted;
$this->skipped += $other->skipped;
$this->conflicts += $other->conflicts;
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Sync;
use InvalidArgumentException;
use Mummert\CalDavSyncBundle\Repository\ContaoCalendarEventRepository;
use Mummert\CalDavSyncBundle\Service\CalendarConfigProvider;
use Psr\Log\LoggerInterface;
final readonly class SyncRunner
{
public function __construct(
private CalendarConfigProvider $configProvider,
private RemoteToLocalSynchronizer $remoteToLocalSynchronizer,
private LocalToRemoteSynchronizer $localToRemoteSynchronizer,
private ContaoCalendarEventRepository $eventRepository,
private LoggerInterface $logger,
) {
}
/**
* @return array<int, array{calendarId:int,pull:SyncResult,push:SyncResult}>
*/
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;
}
}