Synchronizing calendars for gmail and some events.

This commit is contained in:
Burak Kaan Köse
2024-12-27 00:18:46 +01:00
parent 1668dfcce6
commit fbc3ca4517
44 changed files with 1030 additions and 320 deletions

View File

@@ -125,7 +125,7 @@ namespace Wino.Calendar.ViewModels
Type = MailSynchronizationType.UpdateProfile
};
var profileSynchronizationResponse = await WinoServerConnectionManager.GetResponseAsync<MailSynchronizationResult, NewSynchronizationRequested>(new NewSynchronizationRequested(profileSyncOptions, SynchronizationSource.Client));
var profileSynchronizationResponse = await WinoServerConnectionManager.GetResponseAsync<MailSynchronizationResult, NewMailSynchronizationRequested>(new NewMailSynchronizationRequested(profileSyncOptions, SynchronizationSource.Client));
var profileSynchronizationResult = profileSynchronizationResponse.Data;
@@ -141,11 +141,13 @@ namespace Wino.Calendar.ViewModels
accountCreationDialog.State = AccountCreationDialogState.FetchingEvents;
// Start synchronizing events.
var eventsSyncOptions = new MailSynchronizationOptions()
var synchronizationOptions = new CalendarSynchronizationOptions()
{
AccountId = createdAccount.Id,
Type = MailSynchronizationType.Events
Type = CalendarSynchronizationType.CalendarMetadata
};
var synchronizationResponse = await WinoServerConnectionManager.GetResponseAsync<CalendarSynchronizationResult, NewCalendarSynchronizationRequested>(new NewCalendarSynchronizationRequested(synchronizationOptions, SynchronizationSource.Client));
}
}
}

View File

@@ -1,6 +1,5 @@
using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -8,13 +7,14 @@ using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain.Collections;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.MenuItems;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Extensions;
using Wino.Core.ViewModels;
using Wino.Messaging.Client.Calendar;
using Wino.Messaging.Client.Navigation;
using Wino.Messaging.Server;
namespace Wino.Calendar.ViewModels
{
@@ -22,18 +22,17 @@ namespace Wino.Calendar.ViewModels
IRecipient<VisibleDateRangeChangedMessage>,
IRecipient<CalendarEnableStatusChangedMessage>,
IRecipient<CalendarInitializedMessage>,
IRecipient<NavigateManageAccountsRequested>
IRecipient<NavigateManageAccountsRequested>,
IRecipient<GoToCalendarDayMessage>
{
public event EventHandler<CalendarDisplayType> DisplayTypeChanged;
public IPreferencesService PreferencesService { get; }
public IStatePersistanceService StatePersistenceService { get; }
public INavigationService NavigationService { get; }
public IWinoServerConnectionManager ServerConnectionManager { get; }
public MenuItemCollection FooterItems { get; set; }
public MenuItemCollection MenuItems { get; set; }
[ObservableProperty]
private IMenuItem _selectedMenuItem;
private int _selectedMenuItemIndex = -1;
[ObservableProperty]
private bool isCalendarEnabled;
@@ -62,10 +61,13 @@ namespace Wino.Calendar.ViewModels
[ObservableProperty]
private int _selectedDateNavigationHeaderIndex;
private readonly IAccountService _accountService;
public bool IsVerticalCalendar => StatePersistenceService.CalendarDisplayType == CalendarDisplayType.Month;
public AppShellViewModel(IPreferencesService preferencesService,
IStatePersistanceService statePersistanceService,
IAccountService accountService,
INavigationService navigationService,
IWinoServerConnectionManager serverConnectionManager)
{
@@ -74,6 +76,7 @@ namespace Wino.Calendar.ViewModels
PreferencesService = preferencesService;
StatePersistenceService = statePersistanceService;
_accountService = accountService;
StatePersistenceService.StatePropertyChanged += PrefefencesChanged;
}
@@ -93,20 +96,37 @@ namespace Wino.Calendar.ViewModels
{
base.OnNavigatedTo(mode, parameters);
CreateFooterItems();
UpdateDateNavigationHeaderItems();
}
partial void OnSelectedMenuItemChanged(IMenuItem oldValue, IMenuItem newValue)
partial void OnSelectedMenuItemIndexChanged(int oldValue, int newValue)
{
if (newValue is SettingsItem)
switch (newValue)
{
NavigationService.Navigate(WinoPage.SettingsPage);
case -1:
NavigationService.Navigate(WinoPage.CalendarPage);
break;
case 0:
NavigationService.Navigate(WinoPage.AccountManagementPage);
break;
case 1:
NavigationService.Navigate(WinoPage.SettingsPage);
break;
default:
break;
}
else if (newValue is ManageAccountsMenuItem)
}
[RelayCommand]
private void Sync()
{
var t = new NewCalendarSynchronizationRequested(new CalendarSynchronizationOptions()
{
NavigationService.Navigate(WinoPage.AccountManagementPage);
}
AccountId = Guid.Parse("52fae547-0740-4aa3-8d51-519bd31278ca"),
Type = CalendarSynchronizationType.CalendarMetadata
}, SynchronizationSource.Client);
Messenger.Send(t);
}
/// <summary>
@@ -142,14 +162,6 @@ namespace Wino.Calendar.ViewModels
return DateTime.Today.Date;
}
protected override void OnDispatcherAssigned()
{
base.OnDispatcherAssigned();
MenuItems = new MenuItemCollection(Dispatcher);
FooterItems = new MenuItemCollection(Dispatcher);
}
public override void OnPageLoaded()
{
base.OnPageLoaded();
@@ -160,13 +172,6 @@ namespace Wino.Calendar.ViewModels
});
}
private void CreateFooterItems()
{
FooterItems.Clear();
FooterItems.Add(new ManageAccountsMenuItem());
FooterItems.Add(new SettingsItem());
}
#region Commands
[RelayCommand]
@@ -236,6 +241,8 @@ namespace Wino.Calendar.ViewModels
// Calendar page is loaded and calendar is ready to recieve render requests.
public void Receive(CalendarInitializedMessage message) => Messenger.Send(new GoToCalendarDayMessage(DateTime.Now.Date));
public void Receive(NavigateManageAccountsRequested message) => SelectedMenuItem = FooterItems.FirstOrDefault(a => a is ManageAccountsMenuItem);
public void Receive(NavigateManageAccountsRequested message) => SelectedMenuItemIndex = 1;
public void Receive(GoToCalendarDayMessage message) => SelectedMenuItemIndex = -1;
}
}

View File

@@ -375,62 +375,62 @@ namespace Wino.Calendar.ViewModels
base.OnCalendarEventAdded(calendarItem);
// Test
var eventDays = DayRanges.SelectMany(a => a.CalendarDays).Where(b => b.Period.Start.Date == calendarItem.StartTime.Date);
//var eventDays = DayRanges.SelectMany(a => a.CalendarDays).Where(b => b.Period.Start.Date == calendarItem.StartTime.Date);
var beforeAllDay = new CalendarItem(calendarItem.StartTime.Date.AddHours(0), calendarItem.StartTime.Date.AddMinutes(30))
{
Title = "kj"
};
//var beforeAllDay = new CalendarItem(calendarItem.StartTime.Date.AddHours(0), calendarItem.StartTime.Date.AddMinutes(30))
//{
// Title = "kj"
//};
var allday = new CalendarItem(calendarItem.StartTime.Date.AddHours(1), calendarItem.StartTime.Date.AddHours(10).AddMinutes(59))
{
Title = "All day"
};
//var allday = new CalendarItem(calendarItem.StartTime.Date.AddHours(1), calendarItem.StartTime.Date.AddHours(10).AddMinutes(59))
//{
// Title = "All day"
//};
var test = new CalendarItem(calendarItem.StartTime.Date.AddHours(4), calendarItem.StartTime.Date.AddHours(4).AddMinutes(30))
{
Title = "test"
};
//var test = new CalendarItem(calendarItem.StartTime.Date.AddHours(4), calendarItem.StartTime.Date.AddHours(4).AddMinutes(30))
//{
// Title = "test"
//};
var hour = new CalendarItem(calendarItem.StartTime.Date.AddHours(7), calendarItem.StartTime.Date.AddHours(8))
{
Title = "1 h"
};
//var hour = new CalendarItem(calendarItem.StartTime.Date.AddHours(7), calendarItem.StartTime.Date.AddHours(8))
//{
// Title = "1 h"
//};
var hourandhalf = new CalendarItem(calendarItem.StartTime.Date.AddHours(7), calendarItem.StartTime.Date.AddHours(8).AddMinutes(30))
{
Title = "1.5 h"
};
var halfhour1 = new CalendarItem(calendarItem.StartTime.Date.AddHours(7), calendarItem.StartTime.Date.AddHours(7).AddMinutes(30))
{
Title = "30 min"
};
//var hourandhalf = new CalendarItem(calendarItem.StartTime.Date.AddHours(7), calendarItem.StartTime.Date.AddHours(8).AddMinutes(30))
//{
// Title = "1.5 h"
//};
//var halfhour1 = new CalendarItem(calendarItem.StartTime.Date.AddHours(7), calendarItem.StartTime.Date.AddHours(7).AddMinutes(30))
//{
// Title = "30 min"
//};
var halfhour2 = new CalendarItem(calendarItem.StartTime.Date.AddHours(7).AddMinutes(30), calendarItem.StartTime.Date.AddHours(8))
{
Title = "30 min"
};
var halfhour3 = new CalendarItem(calendarItem.StartTime.Date.AddHours(8), calendarItem.StartTime.Date.AddHours(8).AddMinutes(30))
{
Title = "30 min"
};
//var halfhour2 = new CalendarItem(calendarItem.StartTime.Date.AddHours(7).AddMinutes(30), calendarItem.StartTime.Date.AddHours(8))
//{
// Title = "30 min"
//};
//var halfhour3 = new CalendarItem(calendarItem.StartTime.Date.AddHours(8), calendarItem.StartTime.Date.AddHours(8).AddMinutes(30))
//{
// Title = "30 min"
//};
foreach (var day in eventDays)
{
await ExecuteUIThread(() =>
{
day.Events.Add(beforeAllDay);
day.Events.Add(allday);
day.Events.Add(hourandhalf);
day.Events.Add(hour);
day.Events.Add(halfhour1);
day.Events.Add(halfhour2);
day.Events.Add(halfhour3);
day.Events.Add(test);
});
}
//foreach (var day in eventDays)
//{
// await ExecuteUIThread(() =>
// {
// day.Events.Add(beforeAllDay);
// day.Events.Add(allday);
// day.Events.Add(hourandhalf);
// day.Events.Add(hour);
// day.Events.Add(halfhour1);
// day.Events.Add(halfhour2);
// day.Events.Add(halfhour3);
// day.Events.Add(test);
// });
//}
return;
//return;
}
}
}

View File

@@ -12,7 +12,11 @@ using Wino.Activation;
using Wino.Calendar.Activation;
using Wino.Calendar.Services;
using Wino.Calendar.ViewModels;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.UWP;
using Wino.Messaging.Client.Connection;
using Wino.Messaging.Server;
@@ -20,7 +24,7 @@ using Wino.Services;
namespace Wino.Calendar
{
public sealed partial class App : WinoApplication, IRecipient<NewSynchronizationRequested>
public sealed partial class App : WinoApplication, IRecipient<NewCalendarSynchronizationRequested>
{
public override string AppCenterKey => "dfdad6ab-95f9-44cc-9112-45ec6730c49e";
@@ -101,13 +105,6 @@ namespace Wino.Calendar
protected override ActivationHandler<IActivatedEventArgs> GetDefaultActivationHandler()
=> new DefaultActivationHandler();
public void Receive(NewSynchronizationRequested message)
{
}
protected override void OnBackgroundActivated(BackgroundActivatedEventArgs args)
{
base.OnBackgroundActivated(args);
@@ -142,5 +139,20 @@ namespace Wino.Calendar
AppServiceConnectionManager.Connection = null;
}
public async void Receive(NewCalendarSynchronizationRequested message)
{
try
{
var synchronizationResultResponse = await AppServiceConnectionManager.GetResponseAsync<CalendarSynchronizationResult, NewCalendarSynchronizationRequested>(message);
synchronizationResultResponse.ThrowIfFailed();
}
catch (WinoServerException serverException)
{
var dialogService = Services.GetService<ICalendarDialogService>();
dialogService.InfoBarMessage(Translator.Info_SyncFailedTitle, serverException.Message, InfoBarMessageType.Error);
}
}
}
}

View File

@@ -129,7 +129,9 @@ namespace Wino.Calendar.Controls
var childMeasurement = _measurements[child.Item.Id];
double childHeight = Math.Max(0, GetChildHeight(child.Item.StartTime, child.Item.EndTime));
// TODO Math.Max(0, GetChildHeight(child.Item.StartTime, child.Item.EndTime));
// Recurring events may not have an end time. We need to calculate the height based on the start time and duration.
double childHeight = 50;
double childWidth = Math.Max(0, GetChildWidth(childMeasurement, finalSize.Width));
double childTop = Math.Max(0, GetChildTopMargin(child.Item.StartTime, availableHeight));
double childLeft = Math.Max(0, GetChildLeftMargin(childMeasurement, availableWidth));

View File

@@ -19,7 +19,7 @@
<SolidColorBrush x:Key="CalendarFieldSelectedBackgroundBrush">#121212</SolidColorBrush>
<SolidColorBrush x:Key="WinoCalendarViewBorderBrush">#3d3d3d</SolidColorBrush>
<SolidColorBrush x:Key="WinoCalendarViewVisibleDayBackgroundBrush">#30336b</SolidColorBrush>
<SolidColorBrush x:Key="WinoCalendarViewVisibleDayBackgroundBrush">#4b4b4b</SolidColorBrush>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,6 @@ using Microsoft.UI.Xaml.Controls;
using Windows.UI.Xaml.Controls;
using Wino.Calendar.Args;
using Wino.Calendar.Views.Abstract;
using Wino.Core.Domain.Models.Calendar;
using Wino.Messaging.Client.Calendar;
namespace Wino.Calendar.Views
@@ -30,24 +29,15 @@ namespace Wino.Calendar.Views
{
selectedDateTime = e.ClickedDate;
// TODO: Popup is not positioned well on daily view.
TeachingTipPositionerGrid.Width = e.CellSize.Width;
TeachingTipPositionerGrid.Height = e.CellSize.Height;
Canvas.SetLeft(TeachingTipPositionerGrid, e.PositionerPoint.X);
Canvas.SetTop(TeachingTipPositionerGrid, e.PositionerPoint.Y);
WeakReferenceMessenger.Default.Send(new CalendarEventAdded(new CalendarItem(selectedDateTime.Value, default)));
//var t = new Flyout()
//{
// Content = new TextBlock() { Text = "Create event" }
//};
//t.ShowAt(TeachingTipPositionerGrid, new FlyoutShowOptions()
//{
// ShowMode = FlyoutShowMode.Transient,
// Placement = FlyoutPlacementMode.Right
//});
// TODO: End time can be from settings.
// WeakReferenceMessenger.Default.Send(new CalendarEventAdded(new CalendarItem(selectedDateTime.Value, selectedDateTime.Value.AddMinutes(30))));
NewEventTip.IsOpen = true;
}
@@ -71,7 +61,7 @@ namespace Wino.Calendar.Views
var eventEndDate = selectedDateTime.Value.Add(EventTimePicker.Time);
// Create the event.
WeakReferenceMessenger.Default.Send(new CalendarEventAdded(new CalendarItem(selectedDateTime.Value, eventEndDate)));
// WeakReferenceMessenger.Default.Send(new CalendarEventAdded(new CalendarItem(selectedDateTime.Value, eventEndDate)));
}
}
}

View File

@@ -7,10 +7,12 @@ namespace Wino.Core.Domain.Entities.Calendar
{
[PrimaryKey]
public Guid Id { get; set; }
public string RemoteCalendarId { get; set; }
public string SynchronizationDeltaToken { get; set; }
public Guid AccountId { get; set; }
public string Name { get; set; }
public string ColorHex { get; set; }
public string TimeZoneId { get; set; }
public string TimeZone { get; set; }
public bool IsPrimary { get; set; }
}
}

View File

@@ -1,16 +0,0 @@
using System;
using SQLite;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Entities.Calendar
{
public class Attendee
{
[PrimaryKey]
public Guid Id { get; set; }
public Guid EventId { get; set; }
public Guid ContactId { get; set; }
public AttendeeStatus Status { get; set; }
public bool IsOrganizer { get; set; }
}
}

View File

@@ -0,0 +1,20 @@
using System;
using SQLite;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Entities.Calendar
{
// TODO: Connect to Contact store with Wino People.
public class CalendarEventAttendee
{
[PrimaryKey]
public Guid Id { get; set; }
public Guid CalendarItemId { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public AttendeeStatus AttendenceStatus { get; set; }
public bool IsOrganizer { get; set; }
public bool IsOptionalAttendee { get; set; }
public string Comment { get; set; }
}
}

View File

@@ -14,9 +14,8 @@ namespace Wino.Core.Domain.Entities.Calendar
public string Description { get; set; }
public string Location { get; set; }
public DateTimeOffset StartTime { get; set; }
public DateTimeOffset EndTime { get; set; }
public bool IsAllDay { get; set; }
public Guid? RecurrenceRuleId { get; set; }
public int DurationInMinutes { get; set; }
public string Recurrence { get; set; }
public CalendarItemStatus Status { get; set; }
public CalendarItemVisibility Visibility { get; set; }
public DateTimeOffset CreatedAt { get; set; }
@@ -24,6 +23,6 @@ namespace Wino.Core.Domain.Entities.Calendar
public Guid CalendarId { get; set; }
[Ignore]
public TimeRange Period => new TimeRange(StartTime.Date, EndTime.Date);
public TimeRange Period => new TimeRange(StartTime.Date, StartTime.Date.AddMinutes(DurationInMinutes));
}
}

View File

@@ -1,20 +0,0 @@
using System;
using SQLite;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Entities.Calendar
{
public class RecurrenceRule
{
[PrimaryKey]
public Guid Id { get; set; }
public Guid CalendarItemId { get; set; }
public CalendarItemRecurrenceFrequency Frequency { get; set; }
public int Interval { get; set; }
public string DaysOfWeek { get; set; }
public DateTimeOffset StartDate { get; set; }
public DateTimeOffset EndDate { get; set; }
public int Occurrences { get; set; }
}
}

View File

@@ -34,7 +34,7 @@ namespace Wino.Core.Domain.Entities.Shared
public MailProviderType ProviderType { get; set; }
/// <summary>
/// For tracking change delta.
/// For tracking mail change delta.
/// Gmail : historyId
/// Outlook: deltaToken
/// </summary>

View File

@@ -2,6 +2,7 @@
{
public enum AttendeeStatus
{
NeedsAction,
Accepted,
Tentative,
Declined

View File

@@ -2,7 +2,9 @@
{
public enum CalendarItemVisibility
{
Default,
Public,
Private
Private,
Confidential
}
}

View File

@@ -2,8 +2,10 @@
{
public enum CalendarSynchronizationType
{
AllCalendars, // Sync all calendars.
SingleCalendar, // Sync only one calendar.
ExecuteRequests, // Execute all requests in the queue.
CalendarMetadata, // Sync calendar metadata.
CalendarEvents, // Sync all events for all calendars.
SingleCalendar, // Sync events for only specified calendars.
UpdateProfile // Update profile information only.
}
}

View File

@@ -8,7 +8,7 @@ namespace Wino.Core.Domain.Interfaces
string Title { get; }
Guid Id { get; }
DateTimeOffset StartTime { get; }
DateTimeOffset EndTime { get; }
int DurationInMinutes { get; }
TimeRange Period { get; }
}
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Calendar;
namespace Wino.Core.Domain.Interfaces
{
public interface ICalendarService
{
Task<List<AccountCalendar>> GetAccountCalendarsAsync(Guid accountId);
Task DeleteCalendarItemAsync(Guid calendarItemId);
Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar);
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar);
Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees);
}
}

View File

@@ -10,5 +10,6 @@ namespace Wino.Core.Domain.Interfaces
Task<List<AccountContact>> GetAddressInformationAsync(string queryText);
Task<AccountContact> GetAddressInformationByAddressAsync(string address);
Task SaveAddressInformationAsync(MimeMessage message);
Task<AccountContact> CreateNewContactAsync(string address, string displayName);
}
}

View File

@@ -19,6 +19,8 @@ namespace Wino.Core.Domain.Interfaces
/// <returns>Result summary of synchronization.</returns>
Task<MailSynchronizationResult> SynchronizeMailsAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default);
Task<CalendarSynchronizationResult> SynchronizeCalendarEventsAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default);
/// <summary>
/// Downloads a single MIME message from the server and saves it to disk.
/// </summary>

View File

@@ -1,24 +0,0 @@
using System;
using Itenso.TimePeriod;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.Domain.Models.Calendar
{
public class CalendarItem : ICalendarItem
{
public string Title { get; set; }
public CalendarItem(DateTime startTime, DateTime endTime)
{
StartTime = startTime;
EndTime = endTime;
Period = new TimeRange(startTime, endTime);
}
public DateTimeOffset StartTime { get; }
public DateTimeOffset EndTime { get; }
public Guid Id { get; } = Guid.NewGuid();
public TimeRange Period { get; }
}
}

View File

@@ -19,6 +19,7 @@ namespace Wino.Helpers
{
private const string TwentyFourHourTimeFormat = "HH:mm";
private const string TwelveHourTimeFormat = "hh:mm tt";
#region Converters
public static Visibility ReverseBoolToVisibilityConverter(bool value) => value ? Visibility.Collapsed : Visibility.Visible;
@@ -40,6 +41,19 @@ namespace Wino.Helpers
_ => InfoBarSeverity.Informational,
};
}
public static Microsoft.UI.Xaml.Controls.NavigationViewDisplayMode NavigationViewDisplayModeConverter(SplitViewDisplayMode splitViewDisplayMode)
{
return splitViewDisplayMode switch
{
SplitViewDisplayMode.CompactOverlay => Microsoft.UI.Xaml.Controls.NavigationViewDisplayMode.Compact,
SplitViewDisplayMode.CompactInline => Microsoft.UI.Xaml.Controls.NavigationViewDisplayMode.Minimal,
SplitViewDisplayMode.Overlay => Microsoft.UI.Xaml.Controls.NavigationViewDisplayMode.Expanded,
SplitViewDisplayMode.Inline => Microsoft.UI.Xaml.Controls.NavigationViewDisplayMode.Expanded,
_ => Microsoft.UI.Xaml.Controls.NavigationViewDisplayMode.Minimal,
};
}
public static SolidColorBrush GetSolidColorBrushFromHex(string colorHex) => string.IsNullOrEmpty(colorHex) ? new SolidColorBrush(Colors.Transparent) : new SolidColorBrush(colorHex.ToColor());
public static Visibility IsSelectionModeMultiple(ListViewSelectionMode mode) => mode == ListViewSelectionMode.Multiple ? Visibility.Visible : Visibility.Collapsed;
public static FontWeight GetFontWeightBySyncState(bool isSyncing) => isSyncing ? FontWeights.SemiBold : FontWeights.Normal;

View File

@@ -2,8 +2,10 @@
using System.Collections.Generic;
using System.Linq;
using System.Web;
using Google.Apis.Calendar.v3.Data;
using Google.Apis.Gmail.v1.Data;
using MimeKit;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums;
using Wino.Services;
@@ -13,10 +15,6 @@ namespace Wino.Core.Extensions
{
public static class GoogleIntegratorExtensions
{
private static string GetNormalizedLabelName(string labelName)
{
// 1. Remove CATEGORY_ prefix.
@@ -168,5 +166,119 @@ namespace Wino.Core.Extensions
IsVerified = a.VerificationStatus == "accepted" || a.IsDefault.GetValueOrDefault(),
}).ToList();
}
public static AccountCalendar AsCalendar(this CalendarListEntry calendarListEntry, Guid accountId)
{
return new AccountCalendar()
{
RemoteCalendarId = calendarListEntry.Id,
AccountId = accountId,
Name = calendarListEntry.Summary,
Id = Guid.NewGuid(),
TimeZone = calendarListEntry.TimeZone,
IsPrimary = calendarListEntry.Primary.GetValueOrDefault(),
};
}
/// <summary>
/// Extracts the start DateTimeOffset of a Google Calendar Event.
/// Handles different date/time representations (date-only, date-time, recurring events).
/// Uses the DateTimeDateTimeOffset property for optimal performance and accuracy.
/// </summary>
/// <param name="calendarEvent">The Google Calendar Event object.</param>
/// <returns>The start DateTimeOffset of the event, or null if it cannot be determined.</returns>
public static DateTimeOffset? GetEventStartDateTimeOffset(this Event calendarEvent)
{
if (calendarEvent == null)
{
return null;
}
if (calendarEvent.Start != null)
{
if (calendarEvent.Start.DateTimeDateTimeOffset != null)
{
return calendarEvent.Start.DateTimeDateTimeOffset; // Use the direct DateTimeOffset property!
}
else if (calendarEvent.Start.Date != null)
{
if (DateTime.TryParse(calendarEvent.Start.Date, out DateTime startDate))
{
// Date-only events are treated as UTC midnight
return new DateTimeOffset(startDate, TimeSpan.Zero);
}
else
{
return null;
}
}
}
return null; // Start time not found
}
/// <summary>
/// Calculates the duration of a Google Calendar Event in minutes.
/// Handles date-only and date-time events, but *does not* handle recurring events correctly.
/// For recurring events, this method will return the duration of the *first* instance.
/// </summary>
/// <param name="calendarEvent">The Google Calendar Event object.</param>
/// <returns>The duration of the event in minutes, or null if it cannot be determined.</returns>
public static int? GetEventDurationInMinutes(this Event calendarEvent)
{
if (calendarEvent == null)
{
return null;
}
DateTimeOffset? start = calendarEvent.GetEventStartDateTimeOffset();
if (start == null)
{
return null;
}
DateTimeOffset? end = null;
if (calendarEvent.End != null)
{
if (calendarEvent.End.DateTimeDateTimeOffset != null)
{
end = calendarEvent.End.DateTimeDateTimeOffset;
}
else if (calendarEvent.End.Date != null)
{
if (DateTime.TryParse(calendarEvent.End.Date, out DateTime endDate))
{
end = new DateTimeOffset(endDate, TimeSpan.Zero);
}
else
{
return null;
}
}
}
if (end == null)
{
return null;
}
return (int)(end.Value - start.Value).TotalMinutes;
}
/// <summary>
/// RRULE, EXRULE, RDATE and EXDATE lines for a recurring event, as specified in RFC5545.
///
/// </summary>
/// <returns>___ separated lines.</returns>
public static string GetRecurrenceString(this Event calendarEvent)
{
if (calendarEvent == null || calendarEvent.Recurrence == null || !calendarEvent.Recurrence.Any())
{
return null;
}
return string.Join("___", calendarEvent.Recurrence);
}
}
}

View File

@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Google.Apis.Calendar.v3.Data;
using MimeKit;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
@@ -32,28 +34,28 @@ namespace Wino.Core.Integration.Processors
Task DeleteFolderAsync(Guid accountId, string remoteFolderId);
Task InsertFolderAsync(MailItemFolder folder);
Task UpdateFolderAsync(MailItemFolder folder);
/// <summary>
/// Returns the list of folders that are available for account.
/// </summary>
/// <param name="accountId">Account id to get folders for.</param>
/// <returns>All folders.</returns>
Task<List<MailItemFolder>> GetLocalFoldersAsync(Guid accountId);
Task<List<MailItemFolder>> GetSynchronizationFoldersAsync(MailSynchronizationOptions options);
Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId);
Task UpdateFolderLastSyncDateAsync(Guid folderId);
Task<List<MailItemFolder>> GetExistingFoldersAsync(Guid accountId);
Task UpdateRemoteAliasInformationAsync(MailAccount account, List<RemoteAccountAlias> remoteAccountAliases);
// Calendar
Task<List<AccountCalendar>> GetAccountCalendarsAsync(Guid accountId);
Task DeleteCalendarItemAsync(Guid calendarItemId);
Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar);
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar);
}
public interface IGmailChangeProcessor : IDefaultChangeProcessor
{
Task MapLocalDraftAsync(string mailCopyId, string newDraftId, string newThreadId);
Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
Task<CalendarItem> CreateCalendarItemAsync(Event calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount);
}
public interface IOutlookChangeProcessor : IDefaultChangeProcessor
@@ -115,13 +117,15 @@ namespace Wino.Core.Integration.Processors
public class DefaultChangeProcessor(IDatabaseService databaseService,
IFolderService folderService,
IMailService mailService,
ICalendarService calendarService,
IAccountService accountService,
IMimeFileService mimeFileService) : BaseDatabaseService(databaseService), IDefaultChangeProcessor
{
protected IMailService MailService = mailService;
protected ICalendarService CalendarService = calendarService;
protected IFolderService FolderService = folderService;
protected IAccountService AccountService = accountService;
private readonly IMimeFileService _mimeFileService = mimeFileService;
public Task<string> UpdateAccountDeltaSynchronizationIdentifierAsync(Guid accountId, string synchronizationDeltaIdentifier)
@@ -179,5 +183,20 @@ namespace Wino.Core.Integration.Processors
public Task UpdateRemoteAliasInformationAsync(MailAccount account, List<RemoteAccountAlias> remoteAccountAliases)
=> AccountService.UpdateRemoteAliasInformationAsync(account, remoteAccountAliases);
public Task<List<AccountCalendar>> GetAccountCalendarsAsync(Guid accountId)
=> CalendarService.GetAccountCalendarsAsync(accountId);
public Task DeleteCalendarItemAsync(Guid calendarItemId)
=> CalendarService.DeleteCalendarItemAsync(calendarItemId);
public Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar)
=> CalendarService.DeleteAccountCalendarAsync(accountCalendar);
public Task InsertAccountCalendarAsync(AccountCalendar accountCalendar)
=> CalendarService.InsertAccountCalendarAsync(accountCalendar);
public Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar)
=> CalendarService.UpdateAccountCalendarAsync(accountCalendar);
}
}

View File

@@ -1,14 +1,28 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Google.Apis.Calendar.v3.Data;
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.Extensions;
using Wino.Services;
using CalendarEventAttendee = Wino.Core.Domain.Entities.Calendar.CalendarEventAttendee;
using CalendarItem = Wino.Core.Domain.Entities.Calendar.CalendarItem;
namespace Wino.Core.Integration.Processors
{
public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcessor
{
public GmailChangeProcessor(IDatabaseService databaseService, IFolderService folderService, IMailService mailService, IAccountService accountService, IMimeFileService mimeFileService) : base(databaseService, folderService, mailService, accountService, mimeFileService)
public GmailChangeProcessor(IDatabaseService databaseService,
IFolderService folderService,
IMailService mailService,
ICalendarService calendarService,
IAccountService accountService,
IMimeFileService mimeFileService) : base(databaseService, folderService, mailService, calendarService, accountService, mimeFileService)
{
}
public Task MapLocalDraftAsync(string mailCopyId, string newDraftId, string newThreadId)
@@ -16,5 +30,116 @@ namespace Wino.Core.Integration.Processors
public Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
=> MailService.CreateAssignmentAsync(accountId, mailCopyId, remoteFolderId);
public async Task<CalendarItem> CreateCalendarItemAsync(Event calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount)
{
var calendarItem = new CalendarItem()
{
CalendarId = assignedCalendar.Id,
CreatedAt = DateTimeOffset.UtcNow,
Description = calendarEvent.Description,
StartTime = GoogleIntegratorExtensions.GetEventStartDateTimeOffset(calendarEvent) ?? throw new Exception("Event without a start time."),
DurationInMinutes = GoogleIntegratorExtensions.GetEventDurationInMinutes(calendarEvent) ?? throw new Exception("Event without a duration."),
Id = Guid.NewGuid(),
Location = calendarEvent.Location,
Recurrence = GoogleIntegratorExtensions.GetRecurrenceString(calendarEvent),
Status = GetStatus(calendarEvent.Status),
Title = calendarEvent.Summary,
UpdatedAt = DateTimeOffset.UtcNow,
Visibility = GetVisibility(calendarEvent.Visibility),
};
// TODO: There are some edge cases with cancellation here.
CalendarItemStatus GetStatus(string status)
{
return status switch
{
"confirmed" => CalendarItemStatus.Confirmed,
"tentative" => CalendarItemStatus.Tentative,
"cancelled" => CalendarItemStatus.Cancelled,
_ => CalendarItemStatus.Confirmed
};
}
CalendarItemVisibility GetVisibility(string visibility)
{
/// Visibility of the event. Optional. Possible values are: - "default" - Uses the default visibility for
/// events on the calendar. This is the default value. - "public" - The event is public and event details are
/// visible to all readers of the calendar. - "private" - The event is private and only event attendees may
/// view event details. - "confidential" - The event is private. This value is provided for compatibility
/// reasons.
return visibility switch
{
"default" => CalendarItemVisibility.Default,
"public" => CalendarItemVisibility.Public,
"private" => CalendarItemVisibility.Private,
"confidential" => CalendarItemVisibility.Confidential,
_ => CalendarItemVisibility.Default
};
}
// Attendees
var attendees = new List<CalendarEventAttendee>();
if (calendarEvent.Attendees == null)
{
// Self-only event.
attendees.Add(new CalendarEventAttendee()
{
CalendarItemId = calendarItem.Id,
IsOrganizer = true,
Email = organizerAccount.Address,
Name = organizerAccount.SenderName,
AttendenceStatus = AttendeeStatus.Accepted,
Id = Guid.NewGuid(),
IsOptionalAttendee = false,
});
}
else
{
foreach (var attendee in calendarEvent.Attendees)
{
if (attendee.Self == true)
{
// TODO:
}
else if (!string.IsNullOrEmpty(attendee.Email))
{
AttendeeStatus GetAttendenceStatus(string responseStatus)
{
return responseStatus switch
{
"accepted" => AttendeeStatus.Accepted,
"declined" => AttendeeStatus.Declined,
"tentative" => AttendeeStatus.Tentative,
"needsAction" => AttendeeStatus.NeedsAction,
_ => AttendeeStatus.NeedsAction
};
}
var eventAttendee = new CalendarEventAttendee()
{
CalendarItemId = calendarItem.Id,
IsOrganizer = attendee.Organizer ?? false,
Comment = attendee.Comment,
Email = attendee.Email,
Name = attendee.DisplayName,
AttendenceStatus = GetAttendenceStatus(attendee.ResponseStatus),
Id = Guid.NewGuid(),
IsOptionalAttendee = attendee.Optional ?? false,
};
attendees.Add(eventAttendee);
}
}
}
await CalendarService.CreateNewCalendarItemAsync(calendarItem, attendees);
return calendarItem;
}
}
}

View File

@@ -12,7 +12,8 @@ namespace Wino.Core.Integration.Processors
IFolderService folderService,
IMailService mailService,
IAccountService accountService,
IMimeFileService mimeFileService) : base(databaseService, folderService, mailService, accountService, mimeFileService)
ICalendarService calendarService,
IMimeFileService mimeFileService) : base(databaseService, folderService, mailService, calendarService, accountService, mimeFileService)
{
}

View File

@@ -7,9 +7,10 @@ namespace Wino.Core.Integration.Processors
{
public class OutlookChangeProcessor(IDatabaseService databaseService,
IFolderService folderService,
ICalendarService calendarService,
IMailService mailService,
IAccountService accountService,
IMimeFileService mimeFileService) : DefaultChangeProcessor(databaseService, folderService, mailService, accountService, mimeFileService)
IMimeFileService mimeFileService) : DefaultChangeProcessor(databaseService, folderService, mailService, calendarService, accountService, mimeFileService)
, IOutlookChangeProcessor
{
public Task<bool> IsMailExistsAsync(string messageId)

View File

@@ -149,7 +149,7 @@ namespace Wino.Core.Services
Type = MailSynchronizationType.ExecuteRequests
};
WeakReferenceMessenger.Default.Send(new NewSynchronizationRequested(options, SynchronizationSource.Client));
WeakReferenceMessenger.Default.Send(new NewMailSynchronizationRequested(options, SynchronizationSource.Client));
}
private async Task EnsureServerConnectedAsync()

View File

@@ -17,6 +17,7 @@ using Microsoft.IdentityModel.Tokens;
using MimeKit;
using MoreLinq;
using Serilog;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
@@ -33,6 +34,7 @@ using Wino.Core.Requests.Folder;
using Wino.Core.Requests.Mail;
using Wino.Messaging.UI;
using Wino.Services;
using CalendarService = Google.Apis.Calendar.v3.CalendarService;
namespace Wino.Core.Synchronizers.Mail
{
@@ -47,6 +49,7 @@ namespace Wino.Core.Synchronizers.Mail
private readonly ConfigurableHttpClient _googleHttpClient;
private readonly GmailService _gmailService;
private readonly CalendarService _calendarService;
private readonly PeopleServiceService _peopleService;
private readonly IGmailChangeProcessor _gmailChangeProcessor;
@@ -64,9 +67,10 @@ namespace Wino.Core.Synchronizers.Mail
};
_googleHttpClient = new ConfigurableHttpClient(messageHandler);
_gmailService = new GmailService(initializer);
_peopleService = new PeopleServiceService(initializer);
_calendarService = new CalendarService(initializer);
_gmailChangeProcessor = gmailChangeProcessor;
}
@@ -284,109 +288,258 @@ namespace Wino.Core.Synchronizers.Mail
return MailSynchronizationResult.Completed(unreadNewItems);
}
protected override Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
protected override async Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
{
_logger.Information("Internal calendar synchronization started for {Name}", Account.Name);
cancellationToken.ThrowIfCancellationRequested();
await SynchronizeCalendarsAsync(cancellationToken).ConfigureAwait(false);
bool isInitialSync = string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier);
_logger.Debug("Is initial synchronization: {IsInitialSync}", isInitialSync);
var localCalendars = await _gmailChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
// TODO: Better logging and exception handling.
foreach (var calendar in localCalendars)
{
var request = _calendarService.Events.List(calendar.RemoteCalendarId);
request.SingleEvents = false;
request.ShowDeleted = true;
if (!string.IsNullOrEmpty(calendar.SynchronizationDeltaToken))
{
// If a sync token is available, perform an incremental sync
request.SyncToken = calendar.SynchronizationDeltaToken;
}
else
{
// If no sync token, perform an initial sync
// Fetch events from the past year
request.TimeMinDateTimeOffset = DateTimeOffset.UtcNow.AddYears(-1);
}
string nextPageToken;
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);
}
// Get the next page token and sync token
nextPageToken = events.NextPageToken;
syncToken = events.NextSyncToken;
// Set the next page token for subsequent requests
request.PageToken = nextPageToken;
} while (!string.IsNullOrEmpty(nextPageToken));
calendar.SynchronizationDeltaToken = syncToken;
await _gmailChangeProcessor.UpdateAccountCalendarAsync(calendar).ConfigureAwait(false);
foreach (var @event in allEvents)
{
// TODO: Exception handling for event processing.
await _gmailChangeProcessor.CreateCalendarItemAsync(@event, calendar, Account).ConfigureAwait(false);
}
}
return default;
}
private async Task SynchronizeFoldersAsync(CancellationToken cancellationToken = default)
private async Task SynchronizeCalendarsAsync(CancellationToken cancellationToken = default)
{
try
var calendarListRequest = _calendarService.CalendarList.List();
var calendarListResponse = await calendarListRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
if (calendarListResponse.Items == null)
{
var localFolders = await _gmailChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
var folderRequest = _gmailService.Users.Labels.List("me");
_logger.Warning("No calendars found for {Name}", Account.Name);
return;
}
var labelsResponse = await folderRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
var localCalendars = await _gmailChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
if (labelsResponse.Labels == null)
List<AccountCalendar> insertedCalendars = new();
List<AccountCalendar> updatedCalendars = new();
List<AccountCalendar> deletedCalendars = new();
// 1. Handle deleted calendars.
foreach (var calendar in localCalendars)
{
var remoteCalendar = calendarListResponse.Items.FirstOrDefault(a => a.Id == calendar.RemoteCalendarId);
if (remoteCalendar == null)
{
_logger.Warning("No folders found for {Name}", Account.Name);
return;
// Local calendar doesn't exists remotely. Delete local copy.
await _gmailChangeProcessor.DeleteAccountCalendarAsync(calendar).ConfigureAwait(false);
deletedCalendars.Add(calendar);
}
}
List<MailItemFolder> insertedFolders = new();
List<MailItemFolder> updatedFolders = new();
List<MailItemFolder> deletedFolders = new();
// Delete the deleted folders from local list.
deletedCalendars.ForEach(a => localCalendars.Remove(a));
// 1. Handle deleted labels.
foreach (var localFolder in localFolders)
// 2. Handle update/insert based on remote calendars.
foreach (var calendar in calendarListResponse.Items)
{
var existingLocalCalendar = localCalendars.FirstOrDefault(a => a.RemoteCalendarId == calendar.Id);
if (existingLocalCalendar == null)
{
// Category folder is virtual folder for Wino. Skip it.
if (localFolder.SpecialFolderType == SpecialFolderType.Category) continue;
var remoteFolder = labelsResponse.Labels.FirstOrDefault(a => a.Id == localFolder.RemoteFolderId);
if (remoteFolder == null)
{
// Local folder doesn't exists remotely. Delete local copy.
await _gmailChangeProcessor.DeleteFolderAsync(Account.Id, localFolder.RemoteFolderId).ConfigureAwait(false);
deletedFolders.Add(localFolder);
}
// Insert new calendar.
var localCalendar = calendar.AsCalendar(Account.Id);
insertedCalendars.Add(localCalendar);
}
// Delete the deleted folders from local list.
deletedFolders.ForEach(a => localFolders.Remove(a));
// 2. Handle update/insert based on remote folders.
foreach (var remoteFolder in labelsResponse.Labels)
else
{
var existingLocalFolder = localFolders.FirstOrDefault(a => a.RemoteFolderId == remoteFolder.Id);
if (existingLocalFolder == null)
// Update existing calendar. Right now we only update the name.
if (ShouldUpdateCalendar(calendar, existingLocalCalendar))
{
// Insert new folder.
var localFolder = remoteFolder.GetLocalFolder(labelsResponse, Account.Id);
existingLocalCalendar.Name = calendar.Summary;
insertedFolders.Add(localFolder);
updatedCalendars.Add(existingLocalCalendar);
}
else
{
// Update existing folder. Right now we only update the name.
// TODO: Moving folders around different parents. This is not supported right now.
// We will need more comphrensive folder update mechanism to support this.
if (ShouldUpdateFolder(remoteFolder, existingLocalFolder))
{
existingLocalFolder.FolderName = remoteFolder.Name;
existingLocalFolder.TextColorHex = remoteFolder.Color?.TextColor;
existingLocalFolder.BackgroundColorHex = remoteFolder.Color?.BackgroundColor;
updatedFolders.Add(existingLocalFolder);
}
else
{
// Remove it from the local folder list to skip additional folder updates.
localFolders.Remove(existingLocalFolder);
}
// Remove it from the local folder list to skip additional calendar updates.
localCalendars.Remove(existingLocalCalendar);
}
}
// 3.Process changes in order-> Insert, Update. Deleted ones are already processed.
foreach (var folder in insertedFolders)
{
await _gmailChangeProcessor.InsertFolderAsync(folder).ConfigureAwait(false);
}
foreach (var folder in updatedFolders)
{
await _gmailChangeProcessor.UpdateFolderAsync(folder).ConfigureAwait(false);
}
if (insertedFolders.Any() || deletedFolders.Any() || updatedFolders.Any())
{
WeakReferenceMessenger.Default.Send(new AccountFolderConfigurationUpdated(Account.Id));
}
}
catch (Exception)
// 3.Process changes in order-> Insert, Update. Deleted ones are already processed.
foreach (var calendar in insertedCalendars)
{
throw;
await _gmailChangeProcessor.InsertAccountCalendarAsync(calendar).ConfigureAwait(false);
}
foreach (var calendar in updatedCalendars)
{
await _gmailChangeProcessor.UpdateAccountCalendarAsync(calendar).ConfigureAwait(false);
}
if (insertedCalendars.Any() || deletedCalendars.Any() || updatedCalendars.Any())
{
// TODO: Notify calendar updates.
// WeakReferenceMessenger.Default.Send(new AccountFolderConfigurationUpdated(Account.Id));
}
}
private async Task SynchronizeFoldersAsync(CancellationToken cancellationToken = default)
{
var localFolders = await _gmailChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
var folderRequest = _gmailService.Users.Labels.List("me");
var labelsResponse = await folderRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
if (labelsResponse.Labels == null)
{
_logger.Warning("No folders found for {Name}", Account.Name);
return;
}
List<MailItemFolder> insertedFolders = new();
List<MailItemFolder> updatedFolders = new();
List<MailItemFolder> deletedFolders = new();
// 1. Handle deleted labels.
foreach (var localFolder in localFolders)
{
// Category folder is virtual folder for Wino. Skip it.
if (localFolder.SpecialFolderType == SpecialFolderType.Category) continue;
var remoteFolder = labelsResponse.Labels.FirstOrDefault(a => a.Id == localFolder.RemoteFolderId);
if (remoteFolder == null)
{
// Local folder doesn't exists remotely. Delete local copy.
await _gmailChangeProcessor.DeleteFolderAsync(Account.Id, localFolder.RemoteFolderId).ConfigureAwait(false);
deletedFolders.Add(localFolder);
}
}
// Delete the deleted folders from local list.
deletedFolders.ForEach(a => localFolders.Remove(a));
// 2. Handle update/insert based on remote folders.
foreach (var remoteFolder in labelsResponse.Labels)
{
var existingLocalFolder = localFolders.FirstOrDefault(a => a.RemoteFolderId == remoteFolder.Id);
if (existingLocalFolder == null)
{
// Insert new folder.
var localFolder = remoteFolder.GetLocalFolder(labelsResponse, Account.Id);
insertedFolders.Add(localFolder);
}
else
{
// Update existing folder. Right now we only update the name.
// TODO: Moving folders around different parents. This is not supported right now.
// We will need more comphrensive folder update mechanism to support this.
if (ShouldUpdateFolder(remoteFolder, existingLocalFolder))
{
existingLocalFolder.FolderName = remoteFolder.Name;
existingLocalFolder.TextColorHex = remoteFolder.Color?.TextColor;
existingLocalFolder.BackgroundColorHex = remoteFolder.Color?.BackgroundColor;
updatedFolders.Add(existingLocalFolder);
}
else
{
// Remove it from the local folder list to skip additional folder updates.
localFolders.Remove(existingLocalFolder);
}
}
}
// 3.Process changes in order-> Insert, Update. Deleted ones are already processed.
foreach (var folder in insertedFolders)
{
await _gmailChangeProcessor.InsertFolderAsync(folder).ConfigureAwait(false);
}
foreach (var folder in updatedFolders)
{
await _gmailChangeProcessor.UpdateFolderAsync(folder).ConfigureAwait(false);
}
if (insertedFolders.Any() || deletedFolders.Any() || updatedFolders.Any())
{
WeakReferenceMessenger.Default.Send(new AccountFolderConfigurationUpdated(Account.Id));
}
}
private bool ShouldUpdateCalendar(CalendarListEntry calendarListEntry, AccountCalendar accountCalendar)
{
// TODO: Only calendar name is updated for now. We can add more checks here.
var remoteCalendarName = calendarListEntry.Summary;
var localCalendarName = accountCalendar.Name;
return !localCalendarName.Equals(remoteCalendarName, StringComparison.OrdinalIgnoreCase);
}
private bool ShouldUpdateFolder(Label remoteFolder, MailItemFolder existingLocalFolder)

View File

@@ -257,6 +257,18 @@ namespace Wino.Core.Synchronizers
}
}
/// <summary>
/// Batches network requests, executes them, and does the needed synchronization after the batch request execution.
/// </summary>
/// <param name="options">Synchronization options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Synchronization result that contains summary of the sync.</returns>
public Task<CalendarSynchronizationResult> SynchronizeCalendarEventsAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
{
// TODO: Execute requests for calendar events.
return SynchronizeCalendarEventsInternalAsync(options, cancellationToken);
}
/// <summary>
/// Updates unread item counts for some folders and account.
/// Sends a message that shell can pick up and update the UI.
@@ -355,5 +367,7 @@ namespace Wino.Core.Synchronizers
return ret;
}
}
}

View File

@@ -36,6 +36,7 @@
<PackageReference Include="MimeKit" Version="4.9.0" />
<PackageReference Include="morelinq" Version="4.3.0" />
<PackageReference Include="Nito.AsyncEx.Tasks" Version="5.1.2" />
<PackageReference Include="NodaTime" Version="3.2.0" />
<PackageReference Include="Serilog" Version="4.1.0" />
<PackageReference Include="Serilog.Exceptions" Version="8.4.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />

View File

@@ -29,7 +29,7 @@ using Wino.Services;
namespace Wino
{
public sealed partial class App : WinoApplication, IRecipient<NewSynchronizationRequested>
public sealed partial class App : WinoApplication, IRecipient<NewMailSynchronizationRequested>
{
public override string AppCenterKey { get; } = "90deb1d0-a77f-47d0-8a6b-7eaf111c6b72";
@@ -224,11 +224,11 @@ namespace Wino
AppServiceConnectionManager.Connection = null;
}
public async void Receive(NewSynchronizationRequested message)
public async void Receive(NewMailSynchronizationRequested message)
{
try
{
var synchronizationResultResponse = await AppServiceConnectionManager.GetResponseAsync<MailSynchronizationResult, NewSynchronizationRequested>(message);
var synchronizationResultResponse = await AppServiceConnectionManager.GetResponseAsync<MailSynchronizationResult, NewMailSynchronizationRequested>(message);
synchronizationResultResponse.ThrowIfFailed();
}
catch (WinoServerException serverException)

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
using Wino.Core.Domain.Entities.Calendar;
namespace Wino.Messaging.Client.Calendar
{
public record CalendarListRefreshed(List<AccountCalendar> AccountCalendars);
public record CalendarListAdded(AccountCalendar AccountCalendar);
public record CalendarListUpdated(AccountCalendar AccountCalendar);
public record CalendarListDeleted(AccountCalendar AccountCalendar);
}

View File

@@ -0,0 +1,8 @@
using Wino.Core.Domain.Entities.Calendar;
namespace Wino.Messaging.Client.Calendar
{
public record CalendarItemAdded(CalendarItem CalendarItem);
public record CalendarItemUpdated(CalendarItem CalendarItem);
public record CalendarItemDeleted(CalendarItem CalendarItem);
}

View File

@@ -5,8 +5,14 @@ using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Messaging.Server
{
/// <summary>
/// Triggers a new synchronization if possible.
/// Triggers a new mail synchronization if possible.
/// </summary>
/// <param name="Options">Options for synchronization.</param>
public record NewSynchronizationRequested(MailSynchronizationOptions Options, SynchronizationSource Source) : IClientMessage;
public record NewMailSynchronizationRequested(MailSynchronizationOptions Options, SynchronizationSource Source) : IClientMessage;
/// <summary>
/// Triggers a new calendar synchronization if possible.
/// </summary>
/// <param name="Options">Options for synchronization.</param>
public record NewCalendarSynchronizationRequested(CalendarSynchronizationOptions Options, SynchronizationSource Source) : IClientMessage;
}

View File

@@ -14,7 +14,8 @@ namespace Wino.Server.Core
{
return typeName switch
{
nameof(NewSynchronizationRequested) => App.Current.Services.GetService<SynchronizationRequestHandler>(),
nameof(NewMailSynchronizationRequested) => App.Current.Services.GetService<MailSynchronizationRequestHandler>(),
nameof(NewCalendarSynchronizationRequested) => App.Current.Services.GetService<CalendarSynchronizationRequestHandler>(),
nameof(ServerRequestPackage) => App.Current.Services.GetService<UserActionRequestHandler>(),
nameof(DownloadMissingMessageRequested) => App.Current.Services.GetService<SingleMimeDownloadHandler>(),
nameof(AuthorizationRequested) => App.Current.Services.GetService<AuthenticationHandler>(),
@@ -31,7 +32,8 @@ namespace Wino.Server.Core
{
// Register all known handlers.
serviceCollection.AddTransient<SynchronizationRequestHandler>();
serviceCollection.AddTransient<MailSynchronizationRequestHandler>();
serviceCollection.AddTransient<CalendarSynchronizationRequestHandler>();
serviceCollection.AddTransient<UserActionRequestHandler>();
serviceCollection.AddTransient<SingleMimeDownloadHandler>();
serviceCollection.AddTransient<AuthenticationHandler>();

View File

@@ -0,0 +1,40 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Server;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Messaging.Server;
using Wino.Server.Core;
namespace Wino.Server.MessageHandlers
{
public class CalendarSynchronizationRequestHandler : ServerMessageHandler<NewCalendarSynchronizationRequested, CalendarSynchronizationResult>
{
public override WinoServerResponse<CalendarSynchronizationResult> FailureDefaultResponse(Exception ex)
=> WinoServerResponse<CalendarSynchronizationResult>.CreateErrorResponse(ex.Message);
private readonly ISynchronizerFactory _synchronizerFactory;
public CalendarSynchronizationRequestHandler(ISynchronizerFactory synchronizerFactory)
{
_synchronizerFactory = synchronizerFactory;
}
protected override async Task<WinoServerResponse<CalendarSynchronizationResult>> HandleAsync(NewCalendarSynchronizationRequested message, CancellationToken cancellationToken = default)
{
var synchronizer = await _synchronizerFactory.GetAccountSynchronizerAsync(message.Options.AccountId);
try
{
var synchronizationResult = await synchronizer.SynchronizeCalendarEventsAsync(message.Options, cancellationToken);
return WinoServerResponse<CalendarSynchronizationResult>.CreateSuccessResponse(synchronizationResult);
}
catch (Exception ex)
{
throw;
}
}
}
}

View File

@@ -14,9 +14,9 @@ using Wino.Server.Core;
namespace Wino.Server.MessageHandlers
{
/// <summary>
/// Handler for NewSynchronizationRequested from the client.
/// Handler for NewMailSynchronizationRequested from the client.
/// </summary>
public class SynchronizationRequestHandler : ServerMessageHandler<NewSynchronizationRequested, MailSynchronizationResult>
public class MailSynchronizationRequestHandler : ServerMessageHandler<NewMailSynchronizationRequested, MailSynchronizationResult>
{
public override WinoServerResponse<MailSynchronizationResult> FailureDefaultResponse(Exception ex)
=> WinoServerResponse<MailSynchronizationResult>.CreateErrorResponse(ex.Message);
@@ -25,7 +25,7 @@ namespace Wino.Server.MessageHandlers
private readonly INotificationBuilder _notificationBuilder;
private readonly IFolderService _folderService;
public SynchronizationRequestHandler(ISynchronizerFactory synchronizerFactory,
public MailSynchronizationRequestHandler(ISynchronizerFactory synchronizerFactory,
INotificationBuilder notificationBuilder,
IFolderService folderService)
{
@@ -34,7 +34,7 @@ namespace Wino.Server.MessageHandlers
_folderService = folderService;
}
protected override async Task<WinoServerResponse<MailSynchronizationResult>> HandleAsync(NewSynchronizationRequested message, CancellationToken cancellationToken = default)
protected override async Task<WinoServerResponse<MailSynchronizationResult>> HandleAsync(NewMailSynchronizationRequested message, CancellationToken cancellationToken = default)
{
var synchronizer = await _synchronizerFactory.GetAccountSynchronizerAsync(message.Options.AccountId);

View File

@@ -84,6 +84,10 @@ namespace Wino.Server
private async void SynchronizationTimerTriggered(object sender, System.Timers.ElapsedEventArgs e)
{
// TODO: Disabled for calendar synchronization. Implement a separate timer for calendar synchronization.
// or completely separate contexts for both apps.
return;
// Send sync request for all accounts.
var accounts = await _accountService.GetAccountsAsync();
@@ -96,7 +100,7 @@ namespace Wino.Server
Type = MailSynchronizationType.InboxOnly,
};
var request = new NewSynchronizationRequested(options, SynchronizationSource.Server);
var request = new NewMailSynchronizationRequested(options, SynchronizationSource.Server);
await ExecuteServerMessageSafeAsync(null, request);
}
@@ -279,10 +283,15 @@ namespace Wino.Server
{
switch (typeName)
{
case nameof(NewSynchronizationRequested):
Debug.WriteLine($"New synchronization requested.");
case nameof(NewMailSynchronizationRequested):
Debug.WriteLine($"New mail synchronization requested.");
await ExecuteServerMessageSafeAsync(args, JsonSerializer.Deserialize<NewSynchronizationRequested>(messageJson, _jsonSerializerOptions));
await ExecuteServerMessageSafeAsync(args, JsonSerializer.Deserialize<NewMailSynchronizationRequested>(messageJson, _jsonSerializerOptions));
break;
case nameof(NewCalendarSynchronizationRequested):
Debug.WriteLine($"New calendar synchronization requested.");
await ExecuteServerMessageSafeAsync(args, JsonSerializer.Deserialize<NewCalendarSynchronizationRequested>(messageJson, _jsonSerializerOptions));
break;
case nameof(DownloadMissingMessageRequested):
Debug.WriteLine($"Download missing message requested.");

View File

@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using SqlKata;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Interfaces;
using Wino.Messaging.Client.Calendar;
using Wino.Services.Extensions;
namespace Wino.Services
{
public class CalendarService : BaseDatabaseService, ICalendarService
{
public CalendarService(IDatabaseService databaseService) : base(databaseService)
{
}
public Task<List<AccountCalendar>> GetAccountCalendarsAsync(Guid accountId)
=> Connection.Table<AccountCalendar>().Where(x => x.AccountId == accountId).ToListAsync();
public async Task InsertAccountCalendarAsync(AccountCalendar accountCalendar)
{
await Connection.InsertAsync(accountCalendar);
WeakReferenceMessenger.Default.Send(new CalendarListAdded(accountCalendar));
}
public async Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar)
{
await Connection.UpdateAsync(accountCalendar);
WeakReferenceMessenger.Default.Send(new CalendarListUpdated(accountCalendar));
}
public async Task DeleteAccountCalendarAsync(AccountCalendar accountCalendar)
{
var deleteCalendarItemsQuery = new Query()
.From(nameof(CalendarItem))
.Where(nameof(CalendarItem.CalendarId), accountCalendar.Id)
.Where(nameof(AccountCalendar.AccountId), accountCalendar.AccountId);
var rawQuery = deleteCalendarItemsQuery.GetRawQuery();
await Connection.ExecuteAsync(rawQuery);
await Connection.DeleteAsync(accountCalendar);
WeakReferenceMessenger.Default.Send(new CalendarListDeleted(accountCalendar));
}
public async Task DeleteCalendarItemAsync(Guid calendarItemId)
{
var calendarItem = await Connection.GetAsync<CalendarItem>(calendarItemId);
if (calendarItem == null) return;
await Connection.Table<CalendarItem>().DeleteAsync(x => x.Id == calendarItemId);
WeakReferenceMessenger.Default.Send(new CalendarItemDeleted(calendarItem));
}
public Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees)
{
return Connection.RunInTransactionAsync((conn) =>
{
conn.Insert(calendarItem);
conn.InsertAll(attendees);
});
}
}
}

View File

@@ -13,6 +13,15 @@ namespace Wino.Services
{
public ContactService(IDatabaseService databaseService) : base(databaseService) { }
public async Task<AccountContact> CreateNewContactAsync(string address, string displayName)
{
var contact = new AccountContact() { Address = address, Name = displayName };
await Connection.InsertAsync(contact).ConfigureAwait(false);
return contact;
}
public Task<List<AccountContact>> GetAddressInformationAsync(string queryText)
{
if (queryText == null || queryText.Length < 2)

View File

@@ -1,6 +1,7 @@
using System.IO;
using System.Threading.Tasks;
using SQLite;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
@@ -52,7 +53,11 @@ namespace Wino.Services
typeof(AccountSignature),
typeof(MergedInbox),
typeof(MailAccountPreferences),
typeof(MailAccountAlias)
typeof(MailAccountAlias),
typeof(AccountCalendar),
typeof(CalendarEventAttendee),
typeof(CalendarItem),
typeof(Reminder)
);
}
}

View File

@@ -16,6 +16,7 @@ namespace Wino.Services
services.AddSingleton<ILaunchProtocolService, LaunchProtocolService>();
services.AddSingleton<IMimeFileService, MimeFileService>();
services.AddTransient<ICalendarService, CalendarService>();
services.AddTransient<IMailService, MailService>();
services.AddTransient<IFolderService, FolderService>();
services.AddTransient<IAccountService, AccountService>();