diff --git a/Wino.Core.Domain/Entities/Mail/MailCategory.cs b/Wino.Core.Domain/Entities/Mail/MailCategory.cs new file mode 100644 index 00000000..0bb92227 --- /dev/null +++ b/Wino.Core.Domain/Entities/Mail/MailCategory.cs @@ -0,0 +1,25 @@ +using System; +using SQLite; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Entities.Mail; + +public class MailCategory +{ + [PrimaryKey] + public Guid Id { get; set; } + + public Guid MailAccountId { get; set; } + + public string RemoteId { get; set; } + + public string Name { get; set; } + + public bool IsFavorite { get; set; } + + public string BackgroundColorHex { get; set; } + + public string TextColorHex { get; set; } + + public MailCategorySource Source { get; set; } = MailCategorySource.Local; +} diff --git a/Wino.Core.Domain/Entities/Mail/MailCategoryAssignment.cs b/Wino.Core.Domain/Entities/Mail/MailCategoryAssignment.cs new file mode 100644 index 00000000..dc12bd34 --- /dev/null +++ b/Wino.Core.Domain/Entities/Mail/MailCategoryAssignment.cs @@ -0,0 +1,14 @@ +using System; +using SQLite; + +namespace Wino.Core.Domain.Entities.Mail; + +public class MailCategoryAssignment +{ + [PrimaryKey] + public Guid Id { get; set; } + + public Guid MailCategoryId { get; set; } + + public Guid MailCopyUniqueId { get; set; } +} diff --git a/Wino.Core.Domain/Entities/Shared/MailAccount.cs b/Wino.Core.Domain/Entities/Shared/MailAccount.cs index f549f65e..045206ef 100644 --- a/Wino.Core.Domain/Entities/Shared/MailAccount.cs +++ b/Wino.Core.Domain/Entities/Shared/MailAccount.cs @@ -132,5 +132,10 @@ public class MailAccount /// public bool IsAliasSyncSupported => ProviderType == MailProviderType.Gmail || ProviderType == MailProviderType.Outlook; + /// + /// Gets whether the account can perform category definition sync type. + /// + public bool IsCategorySyncSupported => ProviderType == MailProviderType.Outlook; + public override string ToString() => Name; } diff --git a/Wino.Core.Domain/Enums/MailCategorySource.cs b/Wino.Core.Domain/Enums/MailCategorySource.cs new file mode 100644 index 00000000..c24b08d9 --- /dev/null +++ b/Wino.Core.Domain/Enums/MailCategorySource.cs @@ -0,0 +1,7 @@ +namespace Wino.Core.Domain.Enums; + +public enum MailCategorySource +{ + Local, + Outlook +} diff --git a/Wino.Core.Domain/Enums/MailOperation.cs b/Wino.Core.Domain/Enums/MailOperation.cs index 3f34c2fa..ea2773f3 100644 --- a/Wino.Core.Domain/Enums/MailOperation.cs +++ b/Wino.Core.Domain/Enums/MailOperation.cs @@ -13,6 +13,7 @@ public enum MailSynchronizerOperation AlwaysMoveTo, MoveToFocused, Archive, + UpdateCategories, } public enum FolderSynchronizerOperation @@ -35,6 +36,13 @@ public enum CalendarSynchronizerOperation TentativeEvent, } +public enum CategorySynchronizerOperation +{ + CreateCategory, + UpdateCategory, + DeleteCategory, +} + // UI requests public enum MailOperation { diff --git a/Wino.Core.Domain/Enums/MailSynchronizationType.cs b/Wino.Core.Domain/Enums/MailSynchronizationType.cs index 650c2a59..416125f2 100644 --- a/Wino.Core.Domain/Enums/MailSynchronizationType.cs +++ b/Wino.Core.Domain/Enums/MailSynchronizationType.cs @@ -3,6 +3,7 @@ public enum MailSynchronizationType { UpdateProfile, // Only update profile information + Categories, // Only update mail categories ExecuteRequests, // Run the queued requests, and then synchronize if needed. FoldersOnly, // Only synchronize folder metadata. InboxOnly, // Only Inbox, Sent, Draft and Deleted folders. diff --git a/Wino.Core.Domain/Enums/WinoPage.cs b/Wino.Core.Domain/Enums/WinoPage.cs index eb78105a..5a23c97c 100644 --- a/Wino.Core.Domain/Enums/WinoPage.cs +++ b/Wino.Core.Domain/Enums/WinoPage.cs @@ -24,6 +24,7 @@ public enum WinoPage AppPreferencesPage, SettingOptionsPage, AliasManagementPage, + MailCategoryManagementPage, ImapCalDavSettingsPage, KeyboardShortcutsPage, CalendarPage, diff --git a/Wino.Core.Domain/Interfaces/IFolderMenuItem.cs b/Wino.Core.Domain/Interfaces/IFolderMenuItem.cs index 26413208..a9d2e063 100644 --- a/Wino.Core.Domain/Interfaces/IFolderMenuItem.cs +++ b/Wino.Core.Domain/Interfaces/IFolderMenuItem.cs @@ -14,6 +14,22 @@ public interface IFolderMenuItem : IBaseFolderMenuItem public interface IMergedAccountFolderMenuItem : IBaseFolderMenuItem { } +public interface IMailCategoryMenuItem : IBaseFolderMenuItem +{ + Entities.Mail.MailCategory MailCategory { get; } + string TextColorHex { get; } + string BackgroundColorHex { get; } + bool HasTextColor { get; } +} + +public interface IMergedMailCategoryMenuItem : IBaseFolderMenuItem +{ + IReadOnlyList Categories { get; } + string TextColorHex { get; } + string BackgroundColorHex { get; } + bool HasTextColor { get; } +} + public interface IBaseFolderMenuItem : IMenuItem { string FolderName { get; } diff --git a/Wino.Core.Domain/Interfaces/IMailCategoryService.cs b/Wino.Core.Domain/Interfaces/IMailCategoryService.cs new file mode 100644 index 00000000..a21aba3e --- /dev/null +++ b/Wino.Core.Domain/Interfaces/IMailCategoryService.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Models.Accounts; + +namespace Wino.Core.Domain.Interfaces; + +public interface IMailCategoryService +{ + Task> GetCategoriesAsync(Guid accountId); + Task> GetFavoriteCategoriesAsync(Guid accountId); + Task GetCategoryAsync(Guid categoryId); + Task CategoryNameExistsAsync(Guid accountId, string name, Guid? excludedCategoryId = null); + Task CreateCategoryAsync(MailCategory category); + Task UpdateCategoryAsync(MailCategory category); + Task DeleteCategoryAsync(Guid categoryId); + Task DeleteCategoriesAsync(Guid accountId); + Task ToggleFavoriteAsync(Guid categoryId, bool isFavorite); + Task UpdateRemoteIdAsync(Guid categoryId, string remoteId); + Task ReplaceCategoriesAsync(Guid accountId, IEnumerable categories); + Task ReplaceMailAssignmentsAsync(Guid accountId, Guid mailCopyUniqueId, IEnumerable categoryNames); + Task AssignCategoryAsync(Guid categoryId, IEnumerable mailCopyUniqueIds); + Task UnassignCategoryAsync(Guid categoryId, IEnumerable mailCopyUniqueIds); + Task> GetCategoriesForMailAsync(Guid accountId, IEnumerable mailCopyUniqueIds); + Task> GetAssignedCategoryIdsForAllAsync(IEnumerable mailCopyUniqueIds); + Task> GetCategoryNamesForMailAsync(Guid mailCopyUniqueId); + Task> GetMailCopiesForCategoryAsync(Guid categoryId); + Task> GetUnreadCategoryCountResultsAsync(IEnumerable accountIds); +} diff --git a/Wino.Core.Domain/Interfaces/IMailDialogService.cs b/Wino.Core.Domain/Interfaces/IMailDialogService.cs index e07ea3a4..f08a0de3 100644 --- a/Wino.Core.Domain/Interfaces/IMailDialogService.cs +++ b/Wino.Core.Domain/Interfaces/IMailDialogService.cs @@ -11,6 +11,7 @@ using Wino.Core.Domain.Models; using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Folders; +using Wino.Core.Domain.Models.MailItem; namespace Wino.Core.Domain.Interfaces; @@ -52,6 +53,13 @@ public interface IMailDialogService : IDialogServiceBase /// Created alias model if not canceled. Task ShowCreateAccountAliasDialogAsync(); + /// + /// Presents a dialog to the user for mail category creation/modification. + /// +#pragma warning disable CS8625 + Task ShowEditMailCategoryDialogAsync(MailCategory category = null); +#pragma warning restore CS8625 + /// /// Presents a dialog to the user to show email source. /// diff --git a/Wino.Core.Domain/Interfaces/IRequestBundle.cs b/Wino.Core.Domain/Interfaces/IRequestBundle.cs index 955b89d8..849fb26f 100644 --- a/Wino.Core.Domain/Interfaces/IRequestBundle.cs +++ b/Wino.Core.Domain/Interfaces/IRequestBundle.cs @@ -72,3 +72,9 @@ public interface ICalendarActionRequest : IRequestBase Guid? LocalCalendarItemId { get; } CalendarSynchronizerOperation Operation { get; } } + +public interface ICategoryActionRequest : IRequestBase +{ + Guid AccountId { get; } + CategorySynchronizerOperation Operation { get; } +} diff --git a/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs b/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs index 7689e2bb..0d7629c1 100644 --- a/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs +++ b/Wino.Core.Domain/Interfaces/ISynchronizationManager.cs @@ -63,6 +63,12 @@ public interface ISynchronizationManager Task SynchronizeAliasesAsync(Guid accountId, CancellationToken cancellationToken = default); + /// + /// Handles category synchronization for the given account. + /// + Task SynchronizeCategoriesAsync(Guid accountId, + CancellationToken cancellationToken = default); + /// /// Handles profile synchronization for the given account. /// diff --git a/Wino.Core.Domain/Interfaces/IWinoRequestDelegator.cs b/Wino.Core.Domain/Interfaces/IWinoRequestDelegator.cs index 0cf1cabf..4a54ea21 100644 --- a/Wino.Core.Domain/Interfaces/IWinoRequestDelegator.cs +++ b/Wino.Core.Domain/Interfaces/IWinoRequestDelegator.cs @@ -1,4 +1,6 @@ -using System.Threading.Tasks; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.MailItem; @@ -36,4 +38,9 @@ public interface IWinoRequestDelegator /// /// Calendar preparation request. Task ExecuteAsync(CalendarOperationPreparationRequest calendarOperationPreparationRequest); + + /// + /// Queues pre-built requests for a single account and triggers synchronization. + /// + Task ExecuteAsync(Guid accountId, IEnumerable requests); } diff --git a/Wino.Core.Domain/MenuItems/MailCategoryMenuItem.cs b/Wino.Core.Domain/MenuItems/MailCategoryMenuItem.cs new file mode 100644 index 00000000..c5232137 --- /dev/null +++ b/Wino.Core.Domain/MenuItems/MailCategoryMenuItem.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Folders; + +namespace Wino.Core.Domain.MenuItems; + +public partial class MailCategoryMenuItem : MenuItemBase, IFolderMenuItem, IMailCategoryMenuItem +{ + private IReadOnlyList _handlingFolders; + + [ObservableProperty] + private int unreadItemCount; + + public MailCategoryMenuItem(MailCategory category, MailAccount parentAccount, IEnumerable handlingFolders, IMenuItem parentMenuItem) + : base(category, category.Id, parentMenuItem) + { + ParentAccount = parentAccount; + _handlingFolders = handlingFolders?.ToList() ?? []; + } + + public string FolderName => Parameter.Name; + public bool IsSynchronizationEnabled => false; + public SpecialFolderType SpecialFolderType => SpecialFolderType.Other; + public IEnumerable HandlingFolders => _handlingFolders; + public new ObservableCollection SubMenuItems { get; } = []; + public bool IsMoveTarget => true; + public bool IsSticky => false; + public bool IsSystemFolder => false; + public bool ShowUnreadCount => true; + public string AssignedAccountName => ParentAccount?.Name; + public MailAccount ParentAccount { get; private set; } + public string TextColorHex => Parameter.TextColorHex; + public string BackgroundColorHex => Parameter.BackgroundColorHex; + public bool HasTextColor => !string.IsNullOrWhiteSpace(Parameter.TextColorHex); + public MailCategory MailCategory => Parameter; + + public void UpdateFolder(IMailItemFolder folder) + { + } + + public void UpdateParentAccounnt(MailAccount account) => ParentAccount = account; +} diff --git a/Wino.Core.Domain/MenuItems/MenuItemCollection.cs b/Wino.Core.Domain/MenuItems/MenuItemCollection.cs index 7504c0a7..9a9b34f4 100644 --- a/Wino.Core.Domain/MenuItems/MenuItemCollection.cs +++ b/Wino.Core.Domain/MenuItems/MenuItemCollection.cs @@ -22,11 +22,13 @@ public class MenuItemCollection : ObservableRangeCollection public IEnumerable GetAllAccountMenuItems() { - foreach (var item in this) + var rootItems = this.ToList(); + + foreach (var item in rootItems) { if (item is MergedAccountMenuItem mergedAccountMenuItem) { - foreach (var singleItem in mergedAccountMenuItem.SubMenuItems.OfType()) + foreach (var singleItem in mergedAccountMenuItem.SubMenuItems.OfType().ToList()) { yield return singleItem; } @@ -40,9 +42,11 @@ public class MenuItemCollection : ObservableRangeCollection public IEnumerable GetAllFolderMenuItems(Guid folderId) { - foreach (var item in this) + var rootItems = this.ToList(); + + foreach (var item in rootItems) { - if (item is IBaseFolderMenuItem folderMenuItem) + if (item is IBaseFolderMenuItem folderMenuItem && item is not IMailCategoryMenuItem && item is not IMergedMailCategoryMenuItem) { if (folderMenuItem.HandlingFolders.Any(a => a.Id == folderId)) { @@ -50,7 +54,7 @@ public class MenuItemCollection : ObservableRangeCollection } else if (folderMenuItem.SubMenuItems.Any()) { - foreach (var subItem in folderMenuItem.SubMenuItems.OfType()) + foreach (var subItem in folderMenuItem.SubMenuItems.OfType().ToList()) { if (subItem.HandlingFolders.Any(a => a.Id == folderId)) { @@ -65,8 +69,10 @@ public class MenuItemCollection : ObservableRangeCollection public bool TryGetAccountMenuItem(Guid accountId, out IAccountMenuItem value) { - value = this.OfType().FirstOrDefault(a => a.AccountId == accountId); - value ??= this.OfType().FirstOrDefault(a => a.SubMenuItems.OfType().Where(b => b.AccountId == accountId) != null); + var rootItems = this.ToList(); + + value = rootItems.OfType().FirstOrDefault(a => a.AccountId == accountId); + value ??= rootItems.OfType().FirstOrDefault(a => a.SubMenuItems.OfType().Any(b => b.AccountId == accountId)); return value != null; } @@ -74,7 +80,9 @@ public class MenuItemCollection : ObservableRangeCollection // Pattern: Look for special folder menu item inside the loaded folders for Windows Mail style menu items. public bool TryGetWindowsStyleRootSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value) { - value = this.OfType() + var rootItems = this.ToList(); + + value = rootItems.OfType() .FirstOrDefault(a => a.HandlingFolders.Any(b => b.MailAccountId == accountId && b.SpecialFolderType == specialFolderType)) as FolderMenuItem; return value != null; @@ -84,7 +92,9 @@ public class MenuItemCollection : ObservableRangeCollection // This will not look for the folders inside individual account menu items inside merged account menu item. public bool TryGetMergedAccountSpecialFolderMenuItem(Guid mergedInboxId, SpecialFolderType specialFolderType, out IBaseFolderMenuItem value) { - value = this.OfType() + var rootItems = this.ToList(); + + value = rootItems.OfType() .Where(a => a.MergedInbox.Id == mergedInboxId) .FirstOrDefault(a => a.SpecialFolderType == specialFolderType); @@ -93,11 +103,14 @@ public class MenuItemCollection : ObservableRangeCollection public bool TryGetFolderMenuItem(Guid folderId, out IBaseFolderMenuItem value) { + var rootItems = this.ToList(); + // Root folders - value = this.OfType() + value = rootItems.OfType() + .Where(a => a is not IMailCategoryMenuItem && a is not IMergedMailCategoryMenuItem) .FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId)); - value ??= this.OfType() + value ??= rootItems.OfType() .SelectMany(a => a.SubMenuItems) .OfType() .FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId)); @@ -105,10 +118,23 @@ public class MenuItemCollection : ObservableRangeCollection return value != null; } + public bool TryGetCategoryMenuItem(Guid categoryId, out IBaseFolderMenuItem value) + { + var rootItems = this.ToList(); + + value = rootItems.OfType() + .FirstOrDefault(a => a.MailCategory.Id == categoryId); + + value ??= rootItems.OfType() + .FirstOrDefault(a => a.Categories.Any(b => b.Id == categoryId)) as IBaseFolderMenuItem; + + return value != null; + } + public void UpdateUnreadItemCountsToZero() { // Handle the root folders. - foreach (var item in this.OfType()) + foreach (var item in this.OfType().ToList()) { RecursivelyResetUnreadItemCount(item); } @@ -120,7 +146,7 @@ public class MenuItemCollection : ObservableRangeCollection if (baseFolderMenuItem.SubMenuItems == null) return; - foreach (var subMenuItem in baseFolderMenuItem.SubMenuItems.OfType()) + foreach (var subMenuItem in baseFolderMenuItem.SubMenuItems.OfType().ToList()) { RecursivelyResetUnreadItemCount(subMenuItem); } @@ -128,7 +154,9 @@ public class MenuItemCollection : ObservableRangeCollection public bool TryGetSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value) { - value = this.OfType() + var rootItems = this.ToList(); + + value = rootItems.OfType() .FirstOrDefault(a => a.HandlingFolders.Any(b => b.MailAccountId == accountId && b.SpecialFolderType == specialFolderType)) as FolderMenuItem; return value != null; @@ -142,11 +170,12 @@ public class MenuItemCollection : ObservableRangeCollection public AccountMenuItem GetSpecificAccountMenuItem(Guid accountId) { AccountMenuItem accountMenuItem = null; + var rootItems = this.ToList(); - accountMenuItem = this.OfType().FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId)); + accountMenuItem = rootItems.OfType().FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId)); // Look for the items inside the merged accounts if regular menu item is not found. - accountMenuItem ??= this.OfType() + accountMenuItem ??= rootItems.OfType() .FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId))?.SubMenuItems .OfType() .FirstOrDefault(a => a.AccountId == accountId); @@ -167,7 +196,7 @@ public class MenuItemCollection : ObservableRangeCollection /// Whether menu items should be enabled or disabled. public async Task SetAccountMenuItemEnabledStatusAsync(bool isEnabled) { - var accountItems = this.Where(a => a is IAccountMenuItem).Cast(); + var accountItems = this.Where(a => a is IAccountMenuItem).Cast().ToList(); await _dispatcher.ExecuteOnUIThread(() => { @@ -192,6 +221,7 @@ public class MenuItemCollection : ObservableRangeCollection { // Check root-level items. var rootItem = this.OfType() + .Where(a => a is not IMailCategoryMenuItem && a is not IMergedMailCategoryMenuItem) .FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId)); if (rootItem != null) @@ -201,7 +231,7 @@ public class MenuItemCollection : ObservableRangeCollection } // Check sub-items of root folders. - foreach (var rootFolder in this.OfType()) + foreach (var rootFolder in this.OfType().ToList()) { var subItem = rootFolder.SubMenuItems .OfType() diff --git a/Wino.Core.Domain/MenuItems/MergedMailCategoryMenuItem.cs b/Wino.Core.Domain/MenuItems/MergedMailCategoryMenuItem.cs new file mode 100644 index 00000000..b750fbb6 --- /dev/null +++ b/Wino.Core.Domain/MenuItems/MergedMailCategoryMenuItem.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Folders; + +namespace Wino.Core.Domain.MenuItems; + +public partial class MergedMailCategoryMenuItem : MenuItemBase, IMenuItem>, IMergedAccountFolderMenuItem, IMergedMailCategoryMenuItem +{ + private readonly IReadOnlyList _handlingFolders; + + [ObservableProperty] + private int unreadItemCount; + + public MergedMailCategoryMenuItem(List categories, IEnumerable handlingFolders, MergedInbox mergedInbox) + : base(categories, null, null) + { + _handlingFolders = handlingFolders?.ToList() ?? []; + MergedInbox = mergedInbox; + } + + public string FolderName => Parameter.FirstOrDefault()?.Name ?? string.Empty; + public bool IsSynchronizationEnabled => false; + public SpecialFolderType SpecialFolderType => SpecialFolderType.Other; + public IEnumerable HandlingFolders => _handlingFolders; + public bool IsMoveTarget => true; + public bool IsSticky => false; + public bool IsSystemFolder => false; + public bool ShowUnreadCount => true; + public string AssignedAccountName => MergedInbox?.Name; + public MergedInbox MergedInbox { get; } + public string TextColorHex => Parameter.FirstOrDefault()?.TextColorHex; + public string BackgroundColorHex => Parameter.FirstOrDefault()?.BackgroundColorHex; + public bool HasTextColor => !string.IsNullOrWhiteSpace(TextColorHex); + public IReadOnlyList Categories => Parameter; + + public void UpdateFolder(IMailItemFolder folder) + { + } +} diff --git a/Wino.Core.Domain/Models/Accounts/UnreadCategoryCountResult.cs b/Wino.Core.Domain/Models/Accounts/UnreadCategoryCountResult.cs new file mode 100644 index 00000000..eadee1e3 --- /dev/null +++ b/Wino.Core.Domain/Models/Accounts/UnreadCategoryCountResult.cs @@ -0,0 +1,10 @@ +using System; + +namespace Wino.Core.Domain.Models.Accounts; + +public class UnreadCategoryCountResult +{ + public Guid CategoryId { get; set; } + public Guid AccountId { get; set; } + public int UnreadItemCount { get; set; } +} diff --git a/Wino.Core.Domain/Models/MailItem/MailCategoryColorOption.cs b/Wino.Core.Domain/Models/MailItem/MailCategoryColorOption.cs new file mode 100644 index 00000000..2b412f9e --- /dev/null +++ b/Wino.Core.Domain/Models/MailItem/MailCategoryColorOption.cs @@ -0,0 +1,3 @@ +namespace Wino.Core.Domain.Models.MailItem; + +public sealed record MailCategoryColorOption(string BackgroundColorHex, string TextColorHex); diff --git a/Wino.Core.Domain/Models/MailItem/MailCategoryDialogResult.cs b/Wino.Core.Domain/Models/MailItem/MailCategoryDialogResult.cs new file mode 100644 index 00000000..024fe5ac --- /dev/null +++ b/Wino.Core.Domain/Models/MailItem/MailCategoryDialogResult.cs @@ -0,0 +1,3 @@ +namespace Wino.Core.Domain.Models.MailItem; + +public sealed record MailCategoryDialogResult(string Name, string BackgroundColorHex, string TextColorHex); diff --git a/Wino.Core.Domain/Models/MailItem/MailCategoryPalette.cs b/Wino.Core.Domain/Models/MailItem/MailCategoryPalette.cs new file mode 100644 index 00000000..17b1a5bc --- /dev/null +++ b/Wino.Core.Domain/Models/MailItem/MailCategoryPalette.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace Wino.Core.Domain.Models.MailItem; + +public static class MailCategoryPalette +{ + public static IReadOnlyList DefaultOptions { get; } = + [ + new("#FEE2E2", "#991B1B"), + new("#FECACA", "#7F1D1D"), + new("#FFEDD5", "#9A3412"), + new("#FED7AA", "#7C2D12"), + new("#FEF3C7", "#92400E"), + new("#FDE68A", "#78350F"), + new("#ECFCCB", "#3F6212"), + new("#D9F99D", "#365314"), + new("#DCFCE7", "#166534"), + new("#BBF7D0", "#14532D"), + new("#CCFBF1", "#115E59"), + new("#99F6E4", "#134E4A"), + new("#CFFAFE", "#155E75"), + new("#A5F3FC", "#164E63"), + new("#DBEAFE", "#1D4ED8"), + new("#BFDBFE", "#1E3A8A"), + new("#E0E7FF", "#4338CA"), + new("#DDD6FE", "#5B21B6"), + new("#F3E8FF", "#7E22CE"), + new("#FCE7F3", "#9D174D") + ]; +} diff --git a/Wino.Core.Domain/Models/MailItem/MailInsertPackage.cs b/Wino.Core.Domain/Models/MailItem/MailInsertPackage.cs index c57b1589..76b8abf0 100644 --- a/Wino.Core.Domain/Models/MailItem/MailInsertPackage.cs +++ b/Wino.Core.Domain/Models/MailItem/MailInsertPackage.cs @@ -9,4 +9,5 @@ public record NewMailItemPackage( MailCopy Copy, MimeMessage Mime, string AssignedRemoteFolderId, - IReadOnlyList ExtractedContacts = null); + IReadOnlyList ExtractedContacts = null, + IReadOnlyList CategoryNames = null); diff --git a/Wino.Core.Domain/Models/MailItem/MailListInitializationOptions.cs b/Wino.Core.Domain/Models/MailItem/MailListInitializationOptions.cs index 0ac98064..f75c652e 100644 --- a/Wino.Core.Domain/Models/MailItem/MailListInitializationOptions.cs +++ b/Wino.Core.Domain/Models/MailItem/MailListInitializationOptions.cs @@ -17,4 +17,8 @@ public record MailListInitializationOptions(IEnumerable Folders List PreFetchMailCopies = null, bool DeduplicateByServerId = false, int Skip = 0, - int Take = 0); + int Take = 0) +{ + public IReadOnlyList CategoryIds { get; init; } + public bool IsCategoryView => CategoryIds?.Count > 0; +} diff --git a/Wino.Core.Domain/Models/Requests/RequestBase.cs b/Wino.Core.Domain/Models/Requests/RequestBase.cs index 9ea7876c..2f307e17 100644 --- a/Wino.Core.Domain/Models/Requests/RequestBase.cs +++ b/Wino.Core.Domain/Models/Requests/RequestBase.cs @@ -35,6 +35,10 @@ public abstract record CalendarRequestBase(CalendarItem Item) : RequestBase Item?.Id; } +public abstract record CategoryRequestBase(Guid AccountId) : RequestBase, ICategoryActionRequest +{ +} + public class BatchCollection : List, IUIChangeRequest where TRequestType : IUIChangeRequest { public BatchCollection(IEnumerable collection) : base(collection) diff --git a/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs b/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs index 5002a059..4a3581e4 100644 --- a/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs +++ b/Wino.Core.Domain/Models/Settings/SettingsNavigationItemInfo.cs @@ -170,6 +170,7 @@ public static class SettingsNavigationInfoProvider WinoPage.AccountDetailsPage => WinoPage.ManageAccountsPage, WinoPage.MergedAccountDetailsPage => WinoPage.ManageAccountsPage, WinoPage.AliasManagementPage => WinoPage.ManageAccountsPage, + WinoPage.MailCategoryManagementPage => WinoPage.ManageAccountsPage, WinoPage.SignatureManagementPage => WinoPage.ManageAccountsPage, WinoPage.ImapCalDavSettingsPage => WinoPage.ManageAccountsPage, WinoPage.CreateEmailTemplatePage => WinoPage.EmailTemplatesPage, diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index dc43097e..b1fd54b5 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -67,6 +67,7 @@ "BasicIMAPSetupDialog_Password": "Password", "BasicIMAPSetupDialog_Title": "IMAP Account", "Busy": "Busy", + "Buttons_Add": "Add", "Buttons_AddAccount": "Add Account", "Buttons_FixAccount": "Fix Account", "Buttons_AddNewAlias": "Add New Alias", @@ -877,10 +878,28 @@ "SettingsManageAccountSettings_Title": "Manage Accounts", "SettingsManageAliases_Description": "See e-mail aliases assigned for this account, update or delete them.", "SettingsManageAliases_Title": "Aliases", + "SettingsMailCategories_Description": "Manage synchronized and local categories for this account.", + "SettingsMailCategories_Title": "Categories", "SettingsEditAccountDetails_Title": "Edit Account Details", "SettingsEditAccountDetails_Description": "Change account name, sender name and assign a new color if you like.", "EditAccountDetailsPage_SaveSuccess_Title": "Changes Saved", "EditAccountDetailsPage_SaveSuccess_Message": "Your account details have been updated successfully.", + "MailCategoryManagementPage_Title": "Categories", + "MailCategoryManagementPage_Description": "Create, edit, delete, and favorite categories for this account.", + "MailCategoryManagementPage_Empty": "No categories yet.", + "MailCategoryManagementPage_DeleteConfirmationTitle": "Delete Category", + "MailCategoryManagementPage_DeleteConfirmationMessage": "Delete category \"{0}\"?", + "MailCategoryManagementPage_RefreshConfirmationMessage": "This will delete all your local categories, and re-synchronize everything from the server. Do you want to continue?", + "MailCategoryMenuItem": "Category", + "MailCategoryDialog_CreateTitle": "Create category", + "MailCategoryDialog_EditTitle": "Edit category", + "MailCategoryDialog_Name": "Name", + "MailCategoryDialog_NamePlaceholder": "Category name", + "MailCategoryDialog_Color": "Color", + "MailCategoryDialog_InvalidNameTitle": "Category name required", + "MailCategoryDialog_InvalidNameMessage": "Enter a category name to continue.", + "MailCategoryDialog_DuplicateTitle": "Category already exists", + "MailCategoryDialog_DuplicateMessage": "A category with the same name already exists for this account.", "SettingsManageLink_Description": "Move items to add new link or remove existing link.", "SettingsManageLink_Title": "Manage Link", "SettingsMarkAsRead_Description": "Change what should happen to the selected item.", @@ -1493,11 +1512,13 @@ "AccountSetup_Step_TestingCalendarAuth": "Testing calendar authentication", "AccountSetup_Step_SavingAccount": "Saving account information", "AccountSetup_Step_FetchingCalendarMetadata": "Fetching calendar metadata", + "AccountSetup_Step_SyncingCategories": "Synchronizing categories", "AccountSetup_Step_SyncingAliases": "Synchronizing aliases", "AccountSetup_Step_Finalizing": "Finalizing setup", "AccountSetup_FailureMessage": "Setup failed. Go back to fix your settings, or try again later.", "AccountSetup_SuccessMessage": "Your account has been set up successfully!", "AccountSetup_GoBackButton": "Go Back", "AccountSetup_TryAgainButton": "Try Again", + "Exception_FailedToSynchronizeCategories": "Failed to synchronize categories", "ImapCalDavSettings_AutoDiscoveryFailed": "Auto-discovery failed. Please enter settings manually in the Advanced tab." } diff --git a/Wino.Core.Tests/Services/MailFetchingTests.cs b/Wino.Core.Tests/Services/MailFetchingTests.cs index 61fa2437..c13869f3 100644 --- a/Wino.Core.Tests/Services/MailFetchingTests.cs +++ b/Wino.Core.Tests/Services/MailFetchingTests.cs @@ -510,7 +510,8 @@ public class MailFetchingTests : IAsyncLifetime preferencesService.Object, contactPictureFileService.Object); - var folderService = new FolderService(db, accountService); + var mailCategoryService = new MailCategoryService(db); + var folderService = new FolderService(db, accountService, mailCategoryService); var contactService = new ContactService(db); var sentMailReceiptService = new SentMailReceiptService(db, folderService, accountService); @@ -522,6 +523,7 @@ public class MailFetchingTests : IAsyncLifetime signatureService.Object, mimeFileService.Object, preferencesService.Object, - sentMailReceiptService); + sentMailReceiptService, + mailCategoryService); } } diff --git a/Wino.Core.Tests/Services/MailThreadingTests.cs b/Wino.Core.Tests/Services/MailThreadingTests.cs index 1f0af6ab..3e1f827b 100644 --- a/Wino.Core.Tests/Services/MailThreadingTests.cs +++ b/Wino.Core.Tests/Services/MailThreadingTests.cs @@ -269,7 +269,8 @@ public class MailThreadingTests : IAsyncLifetime preferencesService.Object, contactPictureFileService.Object); - var folderService = new FolderService(db, accountService); + var mailCategoryService = new MailCategoryService(db); + var folderService = new FolderService(db, accountService, mailCategoryService); var contactService = new ContactService(db); var sentMailReceiptService = new SentMailReceiptService(db, folderService, accountService); @@ -281,6 +282,7 @@ public class MailThreadingTests : IAsyncLifetime signatureService.Object, mimeFileService.Object, preferencesService.Object, - sentMailReceiptService); + sentMailReceiptService, + mailCategoryService); } } diff --git a/Wino.Core.Tests/Synchronizers/OutlookSynchronizerRequestSuccessTests.cs b/Wino.Core.Tests/Synchronizers/OutlookSynchronizerRequestSuccessTests.cs index c723e79d..98aa4260 100644 --- a/Wino.Core.Tests/Synchronizers/OutlookSynchronizerRequestSuccessTests.cs +++ b/Wino.Core.Tests/Synchronizers/OutlookSynchronizerRequestSuccessTests.cs @@ -70,8 +70,9 @@ public sealed class OutlookSynchronizerRequestSuccessTests var authenticator = new Mock(MockBehavior.Loose); var errorFactory = new Mock(MockBehavior.Loose); + var mailCategoryService = new Mock(MockBehavior.Loose); - return new OutlookSynchronizer(account, authenticator.Object, changeProcessor, errorFactory.Object); + return new OutlookSynchronizer(account, authenticator.Object, changeProcessor, errorFactory.Object, mailCategoryService.Object); } private static MailCopy CreateMailCopy() => diff --git a/Wino.Core/Requests/Category/MailCategoryCreateRequest.cs b/Wino.Core/Requests/Category/MailCategoryCreateRequest.cs new file mode 100644 index 00000000..0234104c --- /dev/null +++ b/Wino.Core/Requests/Category/MailCategoryCreateRequest.cs @@ -0,0 +1,10 @@ +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Requests; + +namespace Wino.Core.Requests.Category; + +public record MailCategoryCreateRequest(MailCategory Category) : CategoryRequestBase(Category.MailAccountId) +{ + public override CategorySynchronizerOperation Operation => CategorySynchronizerOperation.CreateCategory; +} diff --git a/Wino.Core/Requests/Category/MailCategoryDeleteRequest.cs b/Wino.Core/Requests/Category/MailCategoryDeleteRequest.cs new file mode 100644 index 00000000..64b4100c --- /dev/null +++ b/Wino.Core/Requests/Category/MailCategoryDeleteRequest.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Requests; + +namespace Wino.Core.Requests.Category; + +public record MailCategoryDeleteRequest( + MailCategory Category, + string PreviousRemoteId, + IReadOnlyList AffectedMessages = null) : CategoryRequestBase(Category.MailAccountId) +{ + public override CategorySynchronizerOperation Operation => CategorySynchronizerOperation.DeleteCategory; +} diff --git a/Wino.Core/Requests/Category/MailCategoryRequestModels.cs b/Wino.Core/Requests/Category/MailCategoryRequestModels.cs new file mode 100644 index 00000000..2b76b388 --- /dev/null +++ b/Wino.Core/Requests/Category/MailCategoryRequestModels.cs @@ -0,0 +1,5 @@ +using System.Collections.Generic; + +namespace Wino.Core.Requests.Category; + +public sealed record MailCategoryMessageUpdateTarget(string MessageId, IReadOnlyList CategoryNames); diff --git a/Wino.Core/Requests/Category/MailCategoryUpdateRequest.cs b/Wino.Core/Requests/Category/MailCategoryUpdateRequest.cs new file mode 100644 index 00000000..78babdf2 --- /dev/null +++ b/Wino.Core/Requests/Category/MailCategoryUpdateRequest.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Requests; + +namespace Wino.Core.Requests.Category; + +public record MailCategoryUpdateRequest( + MailCategory Category, + string PreviousName, + string PreviousRemoteId, + IReadOnlyList AffectedMessages = null) : CategoryRequestBase(Category.MailAccountId) +{ + public override CategorySynchronizerOperation Operation => CategorySynchronizerOperation.UpdateCategory; +} diff --git a/Wino.Core/Requests/Mail/MailCategoryAssignmentRequest.cs b/Wino.Core/Requests/Mail/MailCategoryAssignmentRequest.cs new file mode 100644 index 00000000..b87cc56b --- /dev/null +++ b/Wino.Core/Requests/Mail/MailCategoryAssignmentRequest.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Requests; + +namespace Wino.Core.Requests.Mail; + +public record MailCategoryAssignmentRequest( + MailCopy Item, + Guid MailCategoryId, + string CategoryName, + IReadOnlyList CategoryNames, + bool IsAssigned) : MailRequestBase(Item), ICustomFolderSynchronizationRequest +{ + public override MailSynchronizerOperation Operation => MailSynchronizerOperation.UpdateCategories; + public List SynchronizationFolderIds => [Item.FolderId]; + public bool ExcludeMustHaveFolders => true; +} + +public class BatchMailCategoryAssignmentRequest : BatchCollection +{ + public BatchMailCategoryAssignmentRequest(IEnumerable collection) : base(collection) + { + } +} diff --git a/Wino.Core/Services/SynchronizationManager.cs b/Wino.Core/Services/SynchronizationManager.cs index a6bf288d..b570aea9 100644 --- a/Wino.Core/Services/SynchronizationManager.cs +++ b/Wino.Core/Services/SynchronizationManager.cs @@ -370,6 +370,26 @@ public class SynchronizationManager : ISynchronizationManager, IRecipient + /// Handles category synchronization for the given account. + /// + /// Account ID to synchronize categories for + /// Cancellation token + /// Synchronization result + public async Task SynchronizeCategoriesAsync(Guid accountId, + CancellationToken cancellationToken = default) + { + EnsureInitialized(); + + var options = new MailSynchronizationOptions + { + AccountId = accountId, + Type = MailSynchronizationType.Categories + }; + + return await SynchronizeMailAsync(options, cancellationToken); + } + /// /// Handles profile synchronization for the given account. /// diff --git a/Wino.Core/Services/SynchronizerFactory.cs b/Wino.Core/Services/SynchronizerFactory.cs index 7aa25880..ccbf2d6d 100644 --- a/Wino.Core/Services/SynchronizerFactory.cs +++ b/Wino.Core/Services/SynchronizerFactory.cs @@ -26,6 +26,7 @@ public class SynchronizerFactory : ISynchronizerFactory private readonly ICalDavClient _calDavClient; private readonly IAutoDiscoveryService _autoDiscoveryService; private readonly ICalendarService _calendarService; + private readonly IMailCategoryService _mailCategoryService; private readonly List synchronizerCache = new(); @@ -41,7 +42,8 @@ public class SynchronizerFactory : ISynchronizerFactory UnifiedImapSynchronizer unifiedImapSynchronizer, ICalDavClient calDavClient, IAutoDiscoveryService autoDiscoveryService, - ICalendarService calendarService) + ICalendarService calendarService, + IMailCategoryService mailCategoryService) { _outlookChangeProcessor = outlookChangeProcessor; _gmailChangeProcessor = gmailChangeProcessor; @@ -56,6 +58,7 @@ public class SynchronizerFactory : ISynchronizerFactory _calDavClient = calDavClient; _autoDiscoveryService = autoDiscoveryService; _calendarService = calendarService; + _mailCategoryService = mailCategoryService; } public async Task GetAccountSynchronizerAsync(Guid accountId) @@ -86,7 +89,7 @@ public class SynchronizerFactory : ISynchronizerFactory { case Domain.Enums.MailProviderType.Outlook: var outlookAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Outlook) as IOutlookAuthenticator; - return new OutlookSynchronizer(mailAccount, outlookAuthenticator, _outlookChangeProcessor, _outlookSynchronizerErrorHandlerFactory); + return new OutlookSynchronizer(mailAccount, outlookAuthenticator, _outlookChangeProcessor, _outlookSynchronizerErrorHandlerFactory, _mailCategoryService); case Domain.Enums.MailProviderType.Gmail: var gmailAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Gmail) as IGmailAuthenticator; return new GmailSynchronizer(mailAccount, gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory); diff --git a/Wino.Core/Services/WinoRequestDelegator.cs b/Wino.Core/Services/WinoRequestDelegator.cs index 033254ba..2b9d6e9d 100644 --- a/Wino.Core/Services/WinoRequestDelegator.cs +++ b/Wino.Core/Services/WinoRequestDelegator.cs @@ -207,6 +207,21 @@ public class WinoRequestDelegator : IWinoRequestDelegator await QueueCalendarSynchronizationAsync(accountId); } + public async Task ExecuteAsync(Guid accountId, IEnumerable requests) + { + var requestList = requests?.Where(a => a != null).ToList() ?? []; + if (requestList.Count == 0) + return; + + foreach (var request in requestList) + { + await QueueRequestAsync(request, accountId).ConfigureAwait(false); + } + + await SendSyncActionsAddedAsync(requestList, accountId).ConfigureAwait(false); + await QueueSynchronizationAsync(accountId).ConfigureAwait(false); + } + private async Task CreateCalendarEventRequestAsync(CalendarOperationPreparationRequest calendarPreparationRequest) { var composeResult = calendarPreparationRequest.ComposeResult diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 11855ec5..3dca7a54 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -41,6 +41,7 @@ using Wino.Core.Integration.Processors; using Wino.Core.Misc; using Wino.Core.Requests.Bundles; using Wino.Core.Requests.Calendar; +using Wino.Core.Requests.Category; using Wino.Core.Requests.Folder; using Wino.Core.Requests.Mail; using Wino.Messaging.UI; @@ -107,6 +108,7 @@ public class OutlookSynchronizer : WinoSynchronizer !string.IsNullOrWhiteSpace(a?.DisplayName)) + .Select(a => + { + var colorOption = GetMailCategoryColorOption(a.Color); + + return new MailCategory + { + MailAccountId = Account.Id, + RemoteId = a.Id, + Name = a.DisplayName, + BackgroundColorHex = colorOption.BackgroundColorHex, + TextColorHex = colorOption.TextColorHex, + Source = MailCategorySource.Outlook + }; + }) + .ToList() ?? []; + + await _mailCategoryService.ReplaceCategoriesAsync(Account.Id, categories).ConfigureAwait(false); + } + + private async Task ReplaceMailAssignmentsAsync(string messageId, IEnumerable categoryNames) + { + var localMailCopies = await _outlookChangeProcessor.GetMailCopiesAsync([messageId]).ConfigureAwait(false); + + foreach (var localMailCopy in localMailCopies) + { + await _mailCategoryService.ReplaceMailAssignmentsAsync(Account.Id, localMailCopy.UniqueId, categoryNames ?? []).ConfigureAwait(false); + } + } + private async Task GetSpecialFolderIdsAsync(CancellationToken cancellationToken) { var localFolders = await _outlookChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false); @@ -1767,6 +1814,87 @@ public class OutlookSynchronizer : WinoSynchronizer> UpdateCategories(BatchMailCategoryAssignmentRequest request) + => ForEachRequest(request, item => CreateMessageCategoryPatchRequest(item.Item.Id, item.CategoryNames)); + + public override List> CreateCategory(MailCategoryCreateRequest request) + { + var outlookCategory = new OutlookCategory + { + DisplayName = request.Category.Name, + Color = GetOutlookCategoryColor(request.Category) + }; + + var requestInfo = _graphClient.Me.Outlook.MasterCategories.ToPostRequestInformation(outlookCategory); + return [new HttpRequestBundle(requestInfo, request)]; + } + + public override List> UpdateCategory(MailCategoryUpdateRequest request) + { + if (string.IsNullOrWhiteSpace(request.PreviousRemoteId)) + return CreateCategory(new MailCategoryCreateRequest(request.Category)); + + var hasNameChanged = !string.Equals(request.PreviousName, request.Category.Name, StringComparison.Ordinal); + if (!hasNameChanged) + { + var requestInfo = _graphClient.Me.Outlook.MasterCategories[request.PreviousRemoteId].ToPatchRequestInformation(new OutlookCategory + { + Color = GetOutlookCategoryColor(request.Category) + }); + + return [new HttpRequestBundle(requestInfo, request)]; + } + + var bundles = new List>(); + var createRequestInfo = _graphClient.Me.Outlook.MasterCategories.ToPostRequestInformation(new OutlookCategory + { + DisplayName = request.Category.Name, + Color = GetOutlookCategoryColor(request.Category) + }); + + bundles.Add(new HttpRequestBundle(createRequestInfo, request)); + + foreach (var target in request.AffectedMessages ?? []) + { + bundles.Add(new HttpRequestBundle( + CreateMessageCategoryPatchRequest(target.MessageId, target.CategoryNames), + request)); + } + + bundles.Add(new HttpRequestBundle( + _graphClient.Me.Outlook.MasterCategories[request.PreviousRemoteId].ToDeleteRequestInformation(), + request)); + + return bundles; + } + + public override List> DeleteCategory(MailCategoryDeleteRequest request) + { + if (string.IsNullOrWhiteSpace(request.PreviousRemoteId)) + return []; + + var bundles = new List>(); + + foreach (var target in request.AffectedMessages ?? []) + { + bundles.Add(new HttpRequestBundle( + CreateMessageCategoryPatchRequest(target.MessageId, target.CategoryNames), + request)); + } + + bundles.Add(new HttpRequestBundle( + _graphClient.Me.Outlook.MasterCategories[request.PreviousRemoteId].ToDeleteRequestInformation(), + request)); + + return bundles; + } + + private RequestInformation CreateMessageCategoryPatchRequest(string messageId, IReadOnlyList categoryNames) + => _graphClient.Me.Messages[messageId].ToPatchRequestInformation(new Message + { + Categories = categoryNames?.ToList() ?? [] + }); + public override async Task DownloadMissingMimeMessageAsync(MailCopy mailItem, MailKit.ITransferProgress transferProgress = null, CancellationToken cancellationToken = default) @@ -1962,7 +2090,9 @@ public class OutlookSynchronizer : WinoSynchronizer(); + if (!string.IsNullOrWhiteSpace(createdCategoryId)) + { + await _mailCategoryService.UpdateRemoteIdAsync(createCategoryRequest.Category.Id, createdCategoryId).ConfigureAwait(false); + } + return; + } + + if (bundle?.UIChangeRequest is MailCategoryUpdateRequest updateCategoryRequest) + { + var updatedCategoryId = json?["id"]?.GetValue(); + if (!string.IsNullOrWhiteSpace(updatedCategoryId)) + { + await _mailCategoryService.UpdateRemoteIdAsync(updateCategoryRequest.Category.Id, updatedCategoryId).ConfigureAwait(false); + } } } catch (Exception ex) @@ -2367,11 +2520,68 @@ public class OutlookSynchronizer : WinoSynchronizer color switch + { + CategoryColor.Preset0 => new("#FEE2E2", "#991B1B"), + CategoryColor.Preset1 => new("#FFEDD5", "#9A3412"), + CategoryColor.Preset2 => new("#FEF3C7", "#92400E"), + CategoryColor.Preset3 => new("#ECFCCB", "#3F6212"), + CategoryColor.Preset4 => new("#DCFCE7", "#166534"), + CategoryColor.Preset5 => new("#CCFBF1", "#115E59"), + CategoryColor.Preset6 => new("#CFFAFE", "#155E75"), + CategoryColor.Preset7 => new("#DBEAFE", "#1D4ED8"), + CategoryColor.Preset8 => new("#E0E7FF", "#4338CA"), + CategoryColor.Preset9 => new("#F3E8FF", "#7E22CE"), + CategoryColor.Preset10 => new("#FCE7F3", "#9D174D"), + CategoryColor.Preset11 => new("#FECACA", "#7F1D1D"), + CategoryColor.Preset12 => new("#FED7AA", "#7C2D12"), + CategoryColor.Preset13 => new("#FDE68A", "#78350F"), + CategoryColor.Preset14 => new("#D9F99D", "#365314"), + CategoryColor.Preset15 => new("#BBF7D0", "#14532D"), + CategoryColor.Preset16 => new("#99F6E4", "#134E4A"), + CategoryColor.Preset17 => new("#A5F3FC", "#164E63"), + CategoryColor.Preset18 => new("#BFDBFE", "#1E3A8A"), + CategoryColor.Preset19 => new("#DDD6FE", "#5B21B6"), + CategoryColor.Preset20 => new("#E5E7EB", "#374151"), + CategoryColor.Preset21 => new("#D1D5DB", "#1F2937"), + CategoryColor.Preset22 => new("#F3F4F6", "#111827"), + CategoryColor.Preset23 => new("#E2E8F0", "#334155"), + CategoryColor.Preset24 => new("#F8FAFC", "#475569"), + _ => new("#E5E7EB", "#374151") + }; + + private static CategoryColor GetOutlookCategoryColor(MailCategory category) + => (category.BackgroundColorHex?.ToUpperInvariant(), category.TextColorHex?.ToUpperInvariant()) switch + { + ("#FEE2E2", "#991B1B") => CategoryColor.Preset0, + ("#FFEDD5", "#9A3412") => CategoryColor.Preset1, + ("#FEF3C7", "#92400E") => CategoryColor.Preset2, + ("#ECFCCB", "#3F6212") => CategoryColor.Preset3, + ("#DCFCE7", "#166534") => CategoryColor.Preset4, + ("#CCFBF1", "#115E59") => CategoryColor.Preset5, + ("#CFFAFE", "#155E75") => CategoryColor.Preset6, + ("#DBEAFE", "#1D4ED8") => CategoryColor.Preset7, + ("#E0E7FF", "#4338CA") => CategoryColor.Preset8, + ("#F3E8FF", "#7E22CE") => CategoryColor.Preset9, + ("#FCE7F3", "#9D174D") => CategoryColor.Preset10, + ("#FECACA", "#7F1D1D") => CategoryColor.Preset11, + ("#FED7AA", "#7C2D12") => CategoryColor.Preset12, + ("#FDE68A", "#78350F") => CategoryColor.Preset13, + ("#D9F99D", "#365314") => CategoryColor.Preset14, + ("#BBF7D0", "#14532D") => CategoryColor.Preset15, + ("#99F6E4", "#134E4A") => CategoryColor.Preset16, + ("#A5F3FC", "#164E63") => CategoryColor.Preset17, + ("#BFDBFE", "#1E3A8A") => CategoryColor.Preset18, + ("#DDD6FE", "#5B21B6") => CategoryColor.Preset19, + _ => CategoryColor.Preset0 + }; + private async Task TryMapCalendarInvitationAsync(MailCopy mailCopy, MimeMessage mimeMessage, CancellationToken cancellationToken) { if (mailCopy.ItemType != MailItemType.CalendarInvitation || mimeMessage == null) diff --git a/Wino.Core/Synchronizers/WinoSynchronizer.cs b/Wino.Core/Synchronizers/WinoSynchronizer.cs index a8f9450d..1ed4fcfa 100644 --- a/Wino.Core/Synchronizers/WinoSynchronizer.cs +++ b/Wino.Core/Synchronizers/WinoSynchronizer.cs @@ -20,6 +20,7 @@ using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Requests.Bundles; using Wino.Core.Requests.Calendar; +using Wino.Core.Requests.Category; using Wino.Core.Requests.Folder; using Wino.Core.Requests.Mail; using Wino.Messaging.UI; @@ -63,6 +64,7 @@ public abstract class WinoSynchronizer protected virtual Task SynchronizeAliasesAsync() => Task.CompletedTask; + protected virtual Task SynchronizeCategoriesAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; /// /// Queues all mail ids for initial synchronization for a specific folder. @@ -194,6 +196,9 @@ public abstract class WinoSynchronizer()))); break; + case MailSynchronizerOperation.UpdateCategories: + nativeRequests.AddRange(UpdateCategories(new BatchMailCategoryAssignmentRequest(group.Cast()))); + break; default: break; } @@ -221,6 +226,23 @@ public abstract class WinoSynchronizerNew synchronization options with minimal HTTP effort. private MailSynchronizationOptions GetSynchronizationOptionsAfterRequestExecution(List requests, Guid existingSynchronizationId) { + if (requests.All(a => a is ICategoryActionRequest or MailCategoryAssignmentRequest)) + { + return new MailSynchronizationOptions + { + AccountId = Account.Id, + Id = existingSynchronizationId, + Type = MailSynchronizationType.FoldersOnly + }; + } + List synchronizationFolderIds = requests .Where(a => a is ICustomFolderSynchronizationRequest) .Cast() @@ -602,6 +658,10 @@ public abstract class WinoSynchronizer> MarkFolderAsRead(MarkFolderAsReadRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); public virtual List> DeleteFolder(DeleteFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); public virtual List> CreateSubFolder(CreateSubFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); + public virtual List> UpdateCategories(BatchMailCategoryAssignmentRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); + public virtual List> CreateCategory(MailCategoryCreateRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); + public virtual List> UpdateCategory(MailCategoryUpdateRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); + public virtual List> DeleteCategory(MailCategoryDeleteRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType())); #endregion diff --git a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs index ac1ceff2..1f659a7a 100644 --- a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs +++ b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs @@ -169,6 +169,10 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel private void EditAliases() => Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsManageAliases_Title, WinoPage.AliasManagementPage, Account.Id)); + [RelayCommand] + private void EditCategories() + => Messenger.Send(new BreadcrumbNavigationRequested(Translator.MailCategoryManagementPage_Title, WinoPage.MailCategoryManagementPage, Account.Id)); + [RelayCommand] private void EditImapCalDavSettings() => Messenger.Send(new BreadcrumbNavigationRequested( diff --git a/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs b/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs index 8c218639..388b9e57 100644 --- a/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs +++ b/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs @@ -81,6 +81,10 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingProfile }); Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SavingAccount }); Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingFolders }); + if (WizardContext.SelectedProvider.Type == MailProviderType.Outlook) + { + Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingCategories }); + } Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_FetchingCalendarMetadata }); Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_SyncingAliases }); Steps.Add(new AccountSetupStepModel { Title = Translator.AccountSetup_Step_Finalizing }); @@ -229,6 +233,16 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel throw new Exception(Translator.Exception_FailedToSynchronizeFolders); SetCurrentStepSucceeded(); + // Step: Categories + if (_createdAccount.IsCategorySyncSupported) + { + SetStepInProgress(Translator.AccountSetup_Step_SyncingCategories); + var categoryResult = await SynchronizationManager.Instance.SynchronizeCategoriesAsync(_createdAccount.Id); + if (categoryResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeCategories); + SetCurrentStepSucceeded(); + } + // Step: Calendar metadata SetStepInProgress(Translator.AccountSetup_Step_FetchingCalendarMetadata); if (_createdAccount.IsCalendarAccessGranted) diff --git a/Wino.Mail.ViewModels/MailAppShellViewModel.cs b/Wino.Mail.ViewModels/MailAppShellViewModel.cs index 1fa4033e..c93578dc 100644 --- a/Wino.Mail.ViewModels/MailAppShellViewModel.cs +++ b/Wino.Mail.ViewModels/MailAppShellViewModel.cs @@ -73,6 +73,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, public IMenuItem CreatePrimaryMenuItem => CreateMailMenuItem; private readonly IFolderService _folderService; + private readonly IMailCategoryService _mailCategoryService; private readonly IConfigurationService _configurationService; private readonly IStartupBehaviorService _startupBehaviorService; private readonly IAccountService _accountService; @@ -99,6 +100,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, IMimeFileService mimeFileService, INativeAppService nativeAppService, IMailService mailService, + IMailCategoryService mailCategoryService, IAccountService accountService, IContextMenuItemService contextMenuItemService, IStoreRatingService storeRatingService, @@ -125,6 +127,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel, _mimeFileService = mimeFileService; _nativeAppService = nativeAppService; _mailService = mailService; + _mailCategoryService = mailCategoryService; _folderService = folderService; _accountService = accountService; _contextMenuItemService = contextMenuItemService; @@ -721,7 +724,8 @@ public partial class MailAppShellViewModel : MailBaseViewModel, { await HandleCreateNewMailAsync(); } - else if (clickedMenuItem is IBaseFolderMenuItem baseFolderMenuItem && baseFolderMenuItem.HandlingFolders.All(a => a.IsMoveTarget)) + else if (clickedMenuItem is IBaseFolderMenuItem baseFolderMenuItem && + (clickedMenuItem is IMailCategoryMenuItem or IMergedMailCategoryMenuItem || baseFolderMenuItem.HandlingFolders.All(a => a.IsMoveTarget))) { // Don't navigate to base folders that contain non-move target folders. // Theory: This is a special folder like Categories or More. Don't navigate to it. @@ -793,11 +797,20 @@ public partial class MailAppShellViewModel : MailBaseViewModel, { // Get visible account menu items, ordered by merged accounts at the last. // We will update the unread counts for all single accounts and trigger UI refresh for merged menu items. - var accountMenuItems = MenuItems.GetAllAccountMenuItems().OrderBy(a => a.HoldingAccounts.Count()); + List accountMenuItems = null; + + await ExecuteUIThread(() => + { + accountMenuItems = MenuItems + .GetAllAccountMenuItems() + .OrderBy(a => a.HoldingAccounts.Count()) + .ToList(); + }); // Individually get all single accounts' unread counts. - var accountIds = accountMenuItems.OfType().Select(a => a.AccountId); + var accountIds = accountMenuItems.OfType().Select(a => a.AccountId).ToList(); var unreadCountResult = await _folderService.GetUnreadItemCountResultsAsync(accountIds).ConfigureAwait(false); + var unreadCategoryCountResult = await _mailCategoryService.GetUnreadCategoryCountResultsAsync(accountIds).ConfigureAwait(false); // Recursively update all folders' unread counts to 0. // Query above only returns unread counts that exists. We need to reset the rest to 0 first. @@ -849,6 +862,29 @@ public partial class MailAppShellViewModel : MailBaseViewModel, } } + foreach (var unreadCategoryCount in unreadCategoryCountResult) + { + if (MenuItems.TryGetCategoryMenuItem(unreadCategoryCount.CategoryId, out var categoryMenuItem)) + { + if (categoryMenuItem is IMergedMailCategoryMenuItem mergedCategoryMenuItem) + { + await ExecuteUIThread(() => + { + categoryMenuItem.UnreadItemCount = unreadCategoryCountResult + .Where(a => mergedCategoryMenuItem.Categories.Any(b => b.Id == a.CategoryId)) + .Sum(a => a.UnreadItemCount); + }); + } + else + { + await ExecuteUIThread(() => + { + categoryMenuItem.UnreadItemCount = unreadCategoryCount.UnreadItemCount; + }); + } + } + } + // Update unread badge after all unread counts are updated. await _notificationBuilder.UpdateTaskbarIconBadgeAsync(); } diff --git a/Wino.Mail.ViewModels/MailCategoryManagementPageViewModel.cs b/Wino.Mail.ViewModels/MailCategoryManagementPageViewModel.cs new file mode 100644 index 00000000..fb431fbf --- /dev/null +++ b/Wino.Mail.ViewModels/MailCategoryManagementPageViewModel.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Wino.Core.Domain; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.MailItem; +using Wino.Core.Domain.Models.Navigation; +using Wino.Core.Domain.Models.Synchronization; +using Wino.Core.Requests.Category; +using Wino.Core.Services; + +namespace Wino.Mail.ViewModels; + +public partial class MailCategoryManagementPageViewModel : MailBaseViewModel +{ + private readonly IMailCategoryService _mailCategoryService; + private readonly IAccountService _accountService; + private readonly IMailDialogService _dialogService; + private readonly IWinoRequestDelegator _winoRequestDelegator; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanRefresh))] + public partial MailAccount Account { get; set; } + + public ObservableCollection Categories { get; } = []; + + public bool CanRefresh => Account?.ProviderType == MailProviderType.Outlook; + public bool HasCategories => Categories.Count > 0; + + public MailCategoryManagementPageViewModel( + IMailCategoryService mailCategoryService, + IAccountService accountService, + IMailDialogService dialogService, + IWinoRequestDelegator winoRequestDelegator) + { + _mailCategoryService = mailCategoryService; + _accountService = accountService; + _dialogService = dialogService; + _winoRequestDelegator = winoRequestDelegator; + } + + public override async void OnNavigatedTo(NavigationMode mode, object parameters) + { + base.OnNavigatedTo(mode, parameters); + + if (parameters is not Guid accountId) + return; + + Account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false); + + if (Account != null) + { + await LoadCategoriesAsync().ConfigureAwait(false); + } + } + + [RelayCommand] + private Task AddCategoryAsync() + => CreateOrUpdateCategoryAsync(); + + [RelayCommand] + private async Task RefreshCategoriesAsync() + { + if (!CanRefresh) + return; + + var shouldContinue = await _dialogService.ShowConfirmationDialogAsync( + Translator.MailCategoryManagementPage_RefreshConfirmationMessage, + Translator.Buttons_Refresh, + Translator.Buttons_Refresh).ConfigureAwait(false); + + if (!shouldContinue) + return; + + await _mailCategoryService.DeleteCategoriesAsync(Account.Id).ConfigureAwait(false); + await SynchronizationManager.Instance.SynchronizeCategoriesAsync(Account.Id).ConfigureAwait(false); + + await LoadCategoriesAsync().ConfigureAwait(false); + } + + public Task EditCategoryAsync(MailCategory category) + => CreateOrUpdateCategoryAsync(category); + + public async Task DeleteCategoryAsync(MailCategory category) + { + if (category == null) + return; + + var shouldDelete = await _dialogService.ShowConfirmationDialogAsync( + string.Format(Translator.MailCategoryManagementPage_DeleteConfirmationMessage, category.Name), + Translator.MailCategoryManagementPage_DeleteConfirmationTitle, + Translator.Buttons_Delete).ConfigureAwait(false); + + if (!shouldDelete) + return; + + var deleteRequest = await BuildDeleteCategoryRequestAsync(category).ConfigureAwait(false); + await _mailCategoryService.DeleteCategoryAsync(category.Id).ConfigureAwait(false); + await QueueOutlookCategoryRequestsAsync(deleteRequest).ConfigureAwait(false); + await LoadCategoriesAsync().ConfigureAwait(false); + } + + public async Task SetFavoriteAsync(MailCategory category, bool isFavorite) + { + if (category == null) + return; + + await _mailCategoryService.ToggleFavoriteAsync(category.Id, isFavorite).ConfigureAwait(false); + await LoadCategoriesAsync().ConfigureAwait(false); + } + + private async Task CreateOrUpdateCategoryAsync(MailCategory existingCategory = null) + { + var dialogResult = await _dialogService.ShowEditMailCategoryDialogAsync(existingCategory).ConfigureAwait(false); + if (dialogResult == null) + return; + + if (string.IsNullOrWhiteSpace(dialogResult.Name)) + { + await _dialogService.ShowMessageAsync( + Translator.MailCategoryDialog_InvalidNameMessage, + Translator.MailCategoryDialog_InvalidNameTitle, + WinoCustomMessageDialogIcon.Warning).ConfigureAwait(false); + return; + } + + var normalizedName = dialogResult.Name.Trim(); + var categoryIdToExclude = existingCategory?.Id; + var alreadyExists = await _mailCategoryService.CategoryNameExistsAsync(Account.Id, normalizedName, categoryIdToExclude).ConfigureAwait(false); + + if (alreadyExists) + { + await _dialogService.ShowMessageAsync( + Translator.MailCategoryDialog_DuplicateMessage, + Translator.MailCategoryDialog_DuplicateTitle, + WinoCustomMessageDialogIcon.Warning).ConfigureAwait(false); + return; + } + + if (existingCategory == null) + { + var newCategory = new MailCategory + { + Id = Guid.NewGuid(), + MailAccountId = Account.Id, + Name = normalizedName, + BackgroundColorHex = dialogResult.BackgroundColorHex, + TextColorHex = dialogResult.TextColorHex, + Source = Account.ProviderType == MailProviderType.Outlook ? MailCategorySource.Outlook : MailCategorySource.Local + }; + + await _mailCategoryService.CreateCategoryAsync(newCategory).ConfigureAwait(false); + + if (Account.ProviderType == MailProviderType.Outlook) + { + await _winoRequestDelegator.ExecuteAsync(Account.Id, [new MailCategoryCreateRequest(newCategory)]).ConfigureAwait(false); + } + } + else + { + var previousName = existingCategory.Name; + var previousRemoteId = existingCategory.RemoteId; + + existingCategory.Name = normalizedName; + existingCategory.BackgroundColorHex = dialogResult.BackgroundColorHex; + existingCategory.TextColorHex = dialogResult.TextColorHex; + + await _mailCategoryService.UpdateCategoryAsync(existingCategory).ConfigureAwait(false); + + if (Account.ProviderType == MailProviderType.Outlook) + { + if (string.IsNullOrWhiteSpace(previousRemoteId)) + { + await _winoRequestDelegator.ExecuteAsync(Account.Id, [new MailCategoryCreateRequest(existingCategory)]).ConfigureAwait(false); + } + else + { + var affectedMessages = await BuildAffectedMessageTargetsAsync(existingCategory.Id).ConfigureAwait(false); + var updateRequest = new MailCategoryUpdateRequest(existingCategory, previousName, previousRemoteId, affectedMessages); + await _winoRequestDelegator.ExecuteAsync(Account.Id, [updateRequest]).ConfigureAwait(false); + } + } + } + + await LoadCategoriesAsync().ConfigureAwait(false); + } + + private async Task BuildDeleteCategoryRequestAsync(MailCategory category) + { + if (category == null || Account?.ProviderType != MailProviderType.Outlook) + return null; + + var mailCopies = await _mailCategoryService.GetMailCopiesForCategoryAsync(category.Id).ConfigureAwait(false); + var affectedMessages = new List(); + + foreach (var mailCopy in mailCopies.Where(a => !string.IsNullOrWhiteSpace(a.Id))) + { + var remainingNames = await _mailCategoryService.GetCategoryNamesForMailAsync(mailCopy.UniqueId).ConfigureAwait(false); + var categoryNames = remainingNames + .Where(a => !string.Equals(a, category.Name, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + affectedMessages.Add(new MailCategoryMessageUpdateTarget(mailCopy.Id, categoryNames)); + } + + return new MailCategoryDeleteRequest(category, category.RemoteId, affectedMessages); + } + + private async Task> BuildAffectedMessageTargetsAsync(Guid categoryId) + { + var mailCopies = await _mailCategoryService.GetMailCopiesForCategoryAsync(categoryId).ConfigureAwait(false); + var affectedMessages = new List(); + + foreach (var mailCopy in mailCopies.Where(a => !string.IsNullOrWhiteSpace(a.Id))) + { + var categoryNames = await _mailCategoryService.GetCategoryNamesForMailAsync(mailCopy.UniqueId).ConfigureAwait(false); + affectedMessages.Add(new MailCategoryMessageUpdateTarget(mailCopy.Id, categoryNames)); + } + + return affectedMessages; + } + + private Task QueueOutlookCategoryRequestsAsync(params IRequestBase[] requests) + => Account?.ProviderType == MailProviderType.Outlook && requests.Any(a => a != null) + ? _winoRequestDelegator.ExecuteAsync(Account.Id, requests.Where(a => a != null)) + : Task.CompletedTask; + + private async Task LoadCategoriesAsync() + { + var categories = await _mailCategoryService.GetCategoriesAsync(Account.Id).ConfigureAwait(false); + + await ExecuteUIThread(() => + { + Categories.Clear(); + + foreach (var category in categories) + { + Categories.Add(category); + } + + OnPropertyChanged(nameof(HasCategories)); + }); + } +} diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index e0e07540..2631bbc8 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -24,6 +24,7 @@ using Wino.Core.Domain.Models.Menus; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Reader; using Wino.Core.Domain.Models.Synchronization; +using Wino.Core.Requests.Mail; using Wino.Core.Services; using Wino.Mail.ViewModels.Collections; using Wino.Mail.ViewModels.Data; @@ -77,6 +78,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, private readonly INotificationBuilder _notificationBuilder; private readonly IFolderService _folderService; private readonly IContextMenuItemService _contextMenuItemService; + private readonly IMailCategoryService _mailCategoryService; private readonly IWinoRequestDelegator _winoRequestDelegator; private readonly IKeyPressService _keyPressService; private readonly IWinoLogger _winoLogger; @@ -156,6 +158,8 @@ public partial class MailListPageViewModel : MailBaseViewModel, [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanSynchronize))] [NotifyPropertyChangedFor(nameof(IsFolderSynchronizationEnabled))] + [NotifyPropertyChangedFor(nameof(IsCategoryView))] + [NotifyPropertyChangedFor(nameof(IsSyncButtonVisible))] public partial IBaseFolderMenuItem ActiveFolder { get; set; } [ObservableProperty] @@ -172,6 +176,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, INotificationBuilder notificationBuilder, IFolderService folderService, IContextMenuItemService contextMenuItemService, + IMailCategoryService mailCategoryService, IWinoRequestDelegator winoRequestDelegator, IKeyPressService keyPressService, IPreferencesService preferencesService, @@ -185,6 +190,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, _mimeFileService = mimeFileService; _folderService = folderService; _contextMenuItemService = contextMenuItemService; + _mailCategoryService = mailCategoryService; _winoRequestDelegator = winoRequestDelegator; _keyPressService = keyPressService; @@ -277,9 +283,11 @@ public partial class MailListPageViewModel : MailBaseViewModel, } } - public bool CanSynchronize => !IsAccountSynchronizerInSynchronization && IsFolderSynchronizationEnabled; + public bool CanSynchronize => !IsCategoryView && !IsAccountSynchronizerInSynchronization && IsFolderSynchronizationEnabled; public bool IsFolderSynchronizationEnabled => ActiveFolder?.IsSynchronizationEnabled ?? false; public bool IsArchiveSpecialFolder => ActiveFolder?.SpecialFolderType == SpecialFolderType.Archive; + public bool IsCategoryView => ActiveFolder is IMailCategoryMenuItem or IMergedMailCategoryMenuItem; + public bool IsSyncButtonVisible => !IsCategoryView; public string SelectedMessageText => IsDragInProgress ? string.Format(Translator.MailsDragging, DraggingItemsCount) @@ -396,9 +404,12 @@ public partial class MailListPageViewModel : MailBaseViewModel, } else { + if (IsCategoryView) + { + PivotFolders.Add(new FolderPivotViewModel(ActiveFolder.FolderName, null)); + } // Merged folders don't support focused feature. - - if (ActiveFolder is IMergedAccountFolderMenuItem) + else if (ActiveFolder is IMergedAccountFolderMenuItem) { PivotFolders.Add(new FolderPivotViewModel(ActiveFolder.FolderName, null)); } @@ -545,7 +556,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, [RelayCommand] private async Task EnableFolderSynchronizationAsync() { - if (ActiveFolder == null) return; + if (ActiveFolder == null || IsCategoryView) return; foreach (var folder in ActiveFolder.HandlingFolders) { @@ -561,13 +572,9 @@ public partial class MailListPageViewModel : MailBaseViewModel, Debug.WriteLine("Loading more..."); await ExecuteUIThread(() => { IsInitializingFolder = true; }); - var initializationOptions = new MailListInitializationOptions(ActiveFolder.HandlingFolders, - SelectedFilterOption.Type, - SelectedSortingOption.Type, - PreferencesService.IsThreadingEnabled, - SelectedFolderPivot.IsFocused, - IsInSearchMode ? SearchQuery : string.Empty, - MailCollection.MailCopyIdHashSet); + var initializationOptions = CreateInitializationOptions( + IsInSearchMode ? SearchQuery : string.Empty, + MailCollection.MailCopyIdHashSet); var items = await _mailService.FetchMailsAsync(initializationOptions).ConfigureAwait(false); @@ -674,6 +681,60 @@ public partial class MailListPageViewModel : MailBaseViewModel, public IEnumerable GetAvailableMailActions(IEnumerable contextMailItems) => _contextMenuItemService.GetMailItemContextMenuActions(contextMailItems.Select(a => a.MailCopy)); + public async Task<(IReadOnlyList Categories, IReadOnlyCollection AssignedCategoryIds)> GetAvailableCategoriesAsync(IEnumerable targetItems) + { + var targetList = targetItems?.Where(a => a?.MailCopy?.AssignedAccount != null).ToList() ?? []; + if (targetList.Count == 0) + return ([], []); + + var accountIds = targetList.Select(a => a.MailCopy.AssignedAccount.Id).Distinct().ToList(); + if (accountIds.Count != 1) + return ([], []); + + var accountId = accountIds[0]; + var uniqueIds = targetList.Select(a => a.MailCopy.UniqueId).Distinct().ToList(); + + var categories = await _mailCategoryService.GetCategoriesAsync(accountId).ConfigureAwait(false); + var assignedCategoryIds = await _mailCategoryService.GetAssignedCategoryIdsForAllAsync(uniqueIds).ConfigureAwait(false); + + return (categories, assignedCategoryIds); + } + + public async Task ToggleCategoryAssignmentAsync(MailCategory category, IEnumerable targetItems, bool isAssignedToAll) + { + var targetList = targetItems?.Where(a => a?.MailCopy?.AssignedAccount != null).ToList() ?? []; + if (category == null || targetList.Count == 0) + return; + + var accountIds = targetList.Select(a => a.MailCopy.AssignedAccount.Id).Distinct().ToList(); + if (accountIds.Count != 1) + return; + + var accountId = accountIds[0]; + var uniqueIds = targetList.Select(a => a.MailCopy.UniqueId).Distinct().ToList(); + + if (isAssignedToAll) + { + await _mailCategoryService.UnassignCategoryAsync(category.Id, uniqueIds).ConfigureAwait(false); + } + else + { + await _mailCategoryService.AssignCategoryAsync(category.Id, uniqueIds).ConfigureAwait(false); + } + + if (targetList.First().MailCopy.AssignedAccount.ProviderType != MailProviderType.Outlook) + return; + + var requests = new List(); + foreach (var mailItem in targetList.Select(a => a.MailCopy).DistinctBy(a => a.UniqueId)) + { + var categoryNames = await _mailCategoryService.GetCategoryNamesForMailAsync(mailItem.UniqueId).ConfigureAwait(false); + requests.Add(new MailCategoryAssignmentRequest(mailItem, category.Id, category.Name, categoryNames, !isAssignedToAll)); + } + + await _winoRequestDelegator.ExecuteAsync(accountId, requests).ConfigureAwait(false); + } + private bool ShouldPreventItemAdd(MailCopy mailItem) { bool condition = mailItem.IsRead @@ -691,7 +752,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, => ActiveFolder?.SpecialFolderType == SpecialFolderType.Draft; private bool BelongsToActiveFolder(MailCopy mailItem) - => mailItem?.AssignedFolder != null && ActiveFolder?.HandlingFolders?.Any(a => a.Id == mailItem.AssignedFolder.Id) == true; + => !IsCategoryView && mailItem?.AssignedFolder != null && ActiveFolder?.HandlingFolders?.Any(a => a.Id == mailItem.AssignedFolder.Id) == true; private bool ShouldIncludeByThread(MailCopy mailItem) => PreferencesService.IsThreadingEnabled @@ -1069,6 +1130,38 @@ public partial class MailListPageViewModel : MailBaseViewModel, } } + private MailListInitializationOptions CreateInitializationOptions( + string searchQuery, + System.Collections.Concurrent.ConcurrentDictionary existingUniqueIds, + List preFetchedMailCopies = null, + bool deduplicateByServerId = false) + { + var options = new MailListInitializationOptions(ActiveFolder.HandlingFolders, + SelectedFilterOption.Type, + SelectedSortingOption.Type, + PreferencesService.IsThreadingEnabled, + SelectedFolderPivot.IsFocused, + searchQuery, + existingUniqueIds, + preFetchedMailCopies, + DeduplicateByServerId: deduplicateByServerId); + + if (!IsCategoryView) + return options; + + var categoryIds = ActiveFolder switch + { + IMailCategoryMenuItem singleCategoryMenuItem => new List { singleCategoryMenuItem.MailCategory.Id }, + IMergedMailCategoryMenuItem mergedCategoryMenuItem => mergedCategoryMenuItem.Categories.Select(a => a.Id).ToList(), + _ => [] + }; + + return options with + { + CategoryIds = categoryIds + }; + } + [RelayCommand] private async Task PerformOnlineSearchAsync() { @@ -1218,15 +1311,11 @@ public partial class MailListPageViewModel : MailBaseViewModel, } } - var initializationOptions = new MailListInitializationOptions(ActiveFolder.HandlingFolders, - SelectedFilterOption.Type, - SelectedSortingOption.Type, - PreferencesService.IsThreadingEnabled, - SelectedFolderPivot.IsFocused, - isDoingOnlineSearch ? string.Empty : SearchQuery, - MailCollection.MailCopyIdHashSet, - onlineSearchItems, - DeduplicateByServerId: isDoingOnlineSearch); + var initializationOptions = CreateInitializationOptions( + isDoingOnlineSearch ? string.Empty : SearchQuery, + MailCollection.MailCopyIdHashSet, + onlineSearchItems, + isDoingOnlineSearch); items = await _mailService.FetchMailsAsync(initializationOptions, cancellationToken).ConfigureAwait(false); diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index 4df1e294..b3b7189a 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -364,6 +364,7 @@ public partial class App : WinoApplication, services.AddTransient(typeof(StoragePageViewModel)); services.AddTransient(typeof(WinoAccountManagementPageViewModel)); services.AddTransient(typeof(AliasManagementPageViewModel)); + services.AddTransient(typeof(MailCategoryManagementPageViewModel)); services.AddTransient(typeof(ContactsPageViewModel)); services.AddTransient(typeof(SignatureAndEncryptionPageViewModel)); services.AddTransient(typeof(EmailTemplatesPageViewModel)); @@ -1321,6 +1322,7 @@ public partial class App : WinoApplication, return synchronizationType switch { MailSynchronizationType.Alias => Translator.Exception_FailedToSynchronizeAliases, + MailSynchronizationType.Categories => Translator.Exception_FailedToSynchronizeCategories, MailSynchronizationType.UpdateProfile => Translator.Exception_FailedToSynchronizeProfileInformation, _ => Translator.Exception_FailedToSynchronizeFolders }; diff --git a/Wino.Mail.WinUI/Dialogs/EditMailCategoryDialog.xaml b/Wino.Mail.WinUI/Dialogs/EditMailCategoryDialog.xaml new file mode 100644 index 00000000..d14d242d --- /dev/null +++ b/Wino.Mail.WinUI/Dialogs/EditMailCategoryDialog.xaml @@ -0,0 +1,60 @@ + + + + 520 + 520 + + + + + + + + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Dialogs/EditMailCategoryDialog.xaml.cs b/Wino.Mail.WinUI/Dialogs/EditMailCategoryDialog.xaml.cs new file mode 100644 index 00000000..074ac9fd --- /dev/null +++ b/Wino.Mail.WinUI/Dialogs/EditMailCategoryDialog.xaml.cs @@ -0,0 +1,59 @@ +using System.Linq; +using Microsoft.UI.Xaml.Controls; +using Wino.Core.Domain; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Models.MailItem; + +namespace Wino.Dialogs; + +public sealed partial class EditMailCategoryDialog : ContentDialog +{ + public MailCategoryDialogResult? Result { get; private set; } + public string CategoryName { get; set; } + public MailCategoryColorOption? SelectedColor { get; set; } + + public System.Collections.Generic.IReadOnlyList AvailableColors => MailCategoryPalette.DefaultOptions; + + public EditMailCategoryDialog(MailCategory? category = null) + { + InitializeComponent(); + + Title = category == null ? Translator.MailCategoryDialog_CreateTitle : Translator.MailCategoryDialog_EditTitle; + CategoryName = category?.Name ?? string.Empty; + SelectedColor = MailCategoryPalette.DefaultOptions.FirstOrDefault(a => + a.BackgroundColorHex == category?.BackgroundColorHex && + a.TextColorHex == category?.TextColorHex) ?? MailCategoryPalette.DefaultOptions.First(); + + IsPrimaryButtonEnabled = !string.IsNullOrWhiteSpace(CategoryName); + } + + private void CategoryNameTextChanged(object sender, TextChangedEventArgs e) + => IsPrimaryButtonEnabled = !string.IsNullOrWhiteSpace(CategoryNameTextBox.Text) && SelectedColor != null; + + private void ColorOptionClicked(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is MailCategoryColorOption option) + { + SelectedColor = option; + ColorsGridView.SelectedItem = option; + IsPrimaryButtonEnabled = !string.IsNullOrWhiteSpace(CategoryNameTextBox.Text); + } + } + + private void SaveClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + if (SelectedColor == null) + { + args.Cancel = true; + return; + } + + Result = new MailCategoryDialogResult(CategoryNameTextBox.Text?.Trim(), SelectedColor.BackgroundColorHex, SelectedColor.TextColorHex); + Hide(); + } + + private void CancelClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + Hide(); + } +} diff --git a/Wino.Mail.WinUI/Selectors/NavigationMenuTemplateSelector.cs b/Wino.Mail.WinUI/Selectors/NavigationMenuTemplateSelector.cs index 339fd155..6bfa1bbf 100644 --- a/Wino.Mail.WinUI/Selectors/NavigationMenuTemplateSelector.cs +++ b/Wino.Mail.WinUI/Selectors/NavigationMenuTemplateSelector.cs @@ -26,6 +26,7 @@ public partial class NavigationMenuTemplateSelector : DataTemplateSelector public DataTemplate NewMailTemplate { get; set; } = null!; public DataTemplate CalendarNewEventTemplate { get; set; } = null!; public DataTemplate CategoryItemsTemplate { get; set; } = null!; + public DataTemplate MergedCategoryItemsTemplate { get; set; } = null!; public DataTemplate FixAuthenticationIssueTemplate { get; set; } = null!; public DataTemplate FixMissingFolderConfigTemplate { get; set; } = null!; @@ -58,6 +59,10 @@ public partial class NavigationMenuTemplateSelector : DataTemplateSelector return MergedAccountTemplate; else if (item is MergedAccountMoreFolderMenuItem) return MergedAccountMoreExpansionItemTemplate; + else if (item is MailCategoryMenuItem) + return CategoryItemsTemplate; + else if (item is MergedMailCategoryMenuItem) + return MergedCategoryItemsTemplate; else if (item is MergedAccountFolderMenuItem) return MergedAccountFolderTemplate; else if (item is FolderMenuItem) diff --git a/Wino.Mail.WinUI/Services/DialogService.cs b/Wino.Mail.WinUI/Services/DialogService.cs index 55716e3a..cc05bc66 100644 --- a/Wino.Mail.WinUI/Services/DialogService.cs +++ b/Wino.Mail.WinUI/Services/DialogService.cs @@ -6,7 +6,6 @@ using CommunityToolkit.Mvvm.Messaging; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Wino.Core.Domain; -using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; @@ -15,11 +14,12 @@ using Wino.Core.Domain.Models; using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Folders; +using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Synchronization; -using Wino.Mail.WinUI.Extensions; -using Wino.Mail.WinUI.Services; using Wino.Dialogs; using Wino.Mail.Dialogs; +using Wino.Mail.WinUI.Extensions; +using Wino.Mail.WinUI.Services; using Wino.Messaging.Server; using Wino.Messaging.UI; @@ -58,6 +58,19 @@ public class DialogService : DialogServiceBase, IMailDialogService return createAccountAliasDialog; } +#pragma warning disable CS8625 + public async Task ShowEditMailCategoryDialogAsync(MailCategory category = null) +#pragma warning restore CS8625 + { + var dialog = new EditMailCategoryDialog(category) + { + RequestedTheme = ThemeService.RootTheme.ToWindowsElementTheme() + }; + + await HandleDialogPresentationAsync(dialog); + return dialog.Result; + } + public async Task HandleSystemFolderConfigurationDialogAsync(Guid accountId, IFolderService folderService) { try diff --git a/Wino.Mail.WinUI/Services/NavigationService.cs b/Wino.Mail.WinUI/Services/NavigationService.cs index 7c4688ed..cdf45cca 100644 --- a/Wino.Mail.WinUI/Services/NavigationService.cs +++ b/Wino.Mail.WinUI/Services/NavigationService.cs @@ -82,6 +82,7 @@ public class NavigationService : NavigationServiceBase, INavigationService WinoPage.ReadComposePanePage, WinoPage.AppPreferencesPage, WinoPage.AliasManagementPage, + WinoPage.MailCategoryManagementPage, WinoPage.ImapCalDavSettingsPage, WinoPage.KeyboardShortcutsPage, WinoPage.SignatureAndEncryptionPage, @@ -150,6 +151,7 @@ public class NavigationService : NavigationServiceBase, INavigationService WinoPage.SettingOptionsPage => typeof(SettingOptionsPage), WinoPage.AppPreferencesPage => typeof(AppPreferencesPage), WinoPage.AliasManagementPage => typeof(AliasManagementPage), + WinoPage.MailCategoryManagementPage => typeof(MailCategoryManagementPage), WinoPage.ImapCalDavSettingsPage => typeof(ImapCalDavSettingsPage), WinoPage.KeyboardShortcutsPage => typeof(KeyboardShortcutsPage), WinoPage.ContactsPage => typeof(ContactsPage), diff --git a/Wino.Mail.WinUI/Views/Abstract/MailCategoryManagementPageAbstract.cs b/Wino.Mail.WinUI/Views/Abstract/MailCategoryManagementPageAbstract.cs new file mode 100644 index 00000000..ebf45d57 --- /dev/null +++ b/Wino.Mail.WinUI/Views/Abstract/MailCategoryManagementPageAbstract.cs @@ -0,0 +1,6 @@ +using Wino.Mail.ViewModels; +using Wino.Mail.WinUI; + +namespace Wino.Views.Abstract; + +public abstract class MailCategoryManagementPageAbstract : BasePage { } diff --git a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml index b0572554..5c460e21 100644 --- a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml +++ b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml @@ -228,6 +228,16 @@ + + + + + + diff --git a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml index 16283c00..a506237f 100644 --- a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml +++ b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml @@ -253,7 +253,8 @@ BorderThickness="0" Command="{x:Bind ViewModel.SyncFolderCommand}" IsEnabled="{x:Bind ViewModel.CanSynchronize, Mode=OneWay}" - ToolTipService.ToolTip="{x:Bind domain:Translator.Buttons_Sync}"> + ToolTipService.ToolTip="{x:Bind domain:Translator.Buttons_Sync}" + Visibility="{x:Bind ViewModel.IsSyncButtonVisible, Mode=OneWay}"> a.MailCopy)); + if (clickedAction.Category != null) + { + await ViewModel.ToggleCategoryAssignmentAsync(clickedAction.Category, targetItems, clickedAction.IsCategoryAssignedToAll); + return; + } + + var prepRequest = new MailOperationPreperationRequest(clickedAction.Operation.Operation, targetItems.Select(a => a.MailCopy)); await ViewModel.ExecuteMailOperationAsync(prepRequest); } @@ -296,14 +309,68 @@ public sealed partial class MailListPage : MailListPageAbstract, }); } - private async Task GetMailOperationFromFlyoutAsync(IEnumerable availableActions, - UIElement showAtElement, - double x, - double y) + private async Task GetMailContextActionFromFlyoutAsync( + IEnumerable availableActions, + IReadOnlyList availableCategories, + IReadOnlyCollection assignedCategoryIds, + UIElement showAtElement, + double x, + double y) { - var source = new TaskCompletionSource(); + var source = new TaskCompletionSource(); + var flyout = new MenuFlyout(); - var flyout = new MailOperationFlyout(availableActions, source); + foreach (var action in availableActions) + { + if (action.Operation == MailOperation.Seperator) + { + flyout.Items.Add(new MenuFlyoutSeparator()); + continue; + } + + var menuFlyoutItem = new MailOperationMenuFlyoutItem(action, clicked => + { + source.TrySetResult(new MailContextAction(clicked)); + flyout.Hide(); + }); + + flyout.Items.Add(menuFlyoutItem); + } + + if (availableCategories?.Count > 0) + { + if (flyout.Items.LastOrDefault() is not MenuFlyoutSeparator) + { + flyout.Items.Add(new MenuFlyoutSeparator()); + } + + var categorySubItem = new MenuFlyoutSubItem + { + Text = Translator.MailCategoryMenuItem + }; + + foreach (var category in availableCategories) + { + var wasAssignedToAll = assignedCategoryIds.Contains(category.Id); + var categoryItem = new ToggleMenuFlyoutItem + { + Text = category.Name, + IsChecked = wasAssignedToAll + }; + + categoryItem.Click += (_, _) => + { + source.TrySetResult(new MailContextAction(category, wasAssignedToAll)); + flyout.Hide(); + }; + + categorySubItem.Items.Add(categoryItem); + } + + flyout.Items.Add(categorySubItem); + } + + flyout.Closing += (_, _) => source.TrySetResult(null); flyout.ShowAt(showAtElement, new FlyoutShowOptions() { @@ -314,6 +381,13 @@ public sealed partial class MailListPage : MailListPageAbstract, return await source.Task; } + private sealed record MailContextAction(MailOperationMenuItem Operation, MailCategory Category = null, bool IsCategoryAssignedToAll = false) + { + public MailContextAction(MailCategory category, bool isCategoryAssignedToAll) : this(null, category, isCategoryAssignedToAll) + { + } + } + async void IRecipient.Receive(ClearMailSelectionsRequested message) { await ViewModel.MailCollection.UnselectAllAsync(); diff --git a/Wino.Mail.WinUI/Views/Settings/MailCategoryManagementPage.xaml b/Wino.Mail.WinUI/Views/Settings/MailCategoryManagementPage.xaml new file mode 100644 index 00000000..375a9e0c --- /dev/null +++ b/Wino.Mail.WinUI/Views/Settings/MailCategoryManagementPage.xaml @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Wino.Mail.WinUI/Views/Settings/MailCategoryManagementPage.xaml.cs b/Wino.Mail.WinUI/Views/Settings/MailCategoryManagementPage.xaml.cs new file mode 100644 index 00000000..4bc6fb5b --- /dev/null +++ b/Wino.Mail.WinUI/Views/Settings/MailCategoryManagementPage.xaml.cs @@ -0,0 +1,47 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Wino.Core.Domain.Entities.Mail; +using Wino.Views.Abstract; + +namespace Wino.Views.Settings; + +public sealed partial class MailCategoryManagementPage : MailCategoryManagementPageAbstract +{ + public MailCategoryManagementPage() + { + InitializeComponent(); + } + + private async void FavoriteCategoryChecked(object sender, RoutedEventArgs e) + { + if (sender is ToggleButton toggleButton && toggleButton.Tag is MailCategory category) + { + await ViewModel.SetFavoriteAsync(category, true); + } + } + + private async void FavoriteCategoryUnchecked(object sender, RoutedEventArgs e) + { + if (sender is ToggleButton toggleButton && toggleButton.Tag is MailCategory category) + { + await ViewModel.SetFavoriteAsync(category, false); + } + } + + private async void EditCategoryClicked(object sender, RoutedEventArgs e) + { + if (sender is Button button && button.Tag is MailCategory category) + { + await ViewModel.EditCategoryAsync(category); + } + } + + private async void DeleteCategoryClicked(object sender, RoutedEventArgs e) + { + if (sender is Button button && button.Tag is MailCategory category) + { + await ViewModel.DeleteCategoryAsync(category); + } + } +} diff --git a/Wino.Mail.WinUI/Views/WinoAppShell.xaml b/Wino.Mail.WinUI/Views/WinoAppShell.xaml index a6c759be..d20c551c 100644 --- a/Wino.Mail.WinUI/Views/WinoAppShell.xaml +++ b/Wino.Mail.WinUI/Views/WinoAppShell.xaml @@ -240,6 +240,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + (), + Connection.CreateTableAsync(), + Connection.CreateTableAsync(), Connection.CreateTableAsync(), Connection.CreateTableAsync(), Connection.CreateTableAsync(), @@ -226,6 +228,12 @@ SET {nameof(KeyboardShortcut.Action)} = await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCopy_MessageId ON MailCopy(MessageId)").ConfigureAwait(false); await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCopy_FolderId_IsRead ON MailCopy(FolderId, IsRead)").ConfigureAwait(false); await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCopy_CreationDate ON MailCopy(CreationDate)").ConfigureAwait(false); + await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCategory_MailAccountId ON MailCategory(MailAccountId)").ConfigureAwait(false); + await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCategory_MailAccountId_Name ON MailCategory(MailAccountId, Name)").ConfigureAwait(false); + await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCategory_MailAccountId_IsFavorite ON MailCategory(MailAccountId, IsFavorite)").ConfigureAwait(false); + await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCategoryAssignment_MailCategoryId ON MailCategoryAssignment(MailCategoryId)").ConfigureAwait(false); + await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailCategoryAssignment_MailCopyUniqueId ON MailCategoryAssignment(MailCopyUniqueId)").ConfigureAwait(false); + await Connection.ExecuteAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MailCategoryAssignment_Category_MailCopy ON MailCategoryAssignment(MailCategoryId, MailCopyUniqueId)").ConfigureAwait(false); await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailItemFolder_MailAccountId ON MailItemFolder(MailAccountId)").ConfigureAwait(false); await Connection.ExecuteAsync("CREATE INDEX IF NOT EXISTS IX_MailItemFolder_MailAccountId_RemoteFolderId ON MailItemFolder(MailAccountId, RemoteFolderId)").ConfigureAwait(false); diff --git a/Wino.Services/FolderService.cs b/Wino.Services/FolderService.cs index 2728377e..dd90facf 100644 --- a/Wino.Services/FolderService.cs +++ b/Wino.Services/FolderService.cs @@ -22,6 +22,7 @@ namespace Wino.Services; public class FolderService : BaseDatabaseService, IFolderService { private readonly IAccountService _accountService; + private readonly IMailCategoryService _mailCategoryService; private readonly ILogger _logger = Log.ForContext(); private readonly SpecialFolderType[] gmailCategoryFolderTypes = @@ -34,9 +35,11 @@ public class FolderService : BaseDatabaseService, IFolderService ]; public FolderService(IDatabaseService databaseService, - IAccountService accountService) : base(databaseService) + IAccountService accountService, + IMailCategoryService mailCategoryService) : base(databaseService) { _accountService = accountService; + _mailCategoryService = mailCategoryService; } public async Task ChangeStickyStatusAsync(Guid folderId, bool isSticky) @@ -269,6 +272,9 @@ public class FolderService : BaseDatabaseService, IFolderService } } + var favoriteCategories = await GetFavoriteCategoryMenuItemsAsync(mailAccount, folders, accountMenuItem).ConfigureAwait(false); + preparedFolderMenuItems.AddRange(favoriteCategories); + // Only add category folder if it's Gmail. if (mailAccount.ProviderType == MailProviderType.Gmail) preparedFolderMenuItems.Add(categoryFolderMenuItem); @@ -309,9 +315,62 @@ public class FolderService : BaseDatabaseService, IFolderService preparedFolderMenuItems.Add(menuItem); } + var favoriteCategories = await GetMergedFavoriteCategoryMenuItemsAsync(holdingAccounts, allAccountFolders, mergedAccountFolderMenuItem.Parameter).ConfigureAwait(false); + preparedFolderMenuItems.AddRange(favoriteCategories); + return preparedFolderMenuItems; } + private async Task> GetFavoriteCategoryMenuItemsAsync(MailAccount account, IEnumerable handlingFolders, IMenuItem parentMenuItem) + { + var favoriteCategories = await _mailCategoryService.GetFavoriteCategoriesAsync(account.Id).ConfigureAwait(false); + + if (!favoriteCategories.Any()) + return []; + + var availableFolders = handlingFolders + .Where(a => a.IsMoveTarget) + .Cast() + .ToList(); + + return favoriteCategories + .Select(category => (IMenuItem)new MailCategoryMenuItem(category, account, availableFolders, parentMenuItem)) + .ToList(); + } + + private async Task> GetMergedFavoriteCategoryMenuItemsAsync(IEnumerable holdingAccounts, IEnumerable> allAccountFolders, MergedInbox mergedInbox) + { + var categoriesByAccount = new List<(MailAccount Account, List Categories)>(); + + foreach (var account in holdingAccounts) + { + var categories = await _mailCategoryService.GetFavoriteCategoriesAsync(account.Id).ConfigureAwait(false); + if (categories.Any()) + { + categoriesByAccount.Add((account, categories)); + } + } + + if (!categoriesByAccount.Any()) + return []; + + var handlingFolders = allAccountFolders + .SelectMany(a => a) + .Where(a => a.IsMoveTarget) + .Cast() + .ToList(); + + return categoriesByAccount + .SelectMany(a => a.Categories) + .GroupBy(a => NormalizeCategoryName(a.Name), StringComparer.OrdinalIgnoreCase) + .Select(group => (IMenuItem)new MergedMailCategoryMenuItem(group.ToList(), handlingFolders, mergedInbox)) + .OrderBy(item => ((MergedMailCategoryMenuItem)item).FolderName, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + } + + private static string NormalizeCategoryName(string name) + => name?.Trim() ?? string.Empty; + private HashSet FindCommonFolders(List> lists) { var allSpecialTypesExceptOther = Enum.GetValues().Cast().Where(a => a != SpecialFolderType.Other).ToList(); diff --git a/Wino.Services/MailCategoryService.cs b/Wino.Services/MailCategoryService.cs new file mode 100644 index 00000000..0d6ad97b --- /dev/null +++ b/Wino.Services/MailCategoryService.cs @@ -0,0 +1,358 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Messaging; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; +using Wino.Messaging.Client.Accounts; +using Wino.Messaging.UI; + +namespace Wino.Services; + +public class MailCategoryService : BaseDatabaseService, IMailCategoryService +{ + public MailCategoryService(IDatabaseService databaseService) : base(databaseService) + { + } + + public Task> GetCategoriesAsync(Guid accountId) + => Connection.QueryAsync( + $"SELECT * FROM {nameof(MailCategory)} WHERE {nameof(MailCategory.MailAccountId)} = ? ORDER BY {nameof(MailCategory.IsFavorite)} DESC, {nameof(MailCategory.Name)} COLLATE NOCASE", + accountId); + + public Task> GetFavoriteCategoriesAsync(Guid accountId) + => Connection.QueryAsync( + $"SELECT * FROM {nameof(MailCategory)} WHERE {nameof(MailCategory.MailAccountId)} = ? AND {nameof(MailCategory.IsFavorite)} = 1 ORDER BY {nameof(MailCategory.Name)} COLLATE NOCASE", + accountId); + + public Task GetCategoryAsync(Guid categoryId) + => Connection.FindAsync(categoryId); + + public async Task CategoryNameExistsAsync(Guid accountId, string name, Guid? excludedCategoryId = null) + { + var normalizedName = NormalizeCategoryName(name); + if (string.IsNullOrWhiteSpace(normalizedName)) + return false; + + var sql = $"SELECT COUNT(*) FROM {nameof(MailCategory)} WHERE {nameof(MailCategory.MailAccountId)} = ? AND lower(trim({nameof(MailCategory.Name)})) = ?"; + var parameters = new List { accountId, normalizedName.ToLowerInvariant() }; + + if (excludedCategoryId.HasValue) + { + sql += $" AND {nameof(MailCategory.Id)} <> ?"; + parameters.Add(excludedCategoryId.Value); + } + + return await Connection.ExecuteScalarAsync(sql, parameters.ToArray()).ConfigureAwait(false) > 0; + } + + public async Task CreateCategoryAsync(MailCategory category) + { + category.Id = category.Id == Guid.Empty ? Guid.NewGuid() : category.Id; + category.Name = NormalizeCategoryName(category.Name); + + await Connection.InsertAsync(category, typeof(MailCategory)).ConfigureAwait(false); + NotifyCategoryStructureChanged(category.MailAccountId); + + return category; + } + + public async Task UpdateCategoryAsync(MailCategory category) + { + category.Name = NormalizeCategoryName(category.Name); + + await Connection.UpdateAsync(category, typeof(MailCategory)).ConfigureAwait(false); + NotifyCategoryStructureChanged(category.MailAccountId); + } + + public async Task DeleteCategoryAsync(Guid categoryId) + { + var category = await GetCategoryAsync(categoryId).ConfigureAwait(false); + if (category == null) + return; + + await Connection.ExecuteAsync($"DELETE FROM {nameof(MailCategoryAssignment)} WHERE {nameof(MailCategoryAssignment.MailCategoryId)} = ?", categoryId).ConfigureAwait(false); + await Connection.DeleteAsync(categoryId).ConfigureAwait(false); + + NotifyCategoryStructureChanged(category.MailAccountId); + } + + public async Task DeleteCategoriesAsync(Guid accountId) + { + var categories = await GetCategoriesAsync(accountId).ConfigureAwait(false); + + if (categories.Count == 0) + return; + + var categoryIds = categories.Select(a => a.Id).ToList(); + var placeholders = string.Join(",", categoryIds.Select(_ => "?")); + var deleteAssignmentsSql = $"DELETE FROM {nameof(MailCategoryAssignment)} WHERE {nameof(MailCategoryAssignment.MailCategoryId)} IN ({placeholders})"; + + await Connection.ExecuteAsync(deleteAssignmentsSql, categoryIds.Cast().ToArray()).ConfigureAwait(false); + await Connection.Table().DeleteAsync(a => a.MailAccountId == accountId).ConfigureAwait(false); + + NotifyCategoryStructureChanged(accountId); + } + + public async Task ToggleFavoriteAsync(Guid categoryId, bool isFavorite) + { + var category = await GetCategoryAsync(categoryId).ConfigureAwait(false); + if (category == null || category.IsFavorite == isFavorite) + return; + + category.IsFavorite = isFavorite; + await Connection.UpdateAsync(category, typeof(MailCategory)).ConfigureAwait(false); + + NotifyCategoryStructureChanged(category.MailAccountId); + } + + public async Task UpdateRemoteIdAsync(Guid categoryId, string remoteId) + { + var category = await GetCategoryAsync(categoryId).ConfigureAwait(false); + if (category == null) + return; + + category.RemoteId = remoteId; + await Connection.UpdateAsync(category, typeof(MailCategory)).ConfigureAwait(false); + } + + public async Task ReplaceCategoriesAsync(Guid accountId, IEnumerable categories) + { + var existingCategories = await GetCategoriesAsync(accountId).ConfigureAwait(false); + var existingByRemoteId = existingCategories + .Where(a => !string.IsNullOrWhiteSpace(a.RemoteId)) + .ToDictionary(a => a.RemoteId, StringComparer.OrdinalIgnoreCase); + var existingByName = existingCategories + .GroupBy(a => NormalizeCategoryName(a.Name), StringComparer.OrdinalIgnoreCase) + .ToDictionary(a => a.Key, a => a.First(), StringComparer.OrdinalIgnoreCase); + + var incomingCategories = categories?.ToList() ?? []; + var preservedIds = new HashSet(); + + foreach (var incoming in incomingCategories) + { + incoming.MailAccountId = accountId; + incoming.Id = incoming.Id == Guid.Empty ? Guid.NewGuid() : incoming.Id; + incoming.Name = NormalizeCategoryName(incoming.Name); + + MailCategory existing = null; + + if (!string.IsNullOrWhiteSpace(incoming.RemoteId) && existingByRemoteId.TryGetValue(incoming.RemoteId, out var byRemote)) + { + existing = byRemote; + } + else if (existingByName.TryGetValue(incoming.Name, out var byName)) + { + existing = byName; + } + + if (existing == null) + { + await Connection.InsertAsync(incoming, typeof(MailCategory)).ConfigureAwait(false); + preservedIds.Add(incoming.Id); + } + else + { + incoming.Id = existing.Id; + incoming.IsFavorite = existing.IsFavorite; + await Connection.UpdateAsync(incoming, typeof(MailCategory)).ConfigureAwait(false); + preservedIds.Add(existing.Id); + } + } + + var categoryIdsToDelete = existingCategories + .Where(a => !preservedIds.Contains(a.Id)) + .Select(a => a.Id) + .ToList(); + + if (categoryIdsToDelete.Count > 0) + { + var placeholders = string.Join(",", categoryIdsToDelete.Select(_ => "?")); + await Connection.ExecuteAsync( + $"DELETE FROM {nameof(MailCategoryAssignment)} WHERE {nameof(MailCategoryAssignment.MailCategoryId)} IN ({placeholders})", + categoryIdsToDelete.Cast().ToArray()).ConfigureAwait(false); + + foreach (var categoryId in categoryIdsToDelete) + { + await Connection.DeleteAsync(categoryId).ConfigureAwait(false); + } + } + + NotifyCategoryStructureChanged(accountId); + } + + public async Task ReplaceMailAssignmentsAsync(Guid accountId, Guid mailCopyUniqueId, IEnumerable categoryNames) + { + var normalizedNames = categoryNames? + .Select(NormalizeCategoryName) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList() ?? []; + + var availableCategories = await GetCategoriesAsync(accountId).ConfigureAwait(false); + var categoryIds = availableCategories + .Where(a => normalizedNames.Contains(NormalizeCategoryName(a.Name), StringComparer.OrdinalIgnoreCase)) + .Select(a => a.Id) + .ToHashSet(); + + var existingAssignments = await Connection.QueryAsync( + $"SELECT * FROM {nameof(MailCategoryAssignment)} WHERE {nameof(MailCategoryAssignment.MailCopyUniqueId)} = ?", + mailCopyUniqueId).ConfigureAwait(false); + + var assignmentsToDelete = existingAssignments.Where(a => !categoryIds.Contains(a.MailCategoryId)).ToList(); + var existingIds = existingAssignments.Select(a => a.MailCategoryId).ToHashSet(); + var assignmentsToAdd = categoryIds.Where(a => !existingIds.Contains(a)).ToList(); + + foreach (var assignment in assignmentsToDelete) + { + await Connection.DeleteAsync(assignment.Id).ConfigureAwait(false); + } + + foreach (var categoryId in assignmentsToAdd) + { + await Connection.InsertAsync(new MailCategoryAssignment + { + Id = Guid.NewGuid(), + MailCategoryId = categoryId, + MailCopyUniqueId = mailCopyUniqueId + }, typeof(MailCategoryAssignment)).ConfigureAwait(false); + } + + WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(accountId)); + } + + public async Task AssignCategoryAsync(Guid categoryId, IEnumerable mailCopyUniqueIds) + { + var uniqueIds = mailCopyUniqueIds?.Distinct().ToList() ?? []; + if (uniqueIds.Count == 0) + return; + + var category = await GetCategoryAsync(categoryId).ConfigureAwait(false); + if (category == null) + return; + + var placeholders = string.Join(",", uniqueIds.Select(_ => "?")); + var query = $"SELECT * FROM {nameof(MailCategoryAssignment)} WHERE {nameof(MailCategoryAssignment.MailCategoryId)} = ? AND {nameof(MailCategoryAssignment.MailCopyUniqueId)} IN ({placeholders})"; + var existingAssignments = await Connection.QueryAsync( + query, + [categoryId, .. uniqueIds.Cast()]).ConfigureAwait(false); + var existingUniqueIds = existingAssignments.Select(a => a.MailCopyUniqueId).ToHashSet(); + + foreach (var uniqueId in uniqueIds.Where(a => !existingUniqueIds.Contains(a))) + { + await Connection.InsertAsync(new MailCategoryAssignment + { + Id = Guid.NewGuid(), + MailCategoryId = categoryId, + MailCopyUniqueId = uniqueId + }, typeof(MailCategoryAssignment)).ConfigureAwait(false); + } + + WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(category.MailAccountId)); + } + + public async Task UnassignCategoryAsync(Guid categoryId, IEnumerable mailCopyUniqueIds) + { + var uniqueIds = mailCopyUniqueIds?.Distinct().ToList() ?? []; + if (uniqueIds.Count == 0) + return; + + var category = await GetCategoryAsync(categoryId).ConfigureAwait(false); + if (category == null) + return; + + var placeholders = string.Join(",", uniqueIds.Select(_ => "?")); + await Connection.ExecuteAsync( + $"DELETE FROM {nameof(MailCategoryAssignment)} WHERE {nameof(MailCategoryAssignment.MailCategoryId)} = ? AND {nameof(MailCategoryAssignment.MailCopyUniqueId)} IN ({placeholders})", + [categoryId, .. uniqueIds.Cast()]).ConfigureAwait(false); + + WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(category.MailAccountId)); + } + + public async Task> GetCategoriesForMailAsync(Guid accountId, IEnumerable mailCopyUniqueIds) + { + var uniqueIds = mailCopyUniqueIds?.Distinct().ToList() ?? []; + if (uniqueIds.Count == 0) + return []; + + var placeholders = string.Join(",", uniqueIds.Select(_ => "?")); + var sql = $"SELECT DISTINCT MailCategory.* FROM {nameof(MailCategory)} " + + $"INNER JOIN {nameof(MailCategoryAssignment)} ON {nameof(MailCategory)}.{nameof(MailCategory.Id)} = {nameof(MailCategoryAssignment)}.{nameof(MailCategoryAssignment.MailCategoryId)} " + + $"WHERE {nameof(MailCategory)}.{nameof(MailCategory.MailAccountId)} = ? AND {nameof(MailCategoryAssignment)}.{nameof(MailCategoryAssignment.MailCopyUniqueId)} IN ({placeholders}) " + + $"ORDER BY {nameof(MailCategory.Name)} COLLATE NOCASE"; + + return await Connection.QueryAsync( + sql, + [accountId, .. uniqueIds.Cast()]).ConfigureAwait(false); + } + + public async Task> GetAssignedCategoryIdsForAllAsync(IEnumerable mailCopyUniqueIds) + { + var uniqueIds = mailCopyUniqueIds?.Distinct().ToList() ?? []; + if (uniqueIds.Count == 0) + return []; + + var placeholders = string.Join(",", uniqueIds.Select(_ => "?")); + var sql = $"SELECT {nameof(MailCategoryAssignment.MailCategoryId)} " + + $"FROM {nameof(MailCategoryAssignment)} " + + $"WHERE {nameof(MailCategoryAssignment.MailCopyUniqueId)} IN ({placeholders}) " + + $"GROUP BY {nameof(MailCategoryAssignment.MailCategoryId)} " + + $"HAVING COUNT(DISTINCT {nameof(MailCategoryAssignment.MailCopyUniqueId)}) = ?"; + + return await Connection.QueryScalarsAsync( + sql, + [.. uniqueIds.Cast(), uniqueIds.Count]).ConfigureAwait(false); + } + + public async Task> GetCategoryNamesForMailAsync(Guid mailCopyUniqueId) + { + var sql = $"SELECT {nameof(MailCategory.Name)} " + + $"FROM {nameof(MailCategory)} " + + $"INNER JOIN {nameof(MailCategoryAssignment)} ON {nameof(MailCategory)}.{nameof(MailCategory.Id)} = {nameof(MailCategoryAssignment.MailCategoryId)} " + + $"WHERE {nameof(MailCategoryAssignment.MailCopyUniqueId)} = ? " + + $"ORDER BY {nameof(MailCategory.Name)} COLLATE NOCASE"; + + return await Connection.QueryScalarsAsync(sql, mailCopyUniqueId).ConfigureAwait(false); + } + + public async Task> GetMailCopiesForCategoryAsync(Guid categoryId) + { + var sql = $"SELECT {nameof(MailCopy)}.* " + + $"FROM {nameof(MailCopy)} " + + $"INNER JOIN {nameof(MailCategoryAssignment)} ON {nameof(MailCopy)}.{nameof(MailCopy.UniqueId)} = {nameof(MailCategoryAssignment.MailCopyUniqueId)} " + + $"WHERE {nameof(MailCategoryAssignment.MailCategoryId)} = ?"; + + return await Connection.QueryAsync(sql, categoryId).ConfigureAwait(false); + } + + public Task> GetUnreadCategoryCountResultsAsync(IEnumerable accountIds) + { + var accountIdList = accountIds?.Distinct().ToList() ?? []; + if (accountIdList.Count == 0) + return Task.FromResult(new List()); + + var placeholders = string.Join(",", accountIdList.Select(_ => "?")); + var sql = + $"SELECT MailCategory.{nameof(MailCategory.Id)} as {nameof(UnreadCategoryCountResult.CategoryId)}, " + + $"MailCategory.{nameof(MailCategory.MailAccountId)} as {nameof(UnreadCategoryCountResult.AccountId)}, " + + $"COUNT(DISTINCT MailCopy.{nameof(MailCopy.UniqueId)}) as {nameof(UnreadCategoryCountResult.UnreadItemCount)} " + + $"FROM {nameof(MailCategory)} " + + $"INNER JOIN {nameof(MailCategoryAssignment)} ON {nameof(MailCategory)}.{nameof(MailCategory.Id)} = {nameof(MailCategoryAssignment)}.{nameof(MailCategoryAssignment.MailCategoryId)} " + + $"INNER JOIN {nameof(MailCopy)} ON {nameof(MailCategoryAssignment)}.{nameof(MailCategoryAssignment.MailCopyUniqueId)} = {nameof(MailCopy)}.{nameof(MailCopy.UniqueId)} " + + $"WHERE MailCategory.{nameof(MailCategory.MailAccountId)} IN ({placeholders}) AND MailCopy.{nameof(MailCopy.IsRead)} = 0 " + + $"GROUP BY MailCategory.{nameof(MailCategory.Id)}"; + + return Connection.QueryAsync(sql, accountIdList.Cast().ToArray()); + } + + private void NotifyCategoryStructureChanged(Guid accountId) + { + WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested(false)); + WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(accountId)); + } + + private static string NormalizeCategoryName(string name) + => name?.Trim() ?? string.Empty; +} diff --git a/Wino.Services/MailService.cs b/Wino.Services/MailService.cs index c8772ad9..d3a33897 100644 --- a/Wino.Services/MailService.cs +++ b/Wino.Services/MailService.cs @@ -32,6 +32,7 @@ public class MailService : BaseDatabaseService, IMailService private readonly IMimeFileService _mimeFileService; private readonly IPreferencesService _preferencesService; private readonly ISentMailReceiptService _sentMailReceiptService; + private readonly IMailCategoryService _mailCategoryService; private readonly ILogger _logger = Log.ForContext(); @@ -42,7 +43,8 @@ public class MailService : BaseDatabaseService, IMailService ISignatureService signatureService, IMimeFileService mimeFileService, IPreferencesService preferencesService, - ISentMailReceiptService sentMailReceiptService) : base(databaseService) + ISentMailReceiptService sentMailReceiptService, + IMailCategoryService mailCategoryService) : base(databaseService) { _folderService = folderService; _contactService = contactService; @@ -51,6 +53,7 @@ public class MailService : BaseDatabaseService, IMailService _mimeFileService = mimeFileService; _preferencesService = preferencesService; _sentMailReceiptService = sentMailReceiptService; + _mailCategoryService = mailCategoryService; } public async Task<(MailCopy draftMailCopy, string draftBase64MimeMessage)> CreateDraftAsync(Guid accountId, DraftCreationOptions draftCreationOptions) @@ -171,7 +174,9 @@ public class MailService : BaseDatabaseService, IMailService private static (string Query, object[] Parameters) BuildMailFetchQuery(MailListInitializationOptions options) { var sql = new StringBuilder(); - sql.Append("SELECT MailCopy.* FROM MailCopy INNER JOIN MailItemFolder ON MailCopy.FolderId = MailItemFolder.Id"); + sql.Append(options.IsCategoryView + ? "SELECT DISTINCT MailCopy.* FROM MailCopy INNER JOIN MailItemFolder ON MailCopy.FolderId = MailItemFolder.Id INNER JOIN MailCategoryAssignment ON MailCopy.UniqueId = MailCategoryAssignment.MailCopyUniqueId" + : "SELECT MailCopy.* FROM MailCopy INNER JOIN MailItemFolder ON MailCopy.FolderId = MailItemFolder.Id"); var whereClauses = new List(); var parameters = new List(); @@ -181,6 +186,13 @@ public class MailService : BaseDatabaseService, IMailService whereClauses.Add($"MailCopy.FolderId IN ({folderPlaceholders})"); parameters.AddRange(options.Folders.Select(f => (object)f.Id)); + if (options.IsCategoryView) + { + var categoryPlaceholders = string.Join(",", options.CategoryIds.Select(_ => "?")); + whereClauses.Add($"MailCategoryAssignment.MailCategoryId IN ({categoryPlaceholders})"); + parameters.AddRange(options.CategoryIds.Select(a => (object)a)); + } + // Filter type switch (options.FilterType) { @@ -338,7 +350,7 @@ public class MailService : BaseDatabaseService, IMailService { List mails; - if (options.PreFetchMailCopies != null) + if (options.PreFetchMailCopies != null && !options.IsCategoryView) { mails = ApplyOptionsToPreFetchedMails(options); } @@ -398,7 +410,7 @@ public class MailService : BaseDatabaseService, IMailService mails.RemoveAll(m => m.AssignedAccount == null || m.AssignedFolder == null); await _sentMailReceiptService.PopulateReceiptStatesAsync(mails).ConfigureAwait(false); - if (!options.CreateThreads || mails.Count == 0) + if (!options.CreateThreads || mails.Count == 0 || options.IsCategoryView) return [.. mails]; // 6. Expand threads: one batch query for all sibling mails across all threads. @@ -727,6 +739,7 @@ public class MailService : BaseDatabaseService, IMailService _logger.Debug("Deleting mail {Id} from folder {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName); await Connection.DeleteAsync(mailCopy.UniqueId).ConfigureAwait(false); + await Connection.ExecuteAsync("DELETE FROM MailCategoryAssignment WHERE MailCopyUniqueId = ?", mailCopy.UniqueId).ConfigureAwait(false); // If there are no more copies exists of the same mail, delete the MIME file as well. var isMailExists = await IsMailExistsAsync(mailCopy.Id).ConfigureAwait(false); @@ -965,6 +978,7 @@ public class MailService : BaseDatabaseService, IMailService mailCopy.UniqueId = existingCopyItem.UniqueId; await UpdateMailAsync(mailCopy).ConfigureAwait(false); + await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false); await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false); await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false); @@ -981,6 +995,7 @@ public class MailService : BaseDatabaseService, IMailService } await InsertMailAsync(mailCopy).ConfigureAwait(false); + await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false); await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false); await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false); @@ -1017,6 +1032,11 @@ public class MailService : BaseDatabaseService, IMailService await _contactService.SaveAddressInformationAsync(contacts).ConfigureAwait(false); } + private Task ReplaceMailCategoriesForPackageAsync(Guid accountId, MailCopy mailCopy, NewMailItemPackage package) + => package?.CategoryNames == null + ? Task.CompletedTask + : _mailCategoryService.ReplaceMailAssignmentsAsync(accountId, mailCopy.UniqueId, package.CategoryNames); + private async Task CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions, MailAccountAlias selectedAlias) { // This unique id is stored in mime headers for Wino to identify remote message with local copy. diff --git a/Wino.Services/ServicesContainerSetup.cs b/Wino.Services/ServicesContainerSetup.cs index 063a7409..b999b42d 100644 --- a/Wino.Services/ServicesContainerSetup.cs +++ b/Wino.Services/ServicesContainerSetup.cs @@ -20,6 +20,7 @@ public static class ServicesContainerSetup services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient();