diff --git a/README.md b/README.md index 9b2cd67..8b8cef3 100644 --- a/README.md +++ b/README.md @@ -1,118 +1,157 @@ # 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 Backend-Button, nur Command -- Harte Loeschung bei fehlendem Gegenstueck -- Konfliktregel: remote gewinnt -- Contao-only-Felder werden niemals remote geschrieben +- Kein RRULE/EXDATE/RECURRENCE-ID-Support +- Kein Backend-Button, Sync nur ueber Command/Cron +- Harte Loeschung auf beiden Seiten, wenn das Gegenstueck fehlt +- Konfliktaufloesung pro Event ueber Last-Modified-Wins (mit Toleranz) +- Contao-only-Felder werden nicht in den Sync-Hash aufgenommen -## Verzeichnisstruktur +## Voraussetzungen -```text -caldav-sync-bundle/ - composer.json - config/ - services.yaml - contao/ - dca/ - tl_calendar.php - tl_calendar_events.php - languages/ - de/ - tl_calendar.php - tl_calendar_events.php - src/ - CalDavSyncBundle.php - Command/ - CalDavSyncCommand.php - Config/ - CalendarSyncConfig.php - Contao/Manager/ - Plugin.php - DependencyInjection/ - CalDavSyncExtension.php - Repository/ - ContaoCalendarRepository.php - ContaoCalendarEventRepository.php - Migration/ - Version20260327000000.php - Service/ - CalendarConfigProvider.php - CalDav/ - Transport/ - CalDavTransportInterface.php - CalDavTransport.php - TransportResponse.php - Parser/ - CalDavXmlParser.php - ICalendarParser.php - ICalendarSerializer.php - Remote/ - RemoteCalendarReader.php - RemoteCalendarWriter.php - Sync/ - RemoteEvent.php - EventMatchResolver.php - SyncFieldExtractor.php - SyncHashGenerator.php - SyncResult.php - RemoteToLocalSynchronizer.php - LocalToRemoteSynchronizer.php - SyncRunner.php +- PHP `^8.4` +- Contao `^5.7` +- `symfony/http-client` `^7.3` +- `sabre/vobject` `^4.5` + +## Installation + +### 1) Bundle als Composer-Abhaengigkeit einbinden + +Bei lokalem Bundle-Checkout z. B. im Projekt-Composer: + +```json +{ + "repositories": [ + { + "type": "path", + "url": "bundles/caldav-sync-bundle", + "options": { "symlink": true } + } + ], + "require": { + "mummert/caldav-sync-bundle": "*@dev" + } +} ``` -## Klassenuebersicht +Dann installieren: -- `CalendarConfigProvider`: Liefert pro `tl_calendar` die Sync-Konfiguration. -- `ContaoCalendarRepository`: Zugriff auf Sync-faehige Kalender. -- `ContaoCalendarEventRepository`: CRUD fuer `tl_calendar_events`. -- `CalDavTransport`: HTTP/WebDAV via Symfony HttpClient (`PROPFIND`, `REPORT`, `GET`, `PUT`, `DELETE`). -- `CalDavXmlParser`: Parsing von WebDAV-Multistatus XML. -- `ICalendarParser`: VEVENT-Parsing via `sabre/vobject`. -- `ICalendarSerializer`: VEVENT-Serialisierung via `sabre/vobject`. -- `RemoteCalendarReader`: Liest Remote-Kalender. -- `RemoteCalendarWriter`: Schreibt/loescht Remote-Events mit ETag-Unterstuetzung. -- `EventMatchResolver`: Matching zuerst ueber `caldavHref`, dann `caldavUid`. -- `SyncFieldExtractor`: Trennt bidirektionale Sync-Felder von lokalen Contao-only-Feldern. -- `SyncHashGenerator`: SHA-256 ueber kanonische Sync-Felder. -- `RemoteToLocalSynchronizer`: Pull inkl. Konfliktregel und Hard-Delete lokal. -- `LocalToRemoteSynchronizer`: Push inkl. Konfliktregel und Hard-Delete remote. -- `SyncRunner`: Orchestrierung je Kalender und Richtung. -- `CalDavSyncCommand`: CLI-Einstiegspunkt. +```bash +composer update mummert/caldav-sync-bundle +php vendor/bin/contao-console contao:migrate +php vendor/bin/contao-console cache:clear +``` -## Mapping-Strategie +## Backend-Konfiguration (Kalender) -Bidirektionale Felder (sync-relevant): +In `System -> Kalender` den gewuenschten Kalender oeffnen und unter CalDAV konfigurieren: -- `title` -- `start/end` -- `all-day / with-time` (`addTime` Mapping) -- `description` (`details`) -- `location` -- optional `url` +- `CalDAV Sync aktivieren` +- `CalDAV URL` +- `CalDAV Benutzername` +- `CalDAV Passwort` +- `CalDAV Autor` (Pflichtfeld, wird fuer importierte Events gesetzt) +- 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 -- `warning`: Konflikte (`remote wins`) mit Kontext (`calendarId`, `eventId`, `href`, `uid`) -- Fehler laufen als Exceptions hoch und werden im Command als `FAILURE` signalisiert - -## Command +## Sync ausfuehren ```bash php bin/console contao:caldav:sync --direction=both -php bin/console contao:caldav:sync --calendar=3 --direction=pull +php bin/console contao:caldav:sync --calendar=4 --direction=pull php bin/console contao:caldav:sync --direction=push --dry-run ``` Optionen: -- `--calendar=ID` -- `--direction=pull|push|both` -- `--dry-run` +- `--calendar=ID`: nur einen `tl_calendar` synchronisieren +- `--direction=pull|push|both`: Richtung waehlen (Default `both`) +- `--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 diff --git a/contao/dca/tl_calendar.php b/contao/dca/tl_calendar.php index 0493f28..7629305 100644 --- a/contao/dca/tl_calendar.php +++ b/contao/dca/tl_calendar.php @@ -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'] = [ 'exclude' => true, @@ -74,3 +74,28 @@ $GLOBALS['TL_DCA']['tl_calendar']['fields']['caldavCalendarHrefs'] = [ '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', +]; + diff --git a/contao/dca/tl_calendar_events.php b/contao/dca/tl_calendar_events.php index 66889c4..835c4bb 100644 --- a/contao/dca/tl_calendar_events.php +++ b/contao/dca/tl_calendar_events.php @@ -26,55 +26,55 @@ foreach (array_keys($GLOBALS['TL_DCA']['tl_calendar_events']['palettes'] ?? []) $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavCalendarHref'] = [ 'exclude' => true, '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 ''", ]; $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavUid'] = [ 'exclude' => true, '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 ''", ]; $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavHref'] = [ 'exclude' => true, '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 ''", ]; $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavEtag'] = [ 'exclude' => true, '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 ''", ]; $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavSyncHash'] = [ 'exclude' => true, '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 ''", ]; $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavLastSync'] = [ 'exclude' => true, '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", ]; $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavOrigin'] = [ 'exclude' => true, '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 ''", ]; $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['caldavSyncState'] = [ 'exclude' => true, '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 ''", ]; diff --git a/contao/languages/de/tl_calendar.php b/contao/languages/de/tl_calendar.php index 73a4286..7b5cb7c 100644 --- a/contao/languages/de/tl_calendar.php +++ b/contao/languages/de/tl_calendar.php @@ -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']['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']['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.']; diff --git a/src/CalDav/Parser/CalDavXmlParser.php b/src/CalDav/Parser/CalDavXmlParser.php index 6654325..89c8af5 100644 --- a/src/CalDav/Parser/CalDavXmlParser.php +++ b/src/CalDav/Parser/CalDavXmlParser.php @@ -109,6 +109,57 @@ final class CalDavXmlParser return $collections; } + /** + * @return array + */ + 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 { if ('' === trim($xml)) { @@ -125,6 +176,7 @@ final class CalDavXmlParser $xpath = new DOMXPath($document); $xpath->registerNamespace('d', 'DAV:'); $xpath->registerNamespace('c', 'urn:ietf:params:xml:ns:caldav'); + $xpath->registerNamespace('cs', 'http://calendarserver.org/ns/'); return $xpath; } diff --git a/src/CalDav/Remote/RemoteCalendarReader.php b/src/CalDav/Remote/RemoteCalendarReader.php index 597956e..d6da60d 100644 --- a/src/CalDav/Remote/RemoteCalendarReader.php +++ b/src/CalDav/Remote/RemoteCalendarReader.php @@ -21,16 +21,23 @@ final readonly class RemoteCalendarReader ) { } - /** - * @return list - */ - public function readEvents(CalendarSyncConfig $config): array + public function readEvents(CalendarSyncConfig $config, array $knownHrefEtags = [], bool $allowCollectionTagShortCircuit = false): RemoteCalendarSyncData { + $collectionTag = $this->fetchCollectionTag($config); + if ( + $allowCollectionTagShortCircuit + && null !== $collectionTag + && $config->hasStoredCollectionTag() + && $collectionTag === $config->storedCollectionTag + ) { + return new RemoteCalendarSyncData([], $knownHrefEtags, $collectionTag, true); + } + $response = $this->transport->report( $config->caldavUrl, $config->caldavUsername, $config->caldavPassword, - $this->buildCalendarQueryBody(), + $this->buildHrefEtagCalendarQueryBody($config), 1, ['Content-Type' => 'application/xml; charset=utf-8'], ); @@ -42,26 +49,44 @@ final readonly class RemoteCalendarReader 'statusCode' => $response->statusCode, ]); - return []; + return new RemoteCalendarSyncData([], [], $collectionTag, false); } - $parsedItems = $this->xmlParser->parseCalendarMultistatus($response->body); + $hrefEtags = $this->xmlParser->parseCalendarHrefEtags($response->body); $events = []; - foreach ($parsedItems as $item) { - $event = $this->icalendarParser->parseEvent( - $item['href'], - $item['etag'], - $item['calendarData'], - $config->timezoneOrDefault(), - ); - - if (null !== $event) { - $events[] = $event; + foreach ($hrefEtags as $href => $etag) { + $knownEtag = trim((string) ($knownHrefEtags[$href] ?? '')); + if ('' !== $knownEtag && $knownEtag === trim((string) $etag)) { + continue; } + + $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 @@ -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 = ' '."\n"; + } + } + + return str_replace('TIME_RANGE_PLACEHOLDER', $timeRange, $template); + } + + private function buildCalendarQueryBodyTemplate(): string { return <<<'XML' - - + +TIME_RANGE_PLACEHOLDER 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; + } + private function absoluteUrl(string $calendarUrl, string $href): string { if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) { diff --git a/src/CalDav/Remote/RemoteCalendarSyncData.php b/src/CalDav/Remote/RemoteCalendarSyncData.php new file mode 100644 index 0000000..6ac002a --- /dev/null +++ b/src/CalDav/Remote/RemoteCalendarSyncData.php @@ -0,0 +1,22 @@ + $events + * @param array $hrefEtags + */ + public function __construct( + public array $events, + public array $hrefEtags, + public ?string $collectionTag, + public bool $collectionTagUnchanged, + ) { + } +} diff --git a/src/Config/CalendarSyncConfig.php b/src/Config/CalendarSyncConfig.php index 6c81921..41afa4c 100644 --- a/src/Config/CalendarSyncConfig.php +++ b/src/Config/CalendarSyncConfig.php @@ -17,6 +17,9 @@ final readonly class CalendarSyncConfig public int $caldavAuthorId, public string $caldavTimezone, public array $selectedCalendarUrls, + public string $storedCollectionTag, + public ?int $syncFromTimestamp, + public ?int $syncUntilTimestamp, ) { } @@ -53,4 +56,14 @@ final readonly class CalendarSyncConfig return $this->selectedCalendarUrls[0]; } + + public function hasStoredCollectionTag(): bool + { + return '' !== trim($this->storedCollectionTag); + } + + public function hasTimeWindow(): bool + { + return null !== $this->syncFromTimestamp || null !== $this->syncUntilTimestamp; + } } diff --git a/src/Migration/Version20260328090000.php b/src/Migration/Version20260328090000.php new file mode 100644 index 0000000..3e07a81 --- /dev/null +++ b/src/Migration/Version20260328090000.php @@ -0,0 +1,30 @@ +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'); + } +} diff --git a/src/Repository/ContaoCalendarRepository.php b/src/Repository/ContaoCalendarRepository.php index b5830e2..0bcd235 100644 --- a/src/Repository/ContaoCalendarRepository.php +++ b/src/Repository/ContaoCalendarRepository.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Mummert\CalDavSyncBundle\Repository; use Doctrine\DBAL\Connection; +use JsonException; final readonly class ContaoCalendarRepository { @@ -32,4 +33,46 @@ final readonly class ContaoCalendarRepository 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]); + } } diff --git a/src/Service/CalendarConfigProvider.php b/src/Service/CalendarConfigProvider.php index 754c68c..cb26376 100644 --- a/src/Service/CalendarConfigProvider.php +++ b/src/Service/CalendarConfigProvider.php @@ -47,6 +47,9 @@ final readonly class CalendarConfigProvider } foreach ($selectedCalendarUrls as $selectedCalendarUrl) { + $ctagMap = $this->resolveStoredCollectionTagMap($row); + [$syncFromTimestamp, $syncUntilTimestamp] = $this->resolveSyncWindow($row); + $configs[] = new CalendarSyncConfig( (int) $row['id'], $selectedCalendarUrl, @@ -55,6 +58,9 @@ final readonly class CalendarConfigProvider (int) ($row['caldavAuthorId'] ?? 0), (string) ($row['caldavTimezone'] ?? ''), $selectedCalendarUrls, + (string) ($ctagMap[$selectedCalendarUrl] ?? ''), + $syncFromTimestamp, + $syncUntilTimestamp, ); } } @@ -81,4 +87,66 @@ final readonly class CalendarConfigProvider return array_values(array_unique($urls)); } + + /** + * @param array $row + * + * @return array + */ + 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 $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]; + } } diff --git a/src/Sync/EventMatchResolver.php b/src/Sync/EventMatchResolver.php index 3a4f845..9c98329 100644 --- a/src/Sync/EventMatchResolver.php +++ b/src/Sync/EventMatchResolver.php @@ -9,15 +9,23 @@ final class EventMatchResolver /** * @param list> $localEvents */ - public function resolve(array $localEvents, RemoteEvent $remoteEvent): ?array + public function resolve(array $localEvents, RemoteEvent $remoteEvent, ?int $expectedCalendarId = null): ?array { foreach ($localEvents as $localEvent) { + if (!$this->matchesExpectedCalendar($localEvent, $expectedCalendarId)) { + continue; + } + if ('' !== (string) ($localEvent['caldavHref'] ?? '') && (string) $localEvent['caldavHref'] === $remoteEvent->href) { return $localEvent; } } foreach ($localEvents as $localEvent) { + if (!$this->matchesExpectedCalendar($localEvent, $expectedCalendarId)) { + continue; + } + if ('' !== (string) ($localEvent['caldavUid'] ?? '') && (string) $localEvent['caldavUid'] === $remoteEvent->uid) { return $localEvent; } @@ -25,4 +33,20 @@ final class EventMatchResolver return null; } + + /** + * @param array $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; + } } diff --git a/src/Sync/LocalToRemoteSynchronizer.php b/src/Sync/LocalToRemoteSynchronizer.php index 5d11fe4..8a92c80 100644 --- a/src/Sync/LocalToRemoteSynchronizer.php +++ b/src/Sync/LocalToRemoteSynchronizer.php @@ -34,13 +34,30 @@ final readonly class LocalToRemoteSynchronizer $allLocalEvents, 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 = []; $localUids = []; foreach ($localEvents as $localEvent) { + if (!$this->isEventWithinConfiguredWindow($localEvent, $config)) { + ++$result->skipped; + continue; + } + $targetCalendarUrl = $config->resolveTargetCalendarForLocalEvent((string) ($localEvent['caldavCalendarHref'] ?? '')); if (null === $targetCalendarUrl || $targetCalendarUrl !== $config->caldavUrl) { ++$result->skipped; @@ -59,7 +76,7 @@ final readonly class LocalToRemoteSynchronizer $localUids[$uid] = true; } - $localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent); + $localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent, $config->timezoneOrDefault()); $currentHash = $this->hashGenerator->generate($localSyncFields); $storedHash = (string) ($localEvent['caldavSyncHash'] ?? ''); $localChanged = '' === $storedHash || $storedHash !== $currentHash; @@ -69,7 +86,7 @@ final readonly class LocalToRemoteSynchronizer 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); if (null !== $matchingRemoteEvent && '' !== $storedHash) { @@ -164,19 +181,19 @@ final readonly class LocalToRemoteSynchronizer } $localUids[$payloadEvent->uid] = true; - if (null === $matchingRemoteEvent) { + if (null === $matchingRemoteEvent && null === $remoteMatch) { ++$result->created; } else { ++$result->updated; } } - foreach ($remoteEvents as $remoteEvent) { - if (isset($localHrefs[$remoteEvent->href]) || isset($localUids[$remoteEvent->uid])) { + foreach ($remoteData->hrefEtags as $remoteHref => $remoteEtag) { + if (isset($localHrefs[$remoteHref])) { continue; } - $this->remoteWriter->deleteEvent($config, $remoteEvent->href, $remoteEvent->etag, $dryRun); + $this->remoteWriter->deleteEvent($config, $remoteHref, $remoteEtag, $dryRun); ++$result->deleted; } @@ -185,13 +202,21 @@ final readonly class LocalToRemoteSynchronizer /** * @param list $remoteEvents + * @param array $hrefEtags * * @return list> */ - private function buildRemotePseudoLocalRows(array $remoteEvents): array + private function buildRemotePseudoLocalRows(array $remoteEvents, array $hrefEtags): array { $rows = []; + foreach ($hrefEtags as $href => $_etag) { + $rows[] = [ + 'caldavHref' => $href, + 'caldavUid' => '', + ]; + } + foreach ($remoteEvents as $remoteEvent) { $rows[] = [ 'caldavHref' => $remoteEvent->href, @@ -240,4 +265,30 @@ final readonly class LocalToRemoteSynchronizer 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; + } } diff --git a/src/Sync/RemoteToLocalSynchronizer.php b/src/Sync/RemoteToLocalSynchronizer.php index a35b69d..dae5269 100644 --- a/src/Sync/RemoteToLocalSynchronizer.php +++ b/src/Sync/RemoteToLocalSynchronizer.php @@ -6,6 +6,7 @@ namespace Mummert\CalDavSyncBundle\Sync; use Mummert\CalDavSyncBundle\CalDav\Remote\RemoteCalendarReader; use Mummert\CalDavSyncBundle\Config\CalendarSyncConfig; +use Mummert\CalDavSyncBundle\Repository\ContaoCalendarRepository; use Mummert\CalDavSyncBundle\Repository\ContaoCalendarEventRepository; use Psr\Log\LoggerInterface; @@ -15,6 +16,7 @@ final readonly class RemoteToLocalSynchronizer public function __construct( private RemoteCalendarReader $remoteReader, + private ContaoCalendarRepository $calendarRepository, private ContaoCalendarEventRepository $eventRepository, private EventMatchResolver $matchResolver, private SyncFieldExtractor $fieldExtractor, @@ -26,21 +28,51 @@ final readonly class RemoteToLocalSynchronizer public function synchronize(CalendarSyncConfig $config, bool $dryRun = false): SyncResult { $result = new SyncResult(); - $remoteEvents = $this->remoteReader->readEvents($config); $allLocalEvents = $this->eventRepository->findByCalendarId($config->calendarId); $localEvents = array_values(array_filter( $allLocalEvents, fn (array $event): bool => $config->shouldManageEventForThisRemoteCalendar((string) ($event['caldavCalendarHref'] ?? '')), )); + $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 = []; $seenUids = []; + foreach (array_keys($remoteData->hrefEtags) as $knownRemoteHref) { + $seenHrefs[$knownRemoteHref] = true; + } + foreach ($remoteEvents as $remoteEvent) { $seenHrefs[$remoteEvent->href] = true; $seenUids[$remoteEvent->uid] = true; - $localEvent = $this->matchResolver->resolve($localEvents, $remoteEvent); + $localEvent = $this->matchResolver->resolve($localEvents, $remoteEvent, $config->calendarId); $remoteSyncFields = $this->fieldExtractor->extractFromRemoteEvent($remoteEvent); $remoteHash = $this->hashGenerator->generate($remoteSyncFields); $expectedLocalFields = $this->fieldExtractor->applyRemoteToLocalFields($remoteEvent); @@ -75,7 +107,7 @@ final readonly class RemoteToLocalSynchronizer continue; } - $localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent); + $localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent, $config->timezoneOrDefault()); $localCurrentHash = $this->hashGenerator->generate($localSyncFields); $storedHash = (string) ($localEvent['caldavSyncHash'] ?? ''); $storedEtag = (string) ($localEvent['caldavEtag'] ?? ''); @@ -182,6 +214,10 @@ final readonly class RemoteToLocalSynchronizer continue; } + if (!$this->isEventWithinConfiguredWindow($localEvent, $config)) { + continue; + } + if (isset($seenHrefs[$localHref]) || ('' !== $localUid && isset($seenUids[$localUid]))) { continue; } @@ -213,4 +249,30 @@ final readonly class RemoteToLocalSynchronizer 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; + } } diff --git a/src/Sync/SyncFieldExtractor.php b/src/Sync/SyncFieldExtractor.php index 5de082e..e2741f9 100644 --- a/src/Sync/SyncFieldExtractor.php +++ b/src/Sync/SyncFieldExtractor.php @@ -44,25 +44,22 @@ final class SyncFieldExtractor /** * @return array */ - public function extractFromLocalEvent(array $localEvent): array + public function extractFromLocalEvent(array $localEvent, ?string $timezone = null): array { - $allDay = '1' !== (string) ($localEvent['addTime'] ?? ''); - $start = (int) ($localEvent['startTime'] ?? 0); - if ($start <= 0) { - $start = (int) ($localEvent['startDate'] ?? 0); - } + $allDay = $this->resolveLocalAllDay($localEvent, $timezone); + if ($allDay) { + $start = $this->resolveAllDayStartTimestamp($localEvent); + $end = $this->resolveAllDayExclusiveEndTimestamp($localEvent, $start); + } else { + $start = (int) ($localEvent['startTime'] ?? 0); + if ($start <= 0) { + $start = (int) ($localEvent['startDate'] ?? 0); + } - $end = (int) ($localEvent['endTime'] ?? 0); - $usesEndDateFallback = false; - - if ($end <= 0) { - $end = (int) ($localEvent['endDate'] ?? 0); - $usesEndDateFallback = $end > 0; - } - - // Contao stores endDate inclusive for all-day events, CalDAV uses exclusive DTEND. - if ($allDay && $usesEndDateFallback) { - $end += 86400; + $end = (int) ($localEvent['endTime'] ?? 0); + if ($end <= 0) { + $end = (int) ($localEvent['endDate'] ?? 0); + } } return $this->normalize([ @@ -99,23 +96,20 @@ final class SyncFieldExtractor $uid = sprintf('contao-%d-%d@local', (int) ($localEvent['pid'] ?? 0), (int) ($localEvent['id'] ?? 0)); } - $allDay = '1' !== (string) ($localEvent['addTime'] ?? ''); - $startAt = (int) ($localEvent['startTime'] ?? 0); - if ($startAt <= 0) { - $startAt = (int) ($localEvent['startDate'] ?? 0); - } + $allDay = $this->resolveLocalAllDay($localEvent, $timezone); + if ($allDay) { + $startAt = $this->resolveAllDayStartTimestamp($localEvent); + $endAt = $this->resolveAllDayExclusiveEndTimestamp($localEvent, $startAt); + } else { + $startAt = (int) ($localEvent['startTime'] ?? 0); + if ($startAt <= 0) { + $startAt = (int) ($localEvent['startDate'] ?? 0); + } - $endAt = (int) ($localEvent['endTime'] ?? 0); - $usesEndDateFallback = false; - - if ($endAt <= 0) { - $endAt = (int) ($localEvent['endDate'] ?? 0); - $usesEndDateFallback = $endAt > 0; - } - - // Convert inclusive local endDate to exclusive CalDAV DTEND for all-day events. - if ($allDay && $usesEndDateFallback) { - $endAt += 86400; + $endAt = (int) ($localEvent['endTime'] ?? 0); + if ($endAt <= 0) { + $endAt = (int) ($localEvent['endDate'] ?? 0); + } } if ($endAt <= $startAt) { @@ -257,4 +251,83 @@ final class SyncFieldExtractor { 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; + } }