diff --git a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs index c8486909..7ca56bba 100644 --- a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs @@ -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); diff --git a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs index 7ebc4ffc..862b76a3 100644 --- a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs @@ -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() + .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() - .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); }); } } diff --git a/Wino.Core.Domain/Interfaces/ICalDavClient.cs b/Wino.Core.Domain/Interfaces/ICalDavClient.cs index cb28f73f..824ff22f 100644 --- a/Wino.Core.Domain/Interfaces/ICalDavClient.cs +++ b/Wino.Core.Domain/Interfaces/ICalDavClient.cs @@ -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); } diff --git a/Wino.Core.Domain/Interfaces/ICalendarIcsFileService.cs b/Wino.Core.Domain/Interfaces/ICalendarIcsFileService.cs index 2bf3efbb..919ea737 100644 --- a/Wino.Core.Domain/Interfaces/ICalendarIcsFileService.cs +++ b/Wino.Core.Domain/Interfaces/ICalendarIcsFileService.cs @@ -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 GetCalendarItemIcsETagAsync(Guid accountId, Guid calendarId, Guid calendarItemId); Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId); Task DeleteCalendarIcsForCalendarAsync(Guid accountId, Guid calendarId); } diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs index f231e022..1db37931 100644 --- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs @@ -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 GetCalendarItemIcsETagAsync(Guid accountId, Guid calendarId, Guid calendarItemId); Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId); Task DeleteCalendarIcsForCalendarAsync(Guid accountId, Guid calendarId); } diff --git a/Wino.Core/Integration/Processors/ImapChangeProcessor.cs b/Wino.Core/Integration/Processors/ImapChangeProcessor.cs index 74439a9b..57e82864 100644 --- a/Wino.Core/Integration/Processors/ImapChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/ImapChangeProcessor.cs @@ -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 GetCalendarItemIcsETagAsync(Guid accountId, Guid calendarId, Guid calendarItemId) + => _calendarIcsFileService.GetCalendarItemIcsETagAsync(accountId, calendarId, calendarItemId); + public Task DeleteCalendarItemIcsAsync(Guid accountId, Guid calendarItemId) => _calendarIcsFileService.DeleteCalendarItemIcsAsync(accountId, calendarItemId); diff --git a/Wino.Core/Synchronizers/ImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSynchronizer.cs index 6a8b5794..76e27be8 100644 --- a/Wino.Core/Synchronizers/ImapSynchronizer.cs +++ b/Wino.Core/Synchronizers/ImapSynchronizer.cs @@ -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( + 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 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 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 ResolveCalDavServiceUriAsync(CancellationToken cancellationToken) { var explicitCalDavUri = TryGetExplicitCalDavServiceUri(); @@ -1526,11 +1610,117 @@ public class ImapSynchronizer : WinoSynchronizer 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 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); } } diff --git a/Wino.Services/CalDavClient.cs b/Wino.Services/CalDavClient.cs index a40388b0..3dcafbe3 100644 --- a/Wino.Services/CalDavClient.cs +++ b/Wino.Services/CalDavClient.cs @@ -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(); 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 ParseCalendarCollection(XDocument xml, Uri baseUri) { var result = new List(); @@ -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); } diff --git a/Wino.Services/CalendarIcsFileService.cs b/Wino.Services/CalendarIcsFileService.cs index bb03d01a..1fe2ce38 100644 --- a/Wino.Services/CalendarIcsFileService.cs +++ b/Wino.Services/CalendarIcsFileService.cs @@ -45,6 +45,37 @@ public class CalendarIcsFileService : ICalendarIcsFileService } } + public async Task 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 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 GetCalendarFolderPathAsync(Guid accountId, Guid calendarId) { var accountRootPath = await GetAccountCalendarsRootPathAsync(accountId).ConfigureAwait(false);