diff --git a/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml b/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml index 3f8b721..763e828 100644 --- a/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml +++ b/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml @@ -3,8 +3,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:conv="clr-namespace:MarcusLaw.OutlookAddin.UI.Converters" Title="שמור קבצים מצורפים ל-Klear" - Height="560" Width="620" - MinHeight="420" MinWidth="500" + Height="640" Width="620" + MinHeight="480" MinWidth="500" FlowDirection="RightToLeft" WindowStartupLocation="CenterOwner" ShowInTaskbar="False" @@ -144,24 +144,38 @@ - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml.cs b/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml.cs index 9c074ed..39f599e 100644 --- a/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml.cs +++ b/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml.cs @@ -32,5 +32,15 @@ namespace MarcusLaw.OutlookAddin.UI.Dialogs DialogResult = _viewModel?.DialogResult; Close(); } + + // WPF's TreeView.SelectedItem is read-only, so we mirror the selection + // into the view-model here (the chosen node drives the save target). + private void FolderTreeView_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (_viewModel != null) + { + _viewModel.SelectedFolder = e.NewValue as FolderNodeViewModel; + } + } } } diff --git a/src/OutlookAddin.UI/ViewModels/FolderNodeViewModel.cs b/src/OutlookAddin.UI/ViewModels/FolderNodeViewModel.cs new file mode 100644 index 0000000..a7dfbf5 --- /dev/null +++ b/src/OutlookAddin.UI/ViewModels/FolderNodeViewModel.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using MarcusLaw.OutlookAddin.Core.Services; +using Serilog; + +namespace MarcusLaw.OutlookAddin.UI.ViewModels +{ + /// + /// One node in the network-storage folder tree shown by the + /// save-attachments dialog. Children are listed lazily the first time the + /// node is expanded, so the user can drill into nested subfolders to any + /// depth without us listing the whole tree up front. + /// + public sealed partial class FolderNodeViewModel : ObservableObject + { + private readonly IEspoCrmClient? _client; + private readonly ILogger? _logger; + private bool _childrenLoaded; + private bool _loadingStarted; + + /// Label shown in the tree. + public string Name { get; } + + /// + /// Path relative to the share root (e.g. "56-ליאור זייני/מסמכים"). + /// Used to list this folder's children. Empty for placeholders. + /// + public string FullPath { get; } + + /// + /// Path relative to the case-root folder (e.g. "מסמכים/רפואי"), or + /// null for the case-root node itself. This is what the save target + /// appends to the resolved case folder. + /// + public string? RelativePath { get; } + + /// True for the transient "loading…" child placeholder. + public bool IsPlaceholder { get; } + + public bool IsRoot => !IsPlaceholder && RelativePath == null; + + public ObservableCollection Children { get; } = + new ObservableCollection(); + + [ObservableProperty] + private bool isExpanded; + + [ObservableProperty] + private bool isSelected; + + [ObservableProperty] + private bool isLoading; + + /// Real folder node. + public FolderNodeViewModel( + IEspoCrmClient client, + ILogger logger, + string name, + string fullPath, + string? relativePath) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + Name = name; + FullPath = fullPath; + RelativePath = relativePath; + // A placeholder child makes the expand chevron appear before we've + // listed; expanding swaps it for the real children (or, if there + // are none, the node becomes a leaf). + Children.Add(new FolderNodeViewModel()); + } + + /// Placeholder ("loading…") node constructor. + private FolderNodeViewModel() + { + Name = "טוען…"; + FullPath = string.Empty; + RelativePath = string.Empty; + IsPlaceholder = true; + _childrenLoaded = true; + } + + partial void OnIsExpandedChanged(bool value) + { + if (value && !_childrenLoaded && !_loadingStarted) + { + _loadingStarted = true; + _ = LoadChildrenAsync(); + } + } + + /// + /// Lists this folder's subfolders and replaces the placeholder with + /// real child nodes. (used for the root) + /// guarantees the default case subfolders show even if the share + /// listing lags or omits a not-yet-created folder. + /// + public async Task LoadChildrenAsync(IEnumerable? seedNames = null) + { + if (_client == null) return; + IsLoading = true; + + // Build into a local list and swap once at the end. The seeds are + // added first so that even if the share listing fails the default + // subfolders still show; on failure for a non-seeded node the list + // is empty and the node reads as a leaf rather than "loading…". + var seen = new HashSet(StringComparer.Ordinal); + var loaded = new List(); + if (seedNames != null) + { + foreach (var n in seedNames) + { + if (seen.Add(n)) loaded.Add(MakeChild(n)); + } + } + + try + { + var items = await _client.ListNetworkStorageFolderAsync(FullPath).ConfigureAwait(true); + foreach (var item in items) + { + if (!item.IsFolder) continue; + if (!seen.Add(item.Name)) continue; + loaded.Add(MakeChild(item.Name)); + } + } + catch (Exception ex) + { + _logger?.Warning(ex, "Listing subfolders under '{Path}' failed", FullPath); + } + finally + { + Children.Clear(); + foreach (var child in loaded) Children.Add(child); + _childrenLoaded = true; + IsLoading = false; + } + } + + private FolderNodeViewModel MakeChild(string name) + { + var fullPath = string.IsNullOrEmpty(FullPath) ? name : FullPath + "/" + name; + var relativePath = IsRoot ? name : RelativePath + "/" + name; + return new FolderNodeViewModel(_client!, _logger!, name, fullPath, relativePath); + } + } +} diff --git a/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs b/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs index bc87f21..957c9ba 100644 --- a/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs +++ b/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs @@ -40,7 +40,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels private CaseEntity? selectedCase; [ObservableProperty] - private string? selectedSubfolder; + private FolderNodeViewModel? selectedFolder; [ObservableProperty] private string? statusMessage; @@ -58,7 +58,12 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels public ObservableCollection Cases { get; } = new ObservableCollection(); - public ObservableCollection Subfolders { get; } = new ObservableCollection(); + /// + /// Folder tree for the selected case. Holds a single root node (the + /// case folder); its children are the subfolders, loaded lazily so the + /// user can drill into any nesting depth. + /// + public ObservableCollection FolderTree { get; } = new ObservableCollection(); public ObservableCollection Attachments { get; } = new ObservableCollection(); @@ -78,14 +83,20 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels } /// - /// Subfolder string the user picked, or null to save to the case - /// folder root. + /// Folder the user picked, as a path relative to the case folder + /// (e.g. "מסמכים/רפואי"), or null to save to the case folder root. + /// Multi-segment paths are honoured by the save target, which appends + /// this to . /// - public string? ChosenSubfolderName => - string.IsNullOrEmpty(SelectedSubfolder) || - string.Equals(SelectedSubfolder, RootSentinel, StringComparison.Ordinal) - ? null - : SelectedSubfolder; + public string? ChosenSubfolderName + { + get + { + var f = SelectedFolder; + if (f == null || f.IsPlaceholder || f.IsRoot) return null; + return string.IsNullOrEmpty(f.RelativePath) ? null : f.RelativePath; + } + } public CaseEntity? PickedCase => SelectedCase; @@ -99,7 +110,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels _client = client ?? throw new ArgumentNullException(nameof(client)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - ResetSubfolders(); + ResetFolderTree(); foreach (var att in attachments) { @@ -139,12 +150,10 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels } } - private void ResetSubfolders() + private void ResetFolderTree() { - Subfolders.Clear(); - Subfolders.Add(RootSentinel); - foreach (var f in DefaultCaseSubfolders) Subfolders.Add(f); - SelectedSubfolder = Subfolders[0]; + FolderTree.Clear(); + SelectedFolder = null; } partial void OnSearchTextChanged(string value) @@ -169,7 +178,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels /// public async Task RefreshSubfoldersForSelectedCaseAsync() { - ResetSubfolders(); + ResetFolderTree(); ResolvedCaseFolderPath = null; var c = SelectedCase; @@ -220,21 +229,25 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels c.Id, c.Number ?? "(none)", primaryContactName ?? "(none)", c.NetworkStorageFolderPath ?? "(none)", folderPath); if (string.IsNullOrEmpty(folderPath)) return; + // Build the tree rooted at the case folder. The root node is + // selected by default (→ save to case root, matching the previous + // default), expanded, and seeded with the default subfolders so + // they appear even before the share listing returns. Deeper levels + // load lazily as the user expands them. + var root = new FolderNodeViewModel(_client, _logger, RootSentinel, folderPath, relativePath: null); + FolderTree.Add(root); + SelectedFolder = root; + root.IsSelected = true; + try { - var items = await _client.ListNetworkStorageFolderAsync(folderPath).ConfigureAwait(true); - foreach (var item in items) - { - if (!item.IsFolder) continue; - if (Subfolders.Contains(item.Name)) continue; - if (string.Equals(item.Name, RootSentinel, StringComparison.Ordinal)) continue; - Subfolders.Add(item.Name); - } + await root.LoadChildrenAsync(DefaultCaseSubfolders).ConfigureAwait(true); } catch (Exception ex) { _logger.Warning(ex, "Listing subfolders for case {CaseId} failed", c.Id); } + root.IsExpanded = true; } /// diff --git a/src/OutlookAddin/Properties/AssemblyInfo.cs b/src/OutlookAddin/Properties/AssemblyInfo.cs index 691307b..fd1779c 100644 --- a/src/OutlookAddin/Properties/AssemblyInfo.cs +++ b/src/OutlookAddin/Properties/AssemblyInfo.cs @@ -33,6 +33,6 @@ using System.Security; // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.2.14.0")] -[assembly: AssemblyFileVersion("1.2.14.0")] +[assembly: AssemblyVersion("1.2.15.0")] +[assembly: AssemblyFileVersion("1.2.15.0")] diff --git a/src/OutlookAddin/Services/AddInHost.cs b/src/OutlookAddin/Services/AddInHost.cs index 8ed69b5..b63e725 100644 --- a/src/OutlookAddin/Services/AddInHost.cs +++ b/src/OutlookAddin/Services/AddInHost.cs @@ -127,6 +127,62 @@ namespace OutlookAddin.Services } } + /// + /// Shows a message box that is guaranteed to render above Outlook and + /// any other window. A bare has + /// no owner — once our WPF dialog closes there is no active window on + /// the add-in thread, so the box sinks behind Outlook and the user + /// never sees the success/error result. We anchor it to a transient, + /// invisible top-most owner window parented to the foreground Outlook + /// window so it always surfaces on top. Caption is always "Klear". + /// + private MessageBoxResult ShowMessage( + string text, + MessageBoxButton button = MessageBoxButton.OK, + MessageBoxImage icon = MessageBoxImage.None) + { + Window? owner = null; + try + { + // A 1×1 borderless window at screen centre: imperceptible, but + // gives the MessageBox a top-most owner to z-order above. The + // box centres on this point → effectively centred on screen. + owner = new Window + { + Width = 1, + Height = 1, + WindowStyle = WindowStyle.None, + ResizeMode = ResizeMode.NoResize, + ShowInTaskbar = false, + Topmost = true, + WindowStartupLocation = WindowStartupLocation.CenterScreen, + }; + + var hwnd = GetForegroundWindow(); + if (hwnd == IntPtr.Zero) + { + hwnd = System.Diagnostics.Process.GetCurrentProcess().MainWindowHandle; + } + if (hwnd != IntPtr.Zero) + { + new WindowInteropHelper(owner).Owner = hwnd; + } + + owner.Show(); + owner.Activate(); + return MessageBox.Show(owner, text, "Klear", button, icon); + } + catch (Exception ex) + { + Logger.Warning(ex, "ShowMessage top-most owner failed; falling back to ownerless box"); + return MessageBox.Show(text, "Klear", button, icon); + } + finally + { + try { owner?.Close(); } catch { /* ignore */ } + } + } + private static void EnableModernTls() { try @@ -402,9 +458,8 @@ namespace OutlookAddin.Services if (mail == null) return; if (!IsConfigured) { - MessageBox.Show( + ShowMessage( "התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.", - "Klear", MessageBoxButton.OK, MessageBoxImage.Information); return; @@ -423,9 +478,8 @@ namespace OutlookAddin.Services catch (Exception ex) { Logger.Error(ex, "LaunchComposeFromCaseAsync failed"); - MessageBox.Show( + ShowMessage( "טעינת התיק נכשלה: " + ex.Message, - "Klear", MessageBoxButton.OK, MessageBoxImage.Error); } @@ -444,9 +498,8 @@ namespace OutlookAddin.Services if (mail == null) return; if (!IsConfigured) { - var result = MessageBox.Show( + var result = ShowMessage( "התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?", - "Klear", MessageBoxButton.YesNo, MessageBoxImage.Information); if (result == MessageBoxResult.Yes) LaunchSettings(); @@ -463,9 +516,8 @@ namespace OutlookAddin.Services catch (Exception ex) { Logger.Error(ex, "LaunchSaveAttachmentsAsync: extract failed"); - MessageBox.Show( + ShowMessage( "קריאת הקבצים המצורפים נכשלה: " + ex.Message, - "Klear", MessageBoxButton.OK, MessageBoxImage.Error); return; @@ -473,9 +525,8 @@ namespace OutlookAddin.Services if (envelope.Attachments.Count == 0) { - MessageBox.Show( + ShowMessage( "אין במייל הזה קבצים מצורפים לשמירה.", - "Klear", MessageBoxButton.OK, MessageBoxImage.Information); return; @@ -510,9 +561,8 @@ namespace OutlookAddin.Services : SaveAttachmentsViewModel.ResolveCaseFolderPath(picked); if (string.IsNullOrEmpty(caseFolder)) { - MessageBox.Show( + ShowMessage( "לא ניתן לחשב את נתיב התיקייה של התיק. ייתכן שהתיק עוד לא הוגדר ב-Klear.", - "Klear", MessageBoxButton.OK, MessageBoxImage.Warning); return; @@ -540,9 +590,8 @@ namespace OutlookAddin.Services catch (Exception ex) { Logger.Error(ex, "LaunchSaveAttachmentsAsync failed"); - MessageBox.Show( + ShowMessage( "שמירת הקבצים נכשלה: " + ex.Message, - "Klear", MessageBoxButton.OK, MessageBoxImage.Error); } @@ -572,7 +621,7 @@ namespace OutlookAddin.Services } } - private static void ShowAttachmentSummary(AttachmentSaveSummary summary, string caseLabel, string targetPath) + private void ShowAttachmentSummary(AttachmentSaveSummary summary, string caseLabel, string targetPath) { string message; MessageBoxImage icon; @@ -589,7 +638,7 @@ namespace OutlookAddin.Services message = parts.Count == 0 ? "לא נשמרו פריטים." : string.Join(" · ", parts); icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information; } - MessageBox.Show(message, "Klear", MessageBoxButton.OK, icon); + ShowMessage(message, MessageBoxButton.OK, icon); } /// @@ -601,9 +650,8 @@ namespace OutlookAddin.Services { if (!IsConfigured) { - MessageBox.Show( + ShowMessage( "התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.", - "Klear", MessageBoxButton.OK, MessageBoxImage.Information); return; @@ -620,9 +668,8 @@ namespace OutlookAddin.Services { Logger.Error(ex, "LaunchComposeFromCaseByIdAsync failed for caseId={CaseId}", caseId); if (mail != null) try { Marshal.ReleaseComObject(mail); } catch { } - MessageBox.Show( + ShowMessage( "פתיחת המייל מתיק נכשלה: " + ex.Message, - "Klear", MessageBoxButton.OK, MessageBoxImage.Error); } @@ -685,9 +732,8 @@ namespace OutlookAddin.Services { if (!IsConfigured) { - var result = MessageBox.Show( + var result = ShowMessage( "התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?", - "Klear", MessageBoxButton.YesNo, MessageBoxImage.Information); if (result == MessageBoxResult.Yes) @@ -700,9 +746,8 @@ namespace OutlookAddin.Services var mailItems = ReadCurrentSelection(); if (mailItems.Count == 0) { - MessageBox.Show( + ShowMessage( "לא נבחר מייל לתיוק. סמן מייל אחד או יותר ונסה שוב.", - "Klear", MessageBoxButton.OK, MessageBoxImage.Information); return; @@ -743,9 +788,8 @@ namespace OutlookAddin.Services Logger.Warning( "Filing skipped — selected EspoEntityRef has no usable Id/EntityType (id={Id}, name={Name}, type={Type})", vm.SelectedTarget?.Id, vm.SelectedTarget?.Name, vm.SelectedTarget?.EntityType); - MessageBox.Show( + ShowMessage( "התיק שנבחר לא הוחזר עם סוג ישות (entityType) תקין. ייתכן ש-Klear שלך מחזיר שדה אחר; הלוג מכיל את הפרטים. שלח לאדמין.", - "Klear", MessageBoxButton.OK, MessageBoxImage.Warning); return; @@ -917,7 +961,7 @@ namespace OutlookAddin.Services } } - private static void ShowSummary(FilingBatchSummary summary, FilingTarget target) + private void ShowSummary(FilingBatchSummary summary, FilingTarget target) { string message; MessageBoxImage icon; @@ -938,7 +982,7 @@ namespace OutlookAddin.Services icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information; } - MessageBox.Show(message, "Klear", MessageBoxButton.OK, icon); + ShowMessage(message, MessageBoxButton.OK, icon); } private static string? TryGetSenderName(Outlook.MailItem item)