Fixed iCloud/Yahoo special providers and implemented more calendar metadata to support calendar colors in CalDav synchronizer.
This commit is contained in:
@@ -1,10 +1,19 @@
|
|||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
|
||||||
namespace Wino.Core.Domain.Models.Calendar;
|
namespace Wino.Core.Domain.Models.Calendar;
|
||||||
|
|
||||||
public sealed class CalDavCalendar
|
public sealed class CalDavCalendar
|
||||||
{
|
{
|
||||||
public string RemoteCalendarId { get; init; } = string.Empty;
|
public string RemoteCalendarId { get; init; } = string.Empty;
|
||||||
public string Name { get; init; } = string.Empty;
|
public string Name { get; init; } = string.Empty;
|
||||||
|
public string Description { get; init; } = string.Empty;
|
||||||
public string CTag { get; init; } = string.Empty;
|
public string CTag { get; init; } = string.Empty;
|
||||||
public string SyncToken { get; init; } = string.Empty;
|
public string SyncToken { get; init; } = string.Empty;
|
||||||
|
public string TimeZone { get; init; } = string.Empty;
|
||||||
|
public string BackgroundColorHex { get; init; } = string.Empty;
|
||||||
|
public bool IsReadOnly { get; init; }
|
||||||
|
public bool SupportsEvents { get; init; } = true;
|
||||||
|
public CalendarItemShowAs DefaultShowAs { get; init; } = CalendarItemShowAs.Busy;
|
||||||
|
public double? Order { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Models.Accounts;
|
||||||
|
using Wino.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Wino.Core.Tests.Services;
|
||||||
|
|
||||||
|
public class SpecialImapProviderConfigResolverTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetServerInformation_ICloud_UsesMailboxLocalPartForIncomingAndOutgoingUsernames()
|
||||||
|
{
|
||||||
|
var sut = new SpecialImapProviderConfigResolver();
|
||||||
|
var account = new MailAccount
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Address = "tester@icloud.com"
|
||||||
|
};
|
||||||
|
var dialogResult = new AccountCreationDialogResult(
|
||||||
|
MailProviderType.IMAP4,
|
||||||
|
"iCloud",
|
||||||
|
new SpecialImapProviderDetails(
|
||||||
|
"tester@icloud.com",
|
||||||
|
"app-password",
|
||||||
|
"Tester",
|
||||||
|
SpecialImapProvider.iCloud,
|
||||||
|
ImapCalendarSupportMode.CalDav),
|
||||||
|
"#0078D4",
|
||||||
|
InitialSynchronizationRange.SixMonths,
|
||||||
|
true,
|
||||||
|
true);
|
||||||
|
|
||||||
|
var serverInformation = sut.GetServerInformation(account, dialogResult);
|
||||||
|
|
||||||
|
serverInformation.IncomingServerUsername.Should().Be("tester");
|
||||||
|
serverInformation.OutgoingServerUsername.Should().Be("tester");
|
||||||
|
serverInformation.CalDavUsername.Should().Be("tester@icloud.com");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.IO;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Moq;
|
||||||
|
using Wino.Core.Domain.Entities.Calendar;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.Calendar;
|
||||||
|
using Wino.Core.Integration.Processors;
|
||||||
|
using Wino.Core.Misc;
|
||||||
|
using Wino.Core.Synchronizers.ImapSync;
|
||||||
|
using Wino.Core.Synchronizers.Mail;
|
||||||
|
using Wino.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Wino.Core.Tests.Synchronizers;
|
||||||
|
|
||||||
|
public class CalDavCalendarMetadataTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ParseCalendarCollection_MapsCollectionMetadataAndSkipsNonEventCalendars()
|
||||||
|
{
|
||||||
|
var xml = XDocument.Parse(
|
||||||
|
"""
|
||||||
|
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
|
||||||
|
<D:response>
|
||||||
|
<D:href>/calendars/work/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype>
|
||||||
|
<D:collection />
|
||||||
|
<C:calendar />
|
||||||
|
</D:resourcetype>
|
||||||
|
<D:displayname>Work</D:displayname>
|
||||||
|
<C:calendar-description>Team calendar</C:calendar-description>
|
||||||
|
<CS:getctag>"ctag-1"</CS:getctag>
|
||||||
|
<D:sync-token>sync-1</D:sync-token>
|
||||||
|
<D:current-user-privilege-set>
|
||||||
|
<D:privilege>
|
||||||
|
<D:read />
|
||||||
|
</D:privilege>
|
||||||
|
</D:current-user-privilege-set>
|
||||||
|
<C:calendar-timezone><![CDATA[
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Europe/Warsaw
|
||||||
|
END:VTIMEZONE
|
||||||
|
END:VCALENDAR
|
||||||
|
]]></C:calendar-timezone>
|
||||||
|
<C:supported-calendar-component-set>
|
||||||
|
<C:comp name="VEVENT" />
|
||||||
|
<C:comp name="VTODO" />
|
||||||
|
</C:supported-calendar-component-set>
|
||||||
|
<C:schedule-calendar-transp>
|
||||||
|
<C:transparent />
|
||||||
|
</C:schedule-calendar-transp>
|
||||||
|
<ICAL:calendar-color>#5b617aff</ICAL:calendar-color>
|
||||||
|
<ICAL:calendar-order>2</ICAL:calendar-order>
|
||||||
|
</D:prop>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
<D:response>
|
||||||
|
<D:href>/calendars/tasks/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype>
|
||||||
|
<D:collection />
|
||||||
|
<C:calendar />
|
||||||
|
</D:resourcetype>
|
||||||
|
<D:displayname>Tasks</D:displayname>
|
||||||
|
<C:supported-calendar-component-set>
|
||||||
|
<C:comp name="VTODO" />
|
||||||
|
</C:supported-calendar-component-set>
|
||||||
|
</D:prop>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>
|
||||||
|
""");
|
||||||
|
|
||||||
|
var calendars = ParseCalendars(xml, new Uri("https://calendar.example.com/"));
|
||||||
|
|
||||||
|
calendars.Should().ContainSingle();
|
||||||
|
|
||||||
|
var calendar = calendars[0];
|
||||||
|
calendar.RemoteCalendarId.Should().Be("https://calendar.example.com/calendars/work");
|
||||||
|
calendar.Name.Should().Be("Work");
|
||||||
|
calendar.Description.Should().Be("Team calendar");
|
||||||
|
calendar.CTag.Should().Be("\"ctag-1\"");
|
||||||
|
calendar.SyncToken.Should().Be("sync-1");
|
||||||
|
calendar.TimeZone.Should().Be("Europe/Warsaw");
|
||||||
|
calendar.BackgroundColorHex.Should().Be("#5B617A");
|
||||||
|
calendar.IsReadOnly.Should().BeTrue();
|
||||||
|
calendar.SupportsEvents.Should().BeTrue();
|
||||||
|
calendar.DefaultShowAs.Should().Be(CalendarItemShowAs.Free);
|
||||||
|
calendar.Order.Should().Be(2d);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SynchronizeCalendarMetadataAsync_UpdatesServerBackedSettingsAndPreservesUserColorOverride()
|
||||||
|
{
|
||||||
|
var tempDirectory = CreateTempDirectory();
|
||||||
|
|
||||||
|
var serverInformation = new CustomServerInformation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
IncomingServer = "imap.example.com",
|
||||||
|
IncomingServerPort = "993",
|
||||||
|
IncomingServerUsername = "user@example.com",
|
||||||
|
IncomingServerPassword = "password",
|
||||||
|
OutgoingServer = "smtp.example.com",
|
||||||
|
OutgoingServerPort = "587",
|
||||||
|
OutgoingServerUsername = "user@example.com",
|
||||||
|
OutgoingServerPassword = "password",
|
||||||
|
MaxConcurrentClients = 5,
|
||||||
|
CalendarSupportMode = ImapCalendarSupportMode.CalDav
|
||||||
|
};
|
||||||
|
|
||||||
|
var account = new MailAccount
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "IMAP Test",
|
||||||
|
Address = "test@example.com",
|
||||||
|
ProviderType = MailProviderType.IMAP4,
|
||||||
|
IsCalendarAccessGranted = true,
|
||||||
|
ServerInformation = serverInformation
|
||||||
|
};
|
||||||
|
|
||||||
|
var localCalendar = new AccountCalendar
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AccountId = account.Id,
|
||||||
|
RemoteCalendarId = "https://calendar.example.com/calendars/work",
|
||||||
|
Name = "Local",
|
||||||
|
BackgroundColorHex = "#123456",
|
||||||
|
TextColorHex = "#FFFFFF",
|
||||||
|
IsBackgroundColorUserOverridden = true,
|
||||||
|
TimeZone = "UTC",
|
||||||
|
IsReadOnly = false,
|
||||||
|
DefaultShowAs = CalendarItemShowAs.Busy
|
||||||
|
};
|
||||||
|
|
||||||
|
var changeProcessor = new Mock<IImapChangeProcessor>();
|
||||||
|
changeProcessor
|
||||||
|
.Setup(x => x.GetAccountCalendarsAsync(account.Id))
|
||||||
|
.ReturnsAsync(new List<AccountCalendar> { localCalendar });
|
||||||
|
changeProcessor
|
||||||
|
.Setup(x => x.UpdateAccountCalendarAsync(It.IsAny<AccountCalendar>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
changeProcessor
|
||||||
|
.Setup(x => x.DeleteCalendarIcsForCalendarAsync(It.IsAny<Guid>(), It.IsAny<Guid>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
changeProcessor
|
||||||
|
.Setup(x => x.DeleteAccountCalendarAsync(It.IsAny<AccountCalendar>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
changeProcessor
|
||||||
|
.Setup(x => x.InsertAccountCalendarAsync(It.IsAny<AccountCalendar>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var synchronizer = CreateSynchronizer(tempDirectory, account, changeProcessor.Object);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await InvokePrivateAsync(
|
||||||
|
synchronizer,
|
||||||
|
"SynchronizeCalendarMetadataAsync",
|
||||||
|
new List<CalDavCalendar>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
RemoteCalendarId = localCalendar.RemoteCalendarId,
|
||||||
|
Name = "Remote",
|
||||||
|
TimeZone = "Europe/Warsaw",
|
||||||
|
BackgroundColorHex = "#ABCDEF",
|
||||||
|
IsReadOnly = true,
|
||||||
|
DefaultShowAs = CalendarItemShowAs.Free,
|
||||||
|
Order = 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
localCalendar.Name.Should().Be("Remote");
|
||||||
|
localCalendar.TimeZone.Should().Be("Europe/Warsaw");
|
||||||
|
localCalendar.IsReadOnly.Should().BeTrue();
|
||||||
|
localCalendar.DefaultShowAs.Should().Be(CalendarItemShowAs.Free);
|
||||||
|
localCalendar.IsPrimary.Should().BeTrue();
|
||||||
|
localCalendar.BackgroundColorHex.Should().Be("#123456");
|
||||||
|
localCalendar.TextColorHex.Should().Be(ColorHelpers.GetReadableTextColorHex("#123456"));
|
||||||
|
|
||||||
|
changeProcessor.Verify(x => x.UpdateAccountCalendarAsync(localCalendar), Times.Once);
|
||||||
|
changeProcessor.Verify(x => x.InsertAccountCalendarAsync(It.IsAny<AccountCalendar>()), Times.Never);
|
||||||
|
changeProcessor.Verify(x => x.DeleteAccountCalendarAsync(It.IsAny<AccountCalendar>()), Times.Never);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await synchronizer.KillSynchronizerAsync();
|
||||||
|
DeleteDirectory(tempDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CalDavCalendar> ParseCalendars(XDocument xml, Uri baseUri)
|
||||||
|
{
|
||||||
|
var parseMethod = typeof(CalDavClient).GetMethod(
|
||||||
|
"ParseCalendarCollection",
|
||||||
|
BindingFlags.NonPublic | BindingFlags.Static);
|
||||||
|
|
||||||
|
parseMethod.Should().NotBeNull();
|
||||||
|
|
||||||
|
var result = parseMethod!.Invoke(null, [xml, baseUri]);
|
||||||
|
return result.Should().BeOfType<List<CalDavCalendar>>().Subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImapSynchronizer CreateSynchronizer(string appDataFolder, MailAccount account, IImapChangeProcessor changeProcessor)
|
||||||
|
{
|
||||||
|
var applicationConfiguration = new Mock<IApplicationConfiguration>();
|
||||||
|
applicationConfiguration.SetupProperty(x => x.ApplicationDataFolderPath, appDataFolder);
|
||||||
|
applicationConfiguration.SetupProperty(x => x.PublisherSharedFolderPath, appDataFolder);
|
||||||
|
applicationConfiguration.SetupProperty(x => x.ApplicationTempFolderPath, appDataFolder);
|
||||||
|
applicationConfiguration.SetupGet(x => x.SentryDNS).Returns(string.Empty);
|
||||||
|
|
||||||
|
var unifiedSynchronizer = new UnifiedImapSynchronizer(
|
||||||
|
Mock.Of<IFolderService>(),
|
||||||
|
Mock.Of<IMailService>(),
|
||||||
|
Mock.Of<IImapSynchronizerErrorHandlerFactory>());
|
||||||
|
|
||||||
|
return new ImapSynchronizer(
|
||||||
|
account,
|
||||||
|
changeProcessor,
|
||||||
|
applicationConfiguration.Object,
|
||||||
|
unifiedSynchronizer,
|
||||||
|
Mock.Of<IImapSynchronizerErrorHandlerFactory>(),
|
||||||
|
Mock.Of<ICalDavClient>(),
|
||||||
|
Mock.Of<IAutoDiscoveryService>(),
|
||||||
|
Mock.Of<ICalendarService>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateTempDirectory()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), "wino-caldav-calendar-tests", Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DeleteDirectory(string path)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(path))
|
||||||
|
{
|
||||||
|
Directory.Delete(path, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task InvokePrivateAsync(object instance, string methodName, params object[] parameters)
|
||||||
|
{
|
||||||
|
var method = instance.GetType().GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance)
|
||||||
|
?? throw new InvalidOperationException($"Method '{methodName}' not found.");
|
||||||
|
|
||||||
|
var task = (Task)method.Invoke(instance, parameters)!;
|
||||||
|
await task.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1522,7 +1522,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
var usedCalendarColors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var usedCalendarColors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var remotePrimaryCalendarId = remoteCalendars.FirstOrDefault()?.RemoteCalendarId;
|
var remotePrimaryCalendarId = GetPrimaryCalDavCalendarId(remoteCalendars);
|
||||||
|
|
||||||
foreach (var localCalendar in localCalendars.ToList())
|
foreach (var localCalendar in localCalendars.ToList())
|
||||||
{
|
{
|
||||||
@@ -1545,6 +1545,7 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
|
|
||||||
if (existingLocal == null)
|
if (existingLocal == null)
|
||||||
{
|
{
|
||||||
|
var insertedCalendarColor = ResolveSynchronizedCalendarBackgroundColor(remoteCalendar.BackgroundColorHex, null, usedCalendarColors);
|
||||||
var newCalendar = new AccountCalendar
|
var newCalendar = new AccountCalendar
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -1552,10 +1553,12 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
RemoteCalendarId = remoteCalendar.RemoteCalendarId,
|
RemoteCalendarId = remoteCalendar.RemoteCalendarId,
|
||||||
Name = remoteCalendar.Name,
|
Name = remoteCalendar.Name,
|
||||||
IsPrimary = isPrimary,
|
IsPrimary = isPrimary,
|
||||||
|
IsReadOnly = remoteCalendar.IsReadOnly,
|
||||||
IsSynchronizationEnabled = true,
|
IsSynchronizationEnabled = true,
|
||||||
IsExtended = true,
|
IsExtended = true,
|
||||||
BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors),
|
DefaultShowAs = remoteCalendar.DefaultShowAs,
|
||||||
TimeZone = "UTC",
|
BackgroundColorHex = insertedCalendarColor,
|
||||||
|
TimeZone = remoteCalendar.TimeZone,
|
||||||
SynchronizationDeltaToken = string.Empty
|
SynchronizationDeltaToken = string.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1565,10 +1568,15 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolvedColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, existingLocal.BackgroundColorHex);
|
var resolvedColor = ResolveSynchronizedCalendarBackgroundColor(remoteCalendar.BackgroundColorHex, existingLocal, usedCalendarColors);
|
||||||
|
var resolvedTextColor = ColorHelpers.GetReadableTextColorHex(resolvedColor);
|
||||||
var shouldUpdate = !string.Equals(existingLocal.Name, remoteCalendar.Name, StringComparison.Ordinal)
|
var shouldUpdate = !string.Equals(existingLocal.Name, remoteCalendar.Name, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(existingLocal.TimeZone, remoteCalendar.TimeZone, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| existingLocal.IsReadOnly != remoteCalendar.IsReadOnly
|
||||||
|
|| existingLocal.DefaultShowAs != remoteCalendar.DefaultShowAs
|
||||||
|| existingLocal.IsPrimary != isPrimary
|
|| existingLocal.IsPrimary != isPrimary
|
||||||
|| !string.Equals(existingLocal.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase);
|
|| !string.Equals(existingLocal.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| !string.Equals(existingLocal.TextColorHex, resolvedTextColor, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
if (!shouldUpdate)
|
if (!shouldUpdate)
|
||||||
{
|
{
|
||||||
@@ -1577,14 +1585,54 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
existingLocal.Name = remoteCalendar.Name;
|
existingLocal.Name = remoteCalendar.Name;
|
||||||
|
existingLocal.TimeZone = remoteCalendar.TimeZone;
|
||||||
|
existingLocal.IsReadOnly = remoteCalendar.IsReadOnly;
|
||||||
|
existingLocal.DefaultShowAs = remoteCalendar.DefaultShowAs;
|
||||||
existingLocal.IsPrimary = isPrimary;
|
existingLocal.IsPrimary = isPrimary;
|
||||||
existingLocal.BackgroundColorHex = resolvedColor;
|
existingLocal.BackgroundColorHex = resolvedColor;
|
||||||
existingLocal.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocal.BackgroundColorHex);
|
existingLocal.TextColorHex = resolvedTextColor;
|
||||||
usedCalendarColors.Add(existingLocal.BackgroundColorHex);
|
usedCalendarColors.Add(existingLocal.BackgroundColorHex);
|
||||||
await _imapChangeProcessor.UpdateAccountCalendarAsync(existingLocal).ConfigureAwait(false);
|
await _imapChangeProcessor.UpdateAccountCalendarAsync(existingLocal).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetPrimaryCalDavCalendarId(IReadOnlyList<CalDavCalendar> remoteCalendars)
|
||||||
|
{
|
||||||
|
if (remoteCalendars == null || remoteCalendars.Count == 0)
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
if (remoteCalendars.Any(calendar => calendar.Order.HasValue))
|
||||||
|
{
|
||||||
|
return remoteCalendars
|
||||||
|
.OrderBy(calendar => calendar.Order ?? double.MaxValue)
|
||||||
|
.ThenBy(calendar => calendar.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(calendar => calendar.RemoteCalendarId)
|
||||||
|
.FirstOrDefault() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return remoteCalendars.First().RemoteCalendarId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveSynchronizedCalendarBackgroundColor(
|
||||||
|
string remoteBackgroundColor,
|
||||||
|
AccountCalendar accountCalendar,
|
||||||
|
ISet<string> usedCalendarColors = null)
|
||||||
|
{
|
||||||
|
if (accountCalendar?.IsBackgroundColorUserOverridden == true)
|
||||||
|
return accountCalendar.BackgroundColorHex;
|
||||||
|
|
||||||
|
var preferredColor = string.IsNullOrWhiteSpace(remoteBackgroundColor)
|
||||||
|
? accountCalendar?.BackgroundColorHex
|
||||||
|
: remoteBackgroundColor;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(remoteBackgroundColor) && usedCalendarColors != null)
|
||||||
|
return ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, preferredColor);
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(preferredColor)
|
||||||
|
? ColorHelpers.GenerateFlatColorHex()
|
||||||
|
: preferredColor;
|
||||||
|
}
|
||||||
|
|
||||||
private interface IImapCalendarOperationHandler
|
private interface IImapCalendarOperationHandler
|
||||||
{
|
{
|
||||||
bool RequiresConnectedClient { get; }
|
bool RequiresConnectedClient { get; }
|
||||||
|
|||||||
@@ -613,6 +613,7 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
|
|||||||
var normalizedEmail = !string.IsNullOrWhiteSpace(EmailAddress) && !EmailAddress.Contains('@')
|
var normalizedEmail = !string.IsNullOrWhiteSpace(EmailAddress) && !EmailAddress.Contains('@')
|
||||||
? $"{EmailAddress}@icloud.com"
|
? $"{EmailAddress}@icloud.com"
|
||||||
: EmailAddress;
|
: EmailAddress;
|
||||||
|
var iCloudMailboxUsername = GetICloudMailboxUsername(normalizedEmail);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(accountCreationDialogResult?.SpecialImapProviderDetails?.SenderName))
|
if (!string.IsNullOrWhiteSpace(accountCreationDialogResult?.SpecialImapProviderDetails?.SenderName))
|
||||||
DisplayName = accountCreationDialogResult.SpecialImapProviderDetails.SenderName;
|
DisplayName = accountCreationDialogResult.SpecialImapProviderDetails.SenderName;
|
||||||
@@ -632,10 +633,10 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
|
|||||||
ApplySpecialProviderDefaults(
|
ApplySpecialProviderDefaults(
|
||||||
"imap.mail.me.com",
|
"imap.mail.me.com",
|
||||||
"993",
|
"993",
|
||||||
normalizedEmail,
|
iCloudMailboxUsername,
|
||||||
"smtp.mail.me.com",
|
"smtp.mail.me.com",
|
||||||
"587",
|
"587",
|
||||||
normalizedEmail,
|
iCloudMailboxUsername,
|
||||||
Password,
|
Password,
|
||||||
"https://caldav.icloud.com/",
|
"https://caldav.icloud.com/",
|
||||||
normalizedEmail,
|
normalizedEmail,
|
||||||
@@ -714,6 +715,19 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel
|
|||||||
OutgoingServerPort = "587";
|
OutgoingServerPort = "587";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetICloudMailboxUsername(string emailAddress)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(emailAddress))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var normalizedAddress = emailAddress.Trim();
|
||||||
|
var atIndex = normalizedAddress.IndexOf('@');
|
||||||
|
|
||||||
|
return atIndex > 0
|
||||||
|
? normalizedAddress[..atIndex]
|
||||||
|
: normalizedAddress;
|
||||||
|
}
|
||||||
|
|
||||||
private static string ReplaceIfEmptyOrMatchingPrevious(string currentValue, string previousValue, string replacementValue)
|
private static string ReplaceIfEmptyOrMatchingPrevious(string currentValue, string previousValue, string replacementValue)
|
||||||
{
|
{
|
||||||
var normalizedCurrentValue = currentValue?.Trim() ?? string.Empty;
|
var normalizedCurrentValue = currentValue?.Trim() ?? string.Empty;
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ public partial class SpecialImapCredentialsPageViewModel : MailBaseViewModel
|
|||||||
{
|
{
|
||||||
if (!CanProceed) return;
|
if (!CanProceed) return;
|
||||||
|
|
||||||
if (await _accountService.AccountAddressExistsAsync(EmailAddress).ConfigureAwait(false))
|
if (await _accountService.AccountAddressExistsAsync(EmailAddress))
|
||||||
{
|
{
|
||||||
await _dialogService.ShowMessageAsync(
|
await _dialogService.ShowMessageAsync(
|
||||||
Translator.DialogMessage_AccountAddressExistsMessage,
|
Translator.DialogMessage_AccountAddressExistsMessage,
|
||||||
|
|||||||
@@ -44,12 +44,19 @@ public sealed class CalDavClient : ICalDavClient
|
|||||||
var homeSetUri = await DiscoverCalendarHomeSetUriAsync(connectionSettings, principalUri, cancellationToken).ConfigureAwait(false);
|
var homeSetUri = await DiscoverCalendarHomeSetUriAsync(connectionSettings, principalUri, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var body = """
|
var body = """
|
||||||
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
|
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
|
||||||
<D:prop>
|
<D:prop>
|
||||||
<D:resourcetype />
|
<D:resourcetype />
|
||||||
<D:displayname />
|
<D:displayname />
|
||||||
|
<D:current-user-privilege-set />
|
||||||
<CS:getctag />
|
<CS:getctag />
|
||||||
<D:sync-token />
|
<D:sync-token />
|
||||||
|
<C:calendar-description />
|
||||||
|
<C:calendar-timezone />
|
||||||
|
<C:supported-calendar-component-set />
|
||||||
|
<C:schedule-calendar-transp />
|
||||||
|
<ICAL:calendar-color />
|
||||||
|
<ICAL:calendar-order />
|
||||||
</D:prop>
|
</D:prop>
|
||||||
</D:propfind>
|
</D:propfind>
|
||||||
""";
|
""";
|
||||||
@@ -344,10 +351,32 @@ public sealed class CalDavClient : ICalDavClient
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
var displayName = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "displayname")?.Value ?? string.Empty;
|
var displayName = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "displayname")?.Value ?? string.Empty;
|
||||||
|
var description = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "calendar-description")?.Value ?? string.Empty;
|
||||||
var ctag = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "getctag")?.Value ?? string.Empty;
|
var ctag = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "getctag")?.Value ?? string.Empty;
|
||||||
var syncToken = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "sync-token")?.Value ?? string.Empty;
|
var syncToken = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "sync-token")?.Value ?? string.Empty;
|
||||||
|
var timeZone = ExtractCalendarTimeZoneId(
|
||||||
|
prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "calendar-timezone")?.Value);
|
||||||
|
var backgroundColor = NormalizeCalendarColor(
|
||||||
|
prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "calendar-color")?.Value);
|
||||||
|
var supportedComponents = prop
|
||||||
|
.Descendants()
|
||||||
|
.Where(e => e.Name.LocalName == "supported-calendar-component-set")
|
||||||
|
.Descendants()
|
||||||
|
.Where(e => e.Name.LocalName == "comp")
|
||||||
|
.Select(e => e.Attribute("name")?.Value?.Trim())
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.ToList();
|
||||||
|
var supportsEvents = supportedComponents.Count == 0 ||
|
||||||
|
supportedComponents.Contains("VEVENT", StringComparer.OrdinalIgnoreCase);
|
||||||
|
var isReadOnly = IsCalendarReadOnly(prop);
|
||||||
|
var defaultShowAs = GetDefaultShowAs(prop);
|
||||||
|
var calendarOrder = ParseCalendarOrder(
|
||||||
|
prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "calendar-order")?.Value);
|
||||||
var remoteUri = CreateAbsoluteUri(baseUri, href).ToString().TrimEnd('/');
|
var remoteUri = CreateAbsoluteUri(baseUri, href).ToString().TrimEnd('/');
|
||||||
|
|
||||||
|
if (!supportsEvents)
|
||||||
|
continue;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(displayName))
|
if (string.IsNullOrWhiteSpace(displayName))
|
||||||
{
|
{
|
||||||
displayName = WebUtility.UrlDecode(remoteUri.Split('/').LastOrDefault() ?? "Calendar");
|
displayName = WebUtility.UrlDecode(remoteUri.Split('/').LastOrDefault() ?? "Calendar");
|
||||||
@@ -357,8 +386,15 @@ public sealed class CalDavClient : ICalDavClient
|
|||||||
{
|
{
|
||||||
RemoteCalendarId = remoteUri,
|
RemoteCalendarId = remoteUri,
|
||||||
Name = displayName,
|
Name = displayName,
|
||||||
|
Description = description,
|
||||||
CTag = ctag,
|
CTag = ctag,
|
||||||
SyncToken = syncToken
|
SyncToken = syncToken,
|
||||||
|
TimeZone = timeZone,
|
||||||
|
BackgroundColorHex = backgroundColor,
|
||||||
|
IsReadOnly = isReadOnly,
|
||||||
|
SupportsEvents = supportsEvents,
|
||||||
|
DefaultShowAs = defaultShowAs,
|
||||||
|
Order = calendarOrder
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -820,6 +856,152 @@ public sealed class CalDavClient : ICalDavClient
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsCalendarReadOnly(XElement prop)
|
||||||
|
{
|
||||||
|
var privilegeSet = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "current-user-privilege-set");
|
||||||
|
if (privilegeSet == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var privilegeNames = privilegeSet
|
||||||
|
.Descendants()
|
||||||
|
.Where(e => e.Name.LocalName == "privilege")
|
||||||
|
.Descendants()
|
||||||
|
.Select(e => e.Name.LocalName)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return !privilegeNames.Contains("all")
|
||||||
|
&& !privilegeNames.Contains("write")
|
||||||
|
&& !privilegeNames.Contains("write-content");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CalendarItemShowAs GetDefaultShowAs(XElement prop)
|
||||||
|
{
|
||||||
|
var transparency = prop.Descendants().FirstOrDefault(e => e.Name.LocalName == "schedule-calendar-transp");
|
||||||
|
if (transparency?.Descendants().Any(e => e.Name.LocalName == "transparent") == true)
|
||||||
|
return CalendarItemShowAs.Free;
|
||||||
|
|
||||||
|
return CalendarItemShowAs.Busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double? ParseCalendarOrder(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return double.TryParse(value.Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var result)
|
||||||
|
? result
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractCalendarTimeZoneId(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var unfoldedValue = UnfoldIcsText(TrimCommonIndentation(value));
|
||||||
|
foreach (var rawLine in unfoldedValue.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var line = rawLine.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var separatorIndex = line.IndexOf(':');
|
||||||
|
if (separatorIndex <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var propertyName = line[..separatorIndex];
|
||||||
|
var propertyValue = line[(separatorIndex + 1)..].Trim();
|
||||||
|
|
||||||
|
if (propertyName.StartsWith("TZID", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(propertyValue))
|
||||||
|
return propertyValue;
|
||||||
|
|
||||||
|
if (propertyName.StartsWith("X-WR-TIMEZONE", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(propertyValue))
|
||||||
|
return propertyValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string UnfoldIcsText(string value)
|
||||||
|
{
|
||||||
|
var normalizedValue = value
|
||||||
|
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||||
|
.Replace('\r', '\n');
|
||||||
|
var unfoldedLines = new List<string>();
|
||||||
|
|
||||||
|
foreach (var rawLine in normalizedValue.Split('\n'))
|
||||||
|
{
|
||||||
|
if ((rawLine.StartsWith(' ') || rawLine.StartsWith('\t')) && unfoldedLines.Count > 0)
|
||||||
|
{
|
||||||
|
unfoldedLines[^1] += rawLine.TrimStart(' ', '\t');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
unfoldedLines.Add(rawLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("\n", unfoldedLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TrimCommonIndentation(string value)
|
||||||
|
{
|
||||||
|
var normalizedValue = value
|
||||||
|
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||||
|
.Replace('\r', '\n');
|
||||||
|
var lines = normalizedValue.Split('\n');
|
||||||
|
var nonEmptyLines = lines
|
||||||
|
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (nonEmptyLines.Count == 0)
|
||||||
|
return normalizedValue;
|
||||||
|
|
||||||
|
var commonIndentation = nonEmptyLines
|
||||||
|
.Select(line => line.TakeWhile(ch => ch is ' ' or '\t').Count())
|
||||||
|
.Min();
|
||||||
|
|
||||||
|
if (commonIndentation <= 0)
|
||||||
|
return normalizedValue;
|
||||||
|
|
||||||
|
return string.Join(
|
||||||
|
"\n",
|
||||||
|
lines.Select(line =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
return line.Length >= commonIndentation
|
||||||
|
? line[commonIndentation..]
|
||||||
|
: line.TrimStart(' ', '\t');
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeCalendarColor(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var color = value.Trim();
|
||||||
|
if (color.StartsWith('#'))
|
||||||
|
{
|
||||||
|
color = color[1..];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color.Length == 8)
|
||||||
|
{
|
||||||
|
color = color[..6];
|
||||||
|
}
|
||||||
|
else if (color.Length == 3)
|
||||||
|
{
|
||||||
|
color = string.Concat(color.Select(c => $"{c}{c}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color.Length != 6 || !int.TryParse(color, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out _))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
return $"#{color.ToUpperInvariant()}";
|
||||||
|
}
|
||||||
|
|
||||||
private static Uri CreateAbsoluteUri(Uri baseUri, string href)
|
private static Uri CreateAbsoluteUri(Uri baseUri, string href)
|
||||||
{
|
{
|
||||||
if (Uri.TryCreate(href, UriKind.Absolute, out var absolute))
|
if (Uri.TryCreate(href, UriKind.Absolute, out var absolute))
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ public class SpecialImapProviderConfigResolver : ISpecialImapProviderConfigResol
|
|||||||
|
|
||||||
if (details.SpecialImapProvider == SpecialImapProvider.iCloud)
|
if (details.SpecialImapProvider == SpecialImapProvider.iCloud)
|
||||||
{
|
{
|
||||||
|
var iCloudMailboxUsername = GetICloudMailboxUsername(details.Address);
|
||||||
|
|
||||||
resolvedConfig = new CustomServerInformation()
|
resolvedConfig = new CustomServerInformation()
|
||||||
{
|
{
|
||||||
IncomingServer = "imap.mail.me.com",
|
IncomingServer = "imap.mail.me.com",
|
||||||
@@ -29,9 +31,9 @@ public class SpecialImapProviderConfigResolver : ISpecialImapProviderConfigResol
|
|||||||
CalDavServiceUrl = "https://caldav.icloud.com/"
|
CalDavServiceUrl = "https://caldav.icloud.com/"
|
||||||
};
|
};
|
||||||
|
|
||||||
// iCloud takes username before the @icloud part for incoming, but full address as outgoing.
|
// iCloud IMAP/SMTP authentication uses only the local-part mailbox username.
|
||||||
resolvedConfig.IncomingServerUsername = details.Address.Split('@')[0];
|
resolvedConfig.IncomingServerUsername = iCloudMailboxUsername;
|
||||||
resolvedConfig.OutgoingServerUsername = details.Address;
|
resolvedConfig.OutgoingServerUsername = iCloudMailboxUsername;
|
||||||
}
|
}
|
||||||
else if (details.SpecialImapProvider == SpecialImapProvider.Yahoo)
|
else if (details.SpecialImapProvider == SpecialImapProvider.Yahoo)
|
||||||
{
|
{
|
||||||
@@ -73,4 +75,17 @@ public class SpecialImapProviderConfigResolver : ISpecialImapProviderConfigResol
|
|||||||
|
|
||||||
return resolvedConfig;
|
return resolvedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetICloudMailboxUsername(string address)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(address))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var normalizedAddress = address.Trim();
|
||||||
|
var atIndex = normalizedAddress.IndexOf('@');
|
||||||
|
|
||||||
|
return atIndex > 0
|
||||||
|
? normalizedAddress[..atIndex]
|
||||||
|
: normalizedAddress;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user