Folder operations, Gmail folder sync improvements and rework of menu items. (#273)

* New rename folder dialog keys.

* Insfra work for folder operations and rename folder code.

* RenameFolder for Gmail.

* Fixed input dialog to take custom take for primary button.

* Missing rename for DS call.

* Outlook to throw exception in case of error.

* Implemented rename folder functionality for Outlook.

* Remove default primary text from input dialog.

* Fixed an issue where outlook folder rename does not work.

* Disable vertical scroll for composing page editor items.

* Fixing some issues with imap folder sync.

* fix copy pasta

* TODO folder update/removed overrides for shell.

* New rename folder dialog keys.

* Insfra work for folder operations and rename folder code.

* RenameFolder for Gmail.

* Fixed input dialog to take custom take for primary button.

* Missing rename for DS call.

* Outlook to throw exception in case of error.

* Implemented rename folder functionality for Outlook.

* Remove default primary text from input dialog.

* Fixed an issue where outlook folder rename does not work.

* Disable vertical scroll for composing page editor items.

* Fixing some issues with imap folder sync.

* fix copy pasta

* TODO folder update/removed overrides for shell.

* New rename folder dialog keys.

* Insfra work for folder operations and rename folder code.

* RenameFolder for Gmail.

* Fixed input dialog to take custom take for primary button.

* Missing rename for DS call.

* Outlook to throw exception in case of error.

* Implemented rename folder functionality for Outlook.

* Remove default primary text from input dialog.

* Fixed an issue where outlook folder rename does not work.

* Disable vertical scroll for composing page editor items.

* Fixing some issues with imap folder sync.

* fix copy pasta

* TODO folder update/removed overrides for shell.

* New rename folder dialog keys.

* Fixed an issue where redundant older updates causing pivots to be re-created.

* New empty folder request

* New rename folder dialog keys.

* Insfra work for folder operations and rename folder code.

* RenameFolder for Gmail.

* Fixed input dialog to take custom take for primary button.

* Missing rename for DS call.

* Outlook to throw exception in case of error.

* Implemented rename folder functionality for Outlook.

* Remove default primary text from input dialog.

* Fixed an issue where outlook folder rename does not work.

* Fixing some issues with imap folder sync.

* fix copy pasta

* TODO folder update/removed overrides for shell.

* New rename folder dialog keys.

* New rename folder dialog keys.

* New rename folder dialog keys.

* Fixed an issue where redundant older updates causing pivots to be re-created.

* New empty folder request

* Enable empty folder on base sync.

* Move updates on event listeners.

* Remove folder UI messages.

* Reworked folder synchronization for gmail.

* Loading folders on the fly as the selected account changed instead of relying on cached menu items.

* Merged account folder items, re-navigating to existing rendering page.

* - Reworked merged account menu system.
- Reworked unread item count loadings.
- Fixed back button visibility.
- Instant rendering of mails if renderer is active.
- Animation fixes.
- Menu item re-load crash/hang fixes.

* Handle folder renaming on the UI.

* Empty folder for all synchronizers.

* New execution delay mechanism and handling folder mark as read for all synchronizers.

* Revert UI changes on failure for IMAP.

* Remove duplicate translation keys.

* Cleanup.
This commit is contained in:
Burak Kaan Köse
2024-07-09 01:05:16 +02:00
committed by GitHub
parent ac01006398
commit 536fbb23a1
67 changed files with 1396 additions and 1585 deletions

View File

@@ -1,5 +1,4 @@
using System.Linq;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.MenuItems;
@@ -7,31 +6,9 @@ namespace Wino.Core.Extensions
{
public static class FolderTreeExtensions
{
public static AccountMenuItem GetAccountMenuTree(this AccountFolderTree accountTree, IMenuItem parentMenuItem = null)
{
var accountMenuItem = new AccountMenuItem(accountTree.Account, parentMenuItem);
foreach (var structure in accountTree.Folders)
{
var tree = GetMenuItemByFolderRecursive(structure, accountMenuItem, null);
accountMenuItem.SubMenuItems.Add(tree);
}
// Create flat folder hierarchy for ease of access.
accountMenuItem.FlattenedFolderHierarchy = ListExtensions
.FlattenBy(accountMenuItem.SubMenuItems, a => a.SubMenuItems)
.Where(a => a is FolderMenuItem)
.Cast<FolderMenuItem>()
.ToList();
return accountMenuItem;
}
private static MenuItemBase<IMailItemFolder, FolderMenuItem> GetMenuItemByFolderRecursive(IMailItemFolder structure, AccountMenuItem parentAccountMenuItem, IMenuItem parentFolderItem)
{
MenuItemBase<IMailItemFolder, FolderMenuItem> parentMenuItem = new FolderMenuItem(structure, parentAccountMenuItem.Parameter, parentAccountMenuItem);
MenuItemBase<IMailItemFolder, FolderMenuItem> parentMenuItem = new FolderMenuItem(structure, parentAccountMenuItem.Parameter, parentFolderItem);
var childStructures = structure.ChildFolders;

View File

@@ -18,57 +18,114 @@ namespace Wino.Core.Extensions
public const string STARRED_LABEL_ID = "STARRED";
public const string DRAFT_LABEL_ID = "DRAFT";
public const string SENT_LABEL_ID = "SENT";
public const string SPAM_LABEL_ID = "SPAM";
public const string CHAT_LABEL_ID = "CHAT";
public const string TRASH_LABEL_ID = "TRASH";
// Category labels.
public const string FORUMS_LABEL_ID = "FORUMS";
public const string UPDATES_LABEL_ID = "UPDATES";
public const string PROMOTIONS_LABEL_ID = "PROMOTIONS";
public const string SOCIAL_LABEL_ID = "SOCIAL";
public const string PERSONAL_LABEL_ID = "PERSONAL";
// Label visibility identifiers.
private const string SYSTEM_FOLDER_IDENTIFIER = "system";
private const string FOLDER_HIDE_IDENTIFIER = "labelHide";
private static Dictionary<string, SpecialFolderType> KnownFolderDictioanry = new Dictionary<string, SpecialFolderType>()
private const string CATEGORY_PREFIX = "CATEGORY_";
private const string FOLDER_SEPERATOR_STRING = "/";
private const char FOLDER_SEPERATOR_CHAR = '/';
private static Dictionary<string, SpecialFolderType> KnownFolderDictionary = new Dictionary<string, SpecialFolderType>()
{
{ INBOX_LABEL_ID, SpecialFolderType.Inbox },
{ "CHAT", SpecialFolderType.Chat },
{ CHAT_LABEL_ID, SpecialFolderType.Chat },
{ IMPORTANT_LABEL_ID, SpecialFolderType.Important },
{ "TRASH", SpecialFolderType.Deleted },
{ TRASH_LABEL_ID, SpecialFolderType.Deleted },
{ DRAFT_LABEL_ID, SpecialFolderType.Draft },
{ SENT_LABEL_ID, SpecialFolderType.Sent },
{ "SPAM", SpecialFolderType.Junk },
{ SPAM_LABEL_ID, SpecialFolderType.Junk },
{ STARRED_LABEL_ID, SpecialFolderType.Starred },
{ UNREAD_LABEL_ID, SpecialFolderType.Unread },
{ "FORUMS", SpecialFolderType.Forums },
{ "UPDATES", SpecialFolderType.Updates },
{ "PROMOTIONS", SpecialFolderType.Promotions },
{ "SOCIAL", SpecialFolderType.Social},
{ "PERSONAL", SpecialFolderType.Personal},
{ FORUMS_LABEL_ID, SpecialFolderType.Forums },
{ UPDATES_LABEL_ID, SpecialFolderType.Updates },
{ PROMOTIONS_LABEL_ID, SpecialFolderType.Promotions },
{ SOCIAL_LABEL_ID, SpecialFolderType.Social},
{ PERSONAL_LABEL_ID, SpecialFolderType.Personal},
};
public static MailItemFolder GetLocalFolder(this Label label, Guid accountId)
public static string[] SubCategoryFolderLabelIds =
[
FORUMS_LABEL_ID,
UPDATES_LABEL_ID,
PROMOTIONS_LABEL_ID,
SOCIAL_LABEL_ID,
PERSONAL_LABEL_ID
];
private static string GetNormalizedLabelName(string labelName)
{
var unchangedFolderName = label.Name;
// 1. Remove CATEGORY_ prefix.
var normalizedLabelName = labelName.Replace(CATEGORY_PREFIX, string.Empty);
if (label.Name.StartsWith("CATEGORY_"))
label.Name = label.Name.Replace("CATEGORY_", "");
// 2. Normalize label name by capitalizing first letter.
normalizedLabelName = char.ToUpper(normalizedLabelName[0]) + normalizedLabelName.Substring(1).ToLower();
bool isSpecialFolder = KnownFolderDictioanry.ContainsKey(label.Name);
return normalizedLabelName;
}
public static MailItemFolder GetLocalFolder(this Label label, ListLabelsResponse labelsResponse, Guid accountId)
{
bool isAllCapital = label.Name.All(a => char.IsUpper(a));
var specialFolderType = isSpecialFolder ? KnownFolderDictioanry[label.Name] : SpecialFolderType.Other;
var normalizedLabelName = GetFolderName(label);
return new MailItemFolder()
// Even though we normalize the label name, check is done by capitalizing the label name.
var capitalNormalizedLabelName = normalizedLabelName.ToUpper();
bool isSpecialFolder = KnownFolderDictionary.ContainsKey(capitalNormalizedLabelName);
var specialFolderType = isSpecialFolder ? KnownFolderDictionary[capitalNormalizedLabelName] : SpecialFolderType.Other;
// We used to support FOLDER_HIDE_IDENTIFIER to hide invisible folders.
// However, a lot of people complained that they don't see their folders after the initial sync
// without realizing that they are hidden in Gmail settings. Therefore, it makes more sense to ignore Gmail's configuration
// since Wino allows folder visibility configuration separately.
// Overridden hidden labels are shown in the UI, but they have their synchronization disabled.
// This is mainly because 'All Mails' label is hidden by default in Gmail, but there is no point to download all mails.
bool shouldEnableSynchronization = label.LabelListVisibility != FOLDER_HIDE_IDENTIFIER;
bool isHidden = false;
bool isChildOfCategoryFolder = label.Name.StartsWith(CATEGORY_PREFIX);
bool isSticky = isSpecialFolder && specialFolderType != SpecialFolderType.Category && !isChildOfCategoryFolder;
// By default, all special folders update unread count in the UI except Trash.
bool shouldShowUnreadCount = specialFolderType != SpecialFolderType.Deleted || specialFolderType != SpecialFolderType.Other;
bool isSystemFolder = label.Type == SYSTEM_FOLDER_IDENTIFIER;
var localFolder = new MailItemFolder()
{
TextColorHex = label.Color?.TextColor,
BackgroundColorHex = label.Color?.BackgroundColor,
FolderName = isAllCapital ? char.ToUpper(label.Name[0]) + label.Name.Substring(1).ToLower() : label.Name, // Capitilize only first letter.
FolderName = normalizedLabelName,
RemoteFolderId = label.Id,
Id = Guid.NewGuid(),
MailAccountId = accountId,
IsSynchronizationEnabled = true,
IsSynchronizationEnabled = shouldEnableSynchronization,
SpecialFolderType = specialFolderType,
IsSystemFolder = label.Type == SYSTEM_FOLDER_IDENTIFIER,
IsSticky = isSpecialFolder && specialFolderType != SpecialFolderType.Category && !unchangedFolderName.StartsWith("CATEGORY"),
IsHidden = label.LabelListVisibility == FOLDER_HIDE_IDENTIFIER,
// By default, all special folders update unread count in the UI except Trash.
ShowUnreadCount = specialFolderType != SpecialFolderType.Deleted || specialFolderType != SpecialFolderType.Other
IsSystemFolder = isSystemFolder,
IsSticky = isSticky,
IsHidden = isHidden,
ShowUnreadCount = shouldShowUnreadCount,
};
localFolder.ParentRemoteFolderId = isChildOfCategoryFolder ? string.Empty : GetParentFolderRemoteId(label.Name, labelsResponse);
return localFolder;
}
public static bool GetIsDraft(this Message message)
@@ -83,6 +140,36 @@ namespace Wino.Core.Extensions
public static bool GetIsFlagged(this Message message)
=> message?.LabelIds?.Any(a => a == STARRED_LABEL_ID) ?? false;
private static string GetParentFolderRemoteId(string fullLabelName, ListLabelsResponse labelsResponse)
{
if (string.IsNullOrEmpty(fullLabelName)) return string.Empty;
// Find the last index of '/'
int lastIndex = fullLabelName.LastIndexOf('/');
// If '/' not found or it's at the start, return the empty string.
if (lastIndex <= 0) return string.Empty;
// Extract the parent label
var parentLabelName = fullLabelName.Substring(0, lastIndex);
return labelsResponse.Labels.FirstOrDefault(a => a.Name == parentLabelName)?.Id ?? string.Empty;
}
public static string GetFolderName(Label label)
{
if (string.IsNullOrEmpty(label.Name)) return string.Empty;
// Folders with "//" at the end has "/" as the name.
if (label.Name.EndsWith(FOLDER_SEPERATOR_STRING)) return FOLDER_SEPERATOR_STRING;
string[] parts = label.Name.Split(FOLDER_SEPERATOR_CHAR);
var lastPart = parts[parts.Length - 1];
return GetNormalizedLabelName(lastPart);
}
/// <summary>
/// Returns MailCopy out of native Gmail message and converted MimeMessage of that native messaage.
/// </summary>
@@ -157,6 +244,5 @@ namespace Wino.Core.Extensions
return new Tuple<MailCopy, MimeMessage, IEnumerable<string>>(mailCopy, mimeMessage, message.LabelIds);
}
}
}

View File

@@ -97,6 +97,13 @@ namespace Wino.Core.Integration
yield return new HttpRequestBundle<TNativeRequestType, TResponse>(action(item), batchChangeRequest);
}
public IEnumerable<IRequestBundle<TNativeRequestType>> CreateHttpBundleWithResponse<TResponse>(
IRequestBase item,
Func<IRequestBase, TNativeRequestType> action)
{
yield return new HttpRequestBundle<TNativeRequestType, TResponse>(action(item), item);
}
/// <summary>
/// Creates a batched HttpBundle with TResponse of expected response type from the http call for each of the items in the batch.
/// Func will be executed for each item separately in the batch request.

View File

@@ -28,12 +28,23 @@ namespace Wino.Core.Integration.Processors
Task DeleteMailAsync(Guid accountId, string mailId);
Task<List<MailCopy>> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable<string> downloadedMailCopyIds);
Task SaveMimeFileAsync(Guid fileId, MimeMessage mimeMessage, Guid accountId);
Task UpdateFolderStructureAsync(Guid accountId, List<MailItemFolder> allFolders);
Task DeleteFolderAsync(Guid accountId, string remoteFolderId);
Task<List<MailItemFolder>> GetSynchronizationFoldersAsync(SynchronizationOptions options);
Task InsertFolderAsync(MailItemFolder folder);
Task UpdateFolderAsync(MailItemFolder folder);
/// <summary>
/// Returns the list of folders that are available for account.
/// </summary>
/// <param name="accountId">Account id to get folders for.</param>
/// <returns>All folders.</returns>
Task<List<MailItemFolder>> GetLocalFoldersAsync(Guid accountId);
Task<List<MailItemFolder>> GetSynchronizationFoldersAsync(SynchronizationOptions options);
Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId);
Task UpdateFolderLastSyncDateAsync(Guid folderId);
Task<List<MailItemFolder>> GetExistingFoldersAsync(Guid accountId);
}
public interface IGmailChangeProcessor : IDefaultChangeProcessor
@@ -68,19 +79,6 @@ namespace Wino.Core.Integration.Processors
/// </summary>
/// <param name="folderId">Folder id to retrieve uIds for.</param>
Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId);
/// <summary>
/// Returns the list of folders that are available for account.
/// </summary>
/// <param name="accountId">Account id to get folders for.</param>
/// <returns>All folders.</returns>
Task<List<MailItemFolder>> GetLocalIMAPFoldersAsync(Guid accountId);
/// <summary>
/// Updates folder.
/// </summary>
/// <param name="folder">Folder to update.</param>
Task UpdateFolderAsync(MailItemFolder folder);
}
public class DefaultChangeProcessor(IDatabaseService databaseService,
@@ -116,14 +114,14 @@ namespace Wino.Core.Integration.Processors
public Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package)
=> MailService.CreateMailAsync(accountId, package);
// Folder methods
public Task UpdateFolderStructureAsync(Guid accountId, List<MailItemFolder> allFolders)
=> FolderService.BulkUpdateFolderStructureAsync(accountId, allFolders);
public Task<List<MailItemFolder>> GetExistingFoldersAsync(Guid accountId)
=> FolderService.GetFoldersAsync(accountId);
public Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId)
=> MailService.MapLocalDraftAsync(accountId, localDraftCopyUniqueId, newMailCopyId, newDraftId, newThreadId);
public Task<List<MailItemFolder>> GetLocalFoldersAsync(Guid accountId)
=> FolderService.GetFoldersAsync(accountId);
public Task<List<MailItemFolder>> GetSynchronizationFoldersAsync(SynchronizationOptions options)
=> FolderService.GetSynchronizationFoldersAsync(options);
@@ -134,6 +132,9 @@ namespace Wino.Core.Integration.Processors
public Task InsertFolderAsync(MailItemFolder folder)
=> FolderService.InsertFolderAsync(folder);
public Task UpdateFolderAsync(MailItemFolder folder)
=> FolderService.UpdateFolderAsync(folder);
public Task<List<MailCopy>> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable<string> downloadedMailCopyIds)
=> MailService.GetDownloadedUnreadMailsAsync(accountId, downloadedMailCopyIds);

View File

@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Services;
@@ -17,13 +16,6 @@ namespace Wino.Core.Integration.Processors
{
}
public Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId)
=> FolderService.GetKnownUidsForFolderAsync(folderId);
public Task<List<MailItemFolder>> GetLocalIMAPFoldersAsync(Guid accountId)
=> FolderService.GetFoldersAsync(accountId);
public Task UpdateFolderAsync(MailItemFolder folder)
=> FolderService.UpdateFolderAsync(folder);
public Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId) => FolderService.GetKnownUidsForFolderAsync(folderId);
}
}

View File

@@ -11,8 +11,6 @@ namespace Wino.Core.MenuItems
{
public partial class AccountMenuItem : MenuItemBase<MailAccount, MenuItemBase<IMailItemFolder, FolderMenuItem>>, IAccountMenuItem
{
public List<FolderMenuItem> FlattenedFolderHierarchy { get; set; }
[ObservableProperty]
private int unreadItemCount;
@@ -20,6 +18,9 @@ namespace Wino.Core.MenuItems
[NotifyPropertyChangedFor(nameof(IsSynchronizationProgressVisible))]
private double synchronizationProgress;
[ObservableProperty]
private bool _isEnabled = true;
public bool IsAttentionRequired => AttentionReason != AccountAttentionReason.None;
public bool IsSynchronizationProgressVisible => SynchronizationProgress > 0 && SynchronizationProgress < 100;
public Guid AccountId => Parameter.Id;
@@ -88,8 +89,5 @@ namespace Wino.Core.MenuItems
}
}
}
public int GetUnreadItemCountByFolderType(SpecialFolderType specialFolderType)
=> FlattenedFolderHierarchy?.Where(a => a.SpecialFolderType == specialFolderType).Sum(a => a.UnreadItemCount) ?? 0;
}
}

View File

@@ -52,6 +52,8 @@ namespace Wino.Core.MenuItems
public bool ShowUnreadCount => Parameter.ShowUnreadCount;
IEnumerable<IMenuItem> IBaseFolderMenuItem.SubMenuItems => SubMenuItems;
public FolderMenuItem(IMailItemFolder folderStructure, MailAccount parentAccount, IMenuItem parentMenuItem) : base(folderStructure, folderStructure.Id, parentMenuItem)
{
ParentAccount = parentAccount;

View File

@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MoreLinq.Extensions;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
@@ -8,64 +10,63 @@ namespace Wino.Core.MenuItems
{
public class MenuItemCollection : ObservableRangeCollection<IMenuItem>
{
public IEnumerable<IBaseFolderMenuItem> GetFolderItems(Guid folderId)
// Which types to remove from the list when folders are changing due to selection of new account.
// We don't clear the whole list since we want to keep the New Mail button and account menu items.
private readonly Type[] _preservingTypesForFolderArea = [typeof(AccountMenuItem), typeof(NewMailMenuItem), typeof(MergedAccountMenuItem)];
private readonly IDispatcher _dispatcher;
public MenuItemCollection(IDispatcher dispatcher)
{
var rootItems = this.OfType<AccountMenuItem>()
.SelectMany(a => a.FlattenedFolderHierarchy)
.Where(a => a.Parameter?.Id == folderId)
.Cast<IBaseFolderMenuItem>();
// Accounts that are merged can't exist in the root items.
// Therefore if the folder is found in root items, return it without searching inside merged accounts.
if (rootItems.Any()) return rootItems;
var mergedItems = this.OfType<MergedAccountMenuItem>()
.SelectMany(a => a.SubMenuItems.OfType<MergedAccountFolderMenuItem>()
.Where(a => a.Parameter.Any(b => b.Id == folderId)))
.Cast<IBaseFolderMenuItem>();
// Folder is found in the MergedInbox shared folders.
if (mergedItems.Any()) return mergedItems;
// Folder is not in any of the above. Looks inside the individual accounts in merged inbox account menu item.
var mergedAccountItems = this.OfType<MergedAccountMenuItem>()
.SelectMany(a => a.SubMenuItems.OfType<AccountMenuItem>()
.SelectMany(a => a.FlattenedFolderHierarchy)
.Where(a => a.Parameter?.Id == folderId))
.Cast<IBaseFolderMenuItem>();
return mergedAccountItems;
_dispatcher = dispatcher;
}
public IBaseFolderMenuItem GetFolderItem(Guid folderId) => GetFolderItems(folderId).FirstOrDefault();
public IAccountMenuItem GetAccountMenuItem(Guid accountId)
public IEnumerable<IAccountMenuItem> GetAllAccountMenuItems()
{
if (accountId == null) return null;
foreach (var item in this)
{
if (item is MergedAccountMenuItem mergedAccountMenuItem)
{
foreach (var singleItem in mergedAccountMenuItem.SubMenuItems.OfType<IAccountMenuItem>())
{
yield return singleItem;
}
if (TryGetRootAccountMenuItem(accountId, out IAccountMenuItem rootAccountMenuItem)) return rootAccountMenuItem;
return null;
yield return mergedAccountMenuItem;
}
else if (item is IAccountMenuItem accountMenuItem)
yield return accountMenuItem;
}
}
// Pattern: Look for root account menu item only. Don't search inside the merged account menu item.
public bool TryGetRootAccountMenuItem(Guid accountId, out IAccountMenuItem value)
public IEnumerable<IBaseFolderMenuItem> GetAllFolderMenuItems(Guid folderId)
{
value = this.OfType<IAccountMenuItem>().FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId));
foreach (var item in this)
{
if (item is IBaseFolderMenuItem folderMenuItem)
{
if (folderMenuItem.HandlingFolders.Any(a => a.Id == folderId))
{
yield return folderMenuItem;
}
else if (folderMenuItem.SubMenuItems.Any())
{
foreach (var subItem in folderMenuItem.SubMenuItems.OfType<IBaseFolderMenuItem>())
{
if (subItem.HandlingFolders.Any(a => a.Id == folderId))
{
yield return subItem;
}
}
value ??= this.OfType<MergedAccountMenuItem>().FirstOrDefault(a => a.EntityId == accountId);
return value != null;
}
}
}
}
// Pattern: Look for root account menu item only and return the folder menu item inside the account menu item that has specific special folder type.
public bool TryGetRootSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
public bool TryGetAccountMenuItem(Guid accountId, out IAccountMenuItem value)
{
value = this.OfType<AccountMenuItem>()
.Where(a => a.HoldingAccounts.Any(b => b.Id == accountId))
.SelectMany(a => a.FlattenedFolderHierarchy)
.FirstOrDefault(a => a.Parameter?.SpecialFolderType == specialFolderType);
value = this.OfType<AccountMenuItem>().FirstOrDefault(a => a.AccountId == accountId);
value ??= this.OfType<MergedAccountMenuItem>().FirstOrDefault(a => a.SubMenuItems.OfType<AccountMenuItem>().Where(b => b.AccountId == accountId) != null);
return value != null;
}
@@ -83,37 +84,49 @@ namespace Wino.Core.MenuItems
// 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<MergedAccountMenuItem>()
.Where(a => a.EntityId == mergedInboxId)
.SelectMany(a => a.SubMenuItems)
.OfType<MergedAccountFolderMenuItem>()
value = this.OfType<MergedAccountFolderMenuItem>()
.Where(a => a.MergedInbox.Id == mergedInboxId)
.FirstOrDefault(a => a.SpecialFolderType == specialFolderType);
return value != null;
}
// Pattern: Find the child account menu item inside the merged account menu item, locate the special folder menu item inside the child account menu item.
public bool TryGetMergedAccountFolderMenuItemByAccountId(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
public bool TryGetFolderMenuItem(Guid folderId, out IBaseFolderMenuItem value)
{
value = this.OfType<MergedAccountMenuItem>()
.SelectMany(a => a.SubMenuItems)
.OfType<AccountMenuItem>()
.FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == accountId))
?.FlattenedFolderHierarchy
.OfType<FolderMenuItem>()
.FirstOrDefault(a => a.Parameter?.SpecialFolderType == specialFolderType);
// Root folders
value = this.OfType<IBaseFolderMenuItem>()
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId));
value ??= this.OfType<FolderMenuItem>()
.SelectMany(a => a.SubMenuItems)
.OfType<IBaseFolderMenuItem>()
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.Id == folderId));
return value != null;
}
// Pattern: Find the common folder menu item with special folder type inside the merged account menu item for the given AccountId.
public bool TryGetMergedAccountRootFolderMenuItemByAccountId(Guid accountId, SpecialFolderType specialFolderType, out MergedAccountFolderMenuItem value)
public void UpdateUnreadItemCountsToZero()
{
value = this.OfType<MergedAccountMenuItem>()
.Where(a => a.HoldingAccounts.Any(b => b.Id == accountId))
.SelectMany(a => a.SubMenuItems)
.OfType<MergedAccountFolderMenuItem>()
.FirstOrDefault(a => a.SpecialFolderType == specialFolderType);
// Handle the root folders.
this.OfType<IBaseFolderMenuItem>().ForEach(a => RecursivelyResetUnreadItemCount(a));
}
private void RecursivelyResetUnreadItemCount(IBaseFolderMenuItem baseFolderMenuItem)
{
baseFolderMenuItem.UnreadItemCount = 0;
if (baseFolderMenuItem.SubMenuItems == null) return;
foreach (var subMenuItem in baseFolderMenuItem.SubMenuItems.OfType<IBaseFolderMenuItem>())
{
RecursivelyResetUnreadItemCount(subMenuItem);
}
}
public bool TryGetSpecialFolderMenuItem(Guid accountId, SpecialFolderType specialFolderType, out FolderMenuItem value)
{
value = this.OfType<IBaseFolderMenuItem>()
.FirstOrDefault(a => a.HandlingFolders.Any(b => b.MailAccountId == accountId && b.SpecialFolderType == specialFolderType)) as FolderMenuItem;
return value != null;
}
@@ -138,12 +151,29 @@ namespace Wino.Core.MenuItems
return accountMenuItem;
}
public void ReplaceFolders(IEnumerable<IMenuItem> folders)
public async Task ReplaceFoldersAsync(IEnumerable<IMenuItem> folders)
{
ClearFolderAreaMenuItems();
await _dispatcher.ExecuteOnUIThread(() =>
{
ClearFolderAreaMenuItems();
Items.Add(new SeperatorItem());
AddRange(folders);
Items.Add(new SeperatorItem());
AddRange(folders);
});
}
/// <summary>
/// Enables/disables account menu items in the list.
/// </summary>
/// <param name="isEnabled">Whether menu items should be enabled or disabled.</param>
public async Task SetAccountMenuItemEnabledStatusAsync(bool isEnabled)
{
var accountItems = this.Where(a => a is IAccountMenuItem).Cast<IAccountMenuItem>();
await _dispatcher.ExecuteOnUIThread(() =>
{
accountItems.ForEach(a => a.IsEnabled = isEnabled);
});
}
public void AddAccountMenuItem(IAccountMenuItem accountMenuItem)
@@ -158,18 +188,15 @@ namespace Wino.Core.MenuItems
private void ClearFolderAreaMenuItems()
{
var cloneItems = Items.ToList();
var itemsToRemove = this.Where(a => !_preservingTypesForFolderArea.Contains(a.GetType())).ToList();
foreach (var item in cloneItems)
itemsToRemove.ForEach(item =>
{
if (item is SeperatorItem || item is IBaseFolderMenuItem || item is MergedAccountMoreFolderMenuItem)
{
item.IsSelected = false;
item.IsExpanded = false;
item.IsExpanded = false;
item.IsSelected = false;
});
Remove(item);
}
}
RemoveRange(itemsToRemove);
}
}
}

View File

@@ -37,6 +37,8 @@ namespace Wino.Core.MenuItems
public bool ShowUnreadCount => HandlingFolders?.Any(a => a.ShowUnreadCount) ?? false;
public IEnumerable<IMenuItem> SubMenuItems => SubMenuItems;
[ObservableProperty]
private int unreadItemCount;

View File

@@ -2,16 +2,15 @@
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.MenuItems
{
public partial class MergedAccountMenuItem : MenuItemBase<MergedInbox, IMenuItem>, IAccountMenuItem
public partial class MergedAccountMenuItem : MenuItemBase<MergedInbox, IMenuItem>, IMergedAccountMenuItem
{
public int MergedAccountCount => GetAccountMenuItems().Count();
public int MergedAccountCount => HoldingAccounts?.Count() ?? 0;
public IEnumerable<MailAccount> HoldingAccounts => GetAccountMenuItems()?.SelectMany(a => a.HoldingAccounts);
public IEnumerable<MailAccount> HoldingAccounts { get; }
[ObservableProperty]
private int unreadItemCount;
@@ -22,34 +21,23 @@ namespace Wino.Core.MenuItems
[ObservableProperty]
private string mergedAccountName;
public MergedAccountMenuItem(MergedInbox mergedInbox, IMenuItem parent) : base(mergedInbox, mergedInbox.Id, parent)
[ObservableProperty]
private bool _isEnabled = true;
public MergedAccountMenuItem(MergedInbox mergedInbox, IEnumerable<MailAccount> holdingAccounts, IMenuItem parent) : base(mergedInbox, mergedInbox.Id, parent)
{
MergedAccountName = mergedInbox.Name;
HoldingAccounts = holdingAccounts;
}
public void RefreshFolderItemCount()
{
UnreadItemCount = GetAccountMenuItems().Select(a => a.GetUnreadItemCountByFolderType(SpecialFolderType.Inbox)).Sum();
var unreadUpdateFolders = SubMenuItems.OfType<IBaseFolderMenuItem>().Where(a => a.ShowUnreadCount);
foreach (var folder in unreadUpdateFolders)
{
folder.UnreadItemCount = GetAccountMenuItems().Select(a => a.GetUnreadItemCountByFolderType(folder.SpecialFolderType)).Sum();
}
}
// Accounts are always located in More folder of Merged Inbox menu item.
public IEnumerable<AccountMenuItem> GetAccountMenuItems()
{
var moreFolder = SubMenuItems.OfType<MergedAccountMoreFolderMenuItem>().FirstOrDefault();
if (moreFolder == null) return default;
return moreFolder.SubMenuItems.OfType<AccountMenuItem>();
UnreadItemCount = SubMenuItems.OfType<IAccountMenuItem>().Sum(a => a.UnreadItemCount);
}
public void UpdateAccount(MailAccount account)
=> GetAccountMenuItems().FirstOrDefault(a => a.HoldingAccounts.Any(b => b.Id == account.Id))?.UpdateAccount(account);
{
}
}
}

View File

@@ -0,0 +1,7 @@
namespace Wino.Core.Messages.Mails
{
/// <summary>
/// When rendering frame should be disposed.
/// </summary>
public class DisposeRenderingFrameRequested { }
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests
{
public record EmptyFolderRequest(MailItemFolder Folder, List<MailCopy> MailsToDelete) : FolderRequestBase(Folder, MailSynchronizerOperation.EmptyFolder), ICustomFolderSynchronizationRequest
{
public override void ApplyUIChanges()
{
foreach (var item in MailsToDelete)
{
WeakReferenceMessenger.Default.Send(new MailRemovedMessage(item));
}
}
public override void RevertUIChanges()
{
foreach (var item in MailsToDelete)
{
WeakReferenceMessenger.Default.Send(new MailAddedMessage(item));
}
}
public List<Guid> SynchronizationFolderIds => [Folder.Id];
}
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests
{
public record MarkFolderAsReadRequest(MailItemFolder Folder, List<MailCopy> MailsToMarkRead) : FolderRequestBase(Folder, MailSynchronizerOperation.MarkFolderRead), ICustomFolderSynchronizationRequest
{
public override void ApplyUIChanges()
{
foreach (var item in MailsToMarkRead)
{
item.IsRead = true;
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item));
}
}
public override void RevertUIChanges()
{
foreach (var item in MailsToMarkRead)
{
item.IsRead = false;
WeakReferenceMessenger.Default.Send(new MailUpdatedMessage(item));
}
}
public override bool DelayExecution => false;
public List<Guid> SynchronizationFolderIds => [Folder.Id];
}
}

View File

@@ -1,19 +1,22 @@
using Wino.Core.Domain.Entities;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests
{
public record RenameFolderRequest(MailItemFolder Folder) : FolderRequestBase(Folder, MailSynchronizerOperation.RenameFolder)
public record RenameFolderRequest(MailItemFolder Folder, string CurrentFolderName, string NewFolderName) : FolderRequestBase(Folder, MailSynchronizerOperation.RenameFolder)
{
public override void ApplyUIChanges()
{
Folder.FolderName = NewFolderName;
WeakReferenceMessenger.Default.Send(new FolderRenamed(Folder));
}
public override void RevertUIChanges()
{
Folder.FolderName = CurrentFolderName;
WeakReferenceMessenger.Default.Send(new FolderRenamed(Folder));
}
}
}

View File

@@ -55,5 +55,7 @@ namespace Wino.Core.Requests
{
Items.ForEach(item => WeakReferenceMessenger.Default.Send(new MailAddedMessage(item.Item)));
}
public override bool DelayExecution => true;
}
}

View File

@@ -1,5 +1,6 @@
using System;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.Requests;
namespace Wino.Core.Requests
@@ -9,10 +10,6 @@ namespace Wino.Core.Requests
public record MailUpdatedMessage(MailCopy UpdatedMail) : IUIMessage;
public record MailDownloadedMessage(MailCopy DownloadedMail) : IUIMessage;
public record FolderAddedMessage(MailItemFolder AddedFolder, MailAccount Account) : IUIMessage;
public record FolderRemovedMessage(MailItemFolder RemovedFolder, MailAccount Account) : IUIMessage;
public record FolderUpdatedMessage(MailItemFolder UpdatedFolder, MailAccount Account) : IUIMessage;
public record AccountCreatedMessage(MailAccount Account) : IUIMessage;
public record AccountRemovedMessage(MailAccount Account) : IUIMessage;
public record AccountUpdatedMessage(MailAccount Account) : IUIMessage;
@@ -22,4 +19,7 @@ namespace Wino.Core.Requests
public record DraftMapped(string LocalDraftCopyId, string RemoteDraftCopyId) : IUIMessage;
public record MergedInboxRenamed(Guid MergedInboxId, string NewName) : IUIMessage;
public record FolderRenamed(IMailItemFolder MailItemFolder) : IUIMessage;
public record FolderSynchronizationEnabled(IMailItemFolder MailItemFolder) : IUIMessage;
}

View File

@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using MoreLinq;
using Serilog;
using SqlKata;
@@ -9,10 +10,12 @@ using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Extensions;
using Wino.Core.MenuItems;
using Wino.Core.Requests;
namespace Wino.Core.Services
@@ -168,6 +171,155 @@ namespace Wino.Core.Services
return accountTree;
}
public Task<IEnumerable<IMenuItem>> GetAccountFoldersForDisplayAsync(IAccountMenuItem accountMenuItem)
{
if (accountMenuItem is IMergedAccountMenuItem mergedAccountFolderMenuItem)
{
return GetMergedAccountFolderMenuItemsAsync(mergedAccountFolderMenuItem);
}
else
{
return GetSingleAccountFolderMenuItemsAsync(accountMenuItem);
}
}
private async Task<FolderMenuItem> GetPreparedFolderMenuItemRecursiveAsync(MailAccount account, MailItemFolder parentFolder, IMenuItem parentMenuItem)
{
// Localize category folder name.
if (parentFolder.SpecialFolderType == SpecialFolderType.Category) parentFolder.FolderName = Translator.CategoriesFolderNameOverride;
var query = new Query(nameof(MailItemFolder))
.Where(nameof(MailItemFolder.ParentRemoteFolderId), parentFolder.RemoteFolderId)
.Where(nameof(MailItemFolder.MailAccountId), parentFolder.MailAccountId);
var preparedFolder = new FolderMenuItem(parentFolder, account, parentMenuItem);
var childFolders = await Connection.QueryAsync<MailItemFolder>(query.GetRawQuery()).ConfigureAwait(false);
if (childFolders.Any())
{
foreach (var subChildFolder in childFolders)
{
var preparedChild = await GetPreparedFolderMenuItemRecursiveAsync(account, subChildFolder, preparedFolder);
if (preparedChild == null) continue;
preparedFolder.SubMenuItems.Add(preparedChild);
}
}
return preparedFolder;
}
private async Task<IEnumerable<IMenuItem>> GetSingleAccountFolderMenuItemsAsync(IAccountMenuItem accountMenuItem)
{
var accountId = accountMenuItem.EntityId.Value;
var preparedFolderMenuItems = new List<IMenuItem>();
// Get all folders for the account. Excluding hidden folders.
var folders = await GetVisibleFoldersAsync(accountId).ConfigureAwait(false);
if (!folders.Any()) return new List<IMenuItem>();
var mailAccount = accountMenuItem.HoldingAccounts.First();
var listingFolders = folders.OrderBy(a => a.SpecialFolderType);
var moreFolder = MailItemFolder.CreateMoreFolder();
var categoryFolder = MailItemFolder.CreateCategoriesFolder();
var moreFolderMenuItem = new FolderMenuItem(moreFolder, mailAccount, accountMenuItem);
var categoryFolderMenuItem = new FolderMenuItem(categoryFolder, mailAccount, accountMenuItem);
foreach (var item in listingFolders)
{
// Category type folders should be skipped. They will be categorized under virtual category folder.
if (GoogleIntegratorExtensions.SubCategoryFolderLabelIds.Contains(item.RemoteFolderId)) continue;
bool skipEmptyParentRemoteFolders = mailAccount.ProviderType == MailProviderType.Gmail;
if (skipEmptyParentRemoteFolders && !string.IsNullOrEmpty(item.ParentRemoteFolderId)) continue;
// Sticky items belong to account menu item directly. Rest goes to More folder.
IMenuItem parentFolderMenuItem = item.IsSticky ? accountMenuItem : (GoogleIntegratorExtensions.SubCategoryFolderLabelIds.Contains(item.FolderName.ToUpper()) ? categoryFolderMenuItem : moreFolderMenuItem);
var preparedItem = await GetPreparedFolderMenuItemRecursiveAsync(mailAccount, item, parentFolderMenuItem).ConfigureAwait(false);
// Don't add menu items that are prepared for More folder. They've been included in More virtual folder already.
// We'll add More folder later on at the end of the list.
if (preparedItem == null) continue;
if (item.IsSticky)
{
preparedFolderMenuItems.Add(preparedItem);
}
else if (parentFolderMenuItem is FolderMenuItem baseParentFolderMenuItem)
{
baseParentFolderMenuItem.SubMenuItems.Add(preparedItem);
}
}
// Only add category folder if it's Gmail.
if (mailAccount.ProviderType == MailProviderType.Gmail) preparedFolderMenuItems.Add(categoryFolderMenuItem);
// Only add More folder if there are any items in it.
if (moreFolderMenuItem.SubMenuItems.Any()) preparedFolderMenuItems.Add(moreFolderMenuItem);
return preparedFolderMenuItems;
}
private async Task<IEnumerable<IMenuItem>> GetMergedAccountFolderMenuItemsAsync(IMergedAccountMenuItem mergedAccountFolderMenuItem)
{
var holdingAccounts = mergedAccountFolderMenuItem.HoldingAccounts;
if (holdingAccounts == null || !holdingAccounts.Any()) return [];
var preparedFolderMenuItems = new List<IMenuItem>();
// First gather all account folders.
// Prepare single menu items for both of them.
var allAccountFolders = new List<List<MailItemFolder>>();
foreach (var account in holdingAccounts)
{
var accountFolders = await GetVisibleFoldersAsync(account.Id).ConfigureAwait(false);
allAccountFolders.Add(accountFolders);
}
var commonFolders = FindCommonFolders(allAccountFolders);
// Prepare menu items for common folders.
foreach (var commonFolderType in commonFolders)
{
var folderItems = allAccountFolders.SelectMany(a => a.Where(b => b.SpecialFolderType == commonFolderType)).Cast<IMailItemFolder>().ToList();
var menuItem = new MergedAccountFolderMenuItem(folderItems, null, mergedAccountFolderMenuItem.Parameter);
preparedFolderMenuItems.Add(menuItem);
}
return preparedFolderMenuItems;
}
private HashSet<SpecialFolderType> FindCommonFolders(List<List<MailItemFolder>> lists)
{
var allSpecialTypesExceptOther = Enum.GetValues(typeof(SpecialFolderType)).Cast<SpecialFolderType>().Where(a => a != SpecialFolderType.Other).ToList();
// Start with all special folder types from the first list
var commonSpecialFolderTypes = new HashSet<SpecialFolderType>(allSpecialTypesExceptOther);
// Intersect with special folder types from all lists
foreach (var list in lists)
{
commonSpecialFolderTypes.IntersectWith(list.Select(f => f.SpecialFolderType));
}
return commonSpecialFolderTypes;
}
private async Task<MailItemFolder> GetChildFolderItemsRecursiveAsync(Guid folderId, Guid accountId)
{
var folder = await Connection.Table<MailItemFolder>().Where(a => a.Id == folderId && a.MailAccountId == accountId).FirstOrDefaultAsync();
@@ -198,49 +350,22 @@ namespace Wino.Core.Services
=> Connection.Table<MailCopy>().Where(a => a.FolderId == folderId).CountAsync();
public Task<List<MailItemFolder>> GetFoldersAsync(Guid accountId)
=> Connection.Table<MailItemFolder>().Where(a => a.MailAccountId == accountId).ToListAsync();
public async Task UpdateCustomServerMailListAsync(Guid accountId, List<MailItemFolder> folders)
{
var account = await Connection.Table<MailAccount>().FirstOrDefaultAsync(a => a.Id == accountId);
var query = new Query(nameof(MailItemFolder))
.Where(nameof(MailItemFolder.MailAccountId), accountId)
.OrderBy(nameof(MailItemFolder.SpecialFolderType));
if (account == null)
return;
return Connection.QueryAsync<MailItemFolder>(query.GetRawQuery());
}
// IMAP servers don't have unique identifier for folders all the time.
// We'll map them with parent-name relation.
public Task<List<MailItemFolder>> GetVisibleFoldersAsync(Guid accountId)
{
var query = new Query(nameof(MailItemFolder))
.Where(nameof(MailItemFolder.MailAccountId), accountId)
.Where(nameof(MailItemFolder.IsHidden), false)
.OrderBy(nameof(MailItemFolder.SpecialFolderType));
var currentFolders = await GetFoldersAsync(accountId);
// These folders don't exist anymore. Remove them.
var localRemoveFolders = currentFolders.ExceptBy(folders, a => a.RemoteFolderId);
foreach (var currentFolder in currentFolders)
{
// Check if we have this folder locally.
var remotelyExistFolder = folders.FirstOrDefault(a => a.RemoteFolderId == currentFolder.RemoteFolderId
&& a.ParentRemoteFolderId == currentFolder.ParentRemoteFolderId);
if (remotelyExistFolder == null)
{
// This folder is removed.
// Remove everything for this folder.
}
}
foreach (var folder in folders)
{
var currentFolder = await Connection.Table<MailItemFolder>().FirstOrDefaultAsync(a => a.MailAccountId == accountId && a.RemoteFolderId == folder.RemoteFolderId);
// Nothing is changed, it's still the same folder.
// Just update Id of the folder.
if (currentFolder != null)
folder.Id = currentFolder.Id;
await Connection.InsertOrReplaceAsync(folder);
}
return Connection.QueryAsync<MailItemFolder>(query.GetRawQuery());
}
public async Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId)
@@ -301,6 +426,8 @@ namespace Wino.Core.Services
localFolder.IsSynchronizationEnabled = isSynchronizationEnabled;
await UpdateFolderAsync(localFolder).ConfigureAwait(false);
Messenger.Send(new FolderSynchronizationEnabled(localFolder));
}
}
@@ -337,11 +464,19 @@ namespace Wino.Core.Services
_logger.Debug("Inserting folder {Id} - {FolderName}", folder.Id, folder.FolderName, folder.MailAccountId);
await Connection.InsertAsync(folder).ConfigureAwait(false);
ReportUIChange(new FolderAddedMessage(folder, account));
}
else
{
// TODO: This is not alright. We should've updated the folder instead of inserting.
// Now we need to match the properties that user might've set locally.
folder.Id = existingFolder.Id;
folder.IsSticky = existingFolder.IsSticky;
folder.SpecialFolderType = existingFolder.SpecialFolderType;
folder.ShowUnreadCount = existingFolder.ShowUnreadCount;
folder.TextColorHex = existingFolder.TextColorHex;
folder.BackgroundColorHex = existingFolder.BackgroundColorHex;
_logger.Debug("Folder {Id} - {FolderName} already exists. Updating.", folder.Id, folder.FolderName);
await UpdateFolderAsync(folder).ConfigureAwait(false);
@@ -364,13 +499,9 @@ namespace Wino.Core.Services
return;
}
#if !DEBUG // Annoying
_logger.Debug("Updating folder {FolderName}", folder.Id, folder.FolderName);
#endif
await Connection.UpdateAsync(folder).ConfigureAwait(false);
ReportUIChange(new FolderUpdatedMessage(folder, account));
}
private async Task DeleteFolderAsync(MailItemFolder folder)
@@ -393,9 +524,10 @@ namespace Wino.Core.Services
await Connection.DeleteAsync(folder).ConfigureAwait(false);
// TODO: Delete all mail copies for this folder.
// Delete all existing mails from this folder.
await Connection.ExecuteAsync("DELETE FROM MailCopy WHERE FolderId = ?", folder.Id);
ReportUIChange(new FolderRemovedMessage(folder, account));
// TODO: Delete MIME messages from the disk.
}
#endregion
@@ -426,8 +558,6 @@ namespace Wino.Core.Services
public Task<List<MailFolderPairMetadata>> GetMailFolderPairMetadatasAsync(string mailCopyId)
=> GetMailFolderPairMetadatasAsync(new List<string>() { mailCopyId });
public async Task SetSpecialFolderAsync(Guid folderId, SpecialFolderType type)
=> await Connection.ExecuteAsync("UPDATE MailItemFolder SET SpecialFolderType = ? WHERE Id = ?", type, folderId);
public async Task<List<MailItemFolder>> GetSynchronizationFoldersAsync(SynchronizationOptions options)
{
@@ -489,38 +619,6 @@ namespace Wino.Core.Services
public Task<MailItemFolder> GetFolderAsync(Guid accountId, string remoteFolderId)
=> Connection.Table<MailItemFolder>().FirstOrDefaultAsync(a => a.MailAccountId == accountId && a.RemoteFolderId == remoteFolderId);
// v2
public async Task BulkUpdateFolderStructureAsync(Guid accountId, List<MailItemFolder> allFolders)
{
var existingFolders = await GetFoldersAsync(accountId).ConfigureAwait(false);
var foldersToInsert = allFolders.ExceptBy(existingFolders, a => a.RemoteFolderId);
var foldersToDelete = existingFolders.ExceptBy(allFolders, a => a.RemoteFolderId);
var foldersToUpdate = allFolders.Except(foldersToInsert).Except(foldersToDelete);
_logger.Debug("Found {0} folders to insert, {1} folders to update and {2} folders to delete.",
foldersToInsert.Count(),
foldersToUpdate.Count(),
foldersToDelete.Count());
foreach (var folder in foldersToInsert)
{
await InsertFolderAsync(folder).ConfigureAwait(false);
}
foreach (var folder in foldersToUpdate)
{
await UpdateFolderAsync(folder).ConfigureAwait(false);
}
foreach (var folder in foldersToDelete)
{
await DeleteFolderAsync(folder).ConfigureAwait(false);
}
}
public async Task DeleteFolderAsync(Guid accountId, string remoteFolderId)
{
var folder = await GetFolderAsync(accountId, remoteFolderId);
@@ -547,33 +645,6 @@ namespace Wino.Core.Services
}
}
// Inbox folder is always included for account menu item unread count.
public Task<List<MailItemFolder>> GetUnreadUpdateFoldersAsync(Guid accountId)
=> Connection.Table<MailItemFolder>().Where(a => a.MailAccountId == accountId && (a.ShowUnreadCount || a.SpecialFolderType == SpecialFolderType.Inbox)).ToListAsync();
public async Task TestAsync()
{
var account = new MailAccount()
{
Address = "test@test.com",
ProviderType = MailProviderType.Gmail,
Name = "Test Account",
Id = Guid.NewGuid()
};
await Connection.InsertAsync(account);
var pref = new MailAccountPreferences
{
Id = Guid.NewGuid(),
AccountId = account.Id
};
await Connection.InsertAsync(pref);
ReportUIChange(new AccountCreatedMessage(account));
}
public async Task<bool> IsInboxAvailableForAccountAsync(Guid accountId)
=> (await Connection.Table<MailItemFolder>()
.Where(a => a.SpecialFolderType == SpecialFolderType.Inbox && a.MailAccountId == accountId)
@@ -581,5 +652,18 @@ namespace Wino.Core.Services
public Task UpdateFolderLastSyncDateAsync(Guid folderId)
=> Connection.ExecuteAsync("UPDATE MailItemFolder SET LastSynchronizedDate = ? WHERE Id = ?", DateTime.UtcNow, folderId);
public Task<List<UnreadItemCountResult>> GetUnreadItemCountResultsAsync(IEnumerable<Guid> accountIds)
{
var query = new Query(nameof(MailCopy))
.Join(nameof(MailItemFolder), $"{nameof(MailCopy)}.FolderId", $"{nameof(MailItemFolder)}.Id")
.WhereIn($"{nameof(MailItemFolder)}.MailAccountId", accountIds)
.Where($"{nameof(MailCopy)}.IsRead", 0)
.Where($"{nameof(MailItemFolder)}.ShowUnreadCount", 1)
.SelectRaw($"{nameof(MailItemFolder)}.Id as FolderId, {nameof(MailItemFolder)}.SpecialFolderType as SpecialFolderType, count (DISTINCT {nameof(MailCopy)}.Id) as UnreadItemCount, {nameof(MailItemFolder)}.MailAccountId as AccountId")
.GroupBy($"{nameof(MailItemFolder)}.Id");
return Connection.QueryAsync<UnreadItemCountResult>(query.GetRawQuery());
}
}
}

View File

@@ -4,7 +4,6 @@ using System.Linq;
using System.Threading.Tasks;
using Microsoft.Kiota.Abstractions.Extensions;
using MimeKit;
using MimeKit.Text;
using MoreLinq;
using Serilog;
using SqlKata;
@@ -111,9 +110,29 @@ namespace Wino.Core.Services
return copy;
}
public Task<List<string>> GetMailIdsByFolderIdAsync(Guid folderId)
=> Connection.QueryScalarsAsync<string>("SELECT Id FROM MailCopy WHERE FolderId = ?", folderId);
public async Task<List<MailCopy>> GetMailsByFolderIdAsync(Guid folderId)
{
var mails = await Connection.QueryAsync<MailCopy>("SELECT * FROM MailCopy WHERE FolderId = ?", folderId);
foreach (var mail in mails)
{
await LoadAssignedPropertiesAsync(mail).ConfigureAwait(false);
}
return mails;
}
public async Task<List<MailCopy>> GetUnreadMailsByFolderIdAsync(Guid folderId)
{
var unreadMails = await Connection.QueryAsync<MailCopy>("SELECT * FROM MailCopy WHERE FolderId = ? AND IsRead = 0", folderId);
foreach (var mail in unreadMails)
{
await LoadAssignedPropertiesAsync(mail).ConfigureAwait(false);
}
return unreadMails;
}
private string BuildMailFetchQuery(MailListInitializationOptions options)
{

View File

@@ -10,7 +10,6 @@ using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Requests;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Messages.Synchronization;
using Wino.Core.Requests;
@@ -85,13 +84,17 @@ namespace Wino.Core.Services
}
}
public async Task ExecuteAsync(FolderOperation operation, IMailItemFolder folderStructure)
public async Task ExecuteAsync(FolderOperationPreperationRequest folderRequest)
{
IRequest request = null;
if (folderRequest == null || folderRequest.Folder == null) return;
IRequestBase request = null;
var accountId = folderRequest.Folder.MailAccountId;
try
{
request = await _winoRequestProcessor.PrepareFolderRequestAsync(operation, folderStructure);
request = await _winoRequestProcessor.PrepareFolderRequestAsync(folderRequest);
}
catch (NotImplementedException)
{
@@ -102,7 +105,10 @@ namespace Wino.Core.Services
Log.Error(ex, "Folder operation execution failed.");
}
// _synchronizationWorker.Queue(request);
if (request == null) return;
QueueRequest(request, accountId);
QueueSynchronization(accountId);
}
public Task ExecuteAsync(DraftPreperationRequest draftPreperationRequest)
@@ -125,7 +131,7 @@ namespace Wino.Core.Services
return Task.CompletedTask;
}
private void QueueRequest(IRequest request, Guid accountId)
private void QueueRequest(IRequestBase request, Guid accountId)
{
var synchronizer = _winoSynchronizerFactory.GetAccountSynchronizer(accountId);

View File

@@ -215,45 +215,45 @@ namespace Wino.Core.Services
return null;
}
public async Task<IRequest> PrepareFolderRequestAsync(FolderOperation operation, IMailItemFolder mailItemFolder)
public async Task<IRequestBase> PrepareFolderRequestAsync(FolderOperationPreperationRequest request)
{
if (mailItemFolder == null) return default;
if (request == null || request.Folder == null) return default;
var accountId = mailItemFolder.MailAccountId;
IRequestBase change = null;
IRequest change = null;
var folder = request.Folder;
var operation = request.Action;
switch (operation)
switch (request.Action)
{
case FolderOperation.Pin:
case FolderOperation.Unpin:
await _folderService.ChangeStickyStatusAsync(mailItemFolder.Id, operation == FolderOperation.Pin);
await _folderService.ChangeStickyStatusAsync(folder.Id, operation == FolderOperation.Pin);
break;
//case FolderOperation.MarkAllAsRead:
// // Get all mails in the folder.
// var mailItems = await _folderService.GetAllUnreadItemsByFolderIdAsync(accountId, folderStructure.RemoteFolderId).ConfigureAwait(false);
case FolderOperation.Rename:
var newFolderName = await _dialogService.ShowTextInputDialogAsync(folder.FolderName, Translator.DialogMessage_RenameFolderTitle, Translator.DialogMessage_RenameFolderMessage, Translator.FolderOperation_Rename);
// if (mailItems.Any())
// change = new FolderMarkAsReadRequest(accountId, mailItems.Select(a => a.Id).Distinct(), folderStructure.RemoteFolderId, folderStructure.FolderId);
if (!string.IsNullOrEmpty(newFolderName))
{
change = new RenameFolderRequest(folder, folder.FolderName, newFolderName);
}
// break;
//case FolderOperation.Empty:
// // Get all mails in the folder.
break;
case FolderOperation.Empty:
var mailsToDelete = await _mailService.GetMailsByFolderIdAsync(folder.Id).ConfigureAwait(false);
// var mailsToDelete = await _folderService.GetMailByFolderIdAsync(folderStructure.FolderId).ConfigureAwait(false);
change = new EmptyFolderRequest(folder, mailsToDelete);
// if (mailsToDelete.Any())
// change = new FolderEmptyRequest(accountId, mailsToDelete.Select(a => a.Id).Distinct(), folderStructure.RemoteFolderId, folderStructure.FolderId);
break;
case FolderOperation.MarkAllAsRead:
// break;
//case FolderOperation.Rename:
// var newFolderName = await _dialogService.ShowRenameFolderDialogAsync(folderStructure.FolderName);
var unreadItems = await _mailService.GetUnreadMailsByFolderIdAsync(folder.Id).ConfigureAwait(false);
// if (!string.IsNullOrEmpty(newFolderName))
// change = new RenameFolderRequest(accountId, folderStructure.RemoteFolderId, folderStructure.FolderId, newFolderName, folderStructure.FolderName);
if (unreadItems.Any())
change = new MarkFolderAsReadRequest(folder, unreadItems);
// break;
break;
//case FolderOperation.Delete:
// var isConfirmed = await _dialogService.ShowConfirmationDialogAsync($"'{folderStructure.FolderName}' is going to be deleted. Do you want to continue?", "Are you sure?", "Yes delete.");

View File

@@ -149,11 +149,10 @@ namespace Wino.Core.Synchronizers
await synchronizationSemaphore.WaitAsync(activeSynchronizationCancellationToken);
// Let servers to finish their job. Sometimes the servers doesn't respond immediately.
// TODO: Outlook sends back the deleted Draft. Might be a bug in the graph API or in Wino.
var hasSendDraftRequest = batches.Any(a => a is BatchSendDraftRequestRequest);
bool shouldDelayExecution = batches.Any(a => a.DelayExecution);
if (hasSendDraftRequest && DelaySendOperationSynchronization())
if (shouldDelayExecution)
{
await Task.Delay(2000);
}
@@ -227,8 +226,13 @@ namespace Wino.Core.Synchronizers
changeRequestQueue.TryTake(out _);
}
}
else
else if (changeRequestQueue.TryTake(out request))
{
// This is a folder operation.
// There is no need to batch them since Users can't do folder ops in bulk.
batchList.Add(request);
}
}
}
@@ -272,6 +276,15 @@ namespace Wino.Core.Synchronizers
case MailSynchronizerOperation.CreateDraft:
yield return CreateDraft((BatchCreateDraftRequest)item);
break;
case MailSynchronizerOperation.RenameFolder:
yield return RenameFolder((RenameFolderRequest)item);
break;
case MailSynchronizerOperation.EmptyFolder:
yield return EmptyFolder((EmptyFolderRequest)item);
break;
case MailSynchronizerOperation.MarkFolderRead:
yield return MarkFolderAsRead((MarkFolderAsReadRequest)item);
break;
case MailSynchronizerOperation.Archive:
yield return Archive((BatchArchiveRequest)item);
break;
@@ -287,12 +300,9 @@ namespace Wino.Core.Synchronizers
/// </summary>
/// <param name="batches">Batch requests to run in synchronization.</param>
/// <returns>New synchronization options with minimal HTTP effort.</returns>
private SynchronizationOptions GetSynchronizationOptionsAfterRequestExecution(IEnumerable<IRequestBase> batches)
private SynchronizationOptions GetSynchronizationOptionsAfterRequestExecution(IEnumerable<IRequestBase> requests)
{
// TODO: Check folders only.
var batchItems = batches.Where(a => a is IBatchChangeRequest).Cast<IBatchChangeRequest>();
var requests = batchItems.SelectMany(a => a.Items);
bool isAllCustomSynchronizationRequests = requests.All(a => a is ICustomFolderSynchronizationRequest);
var options = new SynchronizationOptions()
{
@@ -300,9 +310,7 @@ namespace Wino.Core.Synchronizers
Type = SynchronizationType.FoldersOnly
};
bool isCustomSynchronization = requests.All(a => a is ICustomFolderSynchronizationRequest);
if (isCustomSynchronization)
if (isAllCustomSynchronizationRequests)
{
// Gather FolderIds to synchronize.
@@ -327,6 +335,9 @@ namespace Wino.Core.Synchronizers
public virtual IEnumerable<IRequestBundle<TBaseRequest>> MoveToFocused(BatchMoveToFocusedRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> CreateDraft(BatchCreateDraftRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> SendDraft(BatchSendDraftRequestRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> RenameFolder(RenameFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> EmptyFolder(EmptyFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
public virtual IEnumerable<IRequestBundle<TBaseRequest>> Archive(BatchArchiveRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
/// <summary>

View File

@@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Google.Apis.Gmail.v1;
@@ -245,111 +244,97 @@ namespace Wino.Core.Synchronizers
private async Task SynchronizeFoldersAsync(CancellationToken cancellationToken = default)
{
var folderRequest = _gmailService.Users.Labels.List("me");
var labelsResponse = await folderRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
if (labelsResponse.Labels == null)
try
{
_logger.Warning("No folders found for {Name}", Account.Name);
return;
}
var localFolders = await _gmailChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
var folderRequest = _gmailService.Users.Labels.List("me");
_logger.Debug($"Gmail folders found: {string.Join(",", labelsResponse.Labels.Select(a => a.Name))}");
var labelsResponse = await folderRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
// Gmail has special Categories parent folder.
var categoriesFolder = new MailItemFolder()
{
SpecialFolderType = SpecialFolderType.Category,
MailAccountId = Account.Id,
FolderName = "Categories",
IsSynchronizationEnabled = false,
IsSticky = true,
Id = Guid.NewGuid(),
RemoteFolderId = "Categories"
};
var initializedFolders = new List<MailItemFolder>() { categoriesFolder };
foreach (var label in labelsResponse.Labels)
{
var localFolder = label.GetLocalFolder(Account.Id);
localFolder.MailAccountId = Account.Id;
initializedFolders.Add(localFolder);
}
// 1. Create parent-child relations but first order by name desc to be able to not mess up FolderNames in memory.
// 2. Mark special folder types that belong to categories folder.
var ordered = initializedFolders.OrderByDescending(a => a.FolderName);
// TODO: This can be refactored better.
foreach (var label in ordered)
{
// Gmail categorizes sub-labels by '/' For example:
// ParentTest/SubTest in the name of label means
// SubTest is a sub-label of ParentTest label.
if (label.SpecialFolderType == SpecialFolderType.Promotions ||
label.SpecialFolderType == SpecialFolderType.Updates ||
label.SpecialFolderType == SpecialFolderType.Social ||
label.SpecialFolderType == SpecialFolderType.Forums ||
label.SpecialFolderType == SpecialFolderType.Personal)
if (labelsResponse.Labels == null)
{
label.ParentRemoteFolderId = categoriesFolder.RemoteFolderId;
// These folders can not be a sub folder.
continue;
_logger.Warning("No folders found for {Name}", Account.Name);
return;
}
var isSubFolder = label.FolderName.Contains("/");
List<MailItemFolder> insertedFolders = new();
List<MailItemFolder> updatedFolders = new();
List<MailItemFolder> deletedFolders = new();
if (isSubFolder)
// 1. Handle deleted labels.
foreach (var localFolder in localFolders)
{
var splittedFolderName = label.FolderName.Split('/');
var partCount = splittedFolderName.Length;
// Category folder is virtual folder for Wino. Skip it.
if (localFolder.SpecialFolderType == SpecialFolderType.Category) continue;
if (partCount > 1)
var remoteFolder = labelsResponse.Labels.FirstOrDefault(a => a.Id == localFolder.RemoteFolderId);
if (remoteFolder == null)
{
// Only make the last part connection since other relations will build up in the loop.
// Local folder doesn't exists remotely. Delete local copy.
await _gmailChangeProcessor.DeleteFolderAsync(Account.Id, localFolder.RemoteFolderId).ConfigureAwait(false);
var realChildFolderName = splittedFolderName[splittedFolderName.Length - 1];
deletedFolders.Add(localFolder);
}
}
var childFolder = initializedFolders.Find(a => a.FolderName.EndsWith(realChildFolderName));
// Delete the deleted folders from local list.
deletedFolders.ForEach(a => localFolders.Remove(a));
string GetParentFolderName(string[] parts)
// 2. Handle update/insert based on remote folders.
foreach (var remoteFolder in labelsResponse.Labels)
{
var existingLocalFolder = localFolders.FirstOrDefault(a => a.RemoteFolderId == remoteFolder.Id);
if (existingLocalFolder == null)
{
// Insert new folder.
var localFolder = remoteFolder.GetLocalFolder(labelsResponse, Account.Id);
insertedFolders.Add(localFolder);
}
else
{
// Update existing folder. Right now we only update the name.
// TODO: Moving folders around different parents. This is not supported right now.
// We will need more comphrensive folder update mechanism to support this.
if (ShouldUpdateFolder(remoteFolder, existingLocalFolder))
{
var builder = new StringBuilder();
for (int i = 0; i < parts.Length - 1; i++)
{
builder.Append(parts[i]);
if (i != parts.Length - 2)
builder.Append("/");
}
return builder.ToString();
existingLocalFolder.FolderName = remoteFolder.Name;
updatedFolders.Add(existingLocalFolder);
}
var parentFolderName = GetParentFolderName(splittedFolderName);
var parentFolder = initializedFolders.Find(a => a.FolderName == parentFolderName);
if (childFolder != null && parentFolder != null)
else
{
childFolder.FolderName = realChildFolderName;
childFolder.ParentRemoteFolderId = parentFolder.RemoteFolderId;
// Remove it from the local folder list to skip additional folder updates.
localFolders.Remove(existingLocalFolder);
}
}
}
}
await _gmailChangeProcessor.UpdateFolderStructureAsync(Account.Id, initializedFolders).ConfigureAwait(false);
// 3.Process changes in order-> Insert, Update. Deleted ones are already processed.
foreach (var folder in insertedFolders)
{
await _gmailChangeProcessor.InsertFolderAsync(folder).ConfigureAwait(false);
}
foreach (var folder in updatedFolders)
{
await _gmailChangeProcessor.UpdateFolderAsync(folder).ConfigureAwait(false);
}
}
catch (Exception)
{
throw;
}
}
private bool ShouldUpdateFolder(Label remoteFolder, MailItemFolder existingLocalFolder)
=> existingLocalFolder.FolderName.Equals(GoogleIntegratorExtensions.GetFolderName(remoteFolder), StringComparison.OrdinalIgnoreCase) == false;
/// <summary>
/// Returns a single get request to retrieve the raw message with the given id
/// </summary>
@@ -706,6 +691,34 @@ namespace Wino.Core.Synchronizers
await _gmailChangeProcessor.SaveMimeFileAsync(mailItem.FileId, mimeMessage, Account.Id).ConfigureAwait(false);
}
public override IEnumerable<IRequestBundle<IClientServiceRequest>> RenameFolder(RenameFolderRequest request)
{
return CreateHttpBundleWithResponse<Label>(request, (item) =>
{
if (item is not RenameFolderRequest renameFolderRequest)
throw new ArgumentException($"Renaming folder must be handled with '{nameof(RenameFolderRequest)}'");
var label = new Label()
{
Name = renameFolderRequest.NewFolderName
};
return _gmailService.Users.Labels.Update(label, "me", request.Folder.RemoteFolderId);
});
}
public override IEnumerable<IRequestBundle<IClientServiceRequest>> EmptyFolder(EmptyFolderRequest request)
{
// Create batch delete request.
var deleteRequests = request.MailsToDelete.Select(a => new DeleteRequest(a));
return Delete(new BatchDeleteRequest(deleteRequests));
}
public override IEnumerable<IRequestBundle<IClientServiceRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request)
=> MarkRead(new BatchMarkReadRequest(request.MailsToMarkRead.Select(a => new MarkReadRequest(a, true)), true));
#endregion
#region Request Execution

View File

@@ -273,7 +273,13 @@ namespace Wino.Core.Synchronizers
}
public override IEnumerable<IRequestBundle<ImapRequest>> Archive(BatchArchiveRequest request)
=> Move(new BatchMoveRequest(request.Items, request.FromFolder, request.ToFolder));
=> Move(new BatchMoveRequest(request.Items, request.FromFolder, request.ToFolder));
public override IEnumerable<IRequestBundle<ImapRequest>> EmptyFolder(EmptyFolderRequest request)
=> Delete(new BatchDeleteRequest(request.MailsToDelete.Select(a => new DeleteRequest(a))));
public override IEnumerable<IRequestBundle<ImapRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request)
=> MarkRead(new BatchMarkReadRequest(request.MailsToMarkRead.Select(a => new MarkReadRequest(a, true)), true));
public override IEnumerable<IRequestBundle<ImapRequest>> SendDraft(BatchSendDraftRequestRequest request)
{
@@ -351,6 +357,15 @@ namespace Wino.Core.Synchronizers
_clientPool.Release(client);
}
public override IEnumerable<IRequestBundle<ImapRequest>> RenameFolder(RenameFolderRequest request)
{
return CreateTaskBundle(async (ImapClient client) =>
{
var folder = await client.GetFolderAsync(request.Folder.RemoteFolderId).ConfigureAwait(false);
await folder.RenameAsync(folder.ParentFolder, request.NewFolderName).ConfigureAwait(false);
}, request);
}
#endregion
public override async Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(ImapMessageCreationPackage message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
@@ -406,10 +421,7 @@ namespace Wino.Core.Synchronizers
// Therefore this should be avoided as many times as possible.
// This may create some inconsistencies, but nothing we can do...
if (options.Type == SynchronizationType.FoldersOnly || options.Type == SynchronizationType.Full)
{
await SynchronizeFoldersAsync(cancellationToken).ConfigureAwait(false);
}
await SynchronizeFoldersAsync(cancellationToken).ConfigureAwait(false);
if (options.Type != SynchronizationType.FoldersOnly)
{
@@ -492,7 +504,7 @@ namespace Wino.Core.Synchronizers
}
catch (Exception)
{
item.Request.RevertUIChanges();
throw;
}
finally
@@ -546,26 +558,20 @@ namespace Wino.Core.Synchronizers
localFolder.SpecialFolderType = SpecialFolderType.Starred;
}
/// <summary>
/// Whether the local folder should be updated with the remote folder.
/// </summary>
/// <param name="remoteFolder">Remote folder</param>
/// <param name="localFolder">Local folder.</param>
private bool ShouldUpdateFolder(IMailFolder remoteFolder, MailItemFolder localFolder)
{
return remoteFolder.Name != localFolder.FolderName;
}
private async Task SynchronizeFoldersAsync(CancellationToken cancellationToken = default)
{
// https://www.rfc-editor.org/rfc/rfc4549#section-1.1
var localFolders = await _imapChangeProcessor.GetLocalIMAPFoldersAsync(Account.Id).ConfigureAwait(false);
var localFolders = await _imapChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
ImapClient executorClient = null;
try
{
List<MailItemFolder> insertedFolders = new();
List<MailItemFolder> updatedFolders = new();
List<MailItemFolder> deletedFolders = new();
executorClient = await _clientPool.GetClientAsync().ConfigureAwait(false);
var remoteFolders = (await executorClient.GetFoldersAsync(executorClient.PersonalNamespaces[0], cancellationToken: cancellationToken)).ToList();
@@ -575,8 +581,6 @@ namespace Wino.Core.Synchronizers
// 1.a If local folder doesn't exists remotely, delete it.
// 1.b If local folder exists remotely, check if it is still a valid folder. If UidValidity is changed, delete it.
List<MailItemFolder> deletedFolders = new();
foreach (var localFolder in localFolders)
{
IMailFolder remoteFolder = null;
@@ -628,8 +632,6 @@ namespace Wino.Core.Synchronizers
var nameSpace = executorClient.PersonalNamespaces[0];
// var remoteFolders = (await executorClient.GetFoldersAsync(nameSpace, cancellationToken: cancellationToken)).ToList();
IMailFolder inbox = executorClient.Inbox;
// Sometimes Inbox is the root namespace. We need to check for that.
@@ -684,18 +686,19 @@ namespace Wino.Core.Synchronizers
await remoteFolder.CloseAsync(cancellationToken: cancellationToken);
localFolders.Add(localFolder);
insertedFolders.Add(localFolder);
}
else
{
// Update existing folder. Right now we only update the name.
// TODO: Moving servers around different parents. This is not supported right now.
// TODO: Moving folders around different parents. This is not supported right now.
// We will need more comphrensive folder update mechanism to support this.
if (ShouldUpdateFolder(remoteFolder, existingLocalFolder))
{
existingLocalFolder.FolderName = remoteFolder.Name;
updatedFolders.Add(existingLocalFolder);
}
else
{
@@ -705,13 +708,16 @@ namespace Wino.Core.Synchronizers
}
}
if (localFolders.Any())
// Process changes in order-> Insert, Update. Deleted ones are already processed.
foreach (var folder in insertedFolders)
{
await _imapChangeProcessor.UpdateFolderStructureAsync(Account.Id, localFolders);
await _imapChangeProcessor.InsertFolderAsync(folder).ConfigureAwait(false);
}
else
foreach (var folder in updatedFolders)
{
_logger.Information("No update is needed for imap folders.");
await _imapChangeProcessor.UpdateFolderAsync(folder).ConfigureAwait(false);
}
}
catch (Exception ex)
@@ -729,6 +735,8 @@ namespace Wino.Core.Synchronizers
}
}
private async Task<IEnumerable<string>> SynchronizeFolderInternalAsync(MailItemFolder folder, CancellationToken cancellationToken = default)
{
if (!folder.IsSynchronizationEnabled) return default;
@@ -978,5 +986,14 @@ namespace Wino.Core.Synchronizers
_clientPool.Release(_synchronizationClient);
}
}
/// <summary>
/// Whether the local folder should be updated with the remote folder.
/// IMAP only compares folder name for now.
/// </summary>
/// <param name="remoteFolder">Remote folder</param>
/// <param name="localFolder">Local folder.</param>
public bool ShouldUpdateFolder(IMailFolder remoteFolder, MailItemFolder localFolder) => remoteFolder.Name != localFolder.FolderName;
}
}

View File

@@ -62,6 +62,8 @@ namespace Wino.Core.Synchronizers
"InternetMessageId",
];
private readonly SemaphoreSlim _handleItemRetrievalSemaphore = new(1);
private readonly ILogger _logger = Log.ForContext<OutlookSynchronizer>();
private readonly IOutlookChangeProcessor _outlookChangeProcessor;
private readonly GraphServiceClient _graphClient;
@@ -184,7 +186,21 @@ namespace Wino.Core.Synchronizers
var messageIteratorAsync = PageIterator<Message, Microsoft.Graph.Me.MailFolders.Item.Messages.Delta.DeltaGetResponse>.CreatePageIterator(_graphClient, messageCollectionPage, async (item) =>
{
return await HandleItemRetrievedAsync(item, folder, downloadedMessageIds, cancellationToken);
try
{
await _handleItemRetrievalSemaphore.WaitAsync();
return await HandleItemRetrievedAsync(item, folder, downloadedMessageIds, cancellationToken);
}
catch (Exception ex)
{
_logger.Error(ex, "Error occurred while handling item {Id} for folder {FolderName}", item.Id, folder.FolderName);
}
finally
{
_handleItemRetrievalSemaphore.Release();
}
return true;
});
await messageIteratorAsync
@@ -368,7 +384,7 @@ namespace Wino.Core.Synchronizers
graphFolders = await _graphClient.RequestAdapter.SendAsync(deltaRequest,
Microsoft.Graph.Me.MailFolders.Delta.DeltaGetResponse.CreateFromDiscriminatorValue,
cancellationToken: cancellationToken);
cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
@@ -380,7 +396,7 @@ namespace Wino.Core.Synchronizers
deltaRequest.QueryParameters.Add("%24deltaToken", currentDeltaLink);
graphFolders = await _graphClient.RequestAdapter.SendAsync(deltaRequest,
Microsoft.Graph.Me.MailFolders.Delta.DeltaGetResponse.CreateFromDiscriminatorValue,
cancellationToken: cancellationToken);
cancellationToken: cancellationToken).ConfigureAwait(false);
}
var iterator = PageIterator<MailFolder, Microsoft.Graph.Me.MailFolders.Delta.DeltaGetResponse>.CreatePageIterator(_graphClient, graphFolders, (folder) =>
@@ -575,6 +591,8 @@ namespace Wino.Core.Synchronizers
public override IEnumerable<IRequestBundle<RequestInformation>> Archive(BatchArchiveRequest request)
=> Move(new BatchMoveRequest(request.Items, request.FromFolder, request.ToFolder));
public override async Task DownloadMissingMimeMessageAsync(IMailItem mailItem,
MailKit.ITransferProgress transferProgress = null,
CancellationToken cancellationToken = default)
@@ -583,6 +601,28 @@ namespace Wino.Core.Synchronizers
await _outlookChangeProcessor.SaveMimeFileAsync(mailItem.FileId, mimeMessage, Account.Id).ConfigureAwait(false);
}
public override IEnumerable<IRequestBundle<RequestInformation>> RenameFolder(RenameFolderRequest request)
{
return CreateHttpBundleWithResponse<MailFolder>(request, (item) =>
{
if (item is not RenameFolderRequest renameFolderRequest)
throw new ArgumentException($"Renaming folder must be handled with '{nameof(RenameFolderRequest)}'");
var requestBody = new MailFolder
{
DisplayName = request.NewFolderName,
};
return _graphClient.Me.MailFolders[request.Folder.RemoteFolderId].ToPatchRequestInformation(requestBody);
});
}
public override IEnumerable<IRequestBundle<RequestInformation>> EmptyFolder(EmptyFolderRequest request)
=> Delete(new BatchDeleteRequest(request.MailsToDelete.Select(a => new DeleteRequest(a))));
public override IEnumerable<IRequestBundle<RequestInformation>> MarkFolderAsRead(MarkFolderAsReadRequest request)
=> MarkRead(new BatchMarkReadRequest(request.MailsToMarkRead.Select(a => new MarkReadRequest(a, true)), true));
#endregion
public override async Task ExecuteNativeRequestsAsync(IEnumerable<IRequestBundle<RequestInformation>> batchedRequests, CancellationToken cancellationToken = default)
@@ -646,7 +686,11 @@ namespace Wino.Core.Synchronizers
HttpResponseMessage httpResponseMessage,
CancellationToken cancellationToken = default)
{
if (bundle is HttpRequestBundle<RequestInformation, Message> messageBundle)
if (!httpResponseMessage.IsSuccessStatusCode)
{
throw new SynchronizerException(string.Format(Translator.Exception_SynchronizerFailureHTTP, httpResponseMessage.StatusCode));
}
else if (bundle is HttpRequestBundle<RequestInformation, Message> messageBundle)
{
var outlookMessage = await messageBundle.DeserializeBundleAsync(httpResponseMessage, cancellationToken);
@@ -666,11 +710,6 @@ namespace Wino.Core.Synchronizers
{
// TODO: Handle mime retrieve message.
}
else if (!httpResponseMessage.IsSuccessStatusCode)
{
// TODO: Should we even handle this?
throw new SynchronizerException(string.Format(Translator.Exception_SynchronizerFailureHTTP, httpResponseMessage.StatusCode));
}
}
private async Task<MimeMessage> DownloadMimeMessageAsync(string messageId, CancellationToken cancellationToken = default)

View File

@@ -21,7 +21,7 @@
<PackageReference Include="MailKit" Version="4.6.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Graph" Version="5.55.0" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.60.3" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.47.2" />
<PackageReference Include="MimeKit" Version="4.6.0" />
<PackageReference Include="morelinq" Version="4.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />