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