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
+28
View File
@@ -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;
}
}
+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);
}
}
+28
View File
@@ -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,
) {
}
}
+216
View File
@@ -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);
}
}
+260
View File
@@ -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);
}
}
+18
View File
@@ -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));
}
}
+23
View File
@@ -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;
}
}
+95
View File
@@ -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;
}
}