diff --git a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs index fedfce72..abce683e 100644 --- a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.ObjectModel; using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; @@ -14,11 +15,13 @@ using Wino.Calendar.ViewModels.Interfaces; using Wino.Calendar.ViewModels.Messages; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models; using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Navigation; +using Wino.Core.Services; using Wino.Core.ViewModels; using Wino.Messaging.Client.Calendar; using Wino.Messaging.UI; @@ -119,7 +122,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, public partial bool IsCalendarEnabled { get; set; } = true; [ObservableProperty] - public partial IReadOnlyList CalendarItems { get; set; } = []; + public partial ObservableCollection CalendarItems { get; set; } = new(); #endregion @@ -153,7 +156,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, private bool _subscriptionsAttached; private CancellationTokenSource _pageLifetimeCts = new(); private long _pageLifetimeVersion; - private List _loadedCalendarItems = []; + private Dictionary _loadedCalendarItems = new(); [ObservableProperty] public partial CalendarSettings CurrentSettings { get; set; } @@ -323,8 +326,8 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, CurrentVisibleRange = null; VisibleDateRangeText = string.Empty; LoadedDateWindow = null; - _loadedCalendarItems = []; - CalendarItems = []; + _loadedCalendarItems = new(); + CalendarItems = new(); } public void Dispose() @@ -594,8 +597,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, { if (loadedItems != null) { - _loadedCalendarItems = loadedItems; - CalendarItems = loadedItems; + ReplaceLoadedCalendarItems(loadedItems); } EnsureSelectedQuickEventAccountCalendar(); @@ -656,8 +658,10 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, { var loadedItems = new Dictionary(); var loadPeriod = new TimeRange(loadedDateWindow.StartDate, loadedDateWindow.EndDate); + var activeCalendars = AccountCalendarStateService.ActiveCalendars.ToList(); + var pendingCalendarItemIds = await GetPendingCalendarItemIdsAsync(activeCalendars, lifetimeVersion).ConfigureAwait(false); - foreach (var calendarViewModel in AccountCalendarStateService.ActiveCalendars) + foreach (var calendarViewModel in activeCalendars) { if (!IsPageActive(lifetimeVersion)) return []; @@ -672,12 +676,16 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, if (!loadedItems.ContainsKey(calendarItem.Id)) { - loadedItems.Add(calendarItem.Id, new CalendarItemViewModel(calendarItem)); + loadedItems.Add(calendarItem.Id, CreateCalendarItemViewModel(calendarItem, pendingCalendarItemIds)); } } } - return loadedItems.Values.ToList(); + return loadedItems.Values + .OrderBy(item => item.StartDate) + .ThenBy(item => item.EndDate) + .ThenBy(item => item.Id) + .ToList(); } private static bool IsSameVisibleRange(VisibleDateRange current, VisibleDateRange next) @@ -754,41 +762,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, await ReloadCurrentVisibleRangeAsync().ConfigureAwait(false); } - protected override void OnCalendarItemDeleted(CalendarItem calendarItem) + protected override void OnCalendarItemDeleted(CalendarItem calendarItem, EntityUpdateSource source) { - base.OnCalendarItemDeleted(calendarItem); + base.OnCalendarItemDeleted(calendarItem, source); - if (DisplayDetailsCalendarItemViewModel?.Id == calendarItem.Id || - DisplayDetailsCalendarItemViewModel?.CalendarItem?.RecurringCalendarItemId == calendarItem.Id) - { - DisplayDetailsCalendarItemViewModel = null; - } - - if (ShouldReloadFor(calendarItem)) - { - _ = ReloadCurrentVisibleRangeAsync(); - } - } - - protected override void OnCalendarItemUpdated(CalendarItem calendarItem, CalendarItemUpdateSource source) - { - base.OnCalendarItemUpdated(calendarItem, source); - - if (DisplayDetailsCalendarItemViewModel?.Id == calendarItem.Id) - { - calendarItem.AssignedCalendar ??= DisplayDetailsCalendarItemViewModel.AssignedCalendar; - DisplayDetailsCalendarItemViewModel = new CalendarItemViewModel(calendarItem); - } - - if (ShouldReloadFor(calendarItem)) - { - _ = ReloadCurrentVisibleRangeAsync(); - } - } - - protected override void OnCalendarItemAdded(CalendarItem calendarItem) - { - base.OnCalendarItemAdded(calendarItem); + if (calendarItem == null) + return; if (calendarItem.IsRecurringParent) { @@ -796,21 +775,272 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, return; } - if (ShouldReloadFor(calendarItem)) + var existingItemId = FindLoadedCalendarItemId(calendarItem); + if (!existingItemId.HasValue) + return; + + RemoveLoadedCalendarItem(existingItemId.Value, calendarItem); + } + + protected override void OnCalendarItemUpdated(CalendarItem calendarItem, EntityUpdateSource source) + { + base.OnCalendarItemUpdated(calendarItem, source); + ApplyCalendarItemUpsert(calendarItem, source); + } + + protected override void OnCalendarItemAdded(CalendarItem calendarItem, EntityUpdateSource source) + { + base.OnCalendarItemAdded(calendarItem, source); + ApplyCalendarItemUpsert(calendarItem, source); + } + + private async Task> GetPendingCalendarItemIdsAsync(IEnumerable activeCalendars, long lifetimeVersion) + { + var pendingCalendarItemIds = new HashSet(); + var accountIds = activeCalendars + .Select(calendar => calendar.Account.Id) + .Where(accountId => accountId != Guid.Empty) + .Distinct() + .ToList(); + + foreach (var accountId in accountIds) + { + if (!IsPageActive(lifetimeVersion)) + return pendingCalendarItemIds; + + IWinoSynchronizerBase synchronizer; + try + { + synchronizer = await SynchronizationManager.Instance.GetSynchronizerAsync(accountId).ConfigureAwait(false); + } + catch (InvalidOperationException) + { + return pendingCalendarItemIds; + } + + if (synchronizer == null) + continue; + + foreach (var pendingCalendarItemId in synchronizer.GetPendingCalendarOperationIds()) + { + pendingCalendarItemIds.Add(pendingCalendarItemId); + } + } + + return pendingCalendarItemIds; + } + + private void ApplyCalendarItemUpsert(CalendarItem calendarItem, EntityUpdateSource source) + { + if (calendarItem == null) + return; + + if (calendarItem.IsRecurringParent) { _ = ReloadCurrentVisibleRangeAsync(); + return; + } + + var existingItemId = FindLoadedCalendarItemId(calendarItem); + var shouldDisplay = ShouldDisplayCalendarItem(calendarItem); + + if (!shouldDisplay) + { + if (existingItemId.HasValue) + { + RemoveLoadedCalendarItem(existingItemId.Value, calendarItem); + } + + return; + } + + var newViewModel = CreateCalendarItemViewModel(calendarItem, source); + + if (existingItemId.HasValue) + { + ReplaceLoadedCalendarItem(existingItemId.Value, newViewModel); + } + else + { + InsertLoadedCalendarItem(newViewModel); } } - private bool ShouldReloadFor(CalendarItem calendarItem) + private CalendarItemViewModel CreateCalendarItemViewModel(CalendarItem calendarItem, EntityUpdateSource source) + => CreateCalendarItemViewModel( + calendarItem, + source == EntityUpdateSource.ClientUpdated ? new HashSet { calendarItem.Id } : null, + source); + + private CalendarItemViewModel CreateCalendarItemViewModel(CalendarItem calendarItem, ISet pendingCalendarItemIds, EntityUpdateSource source = EntityUpdateSource.Server) + { + calendarItem.AssignedCalendar ??= ResolveAssignedCalendar(calendarItem.CalendarId); + + return new CalendarItemViewModel(calendarItem) + { + IsBusy = source == EntityUpdateSource.ClientUpdated || HasPendingCalendarOperation(calendarItem, pendingCalendarItemIds) + }; + } + + private void ReplaceLoadedCalendarItems(IEnumerable loadedItems) + { + var loadedItemsList = loadedItems?.ToList() ?? []; + CalendarItems = new ObservableCollection(loadedItemsList); + _loadedCalendarItems = loadedItemsList.ToDictionary(item => item.Id); + } + + private void InsertLoadedCalendarItem(CalendarItemViewModel calendarItemViewModel) + { + var insertionIndex = 0; + + while (insertionIndex < CalendarItems.Count && CompareCalendarItems(CalendarItems[insertionIndex], calendarItemViewModel) <= 0) + { + insertionIndex++; + } + + CalendarItems.Insert(insertionIndex, calendarItemViewModel); + _loadedCalendarItems[calendarItemViewModel.Id] = calendarItemViewModel; + + if (IsDisplayDetailsMatch(calendarItemViewModel.CalendarItem)) + { + DisplayDetailsCalendarItemViewModel = calendarItemViewModel; + } + } + + private void ReplaceLoadedCalendarItem(Guid existingItemId, CalendarItemViewModel replacementViewModel) + { + if (!_loadedCalendarItems.TryGetValue(existingItemId, out var existingViewModel)) + { + InsertLoadedCalendarItem(replacementViewModel); + return; + } + + replacementViewModel.IsSelected = existingViewModel.IsSelected; + + var existingIndex = CalendarItems.IndexOf(existingViewModel); + if (existingIndex >= 0) + { + CalendarItems[existingIndex] = replacementViewModel; + } + + _loadedCalendarItems.Remove(existingItemId); + _loadedCalendarItems[replacementViewModel.Id] = replacementViewModel; + + if (existingIndex >= 0) + { + MoveCalendarItemToSortedPosition(replacementViewModel, existingIndex); + } + + if (IsDisplayDetailsMatch(replacementViewModel.CalendarItem, existingItemId)) + { + DisplayDetailsCalendarItemViewModel = replacementViewModel; + } + } + + private void RemoveLoadedCalendarItem(Guid existingItemId, CalendarItem calendarItem) + { + if (_loadedCalendarItems.TryGetValue(existingItemId, out var existingViewModel)) + { + CalendarItems.Remove(existingViewModel); + _loadedCalendarItems.Remove(existingItemId); + } + + if (IsDisplayDetailsMatch(calendarItem, existingItemId)) + { + DisplayDetailsCalendarItemViewModel = null; + } + } + + private void MoveCalendarItemToSortedPosition(CalendarItemViewModel calendarItemViewModel, int previousIndex) + { + if (previousIndex < 0) + return; + + var targetIndex = 0; + while (targetIndex < CalendarItems.Count && CompareCalendarItems(CalendarItems[targetIndex], calendarItemViewModel) <= 0) + { + targetIndex++; + } + + if (targetIndex > previousIndex) + { + targetIndex--; + } + + if (targetIndex != previousIndex) + { + CalendarItems.Move(previousIndex, targetIndex); + } + } + + private Guid? FindLoadedCalendarItemId(CalendarItem calendarItem) + { + if (calendarItem == null) + return null; + + if (_loadedCalendarItems.ContainsKey(calendarItem.Id)) + return calendarItem.Id; + + var trackedLocalItemId = calendarItem.RemoteEventId.GetClientTrackingId(); + if (trackedLocalItemId.HasValue && _loadedCalendarItems.ContainsKey(trackedLocalItemId.Value)) + return trackedLocalItemId.Value; + + return null; + } + + private bool ShouldDisplayCalendarItem(CalendarItem calendarItem) { if (calendarItem == null || LoadedDateWindow == null) return false; + if (calendarItem.IsHidden || calendarItem.IsRecurringParent || !IsCalendarActive(calendarItem.CalendarId)) + return false; + var loadedWindow = new TimeRange(LoadedDateWindow.StartDate, LoadedDateWindow.EndDate); return loadedWindow.OverlapsWith(calendarItem.Period); } + private bool IsDisplayDetailsMatch(CalendarItem calendarItem, Guid? existingItemId = null) + { + if (DisplayDetailsCalendarItemViewModel == null || calendarItem == null) + return false; + + var trackedLocalItemId = calendarItem.RemoteEventId.GetClientTrackingId(); + + return DisplayDetailsCalendarItemViewModel.Id == calendarItem.Id || + (existingItemId.HasValue && DisplayDetailsCalendarItemViewModel.Id == existingItemId.Value) || + (trackedLocalItemId.HasValue && DisplayDetailsCalendarItemViewModel.Id == trackedLocalItemId.Value) || + DisplayDetailsCalendarItemViewModel.CalendarItem?.RecurringCalendarItemId == calendarItem.Id; + } + + private bool HasPendingCalendarOperation(CalendarItem calendarItem, ISet pendingCalendarItemIds) + { + if (calendarItem == null || pendingCalendarItemIds == null || pendingCalendarItemIds.Count == 0) + return false; + + if (pendingCalendarItemIds.Contains(calendarItem.Id)) + return true; + + var trackedLocalItemId = calendarItem.RemoteEventId.GetClientTrackingId(); + return trackedLocalItemId.HasValue && pendingCalendarItemIds.Contains(trackedLocalItemId.Value); + } + + private AccountCalendarViewModel ResolveAssignedCalendar(Guid calendarId) + => AccountCalendarStateService.AllCalendars.FirstOrDefault(calendar => calendar.Id == calendarId); + + private static int CompareCalendarItems(CalendarItemViewModel left, CalendarItemViewModel right) + { + var compareResult = DateTime.Compare(left?.StartDate ?? DateTime.MinValue, right?.StartDate ?? DateTime.MinValue); + if (compareResult != 0) + return compareResult; + + compareResult = DateTime.Compare(left?.EndDate ?? DateTime.MinValue, right?.EndDate ?? DateTime.MinValue); + if (compareResult != 0) + return compareResult; + + return Nullable.Compare(left?.Id, right?.Id); + } + partial void OnIsAllDayChanged(bool value) { if (value) diff --git a/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs b/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs index 2cc5a0d2..5baf0ae7 100644 --- a/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs +++ b/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs @@ -12,6 +12,7 @@ using Serilog; using Wino.Calendar.ViewModels.Data; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Calendar; @@ -187,20 +188,20 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel await LoadCalendarItemTargetAsync(args); } - protected override async void OnCalendarItemUpdated(CalendarItem calendarItem, CalendarItemUpdateSource source) + protected override async void OnCalendarItemUpdated(CalendarItem calendarItem, EntityUpdateSource source) { base.OnCalendarItemUpdated(calendarItem, source); // If the current event was updated, reload it - if (CurrentEvent?.CalendarItem?.Id == calendarItem.Id || CurrentEvent?.CalendarItem.RecurringCalendarItemId == calendarItem.Id) + if (IsCurrentEventMatch(calendarItem)) { // Reflect client-side optimistic changes immediately; fallback to DB for server updates. - if (source == CalendarItemUpdateSource.ClientUpdated || source == CalendarItemUpdateSource.ClientReverted) + if (source == EntityUpdateSource.ClientUpdated || source == EntityUpdateSource.ClientReverted) { var previousAttendees = CurrentEvent?.Attendees?.ToList() ?? []; CurrentEvent = new CalendarItemViewModel(calendarItem) { - IsBusy = source == CalendarItemUpdateSource.ClientUpdated + IsBusy = source == EntityUpdateSource.ClientUpdated }; foreach (var attendee in previousAttendees) @@ -221,17 +222,54 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel } } - protected override void OnCalendarItemDeleted(CalendarItem calendarItem) + protected override async void OnCalendarItemAdded(CalendarItem calendarItem, EntityUpdateSource source) { - base.OnCalendarItemDeleted(calendarItem); + base.OnCalendarItemAdded(calendarItem, source); + + if (!IsCurrentEventMatch(calendarItem)) + return; + + if (source == EntityUpdateSource.ClientUpdated || source == EntityUpdateSource.ClientReverted) + { + CurrentEvent = new CalendarItemViewModel(calendarItem) + { + IsBusy = source == EntityUpdateSource.ClientUpdated + }; + + return; + } + + var refreshedEvent = await _calendarService.GetCalendarItemAsync(calendarItem.Id); + if (refreshedEvent != null) + { + CurrentEvent = new CalendarItemViewModel(refreshedEvent); + await LoadAttendeesAsync(refreshedEvent.Id, refreshedEvent); + } + } + + protected override void OnCalendarItemDeleted(CalendarItem calendarItem, EntityUpdateSource source) + { + base.OnCalendarItemDeleted(calendarItem, source); // If the current event was deleted, navigate back - if (CurrentEvent?.CalendarItem?.Id == calendarItem.Id || CurrentEvent?.CalendarItem.RecurringCalendarItemId == calendarItem.Id) + if (IsCurrentEventMatch(calendarItem)) { NavigateBackToCalendar(forceReload: true); } } + private bool IsCurrentEventMatch(CalendarItem calendarItem) + { + if (CurrentEvent?.CalendarItem == null || calendarItem == null) + return false; + + var trackedLocalItemId = calendarItem.RemoteEventId.GetClientTrackingId(); + + return CurrentEvent.CalendarItem.Id == calendarItem.Id || + (trackedLocalItemId.HasValue && CurrentEvent.CalendarItem.Id == trackedLocalItemId.Value) || + CurrentEvent.CalendarItem.RecurringCalendarItemId == calendarItem.Id; + } + private async Task LoadCalendarItemTargetAsync(CalendarItemTarget target) { try diff --git a/Wino.Core.Domain/Enums/CalendarItemUpdateSource.cs b/Wino.Core.Domain/Enums/EntityUpdateSource.cs similarity index 54% rename from Wino.Core.Domain/Enums/CalendarItemUpdateSource.cs rename to Wino.Core.Domain/Enums/EntityUpdateSource.cs index f4e3828c..f8aeb282 100644 --- a/Wino.Core.Domain/Enums/CalendarItemUpdateSource.cs +++ b/Wino.Core.Domain/Enums/EntityUpdateSource.cs @@ -1,17 +1,17 @@ namespace Wino.Core.Domain.Enums; /// -/// Indicates the source of a calendar item update. +/// Indicates the source of an entity update. /// -public enum CalendarItemUpdateSource +public enum EntityUpdateSource { /// - /// Update originated from client-side UI changes (ApplyUIChanges). + /// Update originated from client-side optimistic UI changes (ApplyUIChanges). /// ClientUpdated, /// - /// Update originated from client-side UI revert (RevertUIChanges). + /// Update originated from reverting client-side optimistic UI changes (RevertUIChanges). /// ClientReverted, diff --git a/Wino.Core.Domain/Enums/MailUpdateSource.cs b/Wino.Core.Domain/Enums/MailUpdateSource.cs deleted file mode 100644 index e8a3b91f..00000000 --- a/Wino.Core.Domain/Enums/MailUpdateSource.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Wino.Core.Domain.Enums; - -/// -/// Indicates the source of a mail update. -/// -public enum MailUpdateSource -{ - /// - /// Update originated from client-side UI changes (ApplyUIChanges). - /// - ClientUpdated, - - /// - /// Update originated from client-side UI revert (RevertUIChanges). - /// - ClientReverted, - - /// - /// Update originated from server synchronization or database operations. - /// - Server -} diff --git a/Wino.Core.Domain/Interfaces/IBaseSynchronizer.cs b/Wino.Core.Domain/Interfaces/IBaseSynchronizer.cs index f423eadf..a43e488a 100644 --- a/Wino.Core.Domain/Interfaces/IBaseSynchronizer.cs +++ b/Wino.Core.Domain/Interfaces/IBaseSynchronizer.cs @@ -42,6 +42,11 @@ public interface IBaseSynchronizer /// Calendar item id to check. bool HasPendingCalendarOperation(Guid calendarItemId); + /// + /// Returns calendar item ids that currently have queued or executing operations. + /// + IReadOnlyCollection GetPendingCalendarOperationIds(); + /// /// Synchronizes profile information with the server. /// Sender name and Profile picture are updated. diff --git a/Wino.Core.Domain/Models/Synchronization/CalendarSynchronizationResult.cs b/Wino.Core.Domain/Models/Synchronization/CalendarSynchronizationResult.cs index 86de1b5c..7adc7afa 100644 --- a/Wino.Core.Domain/Models/Synchronization/CalendarSynchronizationResult.cs +++ b/Wino.Core.Domain/Models/Synchronization/CalendarSynchronizationResult.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Linq; using System.Text.Json.Serialization; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; @@ -22,6 +24,13 @@ public class CalendarSynchronizationResult public SynchronizationCompletedState CompletedState { get; set; } + public Exception Exception { get; set; } + + public List Issues { get; set; } = []; + + [JsonIgnore] + public IEnumerable AllIssues => Issues; + public static CalendarSynchronizationResult Empty => new() { CompletedState = SynchronizationCompletedState.Success }; // Mail synchronization @@ -41,5 +50,48 @@ public class CalendarSynchronizationResult }; public static CalendarSynchronizationResult Canceled => new() { CompletedState = SynchronizationCompletedState.Canceled }; - public static CalendarSynchronizationResult Failed => new() { CompletedState = SynchronizationCompletedState.Failed }; + public static CalendarSynchronizationResult Failed(Exception exception = null) => new() + { + CompletedState = SynchronizationCompletedState.Failed, + Exception = exception + }; + + public CalendarSynchronizationResult MergeIssues(IEnumerable issues) + { + if (issues == null) + return this; + + foreach (var issue in issues.Where(issue => issue != null)) + { + if (!Issues.Any(existing => AreEquivalent(existing, issue))) + { + Issues.Add(issue); + } + } + + if (CompletedState == SynchronizationCompletedState.Success && Issues.Any()) + { + CompletedState = SynchronizationCompletedState.PartiallyCompleted; + } + + if (Exception == null) + { + Exception = Issues.FirstOrDefault(issue => !string.IsNullOrWhiteSpace(issue?.Message)) is { } issue + ? new Exception(issue.Message) + : null; + } + + return this; + } + + private static bool AreEquivalent(SynchronizationIssue left, SynchronizationIssue right) + => string.Equals(left?.Message, right?.Message, StringComparison.Ordinal) + && left?.ErrorCode == right?.ErrorCode + && left?.Severity == right?.Severity + && left?.Category == right?.Category + && string.Equals(left?.OperationType, right?.OperationType, StringComparison.Ordinal) + && string.Equals(left?.RequestType, right?.RequestType, StringComparison.Ordinal) + && left?.FolderId == right?.FolderId + && left?.CalendarId == right?.CalendarId + && string.Equals(left?.ScopeName, right?.ScopeName, StringComparison.Ordinal); } diff --git a/Wino.Core.Domain/Models/Synchronization/MailSynchronizationResult.cs b/Wino.Core.Domain/Models/Synchronization/MailSynchronizationResult.cs index c38fa6e3..10554104 100644 --- a/Wino.Core.Domain/Models/Synchronization/MailSynchronizationResult.cs +++ b/Wino.Core.Domain/Models/Synchronization/MailSynchronizationResult.cs @@ -26,6 +26,8 @@ public class MailSynchronizationResult public Exception Exception { get; set; } + public List Issues { get; set; } = []; + /// /// Gets or sets the results for each folder that was synchronized. /// Enables partial failure tracking - some folders may succeed while others fail. @@ -75,6 +77,10 @@ public class MailSynchronizationResult [JsonIgnore] public IEnumerable FailedFolders => FolderResults.Where(f => !f.Success); + [JsonIgnore] + public IEnumerable AllIssues + => Issues.Concat(FailedFolders.Select(SynchronizationIssue.FromFolderResult).Where(issue => issue != null)); + public static MailSynchronizationResult Empty => new() { CompletedState = SynchronizationCompletedState.Success }; // Mail synchronization @@ -121,4 +127,43 @@ public class MailSynchronizationResult CompletedState = SynchronizationCompletedState.Failed, Exception = exception }; + + public MailSynchronizationResult MergeIssues(IEnumerable issues) + { + if (issues == null) + return this; + + foreach (var issue in issues.Where(issue => issue != null)) + { + if (!Issues.Any(existing => AreEquivalent(existing, issue))) + { + Issues.Add(issue); + } + } + + if (CompletedState == SynchronizationCompletedState.Success && AllIssues.Any()) + { + CompletedState = SynchronizationCompletedState.PartiallyCompleted; + } + + if (Exception == null) + { + Exception = Issues.FirstOrDefault(issue => !string.IsNullOrWhiteSpace(issue?.Message)) is { } issue + ? new Exception(issue.Message) + : null; + } + + return this; + } + + private static bool AreEquivalent(SynchronizationIssue left, SynchronizationIssue right) + => string.Equals(left?.Message, right?.Message, StringComparison.Ordinal) + && left?.ErrorCode == right?.ErrorCode + && left?.Severity == right?.Severity + && left?.Category == right?.Category + && string.Equals(left?.OperationType, right?.OperationType, StringComparison.Ordinal) + && string.Equals(left?.RequestType, right?.RequestType, StringComparison.Ordinal) + && left?.FolderId == right?.FolderId + && left?.CalendarId == right?.CalendarId + && string.Equals(left?.ScopeName, right?.ScopeName, StringComparison.Ordinal); } diff --git a/Wino.Core.Domain/Models/Synchronization/SynchronizationIssue.cs b/Wino.Core.Domain/Models/Synchronization/SynchronizationIssue.cs new file mode 100644 index 00000000..e5f52757 --- /dev/null +++ b/Wino.Core.Domain/Models/Synchronization/SynchronizationIssue.cs @@ -0,0 +1,110 @@ +using System; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; + +namespace Wino.Core.Domain.Models.Synchronization; + +/// +/// Represents a user-visible synchronization issue collected during request execution or provider synchronization. +/// +public class SynchronizationIssue +{ + public string Message { get; set; } + public int? ErrorCode { get; set; } + public SynchronizerErrorSeverity Severity { get; set; } = SynchronizerErrorSeverity.Fatal; + public SynchronizerErrorCategory Category { get; set; } = SynchronizerErrorCategory.Unknown; + public string OperationType { get; set; } + public string RequestType { get; set; } + public Guid? FolderId { get; set; } + public string FolderName { get; set; } + public Guid? CalendarId { get; set; } + public string CalendarName { get; set; } + public string ScopeName { get; set; } + public bool WasHandled { get; set; } + public string HandledBy { get; set; } + public bool CanContinueSync { get; set; } + public bool IsEntityNotFound { get; set; } + public string ExceptionType { get; set; } + + public static SynchronizationIssue FromErrorContext(SynchronizerErrorContext errorContext) + { + if (errorContext == null) + return null; + + return new SynchronizationIssue + { + Message = errorContext.ErrorMessage ?? errorContext.Exception?.Message, + ErrorCode = errorContext.ErrorCode, + Severity = errorContext.Severity, + Category = errorContext.Category, + OperationType = errorContext.OperationType, + RequestType = errorContext.Request?.GetType().Name, + FolderId = errorContext.FolderId, + FolderName = errorContext.FolderName, + CalendarId = errorContext.CalendarId, + CalendarName = errorContext.CalendarName, + ScopeName = GetScopeName(errorContext), + WasHandled = errorContext.WasHandled, + HandledBy = errorContext.HandledBy, + CanContinueSync = errorContext.CanContinueSync, + IsEntityNotFound = errorContext.IsEntityNotFound, + ExceptionType = errorContext.Exception?.GetType().Name + }; + } + + public static SynchronizationIssue FromException( + Exception exception, + string operationType = null, + SynchronizerErrorSeverity severity = SynchronizerErrorSeverity.Fatal, + SynchronizerErrorCategory category = SynchronizerErrorCategory.Unknown, + string scopeName = null) + { + if (exception == null) + return null; + + return new SynchronizationIssue + { + Message = exception.Message, + Severity = severity, + Category = category, + OperationType = operationType, + ScopeName = scopeName, + CanContinueSync = severity == SynchronizerErrorSeverity.Recoverable, + ExceptionType = exception.GetType().Name + }; + } + + public static SynchronizationIssue FromFolderResult(FolderSyncResult folderResult) + { + if (folderResult == null || folderResult.Success) + return null; + + return new SynchronizationIssue + { + Message = folderResult.ErrorMessage, + Severity = folderResult.ErrorSeverity ?? SynchronizerErrorSeverity.Fatal, + Category = folderResult.ErrorCategory ?? SynchronizerErrorCategory.Unknown, + OperationType = "FolderSync", + FolderId = folderResult.FolderId, + FolderName = folderResult.FolderName, + ScopeName = folderResult.FolderName + }; + } + + private static string GetScopeName(SynchronizerErrorContext errorContext) + { + if (!string.IsNullOrWhiteSpace(errorContext.CalendarName)) + return errorContext.CalendarName; + + if (!string.IsNullOrWhiteSpace(errorContext.FolderName)) + return errorContext.FolderName; + + return errorContext.Request switch + { + IFolderActionRequest folderRequest => folderRequest.Folder?.FolderName, + IMailActionRequest mailRequest => mailRequest.Item?.Subject, + ICalendarActionRequest calendarRequest => calendarRequest.Item?.Title, + _ => null + }; + } +} diff --git a/Wino.Core.Domain/Models/Synchronization/SynchronizerErrorContext.cs b/Wino.Core.Domain/Models/Synchronization/SynchronizerErrorContext.cs index aee886df..9ab30bd9 100644 --- a/Wino.Core.Domain/Models/Synchronization/SynchronizerErrorContext.cs +++ b/Wino.Core.Domain/Models/Synchronization/SynchronizerErrorContext.cs @@ -31,6 +31,11 @@ public class SynchronizerErrorContext /// public IRequestBundle RequestBundle { get; set; } + /// + /// Gets or sets the original request associated with the error when available. + /// + public IRequestBase Request { get; set; } + /// /// Gets or sets additional data associated with the error /// @@ -76,6 +81,16 @@ public class SynchronizerErrorContext /// public string FolderName { get; set; } + /// + /// Gets or sets the calendar ID associated with the error for calendar sync issue tracking. + /// + public Guid? CalendarId { get; set; } + + /// + /// Gets or sets the calendar name for display purposes. + /// + public string CalendarName { get; set; } + /// /// Gets or sets the type of operation that failed. /// Examples: "FolderSync", "MailSync", "RequestExecution", "Idle" @@ -89,6 +104,16 @@ public class SynchronizerErrorContext /// public bool IsEntityNotFound { get; set; } + /// + /// Gets or sets whether a synchronizer error handler processed this error. + /// + public bool WasHandled { get; set; } + + /// + /// Gets or sets the handler type that processed this error. + /// + public string HandledBy { get; set; } + /// /// Gets whether this error should be retried based on severity and retry count. /// diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 0d2d0b54..8f2fb7e3 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -989,6 +989,7 @@ "StoreRatingDialog_MessageFirstLine": "All feedbacks are appreciated and they will make much Wino better in the future. Would you like to rate Wino in Microsoft Store?", "StoreRatingDialog_MessageSecondLine": "Would you like to rate Wino Mail in Microsoft Store?", "StoreRatingDialog_Title": "Enjoying Wino?", + "SynchronizationIssueFormat_WithScope": "{0}: {1}", "SynchronizationFolderReport_Failed": "synchronization is failed", "SynchronizationFolderReport_Success": "up to date", "SystemFolderConfigDialog_ArchiveFolderDescription": "Archived messages will be moved to here.", diff --git a/Wino.Core.Tests/CalendarPageViewModelTests.cs b/Wino.Core.Tests/CalendarPageViewModelTests.cs index 021eb16e..43c11622 100644 --- a/Wino.Core.Tests/CalendarPageViewModelTests.cs +++ b/Wino.Core.Tests/CalendarPageViewModelTests.cs @@ -2,6 +2,7 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Globalization; using CommunityToolkit.Mvvm.Collections; +using CommunityToolkit.Mvvm.Messaging; using FluentAssertions; using Itenso.TimePeriod; using Moq; @@ -10,9 +11,12 @@ using Wino.Calendar.ViewModels.Data; using Wino.Calendar.ViewModels.Interfaces; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Calendar; +using Wino.Core.Domain.Models.Navigation; +using Wino.Messaging.Client.Calendar; using Xunit; namespace Wino.Core.Tests; @@ -155,19 +159,140 @@ public class CalendarPageViewModelTests calendarService.Verify(service => service.GetCalendarEventsAsync(It.Is(calendar => calendar.Id == hiddenCalendar.Id), It.IsAny()), Times.Never); } + [Fact] + public async Task CalendarItemAddedMessage_AddsVisibleItemWithoutReloadAndMarksBusy() + { + var settings = CreateSettings(); + var preferencesService = CreatePreferencesService(settings); + var calendarService = new Mock(); + + var account = CreateAccount(); + var calendar = CreateCalendar(account, "Calendar"); + var accountCalendarViewModel = new AccountCalendarViewModel(account, calendar); + var existingItem = CreateCalendarItem(calendar.Id, new DateTime(2026, 3, 20, 9, 0, 0), "Existing"); + + calendarService + .Setup(service => service.GetCalendarEventsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([existingItem]); + + var viewModel = CreateViewModel( + calendarService.Object, + preferencesService.Object, + new DateOnly(2026, 3, 20), + new FakeAccountCalendarStateService([accountCalendarViewModel])); + + viewModel.OnNavigatedTo(NavigationMode.New, null!); + + try + { + await viewModel.ApplyDisplayRequestAsync(new CalendarDisplayRequest(CalendarDisplayType.Day, new DateOnly(2026, 3, 20))); + + var optimisticItem = CreateCalendarItem(calendar.Id, new DateTime(2026, 3, 20, 10, 0, 0), "Optimistic"); + optimisticItem.AssignedCalendar = accountCalendarViewModel; + + WeakReferenceMessenger.Default.Send(new CalendarItemAdded(optimisticItem, EntityUpdateSource.ClientUpdated)); + + viewModel.CalendarItems.Should().HaveCount(2); + viewModel.CalendarItems.Should().Contain(item => item.Id == optimisticItem.Id && item.IsBusy); + calendarService.Verify(service => service.GetCalendarEventsAsync(It.IsAny(), It.IsAny()), Times.Once); + } + finally + { + viewModel.OnNavigatedFrom(NavigationMode.Back, null!); + } + } + + [Fact] + public async Task CalendarItemDeletedMessage_RemovesVisibleItemWithoutReload() + { + var settings = CreateSettings(); + var preferencesService = CreatePreferencesService(settings); + var calendarService = new Mock(); + + var account = CreateAccount(); + var calendar = CreateCalendar(account, "Calendar"); + var accountCalendarViewModel = new AccountCalendarViewModel(account, calendar); + var existingItem = CreateCalendarItem(calendar.Id, new DateTime(2026, 3, 20, 9, 0, 0), "Existing"); + + calendarService + .Setup(service => service.GetCalendarEventsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([existingItem]); + + var viewModel = CreateViewModel( + calendarService.Object, + preferencesService.Object, + new DateOnly(2026, 3, 20), + new FakeAccountCalendarStateService([accountCalendarViewModel])); + + viewModel.OnNavigatedTo(NavigationMode.New, null!); + + try + { + await viewModel.ApplyDisplayRequestAsync(new CalendarDisplayRequest(CalendarDisplayType.Day, new DateOnly(2026, 3, 20))); + + WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(existingItem, EntityUpdateSource.ClientUpdated)); + + viewModel.CalendarItems.Should().BeEmpty(); + calendarService.Verify(service => service.GetCalendarEventsAsync(It.IsAny(), It.IsAny()), Times.Once); + } + finally + { + viewModel.OnNavigatedFrom(NavigationMode.Back, null!); + } + } + + [Fact] + public async Task CalendarItemAddedMessage_ReconcilesTrackedLocalPreviewInPlace() + { + var settings = CreateSettings(); + var preferencesService = CreatePreferencesService(settings); + var calendarService = new Mock(); + + var account = CreateAccount(); + var calendar = CreateCalendar(account, "Calendar"); + var accountCalendarViewModel = new AccountCalendarViewModel(account, calendar); + var localPreview = CreateCalendarItem(calendar.Id, new DateTime(2026, 3, 20, 9, 0, 0), "Local preview"); + + calendarService + .Setup(service => service.GetCalendarEventsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([localPreview]); + + var viewModel = CreateViewModel( + calendarService.Object, + preferencesService.Object, + new DateOnly(2026, 3, 20), + new FakeAccountCalendarStateService([accountCalendarViewModel])); + + viewModel.OnNavigatedTo(NavigationMode.New, null!); + + try + { + await viewModel.ApplyDisplayRequestAsync(new CalendarDisplayRequest(CalendarDisplayType.Day, new DateOnly(2026, 3, 20))); + + var syncedItem = CreateCalendarItem(calendar.Id, localPreview.StartDate, "Synced"); + syncedItem.RemoteEventId = "remote-event-id".WithClientTrackingId(localPreview.Id); + syncedItem.AssignedCalendar = accountCalendarViewModel; + + WeakReferenceMessenger.Default.Send(new CalendarItemAdded(syncedItem, EntityUpdateSource.Server)); + + viewModel.CalendarItems.Should().ContainSingle(); + viewModel.CalendarItems[0].Id.Should().Be(syncedItem.Id); + viewModel.CalendarItems[0].Title.Should().Be("Synced"); + viewModel.CalendarItems[0].IsBusy.Should().BeFalse(); + viewModel.CalendarItems.Should().NotContain(item => item.Id == localPreview.Id); + } + finally + { + viewModel.OnNavigatedFrom(NavigationMode.Back, null!); + } + } + private static CalendarPageViewModel CreateViewModel( ICalendarService calendarService, IPreferencesService preferencesService, DateOnly today) { - var account = new MailAccount - { - Id = Guid.NewGuid(), - Name = "Primary", - SenderName = "Primary", - Address = "primary@example.com", - ProviderType = MailProviderType.Outlook - }; + var account = CreateAccount(); var calendar = CreateCalendar(account, "Calendar"); var accountCalendarViewModel = new AccountCalendarViewModel(account, calendar); @@ -217,6 +342,26 @@ public class CalendarPageViewModelTests IsSynchronizationEnabled = true }; + private static MailAccount CreateAccount() + => new() + { + Id = Guid.NewGuid(), + Name = "Primary", + SenderName = "Primary", + Address = "primary@example.com", + ProviderType = MailProviderType.Outlook + }; + + private static CalendarItem CreateCalendarItem(Guid calendarId, DateTime startDate, string title) + => new() + { + Id = Guid.NewGuid(), + CalendarId = calendarId, + StartDate = startDate, + DurationInSeconds = TimeSpan.FromMinutes(30).TotalSeconds, + Title = title + }; + private static Mock CreatePreferencesService(CalendarSettings settings) => CreatePreferencesService(() => settings); diff --git a/Wino.Core.Tests/Services/CreateCalendarEventRequestTests.cs b/Wino.Core.Tests/Services/CreateCalendarEventRequestTests.cs index 514adbd0..f1320d8b 100644 --- a/Wino.Core.Tests/Services/CreateCalendarEventRequestTests.cs +++ b/Wino.Core.Tests/Services/CreateCalendarEventRequestTests.cs @@ -32,8 +32,10 @@ public sealed class CreateCalendarEventRequestTests 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); + recipient.Added[0].CalendarItem.Id.Should().Be(request.LocalCalendarItemId!.Value); + recipient.Deleted[0].CalendarItem.Id.Should().Be(request.LocalCalendarItemId!.Value); + recipient.Added[0].Source.Should().Be(EntityUpdateSource.ClientUpdated); + recipient.Deleted[0].Source.Should().Be(EntityUpdateSource.ClientReverted); } finally { @@ -117,11 +119,11 @@ public sealed class CreateCalendarEventRequestTests IRecipient, IRecipient { - public List Added { get; } = []; - public List Deleted { get; } = []; + public List Added { get; } = []; + public List Deleted { get; } = []; - public void Receive(CalendarItemAdded message) => Added.Add(message.CalendarItem); + public void Receive(CalendarItemAdded message) => Added.Add(message); - public void Receive(CalendarItemDeleted message) => Deleted.Add(message.CalendarItem); + public void Receive(CalendarItemDeleted message) => Deleted.Add(message); } } diff --git a/Wino.Core.Tests/Services/MailRequestStateTests.cs b/Wino.Core.Tests/Services/MailRequestStateTests.cs index b06c4487..ae41b930 100644 --- a/Wino.Core.Tests/Services/MailRequestStateTests.cs +++ b/Wino.Core.Tests/Services/MailRequestStateTests.cs @@ -28,8 +28,8 @@ public sealed class MailRequestStateTests mailCopy.IsRead.Should().BeFalse(); recipient.Updated.Should().HaveCount(2); - recipient.Updated[0].Source.Should().Be(MailUpdateSource.ClientUpdated); - recipient.Updated[1].Source.Should().Be(MailUpdateSource.ClientReverted); + recipient.Updated[0].Source.Should().Be(EntityUpdateSource.ClientUpdated); + recipient.Updated[1].Source.Should().Be(EntityUpdateSource.ClientReverted); recipient.Updated[1].UpdatedMail.IsRead.Should().BeFalse(); } finally @@ -56,8 +56,8 @@ public sealed class MailRequestStateTests mailCopy.IsFlagged.Should().BeFalse(); recipient.Updated.Should().HaveCount(2); - recipient.Updated[0].Source.Should().Be(MailUpdateSource.ClientUpdated); - recipient.Updated[1].Source.Should().Be(MailUpdateSource.ClientReverted); + recipient.Updated[0].Source.Should().Be(EntityUpdateSource.ClientUpdated); + recipient.Updated[1].Source.Should().Be(EntityUpdateSource.ClientReverted); recipient.Updated[1].UpdatedMail.IsFlagged.Should().BeFalse(); } finally diff --git a/Wino.Core.Tests/Services/SynchronizationResultTests.cs b/Wino.Core.Tests/Services/SynchronizationResultTests.cs new file mode 100644 index 00000000..1d7c6238 --- /dev/null +++ b/Wino.Core.Tests/Services/SynchronizationResultTests.cs @@ -0,0 +1,80 @@ +using FluentAssertions; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Synchronization; +using Wino.Core.Services; +using Xunit; + +namespace Wino.Core.Tests.Services; + +public class SynchronizationResultTests +{ + [Fact] + public void Mail_result_merge_issues_should_mark_success_as_partial_and_set_exception() + { + var result = MailSynchronizationResult.Completed([]); + var issues = new[] + { + new SynchronizationIssue + { + Message = "Create event failed", + OperationType = "RequestExecution", + Severity = SynchronizerErrorSeverity.Fatal + } + }; + + result.MergeIssues(issues); + + result.CompletedState.Should().Be(SynchronizationCompletedState.PartiallyCompleted); + result.Issues.Should().ContainSingle(); + result.Exception.Should().NotBeNull(); + result.Exception!.Message.Should().Be("Create event failed"); + } + + [Fact] + public void Calendar_result_merge_issues_should_mark_success_as_partial_and_preserve_issue() + { + var result = CalendarSynchronizationResult.Empty; + var issues = new[] + { + new SynchronizationIssue + { + Message = "Calendar API rate limit", + OperationType = "CalendarSync", + Severity = SynchronizerErrorSeverity.Transient + } + }; + + result.MergeIssues(issues); + + result.CompletedState.Should().Be(SynchronizationCompletedState.PartiallyCompleted); + result.Issues.Should().ContainSingle(issue => issue.Message == "Calendar API rate limit"); + result.Exception.Should().NotBeNull(); + result.Exception!.Message.Should().Be("Calendar API rate limit"); + } + + [Fact] + public async Task Error_factory_should_record_handled_metadata_on_context() + { + var factory = new SynchronizerErrorHandlingFactory(); + factory.RegisterHandler(new TestErrorHandler()); + + var context = new SynchronizerErrorContext + { + ErrorMessage = "Handled sync error" + }; + + var handled = await factory.HandleErrorAsync(context); + + handled.Should().BeTrue(); + context.WasHandled.Should().BeTrue(); + context.HandledBy.Should().Be(nameof(TestErrorHandler)); + } + + private sealed class TestErrorHandler : ISynchronizerErrorHandler + { + public bool CanHandle(SynchronizerErrorContext error) => true; + + public Task HandleAsync(SynchronizerErrorContext error) => Task.FromResult(true); + } +} diff --git a/Wino.Core.Tests/Synchronizers/WinoSynchronizerCalendarRequestTests.cs b/Wino.Core.Tests/Synchronizers/WinoSynchronizerCalendarRequestTests.cs new file mode 100644 index 00000000..333fbad3 --- /dev/null +++ b/Wino.Core.Tests/Synchronizers/WinoSynchronizerCalendarRequestTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +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.Interfaces; +using Wino.Core.Domain.Models.MailItem; +using Wino.Core.Domain.Models.Synchronization; +using Wino.Core.Requests.Calendar; +using Wino.Core.Synchronizers; +using Wino.Messaging.UI; +using Xunit; + +namespace Wino.Core.Tests.Synchronizers; + +public sealed class WinoSynchronizerCalendarRequestTests +{ + [Fact] + public async Task Calendar_request_failure_should_complete_actions_and_reset_state() + { + var recipient = new SynchronizationActionsCompletedRecipient(); + WeakReferenceMessenger.Default.RegisterAll(recipient); + + try + { + var synchronizer = new TestCalendarSynchronizer(throwDuringRequestExecution: true); + var calendarItemId = Guid.NewGuid(); + + synchronizer.QueueRequest(new DeleteCalendarEventRequest(new CalendarItem { Id = calendarItemId })); + + var result = await synchronizer.SynchronizeCalendarEventsAsync(new CalendarSynchronizationOptions + { + AccountId = synchronizer.Account.Id, + Type = CalendarSynchronizationType.ExecuteRequests + }); + + result.CompletedState.Should().Be(SynchronizationCompletedState.Failed); + synchronizer.State.Should().Be(AccountSynchronizerState.Idle); + synchronizer.GetPendingCalendarOperationIds().Should().BeEmpty(); + recipient.CompletedAccountIds.Should().ContainSingle().Which.Should().Be(synchronizer.Account.Id); + } + finally + { + WeakReferenceMessenger.Default.UnregisterAll(recipient); + } + } + + [Fact] + public async Task Calendar_request_success_should_complete_actions_and_reset_state() + { + var recipient = new SynchronizationActionsCompletedRecipient(); + WeakReferenceMessenger.Default.RegisterAll(recipient); + + try + { + var synchronizer = new TestCalendarSynchronizer(throwDuringRequestExecution: false); + var calendarItemId = Guid.NewGuid(); + + synchronizer.QueueRequest(new DeleteCalendarEventRequest(new CalendarItem { Id = calendarItemId })); + + var result = await synchronizer.SynchronizeCalendarEventsAsync(new CalendarSynchronizationOptions + { + AccountId = synchronizer.Account.Id, + Type = CalendarSynchronizationType.ExecuteRequests + }); + + result.CompletedState.Should().Be(SynchronizationCompletedState.Success); + synchronizer.State.Should().Be(AccountSynchronizerState.Idle); + synchronizer.GetPendingCalendarOperationIds().Should().BeEmpty(); + recipient.CompletedAccountIds.Should().ContainSingle().Which.Should().Be(synchronizer.Account.Id); + } + finally + { + WeakReferenceMessenger.Default.UnregisterAll(recipient); + } + } + + public sealed class SynchronizationActionsCompletedRecipient : IRecipient + { + public List CompletedAccountIds { get; } = []; + + public void Receive(SynchronizationActionsCompleted message) => CompletedAccountIds.Add(message.AccountId); + } + + private sealed class TestCalendarSynchronizer : WinoSynchronizer + { + private readonly bool _throwDuringRequestExecution; + + public TestCalendarSynchronizer(bool throwDuringRequestExecution) + : base(new MailAccount { Id = Guid.NewGuid(), Name = "Test account" }, WeakReferenceMessenger.Default) + { + _throwDuringRequestExecution = throwDuringRequestExecution; + } + + public override uint BatchModificationSize => 1; + public override uint InitialMessageDownloadCountPerFolder => 0; + + public override Task ExecuteNativeRequestsAsync(List> batchedRequests, CancellationToken cancellationToken = default) + => _throwDuringRequestExecution + ? Task.FromException(new InvalidOperationException("Calendar request execution failed.")) + : Task.CompletedTask; + + public override List> DeleteCalendarEvent(DeleteCalendarEventRequest request) + => [new TestRequestBundle(new object(), request)]; + + public override Task> CreateNewMailPackagesAsync( + object message, + Wino.Core.Domain.Entities.Mail.MailItemFolder assignedFolder, + CancellationToken cancellationToken = default) + => Task.FromResult(new List()); + + protected override Task SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default) + => Task.FromResult(MailSynchronizationResult.Empty); + + protected override Task SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default) + => Task.FromResult(CalendarSynchronizationResult.Empty); + } + + private sealed class TestRequestBundle : IRequestBundle + { + public TestRequestBundle(object nativeRequest, IRequestBase request) + { + NativeRequest = nativeRequest; + Request = request; + } + + public string BundleId { get; set; } = Guid.NewGuid().ToString(); + public IUIChangeRequest UIChangeRequest => Request; + public object NativeRequest { get; } + public IRequestBase Request { get; } + } +} diff --git a/Wino.Core.ViewModels/CalendarBaseViewModel.cs b/Wino.Core.ViewModels/CalendarBaseViewModel.cs index a413b6ad..6180e1aa 100644 --- a/Wino.Core.ViewModels/CalendarBaseViewModel.cs +++ b/Wino.Core.ViewModels/CalendarBaseViewModel.cs @@ -11,13 +11,13 @@ public class CalendarBaseViewModel : CoreBaseViewModel, IRecipient, IRecipient { - public void Receive(CalendarItemAdded message) => DispatchToUIThread(() => OnCalendarItemAdded(message.CalendarItem)); + public void Receive(CalendarItemAdded message) => DispatchToUIThread(() => OnCalendarItemAdded(message.CalendarItem, message.Source)); public void Receive(CalendarItemUpdated message) => DispatchToUIThread(() => OnCalendarItemUpdated(message.CalendarItem, message.Source)); - public void Receive(CalendarItemDeleted message) => DispatchToUIThread(() => OnCalendarItemDeleted(message.CalendarItem)); + public void Receive(CalendarItemDeleted message) => DispatchToUIThread(() => OnCalendarItemDeleted(message.CalendarItem, message.Source)); - protected virtual void OnCalendarItemAdded(CalendarItem calendarItem) { } - protected virtual void OnCalendarItemUpdated(CalendarItem calendarItem, CalendarItemUpdateSource source) { } - protected virtual void OnCalendarItemDeleted(CalendarItem calendarItem) { } + protected virtual void OnCalendarItemAdded(CalendarItem calendarItem, EntityUpdateSource source) { } + protected virtual void OnCalendarItemUpdated(CalendarItem calendarItem, EntityUpdateSource source) { } + protected virtual void OnCalendarItemDeleted(CalendarItem calendarItem, EntityUpdateSource source) { } private void DispatchToUIThread(Action action) { diff --git a/Wino.Core/Requests/Calendar/AcceptEventRequest.cs b/Wino.Core/Requests/Calendar/AcceptEventRequest.cs index 8d6505e0..68a4497d 100644 --- a/Wino.Core/Requests/Calendar/AcceptEventRequest.cs +++ b/Wino.Core/Requests/Calendar/AcceptEventRequest.cs @@ -27,13 +27,13 @@ public record AcceptEventRequest(CalendarItem Item, string ResponseMessage = nul Item.Status = CalendarItemStatus.Accepted; // Notify UI that the event status was updated - WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientUpdated)); + WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, EntityUpdateSource.ClientUpdated)); } public override void RevertUIChanges() { // If acceptance fails, revert to the previous status Item.Status = _previousStatus; - WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientReverted)); + WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, EntityUpdateSource.ClientReverted)); } } diff --git a/Wino.Core/Requests/Calendar/CreateCalendarEventRequest.cs b/Wino.Core/Requests/Calendar/CreateCalendarEventRequest.cs index 1b1c0832..846454b8 100644 --- a/Wino.Core/Requests/Calendar/CreateCalendarEventRequest.cs +++ b/Wino.Core/Requests/Calendar/CreateCalendarEventRequest.cs @@ -47,7 +47,7 @@ public record CreateCalendarEventRequest : CalendarRequestBase if (Item == null) return; - WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item)); + WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item, EntityUpdateSource.ClientUpdated)); } public override void RevertUIChanges() @@ -55,7 +55,7 @@ public record CreateCalendarEventRequest : CalendarRequestBase if (Item == null) return; - WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(Item)); + WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(Item, EntityUpdateSource.ClientReverted)); } private static bool ShouldCreateOptimisticItem(CalendarEventComposeResult composeResult) diff --git a/Wino.Core/Requests/Calendar/DeclineEventRequest.cs b/Wino.Core/Requests/Calendar/DeclineEventRequest.cs index 31f89575..17545cf6 100644 --- a/Wino.Core/Requests/Calendar/DeclineEventRequest.cs +++ b/Wino.Core/Requests/Calendar/DeclineEventRequest.cs @@ -27,13 +27,13 @@ public record DeclineEventRequest(CalendarItem Item, string ResponseMessage = nu Item.Status = CalendarItemStatus.Cancelled; // Notify UI that the event status was updated - WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientUpdated)); + WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, EntityUpdateSource.ClientUpdated)); } public override void RevertUIChanges() { // If decline fails, revert to the previous status Item.Status = _previousStatus; - WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientReverted)); + WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, EntityUpdateSource.ClientReverted)); } } diff --git a/Wino.Core/Requests/Calendar/DeleteCalendarEventRequest.cs b/Wino.Core/Requests/Calendar/DeleteCalendarEventRequest.cs index a9ae4681..ae5f8f72 100644 --- a/Wino.Core/Requests/Calendar/DeleteCalendarEventRequest.cs +++ b/Wino.Core/Requests/Calendar/DeleteCalendarEventRequest.cs @@ -21,12 +21,12 @@ public record DeleteCalendarEventRequest(CalendarItem Item) : CalendarRequestBas public override void ApplyUIChanges() { // Notify UI that the event was deleted - WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(Item)); + WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(Item, EntityUpdateSource.ClientUpdated)); } public override void RevertUIChanges() { // If deletion fails, we should notify the UI to add it back - WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item)); + WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item, EntityUpdateSource.ClientReverted)); } } diff --git a/Wino.Core/Requests/Calendar/OutlookDeclineEventRequest.cs b/Wino.Core/Requests/Calendar/OutlookDeclineEventRequest.cs index c7e87147..009ec6af 100644 --- a/Wino.Core/Requests/Calendar/OutlookDeclineEventRequest.cs +++ b/Wino.Core/Requests/Calendar/OutlookDeclineEventRequest.cs @@ -26,13 +26,13 @@ public record OutlookDeclineEventRequest(CalendarItem Item, string ResponseMessa { // In Outlook, declined events are deleted from the calendar after sync // Send deleted message to remove from UI immediately - WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(Item)); + WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(Item, EntityUpdateSource.ClientUpdated)); } public override void RevertUIChanges() { // If decline fails, restore the previous status and re-add the event Item.Status = _previousStatus; - WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item)); + WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item, EntityUpdateSource.ClientReverted)); } } diff --git a/Wino.Core/Requests/Calendar/TentativeEventRequest.cs b/Wino.Core/Requests/Calendar/TentativeEventRequest.cs index d53c2eb7..7c59cfc8 100644 --- a/Wino.Core/Requests/Calendar/TentativeEventRequest.cs +++ b/Wino.Core/Requests/Calendar/TentativeEventRequest.cs @@ -27,13 +27,13 @@ public record TentativeEventRequest(CalendarItem Item, string ResponseMessage = Item.Status = CalendarItemStatus.Tentative; // Notify UI that the event status was updated - WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientUpdated)); + WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, EntityUpdateSource.ClientUpdated)); } public override void RevertUIChanges() { // If tentative acceptance fails, revert to the previous status Item.Status = _previousStatus; - WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientReverted)); + WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, EntityUpdateSource.ClientReverted)); } } diff --git a/Wino.Core/Requests/Calendar/UpdateCalendarEventRequest.cs b/Wino.Core/Requests/Calendar/UpdateCalendarEventRequest.cs index 8326a37a..6cfefbbc 100644 --- a/Wino.Core/Requests/Calendar/UpdateCalendarEventRequest.cs +++ b/Wino.Core/Requests/Calendar/UpdateCalendarEventRequest.cs @@ -33,7 +33,7 @@ public record UpdateCalendarEventRequest(CalendarItem Item, List MailsToDe { foreach (var item in MailsToDelete) { - WeakReferenceMessenger.Default.Send(new MailRemovedMessage(item)); + WeakReferenceMessenger.Default.Send(new MailRemovedMessage(item, EntityUpdateSource.ClientUpdated)); } } @@ -24,7 +24,7 @@ public record EmptyFolderRequest(MailItemFolder Folder, List MailsToDe { foreach (var item in MailsToDelete) { - WeakReferenceMessenger.Default.Send(new MailAddedMessage(item)); + WeakReferenceMessenger.Default.Send(new MailAddedMessage(item, EntityUpdateSource.ClientReverted)); } } diff --git a/Wino.Core/Requests/Folder/MarkFolderAsReadRequest.cs b/Wino.Core/Requests/Folder/MarkFolderAsReadRequest.cs index f1df31b5..172a1aa0 100644 --- a/Wino.Core/Requests/Folder/MarkFolderAsReadRequest.cs +++ b/Wino.Core/Requests/Folder/MarkFolderAsReadRequest.cs @@ -26,7 +26,7 @@ public record MarkFolderAsReadRequest(MailItemFolder Folder, List Mail item.IsRead = true; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, MailUpdateSource.ClientUpdated, MailCopyChangeFlags.IsRead)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, EntityUpdateSource.ClientUpdated, MailCopyChangeFlags.IsRead)); } } @@ -41,7 +41,7 @@ public record MarkFolderAsReadRequest(MailItemFolder Folder, List Mail item.IsRead = false; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, MailUpdateSource.ClientReverted, MailCopyChangeFlags.IsRead)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, EntityUpdateSource.ClientReverted, MailCopyChangeFlags.IsRead)); } } } diff --git a/Wino.Core/Requests/Mail/ArchiveRequest.cs b/Wino.Core/Requests/Mail/ArchiveRequest.cs index e73eecf8..1fd87e22 100644 --- a/Wino.Core/Requests/Mail/ArchiveRequest.cs +++ b/Wino.Core/Requests/Mail/ArchiveRequest.cs @@ -41,12 +41,12 @@ public record ArchiveRequest(bool IsArchiving, MailCopy Item, MailItemFolder Fro public override void ApplyUIChanges() { - WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item)); + WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item, EntityUpdateSource.ClientUpdated)); } public override void RevertUIChanges() { - WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item)); + WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item, EntityUpdateSource.ClientReverted)); } } diff --git a/Wino.Core/Requests/Mail/ChangeFlagRequest.cs b/Wino.Core/Requests/Mail/ChangeFlagRequest.cs index 231435f4..7ac9ac80 100644 --- a/Wino.Core/Requests/Mail/ChangeFlagRequest.cs +++ b/Wino.Core/Requests/Mail/ChangeFlagRequest.cs @@ -33,7 +33,7 @@ public record ChangeFlagRequest(MailCopy Item, bool IsFlagged) : MailRequestBase Item.IsFlagged = IsFlagged; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientUpdated, MailCopyChangeFlags.IsFlagged)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, EntityUpdateSource.ClientUpdated, MailCopyChangeFlags.IsFlagged)); } public override void RevertUIChanges() @@ -43,7 +43,7 @@ public record ChangeFlagRequest(MailCopy Item, bool IsFlagged) : MailRequestBase Item.IsFlagged = _originalIsFlagged; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientReverted, MailCopyChangeFlags.IsFlagged)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, EntityUpdateSource.ClientReverted, MailCopyChangeFlags.IsFlagged)); } } diff --git a/Wino.Core/Requests/Mail/DeleteRequest.cs b/Wino.Core/Requests/Mail/DeleteRequest.cs index b9bcf968..1aa67244 100644 --- a/Wino.Core/Requests/Mail/DeleteRequest.cs +++ b/Wino.Core/Requests/Mail/DeleteRequest.cs @@ -22,12 +22,12 @@ public record DeleteRequest(MailCopy MailItem) : MailRequestBase(MailItem), public override void ApplyUIChanges() { - WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item)); + WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item, EntityUpdateSource.ClientUpdated)); } public override void RevertUIChanges() { - WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item)); + WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item, EntityUpdateSource.ClientReverted)); } } diff --git a/Wino.Core/Requests/Mail/MarkReadRequest.cs b/Wino.Core/Requests/Mail/MarkReadRequest.cs index 28c4fd3b..a5ccc165 100644 --- a/Wino.Core/Requests/Mail/MarkReadRequest.cs +++ b/Wino.Core/Requests/Mail/MarkReadRequest.cs @@ -32,7 +32,7 @@ public record MarkReadRequest(MailCopy Item, bool IsRead) : MailRequestBase(Item Item.IsRead = IsRead; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientUpdated, MailCopyChangeFlags.IsRead)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, EntityUpdateSource.ClientUpdated, MailCopyChangeFlags.IsRead)); } public override void RevertUIChanges() @@ -42,7 +42,7 @@ public record MarkReadRequest(MailCopy Item, bool IsRead) : MailRequestBase(Item Item.IsRead = _originalIsRead; - WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientReverted, MailCopyChangeFlags.IsRead)); + WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, EntityUpdateSource.ClientReverted, MailCopyChangeFlags.IsRead)); } } diff --git a/Wino.Core/Requests/Mail/MoveRequest.cs b/Wino.Core/Requests/Mail/MoveRequest.cs index 25071e5b..824bc6c9 100644 --- a/Wino.Core/Requests/Mail/MoveRequest.cs +++ b/Wino.Core/Requests/Mail/MoveRequest.cs @@ -18,12 +18,12 @@ public record MoveRequest(MailCopy Item, MailItemFolder FromFolder, MailItemFold public override void ApplyUIChanges() { - WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item)); + WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item, EntityUpdateSource.ClientUpdated)); } public override void RevertUIChanges() { - WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item)); + WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item, EntityUpdateSource.ClientReverted)); } } diff --git a/Wino.Core/Requests/Mail/SendDraftRequest.cs b/Wino.Core/Requests/Mail/SendDraftRequest.cs index 61e43758..b5ff5598 100644 --- a/Wino.Core/Requests/Mail/SendDraftRequest.cs +++ b/Wino.Core/Requests/Mail/SendDraftRequest.cs @@ -34,11 +34,11 @@ public record SendDraftRequest(SendDraftPreparationRequest Request) public override void ApplyUIChanges() { - WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item)); + WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item, EntityUpdateSource.ClientUpdated)); } public override void RevertUIChanges() { - WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item)); + WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item, EntityUpdateSource.ClientReverted)); } } diff --git a/Wino.Core/Services/SynchronizationManager.cs b/Wino.Core/Services/SynchronizationManager.cs index 798431f5..3a2c3746 100644 --- a/Wino.Core/Services/SynchronizationManager.cs +++ b/Wino.Core/Services/SynchronizationManager.cs @@ -146,7 +146,10 @@ public class SynchronizationManager : ISynchronizationManager { _logger.Error("Could not find or create synchronizer for account {AccountId}", options.AccountId); - return MailSynchronizationResult.Failed(new Exception("Can't create/get synchronizer.")); + var exception = new InvalidOperationException("Can't create/get synchronizer."); + return MailSynchronizationResult + .Failed(exception) + .MergeIssues([SynchronizationIssue.FromException(exception, "MailSync")]); } _logger.Information("Starting mail synchronization for account {AccountId} with type {SyncType}", @@ -185,12 +188,16 @@ public class SynchronizationManager : ISynchronizationManager // Create app notification for authentication attention _notificationBuilder.CreateAttentionRequiredNotification(authEx.Account); - return MailSynchronizationResult.Failed(authEx); + return MailSynchronizationResult + .Failed(authEx) + .MergeIssues([SynchronizationIssue.FromException(authEx, "MailSync", SynchronizerErrorSeverity.AuthRequired)]); } catch (Exception ex) { _logger.Error(ex, "Mail synchronization failed for account {AccountId}", options.AccountId); - return MailSynchronizationResult.Failed(ex); + return MailSynchronizationResult + .Failed(ex) + .MergeIssues([SynchronizationIssue.FromException(ex, "MailSync")]); } } @@ -432,7 +439,10 @@ public class SynchronizationManager : ISynchronizationManager if (synchronizer == null) { _logger.Error("Could not find or create synchronizer for account {AccountId}", options.AccountId); - return CalendarSynchronizationResult.Failed; + var exception = new InvalidOperationException("Can't create/get synchronizer."); + return CalendarSynchronizationResult + .Failed(exception) + .MergeIssues([SynchronizationIssue.FromException(exception, "CalendarSync")]); } _logger.Information("Starting calendar synchronization for account {AccountId} with type {SyncType}", @@ -478,12 +488,16 @@ public class SynchronizationManager : ISynchronizationManager // Create app notification for authentication attention _notificationBuilder.CreateAttentionRequiredNotification(authEx.Account); - return CalendarSynchronizationResult.Failed; + return CalendarSynchronizationResult + .Failed(authEx) + .MergeIssues([SynchronizationIssue.FromException(authEx, "CalendarSync", SynchronizerErrorSeverity.AuthRequired)]); } catch (Exception ex) { _logger.Error(ex, "Calendar synchronization failed for account {AccountId}", options.AccountId); - return CalendarSynchronizationResult.Failed; + return CalendarSynchronizationResult + .Failed(ex) + .MergeIssues([SynchronizationIssue.FromException(ex, "CalendarSync")]); } finally { diff --git a/Wino.Core/Services/SynchronizerErrorHandlingFactory.cs b/Wino.Core/Services/SynchronizerErrorHandlingFactory.cs index 446688db..41019c6a 100644 --- a/Wino.Core/Services/SynchronizerErrorHandlingFactory.cs +++ b/Wino.Core/Services/SynchronizerErrorHandlingFactory.cs @@ -37,13 +37,20 @@ public class SynchronizerErrorHandlingFactory _logger.Debug("Found handler {HandlerType} for error code {ErrorCode} message {ErrorMessage}", handler.GetType().Name, error.ErrorCode, error.ErrorMessage); - return await handler.HandleAsync(error); + var handled = await handler.HandleAsync(error); + error.WasHandled = handled; + error.HandledBy = handled ? handler.GetType().Name : null; + + return handled; } } _logger.Debug("No handler found for error code {ErrorCode} message {ErrorMessage}", error.ErrorCode, error.ErrorMessage); + error.WasHandled = false; + error.HandledBy = null; + return false; } } diff --git a/Wino.Core/Synchronizers/BaseSynchronizer.cs b/Wino.Core/Synchronizers/BaseSynchronizer.cs index 1e0bf8ec..5f909044 100644 --- a/Wino.Core/Synchronizers/BaseSynchronizer.cs +++ b/Wino.Core/Synchronizers/BaseSynchronizer.cs @@ -11,6 +11,7 @@ using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; +using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Requests.Bundles; using Wino.Messaging.UI; @@ -24,6 +25,7 @@ public abstract partial class BaseSynchronizer : ObservableObject, protected List changeRequestQueue = []; private readonly ConcurrentDictionary _pendingMailOperationIds = new(); private readonly ConcurrentDictionary _pendingCalendarOperationIds = new(); + private readonly ConcurrentQueue _capturedSynchronizationIssues = new(); protected readonly IMessenger Messenger; public MailAccount Account { get; } @@ -135,6 +137,8 @@ public abstract partial class BaseSynchronizer : ObservableObject, public bool HasPendingCalendarOperation(Guid calendarItemId) => _pendingCalendarOperationIds.ContainsKey(calendarItemId); + public IReadOnlyCollection GetPendingCalendarOperationIds() => _pendingCalendarOperationIds.Keys.ToArray(); + protected void TrackQueuedRequest(IRequestBase request) { if (request is IMailActionRequest mailActionRequest) @@ -173,6 +177,27 @@ public abstract partial class BaseSynchronizer : ObservableObject, UntrackProcessedRequest(request); } + protected void ResetCapturedSynchronizationIssues() + { + while (_capturedSynchronizationIssues.TryDequeue(out _)) + { + } + } + + protected void CaptureSynchronizationIssue(SynchronizationIssue issue) + { + if (issue == null || string.IsNullOrWhiteSpace(issue.Message)) + return; + + _capturedSynchronizationIssues.Enqueue(issue); + } + + protected void CaptureSynchronizationIssue(SynchronizerErrorContext errorContext) + => CaptureSynchronizationIssue(SynchronizationIssue.FromErrorContext(errorContext)); + + protected IReadOnlyList GetCapturedSynchronizationIssues() + => _capturedSynchronizationIssues.ToArray(); + /// /// Runs existing queued requests in the queue. /// diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 5910e8f2..dddda1dd 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -522,74 +522,110 @@ public class GmailSynchronizer : WinoSynchronizer c.IsSynchronizationEnabled) .ToList(); - // TODO: Better logging and exception handling. foreach (var calendar in localCalendars) { - var request = _calendarService.Events.List(calendar.RemoteCalendarId); - - // Fetch individual event instances (including recurring event occurrences) - // rather than recurring event masters. This ensures we get all occurrences - // as separate events that can be stored and displayed directly. - request.SingleEvents = true; - request.ShowDeleted = true; - - if (!string.IsNullOrEmpty(calendar.SynchronizationDeltaToken)) + try { - // If a sync token is available, perform an incremental sync - request.SyncToken = calendar.SynchronizationDeltaToken; - } - else - { - // If no sync token, perform an initial sync - // Fetch events from the past year + var request = _calendarService.Events.List(calendar.RemoteCalendarId); - request.TimeMinDateTimeOffset = DateTimeOffset.UtcNow.AddYears(-1); - } + // Fetch individual event instances (including recurring event occurrences) + // rather than recurring event masters. This ensures we get all occurrences + // as separate events that can be stored and displayed directly. + request.SingleEvents = true; + request.ShowDeleted = true; - string nextPageToken; - string syncToken; - - var allEvents = new List(); - - do - { - // Execute the request - var events = await request.ExecuteAsync(); - - // Process the fetched events - if (events.Items != null) + if (!string.IsNullOrEmpty(calendar.SynchronizationDeltaToken)) { - allEvents.AddRange(events.Items); + request.SyncToken = calendar.SynchronizationDeltaToken; + } + else + { + request.TimeMinDateTimeOffset = DateTimeOffset.UtcNow.AddYears(-1); } - // Get the next page token and sync token - nextPageToken = events.NextPageToken; - syncToken = events.NextSyncToken; + string nextPageToken; + string syncToken; - // Set the next page token for subsequent requests - request.PageToken = nextPageToken; + var allEvents = new List(); - } while (!string.IsNullOrEmpty(nextPageToken)); + do + { + var events = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false); - calendar.SynchronizationDeltaToken = syncToken; + if (events.Items != null) + { + allEvents.AddRange(events.Items); + } - // allEvents contains new or updated events. - // Process them and create/update local calendar items. + nextPageToken = events.NextPageToken; + syncToken = events.NextSyncToken; + request.PageToken = nextPageToken; + } + while (!string.IsNullOrEmpty(nextPageToken)); - var eventByRemoteId = allEvents - .Where(e => !string.IsNullOrWhiteSpace(e.Id)) - .GroupBy(e => e.Id, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + calendar.SynchronizationDeltaToken = syncToken; - foreach (var @event in OrderCalendarEventsForPersistence(allEvents)) - { - cancellationToken.ThrowIfCancellationRequested(); + var eventByRemoteId = allEvents + .Where(e => !string.IsNullOrWhiteSpace(e.Id)) + .GroupBy(e => e.Id, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); - await EnsureRecurringParentProcessedAsync(calendar, @event, eventByRemoteId, cancellationToken).ConfigureAwait(false); - await _gmailChangeProcessor.ManageCalendarEventAsync(@event, calendar, Account).ConfigureAwait(false); + foreach (var @event in OrderCalendarEventsForPersistence(allEvents)) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await EnsureRecurringParentProcessedAsync(calendar, @event, eventByRemoteId, cancellationToken).ConfigureAwait(false); + await _gmailChangeProcessor.ManageCalendarEventAsync(@event, calendar, Account).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + var errorContext = new SynchronizerErrorContext + { + Account = Account, + ErrorMessage = ex.Message, + Exception = ex, + CalendarId = calendar.Id, + CalendarName = calendar.Name, + OperationType = "CalendarEventSync", + Severity = SynchronizerErrorSeverity.Recoverable + }; + + _ = await _gmailSynchronizerErrorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false); + CaptureSynchronizationIssue(errorContext); + _logger.Error(ex, "Failed to process Gmail event {EventId} for calendar {CalendarName}", @event.Id, calendar.Name); + } + } + + await _gmailChangeProcessor.UpdateAccountCalendarAsync(calendar).ConfigureAwait(false); } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + var errorContext = new SynchronizerErrorContext + { + Account = Account, + ErrorMessage = ex.Message, + Exception = ex, + CalendarId = calendar.Id, + CalendarName = calendar.Name, + OperationType = "CalendarSync" + }; - await _gmailChangeProcessor.UpdateAccountCalendarAsync(calendar).ConfigureAwait(false); + _ = await _gmailSynchronizerErrorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false); + CaptureSynchronizationIssue(errorContext); + + if (!errorContext.CanContinueSync) + throw; + } } return CalendarSynchronizationResult.Empty; @@ -1674,6 +1710,7 @@ public class GmailSynchronizer : WinoSynchronizer { @@ -1697,6 +1734,8 @@ public class GmailSynchronizer : WinoSynchronizer { @@ -1876,6 +1877,7 @@ public class OutlookSynchronizer : WinoSynchronizer + if (isInitialSync) { - requestConfiguration.QueryParameters.Select = ["id", "type"]; - requestConfiguration.QueryParameters.StartDateTime = startDate; - requestConfiguration.QueryParameters.EndDateTime = endDate; - }, cancellationToken: cancellationToken); - } - else - { - var currentDeltaToken = calendar.SynchronizationDeltaToken; + _logger.Information("No calendar sync identifier for calendar {Name}. Performing initial sync.", calendar.Name); - _logger.Information("Performing delta sync for calendar {Name}.", calendar.Name); + // ISO 8601 format as expected by Microsoft Graph API (e.g., "2019-11-08T19:00:00-08:00") + var startDate = DateTimeOffset.Now.AddYears(-2).ToString("yyyy-MM-ddTHH:mm:sszzz"); + var endDate = DateTimeOffset.Now.AddYears(2).ToString("yyyy-MM-ddTHH:mm:sszzz"); - var requestInformation = _graphClient.Me.Calendars[calendar.RemoteCalendarId].CalendarView.Delta.ToGetRequestInformation(); - - requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken"); - requestInformation.QueryParameters.Add("%24deltatoken", currentDeltaToken); - - eventsDeltaResponse = await _graphClient.RequestAdapter.SendAsync(requestInformation, Microsoft.Graph.Me.Calendars.Item.CalendarView.Delta.DeltaGetResponse.CreateFromDiscriminatorValue); - } - - List events = new(); - - // We must first save the parent recurring events to not lose exceptions. - // Therefore, order the existing items by their type and save the parent recurring events first. - - var messageIteratorAsync = PageIterator.CreatePageIterator(_graphClient, eventsDeltaResponse, (item) => - { - // Include all event types: SingleInstance, SeriesMaster, Occurrence, and Exception - // CalendarView already expands recurring events into individual occurrences - events.Add(item); - - return true; - }); - - await messageIteratorAsync - .IterateAsync(cancellationToken) - .ConfigureAwait(false); - - // Desc-order will move parent recurring events to the top. - events = events.OrderByDescending(a => a.Type).ToList(); - - _logger.Information("Found {Count} events in total.", events.Count); - - foreach (var item in events) - { - // Declined events are returned as Deleted from the API. - // There is no way to distinguish unfortunately atm. - - if (IsResourceDeleted(item.AdditionalData)) - { - await _outlookChangeProcessor.DeleteCalendarItemAsync(item.Id, calendar.Id).ConfigureAwait(false); + // Get Id only. We will always download the full event. + eventsDeltaResponse = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].CalendarView.Delta.GetAsDeltaGetResponseAsync((requestConfiguration) => + { + requestConfiguration.QueryParameters.Select = ["id", "type"]; + requestConfiguration.QueryParameters.StartDateTime = startDate; + requestConfiguration.QueryParameters.EndDateTime = endDate; + }, cancellationToken: cancellationToken); } else { + var currentDeltaToken = calendar.SynchronizationDeltaToken; + + _logger.Information("Performing delta sync for calendar {Name}.", calendar.Name); + + var requestInformation = _graphClient.Me.Calendars[calendar.RemoteCalendarId].CalendarView.Delta.ToGetRequestInformation(); + + requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken"); + requestInformation.QueryParameters.Add("%24deltatoken", currentDeltaToken); + + eventsDeltaResponse = await _graphClient.RequestAdapter.SendAsync(requestInformation, Microsoft.Graph.Me.Calendars.Item.CalendarView.Delta.DeltaGetResponse.CreateFromDiscriminatorValue); + } + + List events = new(); + + // We must first save the parent recurring events to not lose exceptions. + // Therefore, order the existing items by their type and save the parent recurring events first. + + var messageIteratorAsync = PageIterator.CreatePageIterator(_graphClient, eventsDeltaResponse, (item) => + { + // Include all event types: SingleInstance, SeriesMaster, Occurrence, and Exception + // CalendarView already expands recurring events into individual occurrences + events.Add(item); + + return true; + }); + + await messageIteratorAsync + .IterateAsync(cancellationToken) + .ConfigureAwait(false); + + // Desc-order will move parent recurring events to the top. + events = events.OrderByDescending(a => a.Type).ToList(); + + _logger.Information("Found {Count} events in total.", events.Count); + + foreach (var item in events) + { + // Declined events are returned as Deleted from the API. + // There is no way to distinguish unfortunately atm. + + if (IsResourceDeleted(item.AdditionalData)) + { + await _outlookChangeProcessor.DeleteCalendarItemAsync(item.Id, calendar.Id).ConfigureAwait(false); + continue; + } + try { - await _handleCalendarEventRetrievalSemaphore.WaitAsync(); + await _handleCalendarEventRetrievalSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); Event fullEvent = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[item.Id] .GetAsync(requestConfiguration => @@ -2344,8 +2348,25 @@ public class OutlookSynchronizer : WinoSynchronizerSynchronization result that contains summary of the sync. public async Task SynchronizeMailsAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default) { + ResetCapturedSynchronizationIssues(); + List requestCopies = null; + try { if (!ShouldQueueMailSynchronization(options)) { Log.Debug($"{options.Type} synchronization is ignored."); - return MailSynchronizationResult.Canceled; + return FinalizeMailResult(MailSynchronizationResult.Canceled); } var newCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -147,7 +151,7 @@ public abstract class WinoSynchronizer> nativeRequests = new(); - List requestCopies = new(changeRequestQueue); + requestCopies = new(changeRequestQueue); var keys = changeRequestQueue.GroupBy(a => a.GroupingKey()); @@ -226,10 +230,9 @@ public abstract class WinoSynchronizerSynchronization result that contains summary of the sync. public async Task SynchronizeCalendarEventsAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default) { - bool shouldExecuteRequests = changeRequestQueue.Any(r => r is ICalendarActionRequest); - bool shouldDelayExecution = false; - int maxExecutionDelay = 0; + ResetCapturedSynchronizationIssues(); + List requestCopies = null; + var calendarRequestsWereExecuting = false; - if (shouldExecuteRequests) + try { - State = AccountSynchronizerState.ExecutingRequests; + bool shouldExecuteRequests = changeRequestQueue.Any(r => r is ICalendarActionRequest); + bool shouldDelayExecution = false; + int maxExecutionDelay = 0; - List> nativeRequests = new(); - List requestCopies = new(changeRequestQueue.Where(r => r is ICalendarActionRequest)); - - var keys = requestCopies.GroupBy(a => a.GroupingKey()); - - foreach (var group in keys) + if (shouldExecuteRequests) { - var key = group.Key; + calendarRequestsWereExecuting = true; + State = AccountSynchronizerState.ExecutingRequests; - if (key is CalendarSynchronizerOperation calendarSynchronizerOperation) + List> nativeRequests = new(); + requestCopies = new(changeRequestQueue.Where(r => r is ICalendarActionRequest)); + + var keys = requestCopies.GroupBy(a => a.GroupingKey()); + + foreach (var group in keys) { - switch (calendarSynchronizerOperation) + var key = group.Key; + + if (key is CalendarSynchronizerOperation calendarSynchronizerOperation) { - case CalendarSynchronizerOperation.CreateEvent: - nativeRequests.AddRange(group - .OfType() - .SelectMany(CreateCalendarEvent)); - break; - case CalendarSynchronizerOperation.AcceptEvent: - nativeRequests.AddRange(group - .OfType() - .SelectMany(AcceptEvent)); - break; - case CalendarSynchronizerOperation.DeclineEvent: - if (Account.ProviderType == MailProviderType.Outlook) - { + switch (calendarSynchronizerOperation) + { + case CalendarSynchronizerOperation.CreateEvent: nativeRequests.AddRange(group - .OfType() - .SelectMany(OutlookDeclineEvent)); - } - else - { + .OfType() + .SelectMany(CreateCalendarEvent)); + break; + case CalendarSynchronizerOperation.AcceptEvent: nativeRequests.AddRange(group - .OfType() - .SelectMany(DeclineEvent)); - } - break; - case CalendarSynchronizerOperation.TentativeEvent: - nativeRequests.AddRange(group - .OfType() - .SelectMany(TentativeEvent)); - break; - case CalendarSynchronizerOperation.UpdateEvent: - nativeRequests.AddRange(group - .OfType() - .SelectMany(UpdateCalendarEvent)); - break; - case CalendarSynchronizerOperation.DeleteEvent: - nativeRequests.AddRange(group - .OfType() - .SelectMany(DeleteCalendarEvent)); - break; - default: - break; + .OfType() + .SelectMany(AcceptEvent)); + break; + case CalendarSynchronizerOperation.DeclineEvent: + if (Account.ProviderType == MailProviderType.Outlook) + { + nativeRequests.AddRange(group + .OfType() + .SelectMany(OutlookDeclineEvent)); + } + else + { + nativeRequests.AddRange(group + .OfType() + .SelectMany(DeclineEvent)); + } + break; + case CalendarSynchronizerOperation.TentativeEvent: + nativeRequests.AddRange(group + .OfType() + .SelectMany(TentativeEvent)); + break; + case CalendarSynchronizerOperation.UpdateEvent: + nativeRequests.AddRange(group + .OfType() + .SelectMany(UpdateCalendarEvent)); + break; + case CalendarSynchronizerOperation.DeleteEvent: + nativeRequests.AddRange(group + .OfType() + .SelectMany(DeleteCalendarEvent)); + break; + default: + break; + } } } + + // Remove processed calendar requests from queue + changeRequestQueue.RemoveAll(r => r is ICalendarActionRequest); + + Console.WriteLine($"Prepared {nativeRequests.Count()} native calendar requests"); + + try + { + await ExecuteNativeRequestsAsync(nativeRequests, cancellationToken).ConfigureAwait(false); + } + finally + { + UntrackProcessedRequests(requestCopies); + Messenger.Send(new SynchronizationActionsCompleted(Account.Id)); + } + + // Let servers to finish their job. Sometimes the servers don't respond immediately. + shouldDelayExecution = requestCopies.Any(a => a.ResynchronizationDelay > 0); + + if (shouldDelayExecution) + { + maxExecutionDelay = requestCopies.Aggregate(0, (max, next) => Math.Max(max, next.ResynchronizationDelay)); + } } - // Remove processed calendar requests from queue - changeRequestQueue.RemoveAll(r => r is ICalendarActionRequest); - - Console.WriteLine($"Prepared {nativeRequests.Count()} native calendar requests"); - - try - { - await ExecuteNativeRequestsAsync(nativeRequests, cancellationToken).ConfigureAwait(false); - } - finally - { - UntrackProcessedRequests(requestCopies); - } - - Messenger.Send(new SynchronizationActionsCompleted(Account.Id)); - - // Let servers to finish their job. Sometimes the servers don't respond immediately. - shouldDelayExecution = requestCopies.Any(a => a.ResynchronizationDelay > 0); - if (shouldDelayExecution) { - maxExecutionDelay = requestCopies.Aggregate(0, (max, next) => Math.Max(max, next.ResynchronizationDelay)); + await Task.Delay(maxExecutionDelay, cancellationToken); + } + + var synchronizationResult = await SynchronizeCalendarEventsInternalAsync(options, cancellationToken); + return FinalizeCalendarResult(synchronizationResult); + } + catch (OperationCanceledException) + { + return FinalizeCalendarResult(CalendarSynchronizationResult.Canceled); + } + catch (AuthenticationAttentionException) + { + throw; + } + catch (Exception ex) + { + CaptureSynchronizationIssue(SynchronizationIssue.FromException(ex, "CalendarSync")); + return FinalizeCalendarResult(CalendarSynchronizationResult.Failed(ex)); + } + finally + { + if (calendarRequestsWereExecuting && State == AccountSynchronizerState.ExecutingRequests) + { + ResetSyncProgress(); + State = AccountSynchronizerState.Idle; } } - - if (shouldDelayExecution) - { - await Task.Delay(maxExecutionDelay, cancellationToken); - } - - // Execute the actual synchronization - return await SynchronizeCalendarEventsInternalAsync(options, cancellationToken); } /// @@ -626,4 +671,10 @@ public abstract class WinoSynchronizer (result ?? MailSynchronizationResult.Empty).MergeIssues(GetCapturedSynchronizationIssues()); + + private CalendarSynchronizationResult FinalizeCalendarResult(CalendarSynchronizationResult result) + => (result ?? CalendarSynchronizationResult.Empty).MergeIssues(GetCapturedSynchronizationIssues()); } diff --git a/Wino.Mail.ViewModels.Tests/Collections/WinoMailCollectionTests.cs b/Wino.Mail.ViewModels.Tests/Collections/WinoMailCollectionTests.cs index cdad8808..d52274d4 100644 --- a/Wino.Mail.ViewModels.Tests/Collections/WinoMailCollectionTests.cs +++ b/Wino.Mail.ViewModels.Tests/Collections/WinoMailCollectionTests.cs @@ -181,7 +181,7 @@ public class WinoMailCollectionTests var updatedSecond = CloneMailCopy(second); updatedSecond.ThreadId = "shared-thread"; - await sut.UpdateMailCopy(updatedSecond, MailUpdateSource.Server, MailCopyChangeFlags.ThreadId); + await sut.UpdateMailCopy(updatedSecond, EntityUpdateSource.Server, MailCopyChangeFlags.ThreadId); var items = FlattenItems(sut); var threadItem = items.Should().ContainSingle().Which.Should().BeOfType().Subject; @@ -201,7 +201,7 @@ public class WinoMailCollectionTests var updatedExisting = CloneMailCopy(existing); updatedExisting.ThreadId = "shared-thread"; - await sut.UpdateMailCopy(updatedExisting, MailUpdateSource.Server, MailCopyChangeFlags.ThreadId); + await sut.UpdateMailCopy(updatedExisting, EntityUpdateSource.Server, MailCopyChangeFlags.ThreadId); await sut.AddAsync(incoming); var items = FlattenItems(sut); diff --git a/Wino.Mail.ViewModels.Tests/Data/MailItemViewModelUpdateTests.cs b/Wino.Mail.ViewModels.Tests/Data/MailItemViewModelUpdateTests.cs index a674ba1f..94a4bedf 100644 --- a/Wino.Mail.ViewModels.Tests/Data/MailItemViewModelUpdateTests.cs +++ b/Wino.Mail.ViewModels.Tests/Data/MailItemViewModelUpdateTests.cs @@ -102,7 +102,7 @@ public class MailItemViewModelUpdateTests latest.IsRead = true; - await collection.UpdateMailCopy(latest, MailUpdateSource.ClientUpdated, MailCopyChangeFlags.IsRead); + await collection.UpdateMailCopy(latest, EntityUpdateSource.ClientUpdated, MailCopyChangeFlags.IsRead); raisedProperties.Should().Equal(nameof(ThreadMailItemViewModel.IsRead)); } diff --git a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs index f080a80e..7eb3c790 100644 --- a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs +++ b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs @@ -530,7 +530,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient /// Updated mail copy. /// - public Task UpdateMailCopy(MailCopy updatedMailCopy, MailUpdateSource mailUpdateSource, MailCopyChangeFlags changedProperties = MailCopyChangeFlags.None) + public Task UpdateMailCopy(MailCopy updatedMailCopy, EntityUpdateSource mailUpdateSource, MailCopyChangeFlags changedProperties = MailCopyChangeFlags.None) => RunSerializedAsync(() => { var itemContainer = GetMailItemContainer(updatedMailCopy.UniqueId); diff --git a/Wino.Mail.ViewModels/ComposePageViewModel.cs b/Wino.Mail.ViewModels/ComposePageViewModel.cs index bf6c23b6..24a434f3 100644 --- a/Wino.Mail.ViewModels/ComposePageViewModel.cs +++ b/Wino.Mail.ViewModels/ComposePageViewModel.cs @@ -874,7 +874,7 @@ public partial class ComposePageViewModel : MailBaseViewModel, _dialogService.InfoBarMessage(Translator.Info_InvalidAddressTitle, string.Format(Translator.Info_InvalidAddressMessage, address), InfoBarMessageType.Warning); } - protected override async void OnMailUpdated(MailCopy updatedMail, MailUpdateSource source, MailCopyChangeFlags changedProperties) + protected override async void OnMailUpdated(MailCopy updatedMail, EntityUpdateSource source, MailCopyChangeFlags changedProperties) { base.OnMailUpdated(updatedMail, source, changedProperties); diff --git a/Wino.Mail.ViewModels/MailBaseViewModel.cs b/Wino.Mail.ViewModels/MailBaseViewModel.cs index 07be737f..449ee42a 100644 --- a/Wino.Mail.ViewModels/MailBaseViewModel.cs +++ b/Wino.Mail.ViewModels/MailBaseViewModel.cs @@ -20,9 +20,9 @@ public class MailBaseViewModel : CoreBaseViewModel, IRecipient, IRecipient { - protected virtual void OnMailAdded(MailCopy addedMail) { } - protected virtual void OnMailRemoved(MailCopy removedMail) { } - protected virtual void OnMailUpdated(MailCopy updatedMail, MailUpdateSource source, MailCopyChangeFlags changedProperties) { } + protected virtual void OnMailAdded(MailCopy addedMail, EntityUpdateSource source) { } + protected virtual void OnMailRemoved(MailCopy removedMail, EntityUpdateSource source) { } + protected virtual void OnMailUpdated(MailCopy updatedMail, EntityUpdateSource source, MailCopyChangeFlags changedProperties) { } protected virtual void OnMailDownloaded(MailCopy downloadedMail) { } protected virtual void OnDraftCreated(MailCopy draftMail, MailAccount account) { } protected virtual void OnDraftFailed(MailCopy draftMail, MailAccount account) { } @@ -31,8 +31,8 @@ public class MailBaseViewModel : CoreBaseViewModel, protected virtual void OnFolderDeleted(MailItemFolder folder) { } protected virtual void OnFolderSynchronizationEnabled(IMailItemFolder mailItemFolder) { } - void IRecipient.Receive(MailAddedMessage message) => OnMailAdded(message.AddedMail); - void IRecipient.Receive(MailRemovedMessage message) => OnMailRemoved(message.RemovedMail); + void IRecipient.Receive(MailAddedMessage message) => OnMailAdded(message.AddedMail, message.Source); + void IRecipient.Receive(MailRemovedMessage message) => OnMailRemoved(message.RemovedMail, message.Source); void IRecipient.Receive(MailUpdatedMessage message) => OnMailUpdated(message.UpdatedMail, message.Source, message.ChangedProperties); void IRecipient.Receive(MailDownloadedMessage message) => OnMailDownloaded(message.DownloadedMail); diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index ab274ff0..4c7f13dd 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -745,7 +745,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, var fi = MailCollection.GetFirst(); if (fi == null) return; - Messenger.Send(new MailRemovedMessage(fi.MailCopy)); + Messenger.Send(new MailRemovedMessage(fi.MailCopy, EntityUpdateSource.Server)); } /// @@ -758,9 +758,9 @@ public partial class MailListPageViewModel : MailBaseViewModel, return MailCollection.ContainsThreadId(mailItem.ThreadId); } - protected override async void OnMailAdded(MailCopy addedMail) + protected override async void OnMailAdded(MailCopy addedMail, EntityUpdateSource source) { - base.OnMailAdded(addedMail); + base.OnMailAdded(addedMail, source); if (addedMail.AssignedAccount == null || addedMail.AssignedFolder == null) return; @@ -847,6 +847,15 @@ public partial class MailListPageViewModel : MailBaseViewModel, // AddAsync already handles UI threading internally, no need to wrap it await MailCollection.AddAsync(addedMail); + if (source == EntityUpdateSource.ClientUpdated) + { + var addedContainer = MailCollection.GetMailItemContainer(addedMail.UniqueId); + if (addedContainer?.ItemViewModel != null) + { + addedContainer.ItemViewModel.IsBusy = true; + } + } + await ExecuteUIThread(() => { NotifyItemFoundState(); @@ -862,7 +871,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, } } - protected override async void OnMailUpdated(MailCopy updatedMail, MailUpdateSource source, MailCopyChangeFlags changedProperties) + protected override async void OnMailUpdated(MailCopy updatedMail, EntityUpdateSource source, MailCopyChangeFlags changedProperties) { base.OnMailUpdated(updatedMail, source, changedProperties); @@ -890,9 +899,9 @@ public partial class MailListPageViewModel : MailBaseViewModel, await ExecuteUIThread(() => { SetupTopBarActions(); }); } - protected override async void OnMailRemoved(MailCopy removedMail) + protected override async void OnMailRemoved(MailCopy removedMail, EntityUpdateSource source) { - base.OnMailRemoved(removedMail); + base.OnMailRemoved(removedMail, source); if (removedMail.AssignedAccount == null) return; diff --git a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs index 0385dff6..1d9373d9 100644 --- a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs @@ -639,7 +639,7 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel, MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MarkAsRead, true, false)); } - protected override async void OnMailUpdated(MailCopy updatedMail, MailUpdateSource source, MailCopyChangeFlags changedProperties) + protected override async void OnMailUpdated(MailCopy updatedMail, EntityUpdateSource source, MailCopyChangeFlags changedProperties) { base.OnMailUpdated(updatedMail, source, changedProperties); diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index 35ed1f86..d470efb3 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -910,7 +910,7 @@ public partial class App : WinoApplication, syncResult.CompletedState == SynchronizationCompletedState.PartiallyCompleted) { var dialogService = Services.GetRequiredService(); - var errorMessage = GetSynchronizationFailureMessage(message.Options.Type, syncResult.Exception?.Message); + var errorMessage = GetSynchronizationFailureMessage(message.Options.Type, syncResult.AllIssues, syncResult.Exception?.Message); var severity = syncResult.CompletedState == SynchronizationCompletedState.PartiallyCompleted ? InfoBarMessageType.Warning : InfoBarMessageType.Error; @@ -925,18 +925,15 @@ public partial class App : WinoApplication, var calendarSyncResult = await _synchronizationManager.SynchronizeCalendarAsync(message.Options); - if (calendarSyncResult.CompletedState == SynchronizationCompletedState.Failed) + if (calendarSyncResult.CompletedState is SynchronizationCompletedState.Failed or SynchronizationCompletedState.PartiallyCompleted) { var dialogService = Services.GetRequiredService(); dialogService.InfoBarMessage( Translator.Info_SyncFailedTitle, - message.Options.Type switch - { - CalendarSynchronizationType.CalendarMetadata => Translator.Exception_FailedToSynchronizeCalendarMetadata, - CalendarSynchronizationType.Strict => Translator.Exception_FailedToSynchronizeCalendarData, - _ => Translator.Exception_FailedToSynchronizeCalendarEvents - }, - InfoBarMessageType.Error); + GetCalendarSynchronizationFailureMessage(message.Options.Type, calendarSyncResult.AllIssues, calendarSyncResult.Exception?.Message), + calendarSyncResult.CompletedState == SynchronizationCompletedState.PartiallyCompleted + ? InfoBarMessageType.Warning + : InfoBarMessageType.Error); } } @@ -1052,8 +1049,17 @@ public partial class App : WinoApplication, }); } - private static string GetSynchronizationFailureMessage(MailSynchronizationType synchronizationType, string? exceptionMessage) + private static string GetSynchronizationFailureMessage( + MailSynchronizationType synchronizationType, + IEnumerable issues, + string? exceptionMessage) { + var issueMessage = FormatSynchronizationIssues(issues); + if (!string.IsNullOrWhiteSpace(issueMessage)) + { + return issueMessage; + } + if (!string.IsNullOrWhiteSpace(exceptionMessage)) { return exceptionMessage; @@ -1067,6 +1073,49 @@ public partial class App : WinoApplication, }; } + private static string GetCalendarSynchronizationFailureMessage( + CalendarSynchronizationType synchronizationType, + IEnumerable issues, + string? exceptionMessage) + { + var issueMessage = FormatSynchronizationIssues(issues); + if (!string.IsNullOrWhiteSpace(issueMessage)) + { + return issueMessage; + } + + if (!string.IsNullOrWhiteSpace(exceptionMessage)) + { + return exceptionMessage; + } + + return synchronizationType switch + { + CalendarSynchronizationType.CalendarMetadata => Translator.Exception_FailedToSynchronizeCalendarMetadata, + CalendarSynchronizationType.Strict => Translator.Exception_FailedToSynchronizeCalendarData, + _ => Translator.Exception_FailedToSynchronizeCalendarEvents + }; + } + + private static string? FormatSynchronizationIssues(IEnumerable issues) + { + if (issues == null) + { + return null; + } + + var issueLines = issues + .Where(issue => issue != null && !string.IsNullOrWhiteSpace(issue.Message)) + .Select(issue => string.IsNullOrWhiteSpace(issue.ScopeName) + ? issue.Message + : string.Format(Translator.SynchronizationIssueFormat_WithScope, issue.ScopeName, issue.Message)) + .Distinct(StringComparer.Ordinal) + .Take(5) + .ToList(); + + return issueLines.Count == 0 ? null : string.Join(Environment.NewLine, issueLines); + } + private void PreferencesServiceChanged(object? sender, string propertyName) { if (propertyName != nameof(IPreferencesService.EmailSyncIntervalMinutes)) diff --git a/Wino.Messages/Client/Calendar/CalendarItemEventMessages.cs b/Wino.Messages/Client/Calendar/CalendarItemEventMessages.cs index a741a609..c4039fa0 100644 --- a/Wino.Messages/Client/Calendar/CalendarItemEventMessages.cs +++ b/Wino.Messages/Client/Calendar/CalendarItemEventMessages.cs @@ -3,6 +3,6 @@ using Wino.Core.Domain.Enums; namespace Wino.Messaging.Client.Calendar; -public record CalendarItemAdded(CalendarItem CalendarItem); -public record CalendarItemUpdated(CalendarItem CalendarItem, CalendarItemUpdateSource Source); -public record CalendarItemDeleted(CalendarItem CalendarItem); +public record CalendarItemAdded(CalendarItem CalendarItem, EntityUpdateSource Source = EntityUpdateSource.Server); +public record CalendarItemUpdated(CalendarItem CalendarItem, EntityUpdateSource Source); +public record CalendarItemDeleted(CalendarItem CalendarItem, EntityUpdateSource Source = EntityUpdateSource.Server); diff --git a/Wino.Messages/UI/MailAddedMessage.cs b/Wino.Messages/UI/MailAddedMessage.cs index 888f4bc0..0c5a86f7 100644 --- a/Wino.Messages/UI/MailAddedMessage.cs +++ b/Wino.Messages/UI/MailAddedMessage.cs @@ -1,5 +1,6 @@ using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Enums; namespace Wino.Messaging.UI; -public record MailAddedMessage(MailCopy AddedMail) : UIMessageBase; +public record MailAddedMessage(MailCopy AddedMail, EntityUpdateSource Source = EntityUpdateSource.Server) : UIMessageBase; diff --git a/Wino.Messages/UI/MailRemovedMessage.cs b/Wino.Messages/UI/MailRemovedMessage.cs index c64cd726..e345215c 100644 --- a/Wino.Messages/UI/MailRemovedMessage.cs +++ b/Wino.Messages/UI/MailRemovedMessage.cs @@ -1,5 +1,6 @@ using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Enums; namespace Wino.Messaging.UI; -public record MailRemovedMessage(MailCopy RemovedMail) : UIMessageBase; +public record MailRemovedMessage(MailCopy RemovedMail, EntityUpdateSource Source = EntityUpdateSource.Server) : UIMessageBase; diff --git a/Wino.Messages/UI/MailUpdatedMessage.cs b/Wino.Messages/UI/MailUpdatedMessage.cs index 66c4161b..9f2a79e2 100644 --- a/Wino.Messages/UI/MailUpdatedMessage.cs +++ b/Wino.Messages/UI/MailUpdatedMessage.cs @@ -3,4 +3,4 @@ using Wino.Core.Domain.Enums; namespace Wino.Messaging.UI; -public record MailUpdatedMessage(MailCopy UpdatedMail, MailUpdateSource Source, MailCopyChangeFlags ChangedProperties = MailCopyChangeFlags.None) : UIMessageBase; +public record MailUpdatedMessage(MailCopy UpdatedMail, EntityUpdateSource Source, MailCopyChangeFlags ChangedProperties = MailCopyChangeFlags.None) : UIMessageBase; diff --git a/Wino.Services/AccountService.cs b/Wino.Services/AccountService.cs index 142c2bd1..0c3a5cb0 100644 --- a/Wino.Services/AccountService.cs +++ b/Wino.Services/AccountService.cs @@ -362,7 +362,7 @@ public class AccountService : BaseDatabaseService, IAccountService foreach (var calendarItem in deletedCalendarItems) { - WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(calendarItem)); + WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(calendarItem, EntityUpdateSource.Server)); } foreach (var accountCalendar in accountCalendars) diff --git a/Wino.Services/CalendarService.cs b/Wino.Services/CalendarService.cs index 918eeb95..787ba507 100644 --- a/Wino.Services/CalendarService.cs +++ b/Wino.Services/CalendarService.cs @@ -122,7 +122,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService await Connection.Table().DeleteAsync(r => r.CalendarItemId == @event.Id).ConfigureAwait(false); await Connection.Table().DeleteAsync(a => a.CalendarItemId == @event.Id).ConfigureAwait(false); - WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(@event)); + WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(@event, EntityUpdateSource.Server)); } } @@ -149,7 +149,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService } }); - WeakReferenceMessenger.Default.Send(new CalendarItemAdded(calendarItem)); + WeakReferenceMessenger.Default.Send(new CalendarItemAdded(calendarItem, EntityUpdateSource.Server)); } catch (Exception ex) { @@ -174,7 +174,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService } }); - WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(calendarItem, CalendarItemUpdateSource.Server)); + WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(calendarItem, EntityUpdateSource.Server)); } catch (Exception ex) { diff --git a/Wino.Services/MailService.cs b/Wino.Services/MailService.cs index 304cc500..69a15890 100644 --- a/Wino.Services/MailService.cs +++ b/Wino.Services/MailService.cs @@ -659,7 +659,7 @@ public class MailService : BaseDatabaseService, IMailService await Connection.InsertAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false); - ReportUIChange(new MailAddedMessage(mailCopy)); + ReportUIChange(new MailAddedMessage(mailCopy, EntityUpdateSource.Server)); } public async Task UpdateMailAsync(MailCopy mailCopy) @@ -675,7 +675,7 @@ public class MailService : BaseDatabaseService, IMailService await Connection.UpdateAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false); - ReportUIChange(new MailUpdatedMessage(mailCopy, MailUpdateSource.Server)); + ReportUIChange(new MailUpdatedMessage(mailCopy, EntityUpdateSource.Server)); } private async Task DeleteMailInternalAsync(MailCopy mailCopy, bool preserveMimeFile) @@ -699,7 +699,7 @@ public class MailService : BaseDatabaseService, IMailService await _mimeFileService.DeleteMimeMessageAsync(mailCopy.AssignedAccount.Id, mailCopy.FileId).ConfigureAwait(false); } - ReportUIChange(new MailRemovedMessage(mailCopy)); + ReportUIChange(new MailRemovedMessage(mailCopy, EntityUpdateSource.Server)); } #endregion