Immidiate ui reflection for calendar events and some more error handling.

This commit is contained in:
Burak Kaan Köse
2026-04-07 16:48:46 +02:00
parent 3db54023a4
commit 71fc883e47
53 changed files with 1482 additions and 393 deletions
+275 -45
View File
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.ObjectModel;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
@@ -14,11 +15,13 @@ using Wino.Calendar.ViewModels.Interfaces;
using Wino.Calendar.ViewModels.Messages; using Wino.Calendar.ViewModels.Messages;
using Wino.Core.Domain; using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models; using Wino.Core.Domain.Models;
using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Services;
using Wino.Core.ViewModels; using Wino.Core.ViewModels;
using Wino.Messaging.Client.Calendar; using Wino.Messaging.Client.Calendar;
using Wino.Messaging.UI; using Wino.Messaging.UI;
@@ -119,7 +122,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
public partial bool IsCalendarEnabled { get; set; } = true; public partial bool IsCalendarEnabled { get; set; } = true;
[ObservableProperty] [ObservableProperty]
public partial IReadOnlyList<CalendarItemViewModel> CalendarItems { get; set; } = []; public partial ObservableCollection<CalendarItemViewModel> CalendarItems { get; set; } = new();
#endregion #endregion
@@ -153,7 +156,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
private bool _subscriptionsAttached; private bool _subscriptionsAttached;
private CancellationTokenSource _pageLifetimeCts = new(); private CancellationTokenSource _pageLifetimeCts = new();
private long _pageLifetimeVersion; private long _pageLifetimeVersion;
private List<CalendarItemViewModel> _loadedCalendarItems = []; private Dictionary<Guid, CalendarItemViewModel> _loadedCalendarItems = new();
[ObservableProperty] [ObservableProperty]
public partial CalendarSettings CurrentSettings { get; set; } public partial CalendarSettings CurrentSettings { get; set; }
@@ -323,8 +326,8 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
CurrentVisibleRange = null; CurrentVisibleRange = null;
VisibleDateRangeText = string.Empty; VisibleDateRangeText = string.Empty;
LoadedDateWindow = null; LoadedDateWindow = null;
_loadedCalendarItems = []; _loadedCalendarItems = new();
CalendarItems = []; CalendarItems = new();
} }
public void Dispose() public void Dispose()
@@ -594,8 +597,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
{ {
if (loadedItems != null) if (loadedItems != null)
{ {
_loadedCalendarItems = loadedItems; ReplaceLoadedCalendarItems(loadedItems);
CalendarItems = loadedItems;
} }
EnsureSelectedQuickEventAccountCalendar(); EnsureSelectedQuickEventAccountCalendar();
@@ -656,8 +658,10 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
{ {
var loadedItems = new Dictionary<Guid, CalendarItemViewModel>(); var loadedItems = new Dictionary<Guid, CalendarItemViewModel>();
var loadPeriod = new TimeRange(loadedDateWindow.StartDate, loadedDateWindow.EndDate); 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)) if (!IsPageActive(lifetimeVersion))
return []; return [];
@@ -672,12 +676,16 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
if (!loadedItems.ContainsKey(calendarItem.Id)) 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) private static bool IsSameVisibleRange(VisibleDateRange current, VisibleDateRange next)
@@ -754,41 +762,12 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
await ReloadCurrentVisibleRangeAsync().ConfigureAwait(false); 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 || if (calendarItem == null)
DisplayDetailsCalendarItemViewModel?.CalendarItem?.RecurringCalendarItemId == calendarItem.Id) return;
{
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.IsRecurringParent) if (calendarItem.IsRecurringParent)
{ {
@@ -796,21 +775,272 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
return; return;
} }
if (ShouldReloadFor(calendarItem)) var existingItemId = FindLoadedCalendarItemId(calendarItem);
if (!existingItemId.HasValue)
return;
RemoveLoadedCalendarItem(existingItemId.Value, calendarItem);
}
protected override void OnCalendarItemUpdated(CalendarItem calendarItem, EntityUpdateSource source)
{ {
_ = ReloadCurrentVisibleRangeAsync(); 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<HashSet<Guid>> GetPendingCalendarItemIdsAsync(IEnumerable<AccountCalendarViewModel> activeCalendars, long lifetimeVersion)
{
var pendingCalendarItemIds = new HashSet<Guid>();
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);
} }
} }
private bool ShouldReloadFor(CalendarItem calendarItem) 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 CalendarItemViewModel CreateCalendarItemViewModel(CalendarItem calendarItem, EntityUpdateSource source)
=> CreateCalendarItemViewModel(
calendarItem,
source == EntityUpdateSource.ClientUpdated ? new HashSet<Guid> { calendarItem.Id } : null,
source);
private CalendarItemViewModel CreateCalendarItemViewModel(CalendarItem calendarItem, ISet<Guid> 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<CalendarItemViewModel> loadedItems)
{
var loadedItemsList = loadedItems?.ToList() ?? [];
CalendarItems = new ObservableCollection<CalendarItemViewModel>(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) if (calendarItem == null || LoadedDateWindow == null)
return false; return false;
if (calendarItem.IsHidden || calendarItem.IsRecurringParent || !IsCalendarActive(calendarItem.CalendarId))
return false;
var loadedWindow = new TimeRange(LoadedDateWindow.StartDate, LoadedDateWindow.EndDate); var loadedWindow = new TimeRange(LoadedDateWindow.StartDate, LoadedDateWindow.EndDate);
return loadedWindow.OverlapsWith(calendarItem.Period); 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<Guid> 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) partial void OnIsAllDayChanged(bool value)
{ {
if (value) if (value)
@@ -12,6 +12,7 @@ using Serilog;
using Wino.Calendar.ViewModels.Data; using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain; using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Calendar;
@@ -187,20 +188,20 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel
await LoadCalendarItemTargetAsync(args); 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); base.OnCalendarItemUpdated(calendarItem, source);
// If the current event was updated, reload it // 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. // 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() ?? []; var previousAttendees = CurrentEvent?.Attendees?.ToList() ?? [];
CurrentEvent = new CalendarItemViewModel(calendarItem) CurrentEvent = new CalendarItemViewModel(calendarItem)
{ {
IsBusy = source == CalendarItemUpdateSource.ClientUpdated IsBusy = source == EntityUpdateSource.ClientUpdated
}; };
foreach (var attendee in previousAttendees) 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 the current event was deleted, navigate back
if (CurrentEvent?.CalendarItem?.Id == calendarItem.Id || CurrentEvent?.CalendarItem.RecurringCalendarItemId == calendarItem.Id) if (IsCurrentEventMatch(calendarItem))
{ {
NavigateBackToCalendar(forceReload: true); 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) private async Task LoadCalendarItemTargetAsync(CalendarItemTarget target)
{ {
try try
@@ -1,17 +1,17 @@
namespace Wino.Core.Domain.Enums; namespace Wino.Core.Domain.Enums;
/// <summary> /// <summary>
/// Indicates the source of a calendar item update. /// Indicates the source of an entity update.
/// </summary> /// </summary>
public enum CalendarItemUpdateSource public enum EntityUpdateSource
{ {
/// <summary> /// <summary>
/// Update originated from client-side UI changes (ApplyUIChanges). /// Update originated from client-side optimistic UI changes (ApplyUIChanges).
/// </summary> /// </summary>
ClientUpdated, ClientUpdated,
/// <summary> /// <summary>
/// Update originated from client-side UI revert (RevertUIChanges). /// Update originated from reverting client-side optimistic UI changes (RevertUIChanges).
/// </summary> /// </summary>
ClientReverted, ClientReverted,
@@ -1,22 +0,0 @@
namespace Wino.Core.Domain.Enums;
/// <summary>
/// Indicates the source of a mail update.
/// </summary>
public enum MailUpdateSource
{
/// <summary>
/// Update originated from client-side UI changes (ApplyUIChanges).
/// </summary>
ClientUpdated,
/// <summary>
/// Update originated from client-side UI revert (RevertUIChanges).
/// </summary>
ClientReverted,
/// <summary>
/// Update originated from server synchronization or database operations.
/// </summary>
Server
}
@@ -42,6 +42,11 @@ public interface IBaseSynchronizer
/// <param name="calendarItemId">Calendar item id to check.</param> /// <param name="calendarItemId">Calendar item id to check.</param>
bool HasPendingCalendarOperation(Guid calendarItemId); bool HasPendingCalendarOperation(Guid calendarItemId);
/// <summary>
/// Returns calendar item ids that currently have queued or executing operations.
/// </summary>
IReadOnlyCollection<Guid> GetPendingCalendarOperationIds();
/// <summary> /// <summary>
/// Synchronizes profile information with the server. /// Synchronizes profile information with the server.
/// Sender name and Profile picture are updated. /// Sender name and Profile picture are updated.
@@ -1,4 +1,6 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
@@ -22,6 +24,13 @@ public class CalendarSynchronizationResult
public SynchronizationCompletedState CompletedState { get; set; } public SynchronizationCompletedState CompletedState { get; set; }
public Exception Exception { get; set; }
public List<SynchronizationIssue> Issues { get; set; } = [];
[JsonIgnore]
public IEnumerable<SynchronizationIssue> AllIssues => Issues;
public static CalendarSynchronizationResult Empty => new() { CompletedState = SynchronizationCompletedState.Success }; public static CalendarSynchronizationResult Empty => new() { CompletedState = SynchronizationCompletedState.Success };
// Mail synchronization // Mail synchronization
@@ -41,5 +50,48 @@ public class CalendarSynchronizationResult
}; };
public static CalendarSynchronizationResult Canceled => new() { CompletedState = SynchronizationCompletedState.Canceled }; 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<SynchronizationIssue> 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);
} }
@@ -26,6 +26,8 @@ public class MailSynchronizationResult
public Exception Exception { get; set; } public Exception Exception { get; set; }
public List<SynchronizationIssue> Issues { get; set; } = [];
/// <summary> /// <summary>
/// Gets or sets the results for each folder that was synchronized. /// Gets or sets the results for each folder that was synchronized.
/// Enables partial failure tracking - some folders may succeed while others fail. /// Enables partial failure tracking - some folders may succeed while others fail.
@@ -75,6 +77,10 @@ public class MailSynchronizationResult
[JsonIgnore] [JsonIgnore]
public IEnumerable<FolderSyncResult> FailedFolders => FolderResults.Where(f => !f.Success); public IEnumerable<FolderSyncResult> FailedFolders => FolderResults.Where(f => !f.Success);
[JsonIgnore]
public IEnumerable<SynchronizationIssue> AllIssues
=> Issues.Concat(FailedFolders.Select(SynchronizationIssue.FromFolderResult).Where(issue => issue != null));
public static MailSynchronizationResult Empty => new() { CompletedState = SynchronizationCompletedState.Success }; public static MailSynchronizationResult Empty => new() { CompletedState = SynchronizationCompletedState.Success };
// Mail synchronization // Mail synchronization
@@ -121,4 +127,43 @@ public class MailSynchronizationResult
CompletedState = SynchronizationCompletedState.Failed, CompletedState = SynchronizationCompletedState.Failed,
Exception = exception Exception = exception
}; };
public MailSynchronizationResult MergeIssues(IEnumerable<SynchronizationIssue> 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);
} }
@@ -0,0 +1,110 @@
using System;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.Domain.Models.Synchronization;
/// <summary>
/// Represents a user-visible synchronization issue collected during request execution or provider synchronization.
/// </summary>
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
};
}
}
@@ -31,6 +31,11 @@ public class SynchronizerErrorContext
/// </summary> /// </summary>
public IRequestBundle RequestBundle { get; set; } public IRequestBundle RequestBundle { get; set; }
/// <summary>
/// Gets or sets the original request associated with the error when available.
/// </summary>
public IRequestBase Request { get; set; }
/// <summary> /// <summary>
/// Gets or sets additional data associated with the error /// Gets or sets additional data associated with the error
/// </summary> /// </summary>
@@ -76,6 +81,16 @@ public class SynchronizerErrorContext
/// </summary> /// </summary>
public string FolderName { get; set; } public string FolderName { get; set; }
/// <summary>
/// Gets or sets the calendar ID associated with the error for calendar sync issue tracking.
/// </summary>
public Guid? CalendarId { get; set; }
/// <summary>
/// Gets or sets the calendar name for display purposes.
/// </summary>
public string CalendarName { get; set; }
/// <summary> /// <summary>
/// Gets or sets the type of operation that failed. /// Gets or sets the type of operation that failed.
/// Examples: "FolderSync", "MailSync", "RequestExecution", "Idle" /// Examples: "FolderSync", "MailSync", "RequestExecution", "Idle"
@@ -89,6 +104,16 @@ public class SynchronizerErrorContext
/// </summary> /// </summary>
public bool IsEntityNotFound { get; set; } public bool IsEntityNotFound { get; set; }
/// <summary>
/// Gets or sets whether a synchronizer error handler processed this error.
/// </summary>
public bool WasHandled { get; set; }
/// <summary>
/// Gets or sets the handler type that processed this error.
/// </summary>
public string HandledBy { get; set; }
/// <summary> /// <summary>
/// Gets whether this error should be retried based on severity and retry count. /// Gets whether this error should be retried based on severity and retry count.
/// </summary> /// </summary>
@@ -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_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_MessageSecondLine": "Would you like to rate Wino Mail in Microsoft Store?",
"StoreRatingDialog_Title": "Enjoying Wino?", "StoreRatingDialog_Title": "Enjoying Wino?",
"SynchronizationIssueFormat_WithScope": "{0}: {1}",
"SynchronizationFolderReport_Failed": "synchronization is failed", "SynchronizationFolderReport_Failed": "synchronization is failed",
"SynchronizationFolderReport_Success": "up to date", "SynchronizationFolderReport_Success": "up to date",
"SystemFolderConfigDialog_ArchiveFolderDescription": "Archived messages will be moved to here.", "SystemFolderConfigDialog_ArchiveFolderDescription": "Archived messages will be moved to here.",
+153 -8
View File
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using System.ComponentModel; using System.ComponentModel;
using System.Globalization; using System.Globalization;
using CommunityToolkit.Mvvm.Collections; using CommunityToolkit.Mvvm.Collections;
using CommunityToolkit.Mvvm.Messaging;
using FluentAssertions; using FluentAssertions;
using Itenso.TimePeriod; using Itenso.TimePeriod;
using Moq; using Moq;
@@ -10,9 +11,12 @@ using Wino.Calendar.ViewModels.Data;
using Wino.Calendar.ViewModels.Interfaces; using Wino.Calendar.ViewModels.Interfaces;
using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Navigation;
using Wino.Messaging.Client.Calendar;
using Xunit; using Xunit;
namespace Wino.Core.Tests; namespace Wino.Core.Tests;
@@ -155,19 +159,140 @@ public class CalendarPageViewModelTests
calendarService.Verify(service => service.GetCalendarEventsAsync(It.Is<IAccountCalendar>(calendar => calendar.Id == hiddenCalendar.Id), It.IsAny<ITimePeriod>()), Times.Never); calendarService.Verify(service => service.GetCalendarEventsAsync(It.Is<IAccountCalendar>(calendar => calendar.Id == hiddenCalendar.Id), It.IsAny<ITimePeriod>()), Times.Never);
} }
[Fact]
public async Task CalendarItemAddedMessage_AddsVisibleItemWithoutReloadAndMarksBusy()
{
var settings = CreateSettings();
var preferencesService = CreatePreferencesService(settings);
var calendarService = new Mock<ICalendarService>();
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<IAccountCalendar>(), It.IsAny<ITimePeriod>()))
.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<IAccountCalendar>(), It.IsAny<ITimePeriod>()), 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<ICalendarService>();
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<IAccountCalendar>(), It.IsAny<ITimePeriod>()))
.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<IAccountCalendar>(), It.IsAny<ITimePeriod>()), 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<ICalendarService>();
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<IAccountCalendar>(), It.IsAny<ITimePeriod>()))
.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( private static CalendarPageViewModel CreateViewModel(
ICalendarService calendarService, ICalendarService calendarService,
IPreferencesService preferencesService, IPreferencesService preferencesService,
DateOnly today) DateOnly today)
{ {
var account = new MailAccount var account = CreateAccount();
{
Id = Guid.NewGuid(),
Name = "Primary",
SenderName = "Primary",
Address = "primary@example.com",
ProviderType = MailProviderType.Outlook
};
var calendar = CreateCalendar(account, "Calendar"); var calendar = CreateCalendar(account, "Calendar");
var accountCalendarViewModel = new AccountCalendarViewModel(account, calendar); var accountCalendarViewModel = new AccountCalendarViewModel(account, calendar);
@@ -217,6 +342,26 @@ public class CalendarPageViewModelTests
IsSynchronizationEnabled = true 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<IPreferencesService> CreatePreferencesService(CalendarSettings settings) private static Mock<IPreferencesService> CreatePreferencesService(CalendarSettings settings)
=> CreatePreferencesService(() => settings); => CreatePreferencesService(() => settings);
@@ -32,8 +32,10 @@ public sealed class CreateCalendarEventRequestTests
recipient.Added.Should().ContainSingle(); recipient.Added.Should().ContainSingle();
recipient.Deleted.Should().ContainSingle(); recipient.Deleted.Should().ContainSingle();
recipient.Added[0].Id.Should().Be(request.LocalCalendarItemId!.Value); recipient.Added[0].CalendarItem.Id.Should().Be(request.LocalCalendarItemId!.Value);
recipient.Deleted[0].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 finally
{ {
@@ -117,11 +119,11 @@ public sealed class CreateCalendarEventRequestTests
IRecipient<CalendarItemAdded>, IRecipient<CalendarItemAdded>,
IRecipient<CalendarItemDeleted> IRecipient<CalendarItemDeleted>
{ {
public List<CalendarItem> Added { get; } = []; public List<CalendarItemAdded> Added { get; } = [];
public List<CalendarItem> Deleted { get; } = []; public List<CalendarItemDeleted> 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);
} }
} }
@@ -28,8 +28,8 @@ public sealed class MailRequestStateTests
mailCopy.IsRead.Should().BeFalse(); mailCopy.IsRead.Should().BeFalse();
recipient.Updated.Should().HaveCount(2); recipient.Updated.Should().HaveCount(2);
recipient.Updated[0].Source.Should().Be(MailUpdateSource.ClientUpdated); recipient.Updated[0].Source.Should().Be(EntityUpdateSource.ClientUpdated);
recipient.Updated[1].Source.Should().Be(MailUpdateSource.ClientReverted); recipient.Updated[1].Source.Should().Be(EntityUpdateSource.ClientReverted);
recipient.Updated[1].UpdatedMail.IsRead.Should().BeFalse(); recipient.Updated[1].UpdatedMail.IsRead.Should().BeFalse();
} }
finally finally
@@ -56,8 +56,8 @@ public sealed class MailRequestStateTests
mailCopy.IsFlagged.Should().BeFalse(); mailCopy.IsFlagged.Should().BeFalse();
recipient.Updated.Should().HaveCount(2); recipient.Updated.Should().HaveCount(2);
recipient.Updated[0].Source.Should().Be(MailUpdateSource.ClientUpdated); recipient.Updated[0].Source.Should().Be(EntityUpdateSource.ClientUpdated);
recipient.Updated[1].Source.Should().Be(MailUpdateSource.ClientReverted); recipient.Updated[1].Source.Should().Be(EntityUpdateSource.ClientReverted);
recipient.Updated[1].UpdatedMail.IsFlagged.Should().BeFalse(); recipient.Updated[1].UpdatedMail.IsFlagged.Should().BeFalse();
} }
finally finally
@@ -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<bool> HandleAsync(SynchronizerErrorContext error) => Task.FromResult(true);
}
}
@@ -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<SynchronizationActionsCompleted>
{
public List<Guid> CompletedAccountIds { get; } = [];
public void Receive(SynchronizationActionsCompleted message) => CompletedAccountIds.Add(message.AccountId);
}
private sealed class TestCalendarSynchronizer : WinoSynchronizer<object, object, object>
{
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<IRequestBundle<object>> batchedRequests, CancellationToken cancellationToken = default)
=> _throwDuringRequestExecution
? Task.FromException(new InvalidOperationException("Calendar request execution failed."))
: Task.CompletedTask;
public override List<IRequestBundle<object>> DeleteCalendarEvent(DeleteCalendarEventRequest request)
=> [new TestRequestBundle(new object(), request)];
public override Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(
object message,
Wino.Core.Domain.Entities.Mail.MailItemFolder assignedFolder,
CancellationToken cancellationToken = default)
=> Task.FromResult(new List<NewMailItemPackage>());
protected override Task<MailSynchronizationResult> SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
=> Task.FromResult(MailSynchronizationResult.Empty);
protected override Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
=> Task.FromResult(CalendarSynchronizationResult.Empty);
}
private sealed class TestRequestBundle : IRequestBundle<object>
{
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; }
}
}
@@ -11,13 +11,13 @@ public class CalendarBaseViewModel : CoreBaseViewModel,
IRecipient<CalendarItemUpdated>, IRecipient<CalendarItemUpdated>,
IRecipient<CalendarItemDeleted> IRecipient<CalendarItemDeleted>
{ {
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(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 OnCalendarItemAdded(CalendarItem calendarItem, EntityUpdateSource source) { }
protected virtual void OnCalendarItemUpdated(CalendarItem calendarItem, CalendarItemUpdateSource source) { } protected virtual void OnCalendarItemUpdated(CalendarItem calendarItem, EntityUpdateSource source) { }
protected virtual void OnCalendarItemDeleted(CalendarItem calendarItem) { } protected virtual void OnCalendarItemDeleted(CalendarItem calendarItem, EntityUpdateSource source) { }
private void DispatchToUIThread(Action action) private void DispatchToUIThread(Action action)
{ {
@@ -27,13 +27,13 @@ public record AcceptEventRequest(CalendarItem Item, string ResponseMessage = nul
Item.Status = CalendarItemStatus.Accepted; Item.Status = CalendarItemStatus.Accepted;
// Notify UI that the event status was updated // 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() public override void RevertUIChanges()
{ {
// If acceptance fails, revert to the previous status // If acceptance fails, revert to the previous status
Item.Status = _previousStatus; Item.Status = _previousStatus;
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientReverted)); WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, EntityUpdateSource.ClientReverted));
} }
} }
@@ -47,7 +47,7 @@ public record CreateCalendarEventRequest : CalendarRequestBase
if (Item == null) if (Item == null)
return; return;
WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item)); WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item, EntityUpdateSource.ClientUpdated));
} }
public override void RevertUIChanges() public override void RevertUIChanges()
@@ -55,7 +55,7 @@ public record CreateCalendarEventRequest : CalendarRequestBase
if (Item == null) if (Item == null)
return; return;
WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(Item)); WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(Item, EntityUpdateSource.ClientReverted));
} }
private static bool ShouldCreateOptimisticItem(CalendarEventComposeResult composeResult) private static bool ShouldCreateOptimisticItem(CalendarEventComposeResult composeResult)
@@ -27,13 +27,13 @@ public record DeclineEventRequest(CalendarItem Item, string ResponseMessage = nu
Item.Status = CalendarItemStatus.Cancelled; Item.Status = CalendarItemStatus.Cancelled;
// Notify UI that the event status was updated // 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() public override void RevertUIChanges()
{ {
// If decline fails, revert to the previous status // If decline fails, revert to the previous status
Item.Status = _previousStatus; Item.Status = _previousStatus;
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientReverted)); WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, EntityUpdateSource.ClientReverted));
} }
} }
@@ -21,12 +21,12 @@ public record DeleteCalendarEventRequest(CalendarItem Item) : CalendarRequestBas
public override void ApplyUIChanges() public override void ApplyUIChanges()
{ {
// Notify UI that the event was deleted // 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() public override void RevertUIChanges()
{ {
// If deletion fails, we should notify the UI to add it back // 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));
} }
} }
@@ -26,13 +26,13 @@ public record OutlookDeclineEventRequest(CalendarItem Item, string ResponseMessa
{ {
// In Outlook, declined events are deleted from the calendar after sync // In Outlook, declined events are deleted from the calendar after sync
// Send deleted message to remove from UI immediately // 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() public override void RevertUIChanges()
{ {
// If decline fails, restore the previous status and re-add the event // If decline fails, restore the previous status and re-add the event
Item.Status = _previousStatus; Item.Status = _previousStatus;
WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item)); WeakReferenceMessenger.Default.Send(new CalendarItemAdded(Item, EntityUpdateSource.ClientReverted));
} }
} }
@@ -27,13 +27,13 @@ public record TentativeEventRequest(CalendarItem Item, string ResponseMessage =
Item.Status = CalendarItemStatus.Tentative; Item.Status = CalendarItemStatus.Tentative;
// Notify UI that the event status was updated // 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() public override void RevertUIChanges()
{ {
// If tentative acceptance fails, revert to the previous status // If tentative acceptance fails, revert to the previous status
Item.Status = _previousStatus; Item.Status = _previousStatus;
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientReverted)); WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, EntityUpdateSource.ClientReverted));
} }
} }
@@ -33,7 +33,7 @@ public record UpdateCalendarEventRequest(CalendarItem Item, List<CalendarEventAt
public override void ApplyUIChanges() public override void ApplyUIChanges()
{ {
// Notify UI that the event was updated locally // Notify UI that the event was updated locally
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientUpdated)); WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, EntityUpdateSource.ClientUpdated));
} }
public override void RevertUIChanges() public override void RevertUIChanges()
@@ -42,12 +42,12 @@ public record UpdateCalendarEventRequest(CalendarItem Item, List<CalendarEventAt
if (OriginalItem != null && OriginalAttendees != null) if (OriginalItem != null && OriginalAttendees != null)
{ {
// Send the original item back to restore UI state // Send the original item back to restore UI state
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(OriginalItem, CalendarItemUpdateSource.ClientReverted)); WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(OriginalItem, EntityUpdateSource.ClientReverted));
} }
else else
{ {
// Fallback: just notify with current item to trigger refresh // Fallback: just notify with current item to trigger refresh
WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, CalendarItemUpdateSource.ClientReverted)); WeakReferenceMessenger.Default.Send(new CalendarItemUpdated(Item, EntityUpdateSource.ClientReverted));
} }
} }
} }
@@ -16,7 +16,7 @@ public record EmptyFolderRequest(MailItemFolder Folder, List<MailCopy> MailsToDe
{ {
foreach (var item in MailsToDelete) 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<MailCopy> MailsToDe
{ {
foreach (var item in MailsToDelete) foreach (var item in MailsToDelete)
{ {
WeakReferenceMessenger.Default.Send(new MailAddedMessage(item)); WeakReferenceMessenger.Default.Send(new MailAddedMessage(item, EntityUpdateSource.ClientReverted));
} }
} }
@@ -26,7 +26,7 @@ public record MarkFolderAsReadRequest(MailItemFolder Folder, List<MailCopy> Mail
item.IsRead = true; 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<MailCopy> Mail
item.IsRead = false; item.IsRead = false;
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, MailUpdateSource.ClientReverted, MailCopyChangeFlags.IsRead)); WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item, EntityUpdateSource.ClientReverted, MailCopyChangeFlags.IsRead));
} }
} }
} }
+2 -2
View File
@@ -41,12 +41,12 @@ public record ArchiveRequest(bool IsArchiving, MailCopy Item, MailItemFolder Fro
public override void ApplyUIChanges() public override void ApplyUIChanges()
{ {
WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item)); WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item, EntityUpdateSource.ClientUpdated));
} }
public override void RevertUIChanges() public override void RevertUIChanges()
{ {
WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item)); WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item, EntityUpdateSource.ClientReverted));
} }
} }
+2 -2
View File
@@ -33,7 +33,7 @@ public record ChangeFlagRequest(MailCopy Item, bool IsFlagged) : MailRequestBase
Item.IsFlagged = IsFlagged; 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() public override void RevertUIChanges()
@@ -43,7 +43,7 @@ public record ChangeFlagRequest(MailCopy Item, bool IsFlagged) : MailRequestBase
Item.IsFlagged = _originalIsFlagged; Item.IsFlagged = _originalIsFlagged;
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientReverted, MailCopyChangeFlags.IsFlagged)); WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, EntityUpdateSource.ClientReverted, MailCopyChangeFlags.IsFlagged));
} }
} }
+2 -2
View File
@@ -22,12 +22,12 @@ public record DeleteRequest(MailCopy MailItem) : MailRequestBase(MailItem),
public override void ApplyUIChanges() public override void ApplyUIChanges()
{ {
WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item)); WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item, EntityUpdateSource.ClientUpdated));
} }
public override void RevertUIChanges() public override void RevertUIChanges()
{ {
WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item)); WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item, EntityUpdateSource.ClientReverted));
} }
} }
+2 -2
View File
@@ -32,7 +32,7 @@ public record MarkReadRequest(MailCopy Item, bool IsRead) : MailRequestBase(Item
Item.IsRead = IsRead; 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() public override void RevertUIChanges()
@@ -42,7 +42,7 @@ public record MarkReadRequest(MailCopy Item, bool IsRead) : MailRequestBase(Item
Item.IsRead = _originalIsRead; Item.IsRead = _originalIsRead;
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, MailUpdateSource.ClientReverted, MailCopyChangeFlags.IsRead)); WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(Item, EntityUpdateSource.ClientReverted, MailCopyChangeFlags.IsRead));
} }
} }
+2 -2
View File
@@ -18,12 +18,12 @@ public record MoveRequest(MailCopy Item, MailItemFolder FromFolder, MailItemFold
public override void ApplyUIChanges() public override void ApplyUIChanges()
{ {
WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item)); WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item, EntityUpdateSource.ClientUpdated));
} }
public override void RevertUIChanges() public override void RevertUIChanges()
{ {
WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item)); WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item, EntityUpdateSource.ClientReverted));
} }
} }
+2 -2
View File
@@ -34,11 +34,11 @@ public record SendDraftRequest(SendDraftPreparationRequest Request)
public override void ApplyUIChanges() public override void ApplyUIChanges()
{ {
WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item)); WeakReferenceMessenger.Default.Send(new MailRemovedMessage(Item, EntityUpdateSource.ClientUpdated));
} }
public override void RevertUIChanges() public override void RevertUIChanges()
{ {
WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item)); WeakReferenceMessenger.Default.Send(new MailAddedMessage(Item, EntityUpdateSource.ClientReverted));
} }
} }
+20 -6
View File
@@ -146,7 +146,10 @@ public class SynchronizationManager : ISynchronizationManager
{ {
_logger.Error("Could not find or create synchronizer for account {AccountId}", options.AccountId); _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}", _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 // Create app notification for authentication attention
_notificationBuilder.CreateAttentionRequiredNotification(authEx.Account); _notificationBuilder.CreateAttentionRequiredNotification(authEx.Account);
return MailSynchronizationResult.Failed(authEx); return MailSynchronizationResult
.Failed(authEx)
.MergeIssues([SynchronizationIssue.FromException(authEx, "MailSync", SynchronizerErrorSeverity.AuthRequired)]);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.Error(ex, "Mail synchronization failed for account {AccountId}", options.AccountId); _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) if (synchronizer == null)
{ {
_logger.Error("Could not find or create synchronizer for account {AccountId}", options.AccountId); _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}", _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 // Create app notification for authentication attention
_notificationBuilder.CreateAttentionRequiredNotification(authEx.Account); _notificationBuilder.CreateAttentionRequiredNotification(authEx.Account);
return CalendarSynchronizationResult.Failed; return CalendarSynchronizationResult
.Failed(authEx)
.MergeIssues([SynchronizationIssue.FromException(authEx, "CalendarSync", SynchronizerErrorSeverity.AuthRequired)]);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.Error(ex, "Calendar synchronization failed for account {AccountId}", options.AccountId); _logger.Error(ex, "Calendar synchronization failed for account {AccountId}", options.AccountId);
return CalendarSynchronizationResult.Failed; return CalendarSynchronizationResult
.Failed(ex)
.MergeIssues([SynchronizationIssue.FromException(ex, "CalendarSync")]);
} }
finally finally
{ {
@@ -37,13 +37,20 @@ public class SynchronizerErrorHandlingFactory
_logger.Debug("Found handler {HandlerType} for error code {ErrorCode} message {ErrorMessage}", _logger.Debug("Found handler {HandlerType} for error code {ErrorCode} message {ErrorMessage}",
handler.GetType().Name, error.ErrorCode, error.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}", _logger.Debug("No handler found for error code {ErrorCode} message {ErrorMessage}",
error.ErrorCode, error.ErrorMessage); error.ErrorCode, error.ErrorMessage);
error.WasHandled = false;
error.HandledBy = null;
return false; return false;
} }
} }
@@ -11,6 +11,7 @@ using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Requests.Bundles; using Wino.Core.Requests.Bundles;
using Wino.Messaging.UI; using Wino.Messaging.UI;
@@ -24,6 +25,7 @@ public abstract partial class BaseSynchronizer<TBaseRequest> : ObservableObject,
protected List<IRequestBase> changeRequestQueue = []; protected List<IRequestBase> changeRequestQueue = [];
private readonly ConcurrentDictionary<Guid, byte> _pendingMailOperationIds = new(); private readonly ConcurrentDictionary<Guid, byte> _pendingMailOperationIds = new();
private readonly ConcurrentDictionary<Guid, byte> _pendingCalendarOperationIds = new(); private readonly ConcurrentDictionary<Guid, byte> _pendingCalendarOperationIds = new();
private readonly ConcurrentQueue<SynchronizationIssue> _capturedSynchronizationIssues = new();
protected readonly IMessenger Messenger; protected readonly IMessenger Messenger;
public MailAccount Account { get; } public MailAccount Account { get; }
@@ -135,6 +137,8 @@ public abstract partial class BaseSynchronizer<TBaseRequest> : ObservableObject,
public bool HasPendingCalendarOperation(Guid calendarItemId) => _pendingCalendarOperationIds.ContainsKey(calendarItemId); public bool HasPendingCalendarOperation(Guid calendarItemId) => _pendingCalendarOperationIds.ContainsKey(calendarItemId);
public IReadOnlyCollection<Guid> GetPendingCalendarOperationIds() => _pendingCalendarOperationIds.Keys.ToArray();
protected void TrackQueuedRequest(IRequestBase request) protected void TrackQueuedRequest(IRequestBase request)
{ {
if (request is IMailActionRequest mailActionRequest) if (request is IMailActionRequest mailActionRequest)
@@ -173,6 +177,27 @@ public abstract partial class BaseSynchronizer<TBaseRequest> : ObservableObject,
UntrackProcessedRequest(request); 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<SynchronizationIssue> GetCapturedSynchronizationIssues()
=> _capturedSynchronizationIssues.ToArray();
/// <summary> /// <summary>
/// Runs existing queued requests in the queue. /// Runs existing queued requests in the queue.
/// </summary> /// </summary>
+55 -16
View File
@@ -522,8 +522,9 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
.Where(c => c.IsSynchronizationEnabled) .Where(c => c.IsSynchronizationEnabled)
.ToList(); .ToList();
// TODO: Better logging and exception handling.
foreach (var calendar in localCalendars) foreach (var calendar in localCalendars)
{
try
{ {
var request = _calendarService.Events.List(calendar.RemoteCalendarId); var request = _calendarService.Events.List(calendar.RemoteCalendarId);
@@ -535,14 +536,10 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
if (!string.IsNullOrEmpty(calendar.SynchronizationDeltaToken)) if (!string.IsNullOrEmpty(calendar.SynchronizationDeltaToken))
{ {
// If a sync token is available, perform an incremental sync
request.SyncToken = calendar.SynchronizationDeltaToken; request.SyncToken = calendar.SynchronizationDeltaToken;
} }
else else
{ {
// If no sync token, perform an initial sync
// Fetch events from the past year
request.TimeMinDateTimeOffset = DateTimeOffset.UtcNow.AddYears(-1); request.TimeMinDateTimeOffset = DateTimeOffset.UtcNow.AddYears(-1);
} }
@@ -553,29 +550,21 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
do do
{ {
// Execute the request var events = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false);
var events = await request.ExecuteAsync();
// Process the fetched events
if (events.Items != null) if (events.Items != null)
{ {
allEvents.AddRange(events.Items); allEvents.AddRange(events.Items);
} }
// Get the next page token and sync token
nextPageToken = events.NextPageToken; nextPageToken = events.NextPageToken;
syncToken = events.NextSyncToken; syncToken = events.NextSyncToken;
// Set the next page token for subsequent requests
request.PageToken = nextPageToken; request.PageToken = nextPageToken;
}
} while (!string.IsNullOrEmpty(nextPageToken)); while (!string.IsNullOrEmpty(nextPageToken));
calendar.SynchronizationDeltaToken = syncToken; calendar.SynchronizationDeltaToken = syncToken;
// allEvents contains new or updated events.
// Process them and create/update local calendar items.
var eventByRemoteId = allEvents var eventByRemoteId = allEvents
.Where(e => !string.IsNullOrWhiteSpace(e.Id)) .Where(e => !string.IsNullOrWhiteSpace(e.Id))
.GroupBy(e => e.Id, StringComparer.Ordinal) .GroupBy(e => e.Id, StringComparer.Ordinal)
@@ -585,12 +574,59 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
try
{
await EnsureRecurringParentProcessedAsync(calendar, @event, eventByRemoteId, cancellationToken).ConfigureAwait(false); await EnsureRecurringParentProcessedAsync(calendar, @event, eventByRemoteId, cancellationToken).ConfigureAwait(false);
await _gmailChangeProcessor.ManageCalendarEventAsync(@event, calendar, Account).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); 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 _gmailSynchronizerErrorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
CaptureSynchronizationIssue(errorContext);
if (!errorContext.CanContinueSync)
throw;
}
}
return CalendarSynchronizationResult.Empty; return CalendarSynchronizationResult.Empty;
} }
@@ -1674,6 +1710,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
ErrorCode = error.Code, ErrorCode = error.Code,
ErrorMessage = error.Message, ErrorMessage = error.Message,
RequestBundle = bundle, RequestBundle = bundle,
Request = bundle.Request,
IsEntityNotFound = isEntityNotFound, IsEntityNotFound = isEntityNotFound,
AdditionalData = new Dictionary<string, object> AdditionalData = new Dictionary<string, object>
{ {
@@ -1697,6 +1734,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// If not handled by any specific handler, apply default error handling // If not handled by any specific handler, apply default error handling
if (!handled) if (!handled)
{ {
CaptureSynchronizationIssue(errorContext);
// OutOfMemoryException is a known bug in Gmail SDK. // OutOfMemoryException is a known bug in Gmail SDK.
if (error.Code == 0) if (error.Code == 0)
{ {
@@ -763,6 +763,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
ErrorMessage = ex.Message, ErrorMessage = ex.Message,
Exception = ex, Exception = ex,
RequestBundle = item, RequestBundle = item,
Request = item.Request,
OperationType = "RequestExecution", OperationType = "RequestExecution",
IsEntityNotFound = ex is FolderNotFoundException || ex is SynchronizerEntityNotFoundException IsEntityNotFound = ex is FolderNotFoundException || ex is SynchronizerEntityNotFoundException
}; };
@@ -771,6 +772,8 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
if (!handled) if (!handled)
{ {
CaptureSynchronizationIssue(errorContext);
if (ShouldApplyOptimisticUIChanges(item.Request)) if (ShouldApplyOptimisticUIChanges(item.Request))
{ {
item.Request.RevertUIChanges(); item.Request.RevertUIChanges();
+47 -4
View File
@@ -1861,6 +1861,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
ErrorCode = (int)response.StatusCode, ErrorCode = (int)response.StatusCode,
ErrorMessage = errorMessage, ErrorMessage = errorMessage,
RequestBundle = bundle, RequestBundle = bundle,
Request = bundle.Request,
IsEntityNotFound = IsKnownOutlookEntityNotFoundError(response.StatusCode, errorCode, errorMessage, bundle), IsEntityNotFound = IsKnownOutlookEntityNotFoundError(response.StatusCode, errorCode, errorMessage, bundle),
AdditionalData = new Dictionary<string, object> AdditionalData = new Dictionary<string, object>
{ {
@@ -1876,6 +1877,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
// Transient errors still need to bubble so the request can be retried or surfaced to the caller. // Transient errors still need to bubble so the request can be retried or surfaced to the caller.
if (!handled || errorContext.Severity == SynchronizerErrorSeverity.Transient) if (!handled || errorContext.Severity == SynchronizerErrorSeverity.Transient)
{ {
CaptureSynchronizationIssue(errorContext);
bundle.UIChangeRequest?.RevertUIChanges(); bundle.UIChangeRequest?.RevertUIChanges();
Debug.WriteLine(errorString); Debug.WriteLine(errorString);
errors.Add(errorString); errors.Add(errorString);
@@ -2265,6 +2267,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
// TODO: Maybe we can batch each calendar? // TODO: Maybe we can batch each calendar?
foreach (var calendar in localCalendars) foreach (var calendar in localCalendars)
{
try
{ {
bool isInitialSync = string.IsNullOrEmpty(calendar.SynchronizationDeltaToken); bool isInitialSync = string.IsNullOrEmpty(calendar.SynchronizationDeltaToken);
@@ -2329,12 +2333,12 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
if (IsResourceDeleted(item.AdditionalData)) if (IsResourceDeleted(item.AdditionalData))
{ {
await _outlookChangeProcessor.DeleteCalendarItemAsync(item.Id, calendar.Id).ConfigureAwait(false); await _outlookChangeProcessor.DeleteCalendarItemAsync(item.Id, calendar.Id).ConfigureAwait(false);
continue;
} }
else
{
try try
{ {
await _handleCalendarEventRetrievalSemaphore.WaitAsync(); await _handleCalendarEventRetrievalSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
Event fullEvent = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[item.Id] Event fullEvent = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[item.Id]
.GetAsync(requestConfiguration => .GetAsync(requestConfiguration =>
@@ -2344,8 +2348,25 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
}, cancellationToken: cancellationToken).ConfigureAwait(false); }, cancellationToken: cancellationToken).ConfigureAwait(false);
await _outlookChangeProcessor.ManageCalendarEventAsync(fullEvent, calendar, Account).ConfigureAwait(false); await _outlookChangeProcessor.ManageCalendarEventAsync(fullEvent, calendar, Account).ConfigureAwait(false);
} }
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) 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 _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
CaptureSynchronizationIssue(errorContext);
_logger.Error(ex, "Error occurred while handling item {Id} for calendar {Name}", item.Id, calendar.Name); _logger.Error(ex, "Error occurred while handling item {Id} for calendar {Name}", item.Id, calendar.Name);
} }
finally finally
@@ -2353,7 +2374,6 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
_handleCalendarEventRetrievalSemaphore.Release(); _handleCalendarEventRetrievalSemaphore.Release();
} }
} }
}
var latestDeltaLink = messageIteratorAsync.Deltalink; var latestDeltaLink = messageIteratorAsync.Deltalink;
@@ -2367,6 +2387,29 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
await _outlookChangeProcessor.UpdateCalendarDeltaSynchronizationToken(calendar.Id, deltaToken).ConfigureAwait(false); await _outlookChangeProcessor.UpdateCalendarDeltaSynchronizationToken(calendar.Id, deltaToken).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 _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
CaptureSynchronizationIssue(errorContext);
if (!errorContext.CanContinueSync)
throw;
}
}
// TODO: Return proper results. // TODO: Return proper results.
return CalendarSynchronizationResult.Empty; return CalendarSynchronizationResult.Empty;
+68 -17
View File
@@ -12,6 +12,7 @@ using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.Folders;
@@ -121,12 +122,15 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
/// <returns>Synchronization result that contains summary of the sync.</returns> /// <returns>Synchronization result that contains summary of the sync.</returns>
public async Task<MailSynchronizationResult> SynchronizeMailsAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default) public async Task<MailSynchronizationResult> SynchronizeMailsAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
{ {
ResetCapturedSynchronizationIssues();
List<IRequestBase> requestCopies = null;
try try
{ {
if (!ShouldQueueMailSynchronization(options)) if (!ShouldQueueMailSynchronization(options))
{ {
Log.Debug($"{options.Type} synchronization is ignored."); Log.Debug($"{options.Type} synchronization is ignored.");
return MailSynchronizationResult.Canceled; return FinalizeMailResult(MailSynchronizationResult.Canceled);
} }
var newCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var newCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
@@ -147,7 +151,7 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
List<IRequestBundle<TBaseRequest>> nativeRequests = new(); List<IRequestBundle<TBaseRequest>> nativeRequests = new();
List<IRequestBase> requestCopies = new(changeRequestQueue); requestCopies = new(changeRequestQueue);
var keys = changeRequestQueue.GroupBy(a => a.GroupingKey()); var keys = changeRequestQueue.GroupBy(a => a.GroupingKey());
@@ -226,9 +230,8 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
finally finally
{ {
UntrackProcessedRequests(requestCopies); UntrackProcessedRequests(requestCopies);
}
Messenger.Send(new SynchronizationActionsCompleted(Account.Id)); Messenger.Send(new SynchronizationActionsCompleted(Account.Id));
}
PublishUnreadItemChanges(); PublishUnreadItemChanges();
@@ -275,14 +278,19 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
{ {
newProfileInformation = await SynchronizeProfileInformationInternalAsync(); newProfileInformation = await SynchronizeProfileInformationInternalAsync();
} }
catch (AuthenticationAttentionException)
{
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
Log.Error(ex, "Failed to update profile information for {Name}", Account.Name); Log.Error(ex, "Failed to update profile information for {Name}", Account.Name);
return MailSynchronizationResult.Failed(ex); CaptureSynchronizationIssue(SynchronizationIssue.FromException(ex, "ProfileSync"));
return FinalizeMailResult(MailSynchronizationResult.Failed(ex));
} }
return MailSynchronizationResult.Completed(newProfileInformation); return FinalizeMailResult(MailSynchronizationResult.Completed(newProfileInformation));
} }
// Alias sync. // Alias sync.
@@ -294,13 +302,18 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
{ {
await SynchronizeAliasesAsync(); await SynchronizeAliasesAsync();
return MailSynchronizationResult.Empty; return FinalizeMailResult(MailSynchronizationResult.Empty);
}
catch (AuthenticationAttentionException)
{
throw;
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Error(ex, "Failed to update aliases for {Name}", Account.Name); Log.Error(ex, "Failed to update aliases for {Name}", Account.Name);
return MailSynchronizationResult.Failed(ex); CaptureSynchronizationIssue(SynchronizationIssue.FromException(ex, "AliasSync"));
return FinalizeMailResult(MailSynchronizationResult.Failed(ex));
} }
} }
@@ -314,19 +327,23 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
PublishUnreadItemChanges(); PublishUnreadItemChanges();
return synchronizationResult; return FinalizeMailResult(synchronizationResult);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
Logger.Warning("Synchronization canceled."); Logger.Warning("Synchronization canceled.");
return MailSynchronizationResult.Canceled; return FinalizeMailResult(MailSynchronizationResult.Canceled);
}
catch (AuthenticationAttentionException)
{
throw;
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.Error(ex, "Synchronization failed for {Name}", Account.Name); Logger.Error(ex, "Synchronization failed for {Name}", Account.Name);
CaptureSynchronizationIssue(SynchronizationIssue.FromException(ex, "MailSync"));
throw; return FinalizeMailResult(MailSynchronizationResult.Failed(ex));
} }
finally finally
{ {
@@ -354,6 +371,12 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Synchronization result that contains summary of the sync.</returns> /// <returns>Synchronization result that contains summary of the sync.</returns>
public async Task<CalendarSynchronizationResult> SynchronizeCalendarEventsAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default) public async Task<CalendarSynchronizationResult> SynchronizeCalendarEventsAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
{
ResetCapturedSynchronizationIssues();
List<IRequestBase> requestCopies = null;
var calendarRequestsWereExecuting = false;
try
{ {
bool shouldExecuteRequests = changeRequestQueue.Any(r => r is ICalendarActionRequest); bool shouldExecuteRequests = changeRequestQueue.Any(r => r is ICalendarActionRequest);
bool shouldDelayExecution = false; bool shouldDelayExecution = false;
@@ -361,10 +384,11 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
if (shouldExecuteRequests) if (shouldExecuteRequests)
{ {
calendarRequestsWereExecuting = true;
State = AccountSynchronizerState.ExecutingRequests; State = AccountSynchronizerState.ExecutingRequests;
List<IRequestBundle<TBaseRequest>> nativeRequests = new(); List<IRequestBundle<TBaseRequest>> nativeRequests = new();
List<IRequestBase> requestCopies = new(changeRequestQueue.Where(r => r is ICalendarActionRequest)); requestCopies = new(changeRequestQueue.Where(r => r is ICalendarActionRequest));
var keys = requestCopies.GroupBy(a => a.GroupingKey()); var keys = requestCopies.GroupBy(a => a.GroupingKey());
@@ -433,9 +457,8 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
finally finally
{ {
UntrackProcessedRequests(requestCopies); UntrackProcessedRequests(requestCopies);
}
Messenger.Send(new SynchronizationActionsCompleted(Account.Id)); Messenger.Send(new SynchronizationActionsCompleted(Account.Id));
}
// Let servers to finish their job. Sometimes the servers don't respond immediately. // Let servers to finish their job. Sometimes the servers don't respond immediately.
shouldDelayExecution = requestCopies.Any(a => a.ResynchronizationDelay > 0); shouldDelayExecution = requestCopies.Any(a => a.ResynchronizationDelay > 0);
@@ -451,8 +474,30 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
await Task.Delay(maxExecutionDelay, cancellationToken); await Task.Delay(maxExecutionDelay, cancellationToken);
} }
// Execute the actual synchronization var synchronizationResult = await SynchronizeCalendarEventsInternalAsync(options, cancellationToken);
return 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;
}
}
} }
/// <summary> /// <summary>
@@ -626,4 +671,10 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
request.Value.Dispose(); request.Value.Dispose();
} }
} }
private MailSynchronizationResult FinalizeMailResult(MailSynchronizationResult result)
=> (result ?? MailSynchronizationResult.Empty).MergeIssues(GetCapturedSynchronizationIssues());
private CalendarSynchronizationResult FinalizeCalendarResult(CalendarSynchronizationResult result)
=> (result ?? CalendarSynchronizationResult.Empty).MergeIssues(GetCapturedSynchronizationIssues());
} }
@@ -181,7 +181,7 @@ public class WinoMailCollectionTests
var updatedSecond = CloneMailCopy(second); var updatedSecond = CloneMailCopy(second);
updatedSecond.ThreadId = "shared-thread"; 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 items = FlattenItems(sut);
var threadItem = items.Should().ContainSingle().Which.Should().BeOfType<ThreadMailItemViewModel>().Subject; var threadItem = items.Should().ContainSingle().Which.Should().BeOfType<ThreadMailItemViewModel>().Subject;
@@ -201,7 +201,7 @@ public class WinoMailCollectionTests
var updatedExisting = CloneMailCopy(existing); var updatedExisting = CloneMailCopy(existing);
updatedExisting.ThreadId = "shared-thread"; updatedExisting.ThreadId = "shared-thread";
await sut.UpdateMailCopy(updatedExisting, MailUpdateSource.Server, MailCopyChangeFlags.ThreadId); await sut.UpdateMailCopy(updatedExisting, EntityUpdateSource.Server, MailCopyChangeFlags.ThreadId);
await sut.AddAsync(incoming); await sut.AddAsync(incoming);
var items = FlattenItems(sut); var items = FlattenItems(sut);
@@ -102,7 +102,7 @@ public class MailItemViewModelUpdateTests
latest.IsRead = true; 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)); raisedProperties.Should().Equal(nameof(ThreadMailItemViewModel.IsRead));
} }
@@ -530,7 +530,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
private async Task UpdateExistingItemAsync(MailItemContainer itemContainer, private async Task UpdateExistingItemAsync(MailItemContainer itemContainer,
MailCopy updatedItem, MailCopy updatedItem,
MailUpdateSource mailUpdateSource = MailUpdateSource.Server, EntityUpdateSource mailUpdateSource = EntityUpdateSource.Server,
MailCopyChangeFlags changeHint = MailCopyChangeFlags.None) MailCopyChangeFlags changeHint = MailCopyChangeFlags.None)
{ {
if (itemContainer?.ItemViewModel == null) if (itemContainer?.ItemViewModel == null)
@@ -559,7 +559,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
itemContainer.ThreadViewModel?.ResumeChildPropertyNotifications(); itemContainer.ThreadViewModel?.ResumeChildPropertyNotifications();
} }
existingItem.IsBusy = mailUpdateSource == MailUpdateSource.ClientUpdated; existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated;
UpdateUniqueIdHashes(existingItem, true); UpdateUniqueIdHashes(existingItem, true);
UpdateThreadIdCache(threadOwner, true); UpdateThreadIdCache(threadOwner, true);
@@ -849,7 +849,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
/// </summary> /// </summary>
/// <param name="updatedMailCopy">Updated mail copy.</param> /// <param name="updatedMailCopy">Updated mail copy.</param>
/// <returns></returns> /// <returns></returns>
public Task UpdateMailCopy(MailCopy updatedMailCopy, MailUpdateSource mailUpdateSource, MailCopyChangeFlags changedProperties = MailCopyChangeFlags.None) public Task UpdateMailCopy(MailCopy updatedMailCopy, EntityUpdateSource mailUpdateSource, MailCopyChangeFlags changedProperties = MailCopyChangeFlags.None)
=> RunSerializedAsync(() => => RunSerializedAsync(() =>
{ {
var itemContainer = GetMailItemContainer(updatedMailCopy.UniqueId); var itemContainer = GetMailItemContainer(updatedMailCopy.UniqueId);
+1 -1
View File
@@ -874,7 +874,7 @@ public partial class ComposePageViewModel : MailBaseViewModel,
_dialogService.InfoBarMessage(Translator.Info_InvalidAddressTitle, string.Format(Translator.Info_InvalidAddressMessage, address), InfoBarMessageType.Warning); _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); base.OnMailUpdated(updatedMail, source, changedProperties);
+5 -5
View File
@@ -20,9 +20,9 @@ public class MailBaseViewModel : CoreBaseViewModel,
IRecipient<FolderDeleted>, IRecipient<FolderDeleted>,
IRecipient<FolderSynchronizationEnabled> IRecipient<FolderSynchronizationEnabled>
{ {
protected virtual void OnMailAdded(MailCopy addedMail) { } protected virtual void OnMailAdded(MailCopy addedMail, EntityUpdateSource source) { }
protected virtual void OnMailRemoved(MailCopy removedMail) { } protected virtual void OnMailRemoved(MailCopy removedMail, EntityUpdateSource source) { }
protected virtual void OnMailUpdated(MailCopy updatedMail, MailUpdateSource source, MailCopyChangeFlags changedProperties) { } protected virtual void OnMailUpdated(MailCopy updatedMail, EntityUpdateSource source, MailCopyChangeFlags changedProperties) { }
protected virtual void OnMailDownloaded(MailCopy downloadedMail) { } protected virtual void OnMailDownloaded(MailCopy downloadedMail) { }
protected virtual void OnDraftCreated(MailCopy draftMail, MailAccount account) { } protected virtual void OnDraftCreated(MailCopy draftMail, MailAccount account) { }
protected virtual void OnDraftFailed(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 OnFolderDeleted(MailItemFolder folder) { }
protected virtual void OnFolderSynchronizationEnabled(IMailItemFolder mailItemFolder) { } protected virtual void OnFolderSynchronizationEnabled(IMailItemFolder mailItemFolder) { }
void IRecipient<MailAddedMessage>.Receive(MailAddedMessage message) => OnMailAdded(message.AddedMail); void IRecipient<MailAddedMessage>.Receive(MailAddedMessage message) => OnMailAdded(message.AddedMail, message.Source);
void IRecipient<MailRemovedMessage>.Receive(MailRemovedMessage message) => OnMailRemoved(message.RemovedMail); void IRecipient<MailRemovedMessage>.Receive(MailRemovedMessage message) => OnMailRemoved(message.RemovedMail, message.Source);
void IRecipient<MailUpdatedMessage>.Receive(MailUpdatedMessage message) => OnMailUpdated(message.UpdatedMail, message.Source, message.ChangedProperties); void IRecipient<MailUpdatedMessage>.Receive(MailUpdatedMessage message) => OnMailUpdated(message.UpdatedMail, message.Source, message.ChangedProperties);
void IRecipient<MailDownloadedMessage>.Receive(MailDownloadedMessage message) => OnMailDownloaded(message.DownloadedMail); void IRecipient<MailDownloadedMessage>.Receive(MailDownloadedMessage message) => OnMailDownloaded(message.DownloadedMail);
+15 -6
View File
@@ -745,7 +745,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
var fi = MailCollection.GetFirst(); var fi = MailCollection.GetFirst();
if (fi == null) return; if (fi == null) return;
Messenger.Send(new MailRemovedMessage(fi.MailCopy)); Messenger.Send(new MailRemovedMessage(fi.MailCopy, EntityUpdateSource.Server));
} }
/// <summary> /// <summary>
@@ -758,9 +758,9 @@ public partial class MailListPageViewModel : MailBaseViewModel,
return MailCollection.ContainsThreadId(mailItem.ThreadId); 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; 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 // AddAsync already handles UI threading internally, no need to wrap it
await MailCollection.AddAsync(addedMail); await MailCollection.AddAsync(addedMail);
if (source == EntityUpdateSource.ClientUpdated)
{
var addedContainer = MailCollection.GetMailItemContainer(addedMail.UniqueId);
if (addedContainer?.ItemViewModel != null)
{
addedContainer.ItemViewModel.IsBusy = true;
}
}
await ExecuteUIThread(() => await ExecuteUIThread(() =>
{ {
NotifyItemFoundState(); 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); base.OnMailUpdated(updatedMail, source, changedProperties);
@@ -890,9 +899,9 @@ public partial class MailListPageViewModel : MailBaseViewModel,
await ExecuteUIThread(() => { SetupTopBarActions(); }); 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; if (removedMail.AssignedAccount == null) return;
@@ -639,7 +639,7 @@ public partial class MailRenderingPageViewModel : MailBaseViewModel,
MenuItems.Add(MailOperationMenuItem.Create(MailOperation.MarkAsRead, true, false)); 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); base.OnMailUpdated(updatedMail, source, changedProperties);
+59 -10
View File
@@ -910,7 +910,7 @@ public partial class App : WinoApplication,
syncResult.CompletedState == SynchronizationCompletedState.PartiallyCompleted) syncResult.CompletedState == SynchronizationCompletedState.PartiallyCompleted)
{ {
var dialogService = Services.GetRequiredService<IMailDialogService>(); var dialogService = Services.GetRequiredService<IMailDialogService>();
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 var severity = syncResult.CompletedState == SynchronizationCompletedState.PartiallyCompleted
? InfoBarMessageType.Warning ? InfoBarMessageType.Warning
: InfoBarMessageType.Error; : InfoBarMessageType.Error;
@@ -925,18 +925,15 @@ public partial class App : WinoApplication,
var calendarSyncResult = await _synchronizationManager.SynchronizeCalendarAsync(message.Options); 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<IMailDialogService>(); var dialogService = Services.GetRequiredService<IMailDialogService>();
dialogService.InfoBarMessage( dialogService.InfoBarMessage(
Translator.Info_SyncFailedTitle, Translator.Info_SyncFailedTitle,
message.Options.Type switch GetCalendarSynchronizationFailureMessage(message.Options.Type, calendarSyncResult.AllIssues, calendarSyncResult.Exception?.Message),
{ calendarSyncResult.CompletedState == SynchronizationCompletedState.PartiallyCompleted
CalendarSynchronizationType.CalendarMetadata => Translator.Exception_FailedToSynchronizeCalendarMetadata, ? InfoBarMessageType.Warning
CalendarSynchronizationType.Strict => Translator.Exception_FailedToSynchronizeCalendarData, : InfoBarMessageType.Error);
_ => Translator.Exception_FailedToSynchronizeCalendarEvents
},
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<SynchronizationIssue> issues,
string? exceptionMessage)
{ {
var issueMessage = FormatSynchronizationIssues(issues);
if (!string.IsNullOrWhiteSpace(issueMessage))
{
return issueMessage;
}
if (!string.IsNullOrWhiteSpace(exceptionMessage)) if (!string.IsNullOrWhiteSpace(exceptionMessage))
{ {
return exceptionMessage; return exceptionMessage;
@@ -1067,6 +1073,49 @@ public partial class App : WinoApplication,
}; };
} }
private static string GetCalendarSynchronizationFailureMessage(
CalendarSynchronizationType synchronizationType,
IEnumerable<SynchronizationIssue> 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<SynchronizationIssue> 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) private void PreferencesServiceChanged(object? sender, string propertyName)
{ {
if (propertyName != nameof(IPreferencesService.EmailSyncIntervalMinutes)) if (propertyName != nameof(IPreferencesService.EmailSyncIntervalMinutes))
@@ -3,6 +3,6 @@ using Wino.Core.Domain.Enums;
namespace Wino.Messaging.Client.Calendar; namespace Wino.Messaging.Client.Calendar;
public record CalendarItemAdded(CalendarItem CalendarItem); public record CalendarItemAdded(CalendarItem CalendarItem, EntityUpdateSource Source = EntityUpdateSource.Server);
public record CalendarItemUpdated(CalendarItem CalendarItem, CalendarItemUpdateSource Source); public record CalendarItemUpdated(CalendarItem CalendarItem, EntityUpdateSource Source);
public record CalendarItemDeleted(CalendarItem CalendarItem); public record CalendarItemDeleted(CalendarItem CalendarItem, EntityUpdateSource Source = EntityUpdateSource.Server);
+2 -1
View File
@@ -1,5 +1,6 @@
using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums;
namespace Wino.Messaging.UI; namespace Wino.Messaging.UI;
public record MailAddedMessage(MailCopy AddedMail) : UIMessageBase<MailAddedMessage>; public record MailAddedMessage(MailCopy AddedMail, EntityUpdateSource Source = EntityUpdateSource.Server) : UIMessageBase<MailAddedMessage>;
+2 -1
View File
@@ -1,5 +1,6 @@
using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums;
namespace Wino.Messaging.UI; namespace Wino.Messaging.UI;
public record MailRemovedMessage(MailCopy RemovedMail) : UIMessageBase<MailRemovedMessage>; public record MailRemovedMessage(MailCopy RemovedMail, EntityUpdateSource Source = EntityUpdateSource.Server) : UIMessageBase<MailRemovedMessage>;
+1 -1
View File
@@ -3,4 +3,4 @@ using Wino.Core.Domain.Enums;
namespace Wino.Messaging.UI; namespace Wino.Messaging.UI;
public record MailUpdatedMessage(MailCopy UpdatedMail, MailUpdateSource Source, MailCopyChangeFlags ChangedProperties = MailCopyChangeFlags.None) : UIMessageBase<MailUpdatedMessage>; public record MailUpdatedMessage(MailCopy UpdatedMail, EntityUpdateSource Source, MailCopyChangeFlags ChangedProperties = MailCopyChangeFlags.None) : UIMessageBase<MailUpdatedMessage>;
+1 -1
View File
@@ -362,7 +362,7 @@ public class AccountService : BaseDatabaseService, IAccountService
foreach (var calendarItem in deletedCalendarItems) foreach (var calendarItem in deletedCalendarItems)
{ {
WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(calendarItem)); WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(calendarItem, EntityUpdateSource.Server));
} }
foreach (var accountCalendar in accountCalendars) foreach (var accountCalendar in accountCalendars)
+3 -3
View File
@@ -122,7 +122,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService
await Connection.Table<Reminder>().DeleteAsync(r => r.CalendarItemId == @event.Id).ConfigureAwait(false); await Connection.Table<Reminder>().DeleteAsync(r => r.CalendarItemId == @event.Id).ConfigureAwait(false);
await Connection.Table<CalendarAttachment>().DeleteAsync(a => a.CalendarItemId == @event.Id).ConfigureAwait(false); await Connection.Table<CalendarAttachment>().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) 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) catch (Exception ex)
{ {
+3 -3
View File
@@ -659,7 +659,7 @@ public class MailService : BaseDatabaseService, IMailService
await Connection.InsertAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false); await Connection.InsertAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false);
ReportUIChange(new MailAddedMessage(mailCopy)); ReportUIChange(new MailAddedMessage(mailCopy, EntityUpdateSource.Server));
} }
public async Task UpdateMailAsync(MailCopy mailCopy) public async Task UpdateMailAsync(MailCopy mailCopy)
@@ -675,7 +675,7 @@ public class MailService : BaseDatabaseService, IMailService
await Connection.UpdateAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false); 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) 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); await _mimeFileService.DeleteMimeMessageAsync(mailCopy.AssignedAccount.Id, mailCopy.FileId).ConfigureAwait(false);
} }
ReportUIChange(new MailRemovedMessage(mailCopy)); ReportUIChange(new MailRemovedMessage(mailCopy, EntityUpdateSource.Server));
} }
#endregion #endregion