Release: LMW delta sync hardening, CTAG windowing, calendar matching guard
This commit is contained in:
@@ -109,6 +109,57 @@ final class CalDavXmlParser
|
||||
return $collections;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public function parseCalendarHrefEtags(string $xml): array
|
||||
{
|
||||
if ('' === trim($xml)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$document = new DOMDocument();
|
||||
$loaded = @$document->loadXML($xml);
|
||||
|
||||
if (false === $loaded) {
|
||||
throw new RuntimeException('Invalid WebDAV XML response.');
|
||||
}
|
||||
|
||||
$xpath = new DOMXPath($document);
|
||||
$xpath->registerNamespace('d', 'DAV:');
|
||||
|
||||
$responseNodes = $xpath->query('/d:multistatus/d:response');
|
||||
if (false === $responseNodes) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
|
||||
foreach ($responseNodes as $responseNode) {
|
||||
$href = trim((string) $xpath->evaluate('string(d:href)', $responseNode));
|
||||
if ('' === $href) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$etag = trim((string) $xpath->evaluate('string(d:propstat/d:prop/d:getetag)', $responseNode));
|
||||
$items[$href] = trim($etag, '"');
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function parseCollectionTag(string $xml): ?string
|
||||
{
|
||||
$xpath = $this->createXPath($xml);
|
||||
|
||||
$calendarServerTag = $this->stringOrNull($xpath->evaluate('string(/d:multistatus/d:response/d:propstat/d:prop/cs:getctag)'));
|
||||
if (null !== $calendarServerTag) {
|
||||
return $calendarServerTag;
|
||||
}
|
||||
|
||||
return $this->stringOrNull($xpath->evaluate('string(/d:multistatus/d:response/d:propstat/d:prop/d:getctag)'));
|
||||
}
|
||||
|
||||
private function createXPath(string $xml): DOMXPath
|
||||
{
|
||||
if ('' === trim($xml)) {
|
||||
@@ -125,6 +176,7 @@ final class CalDavXmlParser
|
||||
$xpath = new DOMXPath($document);
|
||||
$xpath->registerNamespace('d', 'DAV:');
|
||||
$xpath->registerNamespace('c', 'urn:ietf:params:xml:ns:caldav');
|
||||
$xpath->registerNamespace('cs', 'http://calendarserver.org/ns/');
|
||||
|
||||
return $xpath;
|
||||
}
|
||||
|
||||
@@ -21,16 +21,23 @@ final readonly class RemoteCalendarReader
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<RemoteEvent>
|
||||
*/
|
||||
public function readEvents(CalendarSyncConfig $config): array
|
||||
public function readEvents(CalendarSyncConfig $config, array $knownHrefEtags = [], bool $allowCollectionTagShortCircuit = false): RemoteCalendarSyncData
|
||||
{
|
||||
$collectionTag = $this->fetchCollectionTag($config);
|
||||
if (
|
||||
$allowCollectionTagShortCircuit
|
||||
&& null !== $collectionTag
|
||||
&& $config->hasStoredCollectionTag()
|
||||
&& $collectionTag === $config->storedCollectionTag
|
||||
) {
|
||||
return new RemoteCalendarSyncData([], $knownHrefEtags, $collectionTag, true);
|
||||
}
|
||||
|
||||
$response = $this->transport->report(
|
||||
$config->caldavUrl,
|
||||
$config->caldavUsername,
|
||||
$config->caldavPassword,
|
||||
$this->buildCalendarQueryBody(),
|
||||
$this->buildHrefEtagCalendarQueryBody($config),
|
||||
1,
|
||||
['Content-Type' => 'application/xml; charset=utf-8'],
|
||||
);
|
||||
@@ -42,26 +49,44 @@ final readonly class RemoteCalendarReader
|
||||
'statusCode' => $response->statusCode,
|
||||
]);
|
||||
|
||||
return [];
|
||||
return new RemoteCalendarSyncData([], [], $collectionTag, false);
|
||||
}
|
||||
|
||||
$parsedItems = $this->xmlParser->parseCalendarMultistatus($response->body);
|
||||
$hrefEtags = $this->xmlParser->parseCalendarHrefEtags($response->body);
|
||||
$events = [];
|
||||
|
||||
foreach ($parsedItems as $item) {
|
||||
$event = $this->icalendarParser->parseEvent(
|
||||
$item['href'],
|
||||
$item['etag'],
|
||||
$item['calendarData'],
|
||||
$config->timezoneOrDefault(),
|
||||
);
|
||||
|
||||
if (null !== $event) {
|
||||
$events[] = $event;
|
||||
foreach ($hrefEtags as $href => $etag) {
|
||||
$knownEtag = trim((string) ($knownHrefEtags[$href] ?? ''));
|
||||
if ('' !== $knownEtag && $knownEtag === trim((string) $etag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$event = $this->readEventByHref($config, $href);
|
||||
if (null === $event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$events[] = new RemoteEvent(
|
||||
$event->href,
|
||||
$event->uid,
|
||||
'' !== trim((string) $etag) ? (string) $etag : $event->etag,
|
||||
$event->lastModifiedAt,
|
||||
$event->hasTitle,
|
||||
$event->hasDescription,
|
||||
$event->hasLocation,
|
||||
$event->hasUrl,
|
||||
$event->title,
|
||||
$event->description,
|
||||
$event->location,
|
||||
$event->url,
|
||||
$event->startAt,
|
||||
$event->endAt,
|
||||
$event->allDay,
|
||||
$event->timezone,
|
||||
);
|
||||
}
|
||||
|
||||
return $events;
|
||||
return new RemoteCalendarSyncData($events, $hrefEtags, $collectionTag, false);
|
||||
}
|
||||
|
||||
public function readEventByHref(CalendarSyncConfig $config, string $href): ?RemoteEvent
|
||||
@@ -91,24 +116,87 @@ final readonly class RemoteCalendarReader
|
||||
);
|
||||
}
|
||||
|
||||
private function buildCalendarQueryBody(): string
|
||||
private function buildHrefEtagCalendarQueryBody(CalendarSyncConfig $config): string
|
||||
{
|
||||
$template = $this->buildCalendarQueryBodyTemplate();
|
||||
$timeRange = '';
|
||||
|
||||
if ($config->hasTimeWindow()) {
|
||||
$attrs = [];
|
||||
|
||||
if (null !== $config->syncFromTimestamp) {
|
||||
$attrs[] = sprintf('start="%s"', gmdate('Ymd\THis\Z', $config->syncFromTimestamp));
|
||||
}
|
||||
|
||||
if (null !== $config->syncUntilTimestamp) {
|
||||
$attrs[] = sprintf('end="%s"', gmdate('Ymd\THis\Z', $config->syncUntilTimestamp));
|
||||
}
|
||||
|
||||
if ([] !== $attrs) {
|
||||
$timeRange = ' <c:time-range '.implode(' ', $attrs).' />'."\n";
|
||||
}
|
||||
}
|
||||
|
||||
return str_replace('TIME_RANGE_PLACEHOLDER', $timeRange, $template);
|
||||
}
|
||||
|
||||
private function buildCalendarQueryBodyTemplate(): 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 name="VEVENT">
|
||||
TIME_RANGE_PLACEHOLDER </c:comp-filter>
|
||||
</c:comp-filter>
|
||||
</c:filter>
|
||||
</c:calendar-query>
|
||||
XML;
|
||||
}
|
||||
|
||||
private function fetchCollectionTag(CalendarSyncConfig $config): ?string
|
||||
{
|
||||
try {
|
||||
$response = $this->transport->propfind(
|
||||
$config->caldavUrl,
|
||||
$config->caldavUsername,
|
||||
$config->caldavPassword,
|
||||
$this->buildCollectionTagPropfindBody(),
|
||||
0,
|
||||
['Content-Type' => 'application/xml; charset=utf-8'],
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!in_array($response->statusCode, [200, 207], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->xmlParser->parseCollectionTag($response->body);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildCollectionTagPropfindBody(): string
|
||||
{
|
||||
return <<<'XML'
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
|
||||
<d:prop>
|
||||
<cs:getctag />
|
||||
<d:getctag />
|
||||
</d:prop>
|
||||
</d:propfind>
|
||||
XML;
|
||||
}
|
||||
|
||||
private function absoluteUrl(string $calendarUrl, string $href): string
|
||||
{
|
||||
if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\CalDavSyncBundle\CalDav\Remote;
|
||||
|
||||
use Mummert\CalDavSyncBundle\Sync\RemoteEvent;
|
||||
|
||||
final readonly class RemoteCalendarSyncData
|
||||
{
|
||||
/**
|
||||
* @param list<RemoteEvent> $events
|
||||
* @param array<string,string> $hrefEtags
|
||||
*/
|
||||
public function __construct(
|
||||
public array $events,
|
||||
public array $hrefEtags,
|
||||
public ?string $collectionTag,
|
||||
public bool $collectionTagUnchanged,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ final readonly class CalendarSyncConfig
|
||||
public int $caldavAuthorId,
|
||||
public string $caldavTimezone,
|
||||
public array $selectedCalendarUrls,
|
||||
public string $storedCollectionTag,
|
||||
public ?int $syncFromTimestamp,
|
||||
public ?int $syncUntilTimestamp,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -53,4 +56,14 @@ final readonly class CalendarSyncConfig
|
||||
|
||||
return $this->selectedCalendarUrls[0];
|
||||
}
|
||||
|
||||
public function hasStoredCollectionTag(): bool
|
||||
{
|
||||
return '' !== trim($this->storedCollectionTag);
|
||||
}
|
||||
|
||||
public function hasTimeWindow(): bool
|
||||
{
|
||||
return null !== $this->syncFromTimestamp || null !== $this->syncUntilTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\CalDavSyncBundle\Migration;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260328090000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add CTAG cache and sync window fields to tl_calendar';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE tl_calendar ADD caldavSyncCtags BLOB DEFAULT NULL');
|
||||
$this->addSql("ALTER TABLE tl_calendar ADD caldavPastSyncRange VARCHAR(8) NOT NULL DEFAULT '1y'");
|
||||
$this->addSql("ALTER TABLE tl_calendar ADD caldavFutureSyncRange VARCHAR(8) NOT NULL DEFAULT '2y'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE tl_calendar DROP caldavFutureSyncRange');
|
||||
$this->addSql('ALTER TABLE tl_calendar DROP caldavPastSyncRange');
|
||||
$this->addSql('ALTER TABLE tl_calendar DROP caldavSyncCtags');
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Mummert\CalDavSyncBundle\Repository;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use JsonException;
|
||||
|
||||
final readonly class ContaoCalendarRepository
|
||||
{
|
||||
@@ -32,4 +33,46 @@ final readonly class ContaoCalendarRepository
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function updateCollectionTagForCalendarUrl(int $calendarId, string $calendarUrl, ?string $collectionTag): void
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT caldavSyncCtags FROM tl_calendar WHERE id = :id',
|
||||
['id' => $calendarId],
|
||||
);
|
||||
|
||||
$existingMap = [];
|
||||
$raw = trim((string) ($row['caldavSyncCtags'] ?? ''));
|
||||
|
||||
if ('' !== $raw) {
|
||||
try {
|
||||
$decoded = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
|
||||
if (is_array($decoded)) {
|
||||
$existingMap = $decoded;
|
||||
}
|
||||
} catch (JsonException) {
|
||||
$existingMap = [];
|
||||
}
|
||||
}
|
||||
|
||||
$normalizedUrl = trim($calendarUrl);
|
||||
if ('' === $normalizedUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
$normalizedTag = trim((string) $collectionTag);
|
||||
if ('' === $normalizedTag) {
|
||||
unset($existingMap[$normalizedUrl]);
|
||||
} else {
|
||||
$existingMap[$normalizedUrl] = $normalizedTag;
|
||||
}
|
||||
|
||||
try {
|
||||
$encoded = json_encode($existingMap, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
$encoded = '{}';
|
||||
}
|
||||
|
||||
$this->connection->update('tl_calendar', ['caldavSyncCtags' => $encoded], ['id' => $calendarId]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ final readonly class CalendarConfigProvider
|
||||
}
|
||||
|
||||
foreach ($selectedCalendarUrls as $selectedCalendarUrl) {
|
||||
$ctagMap = $this->resolveStoredCollectionTagMap($row);
|
||||
[$syncFromTimestamp, $syncUntilTimestamp] = $this->resolveSyncWindow($row);
|
||||
|
||||
$configs[] = new CalendarSyncConfig(
|
||||
(int) $row['id'],
|
||||
$selectedCalendarUrl,
|
||||
@@ -55,6 +58,9 @@ final readonly class CalendarConfigProvider
|
||||
(int) ($row['caldavAuthorId'] ?? 0),
|
||||
(string) ($row['caldavTimezone'] ?? ''),
|
||||
$selectedCalendarUrls,
|
||||
(string) ($ctagMap[$selectedCalendarUrl] ?? ''),
|
||||
$syncFromTimestamp,
|
||||
$syncUntilTimestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,4 +87,66 @@ final readonly class CalendarConfigProvider
|
||||
|
||||
return array_values(array_unique($urls));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function resolveStoredCollectionTagMap(array $row): array
|
||||
{
|
||||
$raw = trim((string) ($row['caldavSyncCtags'] ?? ''));
|
||||
if ('' === $raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($decoded as $url => $ctag) {
|
||||
$normalizedUrl = trim((string) $url);
|
||||
$normalizedTag = trim((string) $ctag);
|
||||
|
||||
if ('' !== $normalizedUrl && '' !== $normalizedTag) {
|
||||
$map[$normalizedUrl] = $normalizedTag;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
*
|
||||
* @return array{0:?int,1:?int}
|
||||
*/
|
||||
private function resolveSyncWindow(array $row): array
|
||||
{
|
||||
$pastRange = trim((string) ($row['caldavPastSyncRange'] ?? '1y'));
|
||||
$futureRange = trim((string) ($row['caldavFutureSyncRange'] ?? '2y'));
|
||||
$now = time();
|
||||
|
||||
$fromTimestamp = match ($pastRange) {
|
||||
'none' => $now,
|
||||
'all' => null,
|
||||
'2y' => $now - 2 * 365 * 86400,
|
||||
default => $now - 365 * 86400,
|
||||
};
|
||||
|
||||
$untilTimestamp = match ($futureRange) {
|
||||
'all' => null,
|
||||
'1y' => $now + 365 * 86400,
|
||||
default => $now + 2 * 365 * 86400,
|
||||
};
|
||||
|
||||
return [$fromTimestamp, $untilTimestamp];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,15 +9,23 @@ final class EventMatchResolver
|
||||
/**
|
||||
* @param list<array<string,mixed>> $localEvents
|
||||
*/
|
||||
public function resolve(array $localEvents, RemoteEvent $remoteEvent): ?array
|
||||
public function resolve(array $localEvents, RemoteEvent $remoteEvent, ?int $expectedCalendarId = null): ?array
|
||||
{
|
||||
foreach ($localEvents as $localEvent) {
|
||||
if (!$this->matchesExpectedCalendar($localEvent, $expectedCalendarId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('' !== (string) ($localEvent['caldavHref'] ?? '') && (string) $localEvent['caldavHref'] === $remoteEvent->href) {
|
||||
return $localEvent;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($localEvents as $localEvent) {
|
||||
if (!$this->matchesExpectedCalendar($localEvent, $expectedCalendarId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('' !== (string) ($localEvent['caldavUid'] ?? '') && (string) $localEvent['caldavUid'] === $remoteEvent->uid) {
|
||||
return $localEvent;
|
||||
}
|
||||
@@ -25,4 +33,20 @@ final class EventMatchResolver
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
*/
|
||||
private function matchesExpectedCalendar(array $row, ?int $expectedCalendarId): bool
|
||||
{
|
||||
if (null === $expectedCalendarId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!array_key_exists('pid', $row)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (int) $row['pid'] === $expectedCalendarId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,13 +34,30 @@ final readonly class LocalToRemoteSynchronizer
|
||||
$allLocalEvents,
|
||||
fn (array $event): bool => $config->shouldManageEventForThisRemoteCalendar((string) ($event['caldavCalendarHref'] ?? '')),
|
||||
));
|
||||
$remoteEvents = $this->remoteReader->readEvents($config);
|
||||
$remotePseudoLocalRows = $this->buildRemotePseudoLocalRows($remoteEvents);
|
||||
|
||||
$knownHrefEtags = [];
|
||||
foreach ($localEvents as $localEvent) {
|
||||
$href = trim((string) ($localEvent['caldavHref'] ?? ''));
|
||||
if ('' === $href) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$knownHrefEtags[$href] = trim((string) ($localEvent['caldavEtag'] ?? ''));
|
||||
}
|
||||
|
||||
$remoteData = $this->remoteReader->readEvents($config, $knownHrefEtags, false);
|
||||
$remoteEvents = $remoteData->events;
|
||||
$remotePseudoLocalRows = $this->buildRemotePseudoLocalRows($remoteEvents, $remoteData->hrefEtags);
|
||||
|
||||
$localHrefs = [];
|
||||
$localUids = [];
|
||||
|
||||
foreach ($localEvents as $localEvent) {
|
||||
if (!$this->isEventWithinConfiguredWindow($localEvent, $config)) {
|
||||
++$result->skipped;
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetCalendarUrl = $config->resolveTargetCalendarForLocalEvent((string) ($localEvent['caldavCalendarHref'] ?? ''));
|
||||
if (null === $targetCalendarUrl || $targetCalendarUrl !== $config->caldavUrl) {
|
||||
++$result->skipped;
|
||||
@@ -59,7 +76,7 @@ final readonly class LocalToRemoteSynchronizer
|
||||
$localUids[$uid] = true;
|
||||
}
|
||||
|
||||
$localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent);
|
||||
$localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent, $config->timezoneOrDefault());
|
||||
$currentHash = $this->hashGenerator->generate($localSyncFields);
|
||||
$storedHash = (string) ($localEvent['caldavSyncHash'] ?? '');
|
||||
$localChanged = '' === $storedHash || $storedHash !== $currentHash;
|
||||
@@ -69,7 +86,7 @@ final readonly class LocalToRemoteSynchronizer
|
||||
continue;
|
||||
}
|
||||
|
||||
$remoteMatch = $this->matchResolver->resolve($remotePseudoLocalRows, $this->fieldExtractor->toRemoteEvent($localEvent, $config->timezoneOrDefault()));
|
||||
$remoteMatch = $this->matchResolver->resolve($remotePseudoLocalRows, $this->fieldExtractor->toRemoteEvent($localEvent, $config->timezoneOrDefault()), $config->calendarId);
|
||||
$matchingRemoteEvent = $this->resolveRemoteFromMatch($remoteEvents, $remoteMatch);
|
||||
|
||||
if (null !== $matchingRemoteEvent && '' !== $storedHash) {
|
||||
@@ -164,19 +181,19 @@ final readonly class LocalToRemoteSynchronizer
|
||||
}
|
||||
$localUids[$payloadEvent->uid] = true;
|
||||
|
||||
if (null === $matchingRemoteEvent) {
|
||||
if (null === $matchingRemoteEvent && null === $remoteMatch) {
|
||||
++$result->created;
|
||||
} else {
|
||||
++$result->updated;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($remoteEvents as $remoteEvent) {
|
||||
if (isset($localHrefs[$remoteEvent->href]) || isset($localUids[$remoteEvent->uid])) {
|
||||
foreach ($remoteData->hrefEtags as $remoteHref => $remoteEtag) {
|
||||
if (isset($localHrefs[$remoteHref])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->remoteWriter->deleteEvent($config, $remoteEvent->href, $remoteEvent->etag, $dryRun);
|
||||
$this->remoteWriter->deleteEvent($config, $remoteHref, $remoteEtag, $dryRun);
|
||||
++$result->deleted;
|
||||
}
|
||||
|
||||
@@ -185,13 +202,21 @@ final readonly class LocalToRemoteSynchronizer
|
||||
|
||||
/**
|
||||
* @param list<RemoteEvent> $remoteEvents
|
||||
* @param array<string,string> $hrefEtags
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function buildRemotePseudoLocalRows(array $remoteEvents): array
|
||||
private function buildRemotePseudoLocalRows(array $remoteEvents, array $hrefEtags): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($hrefEtags as $href => $_etag) {
|
||||
$rows[] = [
|
||||
'caldavHref' => $href,
|
||||
'caldavUid' => '',
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($remoteEvents as $remoteEvent) {
|
||||
$rows[] = [
|
||||
'caldavHref' => $remoteEvent->href,
|
||||
@@ -240,4 +265,30 @@ final readonly class LocalToRemoteSynchronizer
|
||||
|
||||
return $localModifiedAt > ($remoteModifiedAt + self::MODIFIED_TIME_SKEW_SECONDS);
|
||||
}
|
||||
|
||||
private function isEventWithinConfiguredWindow(array $localEvent, CalendarSyncConfig $config): bool
|
||||
{
|
||||
if (!$config->hasTimeWindow()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$start = (int) ($localEvent['startTime'] ?? 0);
|
||||
if ($start <= 0) {
|
||||
$start = (int) ($localEvent['startDate'] ?? 0);
|
||||
}
|
||||
|
||||
if ($start <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (null !== $config->syncFromTimestamp && $start < $config->syncFromTimestamp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (null !== $config->syncUntilTimestamp && $start > $config->syncUntilTimestamp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Mummert\CalDavSyncBundle\Sync;
|
||||
|
||||
use Mummert\CalDavSyncBundle\CalDav\Remote\RemoteCalendarReader;
|
||||
use Mummert\CalDavSyncBundle\Config\CalendarSyncConfig;
|
||||
use Mummert\CalDavSyncBundle\Repository\ContaoCalendarRepository;
|
||||
use Mummert\CalDavSyncBundle\Repository\ContaoCalendarEventRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
@@ -15,6 +16,7 @@ final readonly class RemoteToLocalSynchronizer
|
||||
|
||||
public function __construct(
|
||||
private RemoteCalendarReader $remoteReader,
|
||||
private ContaoCalendarRepository $calendarRepository,
|
||||
private ContaoCalendarEventRepository $eventRepository,
|
||||
private EventMatchResolver $matchResolver,
|
||||
private SyncFieldExtractor $fieldExtractor,
|
||||
@@ -26,21 +28,51 @@ final readonly class RemoteToLocalSynchronizer
|
||||
public function synchronize(CalendarSyncConfig $config, bool $dryRun = false): SyncResult
|
||||
{
|
||||
$result = new SyncResult();
|
||||
$remoteEvents = $this->remoteReader->readEvents($config);
|
||||
$allLocalEvents = $this->eventRepository->findByCalendarId($config->calendarId);
|
||||
$localEvents = array_values(array_filter(
|
||||
$allLocalEvents,
|
||||
fn (array $event): bool => $config->shouldManageEventForThisRemoteCalendar((string) ($event['caldavCalendarHref'] ?? '')),
|
||||
));
|
||||
|
||||
$knownHrefEtags = [];
|
||||
foreach ($localEvents as $localEvent) {
|
||||
$href = trim((string) ($localEvent['caldavHref'] ?? ''));
|
||||
if ('' === $href) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$knownHrefEtags[$href] = trim((string) ($localEvent['caldavEtag'] ?? ''));
|
||||
}
|
||||
|
||||
$remoteData = $this->remoteReader->readEvents($config, $knownHrefEtags, true);
|
||||
$remoteEvents = $remoteData->events;
|
||||
|
||||
if (!$dryRun && null !== $remoteData->collectionTag) {
|
||||
$this->calendarRepository->updateCollectionTagForCalendarUrl(
|
||||
$config->calendarId,
|
||||
$config->caldavUrl,
|
||||
$remoteData->collectionTag,
|
||||
);
|
||||
}
|
||||
|
||||
if ($remoteData->collectionTagUnchanged) {
|
||||
$result->skipped += count($localEvents);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$seenHrefs = [];
|
||||
$seenUids = [];
|
||||
|
||||
foreach (array_keys($remoteData->hrefEtags) as $knownRemoteHref) {
|
||||
$seenHrefs[$knownRemoteHref] = true;
|
||||
}
|
||||
|
||||
foreach ($remoteEvents as $remoteEvent) {
|
||||
$seenHrefs[$remoteEvent->href] = true;
|
||||
$seenUids[$remoteEvent->uid] = true;
|
||||
|
||||
$localEvent = $this->matchResolver->resolve($localEvents, $remoteEvent);
|
||||
$localEvent = $this->matchResolver->resolve($localEvents, $remoteEvent, $config->calendarId);
|
||||
$remoteSyncFields = $this->fieldExtractor->extractFromRemoteEvent($remoteEvent);
|
||||
$remoteHash = $this->hashGenerator->generate($remoteSyncFields);
|
||||
$expectedLocalFields = $this->fieldExtractor->applyRemoteToLocalFields($remoteEvent);
|
||||
@@ -75,7 +107,7 @@ final readonly class RemoteToLocalSynchronizer
|
||||
continue;
|
||||
}
|
||||
|
||||
$localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent);
|
||||
$localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent, $config->timezoneOrDefault());
|
||||
$localCurrentHash = $this->hashGenerator->generate($localSyncFields);
|
||||
$storedHash = (string) ($localEvent['caldavSyncHash'] ?? '');
|
||||
$storedEtag = (string) ($localEvent['caldavEtag'] ?? '');
|
||||
@@ -182,6 +214,10 @@ final readonly class RemoteToLocalSynchronizer
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->isEventWithinConfiguredWindow($localEvent, $config)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($seenHrefs[$localHref]) || ('' !== $localUid && isset($seenUids[$localUid]))) {
|
||||
continue;
|
||||
}
|
||||
@@ -213,4 +249,30 @@ final readonly class RemoteToLocalSynchronizer
|
||||
|
||||
return $localModifiedAt > ($remoteModifiedAt + self::MODIFIED_TIME_SKEW_SECONDS);
|
||||
}
|
||||
|
||||
private function isEventWithinConfiguredWindow(array $localEvent, CalendarSyncConfig $config): bool
|
||||
{
|
||||
if (!$config->hasTimeWindow()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$start = (int) ($localEvent['startTime'] ?? 0);
|
||||
if ($start <= 0) {
|
||||
$start = (int) ($localEvent['startDate'] ?? 0);
|
||||
}
|
||||
|
||||
if ($start <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (null !== $config->syncFromTimestamp && $start < $config->syncFromTimestamp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (null !== $config->syncUntilTimestamp && $start > $config->syncUntilTimestamp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+106
-33
@@ -44,25 +44,22 @@ final class SyncFieldExtractor
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function extractFromLocalEvent(array $localEvent): array
|
||||
public function extractFromLocalEvent(array $localEvent, ?string $timezone = null): array
|
||||
{
|
||||
$allDay = '1' !== (string) ($localEvent['addTime'] ?? '');
|
||||
$start = (int) ($localEvent['startTime'] ?? 0);
|
||||
if ($start <= 0) {
|
||||
$start = (int) ($localEvent['startDate'] ?? 0);
|
||||
}
|
||||
$allDay = $this->resolveLocalAllDay($localEvent, $timezone);
|
||||
if ($allDay) {
|
||||
$start = $this->resolveAllDayStartTimestamp($localEvent);
|
||||
$end = $this->resolveAllDayExclusiveEndTimestamp($localEvent, $start);
|
||||
} else {
|
||||
$start = (int) ($localEvent['startTime'] ?? 0);
|
||||
if ($start <= 0) {
|
||||
$start = (int) ($localEvent['startDate'] ?? 0);
|
||||
}
|
||||
|
||||
$end = (int) ($localEvent['endTime'] ?? 0);
|
||||
$usesEndDateFallback = false;
|
||||
|
||||
if ($end <= 0) {
|
||||
$end = (int) ($localEvent['endDate'] ?? 0);
|
||||
$usesEndDateFallback = $end > 0;
|
||||
}
|
||||
|
||||
// Contao stores endDate inclusive for all-day events, CalDAV uses exclusive DTEND.
|
||||
if ($allDay && $usesEndDateFallback) {
|
||||
$end += 86400;
|
||||
$end = (int) ($localEvent['endTime'] ?? 0);
|
||||
if ($end <= 0) {
|
||||
$end = (int) ($localEvent['endDate'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->normalize([
|
||||
@@ -99,23 +96,20 @@ final class SyncFieldExtractor
|
||||
$uid = sprintf('contao-%d-%d@local', (int) ($localEvent['pid'] ?? 0), (int) ($localEvent['id'] ?? 0));
|
||||
}
|
||||
|
||||
$allDay = '1' !== (string) ($localEvent['addTime'] ?? '');
|
||||
$startAt = (int) ($localEvent['startTime'] ?? 0);
|
||||
if ($startAt <= 0) {
|
||||
$startAt = (int) ($localEvent['startDate'] ?? 0);
|
||||
}
|
||||
$allDay = $this->resolveLocalAllDay($localEvent, $timezone);
|
||||
if ($allDay) {
|
||||
$startAt = $this->resolveAllDayStartTimestamp($localEvent);
|
||||
$endAt = $this->resolveAllDayExclusiveEndTimestamp($localEvent, $startAt);
|
||||
} else {
|
||||
$startAt = (int) ($localEvent['startTime'] ?? 0);
|
||||
if ($startAt <= 0) {
|
||||
$startAt = (int) ($localEvent['startDate'] ?? 0);
|
||||
}
|
||||
|
||||
$endAt = (int) ($localEvent['endTime'] ?? 0);
|
||||
$usesEndDateFallback = false;
|
||||
|
||||
if ($endAt <= 0) {
|
||||
$endAt = (int) ($localEvent['endDate'] ?? 0);
|
||||
$usesEndDateFallback = $endAt > 0;
|
||||
}
|
||||
|
||||
// Convert inclusive local endDate to exclusive CalDAV DTEND for all-day events.
|
||||
if ($allDay && $usesEndDateFallback) {
|
||||
$endAt += 86400;
|
||||
$endAt = (int) ($localEvent['endTime'] ?? 0);
|
||||
if ($endAt <= 0) {
|
||||
$endAt = (int) ($localEvent['endDate'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
if ($endAt <= $startAt) {
|
||||
@@ -257,4 +251,83 @@ final class SyncFieldExtractor
|
||||
{
|
||||
return gmdate('Ymd', $leftTimestamp) === gmdate('Ymd', $rightTimestamp);
|
||||
}
|
||||
|
||||
private function resolveLocalAllDay(array $localEvent, ?string $timezone = null): bool
|
||||
{
|
||||
if ('1' !== (string) ($localEvent['addTime'] ?? '')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback for synced events where Contao save can leave addTime=1
|
||||
// although timestamps still represent an all-day event.
|
||||
if (!$this->hasSyncMarker($localEvent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$startDate = (int) ($localEvent['startDate'] ?? 0);
|
||||
$endDate = (int) ($localEvent['endDate'] ?? 0);
|
||||
$startTime = (int) ($localEvent['startTime'] ?? 0);
|
||||
$endTime = (int) ($localEvent['endTime'] ?? 0);
|
||||
|
||||
if ($startDate <= 0 || $startTime <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sameStartBoundary = $startTime === $startDate || $this->isMidnightInTimezone($startTime, $timezone);
|
||||
$singleDayOrEmptyEndDate = $endDate <= 0 || $endDate === $startDate;
|
||||
$endAtBoundary = $endTime <= 0
|
||||
|| $endTime === $startTime
|
||||
|| $endTime === $startDate
|
||||
|| ($endDate > 0 && $endTime === $endDate)
|
||||
|| $this->isMidnightInTimezone($endTime, $timezone)
|
||||
;
|
||||
|
||||
return $sameStartBoundary && $singleDayOrEmptyEndDate && $endAtBoundary;
|
||||
}
|
||||
|
||||
private function hasSyncMarker(array $localEvent): bool
|
||||
{
|
||||
return '' !== trim((string) ($localEvent['caldavHref'] ?? ''))
|
||||
|| '' !== trim((string) ($localEvent['caldavUid'] ?? ''))
|
||||
|| '' !== trim((string) ($localEvent['caldavOrigin'] ?? ''));
|
||||
}
|
||||
|
||||
private function isMidnightInTimezone(int $timestamp, ?string $timezone): bool
|
||||
{
|
||||
if ($timestamp <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$tz = new DateTimeZone(null !== $timezone && '' !== trim($timezone) ? $timezone : 'UTC');
|
||||
} catch (\Throwable) {
|
||||
$tz = new DateTimeZone('UTC');
|
||||
}
|
||||
|
||||
return '00:00:00' === (new DateTimeImmutable('@'.$timestamp))->setTimezone($tz)->format('H:i:s');
|
||||
}
|
||||
|
||||
private function resolveAllDayStartTimestamp(array $localEvent): int
|
||||
{
|
||||
$startDate = (int) ($localEvent['startDate'] ?? 0);
|
||||
if ($startDate > 0) {
|
||||
return $startDate;
|
||||
}
|
||||
|
||||
return (int) ($localEvent['startTime'] ?? 0);
|
||||
}
|
||||
|
||||
private function resolveAllDayExclusiveEndTimestamp(array $localEvent, int $startTimestamp): int
|
||||
{
|
||||
$endDate = (int) ($localEvent['endDate'] ?? 0);
|
||||
if ($endDate > 0) {
|
||||
if ($endDate >= $startTimestamp) {
|
||||
return $endDate + 86400;
|
||||
}
|
||||
|
||||
return $startTimestamp + 86400;
|
||||
}
|
||||
|
||||
return $startTimestamp + 86400;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user