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, }; } }