Immidiate ui reflection for calendar events and some more error handling.
This commit is contained in:
@@ -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)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pendingCalendarItemIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyCalendarItemUpsert(CalendarItem calendarItem, EntityUpdateSource source)
|
||||||
|
{
|
||||||
|
if (calendarItem == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (calendarItem.IsRecurringParent)
|
||||||
{
|
{
|
||||||
_ = ReloadCurrentVisibleRangeAsync();
|
_ = ReloadCurrentVisibleRangeAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingItemId = FindLoadedCalendarItemId(calendarItem);
|
||||||
|
var shouldDisplay = ShouldDisplayCalendarItem(calendarItem);
|
||||||
|
|
||||||
|
if (!shouldDisplay)
|
||||||
|
{
|
||||||
|
if (existingItemId.HasValue)
|
||||||
|
{
|
||||||
|
RemoveLoadedCalendarItem(existingItemId.Value, calendarItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newViewModel = CreateCalendarItemViewModel(calendarItem, source);
|
||||||
|
|
||||||
|
if (existingItemId.HasValue)
|
||||||
|
{
|
||||||
|
ReplaceLoadedCalendarItem(existingItemId.Value, newViewModel);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
InsertLoadedCalendarItem(newViewModel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool ShouldReloadFor(CalendarItem calendarItem)
|
private CalendarItemViewModel CreateCalendarItemViewModel(CalendarItem calendarItem, EntityUpdateSource source)
|
||||||
|
=> CreateCalendarItemViewModel(
|
||||||
|
calendarItem,
|
||||||
|
source == EntityUpdateSource.ClientUpdated ? new HashSet<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
|
||||||
|
|||||||
+4
-4
@@ -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.",
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -522,74 +522,110 @@ 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)
|
||||||
{
|
{
|
||||||
var request = _calendarService.Events.List(calendar.RemoteCalendarId);
|
try
|
||||||
|
|
||||||
// Fetch individual event instances (including recurring event occurrences)
|
|
||||||
// rather than recurring event masters. This ensures we get all occurrences
|
|
||||||
// as separate events that can be stored and displayed directly.
|
|
||||||
request.SingleEvents = true;
|
|
||||||
request.ShowDeleted = true;
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(calendar.SynchronizationDeltaToken))
|
|
||||||
{
|
{
|
||||||
// If a sync token is available, perform an incremental sync
|
var request = _calendarService.Events.List(calendar.RemoteCalendarId);
|
||||||
request.SyncToken = calendar.SynchronizationDeltaToken;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// If no sync token, perform an initial sync
|
|
||||||
// Fetch events from the past year
|
|
||||||
|
|
||||||
request.TimeMinDateTimeOffset = DateTimeOffset.UtcNow.AddYears(-1);
|
// Fetch individual event instances (including recurring event occurrences)
|
||||||
}
|
// rather than recurring event masters. This ensures we get all occurrences
|
||||||
|
// as separate events that can be stored and displayed directly.
|
||||||
|
request.SingleEvents = true;
|
||||||
|
request.ShowDeleted = true;
|
||||||
|
|
||||||
string nextPageToken;
|
if (!string.IsNullOrEmpty(calendar.SynchronizationDeltaToken))
|
||||||
string syncToken;
|
|
||||||
|
|
||||||
var allEvents = new List<Event>();
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
// Execute the request
|
|
||||||
var events = await request.ExecuteAsync();
|
|
||||||
|
|
||||||
// Process the fetched events
|
|
||||||
if (events.Items != null)
|
|
||||||
{
|
{
|
||||||
allEvents.AddRange(events.Items);
|
request.SyncToken = calendar.SynchronizationDeltaToken;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
request.TimeMinDateTimeOffset = DateTimeOffset.UtcNow.AddYears(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the next page token and sync token
|
string nextPageToken;
|
||||||
nextPageToken = events.NextPageToken;
|
string syncToken;
|
||||||
syncToken = events.NextSyncToken;
|
|
||||||
|
|
||||||
// Set the next page token for subsequent requests
|
var allEvents = new List<Event>();
|
||||||
request.PageToken = nextPageToken;
|
|
||||||
|
|
||||||
} while (!string.IsNullOrEmpty(nextPageToken));
|
do
|
||||||
|
{
|
||||||
|
var events = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
calendar.SynchronizationDeltaToken = syncToken;
|
if (events.Items != null)
|
||||||
|
{
|
||||||
|
allEvents.AddRange(events.Items);
|
||||||
|
}
|
||||||
|
|
||||||
// allEvents contains new or updated events.
|
nextPageToken = events.NextPageToken;
|
||||||
// Process them and create/update local calendar items.
|
syncToken = events.NextSyncToken;
|
||||||
|
request.PageToken = nextPageToken;
|
||||||
|
}
|
||||||
|
while (!string.IsNullOrEmpty(nextPageToken));
|
||||||
|
|
||||||
var eventByRemoteId = allEvents
|
calendar.SynchronizationDeltaToken = syncToken;
|
||||||
.Where(e => !string.IsNullOrWhiteSpace(e.Id))
|
|
||||||
.GroupBy(e => e.Id, StringComparer.Ordinal)
|
|
||||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
|
||||||
|
|
||||||
foreach (var @event in OrderCalendarEventsForPersistence(allEvents))
|
var eventByRemoteId = allEvents
|
||||||
{
|
.Where(e => !string.IsNullOrWhiteSpace(e.Id))
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
.GroupBy(e => e.Id, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||||
|
|
||||||
await EnsureRecurringParentProcessedAsync(calendar, @event, eventByRemoteId, cancellationToken).ConfigureAwait(false);
|
foreach (var @event in OrderCalendarEventsForPersistence(allEvents))
|
||||||
await _gmailChangeProcessor.ManageCalendarEventAsync(@event, calendar, Account).ConfigureAwait(false);
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await EnsureRecurringParentProcessedAsync(calendar, @event, eventByRemoteId, cancellationToken).ConfigureAwait(false);
|
||||||
|
await _gmailChangeProcessor.ManageCalendarEventAsync(@event, calendar, Account).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var errorContext = new SynchronizerErrorContext
|
||||||
|
{
|
||||||
|
Account = Account,
|
||||||
|
ErrorMessage = ex.Message,
|
||||||
|
Exception = ex,
|
||||||
|
CalendarId = calendar.Id,
|
||||||
|
CalendarName = calendar.Name,
|
||||||
|
OperationType = "CalendarEventSync",
|
||||||
|
Severity = SynchronizerErrorSeverity.Recoverable
|
||||||
|
};
|
||||||
|
|
||||||
|
_ = await _gmailSynchronizerErrorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
|
||||||
|
CaptureSynchronizationIssue(errorContext);
|
||||||
|
_logger.Error(ex, "Failed to process Gmail event {EventId} for calendar {CalendarName}", @event.Id, calendar.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _gmailChangeProcessor.UpdateAccountCalendarAsync(calendar).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var errorContext = new SynchronizerErrorContext
|
||||||
|
{
|
||||||
|
Account = Account,
|
||||||
|
ErrorMessage = ex.Message,
|
||||||
|
Exception = ex,
|
||||||
|
CalendarId = calendar.Id,
|
||||||
|
CalendarName = calendar.Name,
|
||||||
|
OperationType = "CalendarSync"
|
||||||
|
};
|
||||||
|
|
||||||
await _gmailChangeProcessor.UpdateAccountCalendarAsync(calendar).ConfigureAwait(false);
|
_ = await _gmailSynchronizerErrorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
|
||||||
|
CaptureSynchronizationIssue(errorContext);
|
||||||
|
|
||||||
|
if (!errorContext.CanContinueSync)
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return CalendarSynchronizationResult.Empty;
|
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();
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -2266,75 +2268,77 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|
|
||||||
foreach (var calendar in localCalendars)
|
foreach (var calendar in localCalendars)
|
||||||
{
|
{
|
||||||
bool isInitialSync = string.IsNullOrEmpty(calendar.SynchronizationDeltaToken);
|
try
|
||||||
|
|
||||||
if (isInitialSync)
|
|
||||||
{
|
{
|
||||||
_logger.Information("No calendar sync identifier for calendar {Name}. Performing initial sync.", calendar.Name);
|
bool isInitialSync = string.IsNullOrEmpty(calendar.SynchronizationDeltaToken);
|
||||||
|
|
||||||
// ISO 8601 format as expected by Microsoft Graph API (e.g., "2019-11-08T19:00:00-08:00")
|
if (isInitialSync)
|
||||||
var startDate = DateTimeOffset.Now.AddYears(-2).ToString("yyyy-MM-ddTHH:mm:sszzz");
|
|
||||||
var endDate = DateTimeOffset.Now.AddYears(2).ToString("yyyy-MM-ddTHH:mm:sszzz");
|
|
||||||
|
|
||||||
// Get Id only. We will always download the full event.
|
|
||||||
eventsDeltaResponse = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].CalendarView.Delta.GetAsDeltaGetResponseAsync((requestConfiguration) =>
|
|
||||||
{
|
{
|
||||||
requestConfiguration.QueryParameters.Select = ["id", "type"];
|
_logger.Information("No calendar sync identifier for calendar {Name}. Performing initial sync.", calendar.Name);
|
||||||
requestConfiguration.QueryParameters.StartDateTime = startDate;
|
|
||||||
requestConfiguration.QueryParameters.EndDateTime = endDate;
|
|
||||||
}, cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var currentDeltaToken = calendar.SynchronizationDeltaToken;
|
|
||||||
|
|
||||||
_logger.Information("Performing delta sync for calendar {Name}.", calendar.Name);
|
// ISO 8601 format as expected by Microsoft Graph API (e.g., "2019-11-08T19:00:00-08:00")
|
||||||
|
var startDate = DateTimeOffset.Now.AddYears(-2).ToString("yyyy-MM-ddTHH:mm:sszzz");
|
||||||
|
var endDate = DateTimeOffset.Now.AddYears(2).ToString("yyyy-MM-ddTHH:mm:sszzz");
|
||||||
|
|
||||||
var requestInformation = _graphClient.Me.Calendars[calendar.RemoteCalendarId].CalendarView.Delta.ToGetRequestInformation();
|
// Get Id only. We will always download the full event.
|
||||||
|
eventsDeltaResponse = await _graphClient.Me.Calendars[calendar.RemoteCalendarId].CalendarView.Delta.GetAsDeltaGetResponseAsync((requestConfiguration) =>
|
||||||
requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken");
|
{
|
||||||
requestInformation.QueryParameters.Add("%24deltatoken", currentDeltaToken);
|
requestConfiguration.QueryParameters.Select = ["id", "type"];
|
||||||
|
requestConfiguration.QueryParameters.StartDateTime = startDate;
|
||||||
eventsDeltaResponse = await _graphClient.RequestAdapter.SendAsync(requestInformation, Microsoft.Graph.Me.Calendars.Item.CalendarView.Delta.DeltaGetResponse.CreateFromDiscriminatorValue);
|
requestConfiguration.QueryParameters.EndDateTime = endDate;
|
||||||
}
|
}, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
List<Event> events = new();
|
|
||||||
|
|
||||||
// We must first save the parent recurring events to not lose exceptions.
|
|
||||||
// Therefore, order the existing items by their type and save the parent recurring events first.
|
|
||||||
|
|
||||||
var messageIteratorAsync = PageIterator<Event, Microsoft.Graph.Me.Calendars.Item.CalendarView.Delta.DeltaGetResponse>.CreatePageIterator(_graphClient, eventsDeltaResponse, (item) =>
|
|
||||||
{
|
|
||||||
// Include all event types: SingleInstance, SeriesMaster, Occurrence, and Exception
|
|
||||||
// CalendarView already expands recurring events into individual occurrences
|
|
||||||
events.Add(item);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
await messageIteratorAsync
|
|
||||||
.IterateAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
// Desc-order will move parent recurring events to the top.
|
|
||||||
events = events.OrderByDescending(a => a.Type).ToList();
|
|
||||||
|
|
||||||
_logger.Information("Found {Count} events in total.", events.Count);
|
|
||||||
|
|
||||||
foreach (var item in events)
|
|
||||||
{
|
|
||||||
// Declined events are returned as Deleted from the API.
|
|
||||||
// There is no way to distinguish unfortunately atm.
|
|
||||||
|
|
||||||
if (IsResourceDeleted(item.AdditionalData))
|
|
||||||
{
|
|
||||||
await _outlookChangeProcessor.DeleteCalendarItemAsync(item.Id, calendar.Id).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
var currentDeltaToken = calendar.SynchronizationDeltaToken;
|
||||||
|
|
||||||
|
_logger.Information("Performing delta sync for calendar {Name}.", calendar.Name);
|
||||||
|
|
||||||
|
var requestInformation = _graphClient.Me.Calendars[calendar.RemoteCalendarId].CalendarView.Delta.ToGetRequestInformation();
|
||||||
|
|
||||||
|
requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken");
|
||||||
|
requestInformation.QueryParameters.Add("%24deltatoken", currentDeltaToken);
|
||||||
|
|
||||||
|
eventsDeltaResponse = await _graphClient.RequestAdapter.SendAsync(requestInformation, Microsoft.Graph.Me.Calendars.Item.CalendarView.Delta.DeltaGetResponse.CreateFromDiscriminatorValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Event> events = new();
|
||||||
|
|
||||||
|
// We must first save the parent recurring events to not lose exceptions.
|
||||||
|
// Therefore, order the existing items by their type and save the parent recurring events first.
|
||||||
|
|
||||||
|
var messageIteratorAsync = PageIterator<Event, Microsoft.Graph.Me.Calendars.Item.CalendarView.Delta.DeltaGetResponse>.CreatePageIterator(_graphClient, eventsDeltaResponse, (item) =>
|
||||||
|
{
|
||||||
|
// Include all event types: SingleInstance, SeriesMaster, Occurrence, and Exception
|
||||||
|
// CalendarView already expands recurring events into individual occurrences
|
||||||
|
events.Add(item);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await messageIteratorAsync
|
||||||
|
.IterateAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Desc-order will move parent recurring events to the top.
|
||||||
|
events = events.OrderByDescending(a => a.Type).ToList();
|
||||||
|
|
||||||
|
_logger.Information("Found {Count} events in total.", events.Count);
|
||||||
|
|
||||||
|
foreach (var item in events)
|
||||||
|
{
|
||||||
|
// Declined events are returned as Deleted from the API.
|
||||||
|
// There is no way to distinguish unfortunately atm.
|
||||||
|
|
||||||
|
if (IsResourceDeleted(item.AdditionalData))
|
||||||
|
{
|
||||||
|
await _outlookChangeProcessor.DeleteCalendarItemAsync(item.Id, calendar.Id).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
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,18 +2374,40 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
_handleCalendarEventRetrievalSemaphore.Release();
|
_handleCalendarEventRetrievalSemaphore.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var latestDeltaLink = messageIteratorAsync.Deltalink;
|
||||||
|
|
||||||
|
//Store delta link for tracking new changes.
|
||||||
|
if (!string.IsNullOrEmpty(latestDeltaLink))
|
||||||
|
{
|
||||||
|
// Parse Delta Token from Delta Link since v5 of Graph SDK works based on the token, not the link.
|
||||||
|
|
||||||
|
var deltaToken = GetDeltaTokenFromDeltaLink(latestDeltaLink);
|
||||||
|
|
||||||
|
await _outlookChangeProcessor.UpdateCalendarDeltaSynchronizationToken(calendar.Id, deltaToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
var latestDeltaLink = messageIteratorAsync.Deltalink;
|
|
||||||
|
|
||||||
//Store delta link for tracking new changes.
|
|
||||||
if (!string.IsNullOrEmpty(latestDeltaLink))
|
|
||||||
{
|
{
|
||||||
// Parse Delta Token from Delta Link since v5 of Graph SDK works based on the token, not the link.
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var errorContext = new SynchronizerErrorContext
|
||||||
|
{
|
||||||
|
Account = Account,
|
||||||
|
ErrorMessage = ex.Message,
|
||||||
|
Exception = ex,
|
||||||
|
CalendarId = calendar.Id,
|
||||||
|
CalendarName = calendar.Name,
|
||||||
|
OperationType = "CalendarSync"
|
||||||
|
};
|
||||||
|
|
||||||
var deltaToken = GetDeltaTokenFromDeltaLink(latestDeltaLink);
|
_ = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
|
||||||
|
CaptureSynchronizationIssue(errorContext);
|
||||||
|
|
||||||
await _outlookChangeProcessor.UpdateCalendarDeltaSynchronizationToken(calendar.Id, deltaToken).ConfigureAwait(false);
|
if (!errorContext.CanContinueSync)
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 +230,9 @@ 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();
|
||||||
|
|
||||||
// Execute request sync options should be re-calculated after execution.
|
// Execute request sync options should be re-calculated after execution.
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -355,104 +372,132 @@ 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<CalendarSynchronizationResult> SynchronizeCalendarEventsAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
|
public async Task<CalendarSynchronizationResult> SynchronizeCalendarEventsAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
bool shouldExecuteRequests = changeRequestQueue.Any(r => r is ICalendarActionRequest);
|
ResetCapturedSynchronizationIssues();
|
||||||
bool shouldDelayExecution = false;
|
List<IRequestBase> requestCopies = null;
|
||||||
int maxExecutionDelay = 0;
|
var calendarRequestsWereExecuting = false;
|
||||||
|
|
||||||
if (shouldExecuteRequests)
|
try
|
||||||
{
|
{
|
||||||
State = AccountSynchronizerState.ExecutingRequests;
|
bool shouldExecuteRequests = changeRequestQueue.Any(r => r is ICalendarActionRequest);
|
||||||
|
bool shouldDelayExecution = false;
|
||||||
|
int maxExecutionDelay = 0;
|
||||||
|
|
||||||
List<IRequestBundle<TBaseRequest>> nativeRequests = new();
|
if (shouldExecuteRequests)
|
||||||
List<IRequestBase> requestCopies = new(changeRequestQueue.Where(r => r is ICalendarActionRequest));
|
|
||||||
|
|
||||||
var keys = requestCopies.GroupBy(a => a.GroupingKey());
|
|
||||||
|
|
||||||
foreach (var group in keys)
|
|
||||||
{
|
{
|
||||||
var key = group.Key;
|
calendarRequestsWereExecuting = true;
|
||||||
|
State = AccountSynchronizerState.ExecutingRequests;
|
||||||
|
|
||||||
if (key is CalendarSynchronizerOperation calendarSynchronizerOperation)
|
List<IRequestBundle<TBaseRequest>> nativeRequests = new();
|
||||||
|
requestCopies = new(changeRequestQueue.Where(r => r is ICalendarActionRequest));
|
||||||
|
|
||||||
|
var keys = requestCopies.GroupBy(a => a.GroupingKey());
|
||||||
|
|
||||||
|
foreach (var group in keys)
|
||||||
{
|
{
|
||||||
switch (calendarSynchronizerOperation)
|
var key = group.Key;
|
||||||
|
|
||||||
|
if (key is CalendarSynchronizerOperation calendarSynchronizerOperation)
|
||||||
{
|
{
|
||||||
case CalendarSynchronizerOperation.CreateEvent:
|
switch (calendarSynchronizerOperation)
|
||||||
nativeRequests.AddRange(group
|
{
|
||||||
.OfType<CreateCalendarEventRequest>()
|
case CalendarSynchronizerOperation.CreateEvent:
|
||||||
.SelectMany(CreateCalendarEvent));
|
|
||||||
break;
|
|
||||||
case CalendarSynchronizerOperation.AcceptEvent:
|
|
||||||
nativeRequests.AddRange(group
|
|
||||||
.OfType<AcceptEventRequest>()
|
|
||||||
.SelectMany(AcceptEvent));
|
|
||||||
break;
|
|
||||||
case CalendarSynchronizerOperation.DeclineEvent:
|
|
||||||
if (Account.ProviderType == MailProviderType.Outlook)
|
|
||||||
{
|
|
||||||
nativeRequests.AddRange(group
|
nativeRequests.AddRange(group
|
||||||
.OfType<OutlookDeclineEventRequest>()
|
.OfType<CreateCalendarEventRequest>()
|
||||||
.SelectMany(OutlookDeclineEvent));
|
.SelectMany(CreateCalendarEvent));
|
||||||
}
|
break;
|
||||||
else
|
case CalendarSynchronizerOperation.AcceptEvent:
|
||||||
{
|
|
||||||
nativeRequests.AddRange(group
|
nativeRequests.AddRange(group
|
||||||
.OfType<DeclineEventRequest>()
|
.OfType<AcceptEventRequest>()
|
||||||
.SelectMany(DeclineEvent));
|
.SelectMany(AcceptEvent));
|
||||||
}
|
break;
|
||||||
break;
|
case CalendarSynchronizerOperation.DeclineEvent:
|
||||||
case CalendarSynchronizerOperation.TentativeEvent:
|
if (Account.ProviderType == MailProviderType.Outlook)
|
||||||
nativeRequests.AddRange(group
|
{
|
||||||
.OfType<TentativeEventRequest>()
|
nativeRequests.AddRange(group
|
||||||
.SelectMany(TentativeEvent));
|
.OfType<OutlookDeclineEventRequest>()
|
||||||
break;
|
.SelectMany(OutlookDeclineEvent));
|
||||||
case CalendarSynchronizerOperation.UpdateEvent:
|
}
|
||||||
nativeRequests.AddRange(group
|
else
|
||||||
.OfType<UpdateCalendarEventRequest>()
|
{
|
||||||
.SelectMany(UpdateCalendarEvent));
|
nativeRequests.AddRange(group
|
||||||
break;
|
.OfType<DeclineEventRequest>()
|
||||||
case CalendarSynchronizerOperation.DeleteEvent:
|
.SelectMany(DeclineEvent));
|
||||||
nativeRequests.AddRange(group
|
}
|
||||||
.OfType<DeleteCalendarEventRequest>()
|
break;
|
||||||
.SelectMany(DeleteCalendarEvent));
|
case CalendarSynchronizerOperation.TentativeEvent:
|
||||||
break;
|
nativeRequests.AddRange(group
|
||||||
default:
|
.OfType<TentativeEventRequest>()
|
||||||
break;
|
.SelectMany(TentativeEvent));
|
||||||
|
break;
|
||||||
|
case CalendarSynchronizerOperation.UpdateEvent:
|
||||||
|
nativeRequests.AddRange(group
|
||||||
|
.OfType<UpdateCalendarEventRequest>()
|
||||||
|
.SelectMany(UpdateCalendarEvent));
|
||||||
|
break;
|
||||||
|
case CalendarSynchronizerOperation.DeleteEvent:
|
||||||
|
nativeRequests.AddRange(group
|
||||||
|
.OfType<DeleteCalendarEventRequest>()
|
||||||
|
.SelectMany(DeleteCalendarEvent));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove processed calendar requests from queue
|
||||||
|
changeRequestQueue.RemoveAll(r => r is ICalendarActionRequest);
|
||||||
|
|
||||||
|
Console.WriteLine($"Prepared {nativeRequests.Count()} native calendar requests");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ExecuteNativeRequestsAsync(nativeRequests, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
UntrackProcessedRequests(requestCopies);
|
||||||
|
Messenger.Send(new SynchronizationActionsCompleted(Account.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let servers to finish their job. Sometimes the servers don't respond immediately.
|
||||||
|
shouldDelayExecution = requestCopies.Any(a => a.ResynchronizationDelay > 0);
|
||||||
|
|
||||||
|
if (shouldDelayExecution)
|
||||||
|
{
|
||||||
|
maxExecutionDelay = requestCopies.Aggregate(0, (max, next) => Math.Max(max, next.ResynchronizationDelay));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove processed calendar requests from queue
|
|
||||||
changeRequestQueue.RemoveAll(r => r is ICalendarActionRequest);
|
|
||||||
|
|
||||||
Console.WriteLine($"Prepared {nativeRequests.Count()} native calendar requests");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await ExecuteNativeRequestsAsync(nativeRequests, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
UntrackProcessedRequests(requestCopies);
|
|
||||||
}
|
|
||||||
|
|
||||||
Messenger.Send(new SynchronizationActionsCompleted(Account.Id));
|
|
||||||
|
|
||||||
// Let servers to finish their job. Sometimes the servers don't respond immediately.
|
|
||||||
shouldDelayExecution = requestCopies.Any(a => a.ResynchronizationDelay > 0);
|
|
||||||
|
|
||||||
if (shouldDelayExecution)
|
if (shouldDelayExecution)
|
||||||
{
|
{
|
||||||
maxExecutionDelay = requestCopies.Aggregate(0, (max, next) => Math.Max(max, next.ResynchronizationDelay));
|
await Task.Delay(maxExecutionDelay, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var synchronizationResult = await SynchronizeCalendarEventsInternalAsync(options, cancellationToken);
|
||||||
|
return FinalizeCalendarResult(synchronizationResult);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return FinalizeCalendarResult(CalendarSynchronizationResult.Canceled);
|
||||||
|
}
|
||||||
|
catch (AuthenticationAttentionException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
CaptureSynchronizationIssue(SynchronizationIssue.FromException(ex, "CalendarSync"));
|
||||||
|
return FinalizeCalendarResult(CalendarSynchronizationResult.Failed(ex));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (calendarRequestsWereExecuting && State == AccountSynchronizerState.ExecutingRequests)
|
||||||
|
{
|
||||||
|
ResetSyncProgress();
|
||||||
|
State = AccountSynchronizerState.Idle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldDelayExecution)
|
|
||||||
{
|
|
||||||
await Task.Delay(maxExecutionDelay, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the actual synchronization
|
|
||||||
return await SynchronizeCalendarEventsInternalAsync(options, cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user