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