Fixing the delta sync for caldav.

This commit is contained in:
Burak Kaan Köse
2026-02-18 20:43:10 +01:00
parent 7a13ae0ac8
commit ab0810f710
9 changed files with 459 additions and 44 deletions
+198 -8
View File
@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Itenso.TimePeriod;
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
@@ -94,7 +95,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
_clientPool = new ImapClientPool(poolOptions);
_localCalendarOperationHandler = new LocalCalendarOperationHandler(Account, _imapChangeProcessor, _calendarService, "local");
_calDavCalendarOperationHandler = new CalDavCalendarOperationHandler(Account, _imapChangeProcessor, _calendarService);
_calDavCalendarOperationHandler = new CalDavCalendarOperationHandler(this, Account, _calendarService, _calDavClient);
}
private Stream CreateAccountProtocolLogFileStream()
@@ -1190,9 +1191,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
if (!remoteCalendarsById.TryGetValue(localCalendar.RemoteCalendarId, out var remoteCalendar))
continue;
var remoteToken = !string.IsNullOrWhiteSpace(remoteCalendar.SyncToken)
? remoteCalendar.SyncToken
: remoteCalendar.CTag;
var remoteToken = BuildCalendarDeltaToken(remoteCalendar);
var isInitialSync = string.IsNullOrWhiteSpace(localCalendar.SynchronizationDeltaToken);
var tokenChanged = !string.Equals(localCalendar.SynchronizationDeltaToken, remoteToken, StringComparison.Ordinal);
@@ -1207,9 +1206,26 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
periodStartUtc,
periodEndUtc,
cancellationToken).ConfigureAwait(false);
var remoteEventIds = new HashSet<string>(
remoteEvents
.Where(e => !string.IsNullOrWhiteSpace(e.RemoteEventId))
.Select(e => e.RemoteEventId),
StringComparer.OrdinalIgnoreCase);
foreach (var remoteEvent in remoteEvents)
{
var existingLocalItem = await _imapChangeProcessor
.GetCalendarItemAsync(localCalendar.Id, remoteEvent.RemoteEventId)
.ConfigureAwait(false);
var shouldSkipUnchangedEvent = await ShouldSkipUnchangedCalDavEventAsync(
localCalendar,
existingLocalItem,
remoteEvent).ConfigureAwait(false);
if (shouldSkipUnchangedEvent)
continue;
await _imapChangeProcessor
.ManageCalendarEventAsync(remoteEvent, localCalendar, Account)
.ConfigureAwait(false);
@@ -1217,7 +1233,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
if (string.IsNullOrWhiteSpace(remoteEvent.IcsContent))
continue;
var localItem = await _imapChangeProcessor
var localItem = existingLocalItem ?? await _imapChangeProcessor
.GetCalendarItemAsync(localCalendar.Id, remoteEvent.RemoteEventId)
.ConfigureAwait(false);
@@ -1236,6 +1252,9 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
.ConfigureAwait(false);
}
await ReconcileDeletedCalendarItemsAsync(localCalendar, periodStartUtc, periodEndUtc, remoteEventIds)
.ConfigureAwait(false);
localCalendar.SynchronizationDeltaToken = remoteToken;
await _imapChangeProcessor.UpdateAccountCalendarAsync(localCalendar).ConfigureAwait(false);
}
@@ -1243,6 +1262,71 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
return CalendarSynchronizationResult.Empty;
}
private async Task<bool> ShouldSkipUnchangedCalDavEventAsync(
AccountCalendar localCalendar,
CalendarItem existingLocalItem,
CalDavCalendarEvent remoteEvent)
{
if (localCalendar == null || existingLocalItem == null || remoteEvent == null)
return false;
// Ensure unresolved parent-child linkage still gets corrected when required.
if (!string.IsNullOrWhiteSpace(remoteEvent.SeriesMasterRemoteEventId) &&
existingLocalItem.RecurringCalendarItemId == null)
{
return false;
}
if (string.IsNullOrWhiteSpace(remoteEvent.ETag))
return false;
var savedETag = await _imapChangeProcessor
.GetCalendarItemIcsETagAsync(Account.Id, localCalendar.Id, existingLocalItem.Id)
.ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(savedETag))
return false;
return string.Equals(savedETag.Trim(), remoteEvent.ETag.Trim(), StringComparison.Ordinal);
}
private async Task ReconcileDeletedCalendarItemsAsync(
AccountCalendar localCalendar,
DateTimeOffset periodStartUtc,
DateTimeOffset periodEndUtc,
HashSet<string> remoteEventIds)
{
var syncPeriod = new TimeRange(periodStartUtc.UtcDateTime, periodEndUtc.UtcDateTime);
var localEventsInWindow = await _calendarService
.GetCalendarEventsAsync(localCalendar, syncPeriod)
.ConfigureAwait(false);
foreach (var localEvent in localEventsInWindow)
{
if (string.IsNullOrWhiteSpace(localEvent.RemoteEventId))
continue;
if (remoteEventIds.Contains(localEvent.RemoteEventId))
continue;
await _imapChangeProcessor.DeleteCalendarItemAsync(localEvent.Id).ConfigureAwait(false);
}
}
private static string BuildCalendarDeltaToken(CalDavCalendar calendar)
{
if (calendar == null)
return string.Empty;
var syncToken = calendar.SyncToken?.Trim() ?? string.Empty;
var ctag = calendar.CTag?.Trim() ?? string.Empty;
if (!string.IsNullOrWhiteSpace(syncToken) && !string.IsNullOrWhiteSpace(ctag))
return $"{syncToken}|{ctag}";
return !string.IsNullOrWhiteSpace(syncToken) ? syncToken : ctag;
}
private async Task<Uri> ResolveCalDavServiceUriAsync(CancellationToken cancellationToken)
{
var explicitCalDavUri = TryGetExplicitCalDavServiceUri();
@@ -1526,11 +1610,117 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
}
}
private sealed class CalDavCalendarOperationHandler : LocalCalendarOperationHandler
private sealed class CalDavCalendarOperationHandler : IImapCalendarOperationHandler
{
public CalDavCalendarOperationHandler(MailAccount account, IImapChangeProcessor changeProcessor, ICalendarService calendarService)
: base(account, changeProcessor, calendarService, "caldav")
private readonly ImapSynchronizer _owner;
private readonly MailAccount _account;
private readonly ICalendarService _calendarService;
private readonly ICalDavClient _calDavClient;
public bool RequiresConnectedClient => false;
public CalDavCalendarOperationHandler(
ImapSynchronizer owner,
MailAccount account,
ICalendarService calendarService,
ICalDavClient calDavClient)
{
_owner = owner;
_account = account;
_calendarService = calendarService;
_calDavClient = calDavClient;
}
public Task CreateCalendarEventAsync(CreateCalendarEventRequest request)
=> UpsertCalendarEventAsync(request.Item, request.Attendees);
public Task UpdateCalendarEventAsync(UpdateCalendarEventRequest request)
=> UpsertCalendarEventAsync(request.Item, request.Attendees);
public async Task DeleteCalendarEventAsync(DeleteCalendarEventRequest request)
{
var (connection, calendar) = await ResolveCalDavContextAsync(request.Item.CalendarId).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(request.Item?.RemoteEventId))
{
throw new InvalidOperationException("Cannot delete CalDAV event because remote event ID is missing.");
}
await _calDavClient
.DeleteCalendarEventAsync(connection, calendar, request.Item.RemoteEventId)
.ConfigureAwait(false);
}
public Task AcceptEventAsync(AcceptEventRequest request)
{
request.Item.Status = CalendarItemStatus.Accepted;
return UpsertCalendarEventAsync(request.Item, null);
}
public Task DeclineEventAsync(DeclineEventRequest request)
{
request.Item.Status = CalendarItemStatus.Cancelled;
return UpsertCalendarEventAsync(request.Item, null);
}
public Task TentativeEventAsync(TentativeEventRequest request)
{
request.Item.Status = CalendarItemStatus.Tentative;
return UpsertCalendarEventAsync(request.Item, null);
}
private async Task UpsertCalendarEventAsync(CalendarItem item, List<CalendarEventAttendee> attendees)
{
EnsureCalendarItemDefaults(item, _account, "caldav");
if (attendees == null)
{
attendees = await _calendarService.GetAttendeesAsync(item.Id).ConfigureAwait(false);
}
var (connection, calendar) = await ResolveCalDavContextAsync(item.CalendarId).ConfigureAwait(false);
var icsContent = BuildIcsContent(item, attendees);
await _calDavClient
.UpsertCalendarEventAsync(connection, calendar, item.RemoteEventId, icsContent)
.ConfigureAwait(false);
}
private async Task<(CalDavConnectionSettings Connection, CalDavCalendar Calendar)> ResolveCalDavContextAsync(Guid calendarId)
{
var assignedCalendar = await _calendarService.GetAccountCalendarAsync(calendarId).ConfigureAwait(false);
if (assignedCalendar == null || string.IsNullOrWhiteSpace(assignedCalendar.RemoteCalendarId))
{
throw new InvalidOperationException("Cannot execute CalDAV operation because the target calendar has no remote ID.");
}
var serviceUri = await _owner.ResolveCalDavServiceUriAsync(CancellationToken.None).ConfigureAwait(false);
if (serviceUri == null)
{
throw new InvalidOperationException("Cannot execute CalDAV operation because no CalDAV service URI is configured.");
}
var username = _owner.ResolveCalDavUsername();
var password = _owner.ResolveCalDavPassword();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
throw new InvalidOperationException("Cannot execute CalDAV operation because credentials are missing.");
}
var connection = new CalDavConnectionSettings
{
ServiceUri = serviceUri,
Username = username,
Password = password
};
var remoteCalendar = new CalDavCalendar
{
RemoteCalendarId = assignedCalendar.RemoteCalendarId,
Name = assignedCalendar.Name
};
return (connection, remoteCalendar);
}
}