diff --git a/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs b/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs index bad47eec..63da2502 100644 --- a/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarEventComposePageViewModel.cs @@ -33,6 +33,7 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel private readonly IContactService _contactService; private readonly IPreferencesService _preferencesService; private readonly IUnderlyingThemeService _underlyingThemeService; + private readonly IWinoRequestDelegator _winoRequestDelegator; private readonly CalendarEventComposeResultValidator _composeResultValidator = new(); public Func> GetHtmlNotesAsync { get; set; } @@ -110,7 +111,8 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel IMailDialogService dialogService, IContactService contactService, IPreferencesService preferencesService, - IUnderlyingThemeService underlyingThemeService) + IUnderlyingThemeService underlyingThemeService, + IWinoRequestDelegator winoRequestDelegator) { _accountService = accountService; _calendarService = calendarService; @@ -119,6 +121,7 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel _contactService = contactService; _preferencesService = preferencesService; _underlyingThemeService = underlyingThemeService; + _winoRequestDelegator = winoRequestDelegator; CurrentSettings = _preferencesService.GetCurrentCalendarSettings(); IsDarkWebviewRenderer = _underlyingThemeService.IsUnderlyingThemeDark(); @@ -290,6 +293,10 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel LastCreatedResult = createdResult; + await _winoRequestDelegator.ExecuteAsync(new CalendarOperationPreparationRequest( + CalendarSynchronizerOperation.CreateEvent, + ComposeResult: createdResult)); + _navigationService.GoBack(); } @@ -505,6 +512,12 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel private void UpdateRecurrenceSummary() { + if (!HasInitializedComposeDateRange()) + { + RecurrenceSummary = string.Empty; + return; + } + var effectiveStart = GetEffectiveStartDateTime(); var effectiveEnd = GetEffectiveEndDateTime(); var selectedDays = WeekdayOptions @@ -524,6 +537,16 @@ public partial class CalendarEventComposePageViewModel : CalendarBaseViewModel RecurrenceEndDate); } + private bool HasInitializedComposeDateRange() + { + if (StartDate == default) + { + return false; + } + + return !IsAllDay || AllDayEndDate != default; + } + private string BuildRecurrenceRule() { if (!IsRecurring || SelectedRecurrenceFrequencyOption == null) diff --git a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs index 1ffd35df..28f69fbb 100644 --- a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs @@ -17,6 +17,7 @@ using Wino.Calendar.ViewModels.Messages; using Wino.Core.Domain.Collections; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Calendar.CalendarTypeStrategies; @@ -271,36 +272,31 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, { var startDate = IsAllDay ? SelectedQuickEventDate.Value.Date : QuickEventStartTime; var endDate = IsAllDay ? SelectedQuickEventDate.Value.Date.AddDays(1) : QuickEventEndTime; - var durationSeconds = (endDate - startDate).TotalSeconds; - - // Get the user's current timezone from the system - var currentTimeZone = TimeZoneInfo.Local.Id; - - var calendarItem = new CalendarItem + var composeResult = new CalendarEventComposeResult { - Id = Guid.NewGuid(), CalendarId = SelectedQuickEventAccountCalendar.Id, - StartDate = startDate, - DurationInSeconds = durationSeconds, - StartTimeZone = currentTimeZone, - EndTimeZone = currentTimeZone, - CreatedAt = DateTime.UtcNow, - Description = string.Empty, - Location = Location ?? string.Empty, + AccountId = SelectedQuickEventAccountCalendar.Account.Id, Title = EventName, + Location = Location ?? string.Empty, + HtmlNotes = string.Empty, + StartDate = startDate, + EndDate = endDate, + IsAllDay = IsAllDay, + TimeZoneId = TimeZoneInfo.Local.Id, ShowAs = SelectedQuickEventAccountCalendar.DefaultShowAs, - IsHidden = false, - AssignedCalendar = SelectedQuickEventAccountCalendar + SelectedReminders = [], + Attendees = [], + Attachments = [], + Recurrence = string.Empty, + RecurrenceSummary = string.Empty }; // Close dialog first IsQuickEventDialogOpen = false; - // Save to local database first - // await _calendarService.CreateNewCalendarItemAsync(calendarItem, null); - - // Queue the request via delegator - var preparationRequest = new CalendarOperationPreparationRequest(CalendarSynchronizerOperation.CreateEvent, calendarItem, null); + var preparationRequest = new CalendarOperationPreparationRequest( + CalendarSynchronizerOperation.CreateEvent, + ComposeResult: composeResult); await _winoRequestDelegator.ExecuteAsync(preparationRequest); } catch (Exception ex) @@ -969,19 +965,9 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, 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); + var trackingId = remoteEventId.GetClientTrackingId(); + clientItemId = trackingId ?? Guid.Empty; + return trackingId.HasValue; } private void RemoveCalendarItemEverywhere(Guid calendarItemId) diff --git a/Wino.Core.Domain/Extensions/CalendarRemoteEventIdExtensions.cs b/Wino.Core.Domain/Extensions/CalendarRemoteEventIdExtensions.cs new file mode 100644 index 00000000..a21f9ccc --- /dev/null +++ b/Wino.Core.Domain/Extensions/CalendarRemoteEventIdExtensions.cs @@ -0,0 +1,67 @@ +using System; + +namespace Wino.Core.Domain.Extensions; + +public static class CalendarRemoteEventIdExtensions +{ + private const string ClientTrackingSeparator = "::"; + private const string CalDavClientTrackingPrefix = "caldav-"; + private const string LocalClientTrackingPrefix = "local-"; + + public static string GetProviderRemoteEventId(this string remoteEventId) + { + if (string.IsNullOrWhiteSpace(remoteEventId)) + return string.Empty; + + var separatorIndex = remoteEventId.IndexOf(ClientTrackingSeparator, StringComparison.Ordinal); + return separatorIndex >= 0 ? remoteEventId[..separatorIndex] : remoteEventId; + } + + public static Guid? GetClientTrackingId(this string remoteEventId) + { + if (string.IsNullOrWhiteSpace(remoteEventId)) + return null; + + if (remoteEventId.Contains(ClientTrackingSeparator, StringComparison.Ordinal)) + { + var trackedPart = remoteEventId[(remoteEventId.LastIndexOf(ClientTrackingSeparator, StringComparison.Ordinal) + ClientTrackingSeparator.Length)..]; + if (TryParseGuid(trackedPart, out var trackedId)) + return trackedId; + } + + if (TryParseGuid(remoteEventId, out var directId)) + return directId; + + if (remoteEventId.StartsWith(CalDavClientTrackingPrefix, StringComparison.OrdinalIgnoreCase) && + TryParseGuid(remoteEventId[CalDavClientTrackingPrefix.Length..], out var calDavId)) + { + return calDavId; + } + + if (remoteEventId.StartsWith(LocalClientTrackingPrefix, StringComparison.OrdinalIgnoreCase) && + TryParseGuid(remoteEventId[LocalClientTrackingPrefix.Length..], out var localId)) + { + return localId; + } + + return null; + } + + public static string WithClientTrackingId(this string providerRemoteEventId, Guid? clientTrackingId) + { + if (string.IsNullOrWhiteSpace(providerRemoteEventId) || !clientTrackingId.HasValue) + return providerRemoteEventId ?? string.Empty; + + return $"{providerRemoteEventId}{ClientTrackingSeparator}{clientTrackingId.Value:N}"; + } + + private static bool TryParseGuid(string value, out Guid parsedGuid) + { + parsedGuid = Guid.Empty; + + if (string.IsNullOrWhiteSpace(value)) + return false; + + return Guid.TryParseExact(value, "N", out parsedGuid) || Guid.TryParse(value, out parsedGuid); + } +} diff --git a/Wino.Core.Domain/Interfaces/IRequestBundle.cs b/Wino.Core.Domain/Interfaces/IRequestBundle.cs index 59bd9e2d..955b89d8 100644 --- a/Wino.Core.Domain/Interfaces/IRequestBundle.cs +++ b/Wino.Core.Domain/Interfaces/IRequestBundle.cs @@ -1,4 +1,5 @@ -using Wino.Core.Domain.Entities.Calendar; +using System; +using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; @@ -68,5 +69,6 @@ public interface IFolderActionRequest : IRequestBase public interface ICalendarActionRequest : IRequestBase { CalendarItem Item { get; } + Guid? LocalCalendarItemId { get; } CalendarSynchronizerOperation Operation { get; } } diff --git a/Wino.Core.Domain/Models/Calendar/CalendarOperationPreparationRequest.cs b/Wino.Core.Domain/Models/Calendar/CalendarOperationPreparationRequest.cs index f2b0cc93..a43c6e10 100644 --- a/Wino.Core.Domain/Models/Calendar/CalendarOperationPreparationRequest.cs +++ b/Wino.Core.Domain/Models/Calendar/CalendarOperationPreparationRequest.cs @@ -15,8 +15,9 @@ namespace Wino.Core.Domain.Models.Calendar; /// Original attendees list before update (for revert capability). public record CalendarOperationPreparationRequest( CalendarSynchronizerOperation Operation, - CalendarItem CalendarItem, - List Attendees, + CalendarItem CalendarItem = null, + List Attendees = null, string ResponseMessage = null, CalendarItem OriginalItem = null, - List OriginalAttendees = null); + List OriginalAttendees = null, + CalendarEventComposeResult ComposeResult = null); diff --git a/Wino.Core.Domain/Models/Requests/RequestBase.cs b/Wino.Core.Domain/Models/Requests/RequestBase.cs index f7e008b7..9ea7876c 100644 --- a/Wino.Core.Domain/Models/Requests/RequestBase.cs +++ b/Wino.Core.Domain/Models/Requests/RequestBase.cs @@ -32,6 +32,7 @@ public abstract record FolderRequestBase(MailItemFolder Folder, FolderSynchroniz public abstract record CalendarRequestBase(CalendarItem Item) : RequestBase, ICalendarActionRequest { + public virtual Guid? LocalCalendarItemId => Item?.Id; } public class BatchCollection : List, IUIChangeRequest where TRequestType : IUIChangeRequest diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 7d62dab9..76c6732d 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -89,6 +89,7 @@ "SyncAction_Archiving": "Archiving {0} mail(s)", "SyncAction_ClearingFlag": "Unflagging {0} mail(s)", "SyncAction_CreatingDraft": "Creating draft", + "SyncAction_CreatingEvent": "Creating event", "SyncAction_Deleting": "Deleting {0} mail(s)", "SyncAction_EmptyingFolder": "Emptying folder", "SyncAction_MarkingAsRead": "Marking {0} mail(s) as read", @@ -148,6 +149,7 @@ "CalendarEventCompose_PickCalendarTitle": "Pick a calendar", "CalendarEventCompose_Recurring": "Recurring", "CalendarEventCompose_RecurringSummary": "Occurs every {0} {1}{2} {3} effective {4}{5}", + "CalendarEventCompose_RecurringSummarySmart": "Occurs {0}{1} {2} starting {3}{4}", "CalendarEventCompose_RepeatEvery": "Repeat every", "CalendarEventCompose_SelectCalendar": "Select calendar", "CalendarEventCompose_SingleOccurrenceSummary": "Occurs on {0} {1}", diff --git a/Wino.Core.Tests/Services/AccountServiceTests.cs b/Wino.Core.Tests/Services/AccountServiceTests.cs index cc38d90a..28f9dcb4 100644 --- a/Wino.Core.Tests/Services/AccountServiceTests.cs +++ b/Wino.Core.Tests/Services/AccountServiceTests.cs @@ -99,6 +99,7 @@ public class AccountServiceTests : IAsyncLifetime var authenticationProvider = new Mock(); var mimeFileService = new Mock(); + var contactPictureFileService = new Mock(); var preferencesService = new Mock(); preferencesService.SetupProperty(a => a.StartupEntityId); @@ -108,6 +109,7 @@ public class AccountServiceTests : IAsyncLifetime signatureService.Object, authenticationProvider.Object, mimeFileService.Object, - preferencesService.Object); + preferencesService.Object, + contactPictureFileService.Object); } } diff --git a/Wino.Core.Tests/Services/CreateCalendarEventRequestTests.cs b/Wino.Core.Tests/Services/CreateCalendarEventRequestTests.cs new file mode 100644 index 00000000..514adbd0 --- /dev/null +++ b/Wino.Core.Tests/Services/CreateCalendarEventRequestTests.cs @@ -0,0 +1,127 @@ +using CommunityToolkit.Mvvm.Messaging; +using FluentAssertions; +using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Calendar; +using Wino.Core.Helpers; +using Wino.Core.Requests.Calendar; +using Wino.Messaging.Client.Calendar; +using Xunit; + +namespace Wino.Core.Tests.Services; + +public sealed class CreateCalendarEventRequestTests +{ + [Fact] + public void ApplyUiChanges_ForNonRecurringEvent_SendsOptimisticAddAndRevertDelete() + { + var composeResult = CreateComposeResult(); + var assignedCalendar = CreateAssignedCalendar(); + var request = new CreateCalendarEventRequest(composeResult, assignedCalendar); + var recipient = new CalendarRequestRecipient(); + + WeakReferenceMessenger.Default.RegisterAll(recipient); + + try + { + request.LocalCalendarItemId.Should().NotBeNull(); + + request.ApplyUIChanges(); + request.RevertUIChanges(); + + recipient.Added.Should().ContainSingle(); + recipient.Deleted.Should().ContainSingle(); + recipient.Added[0].Id.Should().Be(request.LocalCalendarItemId!.Value); + recipient.Deleted[0].Id.Should().Be(request.LocalCalendarItemId!.Value); + } + finally + { + WeakReferenceMessenger.Default.UnregisterAll(recipient); + } + } + + [Fact] + public void ApplyUiChanges_ForRecurringEvent_DoesNotSendOptimisticMessages() + { + var composeResult = CreateComposeResult(); + composeResult.Recurrence = "RRULE:FREQ=DAILY;INTERVAL=1"; + var request = new CreateCalendarEventRequest(composeResult, CreateAssignedCalendar()); + var recipient = new CalendarRequestRecipient(); + + WeakReferenceMessenger.Default.RegisterAll(recipient); + + try + { + request.LocalCalendarItemId.Should().BeNull(); + request.Item.Should().BeNull(); + + request.ApplyUIChanges(); + request.RevertUIChanges(); + + recipient.Added.Should().BeEmpty(); + recipient.Deleted.Should().BeEmpty(); + request.PreparedItem.Should().NotBeNull(); + } + finally + { + WeakReferenceMessenger.Default.UnregisterAll(recipient); + } + } + + [Fact] + public void SynchronizationActionHelper_ForCreateRequest_ReturnsCalendarCreateAction() + { + var request = new CreateCalendarEventRequest(CreateComposeResult(), CreateAssignedCalendar()); + + var actionItems = SynchronizationActionHelper.CreateActionItems([request], Guid.NewGuid(), "Test"); + + actionItems.Should().ContainSingle(); + actionItems[0].Description.Should().Be(Wino.Core.Domain.Translator.SyncAction_CreatingEvent); + } + + private static CalendarEventComposeResult CreateComposeResult() + { + return new CalendarEventComposeResult + { + CalendarId = Guid.NewGuid(), + AccountId = Guid.NewGuid(), + Title = "Planning", + Location = "Room 4", + HtmlNotes = "

Notes

", + StartDate = new DateTime(2026, 3, 7, 10, 0, 0), + EndDate = new DateTime(2026, 3, 7, 11, 0, 0), + TimeZoneId = TimeZoneInfo.Local.Id, + ShowAs = CalendarItemShowAs.Busy + }; + } + + private static AccountCalendar CreateAssignedCalendar() + { + return new AccountCalendar + { + Id = Guid.NewGuid(), + AccountId = Guid.NewGuid(), + Name = "Primary", + DefaultShowAs = CalendarItemShowAs.Busy, + MailAccount = new MailAccount + { + Id = Guid.NewGuid(), + Address = "user@example.com", + SenderName = "User" + } + }; + } + + internal sealed class CalendarRequestRecipient : + IRecipient, + IRecipient + { + public List Added { get; } = []; + public List Deleted { get; } = []; + + public void Receive(CalendarItemAdded message) => Added.Add(message.CalendarItem); + + public void Receive(CalendarItemDeleted message) => Deleted.Add(message.CalendarItem); + } +} diff --git a/Wino.Core.Tests/Services/MailFetchingTests.cs b/Wino.Core.Tests/Services/MailFetchingTests.cs index ebf973e5..4b3f9f68 100644 --- a/Wino.Core.Tests/Services/MailFetchingTests.cs +++ b/Wino.Core.Tests/Services/MailFetchingTests.cs @@ -358,13 +358,15 @@ public class MailFetchingTests : IAsyncLifetime var authProvider = new Mock(); var mimeFileService = new Mock(); var preferencesService = new Mock(); + var contactPictureFileService = new Mock(); var accountService = new AccountService( db, signatureService.Object, authProvider.Object, mimeFileService.Object, - preferencesService.Object); + preferencesService.Object, + contactPictureFileService.Object); var folderService = new FolderService(db, accountService); var contactService = new ContactService(db); diff --git a/Wino.Core/Helpers/CalendarEventComposeMapper.cs b/Wino.Core/Helpers/CalendarEventComposeMapper.cs new file mode 100644 index 00000000..2aa2211e --- /dev/null +++ b/Wino.Core/Helpers/CalendarEventComposeMapper.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Calendar; + +namespace Wino.Core.Helpers; + +public sealed record PreparedCalendarEventCreateModel( + CalendarItem CalendarItem, + List Attendees, + List Reminders); + +public static class CalendarEventComposeMapper +{ + public static PreparedCalendarEventCreateModel Prepare(CalendarEventComposeResult composeResult, AccountCalendar assignedCalendar, Guid? calendarItemId = null) + { + ArgumentNullException.ThrowIfNull(composeResult); + ArgumentNullException.ThrowIfNull(assignedCalendar); + + var itemId = calendarItemId ?? Guid.NewGuid(); + var effectiveTimeZoneId = string.IsNullOrWhiteSpace(composeResult.TimeZoneId) + ? TimeZoneInfo.Local.Id + : composeResult.TimeZoneId; + var utcNow = DateTimeOffset.UtcNow; + + var calendarItem = new CalendarItem + { + Id = itemId, + CalendarId = assignedCalendar.Id, + AssignedCalendar = assignedCalendar, + Title = composeResult.Title?.Trim() ?? string.Empty, + Description = composeResult.HtmlNotes ?? string.Empty, + Location = composeResult.Location?.Trim() ?? string.Empty, + StartDate = composeResult.StartDate, + DurationInSeconds = Math.Max(0, (composeResult.EndDate - composeResult.StartDate).TotalSeconds), + StartTimeZone = effectiveTimeZoneId, + EndTimeZone = effectiveTimeZoneId, + CreatedAt = utcNow, + UpdatedAt = utcNow, + Recurrence = composeResult.Recurrence ?? string.Empty, + OrganizerDisplayName = assignedCalendar.MailAccount?.SenderName ?? string.Empty, + OrganizerEmail = assignedCalendar.MailAccount?.Address ?? string.Empty, + Status = CalendarItemStatus.Accepted, + Visibility = CalendarItemVisibility.Public, + ShowAs = composeResult.ShowAs, + IsHidden = false, + IsLocked = false + }; + + var attendees = composeResult.Attendees? + .Where(attendee => attendee != null) + .Select(attendee => new CalendarEventAttendee + { + Id = attendee.Id == Guid.Empty ? Guid.NewGuid() : attendee.Id, + CalendarItemId = itemId, + Name = attendee.Name ?? string.Empty, + Email = attendee.Email ?? string.Empty, + Comment = attendee.Comment, + AttendenceStatus = attendee.AttendenceStatus, + IsOrganizer = attendee.IsOrganizer, + IsOptionalAttendee = attendee.IsOptionalAttendee, + ResolvedContact = attendee.ResolvedContact + }) + .ToList() ?? []; + + var reminders = composeResult.SelectedReminders? + .Where(reminder => reminder != null) + .Select(reminder => new Reminder + { + Id = reminder.Id == Guid.Empty ? Guid.NewGuid() : reminder.Id, + CalendarItemId = itemId, + DurationInSeconds = reminder.DurationInSeconds, + ReminderType = reminder.ReminderType + }) + .ToList() ?? []; + + return new PreparedCalendarEventCreateModel(calendarItem, attendees, reminders); + } +} diff --git a/Wino.Core/Helpers/CalendarRecurrenceMapper.cs b/Wino.Core/Helpers/CalendarRecurrenceMapper.cs new file mode 100644 index 00000000..aac775d0 --- /dev/null +++ b/Wino.Core/Helpers/CalendarRecurrenceMapper.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.Graph.Models; +using Microsoft.Kiota.Abstractions; +using Wino.Core.Domain; +using Wino.Core.Domain.Entities.Calendar; + +namespace Wino.Core.Helpers; + +public static class CalendarRecurrenceMapper +{ + public static PatternedRecurrence CreateOutlookRecurrence(CalendarItem calendarItem) + { + if (calendarItem == null || string.IsNullOrWhiteSpace(calendarItem.Recurrence)) + return null; + + var ruleLine = calendarItem.Recurrence + .Split(Domain.Constants.CalendarEventRecurrenceRuleSeperator, StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .FirstOrDefault(line => line.StartsWith("RRULE:", StringComparison.OrdinalIgnoreCase)); + + if (string.IsNullOrWhiteSpace(ruleLine)) + return null; + + var components = ruleLine["RRULE:".Length..] + .Split(';', StringSplitOptions.RemoveEmptyEntries) + .Select(part => part.Split('=', 2, StringSplitOptions.TrimEntries)) + .Where(parts => parts.Length == 2) + .ToDictionary(parts => parts[0].ToUpperInvariant(), parts => parts[1], StringComparer.OrdinalIgnoreCase); + + if (!components.TryGetValue("FREQ", out var frequency)) + return null; + + var pattern = new RecurrencePattern + { + Interval = ParseInt(components, "INTERVAL", 1), + FirstDayOfWeek = DayOfWeekObject.Monday + }; + + var byDays = ParseByDays(components); + var startDate = calendarItem.StartDate; + + switch (frequency.ToUpperInvariant()) + { + case "DAILY": + pattern.Type = RecurrencePatternType.Daily; + break; + case "WEEKLY": + pattern.Type = RecurrencePatternType.Weekly; + pattern.DaysOfWeek = byDays.Any() + ? byDays.Select(day => (DayOfWeekObject?)day).ToList() + : [(DayOfWeekObject?)MapDay(startDate.DayOfWeek)]; + break; + case "MONTHLY": + if (byDays.Any()) + { + pattern.Type = RecurrencePatternType.RelativeMonthly; + pattern.DaysOfWeek = byDays.Select(day => (DayOfWeekObject?)day).ToList(); + pattern.Index = MapWeekIndex(startDate); + } + else + { + pattern.Type = RecurrencePatternType.AbsoluteMonthly; + pattern.DayOfMonth = ParseInt(components, "BYMONTHDAY", startDate.Day); + } + break; + case "YEARLY": + pattern.Month = ParseInt(components, "BYMONTH", startDate.Month); + + if (byDays.Any()) + { + pattern.Type = RecurrencePatternType.RelativeYearly; + pattern.DaysOfWeek = byDays.Select(day => (DayOfWeekObject?)day).ToList(); + pattern.Index = MapWeekIndex(startDate); + } + else + { + pattern.Type = RecurrencePatternType.AbsoluteYearly; + pattern.DayOfMonth = ParseInt(components, "BYMONTHDAY", startDate.Day); + } + break; + default: + return null; + } + + var recurrenceRange = CreateRange(components, calendarItem); + return new PatternedRecurrence + { + Pattern = pattern, + Range = recurrenceRange + }; + } + + private static RecurrenceRange CreateRange(IReadOnlyDictionary components, CalendarItem calendarItem) + { + var startDate = CreateDate(calendarItem.StartDate); + + if (components.TryGetValue("UNTIL", out var untilValue) && + TryParseUntil(untilValue, out var untilDate)) + { + return new RecurrenceRange + { + Type = RecurrenceRangeType.EndDate, + StartDate = startDate, + EndDate = CreateDate(untilDate), + RecurrenceTimeZone = calendarItem.StartTimeZone + }; + } + + return new RecurrenceRange + { + Type = RecurrenceRangeType.NoEnd, + StartDate = startDate, + RecurrenceTimeZone = calendarItem.StartTimeZone + }; + } + + private static bool TryParseUntil(string untilValue, out DateTime untilDate) + { + untilDate = default; + + if (string.IsNullOrWhiteSpace(untilValue)) + return false; + + return DateTime.TryParseExact( + untilValue, + ["yyyyMMdd", "yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmmss'Z'"], + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out untilDate) + || DateTime.TryParse(untilValue, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out untilDate); + } + + private static List ParseByDays(IReadOnlyDictionary components) + { + if (!components.TryGetValue("BYDAY", out var byDayValue) || string.IsNullOrWhiteSpace(byDayValue)) + return []; + + return byDayValue + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(MapDay) + .ToList(); + } + + private static int ParseInt(IReadOnlyDictionary components, string key, int fallback) + => components.TryGetValue(key, out var value) && int.TryParse(value, out var parsedValue) ? parsedValue : fallback; + + private static DayOfWeekObject MapDay(string dayToken) + { + return dayToken.ToUpperInvariant() switch + { + "MO" => DayOfWeekObject.Monday, + "TU" => DayOfWeekObject.Tuesday, + "WE" => DayOfWeekObject.Wednesday, + "TH" => DayOfWeekObject.Thursday, + "FR" => DayOfWeekObject.Friday, + "SA" => DayOfWeekObject.Saturday, + "SU" => DayOfWeekObject.Sunday, + _ => throw new ArgumentOutOfRangeException(nameof(dayToken), dayToken, null) + }; + } + + private static DayOfWeekObject MapDay(DayOfWeek dayOfWeek) + { + return dayOfWeek switch + { + DayOfWeek.Monday => DayOfWeekObject.Monday, + DayOfWeek.Tuesday => DayOfWeekObject.Tuesday, + DayOfWeek.Wednesday => DayOfWeekObject.Wednesday, + DayOfWeek.Thursday => DayOfWeekObject.Thursday, + DayOfWeek.Friday => DayOfWeekObject.Friday, + DayOfWeek.Saturday => DayOfWeekObject.Saturday, + DayOfWeek.Sunday => DayOfWeekObject.Sunday, + _ => DayOfWeekObject.Monday + }; + } + + private static WeekIndex MapWeekIndex(DateTime date) + { + var occurrence = ((date.Day - 1) / 7) + 1; + + return occurrence switch + { + 1 => WeekIndex.First, + 2 => WeekIndex.Second, + 3 => WeekIndex.Third, + 4 => WeekIndex.Fourth, + _ => WeekIndex.Last + }; + } + + private static Date CreateDate(DateTime dateTime) => new(dateTime.Year, dateTime.Month, dateTime.Day); +} diff --git a/Wino.Core/Helpers/SynchronizationActionHelper.cs b/Wino.Core/Helpers/SynchronizationActionHelper.cs index e431b512..1646a507 100644 --- a/Wino.Core/Helpers/SynchronizationActionHelper.cs +++ b/Wino.Core/Helpers/SynchronizationActionHelper.cs @@ -5,6 +5,7 @@ using Wino.Core.Domain; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Synchronization; +using Wino.Core.Requests.Calendar; using Wino.Core.Requests.Folder; using Wino.Core.Requests.Mail; @@ -56,6 +57,22 @@ public static class SynchronizationActionHelper } } + var calendarRequests = requests.OfType(); + foreach (var calendarRequest in calendarRequests) + { + var description = GetCalendarActionDescription(calendarRequest); + + if (description != null) + { + items.Add(new SynchronizationActionItem + { + AccountId = accountId, + AccountName = accountName, + Description = description + }); + } + } + return items; } @@ -107,4 +124,13 @@ public static class SynchronizationActionHelper _ => null }; } + + private static string GetCalendarActionDescription(ICalendarActionRequest request) + { + return request switch + { + CreateCalendarEventRequest => Translator.SyncAction_CreatingEvent, + _ => null + }; + } } diff --git a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs index 7b7008a8..57a22c44 100644 --- a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs @@ -8,6 +8,7 @@ using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Extensions; using Wino.Core.Extensions; using Wino.Services; using Reminder = Wino.Core.Domain.Entities.Calendar.Reminder; @@ -64,7 +65,7 @@ public class OutlookChangeProcessor(IDatabaseService databaseService, var durationInSeconds = (eventEndDateTimeOffset - eventStartDateTimeOffset).TotalSeconds; // Store dates as UTC in the database - savingItem.RemoteEventId = calendarEvent.Id; + savingItem.RemoteEventId = calendarEvent.Id.WithClientTrackingId(calendarEvent.TransactionId.GetClientTrackingId()); savingItem.StartDate = eventStartDateTimeOffset.UtcDateTime; savingItem.DurationInSeconds = durationInSeconds; diff --git a/Wino.Core/Requests/Calendar/CreateCalendarEventRequest.cs b/Wino.Core/Requests/Calendar/CreateCalendarEventRequest.cs index 3135f71e..1b1c0832 100644 --- a/Wino.Core/Requests/Calendar/CreateCalendarEventRequest.cs +++ b/Wino.Core/Requests/Calendar/CreateCalendarEventRequest.cs @@ -1,35 +1,63 @@ using System; -using System.Collections.Generic; using CommunityToolkit.Mvvm.Messaging; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Requests; +using Wino.Core.Helpers; using Wino.Messaging.Client.Calendar; namespace Wino.Core.Requests.Calendar; /// /// Request to create a new calendar event on the server. -/// The calendar item should be already saved to the local database before queuing this request. +/// Non-recurring events create an optimistic in-memory item for immediate UI feedback. +/// Recurring events skip optimistic rendering and rely on provider synchronization to materialize instances. /// -public record CreateCalendarEventRequest(CalendarItem Item, List Attendees) : CalendarRequestBase(Item) +public record CreateCalendarEventRequest : CalendarRequestBase { + public CalendarEventComposeResult ComposeResult { get; } + public AccountCalendar AssignedCalendar { get; } + public PreparedCalendarEventCreateModel PreparedEvent { get; } + public CalendarItem PreparedItem => PreparedEvent.CalendarItem; + public bool IsRecurring => !string.IsNullOrWhiteSpace(ComposeResult?.Recurrence); + + public CreateCalendarEventRequest(CalendarEventComposeResult composeResult, AccountCalendar assignedCalendar) + : this(composeResult, assignedCalendar, CalendarEventComposeMapper.Prepare(composeResult, assignedCalendar)) + { + } + + private CreateCalendarEventRequest( + CalendarEventComposeResult composeResult, + AccountCalendar assignedCalendar, + PreparedCalendarEventCreateModel preparedEvent) + : base(ShouldCreateOptimisticItem(composeResult) ? preparedEvent.CalendarItem : null) + { + ComposeResult = composeResult ?? throw new ArgumentNullException(nameof(composeResult)); + AssignedCalendar = assignedCalendar ?? throw new ArgumentNullException(nameof(assignedCalendar)); + PreparedEvent = preparedEvent ?? throw new ArgumentNullException(nameof(preparedEvent)); + } + public override CalendarSynchronizerOperation Operation => CalendarSynchronizerOperation.CreateEvent; - /// - /// After successful creation, we need to resync to get the remote event ID. - /// - public override int ResynchronizationDelay => 2000; + public override int ResynchronizationDelay => 5000; public override void ApplyUIChanges() { - // Notify UI that the event was created locally + if (Item == null) + return; + WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item)); } public override void RevertUIChanges() { - // If creation fails, we should notify the UI to remove it + if (Item == null) + return; + WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(Item)); } + + private static bool ShouldCreateOptimisticItem(CalendarEventComposeResult composeResult) + => string.IsNullOrWhiteSpace(composeResult?.Recurrence); } diff --git a/Wino.Core/Services/WinoRequestDelegator.cs b/Wino.Core/Services/WinoRequestDelegator.cs index 4881383d..7c04ef37 100644 --- a/Wino.Core/Services/WinoRequestDelegator.cs +++ b/Wino.Core/Services/WinoRequestDelegator.cs @@ -27,16 +27,19 @@ public class WinoRequestDelegator : IWinoRequestDelegator private readonly IFolderService _folderService; private readonly IMailDialogService _dialogService; private readonly IAccountService _accountService; + private readonly ICalendarService _calendarService; public WinoRequestDelegator(IWinoRequestProcessor winoRequestProcessor, IFolderService folderService, IMailDialogService dialogService, - IAccountService accountService) + IAccountService accountService, + ICalendarService calendarService) { _winoRequestProcessor = winoRequestProcessor; _folderService = folderService; _dialogService = dialogService; _accountService = accountService; + _calendarService = calendarService; } public async Task ExecuteAsync(MailOperationPreperationRequest request) @@ -159,9 +162,12 @@ public class WinoRequestDelegator : IWinoRequestDelegator public async Task ExecuteAsync(CalendarOperationPreparationRequest calendarPreparationRequest) { + if (calendarPreparationRequest == null) + return; + IRequestBase request = calendarPreparationRequest.Operation switch { - CalendarSynchronizerOperation.CreateEvent => new CreateCalendarEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.Attendees), + CalendarSynchronizerOperation.CreateEvent => await CreateCalendarEventRequestAsync(calendarPreparationRequest).ConfigureAwait(false), CalendarSynchronizerOperation.DeleteEvent => new DeleteCalendarEventRequest(calendarPreparationRequest.CalendarItem), CalendarSynchronizerOperation.AcceptEvent => new AcceptEventRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage), CalendarSynchronizerOperation.DeclineEvent => CreateDeclineRequest(calendarPreparationRequest.CalendarItem, calendarPreparationRequest.ResponseMessage), @@ -174,8 +180,31 @@ public class WinoRequestDelegator : IWinoRequestDelegator _ => throw new NotImplementedException($"Calendar operation {calendarPreparationRequest.Operation} is not implemented yet.") }; - await QueueRequestAsync(request, calendarPreparationRequest.CalendarItem.AssignedCalendar.AccountId); - await QueueCalendarSynchronizationAsync(calendarPreparationRequest.CalendarItem.AssignedCalendar.AccountId); + if (request == null) + return; + + var accountId = calendarPreparationRequest.Operation == CalendarSynchronizerOperation.CreateEvent + ? calendarPreparationRequest.ComposeResult.AccountId + : calendarPreparationRequest.CalendarItem.AssignedCalendar.AccountId; + var accountName = calendarPreparationRequest.Operation == CalendarSynchronizerOperation.CreateEvent + ? null + : calendarPreparationRequest.CalendarItem.AssignedCalendar.MailAccount?.Name; + + await QueueRequestAsync(request, accountId); + await SendSyncActionsAddedAsync([request], accountId, accountName); + await QueueCalendarSynchronizationAsync(accountId); + } + + private async Task CreateCalendarEventRequestAsync(CalendarOperationPreparationRequest calendarPreparationRequest) + { + var composeResult = calendarPreparationRequest.ComposeResult + ?? throw new InvalidOperationException("Create event requests require a compose result."); + var assignedCalendar = await _calendarService.GetAccountCalendarAsync(composeResult.CalendarId).ConfigureAwait(false); + + if (assignedCalendar == null) + throw new InvalidOperationException($"Calendar {composeResult.CalendarId} could not be resolved."); + + return new CreateCalendarEventRequest(composeResult, assignedCalendar); } private IRequestBase CreateDeclineRequest(CalendarItem calendarItem, string responseMessage) diff --git a/Wino.Core/Synchronizers/BaseSynchronizer.cs b/Wino.Core/Synchronizers/BaseSynchronizer.cs index 2cd51dce..1e0bf8ec 100644 --- a/Wino.Core/Synchronizers/BaseSynchronizer.cs +++ b/Wino.Core/Synchronizers/BaseSynchronizer.cs @@ -144,7 +144,10 @@ public abstract partial class BaseSynchronizer : ObservableObject, if (request is ICalendarActionRequest calendarActionRequest) { - _pendingCalendarOperationIds.TryAdd(calendarActionRequest.Item.Id, 0); + if (calendarActionRequest.LocalCalendarItemId.HasValue) + { + _pendingCalendarOperationIds.TryAdd(calendarActionRequest.LocalCalendarItemId.Value, 0); + } } } @@ -157,7 +160,10 @@ public abstract partial class BaseSynchronizer : ObservableObject, if (request is ICalendarActionRequest calendarActionRequest) { - _pendingCalendarOperationIds.TryRemove(calendarActionRequest.Item.Id, out _); + if (calendarActionRequest.LocalCalendarItemId.HasValue) + { + _pendingCalendarOperationIds.TryRemove(calendarActionRequest.LocalCalendarItemId.Value, out _); + } } } diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index a8d0de33..f2b46696 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -2276,55 +2276,49 @@ public class GmailSynchronizer : WinoSynchronizer> CreateCalendarEvent(CreateCalendarEventRequest request) { - var calendarItem = request.Item; - var attendees = request.Attendees; + var calendarItem = request.PreparedItem; + var attendees = request.PreparedEvent.Attendees; + var reminders = request.PreparedEvent.Reminders; + var calendar = request.AssignedCalendar; - // Get the calendar for this event - var calendar = calendarItem.AssignedCalendar; - if (calendar == null) - { - throw new InvalidOperationException("Calendar item must have an assigned calendar"); - } - - // Convert CalendarItem to Google Event var googleEvent = new Event { + Id = calendarItem.Id.ToString("N").ToLowerInvariant(), Summary = calendarItem.Title, Description = calendarItem.Description, Location = calendarItem.Location, - Status = calendarItem.Status == CalendarItemStatus.Accepted ? "confirmed" : "tentative" + Status = calendarItem.Status == CalendarItemStatus.Accepted ? "confirmed" : "tentative", + Transparency = calendarItem.ShowAs == CalendarItemShowAs.Free ? "transparent" : "opaque" }; - // Set start and end time if (calendarItem.IsAllDayEvent) { - // All-day events use Date instead of DateTime googleEvent.Start = new EventDateTime { - Date = calendarItem.StartDate.ToString("yyyy-MM-dd") - }; - googleEvent.End = new EventDateTime - { - Date = calendarItem.EndDate.ToString("yyyy-MM-dd") - }; - } - else - { - // Regular events with time - googleEvent.Start = new EventDateTime - { - DateTimeDateTimeOffset = new DateTimeOffset(calendarItem.StartDate, TimeSpan.Zero), + Date = calendarItem.StartDate.ToString("yyyy-MM-dd"), TimeZone = calendarItem.StartTimeZone }; googleEvent.End = new EventDateTime { - DateTimeDateTimeOffset = new DateTimeOffset(calendarItem.EndDate, TimeSpan.Zero), + Date = calendarItem.EndDate.ToString("yyyy-MM-dd"), + TimeZone = calendarItem.EndTimeZone + }; + } + else + { + googleEvent.Start = new EventDateTime + { + DateTimeDateTimeOffset = new DateTimeOffset(calendarItem.StartDate, ResolveOffset(calendarItem.StartDate, calendarItem.StartTimeZone)), + TimeZone = calendarItem.StartTimeZone + }; + googleEvent.End = new EventDateTime + { + DateTimeDateTimeOffset = new DateTimeOffset(calendarItem.EndDate, ResolveOffset(calendarItem.EndDate, calendarItem.EndTimeZone ?? calendarItem.StartTimeZone)), TimeZone = calendarItem.EndTimeZone }; } - // Add attendees if any - if (attendees != null && attendees.Count > 0) + if (attendees.Count > 0) { googleEvent.Attendees = attendees.Select(a => new EventAttendee { @@ -2334,8 +2328,32 @@ public class GmailSynchronizer : WinoSynchronizer 0) + { + googleEvent.Reminders = new Event.RemindersData + { + UseDefault = false, + Overrides = reminders.Select(reminder => new EventReminder + { + Method = reminder.ReminderType == CalendarItemReminderType.Email ? "email" : "popup", + Minutes = (int)Math.Max(0, reminder.DurationInSeconds / 60) + }).ToList() + }; + } + + if (!string.IsNullOrWhiteSpace(calendarItem.Recurrence)) + { + googleEvent.Recurrence = calendarItem.Recurrence + .Split(Wino.Core.Domain.Constants.CalendarEventRecurrenceRuleSeperator, StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToList(); + } + var insertRequest = _calendarService.Events.Insert(googleEvent, calendar.RemoteCalendarId); + insertRequest.SendUpdates = attendees.Count > 0 + ? Google.Apis.Calendar.v3.EventsResource.InsertRequest.SendUpdatesEnum.All + : Google.Apis.Calendar.v3.EventsResource.InsertRequest.SendUpdatesEnum.None; return [new HttpRequestBundle(insertRequest, request)]; } @@ -2350,7 +2368,8 @@ public class GmailSynchronizer : WinoSynchronizer 0) @@ -2551,13 +2573,13 @@ public class GmailSynchronizer : WinoSynchronizer false; - public LocalCalendarOperationHandler(MailAccount account, IImapChangeProcessor changeProcessor, ICalendarService calendarService, string resourceScheme) + public LocalCalendarOperationHandler(MailAccount account, IImapChangeProcessor changeProcessor, ICalendarService calendarService, string applicationDataFolderPath, string resourceScheme) { _account = account; _changeProcessor = changeProcessor; _calendarService = calendarService; + _applicationDataFolderPath = applicationDataFolderPath; _resourceScheme = resourceScheme; } public async Task CreateCalendarEventAsync(CreateCalendarEventRequest request) { - var item = request.Item; + var item = request.PreparedItem; + var attendees = request.PreparedEvent.Attendees; + var reminders = request.PreparedEvent.Reminders; EnsureCalendarItemDefaults(item, _account, "local"); item.AssignedCalendar ??= await _calendarService.GetAccountCalendarAsync(item.CalendarId).ConfigureAwait(false); var existing = await _calendarService.GetCalendarItemAsync(item.Id).ConfigureAwait(false); if (existing == null) - await _calendarService.CreateNewCalendarItemAsync(item, request.Attendees).ConfigureAwait(false); + await _calendarService.CreateNewCalendarItemAsync(item, attendees).ConfigureAwait(false); else - await _calendarService.UpdateCalendarItemAsync(item, request.Attendees).ConfigureAwait(false); + await _calendarService.UpdateCalendarItemAsync(item, attendees).ConfigureAwait(false); - await PersistIcsAsync(item, request.Attendees).ConfigureAwait(false); + await _calendarService.SaveRemindersAsync(item.Id, reminders).ConfigureAwait(false); + await SaveAttachmentsAsync(request.ComposeResult, item.Id).ConfigureAwait(false); + await PersistIcsAsync(item, attendees).ConfigureAwait(false); } public async Task UpdateCalendarEventAsync(UpdateCalendarEventRequest request) @@ -1616,6 +1623,45 @@ public class ImapSynchronizer : WinoSynchronizer(); + + foreach (var attachment in attachments.Where(a => !string.IsNullOrWhiteSpace(a.FilePath) && File.Exists(a.FilePath))) + { + var fileName = string.IsNullOrWhiteSpace(attachment.FileName) ? Path.GetFileName(attachment.FilePath) : attachment.FileName; + var destinationPath = Path.Combine(attachmentsRoot, fileName); + File.Copy(attachment.FilePath, destinationPath, overwrite: true); + + storedAttachments.Add(new CalendarAttachment + { + Id = Guid.NewGuid(), + CalendarItemId = calendarItemId, + RemoteAttachmentId = attachment.Id.ToString("N"), + FileName = fileName, + Size = attachment.Size, + ContentType = MimeTypes.GetMimeType(fileName), + IsDownloaded = true, + LocalFilePath = destinationPath, + LastModified = DateTimeOffset.UtcNow + }); + } + + if (storedAttachments.Count > 0) + { + await _calendarService.InsertOrReplaceAttachmentsAsync(storedAttachments).ConfigureAwait(false); + } + } } private sealed class CalDavCalendarOperationHandler : IImapCalendarOperationHandler @@ -1640,7 +1686,7 @@ public class ImapSynchronizer : WinoSynchronizer UpsertCalendarEventAsync(request.Item, request.Attendees); + => UpsertCalendarEventAsync(request.PreparedItem, request.PreparedEvent.Attendees); public Task UpdateCalendarEventAsync(UpdateCalendarEventRequest request) => UpsertCalendarEventAsync(request.Item, request.Attendees); @@ -1654,7 +1700,7 @@ public class ImapSynchronizer : WinoSynchronizer bundle, HttpResponseMessage response) { - if (bundle?.UIChangeRequest is not CreateDraftRequest createDraftRequest) - return; - try { var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); @@ -1889,24 +1889,95 @@ public class OutlookSynchronizer : WinoSynchronizer(); - if (string.IsNullOrWhiteSpace(createdDraftId)) + if (bundle?.UIChangeRequest is CreateDraftRequest createDraftRequest) + { + var createdDraftId = json?["id"]?.GetValue(); + if (string.IsNullOrWhiteSpace(createdDraftId)) + return; + + var createdConversationId = json?["conversationId"]?.GetValue(); + var localDraft = createDraftRequest.DraftPreperationRequest.CreatedLocalDraftCopy; + + await _outlookChangeProcessor.MapLocalDraftAsync( + Account.Id, + localDraft.UniqueId, + createdDraftId, + createdConversationId, + createdConversationId).ConfigureAwait(false); return; + } - var createdConversationId = json?["conversationId"]?.GetValue(); - var localDraft = createDraftRequest.DraftPreperationRequest.CreatedLocalDraftCopy; + if (bundle?.UIChangeRequest is CreateCalendarEventRequest createCalendarEventRequest) + { + var createdEventId = json?["id"]?.GetValue(); + if (string.IsNullOrWhiteSpace(createdEventId)) + return; - await _outlookChangeProcessor.MapLocalDraftAsync( - Account.Id, - localDraft.UniqueId, - createdDraftId, - createdConversationId, - createdConversationId).ConfigureAwait(false); + await UploadCalendarEventAttachmentsAsync(createCalendarEventRequest, createdEventId, CancellationToken.None).ConfigureAwait(false); + } } catch (Exception ex) { - // Draft mapping is best-effort here. Delta sync mapping remains as fallback. - _logger.Debug(ex, "Failed to map Outlook draft from create-draft response."); + _logger.Debug(ex, "Failed to process Outlook create response."); + } + } + + private async Task UploadCalendarEventAttachmentsAsync(CreateCalendarEventRequest request, string remoteEventId, CancellationToken cancellationToken) + { + var attachments = request.ComposeResult.Attachments ?? []; + if (attachments.Count == 0) + return; + + var remoteCalendarId = request.AssignedCalendar.RemoteCalendarId; + + foreach (var attachment in attachments.Where(a => !string.IsNullOrWhiteSpace(a.FilePath) && File.Exists(a.FilePath))) + { + cancellationToken.ThrowIfCancellationRequested(); + + var contentBytes = await File.ReadAllBytesAsync(attachment.FilePath, cancellationToken).ConfigureAwait(false); + var contentType = MimeTypes.GetMimeType(attachment.FileName ?? attachment.FilePath); + + var fileAttachment = new FileAttachment + { + Name = attachment.FileName, + ContentType = contentType, + ContentBytes = contentBytes + }; + + if (contentBytes.Length <= SimpleAttachmentUploadLimitBytes) + { + await _graphClient.Me.Calendars[remoteCalendarId].Events[remoteEventId].Attachments.PostAsync(fileAttachment, cancellationToken: cancellationToken).ConfigureAwait(false); + continue; + } + + if (contentBytes.Length > MaximumUploadSessionAttachmentSizeBytes) + { + var attachmentSizeMb = contentBytes.LongLength / (1024d * 1024d); + var maximumSizeMb = MaximumUploadSessionAttachmentSizeBytes / (1024d * 1024d); + + throw new InvalidOperationException( + $"Attachment '{attachment.FileName}' is {attachmentSizeMb:F1} MB, which exceeds Outlook's upload limit of {maximumSizeMb:F0} MB per attachment."); + } + + var sessionBody = new Microsoft.Graph.Me.Calendars.Item.Events.Item.Attachments.CreateUploadSession.CreateUploadSessionPostRequestBody + { + AttachmentItem = new AttachmentItem + { + AttachmentType = AttachmentType.File, + ContentType = contentType, + Name = attachment.FileName, + Size = contentBytes.LongLength + } + }; + + var uploadSession = await _graphClient.Me.Calendars[remoteCalendarId].Events[remoteEventId].Attachments.CreateUploadSession.PostAsync(sessionBody, cancellationToken: cancellationToken).ConfigureAwait(false); + + if (uploadSession?.UploadUrl == null) + { + throw new InvalidOperationException($"Failed to create upload session for attachment '{attachment.FileName}'."); + } + + await UploadAttachmentInChunksAsync(uploadSession.UploadUrl, contentBytes, cancellationToken).ConfigureAwait(false); } } @@ -2370,52 +2441,51 @@ public class OutlookSynchronizer : WinoSynchronizer> CreateCalendarEvent(CreateCalendarEventRequest request) { - var calendarItem = request.Item; - var attendees = request.Attendees; + var calendarItem = request.PreparedItem; + var attendees = request.PreparedEvent.Attendees; + var reminders = request.PreparedEvent.Reminders; + var calendar = request.AssignedCalendar; - // Get the calendar for this event - var calendar = calendarItem.AssignedCalendar; - if (calendar == null) - { - throw new InvalidOperationException("Calendar item must have an assigned calendar"); - } - - // Convert CalendarItem to Outlook Event var outlookEvent = new Microsoft.Graph.Models.Event { Subject = calendarItem.Title, Body = new Microsoft.Graph.Models.ItemBody { - ContentType = Microsoft.Graph.Models.BodyType.Text, + ContentType = Microsoft.Graph.Models.BodyType.Html, Content = calendarItem.Description }, Location = new Microsoft.Graph.Models.Location { DisplayName = calendarItem.Location - } + }, + ShowAs = calendarItem.ShowAs switch + { + CalendarItemShowAs.Free => Microsoft.Graph.Models.FreeBusyStatus.Free, + CalendarItemShowAs.Tentative => Microsoft.Graph.Models.FreeBusyStatus.Tentative, + CalendarItemShowAs.Busy => Microsoft.Graph.Models.FreeBusyStatus.Busy, + CalendarItemShowAs.OutOfOffice => Microsoft.Graph.Models.FreeBusyStatus.Oof, + CalendarItemShowAs.WorkingElsewhere => Microsoft.Graph.Models.FreeBusyStatus.WorkingElsewhere, + _ => Microsoft.Graph.Models.FreeBusyStatus.Busy + }, + TransactionId = calendarItem.Id.ToString("N") }; - // Set start and end time using DateTimeTimeZone if (calendarItem.IsAllDayEvent) { - // All-day events outlookEvent.IsAllDay = true; outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone { DateTime = calendarItem.StartDate.ToString("yyyy-MM-dd"), - TimeZone = "UTC" + TimeZone = calendarItem.StartTimeZone ?? TimeZoneInfo.Local.Id }; outlookEvent.End = new Microsoft.Graph.Models.DateTimeTimeZone { DateTime = calendarItem.EndDate.ToString("yyyy-MM-dd"), - TimeZone = "UTC" + TimeZone = calendarItem.EndTimeZone ?? calendarItem.StartTimeZone ?? TimeZoneInfo.Local.Id }; } else { - // Regular events with time - // StartDate and EndDate are stored in the event's timezone - // We preserve the timezone information during creation outlookEvent.IsAllDay = false; outlookEvent.Start = new Microsoft.Graph.Models.DateTimeTimeZone { @@ -2429,8 +2499,7 @@ public class OutlookSynchronizer : WinoSynchronizer 0) + if (attendees.Count > 0) { outlookEvent.Attendees = attendees.Select(a => new Microsoft.Graph.Models.Attendee { @@ -2443,7 +2512,23 @@ public class OutlookSynchronizer : WinoSynchronizer 0) + { + var reminder = reminders + .OrderBy(reminder => reminder.DurationInSeconds) + .FirstOrDefault(reminder => reminder.ReminderType == CalendarItemReminderType.Popup) + ?? reminders.OrderBy(reminder => reminder.DurationInSeconds).First(); + + outlookEvent.IsReminderOn = true; + outlookEvent.ReminderMinutesBeforeStart = (int)Math.Max(0, reminder.DurationInSeconds / 60); + } + + var recurrence = CalendarRecurrenceMapper.CreateOutlookRecurrence(calendarItem); + if (recurrence != null) + { + outlookEvent.Recurrence = recurrence; + } + var createRequest = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events.ToPostRequestInformation(outlookEvent); return [new HttpRequestBundle(createRequest, request)]; @@ -2459,12 +2544,13 @@ public class OutlookSynchronizer : WinoSynchronizer(updateRequest, request)]; } @@ -2624,13 +2712,13 @@ public class OutlookSynchronizer : WinoSynchronizer(deleteRequest, request)]; } diff --git a/Wino.Services/CalendarService.cs b/Wino.Services/CalendarService.cs index 9a3d0f42..dac3ec97 100644 --- a/Wino.Services/CalendarService.cs +++ b/Wino.Services/CalendarService.cs @@ -128,9 +128,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService public async Task DeleteCalendarItemAsync(string calendarRemoteEventId, Guid calendarId) { - var calendarItem = await Connection.FindWithQueryAsync( - "SELECT * FROM CalendarItem WHERE CalendarId = ? AND RemoteEventId = ?", - calendarId, calendarRemoteEventId); + var calendarItem = await FindCalendarItemByRemoteEventIdAsync(calendarId, calendarRemoteEventId).ConfigureAwait(false); if (calendarItem == null) return; @@ -244,9 +242,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService public async Task GetCalendarItemAsync(Guid accountCalendarId, string remoteEventId) { - var calendarItem = await Connection.FindWithQueryAsync( - "SELECT * FROM CalendarItem WHERE CalendarId = ? AND RemoteEventId = ?", - accountCalendarId, remoteEventId); + var calendarItem = await FindCalendarItemByRemoteEventIdAsync(accountCalendarId, remoteEventId).ConfigureAwait(false); await LoadAssignedCalendarAsync(calendarItem); @@ -481,6 +477,20 @@ public class CalendarService : BaseDatabaseService, ICalendarService private static DateTime MaxDateTime(DateTime first, DateTime second) => first >= second ? first : second; + private Task FindCalendarItemByRemoteEventIdAsync(Guid calendarId, string remoteEventId) + { + if (string.IsNullOrWhiteSpace(remoteEventId)) + return Task.FromResult(null); + + var providerRemoteEventId = remoteEventId.GetProviderRemoteEventId(); + + return Connection.FindWithQueryAsync( + "SELECT * FROM CalendarItem WHERE CalendarId = ? AND (RemoteEventId = ? OR RemoteEventId LIKE ?)", + calendarId, + providerRemoteEventId, + $"{providerRemoteEventId}::%"); + } + private sealed class CalendarReminderCandidate { public Guid CalendarItemId { get; set; }