2 Commits

Author SHA1 Message Date
Jürgen Mummert 6f0f0679cc Docs: convert README to internal technical reference 2026-03-28 17:01:58 +01:00
Jürgen Mummert c0d3bf4c82 Release: LMW delta sync hardening, CTAG windowing, calendar matching guard 2026-03-28 16:59:58 +01:00
15 changed files with 782 additions and 176 deletions
+141 -100
View File
@@ -1,118 +1,159 @@
# CalDAV Sync Bundle (Contao 5.7) # CalDAV Sync Bundle (Contao 5.7)
Produktionsreifes Grundgeruest fuer einen 2-Way-CalDAV-Sync (V1) zwischen `tl_calendar_events` und einem CalDAV-Kalender. Internes Bundle fuer 2-Way-CalDAV-Sync in Contao 5.7 (PHP 8.4).
Diese README ist bewusst technisch gehalten (Prinzip, Logik, Betriebs-Commands) und kein oeffentliches Tutorial.
## V1-Scope ## Umfang (V1)
- Kein RRULE/EXDATE/RECURRENCE-ID - Kein RRULE/EXDATE/RECURRENCE-ID-Support
- Kein Backend-Button, nur Command - Kein Backend-Button, Sync nur ueber Command/Cron
- Harte Loeschung bei fehlendem Gegenstueck - Harte Loeschung auf beiden Seiten, wenn das Gegenstueck fehlt
- Konfliktregel: remote gewinnt - Konfliktaufloesung pro Event ueber Last-Modified-Wins (mit Toleranz)
- Contao-only-Felder werden niemals remote geschrieben - Contao-only-Felder werden nicht in den Sync-Hash aufgenommen
## Verzeichnisstruktur ## Betriebsrelevante Konfiguration (tl_calendar)
```text Verwendete CalDAV-Felder pro Kalender:
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 - `caldavSyncEnabled`
- `caldavUrl`
- `caldavUsername`
- `caldavPassword`
- `caldavAuthorId`
- `caldavTimezone` (Fallback `UTC`)
- `caldavCalendarHrefs` (Mehrfachauswahl)
- `caldavPastSyncRange` (`none|all|1y|2y`)
- `caldavFutureSyncRange` (`all|1y|2y`)
- `caldavSyncCtags` (technischer Cache pro Remote-URL)
- `CalendarConfigProvider`: Liefert pro `tl_calendar` die Sync-Konfiguration. Mehrfachauswahl-Logik:
- `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 - Ein Contao-Kalender kann mehrere Remote-Kalender referenzieren.
- Beim Pull werden importierte Events aus abgewaehlten Remote-Kalendern entfernt.
Bidirektionale Felder (sync-relevant): ## Sync ausfuehren
- `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 ```bash
php bin/console contao:caldav:sync --direction=both php bin/console contao:caldav:sync --direction=both
php bin/console contao:caldav:sync --calendar=3 --direction=pull php bin/console contao:caldav:sync --calendar=4 --direction=pull
php bin/console contao:caldav:sync --direction=push --dry-run php bin/console contao:caldav:sync --direction=push --dry-run
``` ```
Optionen: Optionen:
- `--calendar=ID` - `--calendar=ID`: nur einen `tl_calendar` synchronisieren
- `--direction=pull|push|both` - `--direction=pull|push|both`: Richtung waehlen (Default `both`)
- `--dry-run` - `--dry-run`: keine Schreiboperationen lokal/remote
Ergaenzende Betriebs-Commands:
```bash
php vendor/bin/contao-console contao:migrate --no-interaction
php vendor/bin/contao-console cache:clear
```
## Sync-Verhalten im Detail
### Reihenfolge bei `--direction=both`
1. Push (lokal -> remote)
2. Pull (remote -> lokal)
### Delta-Sync (CTAG + ETAG)
- CTAG wird per `PROPFIND` gelesen und pro Kalender-URL in `caldavSyncCtags` gespeichert.
- Wenn CTAG unveraendert ist, wird Pull fruehzeitig uebersprungen.
- Delta-Inventar per `REPORT` (href + etag).
- `GET` nur fuer neue/geaenderte Remote-Objekte.
### Konfliktregel (Last-Modified-Wins)
- Lokal wird ueber `tl_calendar_events.tstamp` bewertet.
- Remote wird ueber `LAST-MODIFIED` bzw. `DTSTAMP` bewertet.
- Es gilt eine Toleranz von 120 Sekunden.
- Wenn Remote-Zeitstempel fehlen, wird konservativ lokal bevorzugt.
- Alle Vergleiche erfolgen auf Unix-Timestamps.
### Kalenderbindung beim Matching
- Events werden pro `tl_calendar_events.pid` geladen und nur in diesem Kalender verarbeitet.
- Matcher nutzt `caldavHref` und `caldavUid`, optional mit explizitem Guard auf erwartete `calendarId`.
- Zielkalender fuer Push wird ueber `caldavCalendarHref` auf die aktuelle Config-URL aufgeloest.
### Loeschverhalten
- Pull: lokale Gegenstuecke ohne Remote-Pendant werden geloescht.
- Push: Remote-Events ohne lokales Pendant werden geloescht.
- Zusaetzlich entfernt der Runner bei Pull importierte Events aus inzwischen abgewaehlten Remote-Kalendern.
- Bei aktivem Zeitfenster werden Events ausserhalb des Fensters nicht aktiv geloescht.
### Publikationsverhalten
- Remote-importierte Events werden auf `published = 1` gesetzt.
## Feldmapping
Bidirektional (hash-relevant):
- `title`
- `start`/`end`
- `all-day` (`addTime`)
- `description` <-> `teaser` (Plaintext/HTML-Konvertierung)
- `location`
- `url` (optional)
Hash-Regel:
- SyncHash basiert ausschliesslich auf den oben genannten bidirektionalen Feldern.
- Push erfolgt nur bei Hash-Aenderung (oder Initialzustand ohne gespeicherten Hash).
Technische Sync-Felder in `tl_calendar_events`:
- `caldavCalendarHref`
- `caldavUid`
- `caldavHref`
- `caldavEtag`
- `caldavSyncHash`
- `caldavLastSync`
- `caldavOrigin`
- `caldavSyncState`
Alias-Verhalten:
- Alias wird bei Remote-Neuanlage als `YYYY-MM-DD_slug` erzeugt.
- Maximale Laenge: 40 Zeichen.
- Kollisionen werden mit Suffix (`_2`, `_3`, ...) aufgeloest.
## Wichtige Architekturpunkte
- `SyncRunner`: Orchestrierung pro Kalender und Richtung
- `RemoteToLocalSynchronizer`: Pull + lokale Upserts/Deletes
- `LocalToRemoteSynchronizer`: Push + remote Upserts/Deletes
- `SyncFieldExtractor`: Mapping, Datumslogik, Teaser-Konvertierung, Aliasbildung
- `RemoteCalendarReader` / `RemoteCalendarWriter`: CalDAV Lesen/Schreiben
- `ContaoCalendarEventRepository`: DB-Zugriff inkl. schema-toleranter Writes
## Grenzen und Hinweise
- V1 behandelt keine Serienereignisse.
- Sync ist command-getrieben; ein Cronjob sollte das Command zyklisch starten.
- CalDAV-Passwort wird als Klartext fuer Authentifizierung verwendet.
## Kernkomponenten
- `SyncRunner`: Orchestrierung je Kalender und Richtung
- `LocalToRemoteSynchronizer`: Push + LMW + Hash-Guard + Deletes
- `RemoteToLocalSynchronizer`: Pull + LMW + Delta + Deletes
- `RemoteCalendarReader`: CTAG/REPORT/GET-Deltalogik
- `SyncFieldExtractor`: Feldmapping inkl. all-day-Umrechnung
## Troubleshooting
- Keine Kalender in der Mehrfachauswahl:
- URL, Benutzername, Passwort pruefen
- Server muss CalDAV-Discovery/PROPFIND erlauben
- Unerwartete Konflikte:
- `tstamp` lokal und `LAST-MODIFIED` remote vergleichen
- Zeitzonen-Setup im Kalender pruefen
- Schreibfehler beim Push:
- ETag-Precondition und Zugriffsrechte am CalDAV-Server pruefen
+26 -1
View File
@@ -19,7 +19,7 @@ foreach (array_keys($GLOBALS['TL_DCA']['tl_calendar']['palettes'] ?? []) as $pal
; ;
} }
$GLOBALS['TL_DCA']['tl_calendar']['subpalettes']['caldavSyncEnabled'] = 'caldavUrl,caldavUsername,caldavPassword,caldavAuthorId,caldavTimezone,caldavCalendarHrefs'; $GLOBALS['TL_DCA']['tl_calendar']['subpalettes']['caldavSyncEnabled'] = 'caldavUrl,caldavUsername,caldavPassword,caldavAuthorId,caldavTimezone,caldavCalendarHrefs,caldavPastSyncRange,caldavFutureSyncRange,caldavSyncCtags';
$GLOBALS['TL_DCA']['tl_calendar']['fields']['caldavSyncEnabled'] = [ $GLOBALS['TL_DCA']['tl_calendar']['fields']['caldavSyncEnabled'] = [
'exclude' => true, 'exclude' => true,
@@ -74,3 +74,28 @@ $GLOBALS['TL_DCA']['tl_calendar']['fields']['caldavCalendarHrefs'] = [
'sql' => 'blob NULL', 'sql' => 'blob NULL',
]; ];
$GLOBALS['TL_DCA']['tl_calendar']['fields']['caldavPastSyncRange'] = [
'exclude' => true,
'inputType' => 'select',
'options' => ['none', 'all', '1y', '2y'],
'reference' => &$GLOBALS['TL_LANG']['tl_calendar']['caldavPastSyncRangeOptions'],
'eval' => ['mandatory' => true, 'includeBlankOption' => false, 'tl_class' => 'w50'],
'sql' => "varchar(8) NOT NULL default '1y'",
];
$GLOBALS['TL_DCA']['tl_calendar']['fields']['caldavFutureSyncRange'] = [
'exclude' => true,
'inputType' => 'select',
'options' => ['all', '1y', '2y'],
'reference' => &$GLOBALS['TL_LANG']['tl_calendar']['caldavFutureSyncRangeOptions'],
'eval' => ['mandatory' => true, 'includeBlankOption' => false, 'tl_class' => 'w50'],
'sql' => "varchar(8) NOT NULL default '2y'",
];
$GLOBALS['TL_DCA']['tl_calendar']['fields']['caldavSyncCtags'] = [
'exclude' => true,
'inputType' => 'textarea',
'eval' => ['readonly' => true, 'disabled' => true, 'doNotCopy' => true, 'tl_class' => 'clr long'],
'sql' => 'blob NULL',
];
+8 -8
View File
@@ -26,55 +26,55 @@ foreach (array_keys($GLOBALS['TL_DCA']['tl_calendar_events']['palettes'] ?? [])
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavCalendarHref'] = [ $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavCalendarHref'] = [
'exclude' => true, 'exclude' => true,
'inputType' => 'text', 'inputType' => 'text',
'eval' => ['maxlength' => 2048, 'readonly' => true, 'doNotCopy' => true, 'tl_class' => 'clr'], 'eval' => ['maxlength' => 2048, 'readonly' => true, 'disabled' => true, 'doNotCopy' => true, 'tl_class' => 'clr'],
'sql' => "varchar(2048) NOT NULL default ''", 'sql' => "varchar(2048) NOT NULL default ''",
]; ];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavUid'] = [ $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavUid'] = [
'exclude' => true, 'exclude' => true,
'inputType' => 'text', 'inputType' => 'text',
'eval' => ['maxlength' => 255, 'readonly' => true, 'doNotCopy' => true, 'tl_class' => 'clr'], 'eval' => ['maxlength' => 255, 'readonly' => true, 'disabled' => true, 'doNotCopy' => true, 'tl_class' => 'clr'],
'sql' => "varchar(255) NOT NULL default ''", 'sql' => "varchar(255) NOT NULL default ''",
]; ];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavHref'] = [ $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavHref'] = [
'exclude' => true, 'exclude' => true,
'inputType' => 'text', 'inputType' => 'text',
'eval' => ['maxlength' => 2048, 'readonly' => true, 'doNotCopy' => true, 'tl_class' => 'clr'], 'eval' => ['maxlength' => 2048, 'readonly' => true, 'disabled' => true, 'doNotCopy' => true, 'tl_class' => 'clr'],
'sql' => "varchar(2048) NOT NULL default ''", 'sql' => "varchar(2048) NOT NULL default ''",
]; ];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavEtag'] = [ $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavEtag'] = [
'exclude' => true, 'exclude' => true,
'inputType' => 'text', 'inputType' => 'text',
'eval' => ['maxlength' => 255, 'readonly' => true, 'doNotCopy' => true, 'tl_class' => 'clr'], 'eval' => ['maxlength' => 255, 'readonly' => true, 'disabled' => true, 'doNotCopy' => true, 'tl_class' => 'clr'],
'sql' => "varchar(255) NOT NULL default ''", 'sql' => "varchar(255) NOT NULL default ''",
]; ];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavSyncHash'] = [ $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavSyncHash'] = [
'exclude' => true, 'exclude' => true,
'inputType' => 'text', 'inputType' => 'text',
'eval' => ['maxlength' => 64, 'readonly' => true, 'doNotCopy' => true, 'tl_class' => 'clr'], 'eval' => ['maxlength' => 64, 'readonly' => true, 'disabled' => true, 'doNotCopy' => true, 'tl_class' => 'clr'],
'sql' => "varchar(64) NOT NULL default ''", 'sql' => "varchar(64) NOT NULL default ''",
]; ];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavLastSync'] = [ $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavLastSync'] = [
'exclude' => true, 'exclude' => true,
'inputType' => 'text', 'inputType' => 'text',
'eval' => ['readonly' => true, 'doNotCopy' => true, 'tl_class' => 'w50'], 'eval' => ['readonly' => true, 'disabled' => true, 'doNotCopy' => true, 'tl_class' => 'w50'],
'sql' => "int(10) unsigned NOT NULL default 0", 'sql' => "int(10) unsigned NOT NULL default 0",
]; ];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavOrigin'] = [ $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavOrigin'] = [
'exclude' => true, 'exclude' => true,
'inputType' => 'text', 'inputType' => 'text',
'eval' => ['maxlength' => 16, 'readonly' => true, 'doNotCopy' => true, 'tl_class' => 'w50'], 'eval' => ['maxlength' => 16, 'readonly' => true, 'disabled' => true, 'doNotCopy' => true, 'tl_class' => 'w50'],
'sql' => "varchar(16) NOT NULL default ''", 'sql' => "varchar(16) NOT NULL default ''",
]; ];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavSyncState'] = [ $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavSyncState'] = [
'exclude' => true, 'exclude' => true,
'inputType' => 'text', 'inputType' => 'text',
'eval' => ['maxlength' => 32, 'readonly' => true, 'doNotCopy' => true, 'tl_class' => 'w50'], 'eval' => ['maxlength' => 32, 'readonly' => true, 'disabled' => true, 'doNotCopy' => true, 'tl_class' => 'w50'],
'sql' => "varchar(32) NOT NULL default ''", 'sql' => "varchar(32) NOT NULL default ''",
]; ];
+14
View File
@@ -10,3 +10,17 @@ $GLOBALS['TL_LANG']['tl_calendar']['caldavPassword'] = ['CalDAV-Passwort', 'Pass
$GLOBALS['TL_LANG']['tl_calendar']['caldavAuthorId'] = ['CalDAV-Author', 'Pflichtauswahl des Contao-Benutzers fuer neu importierte CalDAV-Events.']; $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']['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.']; $GLOBALS['TL_LANG']['tl_calendar']['caldavCalendarHrefs'] = ['Remote-Kalender', 'Vom Server geladene CalDAV-Kalender. Mehrfachauswahl ist moeglich.'];
$GLOBALS['TL_LANG']['tl_calendar']['caldavPastSyncRange'] = ['Vergangene Events synchronisieren', 'Legt fest, wie weit in die Vergangenheit Events beim Sync beruecksichtigt werden.'];
$GLOBALS['TL_LANG']['tl_calendar']['caldavPastSyncRangeOptions'] = [
'none' => 'Keine',
'all' => 'Alle',
'1y' => 'Bis 1 Jahr zurueck',
'2y' => 'Bis 2 Jahre zurueck',
];
$GLOBALS['TL_LANG']['tl_calendar']['caldavFutureSyncRange'] = ['Zukuenftige Events synchronisieren', 'Legt fest, wie weit in die Zukunft Events beim Sync beruecksichtigt werden.'];
$GLOBALS['TL_LANG']['tl_calendar']['caldavFutureSyncRangeOptions'] = [
'all' => 'Alle',
'1y' => 'Bis 1 Jahr voraus',
'2y' => 'Bis 2 Jahre voraus',
];
$GLOBALS['TL_LANG']['tl_calendar']['caldavSyncCtags'] = ['Sync-CTAG Cache', 'Technischer Speicher fuer zuletzt gelesene Collection Tags pro Remote-Kalender.'];
+52
View File
@@ -109,6 +109,57 @@ final class CalDavXmlParser
return $collections; return $collections;
} }
/**
* @return array<string,string>
*/
public function parseCalendarHrefEtags(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:');
$responseNodes = $xpath->query('/d:multistatus/d:response');
if (false === $responseNodes) {
return [];
}
$items = [];
foreach ($responseNodes as $responseNode) {
$href = trim((string) $xpath->evaluate('string(d:href)', $responseNode));
if ('' === $href) {
continue;
}
$etag = trim((string) $xpath->evaluate('string(d:propstat/d:prop/d:getetag)', $responseNode));
$items[$href] = trim($etag, '"');
}
return $items;
}
public function parseCollectionTag(string $xml): ?string
{
$xpath = $this->createXPath($xml);
$calendarServerTag = $this->stringOrNull($xpath->evaluate('string(/d:multistatus/d:response/d:propstat/d:prop/cs:getctag)'));
if (null !== $calendarServerTag) {
return $calendarServerTag;
}
return $this->stringOrNull($xpath->evaluate('string(/d:multistatus/d:response/d:propstat/d:prop/d:getctag)'));
}
private function createXPath(string $xml): DOMXPath private function createXPath(string $xml): DOMXPath
{ {
if ('' === trim($xml)) { if ('' === trim($xml)) {
@@ -125,6 +176,7 @@ final class CalDavXmlParser
$xpath = new DOMXPath($document); $xpath = new DOMXPath($document);
$xpath->registerNamespace('d', 'DAV:'); $xpath->registerNamespace('d', 'DAV:');
$xpath->registerNamespace('c', 'urn:ietf:params:xml:ns:caldav'); $xpath->registerNamespace('c', 'urn:ietf:params:xml:ns:caldav');
$xpath->registerNamespace('cs', 'http://calendarserver.org/ns/');
return $xpath; return $xpath;
} }
+109 -21
View File
@@ -21,16 +21,23 @@ final readonly class RemoteCalendarReader
) { ) {
} }
/** public function readEvents(CalendarSyncConfig $config, array $knownHrefEtags = [], bool $allowCollectionTagShortCircuit = false): RemoteCalendarSyncData
* @return list<RemoteEvent>
*/
public function readEvents(CalendarSyncConfig $config): array
{ {
$collectionTag = $this->fetchCollectionTag($config);
if (
$allowCollectionTagShortCircuit
&& null !== $collectionTag
&& $config->hasStoredCollectionTag()
&& $collectionTag === $config->storedCollectionTag
) {
return new RemoteCalendarSyncData([], $knownHrefEtags, $collectionTag, true);
}
$response = $this->transport->report( $response = $this->transport->report(
$config->caldavUrl, $config->caldavUrl,
$config->caldavUsername, $config->caldavUsername,
$config->caldavPassword, $config->caldavPassword,
$this->buildCalendarQueryBody(), $this->buildHrefEtagCalendarQueryBody($config),
1, 1,
['Content-Type' => 'application/xml; charset=utf-8'], ['Content-Type' => 'application/xml; charset=utf-8'],
); );
@@ -42,26 +49,44 @@ final readonly class RemoteCalendarReader
'statusCode' => $response->statusCode, 'statusCode' => $response->statusCode,
]); ]);
return []; return new RemoteCalendarSyncData([], [], $collectionTag, false);
} }
$parsedItems = $this->xmlParser->parseCalendarMultistatus($response->body); $hrefEtags = $this->xmlParser->parseCalendarHrefEtags($response->body);
$events = []; $events = [];
foreach ($parsedItems as $item) { foreach ($hrefEtags as $href => $etag) {
$event = $this->icalendarParser->parseEvent( $knownEtag = trim((string) ($knownHrefEtags[$href] ?? ''));
$item['href'], if ('' !== $knownEtag && $knownEtag === trim((string) $etag)) {
$item['etag'], continue;
$item['calendarData'],
$config->timezoneOrDefault(),
);
if (null !== $event) {
$events[] = $event;
} }
$event = $this->readEventByHref($config, $href);
if (null === $event) {
continue;
}
$events[] = new RemoteEvent(
$event->href,
$event->uid,
'' !== trim((string) $etag) ? (string) $etag : $event->etag,
$event->lastModifiedAt,
$event->hasTitle,
$event->hasDescription,
$event->hasLocation,
$event->hasUrl,
$event->title,
$event->description,
$event->location,
$event->url,
$event->startAt,
$event->endAt,
$event->allDay,
$event->timezone,
);
} }
return $events; return new RemoteCalendarSyncData($events, $hrefEtags, $collectionTag, false);
} }
public function readEventByHref(CalendarSyncConfig $config, string $href): ?RemoteEvent public function readEventByHref(CalendarSyncConfig $config, string $href): ?RemoteEvent
@@ -91,24 +116,87 @@ final readonly class RemoteCalendarReader
); );
} }
private function buildCalendarQueryBody(): string private function buildHrefEtagCalendarQueryBody(CalendarSyncConfig $config): string
{
$template = $this->buildCalendarQueryBodyTemplate();
$timeRange = '';
if ($config->hasTimeWindow()) {
$attrs = [];
if (null !== $config->syncFromTimestamp) {
$attrs[] = sprintf('start="%s"', gmdate('Ymd\THis\Z', $config->syncFromTimestamp));
}
if (null !== $config->syncUntilTimestamp) {
$attrs[] = sprintf('end="%s"', gmdate('Ymd\THis\Z', $config->syncUntilTimestamp));
}
if ([] !== $attrs) {
$timeRange = ' <c:time-range '.implode(' ', $attrs).' />'."\n";
}
}
return str_replace('TIME_RANGE_PLACEHOLDER', $timeRange, $template);
}
private function buildCalendarQueryBodyTemplate(): string
{ {
return <<<'XML' return <<<'XML'
<?xml version="1.0" encoding="utf-8" ?> <?xml version="1.0" encoding="utf-8" ?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> <c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop> <d:prop>
<d:getetag /> <d:getetag />
<c:calendar-data />
</d:prop> </d:prop>
<c:filter> <c:filter>
<c:comp-filter name="VCALENDAR"> <c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT" /> <c:comp-filter name="VEVENT">
TIME_RANGE_PLACEHOLDER </c:comp-filter>
</c:comp-filter> </c:comp-filter>
</c:filter> </c:filter>
</c:calendar-query> </c:calendar-query>
XML; XML;
} }
private function fetchCollectionTag(CalendarSyncConfig $config): ?string
{
try {
$response = $this->transport->propfind(
$config->caldavUrl,
$config->caldavUsername,
$config->caldavPassword,
$this->buildCollectionTagPropfindBody(),
0,
['Content-Type' => 'application/xml; charset=utf-8'],
);
} catch (\Throwable) {
return null;
}
if (!in_array($response->statusCode, [200, 207], true)) {
return null;
}
try {
return $this->xmlParser->parseCollectionTag($response->body);
} catch (\Throwable) {
return null;
}
}
private function buildCollectionTagPropfindBody(): string
{
return <<<'XML'
<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
<d:prop>
<cs:getctag />
<d:getctag />
</d:prop>
</d:propfind>
XML;
}
private function absoluteUrl(string $calendarUrl, string $href): string private function absoluteUrl(string $calendarUrl, string $href): string
{ {
if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) { if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) {
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Remote;
use Mummert\CalDavSyncBundle\Sync\RemoteEvent;
final readonly class RemoteCalendarSyncData
{
/**
* @param list<RemoteEvent> $events
* @param array<string,string> $hrefEtags
*/
public function __construct(
public array $events,
public array $hrefEtags,
public ?string $collectionTag,
public bool $collectionTagUnchanged,
) {
}
}
+13
View File
@@ -17,6 +17,9 @@ final readonly class CalendarSyncConfig
public int $caldavAuthorId, public int $caldavAuthorId,
public string $caldavTimezone, public string $caldavTimezone,
public array $selectedCalendarUrls, public array $selectedCalendarUrls,
public string $storedCollectionTag,
public ?int $syncFromTimestamp,
public ?int $syncUntilTimestamp,
) { ) {
} }
@@ -53,4 +56,14 @@ final readonly class CalendarSyncConfig
return $this->selectedCalendarUrls[0]; return $this->selectedCalendarUrls[0];
} }
public function hasStoredCollectionTag(): bool
{
return '' !== trim($this->storedCollectionTag);
}
public function hasTimeWindow(): bool
{
return null !== $this->syncFromTimestamp || null !== $this->syncUntilTimestamp;
}
} }
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260328090000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add CTAG cache and sync window fields to tl_calendar';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE tl_calendar ADD caldavSyncCtags BLOB DEFAULT NULL');
$this->addSql("ALTER TABLE tl_calendar ADD caldavPastSyncRange VARCHAR(8) NOT NULL DEFAULT '1y'");
$this->addSql("ALTER TABLE tl_calendar ADD caldavFutureSyncRange VARCHAR(8) NOT NULL DEFAULT '2y'");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE tl_calendar DROP caldavFutureSyncRange');
$this->addSql('ALTER TABLE tl_calendar DROP caldavPastSyncRange');
$this->addSql('ALTER TABLE tl_calendar DROP caldavSyncCtags');
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\Repository; namespace Mummert\CalDavSyncBundle\Repository;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use JsonException;
final readonly class ContaoCalendarRepository final readonly class ContaoCalendarRepository
{ {
@@ -32,4 +33,46 @@ final readonly class ContaoCalendarRepository
return $rows; return $rows;
} }
public function updateCollectionTagForCalendarUrl(int $calendarId, string $calendarUrl, ?string $collectionTag): void
{
$row = $this->connection->fetchAssociative(
'SELECT caldavSyncCtags FROM tl_calendar WHERE id = :id',
['id' => $calendarId],
);
$existingMap = [];
$raw = trim((string) ($row['caldavSyncCtags'] ?? ''));
if ('' !== $raw) {
try {
$decoded = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
if (is_array($decoded)) {
$existingMap = $decoded;
}
} catch (JsonException) {
$existingMap = [];
}
}
$normalizedUrl = trim($calendarUrl);
if ('' === $normalizedUrl) {
return;
}
$normalizedTag = trim((string) $collectionTag);
if ('' === $normalizedTag) {
unset($existingMap[$normalizedUrl]);
} else {
$existingMap[$normalizedUrl] = $normalizedTag;
}
try {
$encoded = json_encode($existingMap, JSON_THROW_ON_ERROR);
} catch (JsonException) {
$encoded = '{}';
}
$this->connection->update('tl_calendar', ['caldavSyncCtags' => $encoded], ['id' => $calendarId]);
}
} }
+68
View File
@@ -47,6 +47,9 @@ final readonly class CalendarConfigProvider
} }
foreach ($selectedCalendarUrls as $selectedCalendarUrl) { foreach ($selectedCalendarUrls as $selectedCalendarUrl) {
$ctagMap = $this->resolveStoredCollectionTagMap($row);
[$syncFromTimestamp, $syncUntilTimestamp] = $this->resolveSyncWindow($row);
$configs[] = new CalendarSyncConfig( $configs[] = new CalendarSyncConfig(
(int) $row['id'], (int) $row['id'],
$selectedCalendarUrl, $selectedCalendarUrl,
@@ -55,6 +58,9 @@ final readonly class CalendarConfigProvider
(int) ($row['caldavAuthorId'] ?? 0), (int) ($row['caldavAuthorId'] ?? 0),
(string) ($row['caldavTimezone'] ?? ''), (string) ($row['caldavTimezone'] ?? ''),
$selectedCalendarUrls, $selectedCalendarUrls,
(string) ($ctagMap[$selectedCalendarUrl] ?? ''),
$syncFromTimestamp,
$syncUntilTimestamp,
); );
} }
} }
@@ -81,4 +87,66 @@ final readonly class CalendarConfigProvider
return array_values(array_unique($urls)); return array_values(array_unique($urls));
} }
/**
* @param array<string,mixed> $row
*
* @return array<string,string>
*/
private function resolveStoredCollectionTagMap(array $row): array
{
$raw = trim((string) ($row['caldavSyncCtags'] ?? ''));
if ('' === $raw) {
return [];
}
try {
$decoded = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
} catch (\Throwable) {
return [];
}
if (!is_array($decoded)) {
return [];
}
$map = [];
foreach ($decoded as $url => $ctag) {
$normalizedUrl = trim((string) $url);
$normalizedTag = trim((string) $ctag);
if ('' !== $normalizedUrl && '' !== $normalizedTag) {
$map[$normalizedUrl] = $normalizedTag;
}
}
return $map;
}
/**
* @param array<string,mixed> $row
*
* @return array{0:?int,1:?int}
*/
private function resolveSyncWindow(array $row): array
{
$pastRange = trim((string) ($row['caldavPastSyncRange'] ?? '1y'));
$futureRange = trim((string) ($row['caldavFutureSyncRange'] ?? '2y'));
$now = time();
$fromTimestamp = match ($pastRange) {
'none' => $now,
'all' => null,
'2y' => $now - 2 * 365 * 86400,
default => $now - 365 * 86400,
};
$untilTimestamp = match ($futureRange) {
'all' => null,
'1y' => $now + 365 * 86400,
default => $now + 2 * 365 * 86400,
};
return [$fromTimestamp, $untilTimestamp];
}
} }
+25 -1
View File
@@ -9,15 +9,23 @@ final class EventMatchResolver
/** /**
* @param list<array<string,mixed>> $localEvents * @param list<array<string,mixed>> $localEvents
*/ */
public function resolve(array $localEvents, RemoteEvent $remoteEvent): ?array public function resolve(array $localEvents, RemoteEvent $remoteEvent, ?int $expectedCalendarId = null): ?array
{ {
foreach ($localEvents as $localEvent) { foreach ($localEvents as $localEvent) {
if (!$this->matchesExpectedCalendar($localEvent, $expectedCalendarId)) {
continue;
}
if ('' !== (string) ($localEvent['caldavHref'] ?? '') && (string) $localEvent['caldavHref'] === $remoteEvent->href) { if ('' !== (string) ($localEvent['caldavHref'] ?? '') && (string) $localEvent['caldavHref'] === $remoteEvent->href) {
return $localEvent; return $localEvent;
} }
} }
foreach ($localEvents as $localEvent) { foreach ($localEvents as $localEvent) {
if (!$this->matchesExpectedCalendar($localEvent, $expectedCalendarId)) {
continue;
}
if ('' !== (string) ($localEvent['caldavUid'] ?? '') && (string) $localEvent['caldavUid'] === $remoteEvent->uid) { if ('' !== (string) ($localEvent['caldavUid'] ?? '') && (string) $localEvent['caldavUid'] === $remoteEvent->uid) {
return $localEvent; return $localEvent;
} }
@@ -25,4 +33,20 @@ final class EventMatchResolver
return null; return null;
} }
/**
* @param array<string,mixed> $row
*/
private function matchesExpectedCalendar(array $row, ?int $expectedCalendarId): bool
{
if (null === $expectedCalendarId) {
return true;
}
if (!array_key_exists('pid', $row)) {
return true;
}
return (int) $row['pid'] === $expectedCalendarId;
}
} }
+60 -9
View File
@@ -34,13 +34,30 @@ final readonly class LocalToRemoteSynchronizer
$allLocalEvents, $allLocalEvents,
fn (array $event): bool => $config->shouldManageEventForThisRemoteCalendar((string) ($event['caldavCalendarHref'] ?? '')), fn (array $event): bool => $config->shouldManageEventForThisRemoteCalendar((string) ($event['caldavCalendarHref'] ?? '')),
)); ));
$remoteEvents = $this->remoteReader->readEvents($config);
$remotePseudoLocalRows = $this->buildRemotePseudoLocalRows($remoteEvents); $knownHrefEtags = [];
foreach ($localEvents as $localEvent) {
$href = trim((string) ($localEvent['caldavHref'] ?? ''));
if ('' === $href) {
continue;
}
$knownHrefEtags[$href] = trim((string) ($localEvent['caldavEtag'] ?? ''));
}
$remoteData = $this->remoteReader->readEvents($config, $knownHrefEtags, false);
$remoteEvents = $remoteData->events;
$remotePseudoLocalRows = $this->buildRemotePseudoLocalRows($remoteEvents, $remoteData->hrefEtags);
$localHrefs = []; $localHrefs = [];
$localUids = []; $localUids = [];
foreach ($localEvents as $localEvent) { foreach ($localEvents as $localEvent) {
if (!$this->isEventWithinConfiguredWindow($localEvent, $config)) {
++$result->skipped;
continue;
}
$targetCalendarUrl = $config->resolveTargetCalendarForLocalEvent((string) ($localEvent['caldavCalendarHref'] ?? '')); $targetCalendarUrl = $config->resolveTargetCalendarForLocalEvent((string) ($localEvent['caldavCalendarHref'] ?? ''));
if (null === $targetCalendarUrl || $targetCalendarUrl !== $config->caldavUrl) { if (null === $targetCalendarUrl || $targetCalendarUrl !== $config->caldavUrl) {
++$result->skipped; ++$result->skipped;
@@ -59,7 +76,7 @@ final readonly class LocalToRemoteSynchronizer
$localUids[$uid] = true; $localUids[$uid] = true;
} }
$localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent); $localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent, $config->timezoneOrDefault());
$currentHash = $this->hashGenerator->generate($localSyncFields); $currentHash = $this->hashGenerator->generate($localSyncFields);
$storedHash = (string) ($localEvent['caldavSyncHash'] ?? ''); $storedHash = (string) ($localEvent['caldavSyncHash'] ?? '');
$localChanged = '' === $storedHash || $storedHash !== $currentHash; $localChanged = '' === $storedHash || $storedHash !== $currentHash;
@@ -69,7 +86,7 @@ final readonly class LocalToRemoteSynchronizer
continue; continue;
} }
$remoteMatch = $this->matchResolver->resolve($remotePseudoLocalRows, $this->fieldExtractor->toRemoteEvent($localEvent, $config->timezoneOrDefault())); $remoteMatch = $this->matchResolver->resolve($remotePseudoLocalRows, $this->fieldExtractor->toRemoteEvent($localEvent, $config->timezoneOrDefault()), $config->calendarId);
$matchingRemoteEvent = $this->resolveRemoteFromMatch($remoteEvents, $remoteMatch); $matchingRemoteEvent = $this->resolveRemoteFromMatch($remoteEvents, $remoteMatch);
if (null !== $matchingRemoteEvent && '' !== $storedHash) { if (null !== $matchingRemoteEvent && '' !== $storedHash) {
@@ -164,19 +181,19 @@ final readonly class LocalToRemoteSynchronizer
} }
$localUids[$payloadEvent->uid] = true; $localUids[$payloadEvent->uid] = true;
if (null === $matchingRemoteEvent) { if (null === $matchingRemoteEvent && null === $remoteMatch) {
++$result->created; ++$result->created;
} else { } else {
++$result->updated; ++$result->updated;
} }
} }
foreach ($remoteEvents as $remoteEvent) { foreach ($remoteData->hrefEtags as $remoteHref => $remoteEtag) {
if (isset($localHrefs[$remoteEvent->href]) || isset($localUids[$remoteEvent->uid])) { if (isset($localHrefs[$remoteHref])) {
continue; continue;
} }
$this->remoteWriter->deleteEvent($config, $remoteEvent->href, $remoteEvent->etag, $dryRun); $this->remoteWriter->deleteEvent($config, $remoteHref, $remoteEtag, $dryRun);
++$result->deleted; ++$result->deleted;
} }
@@ -185,13 +202,21 @@ final readonly class LocalToRemoteSynchronizer
/** /**
* @param list<RemoteEvent> $remoteEvents * @param list<RemoteEvent> $remoteEvents
* @param array<string,string> $hrefEtags
* *
* @return list<array<string,mixed>> * @return list<array<string,mixed>>
*/ */
private function buildRemotePseudoLocalRows(array $remoteEvents): array private function buildRemotePseudoLocalRows(array $remoteEvents, array $hrefEtags): array
{ {
$rows = []; $rows = [];
foreach ($hrefEtags as $href => $_etag) {
$rows[] = [
'caldavHref' => $href,
'caldavUid' => '',
];
}
foreach ($remoteEvents as $remoteEvent) { foreach ($remoteEvents as $remoteEvent) {
$rows[] = [ $rows[] = [
'caldavHref' => $remoteEvent->href, 'caldavHref' => $remoteEvent->href,
@@ -240,4 +265,30 @@ final readonly class LocalToRemoteSynchronizer
return $localModifiedAt > ($remoteModifiedAt + self::MODIFIED_TIME_SKEW_SECONDS); return $localModifiedAt > ($remoteModifiedAt + self::MODIFIED_TIME_SKEW_SECONDS);
} }
private function isEventWithinConfiguredWindow(array $localEvent, CalendarSyncConfig $config): bool
{
if (!$config->hasTimeWindow()) {
return true;
}
$start = (int) ($localEvent['startTime'] ?? 0);
if ($start <= 0) {
$start = (int) ($localEvent['startDate'] ?? 0);
}
if ($start <= 0) {
return true;
}
if (null !== $config->syncFromTimestamp && $start < $config->syncFromTimestamp) {
return false;
}
if (null !== $config->syncUntilTimestamp && $start > $config->syncUntilTimestamp) {
return false;
}
return true;
}
} }
+65 -3
View File
@@ -6,6 +6,7 @@ namespace Mummert\CalDavSyncBundle\Sync;
use Mummert\CalDavSyncBundle\CalDav\Remote\RemoteCalendarReader; use Mummert\CalDavSyncBundle\CalDav\Remote\RemoteCalendarReader;
use Mummert\CalDavSyncBundle\Config\CalendarSyncConfig; use Mummert\CalDavSyncBundle\Config\CalendarSyncConfig;
use Mummert\CalDavSyncBundle\Repository\ContaoCalendarRepository;
use Mummert\CalDavSyncBundle\Repository\ContaoCalendarEventRepository; use Mummert\CalDavSyncBundle\Repository\ContaoCalendarEventRepository;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -15,6 +16,7 @@ final readonly class RemoteToLocalSynchronizer
public function __construct( public function __construct(
private RemoteCalendarReader $remoteReader, private RemoteCalendarReader $remoteReader,
private ContaoCalendarRepository $calendarRepository,
private ContaoCalendarEventRepository $eventRepository, private ContaoCalendarEventRepository $eventRepository,
private EventMatchResolver $matchResolver, private EventMatchResolver $matchResolver,
private SyncFieldExtractor $fieldExtractor, private SyncFieldExtractor $fieldExtractor,
@@ -26,21 +28,51 @@ final readonly class RemoteToLocalSynchronizer
public function synchronize(CalendarSyncConfig $config, bool $dryRun = false): SyncResult public function synchronize(CalendarSyncConfig $config, bool $dryRun = false): SyncResult
{ {
$result = new SyncResult(); $result = new SyncResult();
$remoteEvents = $this->remoteReader->readEvents($config);
$allLocalEvents = $this->eventRepository->findByCalendarId($config->calendarId); $allLocalEvents = $this->eventRepository->findByCalendarId($config->calendarId);
$localEvents = array_values(array_filter( $localEvents = array_values(array_filter(
$allLocalEvents, $allLocalEvents,
fn (array $event): bool => $config->shouldManageEventForThisRemoteCalendar((string) ($event['caldavCalendarHref'] ?? '')), fn (array $event): bool => $config->shouldManageEventForThisRemoteCalendar((string) ($event['caldavCalendarHref'] ?? '')),
)); ));
$knownHrefEtags = [];
foreach ($localEvents as $localEvent) {
$href = trim((string) ($localEvent['caldavHref'] ?? ''));
if ('' === $href) {
continue;
}
$knownHrefEtags[$href] = trim((string) ($localEvent['caldavEtag'] ?? ''));
}
$remoteData = $this->remoteReader->readEvents($config, $knownHrefEtags, true);
$remoteEvents = $remoteData->events;
if (!$dryRun && null !== $remoteData->collectionTag) {
$this->calendarRepository->updateCollectionTagForCalendarUrl(
$config->calendarId,
$config->caldavUrl,
$remoteData->collectionTag,
);
}
if ($remoteData->collectionTagUnchanged) {
$result->skipped += count($localEvents);
return $result;
}
$seenHrefs = []; $seenHrefs = [];
$seenUids = []; $seenUids = [];
foreach (array_keys($remoteData->hrefEtags) as $knownRemoteHref) {
$seenHrefs[$knownRemoteHref] = true;
}
foreach ($remoteEvents as $remoteEvent) { foreach ($remoteEvents as $remoteEvent) {
$seenHrefs[$remoteEvent->href] = true; $seenHrefs[$remoteEvent->href] = true;
$seenUids[$remoteEvent->uid] = true; $seenUids[$remoteEvent->uid] = true;
$localEvent = $this->matchResolver->resolve($localEvents, $remoteEvent); $localEvent = $this->matchResolver->resolve($localEvents, $remoteEvent, $config->calendarId);
$remoteSyncFields = $this->fieldExtractor->extractFromRemoteEvent($remoteEvent); $remoteSyncFields = $this->fieldExtractor->extractFromRemoteEvent($remoteEvent);
$remoteHash = $this->hashGenerator->generate($remoteSyncFields); $remoteHash = $this->hashGenerator->generate($remoteSyncFields);
$expectedLocalFields = $this->fieldExtractor->applyRemoteToLocalFields($remoteEvent); $expectedLocalFields = $this->fieldExtractor->applyRemoteToLocalFields($remoteEvent);
@@ -75,7 +107,7 @@ final readonly class RemoteToLocalSynchronizer
continue; continue;
} }
$localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent); $localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent, $config->timezoneOrDefault());
$localCurrentHash = $this->hashGenerator->generate($localSyncFields); $localCurrentHash = $this->hashGenerator->generate($localSyncFields);
$storedHash = (string) ($localEvent['caldavSyncHash'] ?? ''); $storedHash = (string) ($localEvent['caldavSyncHash'] ?? '');
$storedEtag = (string) ($localEvent['caldavEtag'] ?? ''); $storedEtag = (string) ($localEvent['caldavEtag'] ?? '');
@@ -182,6 +214,10 @@ final readonly class RemoteToLocalSynchronizer
continue; continue;
} }
if (!$this->isEventWithinConfiguredWindow($localEvent, $config)) {
continue;
}
if (isset($seenHrefs[$localHref]) || ('' !== $localUid && isset($seenUids[$localUid]))) { if (isset($seenHrefs[$localHref]) || ('' !== $localUid && isset($seenUids[$localUid]))) {
continue; continue;
} }
@@ -213,4 +249,30 @@ final readonly class RemoteToLocalSynchronizer
return $localModifiedAt > ($remoteModifiedAt + self::MODIFIED_TIME_SKEW_SECONDS); return $localModifiedAt > ($remoteModifiedAt + self::MODIFIED_TIME_SKEW_SECONDS);
} }
private function isEventWithinConfiguredWindow(array $localEvent, CalendarSyncConfig $config): bool
{
if (!$config->hasTimeWindow()) {
return true;
}
$start = (int) ($localEvent['startTime'] ?? 0);
if ($start <= 0) {
$start = (int) ($localEvent['startDate'] ?? 0);
}
if ($start <= 0) {
return true;
}
if (null !== $config->syncFromTimestamp && $start < $config->syncFromTimestamp) {
return false;
}
if (null !== $config->syncUntilTimestamp && $start > $config->syncUntilTimestamp) {
return false;
}
return true;
}
} }
+106 -33
View File
@@ -44,25 +44,22 @@ final class SyncFieldExtractor
/** /**
* @return array<string,mixed> * @return array<string,mixed>
*/ */
public function extractFromLocalEvent(array $localEvent): array public function extractFromLocalEvent(array $localEvent, ?string $timezone = null): array
{ {
$allDay = '1' !== (string) ($localEvent['addTime'] ?? ''); $allDay = $this->resolveLocalAllDay($localEvent, $timezone);
$start = (int) ($localEvent['startTime'] ?? 0); if ($allDay) {
if ($start <= 0) { $start = $this->resolveAllDayStartTimestamp($localEvent);
$start = (int) ($localEvent['startDate'] ?? 0); $end = $this->resolveAllDayExclusiveEndTimestamp($localEvent, $start);
} } else {
$start = (int) ($localEvent['startTime'] ?? 0);
if ($start <= 0) {
$start = (int) ($localEvent['startDate'] ?? 0);
}
$end = (int) ($localEvent['endTime'] ?? 0); $end = (int) ($localEvent['endTime'] ?? 0);
$usesEndDateFallback = false; if ($end <= 0) {
$end = (int) ($localEvent['endDate'] ?? 0);
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([ return $this->normalize([
@@ -99,23 +96,20 @@ final class SyncFieldExtractor
$uid = sprintf('contao-%d-%d@local', (int) ($localEvent['pid'] ?? 0), (int) ($localEvent['id'] ?? 0)); $uid = sprintf('contao-%d-%d@local', (int) ($localEvent['pid'] ?? 0), (int) ($localEvent['id'] ?? 0));
} }
$allDay = '1' !== (string) ($localEvent['addTime'] ?? ''); $allDay = $this->resolveLocalAllDay($localEvent, $timezone);
$startAt = (int) ($localEvent['startTime'] ?? 0); if ($allDay) {
if ($startAt <= 0) { $startAt = $this->resolveAllDayStartTimestamp($localEvent);
$startAt = (int) ($localEvent['startDate'] ?? 0); $endAt = $this->resolveAllDayExclusiveEndTimestamp($localEvent, $startAt);
} } else {
$startAt = (int) ($localEvent['startTime'] ?? 0);
if ($startAt <= 0) {
$startAt = (int) ($localEvent['startDate'] ?? 0);
}
$endAt = (int) ($localEvent['endTime'] ?? 0); $endAt = (int) ($localEvent['endTime'] ?? 0);
$usesEndDateFallback = false; if ($endAt <= 0) {
$endAt = (int) ($localEvent['endDate'] ?? 0);
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) { if ($endAt <= $startAt) {
@@ -257,4 +251,83 @@ final class SyncFieldExtractor
{ {
return gmdate('Ymd', $leftTimestamp) === gmdate('Ymd', $rightTimestamp); return gmdate('Ymd', $leftTimestamp) === gmdate('Ymd', $rightTimestamp);
} }
private function resolveLocalAllDay(array $localEvent, ?string $timezone = null): bool
{
if ('1' !== (string) ($localEvent['addTime'] ?? '')) {
return true;
}
// Fallback for synced events where Contao save can leave addTime=1
// although timestamps still represent an all-day event.
if (!$this->hasSyncMarker($localEvent)) {
return false;
}
$startDate = (int) ($localEvent['startDate'] ?? 0);
$endDate = (int) ($localEvent['endDate'] ?? 0);
$startTime = (int) ($localEvent['startTime'] ?? 0);
$endTime = (int) ($localEvent['endTime'] ?? 0);
if ($startDate <= 0 || $startTime <= 0) {
return false;
}
$sameStartBoundary = $startTime === $startDate || $this->isMidnightInTimezone($startTime, $timezone);
$singleDayOrEmptyEndDate = $endDate <= 0 || $endDate === $startDate;
$endAtBoundary = $endTime <= 0
|| $endTime === $startTime
|| $endTime === $startDate
|| ($endDate > 0 && $endTime === $endDate)
|| $this->isMidnightInTimezone($endTime, $timezone)
;
return $sameStartBoundary && $singleDayOrEmptyEndDate && $endAtBoundary;
}
private function hasSyncMarker(array $localEvent): bool
{
return '' !== trim((string) ($localEvent['caldavHref'] ?? ''))
|| '' !== trim((string) ($localEvent['caldavUid'] ?? ''))
|| '' !== trim((string) ($localEvent['caldavOrigin'] ?? ''));
}
private function isMidnightInTimezone(int $timestamp, ?string $timezone): bool
{
if ($timestamp <= 0) {
return false;
}
try {
$tz = new DateTimeZone(null !== $timezone && '' !== trim($timezone) ? $timezone : 'UTC');
} catch (\Throwable) {
$tz = new DateTimeZone('UTC');
}
return '00:00:00' === (new DateTimeImmutable('@'.$timestamp))->setTimezone($tz)->format('H:i:s');
}
private function resolveAllDayStartTimestamp(array $localEvent): int
{
$startDate = (int) ($localEvent['startDate'] ?? 0);
if ($startDate > 0) {
return $startDate;
}
return (int) ($localEvent['startTime'] ?? 0);
}
private function resolveAllDayExclusiveEndTimestamp(array $localEvent, int $startTimestamp): int
{
$endDate = (int) ($localEvent['endDate'] ?? 0);
if ($endDate > 0) {
if ($endDate >= $startTimestamp) {
return $endDate + 86400;
}
return $startTimestamp + 86400;
}
return $startTimestamp + 86400;
}
} }