Release: CalDAV sync bundle hardening and LMW sync
This commit is contained in:
@@ -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