Bunch of calendar implementation thing.

This commit is contained in:
Burak Kaan Köse
2026-02-15 11:27:30 +01:00
parent acf0f649e8
commit 42e51571a8
14 changed files with 804 additions and 128 deletions
@@ -131,7 +131,7 @@ public static class GoogleIntegratorExtensions
}).ToList();
}
public static AccountCalendar AsCalendar(this CalendarListEntry calendarListEntry, Guid accountId)
public static AccountCalendar AsCalendar(this CalendarListEntry calendarListEntry, Guid accountId, string fallbackBackgroundColor = null)
{
var calendar = new AccountCalendar()
{
@@ -147,7 +147,9 @@ public static class GoogleIntegratorExtensions
// Bg color must present. Generate one if doesnt exists.
// Text color is optional. It'll be overriden by UI for readibility.
calendar.BackgroundColorHex = string.IsNullOrEmpty(calendarListEntry.BackgroundColor) ? ColorHelpers.GenerateFlatColorHex() : calendarListEntry.BackgroundColor;
calendar.BackgroundColorHex = string.IsNullOrEmpty(calendarListEntry.BackgroundColor)
? fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex()
: calendarListEntry.BackgroundColor;
calendar.TextColorHex = string.IsNullOrEmpty(calendarListEntry.ForegroundColor) ? "#000000" : calendarListEntry.ForegroundColor;
return calendar;
@@ -174,7 +174,7 @@ public static class OutlookIntegratorExtensions
return message;
}
public static AccountCalendar AsCalendar(this Calendar outlookCalendar, MailAccount assignedAccount)
public static AccountCalendar AsCalendar(this Calendar outlookCalendar, MailAccount assignedAccount, string fallbackBackgroundColor = null)
{
var calendar = new AccountCalendar()
{
@@ -191,7 +191,9 @@ public static class OutlookIntegratorExtensions
// 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.BackgroundColorHex = string.IsNullOrEmpty(outlookCalendar.HexColor)
? fallbackBackgroundColor ?? ColorHelpers.GenerateFlatColorHex()
: outlookCalendar.HexColor;
calendar.TextColorHex = "#000000";
return calendar;
+93 -27
View File
@@ -1,49 +1,115 @@
using System;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Globalization;
namespace Wino.Core.Misc;
public static class ColorHelpers
{
public static string GenerateFlatColorHex()
private static readonly string[] FlatUiColorPalette =
[
"#B91C1C", "#C2410C", "#B45309", "#A16207", "#4D7C0F", "#15803D", "#047857", "#0F766E", "#0E7490", "#0369A1",
"#1D4ED8", "#4338CA", "#6D28D9", "#7E22CE", "#A21CAF", "#BE185D", "#E11D48", "#DC2626", "#EA580C", "#D97706",
"#CA8A04", "#65A30D", "#16A34A", "#059669", "#0D9488", "#0891B2", "#0284C7", "#2563EB", "#4F46E5", "#7C3AED",
"#9333EA", "#C026D3", "#DB2777", "#F43F5E", "#EF4444", "#F97316", "#F59E0B", "#EAB308", "#84CC16", "#22C55E",
"#10B981", "#14B8A6", "#06B6D4", "#0EA5E9", "#3B82F6", "#6366F1", "#8B5CF6", "#A855F7", "#D946EF", "#EC4899",
"#FB7185", "#F87171", "#FB923C", "#FBBF24", "#FACC15", "#A3E635", "#4ADE80", "#34D399", "#2DD4BF", "#22D3EE",
"#38BDF8", "#60A5FA", "#818CF8", "#A78BFA", "#C084FC", "#E879F9", "#F472B6", "#FDA4AF", "#FCA5A5", "#FDBA74",
"#FCD34D", "#FDE047", "#BEF264", "#86EFAC", "#6EE7B7", "#5EEAD4", "#67E8F9", "#7DD3FC", "#93C5FD", "#A5B4FC",
"#C4B5FD", "#D8B4FE", "#F0ABFC", "#F9A8D4", "#A16207", "#9A3412", "#7C2D12", "#6F1D1B", "#7F1D1D", "#881337",
"#831843", "#701A75", "#581C87", "#312E81", "#1E3A8A", "#1D4ED8", "#155E75", "#134E4A", "#14532D", "#3F6212",
"#365314", "#3F3F46", "#52525B", "#57534E", "#44403C", "#78716C", "#6B7280", "#4B5563", "#374151", "#1F2937",
"#A16207", "#B45309", "#C2410C", "#9F1239", "#BE123C", "#C026D3", "#7E22CE", "#6D28D9", "#4338CA", "#1D4ED8"
];
public static IReadOnlyList<string> GetFlatColorPalette() => FlatUiColorPalette;
public static string GenerateFlatColorHex() => GetDistinctFlatColorHex(Array.Empty<string>());
public static string GetDistinctFlatColorHex(IEnumerable<string> usedColors)
{
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%)
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var color = FromHsl(hue, saturation, lightness);
if (usedColors != null)
{
foreach (var color in usedColors)
{
if (TryNormalizeHexColor(color, out var normalized))
{
used.Add(normalized);
}
}
}
return ToHexString(color);
foreach (var color in FlatUiColorPalette)
{
if (!used.Contains(color))
{
return color;
}
}
var attempt = 0;
while (attempt < 500)
{
var baseColor = FlatUiColorPalette[attempt % FlatUiColorPalette.Length];
var cycle = (attempt / FlatUiColorPalette.Length) + 1;
var candidate = AdjustColor(baseColor, cycle);
if (!used.Contains(candidate))
{
return candidate;
}
attempt++;
}
return "#5C7A8A";
}
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)
private static bool TryNormalizeHexColor(string value, out string normalized)
{
double hue = h / 360.0;
double saturation = s / 100.0;
double lightness = l / 100.0;
normalized = null;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
// Conversion from HSL to RGB
var chroma = (1 - Math.Abs(2 * lightness - 1)) * saturation;
var x = chroma * (1 - Math.Abs((hue * 6) % 2 - 1));
var m = lightness - chroma / 2;
var color = value.Trim();
if (color.StartsWith('#'))
{
color = color[1..];
}
double r = 0, g = 0, b = 0;
if (color.Length != 6)
{
return false;
}
if (hue < 1.0 / 6.0) { r = chroma; g = x; b = 0; }
else if (hue < 2.0 / 6.0) { r = x; g = chroma; b = 0; }
else if (hue < 3.0 / 6.0) { r = 0; g = chroma; b = x; }
else if (hue < 4.0 / 6.0) { r = 0; g = x; b = chroma; }
else if (hue < 5.0 / 6.0) { r = x; g = 0; b = chroma; }
else { r = chroma; g = 0; b = x; }
if (!int.TryParse(color, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _))
{
return false;
}
return Color.FromArgb(
(int)((r + m) * 255),
(int)((g + m) * 255),
(int)((b + m) * 255));
normalized = $"#{color.ToUpperInvariant()}";
return true;
}
private static string AdjustColor(string hexColor, int cycle)
{
var color = ColorTranslator.FromHtml(hexColor);
var factor = Math.Max(0.55, 1.0 - (cycle * 0.08));
var adjusted = Color.FromArgb(
(int)Math.Clamp(color.R * factor, 0, 255),
(int)Math.Clamp(color.G * factor, 0, 255),
(int)Math.Clamp(color.B * factor, 0, 255));
return adjusted.ToHexString();
}
}
@@ -9,18 +9,20 @@ public class ImapRequest
{
public Func<IImapClient, IRequestBase, Task> IntegratorTask { get; }
public IRequestBase Request { get; }
public bool RequiresConnectedClient { get; }
public ImapRequest(Func<IImapClient, IRequestBase, Task> integratorTask, IRequestBase request)
public ImapRequest(Func<IImapClient, IRequestBase, Task> integratorTask, IRequestBase request, bool requiresConnectedClient = true)
{
IntegratorTask = integratorTask;
Request = request;
RequiresConnectedClient = requiresConnectedClient;
}
}
public class ImapRequest<TRequestBaseType> : ImapRequest where TRequestBaseType : IRequestBase
{
public ImapRequest(Func<IImapClient, TRequestBaseType, Task> integratorTask, TRequestBaseType request)
: base((client, request) => integratorTask(client, (TRequestBaseType)request), request)
public ImapRequest(Func<IImapClient, TRequestBaseType, Task> integratorTask, TRequestBaseType request, bool requiresConnectedClient = true)
: base((client, request) => integratorTask(client, (TRequestBaseType)request), request, requiresConnectedClient)
{
}
}
+5 -2
View File
@@ -25,6 +25,7 @@ public class SynchronizerFactory : ISynchronizerFactory
private readonly UnifiedImapSynchronizer _unifiedImapSynchronizer;
private readonly ICalDavClient _calDavClient;
private readonly IAutoDiscoveryService _autoDiscoveryService;
private readonly ICalendarService _calendarService;
private readonly List<IWinoSynchronizerBase> synchronizerCache = new();
@@ -39,7 +40,8 @@ public class SynchronizerFactory : ISynchronizerFactory
IImapSynchronizerErrorHandlerFactory imapSynchronizerErrorHandlerFactory,
UnifiedImapSynchronizer unifiedImapSynchronizer,
ICalDavClient calDavClient,
IAutoDiscoveryService autoDiscoveryService)
IAutoDiscoveryService autoDiscoveryService,
ICalendarService calendarService)
{
_outlookChangeProcessor = outlookChangeProcessor;
_gmailChangeProcessor = gmailChangeProcessor;
@@ -53,6 +55,7 @@ public class SynchronizerFactory : ISynchronizerFactory
_unifiedImapSynchronizer = unifiedImapSynchronizer;
_calDavClient = calDavClient;
_autoDiscoveryService = autoDiscoveryService;
_calendarService = calendarService;
}
public async Task<IWinoSynchronizerBase> GetAccountSynchronizerAsync(Guid accountId)
@@ -88,7 +91,7 @@ public class SynchronizerFactory : ISynchronizerFactory
var gmailAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Gmail) as IGmailAuthenticator;
return new GmailSynchronizer(mailAccount, gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory);
case Domain.Enums.MailProviderType.IMAP4:
return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _applicationConfiguration, _unifiedImapSynchronizer, _imapSynchronizerErrorHandlerFactory, _calDavClient, _autoDiscoveryService);
return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _applicationConfiguration, _unifiedImapSynchronizer, _imapSynchronizerErrorHandlerFactory, _calDavClient, _autoDiscoveryService, _calendarService);
default:
break;
}
+11 -1
View File
@@ -34,6 +34,7 @@ using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Extensions;
using Wino.Core.Http;
using Wino.Core.Integration.Processors;
using Wino.Core.Misc;
using Wino.Core.Requests.Bundles;
using Wino.Core.Requests.Calendar;
using Wino.Core.Requests.Folder;
@@ -583,6 +584,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
var localCalendars = await _gmailChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
var remotePrimaryCalendarId = GetPrimaryCalendarId(calendarListResponse.Items);
var usedCalendarColors = new HashSet<string>(
localCalendars
.Select(a => a.BackgroundColorHex)
.Where(a => !string.IsNullOrWhiteSpace(a)),
StringComparer.OrdinalIgnoreCase);
List<AccountCalendar> insertedCalendars = new();
List<AccountCalendar> updatedCalendars = new();
@@ -612,8 +618,12 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
if (existingLocalCalendar == null)
{
// Insert new calendar.
var localCalendar = calendar.AsCalendar(Account.Id);
var fallbackColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors);
var localCalendar = calendar.AsCalendar(Account.Id, fallbackColor);
localCalendar.IsPrimary = string.Equals(localCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(localCalendar.BackgroundColorHex) || usedCalendarColors.Contains(localCalendar.BackgroundColorHex))
localCalendar.BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors);
usedCalendarColors.Add(localCalendar.BackgroundColorHex);
insertedCalendars.Add(localCalendar);
}
else
+353 -4
View File
@@ -25,6 +25,7 @@ using Wino.Core.Extensions;
using Wino.Core.Integration;
using Wino.Core.Integration.Processors;
using Wino.Core.Requests.Bundles;
using Wino.Core.Requests.Calendar;
using Wino.Core.Requests.Folder;
using Wino.Core.Requests.Mail;
using Wino.Core.Synchronizers.ImapSync;
@@ -63,9 +64,12 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
private readonly IImapSynchronizerErrorHandlerFactory _errorHandlerFactory;
private readonly ICalDavClient _calDavClient;
private readonly IAutoDiscoveryService _autoDiscoveryService;
private readonly ICalendarService _calendarService;
private readonly SemaphoreSlim _calDavDiscoveryLock = new(1, 1);
private Uri _cachedCalDavServiceUri;
private bool _isCalDavDiscoveryAttempted;
private readonly IImapCalendarOperationHandler _localCalendarOperationHandler;
private readonly IImapCalendarOperationHandler _calDavCalendarOperationHandler;
public ImapSynchronizer(MailAccount account,
IImapChangeProcessor imapChangeProcessor,
@@ -73,7 +77,8 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
UnifiedImapSynchronizer unifiedSynchronizer,
IImapSynchronizerErrorHandlerFactory errorHandlerFactory,
ICalDavClient calDavClient,
IAutoDiscoveryService autoDiscoveryService) : base(account, WeakReferenceMessenger.Default)
IAutoDiscoveryService autoDiscoveryService,
ICalendarService calendarService) : base(account, WeakReferenceMessenger.Default)
{
// Create client pool with account protocol log.
_imapChangeProcessor = imapChangeProcessor;
@@ -82,11 +87,14 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
_errorHandlerFactory = errorHandlerFactory;
_calDavClient = calDavClient;
_autoDiscoveryService = autoDiscoveryService;
_calendarService = calendarService;
var protocolLogStream = CreateAccountProtocolLogFileStream();
var poolOptions = ImapClientPoolOptions.CreateDefault(Account.ServerInformation, protocolLogStream);
_clientPool = new ImapClientPool(poolOptions);
_localCalendarOperationHandler = new LocalCalendarOperationHandler(Account, _imapChangeProcessor, _calendarService, "local");
_calDavCalendarOperationHandler = new CalDavCalendarOperationHandler(Account, _imapChangeProcessor, _calendarService);
}
private Stream CreateAccountProtocolLogFileStream()
@@ -319,6 +327,87 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
}, request, request);
}
public override List<IRequestBundle<ImapRequest>> CreateCalendarEvent(CreateCalendarEventRequest request)
{
var handler = ResolveCalendarOperationHandler();
return CreateCalendarOperationTaskBundle(
request,
async value => await handler.CreateCalendarEventAsync(value).ConfigureAwait(false),
handler.RequiresConnectedClient);
}
public override List<IRequestBundle<ImapRequest>> UpdateCalendarEvent(UpdateCalendarEventRequest request)
{
var handler = ResolveCalendarOperationHandler();
return CreateCalendarOperationTaskBundle(
request,
async value => await handler.UpdateCalendarEventAsync(value).ConfigureAwait(false),
handler.RequiresConnectedClient);
}
public override List<IRequestBundle<ImapRequest>> DeleteCalendarEvent(DeleteCalendarEventRequest request)
{
var handler = ResolveCalendarOperationHandler();
return CreateCalendarOperationTaskBundle(
request,
async value => await handler.DeleteCalendarEventAsync(value).ConfigureAwait(false),
handler.RequiresConnectedClient);
}
public override List<IRequestBundle<ImapRequest>> AcceptEvent(AcceptEventRequest request)
{
var handler = ResolveCalendarOperationHandler();
return CreateCalendarOperationTaskBundle(
request,
async value => await handler.AcceptEventAsync(value).ConfigureAwait(false),
handler.RequiresConnectedClient);
}
public override List<IRequestBundle<ImapRequest>> DeclineEvent(DeclineEventRequest request)
{
var handler = ResolveCalendarOperationHandler();
return CreateCalendarOperationTaskBundle(
request,
async value => await handler.DeclineEventAsync(value).ConfigureAwait(false),
handler.RequiresConnectedClient);
}
public override List<IRequestBundle<ImapRequest>> TentativeEvent(TentativeEventRequest request)
{
var handler = ResolveCalendarOperationHandler();
return CreateCalendarOperationTaskBundle(
request,
async value => await handler.TentativeEventAsync(value).ConfigureAwait(false),
handler.RequiresConnectedClient);
}
private IImapCalendarOperationHandler ResolveCalendarOperationHandler()
{
var mode = Account.ServerInformation?.CalendarSupportMode ?? ImapCalendarSupportMode.Disabled;
return mode switch
{
ImapCalendarSupportMode.LocalOnly => _localCalendarOperationHandler,
ImapCalendarSupportMode.CalDav => _calDavCalendarOperationHandler,
_ => throw new NotSupportedException("Calendar operations are disabled for this IMAP account.")
};
}
private List<IRequestBundle<ImapRequest>> CreateCalendarOperationTaskBundle<TRequest>(
TRequest request,
Func<TRequest, Task> operation,
bool requiresConnectedClient)
where TRequest : IRequestBase, IUIChangeRequest
{
return
[
new ImapRequestBundle(
new ImapRequest<TRequest>((client, value) => operation(value), request, requiresConnectedClient),
request,
request)
];
}
#endregion
public override async Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(ImapMessageCreationPackage message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
@@ -635,7 +724,10 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
try
{
executorClient = await _clientPool.GetClientAsync();
if (item.NativeRequest.RequiresConnectedClient)
{
executorClient = await _clientPool.GetClientAsync();
}
}
catch (ImapClientPoolException)
{
@@ -682,7 +774,10 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
}
finally
{
_clientPool.Release(executorClient);
if (executorClient != null)
{
_clientPool.Release(executorClient);
}
}
}
}
@@ -1240,6 +1335,11 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
var remoteCalendarsById = remoteCalendars
.GroupBy(c => c.RemoteCalendarId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var usedCalendarColors = new HashSet<string>(
localCalendars
.Select(a => a.BackgroundColorHex)
.Where(a => !string.IsNullOrWhiteSpace(a)),
StringComparer.OrdinalIgnoreCase);
var remotePrimaryCalendarId = remoteCalendars.FirstOrDefault()?.RemoteCalendarId;
@@ -1274,11 +1374,12 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
IsSynchronizationEnabled = true,
IsExtended = true,
TextColorHex = "#000000",
BackgroundColorHex = ColorHelpers.GenerateFlatColorHex(),
BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors),
TimeZone = "UTC",
SynchronizationDeltaToken = string.Empty
};
usedCalendarColors.Add(newCalendar.BackgroundColorHex);
await _imapChangeProcessor.InsertAccountCalendarAsync(newCalendar).ConfigureAwait(false);
continue;
}
@@ -1295,6 +1396,254 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
}
}
private interface IImapCalendarOperationHandler
{
bool RequiresConnectedClient { get; }
Task CreateCalendarEventAsync(CreateCalendarEventRequest request);
Task UpdateCalendarEventAsync(UpdateCalendarEventRequest request);
Task DeleteCalendarEventAsync(DeleteCalendarEventRequest request);
Task AcceptEventAsync(AcceptEventRequest request);
Task DeclineEventAsync(DeclineEventRequest request);
Task TentativeEventAsync(TentativeEventRequest request);
}
private class LocalCalendarOperationHandler : IImapCalendarOperationHandler
{
private readonly MailAccount _account;
private readonly IImapChangeProcessor _changeProcessor;
private readonly ICalendarService _calendarService;
private readonly string _resourceScheme;
public bool RequiresConnectedClient => false;
public LocalCalendarOperationHandler(MailAccount account, IImapChangeProcessor changeProcessor, ICalendarService calendarService, string resourceScheme)
{
_account = account;
_changeProcessor = changeProcessor;
_calendarService = calendarService;
_resourceScheme = resourceScheme;
}
public async Task CreateCalendarEventAsync(CreateCalendarEventRequest request)
{
var item = request.Item;
EnsureCalendarItemDefaults(item, _account, "local");
item.AssignedCalendar ??= await _calendarService.GetAccountCalendarAsync(item.CalendarId).ConfigureAwait(false);
var existing = await _calendarService.GetCalendarItemAsync(item.Id).ConfigureAwait(false);
if (existing == null)
await _calendarService.CreateNewCalendarItemAsync(item, request.Attendees).ConfigureAwait(false);
else
await _calendarService.UpdateCalendarItemAsync(item, request.Attendees).ConfigureAwait(false);
await PersistIcsAsync(item, request.Attendees).ConfigureAwait(false);
}
public async Task UpdateCalendarEventAsync(UpdateCalendarEventRequest request)
{
var item = request.Item;
EnsureCalendarItemDefaults(item, _account, "local");
item.AssignedCalendar ??= await _calendarService.GetAccountCalendarAsync(item.CalendarId).ConfigureAwait(false);
var attendees = request.Attendees ?? await _calendarService.GetAttendeesAsync(item.Id).ConfigureAwait(false);
await _calendarService.UpdateCalendarItemAsync(item, attendees).ConfigureAwait(false);
await PersistIcsAsync(item, attendees).ConfigureAwait(false);
}
public Task DeleteCalendarEventAsync(DeleteCalendarEventRequest request)
=> _changeProcessor.DeleteCalendarItemAsync(request.Item.Id);
public async Task AcceptEventAsync(AcceptEventRequest request)
{
request.Item.Status = CalendarItemStatus.Accepted;
await UpdateStatusAsync(request.Item).ConfigureAwait(false);
}
public async Task DeclineEventAsync(DeclineEventRequest request)
{
request.Item.Status = CalendarItemStatus.Cancelled;
await UpdateStatusAsync(request.Item).ConfigureAwait(false);
}
public async Task TentativeEventAsync(TentativeEventRequest request)
{
request.Item.Status = CalendarItemStatus.Tentative;
await UpdateStatusAsync(request.Item).ConfigureAwait(false);
}
private async Task UpdateStatusAsync(CalendarItem item)
{
EnsureCalendarItemDefaults(item, _account, "local");
item.AssignedCalendar ??= await _calendarService.GetAccountCalendarAsync(item.CalendarId).ConfigureAwait(false);
var attendees = await _calendarService.GetAttendeesAsync(item.Id).ConfigureAwait(false);
await _calendarService.UpdateCalendarItemAsync(item, attendees).ConfigureAwait(false);
await PersistIcsAsync(item, attendees).ConfigureAwait(false);
}
private Task PersistIcsAsync(CalendarItem item, List<CalendarEventAttendee> attendees)
{
var resourceHref = $"{_resourceScheme}://calendar/{item.CalendarId:N}/{item.Id:N}";
var icsContent = BuildIcsContent(item, attendees);
return _changeProcessor.SaveCalendarItemIcsAsync(
_account.Id,
item.CalendarId,
item.Id,
item.RemoteEventId,
resourceHref,
DateTimeOffset.UtcNow.ToString("O"),
icsContent);
}
}
private sealed class CalDavCalendarOperationHandler : LocalCalendarOperationHandler
{
public CalDavCalendarOperationHandler(MailAccount account, IImapChangeProcessor changeProcessor, ICalendarService calendarService)
: base(account, changeProcessor, calendarService, "caldav")
{
}
}
private static void EnsureCalendarItemDefaults(CalendarItem item, MailAccount account, string idPrefix)
{
if (item == null)
throw new ArgumentNullException(nameof(item));
if (item.Id == Guid.Empty)
item.Id = Guid.NewGuid();
if (string.IsNullOrWhiteSpace(item.RemoteEventId))
item.RemoteEventId = $"{idPrefix}-{item.Id:N}";
if (item.CreatedAt == default)
item.CreatedAt = DateTimeOffset.UtcNow;
item.UpdatedAt = DateTimeOffset.UtcNow;
item.OrganizerDisplayName ??= account?.SenderName ?? string.Empty;
item.OrganizerEmail ??= account?.Address ?? string.Empty;
item.StartTimeZone ??= TimeZoneInfo.Local.Id;
item.EndTimeZone ??= item.StartTimeZone;
}
private static string BuildIcsContent(CalendarItem item, List<CalendarEventAttendee> attendees)
{
var uid = item.RemoteEventId?.Split(new[] { "::" }, StringSplitOptions.None)[0] ?? item.Id.ToString("N");
var dtStamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd'T'HHmmss'Z'");
var lines = new List<string>
{
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Wino Mail//Calendar//EN",
"CALSCALE:GREGORIAN",
"BEGIN:VEVENT",
$"UID:{EscapeIcs(uid)}",
$"DTSTAMP:{dtStamp}",
};
if (item.IsAllDayEvent)
{
lines.Add($"DTSTART;VALUE=DATE:{item.StartDate:yyyyMMdd}");
lines.Add($"DTEND;VALUE=DATE:{item.EndDate:yyyyMMdd}");
}
else
{
lines.Add($"DTSTART:{item.StartDate.ToUniversalTime():yyyyMMdd'T'HHmmss'Z'}");
lines.Add($"DTEND:{item.EndDate.ToUniversalTime():yyyyMMdd'T'HHmmss'Z'}");
}
if (!string.IsNullOrWhiteSpace(item.Title))
lines.Add($"SUMMARY:{EscapeIcs(item.Title)}");
if (!string.IsNullOrWhiteSpace(item.Description))
lines.Add($"DESCRIPTION:{EscapeIcs(item.Description)}");
if (!string.IsNullOrWhiteSpace(item.Location))
lines.Add($"LOCATION:{EscapeIcs(item.Location)}");
lines.Add($"STATUS:{MapStatus(item.Status)}");
lines.Add($"TRANSP:{(item.ShowAs == CalendarItemShowAs.Free ? "TRANSPARENT" : "OPAQUE")}");
lines.Add($"CLASS:{MapVisibility(item.Visibility)}");
if (!string.IsNullOrWhiteSpace(item.Recurrence))
{
var recurrenceLines = item.Recurrence
.Split(Wino.Core.Domain.Constants.CalendarEventRecurrenceRuleSeperator, StringSplitOptions.RemoveEmptyEntries)
.Select(l => l.Trim())
.Where(l => !string.IsNullOrWhiteSpace(l));
lines.AddRange(recurrenceLines);
}
if (!string.IsNullOrWhiteSpace(item.OrganizerEmail))
{
var organizerName = string.IsNullOrWhiteSpace(item.OrganizerDisplayName)
? item.OrganizerEmail
: item.OrganizerDisplayName;
lines.Add($"ORGANIZER;CN={EscapeIcs(organizerName)}:mailto:{EscapeIcs(item.OrganizerEmail)}");
}
if (attendees != null)
{
foreach (var attendee in attendees.Where(a => !string.IsNullOrWhiteSpace(a.Email)))
{
var role = attendee.IsOptionalAttendee ? "OPT-PARTICIPANT" : "REQ-PARTICIPANT";
var partStat = attendee.AttendenceStatus switch
{
AttendeeStatus.Accepted => "ACCEPTED",
AttendeeStatus.Declined => "DECLINED",
AttendeeStatus.Tentative => "TENTATIVE",
_ => "NEEDS-ACTION"
};
var cn = string.IsNullOrWhiteSpace(attendee.Name) ? attendee.Email : attendee.Name;
lines.Add($"ATTENDEE;CN={EscapeIcs(cn)};ROLE={role};PARTSTAT={partStat}:mailto:{EscapeIcs(attendee.Email)}");
}
}
lines.Add("END:VEVENT");
lines.Add("END:VCALENDAR");
return string.Join(Environment.NewLine, lines);
}
private static string EscapeIcs(string value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
return value
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace(";", "\\;", StringComparison.Ordinal)
.Replace(",", "\\,", StringComparison.Ordinal)
.Replace("\r\n", "\\n", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal);
}
private static string MapStatus(CalendarItemStatus status)
{
return status switch
{
CalendarItemStatus.Cancelled => "CANCELLED",
CalendarItemStatus.Tentative => "TENTATIVE",
_ => "CONFIRMED"
};
}
private static string MapVisibility(CalendarItemVisibility visibility)
{
return visibility switch
{
CalendarItemVisibility.Public => "PUBLIC",
CalendarItemVisibility.Private => "PRIVATE",
CalendarItemVisibility.Confidential => "CONFIDENTIAL",
_ => "PUBLIC"
};
}
public Task StartIdleClientAsync()
{
if (IsDisposing)
+11 -1
View File
@@ -36,6 +36,7 @@ using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Extensions;
using Wino.Core.Http;
using Wino.Core.Integration.Processors;
using Wino.Core.Misc;
using Wino.Core.Requests.Bundles;
using Wino.Core.Requests.Calendar;
using Wino.Core.Requests.Folder;
@@ -2109,6 +2110,11 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
var remotePrimaryCalendarId = await GetPrimaryCalendarIdAsync(calendars.Value, cancellationToken).ConfigureAwait(false);
var localCalendars = await _outlookChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
var usedCalendarColors = new HashSet<string>(
localCalendars
.Select(a => a.BackgroundColorHex)
.Where(a => !string.IsNullOrWhiteSpace(a)),
StringComparer.OrdinalIgnoreCase);
List<AccountCalendar> insertedCalendars = new();
List<AccountCalendar> updatedCalendars = new();
@@ -2138,8 +2144,12 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
if (existingLocalCalendar == null)
{
// Insert new calendar.
var localCalendar = calendar.AsCalendar(Account);
var fallbackColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors);
var localCalendar = calendar.AsCalendar(Account, fallbackColor);
localCalendar.IsPrimary = string.Equals(localCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(localCalendar.BackgroundColorHex) || usedCalendarColors.Contains(localCalendar.BackgroundColorHex))
localCalendar.BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors);
usedCalendarColors.Add(localCalendar.BackgroundColorHex);
insertedCalendars.Add(localCalendar);
}
else