using AIStudio.Tools.Mail;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
///
/// Shows the folders of a mailbox as the server lists them, picks one of them, and creates new ones.
///
///
/// The whole mailbox is the root of the tree and stands for no folder at all, i.e., an empty full
/// name. The trash and the junk folder cannot be picked: the synchronization leaves them out, so a
/// mailbox restricted to one of them would never hold a mail.
///
public partial class MailFolderPicker : MSGComponentBase
{
///
/// The folders of the mailbox, as the server lists them.
///
///
/// The tree is built anew whenever another list arrives, so pass a new list instead of changing this one.
///
[Parameter]
public IReadOnlyList Folders { get; set; } = [];
///
/// The full name of the picked folder, or empty for the whole mailbox.
///
[Parameter]
public string SelectedFolder { get; set; } = string.Empty;
[Parameter]
public EventCallback SelectedFolderChanged { get; set; }
[Parameter]
public bool Disabled { get; set; }
///
/// Creates a folder on the server, from the full name of the folder to create it in (empty at
/// the top level) and the name of the new folder.
///
///
/// Returns the new folder, or null when the server did not create it, in which case the caller
/// tells the user why. Without it, there is no way to create a folder here.
///
[Parameter]
public Func>? CreateFolder { get; set; }
private IReadOnlyCollection> treeItems = [];
private IReadOnlyList? treeFolders;
private Dictionary foldersByName = new(StringComparer.Ordinal);
private string newFolderName = string.Empty;
private bool isCreatingFolder;
#region Overrides of ComponentBase
protected override void OnParametersSet()
{
if (!ReferenceEquals(this.treeFolders, this.Folders))
{
this.treeFolders = this.Folders;
this.foldersByName = IndexByName(this.Folders);
this.treeItems = BuildTree(this.Folders, this.SelectedFolder, T("Whole mailbox"));
}
base.OnParametersSet();
}
#endregion
private char NewFolderSeparator => this.foldersByName.TryGetValue(this.SelectedFolder, out var parent)
? parent.DirectorySeparator
: this.Folders.FirstOrDefault()?.DirectorySeparator ?? '\0';
private bool IsNewFolderNameValid => ImapMailboxConnector.IsValidFolderName(this.newFolderName.Trim(), this.NewFolderSeparator);
private bool CanCreateFolder => !this.isCreatingFolder && this.IsNewFolderNameValid;
private string NewFolderHelperText
{
get
{
if (!string.IsNullOrWhiteSpace(this.newFolderName) && !this.IsNewFolderNameValid)
return T("This name is too long, or it contains a character the server reserves for folder paths.");
return string.IsNullOrEmpty(this.SelectedFolder)
? T("Creates the folder on the server right away, at the top level of the mailbox.")
: string.Format(T("Creates the folder on the server right away, inside '{0}'."), this.SelectedFolder);
}
}
private bool CanBeSelected(string? fullName)
{
if (fullName is null)
return false;
if (fullName.Length == 0)
return true;
return this.foldersByName.TryGetValue(fullName, out var folder) && CanBeSelected(folder);
}
private async Task SelectFolder(string? fullName)
{
if (!this.CanBeSelected(fullName) || fullName == this.SelectedFolder)
return;
this.SelectedFolder = fullName!;
await this.SelectedFolderChanged.InvokeAsync(this.SelectedFolder);
}
private async Task CreateNewFolder()
{
if (this.CreateFolder is null || !this.CanCreateFolder)
return;
this.isCreatingFolder = true;
try
{
var createdFolder = await this.CreateFolder(this.SelectedFolder, this.newFolderName.Trim());
if (createdFolder is null)
return;
this.newFolderName = string.Empty;
this.SelectedFolder = createdFolder.FullName;
await this.SelectedFolderChanged.InvokeAsync(this.SelectedFolder);
}
finally
{
this.isCreatingFolder = false;
}
}
///
/// Whether a folder can be picked, cf. the remarks of this component.
///
internal static bool CanBeSelected(MailServerFolder folder) => folder.SpecialUse is not (MailFolderSpecialUse.TRASH or MailFolderSpecialUse.JUNK);
///
/// Builds the tree of folders below the whole mailbox, with the way to the picked folder expanded.
///
///
/// A folder whose parent the server did not list goes to the top level, so that nothing listed
/// gets lost. Each level starts with the inbox, and the other folders follow by name.
///
/// The folders as the server lists them.
/// The full name of the picked folder, or empty for the whole mailbox.
/// The text of the root, which stands for the whole mailbox.
/// The root of the tree, as the only item.
internal static List> BuildTree(IReadOnlyList folders, string selectedFolder, string wholeMailboxText)
{
var foldersByName = IndexByName(folders);
var childrenByParent = foldersByName.Values
.ToLookup(folder => foldersByName.ContainsKey(folder.ParentFullName) && folder.ParentFullName != folder.FullName ? folder.ParentFullName : string.Empty, StringComparer.Ordinal);
//
// Every folder on the way to the picked one is expanded. Should a server list two folders as
// each other's parent, the way ends where it meets itself:
//
var expandedFolders = new HashSet(StringComparer.Ordinal);
var current = selectedFolder;
while (foldersByName.TryGetValue(current, out var folder) && expandedFolders.Add(folder.ParentFullName))
current = folder.ParentFullName;
var topLevelFolders = BuildChildren(string.Empty);
return
[
new TreeItemData
{
Value = string.Empty,
Text = wholeMailboxText,
Icon = Icons.Material.Filled.Mail,
Expanded = true,
Expandable = topLevelFolders.Count > 0,
Children = topLevelFolders,
},
];
List> BuildChildren(string parentFullName) => childrenByParent[parentFullName]
.OrderByDescending(folder => folder.IsInbox)
.ThenBy(folder => folder.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(folder => folder.FullName, StringComparer.Ordinal)
.Select(folder =>
{
var children = BuildChildren(folder.FullName);
return new TreeItemData
{
Value = folder.FullName,
Text = folder.Name,
Icon = GetIcon(folder),
Expanded = expandedFolders.Contains(folder.FullName),
Expandable = children.Count > 0,
Children = children,
};
})
.ToList();
}
///
/// The empty full name belongs to the whole mailbox, so a folder listed under it would become
/// its own child.
///
private static Dictionary IndexByName(IEnumerable folders)
{
var foldersByName = new Dictionary(StringComparer.Ordinal);
foreach (var folder in folders)
{
if (folder.FullName.Length > 0)
foldersByName.TryAdd(folder.FullName, folder);
}
return foldersByName;
}
private static string GetIcon(MailServerFolder folder)
{
if (folder.IsInbox)
return Icons.Material.Filled.Inbox;
return folder.SpecialUse switch
{
MailFolderSpecialUse.ALL => Icons.Material.Filled.AllInbox,
MailFolderSpecialUse.ARCHIVE => Icons.Material.Filled.Archive,
MailFolderSpecialUse.DRAFTS => Icons.Material.Filled.Drafts,
MailFolderSpecialUse.FLAGGED => Icons.Material.Filled.Flag,
MailFolderSpecialUse.IMPORTANT => Icons.Material.Filled.LabelImportant,
MailFolderSpecialUse.JUNK => Icons.Material.Filled.Report,
MailFolderSpecialUse.SENT => Icons.Material.Filled.Send,
MailFolderSpecialUse.TRASH => Icons.Material.Filled.Delete,
_ => Icons.Material.Filled.Folder,
};
}
}