Release: CalDAV sync bundle hardening and LMW sync

This commit is contained in:
Jürgen Mummert
2026-03-27 22:16:48 +01:00
commit c6f63a56a9
36 changed files with 2993 additions and 0 deletions
+243
View File
@@ -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);
}
}