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
@@ -230,7 +230,7 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
var t = new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions() var t = new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions()
{ {
AccountId = account.Id, AccountId = account.Id,
Type = CalendarSynchronizationType.CalendarMetadata Type = CalendarSynchronizationType.CalendarEvents
}); });
Messenger.Send(t); Messenger.Send(t);
@@ -921,6 +921,56 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
} }
} }
private CalendarItemViewModel FindPendingBusyMatchByRemoteEventId(CalendarItem syncedItem)
{
if (syncedItem == null ||
string.IsNullOrWhiteSpace(syncedItem.RemoteEventId) ||
!TryExtractClientItemIdFromRemoteEventId(syncedItem.RemoteEventId, out var clientItemId))
{
return null;
}
return DayRanges
.SelectMany(a => a.CalendarDays)
.SelectMany(b => b.EventsCollection.RegularEvents.Concat(b.EventsCollection.AllDayEvents))
.OfType<CalendarItemViewModel>()
.FirstOrDefault(vm => vm.IsBusy &&
vm.Id == clientItemId &&
vm.AssignedCalendar?.Id == syncedItem.CalendarId);
}
private static bool TryExtractClientItemIdFromRemoteEventId(string remoteEventId, out Guid clientItemId)
{
clientItemId = Guid.Empty;
if (string.IsNullOrWhiteSpace(remoteEventId))
return false;
var uid = remoteEventId.Split(new[] { "::" }, StringSplitOptions.None)[0];
const string calDavPrefix = "caldav-";
if (!uid.StartsWith(calDavPrefix, StringComparison.OrdinalIgnoreCase))
return false;
var guidPart = uid[calDavPrefix.Length..];
return Guid.TryParseExact(guidPart, "N", out clientItemId) || Guid.TryParse(guidPart, out clientItemId);
}
private void RemoveCalendarItemEverywhere(Guid calendarItemId)
{
foreach (var dayRange in DayRanges)
{
foreach (var calendarDay in dayRange.CalendarDays)
{
var existingItem = calendarDay.EventsCollection.GetCalendarItem(calendarItemId);
if (existingItem != null)
{
calendarDay.EventsCollection.RemoveCalendarItem(existingItem);
}
}
}
}
public void Receive(CalendarItemTappedMessage message) public void Receive(CalendarItemTappedMessage message)
{ {
if (message.CalendarItemViewModel == null) return; if (message.CalendarItemViewModel == null) return;
@@ -1088,43 +1138,19 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
// Check if event falls into the current date range. // Check if event falls into the current date range.
if (DayRanges.DisplayRange == null) return; if (DayRanges.DisplayRange == null) return;
// Check if this is a server-synced item that matches a local preview // If this is server data, reconcile against optimistic client-side items first.
// Local previews don't have RemoteEventId, server-synced items do // This prevents duplicate rendering when a pending busy item is replaced by the synced one.
if (!string.IsNullOrEmpty(calendarItem.RemoteEventId)) if (!string.IsNullOrEmpty(calendarItem.RemoteEventId))
{ {
// Find local preview items that match this event's properties var pendingMatch = FindPendingBusyMatchByRemoteEventId(calendarItem);
var localPreviewItems = DayRanges
.SelectMany(a => a.CalendarDays)
.SelectMany(b => b.EventsCollection.RegularEvents.Concat(b.EventsCollection.AllDayEvents))
.OfType<CalendarItemViewModel>()
.Where(c => c.AssignedCalendar.Id == calendarItem.CalendarId &&
c.CalendarItem.IsLocalPreview && // Local preview (no RemoteEventId)
c.Title == calendarItem.Title &&
Math.Abs((c.StartDate - calendarItem.LocalStartDate).TotalSeconds) < 60 &&
Math.Abs(c.DurationInSeconds - calendarItem.DurationInSeconds) < 1)
.ToList();
if (localPreviewItems.Any()) if (pendingMatch != null)
{ {
Debug.WriteLine($"Found {localPreviewItems.Count} matching local preview items for {calendarItem.Title}, removing them."); Debug.WriteLine($"Mapped pending busy item {pendingMatch.Id} with synced server event {calendarItem.Id}.");
// Remove all matching local preview items
await ExecuteUIThread(() => await ExecuteUIThread(() =>
{ {
foreach (var dayRange in DayRanges) RemoveCalendarItemEverywhere(pendingMatch.Id);
{
foreach (var calendarDay in dayRange.CalendarDays)
{
foreach (var localPreview in localPreviewItems)
{
var itemInDay = calendarDay.EventsCollection.GetCalendarItem(localPreview.Id);
if (itemInDay != null)
{
calendarDay.EventsCollection.RemoveCalendarItem(itemInDay);
}
}
}
}
}); });
} }
} }
@@ -18,5 +18,18 @@ public interface ICalDavClient
DateTimeOffset startUtc, DateTimeOffset startUtc,
DateTimeOffset endUtc, DateTimeOffset endUtc,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task UpsertCalendarEventAsync(
CalDavConnectionSettings connectionSettings,
CalDavCalendar calendar,
string remoteEventId,
string icsContent,
CancellationToken cancellationToken = default);
Task DeleteCalendarEventAsync(
CalDavConnectionSettings connectionSettings,
CalDavCalendar calendar,
string remoteEventId,
CancellationToken cancellationToken = default);
} }
@@ -9,6 +9,7 @@ namespace Wino.Core.Domain.Interfaces;
public interface ICalendarIcsFileService public interface ICalendarIcsFileService
{ {
Task SaveCalendarItemIcsAsync(Guid accountId, Guid calendarId, Guid calendarItemId, string remoteEventId, string remoteResourceHref, string eTag, string icsContent); Task SaveCalendarItemIcsAsync(Guid accountId, Guid calendarId, Guid calendarItemId, string remoteEventId, string remoteResourceHref, string eTag, string icsContent);
Task<string> GetCalendarItemIcsETagAsync(Guid accountId, Guid calendarId, Guid calendarItemId);
Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId); Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId);
Task DeleteCalendarIcsForCalendarAsync(Guid accountId, Guid calendarId); Task DeleteCalendarIcsForCalendarAsync(Guid accountId, Guid calendarId);
} }
@@ -121,6 +121,7 @@ public interface IImapChangeProcessor : IDefaultChangeProcessor
Task ManageCalendarEventAsync(CalDavCalendarEvent calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount); Task ManageCalendarEventAsync(CalDavCalendarEvent calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount);
Task SaveCalendarItemIcsAsync(Guid accountId, Guid calendarId, Guid calendarItemId, string remoteEventId, string remoteResourceHref, string eTag, string icsContent); Task SaveCalendarItemIcsAsync(Guid accountId, Guid calendarId, Guid calendarItemId, string remoteEventId, string remoteResourceHref, string eTag, string icsContent);
Task<string> GetCalendarItemIcsETagAsync(Guid accountId, Guid calendarId, Guid calendarItemId);
Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId); Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId);
Task DeleteCalendarIcsForCalendarAsync(Guid accountId, Guid calendarId); Task DeleteCalendarIcsForCalendarAsync(Guid accountId, Guid calendarId);
} }
@@ -131,6 +131,9 @@ public class ImapChangeProcessor : DefaultChangeProcessor, IImapChangeProcessor
public Task SaveCalendarItemIcsAsync(Guid accountId, Guid calendarId, Guid calendarItemId, string remoteEventId, string remoteResourceHref, string eTag, string icsContent) public Task SaveCalendarItemIcsAsync(Guid accountId, Guid calendarId, Guid calendarItemId, string remoteEventId, string remoteResourceHref, string eTag, string icsContent)
=> _calendarIcsFileService.SaveCalendarItemIcsAsync(accountId, calendarId, calendarItemId, remoteEventId, remoteResourceHref, eTag, icsContent); => _calendarIcsFileService.SaveCalendarItemIcsAsync(accountId, calendarId, calendarItemId, remoteEventId, remoteResourceHref, eTag, icsContent);
public Task<string> GetCalendarItemIcsETagAsync(Guid accountId, Guid calendarId, Guid calendarItemId)
=> _calendarIcsFileService.GetCalendarItemIcsETagAsync(accountId, calendarId, calendarItemId);
public Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId) public Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId)
=> _calendarIcsFileService.DeleteCalendarItemIcsAsync(accountId, calendarItemId); => _calendarIcsFileService.DeleteCalendarItemIcsAsync(accountId, calendarItemId);
+198 -8
View File
@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using Itenso.TimePeriod;
using MailKit; using MailKit;
using MailKit.Net.Imap; using MailKit.Net.Imap;
using MailKit.Search; using MailKit.Search;
@@ -94,7 +95,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
_clientPool = new ImapClientPool(poolOptions); _clientPool = new ImapClientPool(poolOptions);
_localCalendarOperationHandler = new LocalCalendarOperationHandler(Account, _imapChangeProcessor, _calendarService, "local"); _localCalendarOperationHandler = new LocalCalendarOperationHandler(Account, _imapChangeProcessor, _calendarService, "local");
_calDavCalendarOperationHandler = new CalDavCalendarOperationHandler(Account, _imapChangeProcessor, _calendarService); _calDavCalendarOperationHandler = new CalDavCalendarOperationHandler(this, Account, _calendarService, _calDavClient);
} }
private Stream CreateAccountProtocolLogFileStream() private Stream CreateAccountProtocolLogFileStream()
@@ -1190,9 +1191,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
if (!remoteCalendarsById.TryGetValue(localCalendar.RemoteCalendarId, out var remoteCalendar)) if (!remoteCalendarsById.TryGetValue(localCalendar.RemoteCalendarId, out var remoteCalendar))
continue; continue;
var remoteToken = !string.IsNullOrWhiteSpace(remoteCalendar.SyncToken) var remoteToken = BuildCalendarDeltaToken(remoteCalendar);
? remoteCalendar.SyncToken
: remoteCalendar.CTag;
var isInitialSync = string.IsNullOrWhiteSpace(localCalendar.SynchronizationDeltaToken); var isInitialSync = string.IsNullOrWhiteSpace(localCalendar.SynchronizationDeltaToken);
var tokenChanged = !string.Equals(localCalendar.SynchronizationDeltaToken, remoteToken, StringComparison.Ordinal); var tokenChanged = !string.Equals(localCalendar.SynchronizationDeltaToken, remoteToken, StringComparison.Ordinal);
@@ -1207,9 +1206,26 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
periodStartUtc, periodStartUtc,
periodEndUtc, periodEndUtc,
cancellationToken).ConfigureAwait(false); 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) 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 await _imapChangeProcessor
.ManageCalendarEventAsync(remoteEvent, localCalendar, Account) .ManageCalendarEventAsync(remoteEvent, localCalendar, Account)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -1217,7 +1233,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
if (string.IsNullOrWhiteSpace(remoteEvent.IcsContent)) if (string.IsNullOrWhiteSpace(remoteEvent.IcsContent))
continue; continue;
var localItem = await _imapChangeProcessor var localItem = existingLocalItem ?? await _imapChangeProcessor
.GetCalendarItemAsync(localCalendar.Id, remoteEvent.RemoteEventId) .GetCalendarItemAsync(localCalendar.Id, remoteEvent.RemoteEventId)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -1236,6 +1252,9 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
.ConfigureAwait(false); .ConfigureAwait(false);
} }
await ReconcileDeletedCalendarItemsAsync(localCalendar, periodStartUtc, periodEndUtc, remoteEventIds)
.ConfigureAwait(false);
localCalendar.SynchronizationDeltaToken = remoteToken; localCalendar.SynchronizationDeltaToken = remoteToken;
await _imapChangeProcessor.UpdateAccountCalendarAsync(localCalendar).ConfigureAwait(false); await _imapChangeProcessor.UpdateAccountCalendarAsync(localCalendar).ConfigureAwait(false);
} }
@@ -1243,6 +1262,71 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
return CalendarSynchronizationResult.Empty; 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) private async Task<Uri> ResolveCalDavServiceUriAsync(CancellationToken cancellationToken)
{ {
var explicitCalDavUri = TryGetExplicitCalDavServiceUri(); 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) private readonly ImapSynchronizer _owner;
: base(account, changeProcessor, calendarService, "caldav") 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);
} }
} }
+144 -5
View File
@@ -23,6 +23,8 @@ public sealed class CalDavClient : ICalDavClient
{ {
private static readonly HttpMethod PropFindMethod = new("PROPFIND"); private static readonly HttpMethod PropFindMethod = new("PROPFIND");
private static readonly HttpMethod ReportMethod = new("REPORT"); private static readonly HttpMethod ReportMethod = new("REPORT");
private static readonly HttpMethod PutMethod = HttpMethod.Put;
private static readonly HttpMethod DeleteMethod = HttpMethod.Delete;
private static readonly ILogger Logger = Log.ForContext<CalDavClient>(); private static readonly ILogger Logger = Log.ForContext<CalDavClient>();
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
@@ -128,6 +130,58 @@ public sealed class CalDavClient : ICalDavClient
.ToList(); .ToList();
} }
public Task UpsertCalendarEventAsync(
CalDavConnectionSettings connectionSettings,
CalDavCalendar calendar,
string remoteEventId,
string icsContent,
CancellationToken cancellationToken = default)
{
ValidateConnectionSettings(connectionSettings);
if (calendar == null || string.IsNullOrWhiteSpace(calendar.RemoteCalendarId))
throw new ArgumentException("Calendar remote ID is required for CalDAV writes.");
if (string.IsNullOrWhiteSpace(remoteEventId))
throw new ArgumentException("Remote event ID is required for CalDAV writes.");
if (string.IsNullOrWhiteSpace(icsContent))
throw new ArgumentException("ICS content is required for CalDAV writes.");
var resourceUri = BuildEventResourceUri(calendar.RemoteCalendarId, remoteEventId);
return SendAsync(
connectionSettings,
PutMethod,
resourceUri,
new StringContent(icsContent, Encoding.UTF8, "text/calendar"),
cancellationToken);
}
public Task DeleteCalendarEventAsync(
CalDavConnectionSettings connectionSettings,
CalDavCalendar calendar,
string remoteEventId,
CancellationToken cancellationToken = default)
{
ValidateConnectionSettings(connectionSettings);
if (calendar == null || string.IsNullOrWhiteSpace(calendar.RemoteCalendarId))
throw new ArgumentException("Calendar remote ID is required for CalDAV deletes.");
if (string.IsNullOrWhiteSpace(remoteEventId))
throw new ArgumentException("Remote event ID is required for CalDAV deletes.");
var resourceUri = BuildEventResourceUri(calendar.RemoteCalendarId, remoteEventId);
return SendAsync(
connectionSettings,
DeleteMethod,
resourceUri,
null,
cancellationToken);
}
private static void ValidateConnectionSettings(CalDavConnectionSettings connectionSettings) private static void ValidateConnectionSettings(CalDavConnectionSettings connectionSettings)
{ {
if (connectionSettings?.ServiceUri == null) if (connectionSettings?.ServiceUri == null)
@@ -242,6 +296,33 @@ public sealed class CalDavClient : ICalDavClient
return XDocument.Parse(xml); return XDocument.Parse(xml);
} }
private async Task SendAsync(
CalDavConnectionSettings connectionSettings,
HttpMethod method,
Uri uri,
HttpContent content,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(method, uri);
request.Headers.Authorization = new AuthenticationHeaderValue(
"Basic",
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{connectionSettings.Username}:{connectionSettings.Password}")));
if (content != null)
request.Content = content;
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
throw new UnauthorizedAccessException("CalDAV authorization failed.");
if (!response.IsSuccessStatusCode)
{
var failureBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new HttpRequestException($"CalDAV request failed ({(int)response.StatusCode}): {failureBody}");
}
}
private static List<CalDavCalendar> ParseCalendarCollection(XDocument xml, Uri baseUri) private static List<CalDavCalendar> ParseCalendarCollection(XDocument xml, Uri baseUri)
{ {
var result = new List<CalDavCalendar>(); var result = new List<CalDavCalendar>();
@@ -501,12 +582,14 @@ public sealed class CalDavClient : ICalDavClient
end = start.AddHours(1); end = start.AddHours(1);
var status = MapStatus(sourceEvent.Status); var status = MapStatus(sourceEvent.Status);
var organizerEmail = NormalizeCalendarEmail(sourceEvent.Organizer?.Value);
var organizerDisplayName = NormalizeOrganizerDisplayName(sourceEvent.Organizer?.CommonName, organizerEmail);
var attendees = sourceEvent.Attendees? var attendees = sourceEvent.Attendees?
.Where(a => a != null && a.Value != null) .Where(a => a != null && a.Value != null)
.Select(a => new CalDavEventAttendee .Select(a => new CalDavEventAttendee
{ {
Name = a.CommonName ?? string.Empty,
Email = NormalizeCalendarEmail(a.Value), Email = NormalizeCalendarEmail(a.Value),
Name = NormalizeAttendeeName(a.CommonName, NormalizeCalendarEmail(a.Value)),
AttendenceStatus = MapAttendeeStatus(a.ParticipationStatus), AttendenceStatus = MapAttendeeStatus(a.ParticipationStatus),
IsOrganizer = string.Equals(a.Role, "CHAIR", StringComparison.OrdinalIgnoreCase), IsOrganizer = string.Equals(a.Role, "CHAIR", StringComparison.OrdinalIgnoreCase),
IsOptionalAttendee = string.Equals(a.Role, "OPT-PARTICIPANT", StringComparison.OrdinalIgnoreCase) IsOptionalAttendee = string.Equals(a.Role, "OPT-PARTICIPANT", StringComparison.OrdinalIgnoreCase)
@@ -544,8 +627,8 @@ public sealed class CalDavClient : ICalDavClient
StartTimeZone = sourceEvent.Start?.TzId ?? string.Empty, StartTimeZone = sourceEvent.Start?.TzId ?? string.Empty,
EndTimeZone = sourceEvent.End?.TzId ?? string.Empty, EndTimeZone = sourceEvent.End?.TzId ?? string.Empty,
Recurrence = recurrence, Recurrence = recurrence,
OrganizerDisplayName = sourceEvent.Organizer?.CommonName ?? string.Empty, OrganizerDisplayName = organizerDisplayName,
OrganizerEmail = NormalizeCalendarEmail(sourceEvent.Organizer?.Value), OrganizerEmail = organizerEmail,
Status = status, Status = status,
Visibility = MapVisibility(sourceEvent.Class), Visibility = MapVisibility(sourceEvent.Class),
ShowAs = MapShowAs(sourceEvent.Transparency), ShowAs = MapShowAs(sourceEvent.Transparency),
@@ -656,13 +739,57 @@ public sealed class CalDavClient : ICalDavClient
if (emailUri == null) if (emailUri == null)
return string.Empty; return string.Empty;
var value = emailUri.OriginalString; var value = emailUri.OriginalString?.Trim();
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
if (value.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase)) if (value.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase))
value = value[7..]; value = value[7..].Trim();
// CalDAV providers may use non-mail identifiers (urn:uuid, principal paths, etc.) here.
// Keep only valid email-like values.
if (!value.Contains('@'))
return string.Empty;
return value; return value;
} }
private static string NormalizeAttendeeName(string attendeeName, string attendeeEmail)
{
var normalizedName = NormalizeOrganizerDisplayName(attendeeName, attendeeEmail);
return string.IsNullOrWhiteSpace(normalizedName) ? attendeeEmail : normalizedName;
}
private static string NormalizeOrganizerDisplayName(string organizerDisplayName, string organizerEmail)
{
var normalizedName = organizerDisplayName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(normalizedName))
return organizerEmail ?? string.Empty;
if (LooksLikeOpaqueIdentifier(normalizedName))
return organizerEmail ?? normalizedName;
return normalizedName;
}
private static bool LooksLikeOpaqueIdentifier(string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
if (Guid.TryParse(value, out _))
return true;
if (value.StartsWith("urn:", StringComparison.OrdinalIgnoreCase))
return true;
if (value.Contains("/", StringComparison.Ordinal) && !value.Contains('@'))
return true;
return false;
}
private static Uri CreateAbsoluteUri(Uri baseUri, string href) private static Uri CreateAbsoluteUri(Uri baseUri, string href)
{ {
if (Uri.TryCreate(href, UriKind.Absolute, out var absolute)) if (Uri.TryCreate(href, UriKind.Absolute, out var absolute))
@@ -671,6 +798,18 @@ public sealed class CalDavClient : ICalDavClient
return new Uri(baseUri, href); return new Uri(baseUri, href);
} }
private static Uri BuildEventResourceUri(string remoteCalendarId, string remoteEventId)
{
var calendarUri = new Uri($"{remoteCalendarId.TrimEnd('/')}/");
var normalizedEventId = remoteEventId.Split(new[] { "::" }, StringSplitOptions.None)[0];
var safeEventId = Uri.EscapeDataString(normalizedEventId);
var fileName = safeEventId.EndsWith(".ics", StringComparison.OrdinalIgnoreCase)
? safeEventId
: $"{safeEventId}.ics";
return new Uri(calendarUri, fileName);
}
private sealed record CalDavEventResponse(string Href, string ETag, string CalendarData); private sealed record CalDavEventResponse(string Href, string ETag, string CalendarData);
} }
+42
View File
@@ -45,6 +45,37 @@ public class CalendarIcsFileService : ICalendarIcsFileService
} }
} }
public async Task<string> GetCalendarItemIcsETagAsync(Guid accountId, Guid calendarId, Guid calendarItemId)
{
if (accountId == Guid.Empty || calendarId == Guid.Empty || calendarItemId == Guid.Empty)
return string.Empty;
try
{
var itemPath = await GetCalendarItemPathAsync(accountId, calendarId, calendarItemId).ConfigureAwait(false);
var metaPath = Path.Combine(itemPath, "event.meta.json");
if (!File.Exists(metaPath))
return string.Empty;
var lines = await File.ReadAllLinesAsync(metaPath).ConfigureAwait(false);
foreach (var line in lines)
{
if (!line.StartsWith("ETag=", StringComparison.OrdinalIgnoreCase))
continue;
return line["ETag=".Length..].Trim();
}
}
catch (Exception ex)
{
_logger.Warning(ex, "Failed to load ICS metadata for account {AccountId}, calendar {CalendarId}, item {CalendarItemId}", accountId, calendarId, calendarItemId);
}
return string.Empty;
}
public async Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId) public async Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId)
{ {
if (accountId == Guid.Empty || calendarItemId == Guid.Empty) if (accountId == Guid.Empty || calendarItemId == Guid.Empty)
@@ -101,6 +132,17 @@ public class CalendarIcsFileService : ICalendarIcsFileService
return itemDirectory; return itemDirectory;
} }
private async Task<string> GetCalendarItemPathAsync(Guid accountId, Guid calendarId, Guid calendarItemId)
{
var root = await GetIcsRootPathAsync().ConfigureAwait(false);
return Path.Combine(
root,
accountId.ToString("N"),
"calendars",
calendarId.ToString("N"),
calendarItemId.ToString("N"));
}
private async Task<string> GetCalendarFolderPathAsync(Guid accountId, Guid calendarId) private async Task<string> GetCalendarFolderPathAsync(Guid accountId, Guid calendarId)
{ {
var accountRootPath = await GetAccountCalendarsRootPathAsync(accountId).ConfigureAwait(false); var accountRootPath = await GetAccountCalendarsRootPathAsync(accountId).ConfigureAwait(false);