Release: CalDAV sync bundle hardening and LMW sync
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user