295 lines
11 KiB
PHP
295 lines
11 KiB
PHP
<?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'] ?? '')),
|
|
));
|
|
|
|
$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;
|
|
|
|
continue;
|
|
}
|
|
|
|
$href = (string) ($localEvent['caldavHref'] ?? '');
|
|
$uid = (string) ($localEvent['caldavUid'] ?? '');
|
|
|
|
if ('' !== $href) {
|
|
$localHrefs[$href] = true;
|
|
}
|
|
|
|
if ('' !== $uid) {
|
|
$localUids[$uid] = true;
|
|
}
|
|
|
|
$localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent, $config->timezoneOrDefault());
|
|
$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()), $config->calendarId);
|
|
$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 && null === $remoteMatch) {
|
|
++$result->created;
|
|
} else {
|
|
++$result->updated;
|
|
}
|
|
}
|
|
|
|
foreach ($remoteData->hrefEtags as $remoteHref => $remoteEtag) {
|
|
if (isset($localHrefs[$remoteHref])) {
|
|
continue;
|
|
}
|
|
|
|
$this->remoteWriter->deleteEvent($config, $remoteHref, $remoteEtag, $dryRun);
|
|
++$result->deleted;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @param list<RemoteEvent> $remoteEvents
|
|
* @param array<string,string> $hrefEtags
|
|
*
|
|
* @return list<array<string,mixed>>
|
|
*/
|
|
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,
|
|
'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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|