Release: LMW delta sync hardening, CTAG windowing, calendar matching guard
This commit is contained in:
@@ -1,118 +1,157 @@
|
|||||||
# 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.
|
Produktionsreifes 2-Way-CalDAV-Sync-Bundle fuer Contao 5.7 (PHP 8.4).
|
||||||
|
Es synchronisiert Events zwischen `tl_calendar_events` und einem oder mehreren CalDAV-Kalendern.
|
||||||
|
|
||||||
## 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
|
## Voraussetzungen
|
||||||
|
|
||||||
```text
|
- PHP `^8.4`
|
||||||
caldav-sync-bundle/
|
- Contao `^5.7`
|
||||||
composer.json
|
- `symfony/http-client` `^7.3`
|
||||||
config/
|
- `sabre/vobject` `^4.5`
|
||||||
services.yaml
|
|
||||||
contao/
|
## Installation
|
||||||
dca/
|
|
||||||
tl_calendar.php
|
### 1) Bundle als Composer-Abhaengigkeit einbinden
|
||||||
tl_calendar_events.php
|
|
||||||
languages/
|
Bei lokalem Bundle-Checkout z. B. im Projekt-Composer:
|
||||||
de/
|
|
||||||
tl_calendar.php
|
```json
|
||||||
tl_calendar_events.php
|
{
|
||||||
src/
|
"repositories": [
|
||||||
CalDavSyncBundle.php
|
{
|
||||||
Command/
|
"type": "path",
|
||||||
CalDavSyncCommand.php
|
"url": "bundles/caldav-sync-bundle",
|
||||||
Config/
|
"options": { "symlink": true }
|
||||||
CalendarSyncConfig.php
|
}
|
||||||
Contao/Manager/
|
],
|
||||||
Plugin.php
|
"require": {
|
||||||
DependencyInjection/
|
"mummert/caldav-sync-bundle": "*@dev"
|
||||||
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
|
Dann installieren:
|
||||||
|
|
||||||
- `CalendarConfigProvider`: Liefert pro `tl_calendar` die Sync-Konfiguration.
|
```bash
|
||||||
- `ContaoCalendarRepository`: Zugriff auf Sync-faehige Kalender.
|
composer update mummert/caldav-sync-bundle
|
||||||
- `ContaoCalendarEventRepository`: CRUD fuer `tl_calendar_events`.
|
php vendor/bin/contao-console contao:migrate
|
||||||
- `CalDavTransport`: HTTP/WebDAV via Symfony HttpClient (`PROPFIND`, `REPORT`, `GET`, `PUT`, `DELETE`).
|
php vendor/bin/contao-console cache:clear
|
||||||
- `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
|
## Backend-Konfiguration (Kalender)
|
||||||
|
|
||||||
Bidirektionale Felder (sync-relevant):
|
In `System -> Kalender` den gewuenschten Kalender oeffnen und unter CalDAV konfigurieren:
|
||||||
|
|
||||||
- `title`
|
- `CalDAV Sync aktivieren`
|
||||||
- `start/end`
|
- `CalDAV URL`
|
||||||
- `all-day / with-time` (`addTime` Mapping)
|
- `CalDAV Benutzername`
|
||||||
- `description` (`details`)
|
- `CalDAV Passwort`
|
||||||
- `location`
|
- `CalDAV Autor` (Pflichtfeld, wird fuer importierte Events gesetzt)
|
||||||
- optional `url`
|
- optional `CalDAV Zeitzone` (Fallback: `UTC`)
|
||||||
|
- `Remote-Kalender` als Mehrfachauswahl (CheckboxWizard)
|
||||||
|
|
||||||
Contao-only-Felder sind explizit nicht Teil des Sync-Hashes und loesen damit keinen Push aus.
|
Hinweis zur Mehrfachauswahl:
|
||||||
|
|
||||||
## Logging-Strategie
|
- Es koennen mehrere Remote-Kalender unter einer Verbindung ausgewaehlt werden.
|
||||||
|
- Events aus abgewaehlten Remote-Kalendern werden beim naechsten Pull fuer den betroffenen Contao-Kalender entfernt.
|
||||||
|
|
||||||
- `info`: erfolgreicher Abschluss pro Kalender inkl. Zaehler
|
## Sync ausfuehren
|
||||||
- `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
|
||||||
|
|
||||||
|
## Sync-Verhalten im Detail
|
||||||
|
|
||||||
|
### Reihenfolge bei `--direction=both`
|
||||||
|
|
||||||
|
1. Push (lokal -> remote)
|
||||||
|
2. Pull (remote -> lokal)
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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 ''",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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.'];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
|
$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,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (null !== $event) {
|
|
||||||
$events[] = $event;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
if ($allDay) {
|
||||||
|
$start = $this->resolveAllDayStartTimestamp($localEvent);
|
||||||
|
$end = $this->resolveAllDayExclusiveEndTimestamp($localEvent, $start);
|
||||||
|
} else {
|
||||||
$start = (int) ($localEvent['startTime'] ?? 0);
|
$start = (int) ($localEvent['startTime'] ?? 0);
|
||||||
if ($start <= 0) {
|
if ($start <= 0) {
|
||||||
$start = (int) ($localEvent['startDate'] ?? 0);
|
$start = (int) ($localEvent['startDate'] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
$end = (int) ($localEvent['endTime'] ?? 0);
|
$end = (int) ($localEvent['endTime'] ?? 0);
|
||||||
$usesEndDateFallback = false;
|
|
||||||
|
|
||||||
if ($end <= 0) {
|
if ($end <= 0) {
|
||||||
$end = (int) ($localEvent['endDate'] ?? 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);
|
||||||
|
if ($allDay) {
|
||||||
|
$startAt = $this->resolveAllDayStartTimestamp($localEvent);
|
||||||
|
$endAt = $this->resolveAllDayExclusiveEndTimestamp($localEvent, $startAt);
|
||||||
|
} else {
|
||||||
$startAt = (int) ($localEvent['startTime'] ?? 0);
|
$startAt = (int) ($localEvent['startTime'] ?? 0);
|
||||||
if ($startAt <= 0) {
|
if ($startAt <= 0) {
|
||||||
$startAt = (int) ($localEvent['startDate'] ?? 0);
|
$startAt = (int) ($localEvent['startDate'] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
$endAt = (int) ($localEvent['endTime'] ?? 0);
|
$endAt = (int) ($localEvent['endTime'] ?? 0);
|
||||||
$usesEndDateFallback = false;
|
|
||||||
|
|
||||||
if ($endAt <= 0) {
|
if ($endAt <= 0) {
|
||||||
$endAt = (int) ($localEvent['endDate'] ?? 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user