Import functionality for wino accounts, calendar sync UI, bunch of shell improvements

This commit is contained in:
Burak Kaan Köse
2026-04-04 20:23:20 +02:00
parent 1667aa34db
commit 1d0fcfb5b0
68 changed files with 2792 additions and 519 deletions
@@ -0,0 +1,68 @@
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Google;
using Serilog;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Synchronizers.Errors.Gmail;
public class GmailAuthenticationFailedHandler : ISynchronizerErrorHandler
{
private readonly ILogger _logger = Log.ForContext<GmailAuthenticationFailedHandler>();
private readonly IAccountService _accountService;
public GmailAuthenticationFailedHandler(IAccountService accountService)
{
_accountService = accountService;
}
public bool CanHandle(SynchronizerErrorContext error)
{
if (error.Exception is not GoogleApiException googleEx)
return false;
var reason = googleEx.Error?.Errors?.FirstOrDefault()?.Reason?.ToLowerInvariant() ?? string.Empty;
var message = googleEx.Message?.ToLowerInvariant() ?? string.Empty;
return googleEx.HttpStatusCode == HttpStatusCode.Unauthorized ||
(googleEx.HttpStatusCode == HttpStatusCode.Forbidden &&
(reason.Contains("auth") ||
reason.Contains("credential") ||
message.Contains("invalid credentials") ||
message.Contains("insufficient authentication") ||
message.Contains("login required")));
}
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
{
_logger.Warning(error.Exception,
"Gmail authentication failed for account {AccountName} ({AccountId}). User intervention is required.",
error.Account?.Name, error.Account?.Id);
if (error.Account != null)
{
await PersistInvalidCredentialAttentionAsync(error.Account).ConfigureAwait(false);
}
error.Severity = SynchronizerErrorSeverity.AuthRequired;
error.Category = SynchronizerErrorCategory.Authentication;
error.RetryDelay = null;
return true;
}
private async Task PersistInvalidCredentialAttentionAsync(MailAccount account)
{
var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false);
if (persistedAccount == null || persistedAccount.AttentionReason == AccountAttentionReason.InvalidCredentials)
return;
persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials;
await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false);
}
}
@@ -1,6 +1,7 @@
using System.Threading.Tasks;
using MailKit.Security;
using Serilog;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
@@ -14,6 +15,12 @@ namespace Wino.Core.Synchronizers.Errors.Imap;
public class ImapAuthenticationFailedHandler : ISynchronizerErrorHandler
{
private readonly ILogger _logger = Log.ForContext<ImapAuthenticationFailedHandler>();
private readonly IAccountService _accountService;
public ImapAuthenticationFailedHandler(IAccountService accountService)
{
_accountService = accountService;
}
public bool CanHandle(SynchronizerErrorContext error)
{
@@ -22,12 +29,17 @@ public class ImapAuthenticationFailedHandler : ISynchronizerErrorHandler
(error.ErrorMessage?.Contains("authentication", System.StringComparison.OrdinalIgnoreCase) ?? false);
}
public Task<bool> HandleAsync(SynchronizerErrorContext error)
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
{
_logger.Warning(error.Exception,
"IMAP authentication failed for account {AccountName} ({AccountId}). User needs to re-authenticate.",
error.Account?.Name, error.Account?.Id);
if (error.Account != null)
{
await PersistInvalidCredentialAttentionAsync(error.Account).ConfigureAwait(false);
}
// Mark as requiring authentication - this will stop sync and notify user
error.Severity = SynchronizerErrorSeverity.AuthRequired;
error.Category = SynchronizerErrorCategory.Authentication;
@@ -35,6 +47,20 @@ public class ImapAuthenticationFailedHandler : ISynchronizerErrorHandler
// No point in retrying auth failures - credentials need to be updated
error.RetryDelay = null;
return Task.FromResult(true);
return true;
}
private async Task PersistInvalidCredentialAttentionAsync(MailAccount account)
{
var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false);
if (persistedAccount == null)
return;
if (persistedAccount.AttentionReason == AccountAttentionReason.InvalidCredentials)
return;
persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials;
await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false);
}
}
@@ -0,0 +1,83 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Graph.Models.ODataErrors;
using Microsoft.Kiota.Abstractions;
using Serilog;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Synchronizers.Errors.Outlook;
public class OutlookAuthenticationFailedHandler : ISynchronizerErrorHandler
{
private readonly ILogger _logger = Log.ForContext<OutlookAuthenticationFailedHandler>();
private readonly IAccountService _accountService;
public OutlookAuthenticationFailedHandler(IAccountService accountService)
{
_accountService = accountService;
}
public bool CanHandle(SynchronizerErrorContext error)
{
if (error.Exception is ApiException apiException)
{
if (apiException.ResponseStatusCode == 401)
return true;
if (apiException.ResponseStatusCode == 403)
{
var message = apiException.Message?.ToLowerInvariant() ?? string.Empty;
return message.Contains("access denied") || message.Contains("authentication");
}
}
if (error.Exception is ODataError oDataError)
{
if (oDataError.ResponseStatusCode == 401)
return true;
var code = oDataError.Error?.Code?.ToLowerInvariant() ?? string.Empty;
var message = oDataError.Error?.Message?.ToLowerInvariant() ?? string.Empty;
return code.Contains("invalidauthenticationtoken") ||
code.Contains("invalidgrant") ||
code.Contains("token") ||
message.Contains("access token") ||
message.Contains("authentication");
}
return false;
}
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
{
_logger.Warning(error.Exception,
"Outlook authentication failed for account {AccountName} ({AccountId}). User intervention is required.",
error.Account?.Name, error.Account?.Id);
if (error.Account != null)
{
await PersistInvalidCredentialAttentionAsync(error.Account).ConfigureAwait(false);
}
error.Severity = SynchronizerErrorSeverity.AuthRequired;
error.Category = SynchronizerErrorCategory.Authentication;
error.RetryDelay = null;
return true;
}
private async Task PersistInvalidCredentialAttentionAsync(MailAccount account)
{
var persistedAccount = await _accountService.GetAccountAsync(account.Id).ConfigureAwait(false);
if (persistedAccount == null || persistedAccount.AttentionReason == AccountAttentionReason.InvalidCredentials)
return;
persistedAccount.AttentionReason = AccountAttentionReason.InvalidCredentials;
await _accountService.UpdateAccountAsync(persistedAccount).ConfigureAwait(false);
}
}
+11 -13
View File
@@ -603,11 +603,7 @@ 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);
var usedCalendarColors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
List<AccountCalendar> insertedCalendars = new();
List<AccountCalendar> updatedCalendars = new();
@@ -637,25 +633,25 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
if (existingLocalCalendar == null)
{
// Insert new calendar.
var fallbackColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors);
var fallbackColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, calendar.BackgroundColor);
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);
localCalendar.BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, localCalendar.BackgroundColorHex);
localCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(localCalendar.BackgroundColorHex);
usedCalendarColors.Add(localCalendar.BackgroundColorHex);
insertedCalendars.Add(localCalendar);
}
else
{
// Update existing calendar. Right now we only update the name.
if (ShouldUpdateCalendar(calendar, existingLocalCalendar, remotePrimaryCalendarId))
var resolvedColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, existingLocalCalendar.BackgroundColorHex);
if (ShouldUpdateCalendar(calendar, existingLocalCalendar, remotePrimaryCalendarId) ||
!string.Equals(existingLocalCalendar.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase))
{
existingLocalCalendar.Name = calendar.Summary;
existingLocalCalendar.TimeZone = calendar.TimeZone;
if (!string.IsNullOrEmpty(calendar.BackgroundColor))
existingLocalCalendar.BackgroundColorHex = calendar.BackgroundColor;
if (!string.IsNullOrEmpty(calendar.ForegroundColor))
existingLocalCalendar.TextColorHex = calendar.ForegroundColor;
existingLocalCalendar.BackgroundColorHex = resolvedColor;
existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex);
existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
updatedCalendars.Add(existingLocalCalendar);
@@ -665,6 +661,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// Remove it from the local folder list to skip additional calendar updates.
localCalendars.Remove(existingLocalCalendar);
}
usedCalendarColors.Add(resolvedColor);
}
}
+11 -7
View File
@@ -1455,11 +1455,7 @@ 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 usedCalendarColors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var remotePrimaryCalendarId = remoteCalendars.FirstOrDefault()?.RemoteCalendarId;
@@ -1493,25 +1489,33 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
IsPrimary = isPrimary,
IsSynchronizationEnabled = true,
IsExtended = true,
TextColorHex = "#000000",
BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors),
TimeZone = "UTC",
SynchronizationDeltaToken = string.Empty
};
newCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(newCalendar.BackgroundColorHex);
usedCalendarColors.Add(newCalendar.BackgroundColorHex);
await _imapChangeProcessor.InsertAccountCalendarAsync(newCalendar).ConfigureAwait(false);
continue;
}
var resolvedColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, existingLocal.BackgroundColorHex);
var shouldUpdate = !string.Equals(existingLocal.Name, remoteCalendar.Name, StringComparison.Ordinal)
|| existingLocal.IsPrimary != isPrimary;
|| existingLocal.IsPrimary != isPrimary
|| !string.Equals(existingLocal.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase);
if (!shouldUpdate)
{
usedCalendarColors.Add(resolvedColor);
continue;
}
existingLocal.Name = remoteCalendar.Name;
existingLocal.IsPrimary = isPrimary;
existingLocal.BackgroundColorHex = resolvedColor;
existingLocal.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocal.BackgroundColorHex);
usedCalendarColors.Add(existingLocal.BackgroundColorHex);
await _imapChangeProcessor.UpdateAccountCalendarAsync(existingLocal).ConfigureAwait(false);
}
}
+11 -11
View File
@@ -2333,11 +2333,7 @@ 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);
var usedCalendarColors = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
List<AccountCalendar> insertedCalendars = new();
List<AccountCalendar> updatedCalendars = new();
@@ -2367,23 +2363,25 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
if (existingLocalCalendar == null)
{
// Insert new calendar.
var fallbackColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors);
var fallbackColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, calendar.HexColor);
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);
localCalendar.BackgroundColorHex = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, localCalendar.BackgroundColorHex);
localCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(localCalendar.BackgroundColorHex);
usedCalendarColors.Add(localCalendar.BackgroundColorHex);
insertedCalendars.Add(localCalendar);
}
else
{
// Update existing calendar. Right now we only update the name.
if (ShouldUpdateCalendar(calendar, existingLocalCalendar, remotePrimaryCalendarId))
var resolvedColor = ColorHelpers.GetDistinctFlatColorHex(usedCalendarColors, existingLocalCalendar.BackgroundColorHex);
if (ShouldUpdateCalendar(calendar, existingLocalCalendar, remotePrimaryCalendarId) ||
!string.Equals(existingLocalCalendar.BackgroundColorHex, resolvedColor, StringComparison.OrdinalIgnoreCase))
{
existingLocalCalendar.Name = calendar.Name;
existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
if (!string.IsNullOrEmpty(calendar.HexColor))
existingLocalCalendar.BackgroundColorHex = calendar.HexColor;
existingLocalCalendar.BackgroundColorHex = resolvedColor;
existingLocalCalendar.TextColorHex = ColorHelpers.GetReadableTextColorHex(existingLocalCalendar.BackgroundColorHex);
updatedCalendars.Add(existingLocalCalendar);
}
@@ -2392,6 +2390,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
// Remove it from the local folder list to skip additional calendar updates.
localCalendars.Remove(existingLocalCalendar);
}
usedCalendarColors.Add(resolvedColor);
}
}