Outlook calendar/event syncing basics without delta. Bunch of UI updates for the calendar view.

This commit is contained in:
Burak Kaan Köse
2025-01-06 02:15:21 +01:00
parent a7674d436d
commit 125c277c88
46 changed files with 1104 additions and 356 deletions

View File

@@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Web;
using CommunityToolkit.Diagnostics;
using Google.Apis.Calendar.v3.Data;
using Google.Apis.Gmail.v1.Data;
using MimeKit;
@@ -182,22 +181,11 @@ namespace Wino.Core.Extensions
IsPrimary = calendarListEntry.Primary.GetValueOrDefault(),
};
// Optional background color.
if (calendarListEntry.BackgroundColor != null) calendar.BackgroundColorHex = calendarListEntry.BackgroundColor;
// Bg color must present. Generate one if doesnt exists.
// Text color is optional. It'll be overriden by UI for readibility.
if (!string.IsNullOrEmpty(calendarListEntry.ForegroundColor))
{
calendar.TextColorHex = calendarListEntry.ForegroundColor;
}
else
{
// Calendars must have text color assigned.
// Generate one if not provided.
var randomColor = RandomFlatColorGenerator.Generate();
calendar.TextColorHex = randomColor.ToHexString();
}
calendar.BackgroundColorHex = string.IsNullOrEmpty(calendarListEntry.BackgroundColor) ? ColorHelpers.GenerateFlatColorHex() : calendar.BackgroundColorHex;
calendar.TextColorHex = string.IsNullOrEmpty(calendarListEntry.ForegroundColor) ? "#000000" : calendarListEntry.ForegroundColor;
return calendar;
}

View File

@@ -1,10 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Graph.Models;
using MimeKit;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Misc;
namespace Wino.Core.Extensions
{
@@ -101,6 +105,148 @@ namespace Wino.Core.Extensions
return message;
}
public static AccountCalendar AsCalendar(this Calendar outlookCalendar, MailAccount assignedAccount)
{
var calendar = new AccountCalendar()
{
AccountId = assignedAccount.Id,
Id = Guid.NewGuid(),
RemoteCalendarId = outlookCalendar.Id,
IsPrimary = outlookCalendar.IsDefaultCalendar.GetValueOrDefault(),
Name = outlookCalendar.Name,
IsExtended = true,
};
// Colors:
// Bg must be present. Generate flat one if doesn't exists.
// Text doesnt exists for Outlook.
calendar.BackgroundColorHex = string.IsNullOrEmpty(outlookCalendar.HexColor) ? ColorHelpers.GenerateFlatColorHex() : outlookCalendar.HexColor;
calendar.TextColorHex = "#000000";
return calendar;
}
private static string GetRfc5545DayOfWeek(DayOfWeekObject dayOfWeek)
{
return dayOfWeek switch
{
DayOfWeekObject.Monday => "MO",
DayOfWeekObject.Tuesday => "TU",
DayOfWeekObject.Wednesday => "WE",
DayOfWeekObject.Thursday => "TH",
DayOfWeekObject.Friday => "FR",
DayOfWeekObject.Saturday => "SA",
DayOfWeekObject.Sunday => "SU",
_ => throw new ArgumentOutOfRangeException(nameof(dayOfWeek), dayOfWeek, null)
};
}
public static string ToRfc5545RecurrenceString(this PatternedRecurrence recurrence)
{
if (recurrence == null || recurrence.Pattern == null)
throw new ArgumentNullException(nameof(recurrence), "PatternedRecurrence or its Pattern cannot be null.");
var ruleBuilder = new StringBuilder("RRULE:");
var pattern = recurrence.Pattern;
// Frequency
switch (pattern.Type)
{
case RecurrencePatternType.Daily:
ruleBuilder.Append("FREQ=DAILY;");
break;
case RecurrencePatternType.Weekly:
ruleBuilder.Append("FREQ=WEEKLY;");
break;
case RecurrencePatternType.AbsoluteMonthly:
ruleBuilder.Append("FREQ=MONTHLY;");
break;
case RecurrencePatternType.AbsoluteYearly:
ruleBuilder.Append("FREQ=YEARLY;");
break;
case RecurrencePatternType.RelativeMonthly:
ruleBuilder.Append("FREQ=MONTHLY;");
break;
case RecurrencePatternType.RelativeYearly:
ruleBuilder.Append("FREQ=YEARLY;");
break;
default:
throw new NotSupportedException($"Unsupported recurrence pattern type: {pattern.Type}");
}
// Interval
if (pattern.Interval > 0)
ruleBuilder.Append($"INTERVAL={pattern.Interval};");
// Days of Week
if (pattern.DaysOfWeek?.Any() == true)
{
var days = string.Join(",", pattern.DaysOfWeek.Select(day => day.ToString().ToUpperInvariant().Substring(0, 2)));
ruleBuilder.Append($"BYDAY={days};");
}
// Day of Month (BYMONTHDAY)
if (pattern.Type == RecurrencePatternType.AbsoluteMonthly || pattern.Type == RecurrencePatternType.AbsoluteYearly)
{
if (pattern.DayOfMonth <= 0)
throw new ArgumentException("DayOfMonth must be greater than 0 for absoluteMonthly or absoluteYearly patterns.");
ruleBuilder.Append($"BYMONTHDAY={pattern.DayOfMonth};");
}
// Month (BYMONTH)
if (pattern.Type == RecurrencePatternType.AbsoluteYearly || pattern.Type == RecurrencePatternType.RelativeYearly)
{
if (pattern.Month <= 0)
throw new ArgumentException("Month must be greater than 0 for absoluteYearly or relativeYearly patterns.");
ruleBuilder.Append($"BYMONTH={pattern.Month};");
}
// Count or Until
if (recurrence.Range != null)
{
if (recurrence.Range.Type == RecurrenceRangeType.EndDate && recurrence.Range.EndDate != null)
{
ruleBuilder.Append($"UNTIL={recurrence.Range.EndDate.Value:yyyyMMddTHHmmssZ};");
}
else if (recurrence.Range.Type == RecurrenceRangeType.Numbered && recurrence.Range.NumberOfOccurrences.HasValue)
{
ruleBuilder.Append($"COUNT={recurrence.Range.NumberOfOccurrences.Value};");
}
}
// Remove trailing semicolon
return ruleBuilder.ToString().TrimEnd(';');
}
public static DateTimeOffset GetDateTimeOffsetFromDateTimeTimeZone(DateTimeTimeZone dateTimeTimeZone)
{
if (dateTimeTimeZone == null || string.IsNullOrEmpty(dateTimeTimeZone.DateTime) || string.IsNullOrEmpty(dateTimeTimeZone.TimeZone))
{
throw new ArgumentException("DateTimeTimeZone is null or empty.");
}
try
{
// Parse the DateTime string
if (DateTime.TryParse(dateTimeTimeZone.DateTime, out DateTime parsedDateTime))
{
// Get TimeZoneInfo to get the offset
TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(dateTimeTimeZone.TimeZone);
TimeSpan offset = timeZoneInfo.GetUtcOffset(parsedDateTime);
return new DateTimeOffset(parsedDateTime, offset);
}
else
throw new ArgumentException("DateTime string is not in a valid format.");
}
catch (Exception)
{
throw;
}
}
#region Mime to Outlook Message Helpers
private static IEnumerable<Recipient> GetRecipients(this InternetAddressList internetAddresses)
@@ -176,6 +322,8 @@ namespace Wino.Core.Extensions
return id;
}
#endregion
}
}

View File

@@ -103,6 +103,7 @@ namespace Wino.Core.Integration.Processors
/// <param name="accountId">Account identifier to reset delta token for.</param>
/// <returns>Empty string to assign account delta sync for.</returns>
Task<string> ResetAccountDeltaTokenAsync(Guid accountId);
Task ManageCalendarEventAsync(Microsoft.Graph.Models.Event calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount);
}
public interface IImapChangeProcessor : IDefaultChangeProcessor

View File

@@ -85,6 +85,10 @@ namespace Wino.Core.Integration.Processors
totalDurationInSeconds = parentRecurringEvent.DurationInSeconds;
}
var organizerMail = GetOrganizerEmail(calendarEvent, organizerAccount);
var organizerName = GetOrganizerName(calendarEvent, organizerAccount);
calendarItem = new CalendarItem()
{
CalendarId = assignedCalendar.Id,
@@ -103,9 +107,11 @@ namespace Wino.Core.Integration.Processors
Title = string.IsNullOrEmpty(calendarEvent.Summary) ? parentRecurringEvent.Title : calendarEvent.Summary,
UpdatedAt = DateTimeOffset.UtcNow,
Visibility = string.IsNullOrEmpty(calendarEvent.Visibility) ? parentRecurringEvent.Visibility : GetVisibility(calendarEvent.Visibility),
HtmlLink = calendarEvent.HtmlLink,
HtmlLink = string.IsNullOrEmpty(calendarEvent.HtmlLink) ? parentRecurringEvent.HtmlLink : calendarEvent.HtmlLink,
RemoteEventId = calendarEvent.Id,
IsLocked = calendarEvent.Locked.GetValueOrDefault()
IsLocked = calendarEvent.Locked.GetValueOrDefault(),
OrganizerDisplayName = string.IsNullOrEmpty(organizerName) ? parentRecurringEvent.OrganizerDisplayName : organizerName,
OrganizerEmail = string.IsNullOrEmpty(organizerMail) ? parentRecurringEvent.OrganizerEmail : organizerMail
};
}
else
@@ -137,7 +143,9 @@ namespace Wino.Core.Integration.Processors
Visibility = GetVisibility(calendarEvent.Visibility),
HtmlLink = calendarEvent.HtmlLink,
RemoteEventId = calendarEvent.Id,
IsLocked = calendarEvent.Locked.GetValueOrDefault()
IsLocked = calendarEvent.Locked.GetValueOrDefault(),
OrganizerDisplayName = GetOrganizerName(calendarEvent, organizerAccount),
OrganizerEmail = GetOrganizerEmail(calendarEvent, organizerAccount)
};
}
@@ -216,8 +224,6 @@ namespace Wino.Core.Integration.Processors
// We have this event already. Update it.
if (calendarEvent.Status == "cancelled")
{
// Event is canceled.
// Parent event is canceled. We must delete everything.
if (string.IsNullOrEmpty(recurringEventId))
{
@@ -249,6 +255,30 @@ namespace Wino.Core.Integration.Processors
await Connection.InsertOrReplaceAsync(existingCalendarItem);
}
private string GetOrganizerName(Event calendarEvent, MailAccount account)
{
if (calendarEvent.Organizer == null) return string.Empty;
if (calendarEvent.Organizer.Self == true)
{
return account.SenderName;
}
else
return calendarEvent.Organizer.DisplayName;
}
private string GetOrganizerEmail(Event calendarEvent, MailAccount account)
{
if (calendarEvent.Organizer == null) return string.Empty;
if (calendarEvent.Organizer.Self == true)
{
return account.Address;
}
else
return calendarEvent.Organizer.Email;
}
private CalendarItemStatus GetStatus(string status)
{
return status switch

View File

@@ -1,6 +1,12 @@
using System;
using System.Threading.Tasks;
using Microsoft.Graph.Models;
using Serilog;
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;
namespace Wino.Core.Integration.Processors
@@ -35,5 +41,105 @@ namespace Wino.Core.Integration.Processors
public Task UpdateFolderDeltaSynchronizationIdentifierAsync(Guid folderId, string synchronizationIdentifier)
=> Connection.ExecuteAsync("UPDATE MailItemFolder SET DeltaToken = ? WHERE Id = ?", synchronizationIdentifier, folderId);
public async Task ManageCalendarEventAsync(Event calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount)
{
// TODO: Make sure to call this method ordered by type:SeriesMaster first.
// otherwise we might lose exceptions.s
// We parse the occurrences based on the parent event.
// There is literally no point to store them because
// type=Exception events are the exceptional childs of recurrency parent event.
if (calendarEvent.Type == EventType.Occurrence) return;
var savingItem = await CalendarService.GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.Id);
Guid savingItemId = Guid.Empty;
if (savingItem != null)
savingItemId = savingItem.Id;
else
{
savingItemId = Guid.NewGuid();
savingItem = new CalendarItem() { Id = savingItemId };
}
DateTimeOffset eventStartDateTimeOffset = OutlookIntegratorExtensions.GetDateTimeOffsetFromDateTimeTimeZone(calendarEvent.Start);
DateTimeOffset eventEndDateTimeOffset = OutlookIntegratorExtensions.GetDateTimeOffsetFromDateTimeTimeZone(calendarEvent.End);
var durationInSeconds = (eventEndDateTimeOffset - eventStartDateTimeOffset).TotalSeconds;
savingItem.RemoteEventId = calendarEvent.Id;
savingItem.StartDate = eventStartDateTimeOffset.DateTime;
savingItem.StartDateOffset = eventStartDateTimeOffset.Offset;
savingItem.EndDateOffset = eventEndDateTimeOffset.Offset;
savingItem.DurationInSeconds = durationInSeconds;
savingItem.Title = calendarEvent.Subject;
savingItem.Description = calendarEvent.Body?.Content;
savingItem.Location = calendarEvent.Location?.DisplayName;
if (calendarEvent.Type == EventType.Exception && !string.IsNullOrEmpty(calendarEvent.SeriesMasterId))
{
// This is a recurring event exception.
// We need to find the parent event and set it as recurring event id.
var parentEvent = await CalendarService.GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.SeriesMasterId);
if (parentEvent != null)
{
savingItem.RecurringCalendarItemId = parentEvent.Id;
}
else
{
Log.Warning($"Parent recurring event is missing for event. Skipping creation of {calendarEvent.Id}");
return;
}
}
// Convert the recurrence pattern to string for parent recurring events.
if (calendarEvent.Type == EventType.SeriesMaster && calendarEvent.Recurrence != null)
{
savingItem.Recurrence = OutlookIntegratorExtensions.ToRfc5545RecurrenceString(calendarEvent.Recurrence);
}
savingItem.HtmlLink = calendarEvent.WebLink;
savingItem.CalendarId = assignedCalendar.Id;
savingItem.OrganizerEmail = calendarEvent.Organizer?.EmailAddress?.Address;
savingItem.OrganizerDisplayName = calendarEvent.Organizer?.EmailAddress?.Name;
savingItem.IsHidden = false;
if (calendarEvent.ResponseStatus?.Response != null)
{
switch (calendarEvent.ResponseStatus.Response.Value)
{
case ResponseType.None:
case ResponseType.NotResponded:
savingItem.Status = CalendarItemStatus.NotResponded;
break;
case ResponseType.TentativelyAccepted:
savingItem.Status = CalendarItemStatus.Tentative;
break;
case ResponseType.Accepted:
case ResponseType.Organizer:
savingItem.Status = CalendarItemStatus.Confirmed;
break;
case ResponseType.Declined:
savingItem.Status = CalendarItemStatus.Cancelled;
savingItem.IsHidden = true;
break;
default:
break;
}
}
else
{
savingItem.Status = CalendarItemStatus.Confirmed;
}
// Upsert the event.
await Connection.InsertOrReplaceAsync(savingItem);
}
}
}

View File

@@ -3,18 +3,24 @@ using System.Drawing;
namespace Wino.Core.Misc
{
public static class RandomFlatColorGenerator
public static class ColorHelpers
{
public static Color Generate()
public static string GenerateFlatColorHex()
{
Random random = new();
int hue = random.Next(0, 360); // Full hue range
int saturation = 70 + random.Next(30); // High saturation (70-100%)
int lightness = 50 + random.Next(20); // Bright colors (50-70%)
return FromHsl(hue, saturation, lightness);
var color = FromHsl(hue, saturation, lightness);
return ToHexString(color);
}
public static string ToHexString(this Color c) => $"#{c.R:X2}{c.G:X2}{c.B:X2}";
public static string ToRgbString(this Color c) => $"RGB({c.R}, {c.G}, {c.B})";
private static Color FromHsl(int h, int s, int l)
{
double hue = h / 360.0;

View File

@@ -20,6 +20,7 @@ using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options;
using MimeKit;
using MoreLinq.Extensions;
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;
@@ -957,9 +958,177 @@ namespace Wino.Core.Synchronizers.Mail
return [package];
}
protected override Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
protected override async Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
_logger.Information("Internal calendar synchronization started for {Name}", Account.Name);
cancellationToken.ThrowIfCancellationRequested();
// await SynchronizeCalendarsAsync(cancellationToken).ConfigureAwait(false);
var localCalendars = await _outlookChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
foreach (var calendar in localCalendars)
{
bool isInitialSync = string.IsNullOrEmpty(calendar.SynchronizationDeltaToken);
Microsoft.Graph.Me.CalendarView.Delta.DeltaGetResponse eventsDeltaResponse = null;
if (isInitialSync)
{
_logger.Debug("No sync identifier for Calendar {FolderName}. Performing initial sync.", calendar.Name);
var startDate = DateTime.UtcNow.AddYears(-2).ToString("u");
var endDate = DateTime.UtcNow.ToString("u");
// No delta link. Performing initial sync.
eventsDeltaResponse = await _graphClient.Me.CalendarView.Delta.GetAsDeltaGetResponseAsync((requestConfiguration) =>
{
requestConfiguration.QueryParameters.StartDateTime = startDate;
requestConfiguration.QueryParameters.EndDateTime = endDate;
requestConfiguration.QueryParameters.Expand = ["calendar"];
}, cancellationToken: cancellationToken);
}
else
{
var currentDeltaToken = calendar.SynchronizationDeltaToken;
var requestInformation = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events.Delta.ToGetRequestInformation((config) =>
{
config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder;
config.QueryParameters.Select = outlookMessageSelectParameters;
config.QueryParameters.Orderby = ["receivedDateTime desc"];
});
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.Events.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.CalendarView.Delta.DeltaGetResponse>.CreatePageIterator(_graphClient, eventsDeltaResponse, (item) =>
{
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();
foreach (var item in events)
{
try
{
await _handleItemRetrievalSemaphore.WaitAsync();
await _outlookChangeProcessor.ManageCalendarEventAsync(item, calendar, Account).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.Error(ex, "Error occurred while handling item {Id} for calendar {Name}", item.Id, calendar.Name);
}
finally
{
_handleItemRetrievalSemaphore.Release();
}
}
// latestDeltaLink = messageIteratorAsync.Deltalink;
}
return default;
}
private async Task SynchronizeCalendarsAsync(CancellationToken cancellationToken = default)
{
var calendars = await _graphClient.Me.Calendars.GetAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
var localCalendars = await _outlookChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
List<AccountCalendar> insertedCalendars = new();
List<AccountCalendar> updatedCalendars = new();
List<AccountCalendar> deletedCalendars = new();
// 1. Handle deleted calendars.
foreach (var calendar in localCalendars)
{
var remoteCalendar = calendars.Value.FirstOrDefault(a => a.Id == calendar.RemoteCalendarId);
if (remoteCalendar == null)
{
// Local calendar doesn't exists remotely. Delete local copy.
await _outlookChangeProcessor.DeleteAccountCalendarAsync(calendar).ConfigureAwait(false);
deletedCalendars.Add(calendar);
}
}
// Delete the deleted folders from local list.
deletedCalendars.ForEach(a => localCalendars.Remove(a));
// 2. Handle update/insert based on remote calendars.
foreach (var calendar in calendars.Value)
{
var existingLocalCalendar = localCalendars.FirstOrDefault(a => a.RemoteCalendarId == calendar.Id);
if (existingLocalCalendar == null)
{
// Insert new calendar.
var localCalendar = calendar.AsCalendar(Account);
insertedCalendars.Add(localCalendar);
}
else
{
// Update existing calendar. Right now we only update the name.
if (ShouldUpdateCalendar(calendar, existingLocalCalendar))
{
existingLocalCalendar.Name = calendar.Name;
updatedCalendars.Add(existingLocalCalendar);
}
else
{
// 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 calendar in insertedCalendars)
{
await _outlookChangeProcessor.InsertAccountCalendarAsync(calendar).ConfigureAwait(false);
}
foreach (var calendar in updatedCalendars)
{
await _outlookChangeProcessor.UpdateAccountCalendarAsync(calendar).ConfigureAwait(false);
}
if (insertedCalendars.Any() || deletedCalendars.Any() || updatedCalendars.Any())
{
// TODO: Notify calendar updates.
// WeakReferenceMessenger.Default.Send(new AccountFolderConfigurationUpdated(Account.Id));
}
}
private bool ShouldUpdateCalendar(Calendar calendar, AccountCalendar accountCalendar)
{
// TODO: Only calendar name is updated for now. We can add more checks here.
var remoteCalendarName = calendar.Name;
var localCalendarName = accountCalendar.Name;
return !localCalendarName.Equals(remoteCalendarName, StringComparison.OrdinalIgnoreCase);
}
}
}