Fixing the delta sync for caldav.
This commit is contained in:
@@ -230,7 +230,7 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
|
||||
var t = new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions()
|
||||
{
|
||||
AccountId = account.Id,
|
||||
Type = CalendarSynchronizationType.CalendarMetadata
|
||||
Type = CalendarSynchronizationType.CalendarEvents
|
||||
});
|
||||
|
||||
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)
|
||||
{
|
||||
if (message.CalendarItemViewModel == null) return;
|
||||
@@ -1088,43 +1138,19 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
||||
// Check if event falls into the current date range.
|
||||
if (DayRanges.DisplayRange == null) return;
|
||||
|
||||
// Check if this is a server-synced item that matches a local preview
|
||||
// Local previews don't have RemoteEventId, server-synced items do
|
||||
// If this is server data, reconcile against optimistic client-side items first.
|
||||
// This prevents duplicate rendering when a pending busy item is replaced by the synced one.
|
||||
if (!string.IsNullOrEmpty(calendarItem.RemoteEventId))
|
||||
{
|
||||
// Find local preview items that match this event's properties
|
||||
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();
|
||||
var pendingMatch = FindPendingBusyMatchByRemoteEventId(calendarItem);
|
||||
|
||||
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(() =>
|
||||
{
|
||||
foreach (var dayRange in DayRanges)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RemoveCalendarItemEverywhere(pendingMatch.Id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,5 +18,18 @@ public interface ICalDavClient
|
||||
DateTimeOffset startUtc,
|
||||
DateTimeOffset endUtc,
|
||||
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
|
||||
{
|
||||
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 DeleteCalendarIcsForCalendarAsync(Guid accountId, Guid calendarId);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ public interface IImapChangeProcessor : IDefaultChangeProcessor
|
||||
|
||||
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<string> GetCalendarItemIcsETagAsync(Guid accountId, Guid calendarId, Guid calendarItemId);
|
||||
Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId);
|
||||
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)
|
||||
=> _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)
|
||||
=> _calendarIcsFileService.DeleteCalendarItemIcsAsync(accountId, calendarItemId);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ public sealed class CalDavClient : ICalDavClient
|
||||
{
|
||||
private static readonly HttpMethod PropFindMethod = new("PROPFIND");
|
||||
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 readonly HttpClient _httpClient;
|
||||
@@ -128,6 +130,58 @@ public sealed class CalDavClient : ICalDavClient
|
||||
.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)
|
||||
{
|
||||
if (connectionSettings?.ServiceUri == null)
|
||||
@@ -242,6 +296,33 @@ public sealed class CalDavClient : ICalDavClient
|
||||
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)
|
||||
{
|
||||
var result = new List<CalDavCalendar>();
|
||||
@@ -501,12 +582,14 @@ public sealed class CalDavClient : ICalDavClient
|
||||
end = start.AddHours(1);
|
||||
|
||||
var status = MapStatus(sourceEvent.Status);
|
||||
var organizerEmail = NormalizeCalendarEmail(sourceEvent.Organizer?.Value);
|
||||
var organizerDisplayName = NormalizeOrganizerDisplayName(sourceEvent.Organizer?.CommonName, organizerEmail);
|
||||
var attendees = sourceEvent.Attendees?
|
||||
.Where(a => a != null && a.Value != null)
|
||||
.Select(a => new CalDavEventAttendee
|
||||
{
|
||||
Name = a.CommonName ?? string.Empty,
|
||||
Email = NormalizeCalendarEmail(a.Value),
|
||||
Name = NormalizeAttendeeName(a.CommonName, NormalizeCalendarEmail(a.Value)),
|
||||
AttendenceStatus = MapAttendeeStatus(a.ParticipationStatus),
|
||||
IsOrganizer = string.Equals(a.Role, "CHAIR", 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,
|
||||
EndTimeZone = sourceEvent.End?.TzId ?? string.Empty,
|
||||
Recurrence = recurrence,
|
||||
OrganizerDisplayName = sourceEvent.Organizer?.CommonName ?? string.Empty,
|
||||
OrganizerEmail = NormalizeCalendarEmail(sourceEvent.Organizer?.Value),
|
||||
OrganizerDisplayName = organizerDisplayName,
|
||||
OrganizerEmail = organizerEmail,
|
||||
Status = status,
|
||||
Visibility = MapVisibility(sourceEvent.Class),
|
||||
ShowAs = MapShowAs(sourceEvent.Transparency),
|
||||
@@ -656,13 +739,57 @@ public sealed class CalDavClient : ICalDavClient
|
||||
if (emailUri == null)
|
||||
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))
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (Uri.TryCreate(href, UriKind.Absolute, out var absolute))
|
||||
@@ -671,6 +798,18 @@ public sealed class CalDavClient : ICalDavClient
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
if (accountId == Guid.Empty || calendarItemId == Guid.Empty)
|
||||
@@ -101,6 +132,17 @@ public class CalendarIcsFileService : ICalendarIcsFileService
|
||||
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)
|
||||
{
|
||||
var accountRootPath = await GetAccountCalendarsRootPathAsync(accountId).ConfigureAwait(false);
|
||||
|
||||
Reference in New Issue
Block a user